# backend/services/planning/heuristics.py
#
# EMS plánovač – pre-solver heuristiky (SoC série, neg-sell fáze, evening push,
# pre-neg okna). Fáze 1 dekompozice: čistý přesun z planning_engine.py.
#
# POZOR (Fáze 2/3): tyto funkce jsou hlavní kandidáti na zjednodušení/odstranění —
# ~35 % ekonomické logiky obchází solver (viz scripts/harness/README.md, baseline
# GAP 27 %). Neměnit chování bez golden gate + economics_report.

import logging
from dataclasses import replace
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from typing import Any, Optional

from services.planning.constants import (
    MORNING_PRENEG_START_HOUR,
    MORNING_PRENEG_END_HOUR,
    ARB_FLOOR_E_REF_FRAC,
    ARB_LOOKAHEAD_SLOTS,
    EVENING_PUSH_HYSTERESIS_SELL_PEAK_DELTA_CZK_KWH,
    EVENING_PUSH_HYSTERESIS_SOC_PCT,
    EXTREME_BUY_DUMP_PREWINDOW_SLOTS,
    GE_MIN_EXPORT_W,
    INTERVAL_H,
    NIGHT_EXPORT_EVENING_START_HOUR,
    NIGHT_EXPORT_MORNING_END_HOUR,
    NIGHT_EXPORT_PV_SUNRISE_SURPLUS_W,
    PRE_NEG_BATT_EXPORT_MIN_SELL_CZK_KWH,
    PRE_NEG_PV_EXPORT_FORECAST_MARGIN,
    PRE_NEG_PV_EXPORT_MIN_NEEDED_WH,
    TERMINAL_NEG_BUY_MAGNITUDE_FLOOR,
    TERMINAL_NEG_BUY_MAGNITUDE_REF_CZK,
    TERMINAL_NEG_BUY_WEIGHT_CAP,
    TERMINAL_NEG_BUY_WEIGHT_HORIZON_SLOTS,
)
from services.planning.types import (
    PlanningSlot,
    DispatchResult,
    SOC_MIN_RELAX_LOOKAHEAD_SLOTS,
    _prague_calendar_date,
    _prague_hour,
)

logger = logging.getLogger(__name__)


def _pv_scarcity_penalty_multiplier(slots: list["PlanningSlot"], battery) -> float:
    """
    Měkká úprava ekonomiky cyklu podle očekávaného slunečního zisku.
    - málo očekávané FVE energie -> nižší penalizace cyklu (podpora precharge ze sítě),
    - hodně očekávané FVE energie -> standardní penalizace.
    """
    horizon_slots = min(len(slots), int(24 / INTERVAL_H))  # konzervativní 1 den dopředu
    if horizon_slots <= 0:
        return 1.0

    pv_kwh = 0.0
    for s in slots[:horizon_slots]:
        pv_kwh += max(0.0, float(s.pv_a_forecast_w + s.pv_b_forecast_w)) * INTERVAL_H / 1000.0

    batt_kwh = max(1.0, float(getattr(battery, "usable_capacity_wh", 0.0)) / 1000.0)
    # coverage = kolikanásobek baterie očekáváme ze slunce v horizontu.
    coverage = pv_kwh / batt_kwh
    coverage_clamped = max(0.0, min(1.0, coverage))
    # 0.65 při nízkém slunci, 1.0 při vysokém slunci.
    return 0.65 + 0.35 * coverage_clamped

def _pv_coverage_ratio(slots: list["PlanningSlot"], battery, hours: int = 24) -> float:
    horizon_slots = min(len(slots), int(hours / INTERVAL_H))
    if horizon_slots <= 0:
        return 1.0
    pv_kwh = 0.0
    for s in slots[:horizon_slots]:
        pv_kwh += max(0.0, float(s.pv_a_forecast_w + s.pv_b_forecast_w)) * INTERVAL_H / 1000.0
    batt_kwh = max(1.0, float(getattr(battery, "usable_capacity_wh", 0.0)) / 1000.0)
    return max(0.0, min(1.0, pv_kwh / batt_kwh))

def _dynamic_arb_floor_wh_series(
    slots: list["PlanningSlot"],
    min_soc_wh: float,
    arb_base_wh: float,
    usable_wh: float,
) -> list[float]:
    """
    Časově proměnná ekonomická podlaha Wh pro MILP (nad min_soc_wh).
    Hodně očekávané FVE energie v dalších ARB_LOOKAHEAD_SLOTS → podlaha klesá k min_soc_wh;
    málo slunce → zůstává u arb_base_wh (typicky reserve z DB).
    """
    T = len(slots)
    if T == 0:
        return []
    e_ref = max(1.0, ARB_FLOOR_E_REF_FRAC * float(usable_wh))
    spread = max(0.0, float(arb_base_wh) - float(min_soc_wh))
    out: list[float] = []
    for t in range(T):
        e_pv_wh = 0.0
        for k in range(t, min(T, t + ARB_LOOKAHEAD_SLOTS)):
            s = slots[k]
            e_pv_wh += max(0, s.pv_a_forecast_w + s.pv_b_forecast_w) * INTERVAL_H
        f = min(1.0, e_pv_wh / e_ref) if e_ref > 1e-9 else 1.0
        arb_t = float(min_soc_wh) + (1.0 - f) * spread
        out.append(arb_t)
    return out

def _soc_security_profile(slots: list["PlanningSlot"], battery) -> tuple[float, float]:
    """
    Při nízkém očekávaném slunci drží solver vyšší SoC buffer:
    - cílový buffer: reserve + až 20 % usable capacity,
    - ekonomická penalizace deficitu vůči bufferu z průměrné ceny.
    """
    coverage = _pv_coverage_ratio(slots, battery, hours=24)
    scarcity = 1.0 - coverage
    usable_wh = float(getattr(battery, "usable_capacity_wh", 0.0))
    reserve_wh = float(getattr(battery, "reserve_soc_wh", 0.0))
    soc_max_wh = float(getattr(battery, "soc_max_wh", usable_wh))
    extra_buffer_wh = 0.35 * usable_wh * scarcity
    target_wh = min(soc_max_wh, reserve_wh + extra_buffer_wh)

    h24 = min(len(slots), int(24 / INTERVAL_H))
    avg_buy = (
        sum(float(s.buy_price) for s in slots[:h24]) / h24
        if h24 > 0
        else 4.0
    )
    penalty_czk_kwh = max(0.1, avg_buy * 1.00 * scarcity)
    return target_wh, penalty_czk_kwh

def _soc_min_wh_series(
    slots: list[PlanningSlot],
    usable_wh: float,
    base_min_wh: float,
    buy_extreme_threshold: float,
    planner_discharge_floor_pct: float | None,
) -> list[float]:
    """
    Spodní mez SoC (Wh) pro každý slot: při extrémně záporném buy v lookahead povolit hlubší vybíjení
    až na planner_discharge_floor_percent (jinak min_soc z DB). Absolutní minimum 5 % usable.
    """
    t_len = len(slots)
    abs_min_wh = max(usable_wh * 0.05, 1.0)
    if planner_discharge_floor_pct is None:
        relaxed_wh = base_min_wh
    else:
        relaxed_wh = max(abs_min_wh, float(planner_discharge_floor_pct) / 100.0 * usable_wh)
    effective_relaxed = min(base_min_wh, relaxed_wh)
    out: list[float] = []
    for t in range(t_len):
        j_end = min(t_len, t + SOC_MIN_RELAX_LOOKAHEAD_SLOTS)
        min_buy_fwd = min(float(slots[k].buy_price) for k in range(t, j_end))
        if min_buy_fwd <= buy_extreme_threshold:
            out.append(float(effective_relaxed))
        else:
            out.append(float(base_min_wh))
    return out

def _slots_until_buy_le_threshold(
    slots: list[PlanningSlot], buy_threshold: float
) -> list[int]:
    """
    Pro slot t: kolik slotů (0 = tento slot) do nejbližšího k>=t s buy_price <= buy_threshold.
    Pokud v [t, T) žádný takový není, vrátí T + 1 (větší než jakýkoli rozumný prewindow).
    """
    t_len = len(slots)
    sentinel = t_len + 1
    next_le = sentinel
    next_at_or_after: list[int] = [sentinel] * t_len
    for t in range(t_len - 1, -1, -1):
        if float(slots[t].buy_price) <= buy_threshold:
            next_le = t
        next_at_or_after[t] = next_le
    out: list[int] = []
    for t in range(t_len):
        nxt = next_at_or_after[t]
        if nxt >= t_len:
            out.append(sentinel)
        else:
            out.append(nxt - t)
    return out

def _slots_until_sell_lt(slots: list[PlanningSlot], sell_upper: float) -> list[int]:
    """
    Pro slot t: kolik slotů (0 = tento slot) do nejbližšího k>=t s sell_price < sell_upper.
    Typicky sell_upper=0 (první záporný / „ztrátový“ prodej z pohledu OTE).
    Pokud v [t, T) žádný takový není, vrátí T + 1.
    """
    t_len = len(slots)
    sentinel = t_len + 1
    next_lt = sentinel
    next_at_or_after: list[int] = [sentinel] * t_len
    for t in range(t_len - 1, -1, -1):
        if float(slots[t].sell_price) < sell_upper:
            next_lt = t
        next_at_or_after[t] = next_lt
    out: list[int] = []
    for t in range(t_len):
        nxt = next_at_or_after[t]
        if nxt >= t_len:
            out.append(sentinel)
        else:
            out.append(nxt - t)
    return out

def _prewindow_deferral_slots(
    slots: list[PlanningSlot], buy_extreme_threshold: float, sell_upper: float = 0.0
) -> list[int]:
    """
    Vzdálenost (v 15min slotech) pro zpoždění hlubokého planner flooru:
    primárně do prvního sell < sell_upper (poslední „bez ztráty na prodeji“ je k-1),
    pokud v horizontu není záporný prodej, fallback na první buy <= buy_extreme_threshold.
    """
    t_len = len(slots)
    sell_d = _slots_until_sell_lt(slots, sell_upper)
    buy_d = _slots_until_buy_le_threshold(slots, buy_extreme_threshold)
    sentinel = t_len + 1
    out: list[int] = []
    for t in range(t_len):
        if sell_d[t] < sentinel:
            out.append(sell_d[t])
        else:
            out.append(buy_d[t])
    return out

def _soc_panel_min_wh_series(
    soc_min_series: list[float],
    slots_until_relax_anchor: list[int],
    min_soc_wh: float,
    arb_base_wh: float,
    prewindow_slots: int,
) -> list[float]:
    """
    Zpoždění hluboké relaxace: pokud je lookahead extrémní (soc_min pod min_soc), ale kotva
    (záporný prodej / fallback extrémní buy) je dál než prewindow_slots, drž spodek na
    max(relax_wh, arb_base_wh) — prakticky na rezervě.
    """
    t_len = len(soc_min_series)
    out: list[float] = []
    for t in range(t_len):
        sm = float(soc_min_series[t])
        if sm < min_soc_wh - 1e-3 and slots_until_relax_anchor[t] > prewindow_slots:
            out.append(max(sm, float(arb_base_wh)))
        else:
            out.append(sm)
    return out

def _recompute_charge_acquisition_from_results(
    slots: list[PlanningSlot],
    results: list["DispatchResult"],
    battery,
) -> float:
    """Vážený buy z nabíjecích slotů (grid import + bat charge) z prvního solve."""
    wh_total = 0.0
    cost = 0.0
    for s, r in zip(slots, results):
        if not s.allow_charge:
            continue
        # Zaporne buy sloty (OTE) nejsou grid acquisition pro arbitraz exportu baterie.
        if float(s.buy_price) < 0:
            continue
        gi_w = max(0, int(r.grid_setpoint_w or 0))
        bc_w = max(0, int(r.battery_setpoint_w or 0))
        wh = (gi_w + bc_w) * INTERVAL_H
        if wh <= 0:
            continue
        wh_total += wh
        cost += float(s.buy_price) * wh
    if wh_total <= 0:
        raw = getattr(slots[0], "charge_acquisition_buy_czk_kwh", None)
        if raw is not None:
            return float(raw)
        return min(float(s.buy_price) for s in slots)
    return cost / wh_total

def _slots_with_charge_acquisition(
    slots: list[PlanningSlot],
    acquisition_czk_kwh: float,
) -> list[PlanningSlot]:
    return [
        replace(s, charge_acquisition_buy_czk_kwh=acquisition_czk_kwh)
        for s in slots
    ]

def _pv_store_value_czk_kwh(slot: PlanningSlot, min_spread: float) -> float:
    """
    Práh pro tvrdý zákaz ge_pv (sell pod budoucím max sell v horizontu).
    U spotu při sell >= 0 se neaplikuje — export vs. nabíjení řeší LP; baterii
    na večerní peak drží ge_bat (evening_early / push), ne ge_pv == 0.
    """
    future = float(
        slot.future_sell_opportunity_czk_kwh
        if slot.future_sell_opportunity_czk_kwh is not None
        else slot.sell_price
    )
    return future - min_spread

def _slot_profitable_battery_export(
    slot: PlanningSlot,
    *,
    charge_acquisition_czk_kwh: float,
    min_spread: float,
    fixed_tariff: bool,
) -> bool:
    """
    Export z baterie do sítě má kladnou marži.
    Spot: sell > charge_acquisition + spread (energie ze sítě / vážený nákup).
    Fixní tarif (BA81/KV1): stejně jako R__063 discharge maska — sell > buy + spread;
    acquisition může být nafouknutá grid nabíjením a blokovat večerní špičku (3,7 < 3,9).
    """
    sell_t = float(slot.sell_price)
    acq = float(charge_acquisition_czk_kwh)
    if fixed_tariff:
        buy_t = float(slot.buy_price)
        if buy_t >= 0.0:
            return sell_t > buy_t + min_spread
    return sell_t > acq + min_spread

def _purchase_pricing_fixed(grid: Any) -> bool:
    """Režim nákupu z DB (`site_market_config.purchase_pricing_mode`), ne odhad z rozptylu buy."""
    return (
        str(getattr(grid, "purchase_pricing_mode", "spot") or "spot").strip().lower()
        == "fixed"
    )

def _horizon_fixed_tariff_like(slots: list[PlanningSlot]) -> bool:
    """
    Heuristika pro drahý import / charge_acquisition: buy v horizontu je prakticky konstantní.
    U spotu (home-01) nesmí expensive_import používat charge_acquisition — jinak
    buy > ~1 Kč označí téměř všechny sloty jako drahé (gi=0 pro dům) → Infeasible.
    BA81 má fixní nákup v DB, ale NT/VT → buy skáče; proto neg-sell export řídí _purchase_pricing_fixed.
    """
    buys = [float(s.buy_price) for s in slots if float(s.buy_price) >= 0.0]
    if not buys:
        return False
    if len(buys) == 1:
        return True
    return max(buys) - min(buys) < 0.25

def _future_extreme_buy_from(
    slots: list[PlanningSlot],
    buy_thr: float,
) -> list[bool]:
    """True v t, pokud v některém budoucím slotu buy <= buy_thr."""
    t_len = len(slots)
    out = [False] * t_len
    seen = False
    for i in range(t_len - 1, -1, -1):
        if float(slots[i].buy_price) <= buy_thr:
            seen = True
        out[i] = seen
    return out

def _neg_sell_bat_dump_slots(
    slots: list[PlanningSlot],
    *,
    operating_mode: str,
    purchase_fixed: bool,
    grid: Any,
    buy_extreme_thr: float,
    degrad_czk_kwh: float,
) -> set[int]:
    """Sloty, kde smí ge_bat>0 při sell<0 (výboj před extrémně záporným buy)."""
    if operating_mode != "AUTO" or purchase_fixed:
        return set()
    if bool(getattr(grid, "block_export_on_negative_sell", False)):
        return set()
    t_len = len(slots)
    future_extreme = _future_extreme_buy_from(slots, buy_extreme_thr)
    dist = _slots_until_buy_le(slots, buy_extreme_thr)
    out: set[int] = set()
    for t, s in enumerate(slots):
        if float(s.sell_price) >= 0.0:
            continue
        future_min = min(
            (float(slots[j].buy_price) for j in range(t + 1, t_len)),
            default=float(s.buy_price),
        )
        if (
            future_extreme[t]
            and 0 < dist[t] <= EXTREME_BUY_DUMP_PREWINDOW_SLOTS
            and future_min < float(s.sell_price) - degrad_czk_kwh
        ):
            out.add(t)
    return out

def _slots_until_buy_le(
    slots: list[PlanningSlot],
    buy_thr: float,
) -> list[int]:
    """Počet slotů do nejbližšího buy <= thr (0 = v tomto slotu, T = nikdy)."""
    t_len = len(slots)
    dist = [t_len] * t_len
    next_idx = t_len
    for i in range(t_len - 1, -1, -1):
        if float(slots[i].buy_price) <= buy_thr:
            next_idx = i
        dist[i] = (next_idx - i) if next_idx < t_len else t_len
    return dist

def _pre_negative_sell_export_window(
    slots: list[PlanningSlot],
) -> tuple[int | None, int | None]:
    """Index prvního sell<0 a posledního slotu před ním (pro strategii „vyvézt dřív“)."""
    first_neg = next(
        (i for i, s in enumerate(slots) if float(s.sell_price) < 0),
        None,
    )
    if first_neg is None or first_neg <= 0:
        return first_neg, None
    return first_neg, first_neg - 1

def _neg_sell_phases_enabled(battery: Any) -> bool:
    # Bez atributů z DB (unit testy) = legacy; z DB default 80 % / 4 sloty (V083).
    prep_pct = float(getattr(battery, "planner_neg_sell_prep_soc_percent", 100.0))
    tail_slots = int(getattr(battery, "planner_neg_sell_full_soc_tail_slots", 0))
    return prep_pct < 100.0 - 1e-6 and tail_slots > 0

def _neg_sell_indices_by_prague_day(
    slots: list[PlanningSlot],
) -> dict[object, list[int]]:
    by_day: dict[object, list[int]] = {}
    for t, st in enumerate(slots):
        if float(st.sell_price) < 0.0:
            by_day.setdefault(_prague_calendar_date(st), []).append(t)
    for day in by_day:
        by_day[day].sort()
    return by_day

def _neg_sell_t_detach_index(
    indices: list[int],
    charge_b: dict[int, float],
    soc_need: dict[int, float],
    tail_start: int,
    soc_max: float,
    *,
    margin: float = 1.05,
    min_gap_wh: float = 500.0,
    detach_soc_frac: float = 0.85,
) -> int:
    """
    Bod T: první prep slot, kde (1) soc_need[t] ≥ detach_soc_frac × soc_max a
    (2) zbývající B-nabití od t do konce pokryje mezeru do 100 %.
    Dřívější chyba: soc_need[t] ≤ soc_need[tail_start] platilo hned na začátku okna.
    """
    if not indices:
        return 0
    suffix_from: dict[int, float] = {}
    run = 0.0
    for t in reversed(indices):
        run += float(charge_b.get(t, 0.0))
        suffix_from[t] = run
    thresh_wh = max(
        soc_max * detach_soc_frac,
        float(soc_need.get(tail_start, soc_max)) * 0.92,
    )
    for t in indices:
        if t >= tail_start:
            continue
        need_t = float(soc_need.get(t, soc_max))
        if need_t < thresh_wh:
            continue
        gap_rem = soc_max - need_t
        if gap_rem <= min_gap_wh:
            return t
        if suffix_from.get(t, 0.0) >= gap_rem * margin:
            return t
    return tail_start

def _neg_sell_pv_b_charge_wh(slot: PlanningSlot, battery: Any) -> float:
    """Odhad Wh nabitelné jen z PV B v jednom sell<0 slotu (surplus nad load, cap výkonu)."""
    pv_surplus_b = max(0.0, float(slot.pv_b_forecast_w) - float(slot.load_baseline_w))
    if pv_surplus_b <= 500.0:
        return 0.0
    cap_w = min(pv_surplus_b, float(battery.max_charge_power_w))
    return cap_w * INTERVAL_H * float(battery.charge_efficiency)

def _neg_sell_pv_forecast_charge_wh(slot: PlanningSlot, battery: Any) -> float:
    """Odhad Wh z FVE A+B v sell<0 slotu pro zpětnou projekci soc_need (v44)."""
    pv_surplus = max(
        0.0,
        float(slot.pv_a_forecast_w)
        + float(slot.pv_b_forecast_w)
        - float(slot.load_baseline_w),
    )
    if pv_surplus <= 500.0:
        return 0.0
    cap_w = min(pv_surplus, float(battery.max_charge_power_w))
    return cap_w * INTERVAL_H * float(battery.charge_efficiency)

def _neg_sell_day_pv_b_usable_wh(
    slots: list[PlanningSlot],
    first_neg_sell_idx: int | None,
    battery: Any,
) -> float:
    """Součet B-nabíjení ve všech sell<0 slotech téhož pražského dne."""
    if first_neg_sell_idx is None:
        return 0.0
    neg_day = _prague_calendar_date(slots[first_neg_sell_idx])
    total = 0.0
    for s in slots:
        if _prague_calendar_date(s) != neg_day:
            continue
        if float(s.sell_price) >= 0.0:
            continue
        total += _neg_sell_pv_b_charge_wh(s, battery)
    return total

def _neg_sell_e_surplus_after_t_wh(
    slots: list[PlanningSlot],
    t_detach: int,
    last_neg: int,
    battery: Any,
) -> float:
    """Integrál přebytku FVE nad load+bat cap od t_detach do last_neg (Wh)."""
    total = 0.0
    for t in range(t_detach, last_neg + 1):
        if t < 0 or t >= len(slots):
            continue
        st = slots[t]
        if float(st.sell_price) >= 0.0:
            continue
        pv_surplus = max(
            0.0,
            float(st.pv_a_forecast_w)
            + float(st.pv_b_forecast_w)
            - float(st.load_baseline_w),
        )
        if pv_surplus <= 500.0:
            continue
        cap_charge_wh = (
            min(pv_surplus, float(battery.max_charge_power_w))
            * INTERVAL_H
            * float(battery.charge_efficiency)
        )
        total += max(0.0, pv_surplus * INTERVAL_H - cap_charge_wh)
    return total

def _neg_sell_day_phases(
    slots: list[PlanningSlot],
    battery: Any,
) -> tuple[list[str], list[Optional[float]], list[float], dict[str, Any]]:
    """
    Per slot: phase (none|prep|tail), soc_target_wh (rampa z PV B, ne fixní %), shortfall váha.
    V35: zpětná projekce soc_need z B od tail.
    V36: t_detach = první prep slot kde suffix B-nabití pokryje (soc_max − soc_need[t]).
    """
    t_len = len(slots)
    phases: list[str] = ["none"] * t_len
    soc_targets: list[Optional[float]] = [None] * t_len
    shortfall_weights: list[float] = [0.0] * t_len
    tail_n = int(getattr(battery, "planner_neg_sell_full_soc_tail_slots", 0))
    soc_max = float(battery.soc_max_wh)
    min_soc = float(battery.min_soc_wh)
    post_detach_prep_ts: set[int] = set()
    day_meta: list[dict[str, Any]] = []

    by_day: dict[object, list[int]] = {}
    for t, st in enumerate(slots):
        if float(st.sell_price) < 0.0:
            by_day.setdefault(_prague_calendar_date(st), []).append(t)

    for day, indices in by_day.items():
        if not indices:
            continue
        indices.sort()
        last_t = indices[-1]
        tail_start = max(indices[0], last_t - tail_n + 1) if tail_n > 0 else last_t + 1
        charge_b = {
            t: _neg_sell_pv_forecast_charge_wh(slots[t], battery) for t in indices
        }
        soc_need: dict[int, float] = {last_t: soc_max}
        for i in range(len(indices) - 1, 0, -1):
            t_cur = indices[i]
            t_prev = indices[i - 1]
            soc_need[t_prev] = max(min_soc, soc_need[t_cur] - charge_b[t_cur])

        t_detach = _neg_sell_t_detach_index(
            indices,
            charge_b,
            soc_need,
            tail_start,
            soc_max,
        )
        soc_detach_wh = float(soc_need.get(t_detach, soc_max))

        e_surplus = _neg_sell_e_surplus_after_t_wh(slots, t_detach, last_t, battery)

        for t in indices:
            if t >= tail_start:
                phases[t] = "tail"
                if tail_n <= 1:
                    soc_targets[t] = soc_max
                else:
                    pos = t - tail_start
                    frac = pos / float(max(1, tail_n - 1))
                    lo = float(soc_need.get(tail_start, soc_max))
                    soc_targets[t] = lo + frac * (soc_max - lo)
            else:
                phases[t] = "prep"
                soc_targets[t] = float(soc_need[t])
                if t >= t_detach:
                    post_detach_prep_ts.add(t)
            shortfall_weights[t] = float(last_t - t + 1) / float(len(indices))

        day_meta.append(
            {
                "prague_date": str(day),
                "first_neg_idx": indices[0],
                "last_neg_idx": last_t,
                "tail_start_idx": tail_start,
                "t_detach_idx": t_detach,
                "soc_detach_wh": soc_detach_wh,
                "e_surplus_after_t_wh": e_surplus,
                "soc_ramp_wh": [
                    {
                        "slot": slots[t].interval_start.isoformat(),
                        "soc_need_wh": float(soc_need[t]),
                        "phase": phases[t],
                        "soc_target_wh": float(soc_targets[t] or 0.0),
                    }
                    for t in indices
                ],
            }
        )

    meta: dict[str, Any] = {
        "neg_sell_b_ramp_v35": True,
        "neg_sell_prep_window_v36": True,
        "days": day_meta,
        "post_detach_prep_ts": sorted(post_detach_prep_ts),
    }
    if day_meta:
        meta["t_detach_idx"] = day_meta[0]["t_detach_idx"]
        meta["e_surplus_after_t_wh"] = day_meta[0]["e_surplus_after_t_wh"]
    return phases, soc_targets, shortfall_weights, meta

def _neg_sell_day_pv_usable_wh(
    slots: list[PlanningSlot],
    first_neg_sell_idx: int | None,
    *,
    max_charge_power_w: float,
    charge_efficiency: float,
) -> float:
    """
    Odhad Wh nabitelné z FVE v sell<0 slotech téhož pražského dne (forecast surplus × cap nabíjení).
    """
    if first_neg_sell_idx is None:
        return 0.0
    neg_day = _prague_calendar_date(slots[first_neg_sell_idx])
    total_wh = 0.0
    for s in slots:
        if _prague_calendar_date(s) != neg_day:
            continue
        if float(s.sell_price) >= 0.0:
            continue
        pv_surplus_w = max(
            0.0,
            float(s.pv_a_forecast_w)
            + float(s.pv_b_forecast_w)
            - float(s.load_baseline_w),
        )
        if pv_surplus_w <= 500.0:
            continue
        cap_w = min(pv_surplus_w, float(max_charge_power_w))
        total_wh += cap_w * INTERVAL_H * float(charge_efficiency)
    return total_wh

def _pre_neg_pv_export_forecast_cushion_ok_for_day(
    slots: list[PlanningSlot],
    battery: Any,
    first_neg_t: int,
    observed_soc_wh: float,
    *,
    neg_sell_phases_en: bool,
    soc_target_by_t: list[Optional[float]] | None = None,
) -> bool:
    """
    Cushion pro jeden pražský den: usable A+B v sell<0 okně pokryje dobítí na soc_need[first_neg].
    Vstup SoC = pozorovaná telemetrie (ne trajektorie z předchozího solve).
    """
    if first_neg_t < 0 or first_neg_t >= len(slots):
        return False
    if neg_sell_phases_en and soc_target_by_t is not None:
        tgt = soc_target_by_t[first_neg_t]
        target_wh = float(tgt) if tgt is not None else float(battery.soc_max_wh)
    else:
        target_wh = float(battery.soc_max_wh)
    soc_obs = max(
        float(battery.min_soc_wh),
        min(float(observed_soc_wh), float(battery.soc_max_wh)),
    )
    if soc_obs >= target_wh - 1e-3:
        return True
    usable_wh = _neg_sell_day_pv_usable_wh(
        slots,
        first_neg_t,
        max_charge_power_w=float(battery.max_charge_power_w),
        charge_efficiency=float(battery.charge_efficiency),
    )
    needed_wh = max(0.0, target_wh - soc_obs)
    if needed_wh < PRE_NEG_PV_EXPORT_MIN_NEEDED_WH:
        return True
    return usable_wh >= needed_wh * PRE_NEG_PV_EXPORT_FORECAST_MARGIN

def _pre_neg_pv_export_forecast_cushion_ok(
    slots: list[PlanningSlot],
    battery: Any,
    observed_soc_wh: float,
    first_neg_sell_idx: int | None,
    *,
    neg_sell_phases_en: bool,
    soc_target_by_t: list[Optional[float]] | None = None,
) -> bool:
    """Zpětná kompatibilita: cushion pro první sell<0 v horizontu (pozorované SoC)."""
    if first_neg_sell_idx is None or first_neg_sell_idx <= 0:
        return False
    targets = soc_target_by_t
    if neg_sell_phases_en and targets is None:
        _ph, targets, _w, _meta = _neg_sell_day_phases(slots, battery)
    return _pre_neg_pv_export_forecast_cushion_ok_for_day(
        slots,
        battery,
        first_neg_sell_idx,
        observed_soc_wh,
        neg_sell_phases_en=neg_sell_phases_en,
        soc_target_by_t=targets,
    )

def _pre_neg_pv_export_slot_indices_for_day(
    slots: list[PlanningSlot],
    first_neg_t: int,
    first_neg_buy_idx: int | None,
) -> set[int]:
    """Kladný sell téhož dne před prvním sell<0, PV přebytek."""
    if first_neg_t <= 0:
        return set()
    neg_day = _prague_calendar_date(slots[first_neg_t])
    out: set[int] = set()
    for t in range(first_neg_t):
        if _prague_calendar_date(slots[t]) != neg_day:
            continue
        if float(slots[t].sell_price) < 0.0:
            continue
        if first_neg_buy_idx is not None and t >= first_neg_buy_idx:
            continue
        if _slot_pv_surplus_w(slots[t]) <= NIGHT_EXPORT_PV_SUNRISE_SURPLUS_W:
            continue
        out.add(t)
    return out

def _pre_neg_pv_export_bundle(
    slots: list[PlanningSlot],
    battery: Any,
    observed_soc_wh: float,
    first_neg_buy_idx: int | None,
    *,
    neg_sell_phases_en: bool,
    soc_target_by_t: list[Optional[float]] | None = None,
) -> tuple[set[int], dict[str, bool]]:
    """
    v36: pre-neg export per pražský den s vlastním cushion (A+B v neg okně dne).
    v40: cushion vždy z pozorovaného SoC (telemetrie), bez řetězení modelových cílů mezi dny.
    """
    by_day = _neg_sell_indices_by_prague_day(slots)
    export_ts: set[int] = set()
    cushion_by_day: dict[str, bool] = {}
    for day in sorted(by_day.keys()):
        indices = by_day[day]
        if not indices:
            continue
        first_t = indices[0]
        ok = _pre_neg_pv_export_forecast_cushion_ok_for_day(
            slots,
            battery,
            first_t,
            observed_soc_wh,
            neg_sell_phases_en=neg_sell_phases_en,
            soc_target_by_t=soc_target_by_t,
        )
        cushion_by_day[str(day)] = ok
        if ok:
            export_ts |= _pre_neg_pv_export_slot_indices_for_day(
                slots,
                first_t,
                first_neg_buy_idx,
            )
    return export_ts, cushion_by_day

def _pre_neg_pv_export_slot_indices(
    slots: list[PlanningSlot],
    first_neg_sell_idx: int | None,
    pre_neg_export_last_t: int | None,
    first_neg_buy_idx: int | None,
) -> set[int]:
    """Legacy: jen před globálním prvním sell<0 (v36 preferuj _pre_neg_pv_export_bundle)."""
    if first_neg_sell_idx is None or pre_neg_export_last_t is None:
        return set()
    out: set[int] = set()
    for t in range(pre_neg_export_last_t + 1):
        if float(slots[t].sell_price) < 0.0:
            continue
        if first_neg_buy_idx is not None and t >= first_neg_buy_idx:
            continue
        if _slot_pv_surplus_w(slots[t]) <= NIGHT_EXPORT_PV_SUNRISE_SURPLUS_W:
            continue
        out.add(t)
    return out

def _discharge_before_first_neg_sell_ts(
    slots: list[PlanningSlot],
    first_neg_sell_idx: int | None,
) -> set[int]:
    """Všechny kladné-sell sloty před 1. sell<0 (funguje i v rolling bez D−1 večera v horizontu)."""
    if first_neg_sell_idx is None or first_neg_sell_idx <= 0:
        return set()
    return {
        t
        for t in range(first_neg_sell_idx)
        if float(slots[t].sell_price) >= 0.0
    }

def _evening_discharge_before_neg_day_ts(
    slots: list[PlanningSlot],
    neg_sell_day_meta: dict[str, Any],
) -> set[int]:
    """
    Večer/noc kalendářního dne D−1 před pražským dnem D s sell<0: příprava headroomu.
    """
    from datetime import timedelta

    out: set[int] = set()
    for day_info in neg_sell_day_meta.get("days") or []:
        first_neg = int(day_info.get("first_neg_idx", -1))
        if first_neg < 0 or first_neg >= len(slots):
            continue
        neg_date = _prague_calendar_date(slots[first_neg])
        prev_date = neg_date - timedelta(days=1)
        for t, st in enumerate(slots):
            if _prague_calendar_date(st) != prev_date:
                continue
            if float(st.sell_price) < 0.0:
                continue
            h = _prague_hour(st)
            if not (17 <= h <= 23 or _in_night_battery_export_window(st)):
                continue
            if float(st.sell_price) < 0.0:
                continue
            out.add(t)
    return out

def _night_baseload_buffer_wh_from_slots(
    slots: list[PlanningSlot],
    battery: Any,
) -> float:
    """Buffer Wh nad reserve pro noc (R__063 nebo % z asset_battery)."""
    if not slots:
        return 0.0
    slot0 = slots[0]
    buf = getattr(slot0, "night_baseload_buffer_wh", None)
    if buf is not None:
        return max(0.0, float(buf))
    target = getattr(slot0, "night_baseload_target_wh", None)
    if target is not None:
        pct = float(getattr(battery, "planner_night_baseload_buffer_percent", 20.0) or 20.0)
        return max(0.0, float(target) * pct / 100.0)
    return 0.0

def _neg_evening_discharge_budget_wh(
    *,
    observed_soc_wh: float,
    reserve_soc_wh: float,
    night_baseload_buffer_wh: float,
) -> float:
    """Wh k výboji nad reserve + noční buffer — z telemetrie, ne z LP trajektorie."""
    return max(
        0.0,
        float(observed_soc_wh) - float(reserve_soc_wh) - float(night_baseload_buffer_wh),
    )

def _first_neg_sell_idx_on_prague_day(
    slots: list[PlanningSlot],
    prague_day: object,
) -> int | None:
    for t, st in enumerate(slots):
        if _prague_calendar_date(st) != prague_day:
            continue
        if float(st.sell_price) < 0.0:
            return t
    return None

def _terminal_neg_buy_weight(
    slots: list[PlanningSlot],
    *,
    first_neg_buy_idx: int | None,
) -> float:
    """
    w_neg ∈ [0, TERMINAL_NEG_BUY_WEIGHT_CAP]: snížení terminal SoC shadow price při buy<0 v horizontu.
    Blížší a zápornější okno → vyšší váha; effective_factor = planner_terminal_soc_value_factor × (1 − w_neg).
    """
    if first_neg_buy_idx is None or first_neg_buy_idx <= 0:
        return 0.0
    slots_ahead = first_neg_buy_idx
    prox = max(
        0.0,
        1.0 - slots_ahead / TERMINAL_NEG_BUY_WEIGHT_HORIZON_SLOTS,
    )
    if prox <= 0.0:
        return 0.0
    window_end = min(len(slots), first_neg_buy_idx + int(24 / INTERVAL_H))
    neg_buys = [
        float(slots[t].buy_price)
        for t in range(first_neg_buy_idx, window_end)
        if float(slots[t].buy_price) < 0.0
    ]
    if not neg_buys:
        return 0.0
    min_neg_buy = min(neg_buys)
    mag_raw = min(1.0, abs(min_neg_buy) / TERMINAL_NEG_BUY_MAGNITUDE_REF_CZK)
    mag = TERMINAL_NEG_BUY_MAGNITUDE_FLOOR + (1.0 - TERMINAL_NEG_BUY_MAGNITUDE_FLOOR) * mag_raw
    return min(TERMINAL_NEG_BUY_WEIGHT_CAP, prox * mag)

def _future_neg_buy_discharge_enabled(
    slots: list[PlanningSlot],
    battery: Any,
    *,
    first_neg_buy_idx: int,
    first_neg_sell_idx: int | None,
    observed_soc_wh: float,
    neg_sell_phases_en: bool,
    neg_sell_soc_target_by_t: list[Optional[float]] | None = None,
) -> bool:
    """
    Večerní vývoz k reserve_soc před dnem s buy<0: aktivní i při relaxed_neg_prep_window,
    pokud FVE v sell<0 okně pokryje deficit do prep rampy (× PRE_NEG_PV_EXPORT_FORECAST_MARGIN).
    """
    if first_neg_buy_idx <= 0:
        return False
    neg_buy_day = _prague_calendar_date(slots[first_neg_buy_idx])
    neg_sell_t = first_neg_sell_idx
    if (
        neg_sell_t is None
        or _prague_calendar_date(slots[neg_sell_t]) != neg_buy_day
    ):
        neg_sell_t = _first_neg_sell_idx_on_prague_day(slots, neg_buy_day)
    if neg_sell_t is None:
        return False
    if neg_sell_phases_en and neg_sell_soc_target_by_t is not None:
        tgt = neg_sell_soc_target_by_t[neg_sell_t]
        target_wh = float(tgt) if tgt is not None else float(battery.soc_max_wh)
    else:
        target_wh = float(battery.soc_max_wh)
    reserve_wh = float(
        getattr(battery, "reserve_soc_wh", getattr(battery, "min_soc_wh", 0.0))
    )
    soc_obs = max(
        float(battery.min_soc_wh),
        min(float(observed_soc_wh), float(battery.soc_max_wh)),
    )
    if soc_obs <= reserve_wh + 1e-3:
        return False
    if soc_obs >= target_wh - 1e-3:
        return True
    usable_wh = _neg_sell_day_pv_usable_wh(
        slots,
        neg_sell_t,
        max_charge_power_w=float(battery.max_charge_power_w),
        charge_efficiency=float(battery.charge_efficiency),
    )
    needed_wh = max(0.0, target_wh - soc_obs)
    if needed_wh < PRE_NEG_PV_EXPORT_MIN_NEEDED_WH:
        return True
    return usable_wh >= needed_wh * PRE_NEG_PV_EXPORT_FORECAST_MARGIN

def _pos_sell_pre_neg_buy_evening_export_exempt_ts(
    slots: list[PlanningSlot],
    pos_sell_pre_neg_buy_ts: list[int],
    evening_peak_export_ts: list[int],
    *,
    charge_acquisition_czk_kwh: float,
    min_spread: float,
    fixed_tariff: bool,
    future_neg_buy_discharge_en: bool,
) -> set[int]:
    """Večerní peak před buy<0: neaplikovat ge=0, pokud je vývoz ekonomicky výhodný."""
    if not future_neg_buy_discharge_en:
        return set()
    evening_peak_set = set(evening_peak_export_ts)
    out: set[int] = set()
    for t in pos_sell_pre_neg_buy_ts:
        if t not in evening_peak_set and not _in_evening_push_hour_window(slots[t]):
            continue
        if not _slot_profitable_battery_export(
            slots[t],
            charge_acquisition_czk_kwh=charge_acquisition_czk_kwh,
            min_spread=min_spread,
            fixed_tariff=fixed_tariff,
        ):
            continue
        out.add(t)
    return out

def _neg_evening_before_neg_push_indices(
    slots: list[PlanningSlot],
    candidate_ts: set[int],
    *,
    export_budget_wh: float,
    per_slot_discharge_wh: float,
    discharge_export_ok: set[int] | None = None,
) -> set[int]:
    """Nejdražší kladné-sell sloty v kandidátech, dokud budget z pozorovaného SoC."""
    if export_budget_wh < per_slot_discharge_wh * 0.5 or not candidate_ts:
        return set()
    eligible = {
        t
        for t in candidate_ts
        if discharge_export_ok is None or t in discharge_export_ok
    }
    if not eligible:
        return set()
    ranked = sorted(
        eligible,
        key=lambda t: (float(slots[t].sell_price), -t),
        reverse=True,
    )
    out: set[int] = set()
    cum_wh = 0.0
    for t in ranked:
        if float(slots[t].sell_price) < 0.0:
            continue
        if cum_wh + per_slot_discharge_wh > export_budget_wh + 1e-6:
            break
        out.add(t)
        cum_wh += per_slot_discharge_wh
    return out

def _neg_evening_reserve_soc_anchors(
    slots: list[PlanningSlot],
    neg_sell_day_meta: dict[str, Any],
    battery: Any,
) -> list[tuple[int, float]]:
    """
    Kotvy SoC ≤ reserve_soc před neg oknem:
    - večer D−1 (23:45) pokud je v horizontu,
    - slot těsně před 1. sell<0 (rolling: ráno bez včerejška v okně).
    """
    from datetime import timedelta

    reserve_wh = float(
        getattr(battery, "reserve_soc_wh", getattr(battery, "min_soc_wh", 0.0))
    )
    out: list[tuple[int, float]] = []
    seen: set[int] = set()
    for day_info in neg_sell_day_meta.get("days") or []:
        first_neg = int(day_info.get("first_neg_idx", -1))
        if first_neg < 0 or first_neg >= len(slots):
            continue
        neg_date = _prague_calendar_date(slots[first_neg])
        prev_date = neg_date - timedelta(days=1)
        eve_slots = [
            t
            for t, st in enumerate(slots)
            if _prague_calendar_date(st) == prev_date
            and (
                17 <= _prague_hour(st) <= 23
                or _in_night_battery_export_window(st)
            )
        ]
        if eve_slots:
            t_eve = max(eve_slots)
            if t_eve not in seen:
                out.append((t_eve, reserve_wh))
                seen.add(t_eve)
        if first_neg > 0:
            t_pre = first_neg - 1
            if (
                t_pre not in seen
                and float(slots[t_pre].sell_price) >= 0.0
            ):
                out.append((t_pre, reserve_wh))
                seen.add(t_pre)
    return out

def _battery_export_cap_w(battery: Any, grid: Any) -> float:
    """Max výkon vývozu baterie do sítě [W] — z DB, ne hardcoded konstanta."""
    return min(
        float(battery.max_discharge_power_w),
        float(grid.max_export_power_w),
    )

def _evening_push_battery_export_w(
    slot: PlanningSlot,
    battery: Any,
    grid: Any,
) -> float:
    """
    Tvrdý push ge_bat: min(site/inverter export cap, BMS − load).
    Stejná fyzika jako Deye SELL — load pokryje baterie, zbytek výkonu jde do sítě
    (ne (max−load)/2 z dvojího započtení bd+ge_bat v LP).
    """
    cap = _battery_export_cap_w(battery, grid)
    load_w = max(0.0, float(slot.load_baseline_w))
    discharge_headroom = max(
        0.0,
        float(battery.max_discharge_power_w) - load_w,
    )
    return min(cap, discharge_headroom)

def _dispatch_grid_setpoint_w(
    *,
    gi_w: float,
    ge_w: float,
    ge_bat_w: float,
    ge_pv_w: float,
    max_export_power_w: int,
) -> tuple[int, str]:
    """
    grid_setpoint pro export do sítě (záporný W) a export_mode.
    gi−ge může být ~0 při load-first, i když ge_bat exportuje — Deye reg 143 potřebuje |grid_setpoint|.
    """
    ge_total = max(0.0, float(ge_w))
    ge_bat_v = max(0.0, float(ge_bat_w))
    cap = float(max_export_power_w)
    if ge_bat_v >= GE_MIN_EXPORT_W and ge_bat_v >= max(ge_total * 0.5, 500.0):
        export_w = min(cap, max(ge_total, ge_bat_v + max(0.0, float(ge_pv_w))))
        return -int(round(export_w)), "BATTERY_SELL"
    if ge_total >= GE_MIN_EXPORT_W:
        return -int(round(min(cap, ge_total))), "PV_SURPLUS"
    return round(float(gi_w) - ge_total), "NONE"

def _morning_pre_neg_zone_peak_sell(
    slots: list[PlanningSlot],
    first_neg_sell_idx: int | None,
) -> float | None:
    """Max kladný sell v pásmu 5–11 Prague před prvním sell<0 (shodně s R__063)."""
    if first_neg_sell_idx is None or first_neg_sell_idx <= 0:
        return None
    neg_day = _prague_calendar_date(slots[first_neg_sell_idx])
    sells = [
        float(slots[i].sell_price)
        for i in range(first_neg_sell_idx)
        if float(slots[i].sell_price) >= 0.0
        and _prague_calendar_date(slots[i]) == neg_day
        and MORNING_PRENEG_START_HOUR <= _prague_hour(slots[i]) <= MORNING_PRENEG_END_HOUR
    ]
    if not sells:
        return None
    return max(sells)

def _pre_neg_peak_sell_idx(
    slots: list[PlanningSlot],
    first_neg_sell_idx: int | None,
) -> int | None:
    """Nejvyšší kladný sell v ranním pásmu před prvním sell<0 (ne půlnoc celého dne)."""
    if first_neg_sell_idx is None or first_neg_sell_idx <= 0:
        return None
    zone_peak = _morning_pre_neg_zone_peak_sell(slots, first_neg_sell_idx)
    if zone_peak is None:
        return None
    neg_day = _prague_calendar_date(slots[first_neg_sell_idx])
    positive = [
        (i, float(slots[i].sell_price))
        for i in range(first_neg_sell_idx)
        if float(slots[i].sell_price) >= 0.0
        and _prague_calendar_date(slots[i]) == neg_day
        and MORNING_PRENEG_START_HOUR <= _prague_hour(slots[i]) <= MORNING_PRENEG_END_HOUR
    ]
    if not positive:
        return None
    return max(positive, key=lambda x: (x[1], x[0]))[0]

def _morning_pre_neg_export_indices(
    slots: list[PlanningSlot],
    first_neg_sell_idx: int | None,
    *,
    degrad_czk_kwh: float,
) -> list[int]:
    """Všechny ranní peak sloty (sell ≥ zónový max − degrad) před prvním sell<0."""
    zone_peak = _morning_pre_neg_zone_peak_sell(slots, first_neg_sell_idx)
    if zone_peak is None or first_neg_sell_idx is None or first_neg_sell_idx <= 0:
        return []
    neg_day = _prague_calendar_date(slots[first_neg_sell_idx])
    out: list[int] = []
    for i in range(first_neg_sell_idx):
        if (
            float(slots[i].sell_price) >= zone_peak - degrad_czk_kwh
            and float(slots[i].sell_price) >= 0.0
            and _prague_calendar_date(slots[i]) == neg_day
            and MORNING_PRENEG_START_HOUR <= _prague_hour(slots[i]) <= MORNING_PRENEG_END_HOUR
        ):
            out.append(i)
    return out

def _pre_neg_buy_discharge_indices(
    slots: list[PlanningSlot],
    first_neg_buy_idx: int | None,
    *,
    charge_acquisition_czk_kwh: float,
    min_spread: float,
    fixed_tariff: bool,
) -> set[int]:
    """
    Sloty před prvním buy<0: výboj baterie do sítě při kladném sell (včetně noci).
    Bez rozšíření discharge_export_slots (v19b — jinak w_arb → Infeasible).
    """
    if first_neg_buy_idx is None or first_neg_buy_idx <= 0:
        return set()
    out: set[int] = set()
    for i in range(first_neg_buy_idx):
        s = slots[i]
        if float(s.buy_price) < 0.0:
            continue
        if float(s.sell_price) < PRE_NEG_BATT_EXPORT_MIN_SELL_CZK_KWH:
            continue
        if not _slot_profitable_battery_export(
            s,
            charge_acquisition_czk_kwh=charge_acquisition_czk_kwh,
            min_spread=min_spread,
            fixed_tariff=fixed_tariff,
        ):
            continue
        out.add(i)
    return out

def _slot_pv_surplus_w(slot: PlanningSlot) -> float:
    load_w = float(slot.load_baseline_w)
    pv_w = float(slot.pv_a_forecast_w) + float(slot.pv_b_forecast_w)
    return max(0.0, pv_w - load_w)

def _battery_export_push_defer_to_pv(slot: PlanningSlot) -> bool:
    """
    Při kladném sell a PV přebytku nevnucovat vývoz z baterie (ge_bat / z_export).
    Platí pro ranní pre-neg, večerní push i KV1 odpoledne (block_export + fixní tarif):
    přetok řeší ge_pv / Deye PASSIVE, ne BATTERY_SELL.
    """
    if float(slot.sell_price) < 0.0:
        return False
    return _slot_pv_surplus_w(slot) > NIGHT_EXPORT_PV_SUNRISE_SURPLUS_W

def _in_night_battery_export_window(slot: PlanningSlot) -> bool:
    """
    Noční okno pro večerní push / peak sell: >=17h Prague, nebo 0–5h (přes půlnoc).
    Končí prvním slotem s významným PV přebytkem (východ FVE), ne kalendářním dnem.
    """
    if _slot_pv_surplus_w(slot) > NIGHT_EXPORT_PV_SUNRISE_SURPLUS_W:
        return False
    h = _prague_hour(slot)
    if h >= NIGHT_EXPORT_EVENING_START_HOUR:
        return True
    return h <= NIGHT_EXPORT_MORNING_END_HOUR

def _in_evening_push_hour_window(slot: PlanningSlot) -> bool:
    """Tvrdý večerní push jen ≥17h Prague — ne noční vývoz ve 02–06h (sell < buy)."""
    return _prague_hour(slot) >= NIGHT_EXPORT_EVENING_START_HOUR

def _night_export_window_segments(slots: list[PlanningSlot]) -> list[list[int]]:
    """Souvislé úseky nočního okna v horizontu (oddělené denní pauzou / východem FVE)."""
    segments: list[list[int]] = []
    current: list[int] = []
    for t, s in enumerate(slots):
        if _in_night_battery_export_window(s):
            current.append(t)
        else:
            if current:
                segments.append(current)
                current = []
    if current:
        segments.append(current)
    return segments

def _night_peak_sell_czk_kwh(slots: list[PlanningSlot], slot_index: int) -> float:
    """Max sell v nočním úseku, do kterého slot patří (pro evening_early)."""
    for seg in _night_export_window_segments(slots):
        if slot_index in seg:
            return max(float(slots[t].sell_price) for t in seg)
    return 0.0

def _evening_peak_export_indices(
    slots: list[PlanningSlot],
    *,
    degrad_czk_kwh: float,
    evening_start_hour: int = 17,
) -> list[int]:
    """
    Noční špičky sell: jeden peak na souvislý úsek (17h → půlnoc → ráno do východu FVE),
    ne per kalendářní den (oprava 23:30 vs 00:00).
    """
    _ = evening_start_hour  # kompatibilita volání; okno řídí NIGHT_EXPORT_* konstanty
    out: list[int] = []
    for seg in _night_export_window_segments(slots):
        if not seg:
            continue
        peak = max(float(slots[t].sell_price) for t in seg)
        if peak <= 0.0:
            continue
        for t in seg:
            if float(slots[t].sell_price) >= peak - degrad_czk_kwh:
                out.append(t)
    return sorted(out)

def _planner_discharge_floor_wh(battery: Any) -> float:
    """Provozní podlaha vývoje: reserve_soc (domluva), ne jen min_soc."""
    return max(
        float(getattr(battery, "min_soc_wh", 0.0)),
        float(getattr(battery, "reserve_soc_wh", 0.0)),
    )

def _evening_push_discharge_budget_wh(
    *,
    current_soc_wh: float,
    discharge_floor_wh: float,
    soc_max_wh: float,
    discharge_slot_buffer: float,
) -> float:
    """
    Rozpočet Wh pro tvrdý večerní push — stejný princip jako R__063 (discharge_slot_buffer).
    Podlaha = reserve_soc (typ. 20 %), ne min_soc (10 %).
    """
    exportable_full_wh = max(0.0, float(soc_max_wh) - float(discharge_floor_wh))
    available_wh = max(0.0, float(current_soc_wh) - float(discharge_floor_wh))
    buf = float(discharge_slot_buffer)
    if buf <= 0.0:
        return available_wh
    return min(available_wh, exportable_full_wh * buf)

def _kv1_block_export_fixed_evening_push(
    grid: Any,
    *,
    purchase_fixed: bool,
) -> bool:
    """KV1: fixní buy + block_export — večerní push jiná profitabilita než acq+spread."""
    return purchase_fixed and bool(
        getattr(grid, "block_export_on_negative_sell", False)
    )

def _slot_evening_push_profitable(
    slot: PlanningSlot,
    *,
    charge_acquisition_czk_kwh: float,
    min_spread: float,
    slots: list[PlanningSlot] | None = None,
    first_neg_sell_idx: int | None = None,
    kv1_evening_push: bool = False,
    purchase_fixed: bool = False,
) -> bool:
    """
    Push večerní špičky.
    Spot: sell > acq+spread (zásoba z levného nabití).
    Fixní tarif (BA81/KV1): sell > buy+spread (stejně jako R__063 discharge maska).
    KV1 (fixed + block_export, v52): navíc sell ≥ max sell v pásmu 5–11 před 1. sell<0 − spread.
    """
    sell_t = float(slot.sell_price)
    if kv1_evening_push:
        if sell_t < PRE_NEG_BATT_EXPORT_MIN_SELL_CZK_KWH:
            return False
        if slots is not None:
            zone_peak = _morning_pre_neg_zone_peak_sell(slots, first_neg_sell_idx)
            if zone_peak is not None:
                return sell_t >= float(zone_peak) - float(min_spread)
        return True
    if purchase_fixed:
        buy_t = float(slot.buy_price)
        if buy_t >= 0.0:
            return sell_t > buy_t + float(min_spread)
    return sell_t > float(charge_acquisition_czk_kwh) + float(min_spread)

def _evening_push_segment_candidates(
    slots: list[PlanningSlot],
    seg: list[int],
    *,
    charge_acquisition_czk_kwh: float,
    min_spread: float,
    discharge_export_ok: set[int] | None = None,
    first_neg_sell_idx: int | None = None,
    kv1_evening_push: bool = False,
    purchase_fixed: bool = False,
) -> list[int]:
    """Profitable sloty v nočním úseku — výběr pořadí a strop dělá rozpočet Wh (sell desc)."""
    if not seg:
        return []
    out: list[int] = []
    for t in seg:
        if discharge_export_ok is not None and t not in discharge_export_ok:
            continue
        if not _in_evening_push_hour_window(slots[t]):
            continue
        if not _slot_evening_push_profitable(
            slots[t],
            charge_acquisition_czk_kwh=charge_acquisition_czk_kwh,
            min_spread=min_spread,
            slots=slots,
            first_neg_sell_idx=first_neg_sell_idx,
            kv1_evening_push=kv1_evening_push,
            purchase_fixed=purchase_fixed,
        ):
            continue
        out.append(t)
    return out

def _strict_late_replan_evening_slot_indices(
    slots: list[PlanningSlot],
    *,
    first_neg_buy_idx: int | None,
    observed_soc_wh: float,
    reserve_soc_wh: float,
) -> set[int]:
    """
    Strict solve: večer D0 (17–22h) před dnem s buy<0 — vývoz k reserve, výjimka z pos_sell ge=0.
    """
    if first_neg_buy_idx is None or first_neg_buy_idx <= 0:
        return set()
    if not any(float(s.buy_price) < 0.0 for s in slots):
        return set()
    if observed_soc_wh <= float(reserve_soc_wh) + 500.0:
        return set()
    replan_day = _prague_calendar_date(slots[0])
    out: set[int] = set()
    for t, s in enumerate(slots):
        if _prague_calendar_date(s) != replan_day:
            continue
        h = _prague_hour(s)
        if h < NIGHT_EXPORT_EVENING_START_HOUR or h > 22:
            continue
        if float(s.sell_price) < 0.0:
            continue
        out.add(t)
    return out

def _strict_late_replan_night_self_consume_indices(
    slots: list[PlanningSlot],
    *,
    evening_export_ts: set[int],
) -> set[int]:
    """Strict: noc po 22h — dům z baterie, ne drahý import (mimo večerní export sloty)."""
    out: set[int] = set()
    for t, s in enumerate(slots):
        if t in evening_export_ts:
            continue
        if not _in_night_battery_export_window(s):
            continue
        if float(s.load_baseline_w) <= 0:
            continue
        if float(s.buy_price) < 0.0:
            continue
        out.add(t)
    return out

def _degraded_relaxed_night_self_consume_indices(
    slots: list[PlanningSlot],
) -> set[int]:
    """
    relaxed_solver_masks: celé noční okno — dům z baterie (až min_soc), ne import za spot buy.
    """
    out: set[int] = set()
    for t, s in enumerate(slots):
        if not _in_night_battery_export_window(s):
            continue
        if float(s.load_baseline_w) <= 0:
            continue
        if float(s.buy_price) < 0.0:
            continue
        out.add(t)
    return out

def _degraded_relaxed_evening_export_to_reserve_indices(
    slots: list[PlanningSlot],
    *,
    observed_soc_wh: float,
    reserve_soc_wh: float,
    first_neg_buy_idx: int | None,
) -> set[int]:
    """
    Nouzový solve: večer D0 smí vývoz bat k reserve_soc před dnem s buy<0 (headroom na zítra).
    Jen kalendářní večer 17–22h — po 22h už noc (dům z baterie, ne držet kvůli exportu).
    """
    if first_neg_buy_idx is None or first_neg_buy_idx <= 0:
        return set()
    if observed_soc_wh <= float(reserve_soc_wh) + 500.0:
        return set()
    replan_day = _prague_calendar_date(slots[0])
    out: set[int] = set()
    for t, s in enumerate(slots):
        if _prague_calendar_date(s) != replan_day:
            continue
        h = _prague_hour(s)
        if h < NIGHT_EXPORT_EVENING_START_HOUR or h > 22:
            continue
        if float(s.sell_price) < 0.0:
            continue
        out.add(t)
    return out

def _post_evening_push_night_self_consume_indices(
    slots: list[PlanningSlot],
    evening_push_ts: set[int],
) -> set[int]:
    """
    Po posledním evening_push daného večera až do rána: dům z baterie, ne import za ~5 Kč.
    """
    if not evening_push_ts:
        return set()
    last_push_by_day: dict[object, int] = {}
    for t in evening_push_ts:
        last_push_by_day[_prague_calendar_date(slots[t])] = max(
            last_push_by_day.get(_prague_calendar_date(slots[t]), -1),
            t,
        )
    out: set[int] = set()
    for t, s in enumerate(slots):
        day = _prague_calendar_date(s)
        t_last = last_push_by_day.get(day)
        if t_last is None or t <= t_last:
            continue
        if t in evening_push_ts:
            continue
        if not _in_night_battery_export_window(s):
            continue
        if float(s.buy_price) <= 0.0:
            continue
        if float(s.load_baseline_w) <= 0:
            continue
        out.add(t)
    return out

def _evening_push_calendar_segments(
    slots: list[PlanningSlot],
    discharge_export_ok: set[int] | None = None,
) -> list[list[int]]:
    """Kalendářní večery (≥17h) v nočním okně — každý den vlastní push rozpočet."""
    by_date: dict[object, list[int]] = {}
    for t, s in enumerate(slots):
        if not _in_evening_push_hour_window(s):
            continue
        if not _in_night_battery_export_window(s):
            continue
        if discharge_export_ok is not None and t not in discharge_export_ok:
            continue
        by_date.setdefault(_prague_calendar_date(s), []).append(t)
    return [sorted(v) for v in by_date.values() if v]

def _primary_night_export_segment_indices(slots: list[PlanningSlot]) -> set[int]:
    """
    První noční epizoda v horizontu (17h → půlnoc → do východu FVE), která platí pro
    rozpočet Wh z aktuální SoC. Další večery v horizontu (po dni FVE / nabíjení) se
    plánují až vlastním rolling replanem — nesdílí dnešní baterii.
    """
    segs = _night_export_window_segments(slots)
    if not segs:
        return set()
    for seg in segs:
        if 0 in seg:
            return set(seg)
    return set(segs[0])

def _evening_push_soc_budget_calendar_segments(
    slots: list[PlanningSlot],
    discharge_export_ok: set[int] | None = None,
) -> list[list[int]]:
    """Kalendářní večery jen v primární noční epizodě — vhodné pro push_budget z current_soc."""
    primary = _primary_night_export_segment_indices(slots)
    if not primary:
        return []
    return [
        seg
        for seg in _evening_push_calendar_segments(slots, discharge_export_ok)
        if seg and all(t in primary for t in seg)
    ]

def _night_self_consume_discourage_import_indices(
    slots: list[PlanningSlot],
    *,
    evening_push_ts: set[int],
    charge_acquisition_czk_kwh: float,
    min_spread: float,
    purchase_fixed: bool = False,
) -> set[int]:
    """
    Noční sloty mimo evening_push: penalizace importu pro dům (preferovat bd).
    v45: celé noční okno, ne jen evening_early_export_ban subset.
    KV1: buy ≈ acq (konstantní ~6,35) — jinak prázdná množina, síť místo baterie v noci.
    """
    out: set[int] = set()
    for t, s in enumerate(slots):
        if t in evening_push_ts:
            continue
        if not _in_night_battery_export_window(s):
            continue
        if float(s.load_baseline_w) <= 0:
            continue
        buy_t = float(s.buy_price)
        if purchase_fixed:
            if buy_t >= 0.0:
                out.add(t)
            continue
        if buy_t <= float(charge_acquisition_czk_kwh) + float(min_spread):
            continue
        out.add(t)
    return out

def _evening_battery_export_push_indices(
    slots: list[PlanningSlot],
    *,
    charge_acquisition_czk_kwh: float,
    degrad_czk_kwh: float,
    current_soc_wh: float,
    min_soc_wh: float,
    soc_max_wh: float,
    per_slot_discharge_wh: float,
    discharge_slot_buffer: float,
    discharge_export_ok: set[int] | None = None,
    evening_start_hour: int = 17,
    first_neg_sell_idx: int | None = None,
    kv1_evening_push: bool = False,
    purchase_fixed: bool = False,
) -> list[int]:
    """
    Večerní push (≥17h): plný ge_bat v nejdražších slotách (sell desc), rozpočet Wh
    z aktuální SoC jen pro **primární noční epizodu** (dnešní večer → ráno).
    Zítřejší večer v horizontu se nekrade polovinou budgetu (v43 split) — nabije se
    přes den / neg okno; push přidá zítřejší rolling replan.
    per_slot_discharge_wh: min(BMS, export cap) × účinnost × 0,25 h.
    """
    _ = evening_start_hour  # kompatibilita volání
    if per_slot_discharge_wh <= 0.0:
        return []
    push_budget_wh = _evening_push_discharge_budget_wh(
        current_soc_wh=current_soc_wh,
        discharge_floor_wh=min_soc_wh,
        soc_max_wh=soc_max_wh,
        discharge_slot_buffer=discharge_slot_buffer,
    )
    if push_budget_wh < per_slot_discharge_wh * 0.5:
        return []
    evening_segments = _evening_push_soc_budget_calendar_segments(
        slots,
        discharge_export_ok=discharge_export_ok,
    )
    if not evening_segments:
        return []
    candidates: list[int] = []
    seen: set[int] = set()
    for seg in evening_segments:
        for t in _evening_push_segment_candidates(
            slots,
            seg,
            charge_acquisition_czk_kwh=charge_acquisition_czk_kwh,
            min_spread=degrad_czk_kwh,
            discharge_export_ok=discharge_export_ok,
            first_neg_sell_idx=first_neg_sell_idx,
            kv1_evening_push=kv1_evening_push,
            purchase_fixed=purchase_fixed,
        ):
            if t not in seen:
                seen.add(t)
                candidates.append(t)
    if not candidates:
        return []
    ranked = sorted(
        candidates,
        key=lambda i: (float(slots[i].sell_price), -i),
        reverse=True,
    )
    remaining_wh = float(push_budget_wh)
    out: list[int] = []
    for t in ranked:
        if remaining_wh + 1e-6 < per_slot_discharge_wh:
            break
        out.append(t)
        remaining_wh -= per_slot_discharge_wh
    return sorted(out)

def _evening_push_peak_fallback_indices(
    slots: list[PlanningSlot],
    *,
    charge_acquisition_czk_kwh: float,
    min_spread: float,
    discharge_export_ok: set[int] | None,
    first_neg_sell_idx: int | None,
    kv1_evening_push: bool,
    purchase_fixed: bool = False,
) -> set[int]:
    """Alespoň jeden večerní peak slot (sell desc), když rozpočet Wh nevybral žádný push."""
    best_t: int | None = None
    best_sell = -1.0
    for t, s in enumerate(slots):
        if discharge_export_ok is not None and t not in discharge_export_ok:
            continue
        if not _in_evening_push_hour_window(s):
            continue
        if not _slot_evening_push_profitable(
            s,
            charge_acquisition_czk_kwh=charge_acquisition_czk_kwh,
            min_spread=min_spread,
            slots=slots,
            first_neg_sell_idx=first_neg_sell_idx,
            kv1_evening_push=kv1_evening_push,
            purchase_fixed=purchase_fixed,
        ):
            continue
        sell_t = float(s.sell_price)
        if sell_t > best_sell:
            best_sell = sell_t
            best_t = t
    return {best_t} if best_t is not None else set()

def _evening_night_peak_sell_czk(slots: list[PlanningSlot]) -> float:
    sells = [
        float(s.sell_price)
        for s in slots
        if _in_night_battery_export_window(s) and float(s.sell_price) >= 0.0
    ]
    return max(sells) if sells else 0.0

def _evening_push_peak_sell_czk(slots: list[PlanningSlot], push_ts: set[int]) -> float:
    if not push_ts:
        return 0.0
    return max(float(slots[t].sell_price) for t in push_ts)

def _evening_push_ts_from_iso(slots: list[PlanningSlot], iso_slots: list[str]) -> set[int]:
    by_iso = {s.interval_start.isoformat(): t for t, s in enumerate(slots)}
    return {by_iso[iso] for iso in iso_slots if iso in by_iso}

def _evening_push_hysteresis_active(
    *,
    prev_peak_sell_czk: float | None,
    new_peak_sell_czk: float,
    prev_soc_wh: float | None,
    current_soc_wh: float,
    usable_capacity_wh: float,
) -> bool:
    if prev_peak_sell_czk is None:
        return False
    if abs(new_peak_sell_czk - float(prev_peak_sell_czk)) >= (
        EVENING_PUSH_HYSTERESIS_SELL_PEAK_DELTA_CZK_KWH
    ):
        return False
    if prev_soc_wh is not None and usable_capacity_wh > 1e-6:
        delta_pct = (
            abs(float(current_soc_wh) - float(prev_soc_wh))
            / float(usable_capacity_wh)
            * 100.0
        )
        if delta_pct >= EVENING_PUSH_HYSTERESIS_SOC_PCT:
            return False
    return True

def _evening_early_export_penalty_indices(
    slots: list[PlanningSlot],
    *,
    discharge_export_slots: set[int],
    evening_push_ts: set[int],
    exempt_ts: set[int] | None = None,
) -> set[int]:
    """
    ge_bat=0 v nočním okně mimo tvrdý evening_push (a mimo pre-neg / neg-evening větve).
    """
    exempt = exempt_ts or set()
    out: set[int] = set()
    for t_ev, s_ev in enumerate(slots):
        if not _in_night_battery_export_window(s_ev):
            continue
        if t_ev not in discharge_export_slots:
            continue
        if t_ev in evening_push_ts or t_ev in exempt:
            continue
        out.add(t_ev)
    return out

def _last_non_negative_sell_before_neg_buy(
    slots: list[PlanningSlot],
    first_neg_buy_idx: int | None,
) -> int | None:
    if first_neg_buy_idx is None or first_neg_buy_idx <= 0:
        return None
    candidates = [
        i for i in range(first_neg_buy_idx) if float(slots[i].sell_price) >= 0.0
    ]
    return max(candidates) if candidates else None

def _positive_sell_pre_neg_buy_indices(
    slots: list[PlanningSlot],
    first_neg_buy_idx: int | None,
) -> list[int]:
    if first_neg_buy_idx is None or first_neg_buy_idx <= 0:
        return []
    return [
        t
        for t in range(first_neg_buy_idx)
        if float(slots[t].sell_price) >= 0.0
    ]

def _pre_neg_buy_empty_discharge_indices(
    slots: list[PlanningSlot],
    first_neg_buy_idx: int | None,
    last_pos_sell_idx: int | None,
) -> list[int]:
    """Sloty mezi posledním sell≥0 a prvním buy<0 — vyprázdnit před levným importem."""
    if first_neg_buy_idx is None or first_neg_buy_idx <= 0:
        return []
    if last_pos_sell_idx is None:
        return []
    start = last_pos_sell_idx + 1
    end = first_neg_buy_idx - 1
    if start > end:
        return []
    return list(range(start, end + 1))

def _pre_neg_buy_soc_ceiling_wh(
    slots: list[PlanningSlot],
    *,
    first_neg_buy_idx: int | None,
    min_soc_wh: float,
    soc_max_wh: float,
    max_charge_w: float,
    charge_eff: float,
    evening_start_hour: int = 17,
) -> float | None:
    """
    Horní SoC těsně před prvním buy<0: pod soc_max musí vejít import v buy<0,
    PV B v tom okně a rezerva na odpolední sell<0 (stejný den, před večerem).
    """
    if first_neg_buy_idx is None or first_neg_buy_idx <= 0:
        return None
    per_slot_chg = max(0.0, float(max_charge_w) * float(charge_eff) * INTERVAL_H)
    neg_buy_ts = [t for t, s in enumerate(slots) if float(s.buy_price) < 0.0]
    if not neg_buy_ts:
        return None
    last_neg_buy = max(neg_buy_ts)
    neg_day = _prague_calendar_date(slots[first_neg_buy_idx])
    grid_wh = len(neg_buy_ts) * per_slot_chg
    pv_b_wh = 0.0
    for t in neg_buy_ts:
        s = slots[t]
        sur = max(
            0.0,
            float(s.pv_a_forecast_w) + float(s.pv_b_forecast_w) - float(s.load_baseline_w),
        )
        pv_b_wh += min(sur, float(max_charge_w)) * float(charge_eff) * INTERVAL_H
    post_wh = 0.0
    for t in range(last_neg_buy + 1, len(slots)):
        s = slots[t]
        if _prague_calendar_date(s) != neg_day:
            continue
        if float(s.buy_price) < 0.0:
            continue
        if float(s.sell_price) >= 0.0:
            break
        if _prague_hour(s) >= evening_start_hour:
            break
        sur = max(0.0, float(s.pv_b_forecast_w) - float(s.load_baseline_w) * 0.25)
        post_wh += min(sur, float(max_charge_w)) * float(charge_eff) * INTERVAL_H
    buffer_wh = max(per_slot_chg * 2.0, 3000.0)
    needed = grid_wh + pv_b_wh + post_wh + buffer_wh
    ceiling = float(soc_max_wh) - needed
    floor = float(min_soc_wh) + max(per_slot_chg, 1000.0)
    return max(floor, min(float(soc_max_wh) - per_slot_chg, ceiling))

def _planner_soc_for_solver(
    current_soc_wh: float,
    battery,
) -> tuple[float, float | None]:
    """
    SoC pro MILP. Při telemetrii na soc_max a dlouhém sell<0 s vysokou FVE bez rezervy pod stropem
    je model neřešitelný (nelze nabít / odvést přebytek). Necháme min. ~650 Wh pod soc_max.
    """
    soc_max = float(battery.soc_max_wh)
    soc_min = float(battery.min_soc_wh)
    soc = max(soc_min, min(float(current_soc_wh), soc_max))
    charge_slot_wh = (
        float(battery.max_charge_power_w)
        * INTERVAL_H
        / max(float(battery.charge_efficiency), 1e-6)
    )
    headroom = max(650.0, 0.382 * charge_slot_wh)
    if soc > soc_max - headroom:
        return max(soc_min, soc_max - headroom), headroom
    return soc, None

def _pv_forced_vent_export_allowed(
    t: int,
    *,
    current_soc_wh: float,
    battery,
    soc_headroom_wh: float,
    pv_surplus_w: float,
) -> bool:
    """Přebytek FVE do sítě jen když baterie na konci předchozího slotu nemá kapacitu."""
    if pv_surplus_w <= 0:
        return False
    if t == 0:
        return current_soc_wh >= float(battery.soc_max_wh) - soc_headroom_wh
    return False

def _relax_solver_slot_masks(slots: list[PlanningSlot]) -> list[PlanningSlot]:
    """Nouzově permissivní allow_* — SQL masky nesmí učinit LP neřešitelným."""
    return [
        replace(
            s,
            allow_charge=True,
            allow_discharge_export=float(s.sell_price) >= 0.0,
        )
        for s in slots
    ]

def _unlock_late_replan_evening_slots(
    slots: list[PlanningSlot],
    *,
    current_soc_wh: float,
    reserve_soc_wh: float,
) -> None:
    """Pozdní replan: večer D0 povolit grid import + export (SQL allow_charge často false)."""
    if not slots or current_soc_wh <= float(reserve_soc_wh) + 500.0:
        return
    if not any(float(s.buy_price) < 0.0 for s in slots):
        return
    replan_day = _prague_calendar_date(slots[0])
    unlocked = 0
    for i, s in enumerate(slots):
        if _prague_calendar_date(s) != replan_day:
            continue
        if float(s.sell_price) < 0.0:
            continue
        if not _in_evening_push_hour_window(s):
            continue
        if s.allow_charge and s.allow_discharge_export:
            continue
        slots[i] = replace(s, allow_charge=True, allow_discharge_export=True)
        unlocked += 1
    if unlocked:
        logger.info(
            "Late replan: unlocked evening slot masks on %d slot(s) (soc=%.0f Wh)",
            unlocked,
            float(current_soc_wh),
        )

def _evening_push_override_for_solve(
    evening_push_ts_override: Optional[set[int]],
    *,
    relaxed_expensive_import: bool,
    relaxed_neg_buy_charge: bool,
    relaxed_neg_prep_hold_only: bool,
    relaxed_neg_prep_window: bool,
    neg_sell_phases_fallback: bool,
    relaxed_pos_sell_ge_block: bool = False,
    relaxed_solver_masks: bool = False,
) -> Optional[set[int]]:
    """Po Infeasible nesmí retry držet hysterézní push z minulého běhu."""
    if evening_push_ts_override is None:
        return None
    if (
        relaxed_expensive_import
        or relaxed_neg_buy_charge
        or relaxed_neg_prep_hold_only
        or relaxed_neg_prep_window
        or neg_sell_phases_fallback
        or relaxed_pos_sell_ge_block
        or relaxed_solver_masks
    ):
        return None
    return set(evening_push_ts_override)

def _filter_evening_push_override_indices(
    slots: list[PlanningSlot],
    override_ts: set[int],
    *,
    battery: Any,
    grid: Any,
    discharge_export_ok: set[int] | None,
) -> set[int]:
    """Hysterézní push jen na sloty, kde dnes smí a dává smysl tvrdý ge_bat push."""
    out: set[int] = set()
    for t in override_ts:
        if t < 0 or t >= len(slots):
            continue
        if discharge_export_ok is not None and t not in discharge_export_ok:
            continue
        if _battery_export_push_defer_to_pv(slots[t]):
            continue
        push_floor_w = _evening_push_battery_export_w(slots[t], battery, grid)
        if push_floor_w < GE_MIN_EXPORT_W:
            continue
        out.add(t)
    return out
