# backend/services/planning_engine.py
#
# EMS Platform – plánovací engine
# Obsahuje: hlavní denní plán + rolling 15min replan
#
# Spouštění (APScheduler v lifespan.py):
#   scheduler.add_job(run_daily_plan,   'cron', hour=15, minute=0)
#   scheduler.add_job(run_rolling_replan, 'cron', minute='*/15')
# Horizont: ems.fn_planning_horizon_end (OTE + strop/min v SQL).

import json
import logging
import time
from dataclasses import dataclass, replace
from datetime import datetime, timezone, timedelta
from types import SimpleNamespace
from typing import Any, Optional
from zoneinfo import ZoneInfo

import pulp

from app.config import get_settings

logger = logging.getLogger(__name__)


from services.planning.solver_v2 import solve_dispatch_v2
from services.planning.types import (
    PlannerSolverError,
    _timestamptz_from_db,
    _slot_float_nullable,
    _prague_dow_hour,
    PlanningSlot,
    SOC_MIN_RELAX_LOOKAHEAD_SLOTS,
    DispatchResult,
    _prague_calendar_date,
    _prague_hour,
    _parse_json_dt,
    _current_slot_start,
)
from services.planning.forecast import (
    compute_correction_factor,
    apply_forecast_correction,
)
from services.planning.db_io import (
    _ev_session_from_json,
    _load_site_context,
    _load_previous_plan_charge_commitment_prev_w,
    _load_slots,
    _build_slot_inputs,
    _save_planning_run,
    _save_failed_planning_run,
)
from services.planning.heuristics import (
    _pv_scarcity_penalty_multiplier,
    _pv_coverage_ratio,
    _dynamic_arb_floor_wh_series,
    _soc_security_profile,
    _soc_min_wh_series,
    _slots_until_buy_le_threshold,
    _slots_until_sell_lt,
    _prewindow_deferral_slots,
    _soc_panel_min_wh_series,
    _recompute_charge_acquisition_from_results,
    _slots_with_charge_acquisition,
    _pv_store_value_czk_kwh,
    _slot_profitable_battery_export,
    _purchase_pricing_fixed,
    _horizon_fixed_tariff_like,
    _future_extreme_buy_from,
    _neg_sell_bat_dump_slots,
    _slots_until_buy_le,
    _pre_negative_sell_export_window,
    _neg_sell_phases_enabled,
    _neg_sell_indices_by_prague_day,
    _neg_sell_t_detach_index,
    _neg_sell_pv_b_charge_wh,
    _neg_sell_pv_forecast_charge_wh,
    _neg_sell_day_pv_b_usable_wh,
    _neg_sell_e_surplus_after_t_wh,
    _neg_sell_day_phases,
    _neg_sell_day_pv_usable_wh,
    _pre_neg_pv_export_forecast_cushion_ok_for_day,
    _pre_neg_pv_export_forecast_cushion_ok,
    _pre_neg_pv_export_slot_indices_for_day,
    _pre_neg_pv_export_bundle,
    _pre_neg_pv_export_slot_indices,
    _discharge_before_first_neg_sell_ts,
    _evening_discharge_before_neg_day_ts,
    _night_baseload_buffer_wh_from_slots,
    _neg_evening_discharge_budget_wh,
    _first_neg_sell_idx_on_prague_day,
    _terminal_neg_buy_weight,
    _future_neg_buy_discharge_enabled,
    _pos_sell_pre_neg_buy_evening_export_exempt_ts,
    _neg_evening_before_neg_push_indices,
    _neg_evening_reserve_soc_anchors,
    _battery_export_cap_w,
    _evening_push_battery_export_w,
    _dispatch_grid_setpoint_w,
    _morning_pre_neg_zone_peak_sell,
    _pre_neg_peak_sell_idx,
    _morning_pre_neg_export_indices,
    _pre_neg_buy_discharge_indices,
    _slot_pv_surplus_w,
    _battery_export_push_defer_to_pv,
    _in_night_battery_export_window,
    _in_evening_push_hour_window,
    _night_export_window_segments,
    _night_peak_sell_czk_kwh,
    _evening_peak_export_indices,
    _planner_discharge_floor_wh,
    _evening_push_discharge_budget_wh,
    _kv1_block_export_fixed_evening_push,
    _slot_evening_push_profitable,
    _evening_push_segment_candidates,
    _strict_late_replan_evening_slot_indices,
    _strict_late_replan_night_self_consume_indices,
    _degraded_relaxed_night_self_consume_indices,
    _degraded_relaxed_evening_export_to_reserve_indices,
    _post_evening_push_night_self_consume_indices,
    _evening_push_calendar_segments,
    _primary_night_export_segment_indices,
    _evening_push_soc_budget_calendar_segments,
    _night_self_consume_discourage_import_indices,
    _evening_battery_export_push_indices,
    _evening_push_peak_fallback_indices,
    _evening_night_peak_sell_czk,
    _evening_push_peak_sell_czk,
    _evening_push_ts_from_iso,
    _evening_push_hysteresis_active,
    _evening_early_export_penalty_indices,
    _last_non_negative_sell_before_neg_buy,
    _positive_sell_pre_neg_buy_indices,
    _pre_neg_buy_empty_discharge_indices,
    _pre_neg_buy_soc_ceiling_wh,
    _planner_soc_for_solver,
    _pv_forced_vent_export_allowed,
    _relax_solver_slot_masks,
    _unlock_late_replan_evening_slots,
    _evening_push_override_for_solve,
    _filter_evening_push_override_indices,
)
from services.planning.constants import (
    MORNING_PRENEG_START_HOUR,
    MORNING_PRENEG_END_HOUR,
    ACQUISITION_TWO_PASS_EPS_KWH,
    SOLVER_RELAX_STEPS,
    ARB_FLOOR_E_REF_FRAC,
    ARB_LOOKAHEAD_SLOTS,
    CORRECTION_DECAY_SLOTS,
    CORRECTION_MAX_CLAMP,
    CORRECTION_MIN_CLAMP,
    CORRECTION_WINDOW_H,
    CURTAILMENT_PENALTY,
    DAWN_LOW_PV_NO_CURTAIL_W,
    DEFAULT_PLANNER_DISCHARGE_RELAX_PREWINDOW_SLOTS,
    EVENING_PEAK_SELL_EPS_CZK_KWH,
    EVENING_PUSH_HYSTERESIS_SELL_PEAK_DELTA_CZK_KWH,
    EVENING_PUSH_HYSTERESIS_SOC_PCT,
    EVENING_PUSH_Z_EXPORT_BONUS_CZK,
    EXTREME_BUY_DUMP_PREWINDOW_SLOTS,
    GE_MIN_EXPORT_W,
    INTERVAL_H,
    LOAD_FIRST_INCENTIVE_CZK_KWH,
    NEG_BUY_CHARGE_SHORTFALL_PENALTY_CZK_KWH,
    NEG_EVENING_PREP_DISCHARGE_SHORTFALL_PENALTY_CZK_KWH,
    NEG_EVENING_RESERVE_SOC_MAX_SLACK_WH,
    NEG_EVENING_RESERVE_SOC_SLACK_PENALTY_CZK_PER_WH,
    NEG_SELL_BAT_DUMP_SHORTFALL_PENALTY_CZK_KWH,
    NEG_SELL_CURTAIL_PENALTY_CZK_KWH,
    NEG_SELL_POST_DETACH_BCPV_DISCOURAGE_CZK_KWH,
    NEG_SELL_PREP_HOLD_BCPV_PENALTY_CZK_KWH,
    NEG_SELL_PREP_SOC_SHORTFALL_PENALTY_CZK_PER_WH,
    NEG_SELL_PV_B_VENT_PENALTY_CZK_KWH,
    NEG_SELL_PV_CHARGE_REWARD_CZK_KWH,
    NEG_SELL_SOC_UNDERFILL_PENALTY_CZK_PER_WH,
    NIGHT_EXPORT_EVENING_START_HOUR,
    NIGHT_EXPORT_MORNING_END_HOUR,
    NIGHT_EXPORT_PV_SUNRISE_SURPLUS_W,
    NIGHT_SELF_CONSUME_IMPORT_SURCHARGE_CZK_KWH,
    PEAK_EXPORT_SHORTFALL_PENALTY_CZK_KWH,
    PLANNER_BUILD_TAG,
    POS_SELL_PRE_NEG_SOC_SHORTFALL_PENALTY_CZK_PER_WH,
    PRENEG_SELL_SOC_ANCHOR_SLACK_PENALTY_CZK_PER_WH,
    PRE_NEG_BATT_EXPORT_MIN_SELL_CZK_KWH,
    PRE_NEG_BATT_EXPORT_SHORTFALL_PENALTY_CZK_KWH,
    PRE_NEG_BUY_EMPTY_EXPORT_SHORTFALL_PENALTY_CZK_KWH,
    PRE_NEG_BUY_PV_CHARGE_PENALTY_CZK_KWH,
    PRE_NEG_BUY_SOC_CEILING_SLACK_PENALTY_CZK_PER_WH,
    PRE_NEG_CHARGE_PENALTY_CZK_KWH,
    PRE_NEG_PV_BCPV_DISCOURAGE_CZK_KWH,
    PRE_NEG_PV_EXPORT_FORECAST_MARGIN,
    PRE_NEG_PV_EXPORT_MIN_NEEDED_WH,
    PRE_NEG_PV_EXPORT_SHORTFALL_PENALTY_CZK_KWH,
    PV_CHARGE_SHORTFALL_PENALTY_CZK_KWH,
    SOLVER_TIME_LIMIT,
    TERMINAL_NEG_BUY_MAGNITUDE_FLOOR,
    TERMINAL_NEG_BUY_MAGNITUDE_REF_CZK,
    TERMINAL_NEG_BUY_WEIGHT_CAP,
    TERMINAL_NEG_BUY_WEIGHT_HORIZON_SLOTS,
    _DAILY_FALLBACK_HORIZON_HOURS,
    _PRAGUE_TZ,
)



def _solver_relax_chain(
    *,
    relaxed_expensive_import: bool = False,
    relaxed_neg_buy_charge: bool = False,
    relaxed_neg_prep_hold_only: bool = False,
    relaxed_neg_prep_window: bool = False,
    neg_sell_phases_fallback: bool = False,
    relaxed_pos_sell_ge_block: bool = False,
    relaxed_solver_masks: bool = False,
) -> list[str]:
    flags = {
        "relaxed_expensive_import": relaxed_expensive_import,
        "relaxed_neg_buy_charge": relaxed_neg_buy_charge,
        "relaxed_neg_prep_hold_only": relaxed_neg_prep_hold_only,
        "relaxed_neg_prep_window": relaxed_neg_prep_window,
        "neg_sell_phases_fallback": neg_sell_phases_fallback,
        "relaxed_pos_sell_ge_block": relaxed_pos_sell_ge_block,
        "relaxed_solver_masks": relaxed_solver_masks,
    }
    chain = [SOLVER_RELAX_STEPS[0]]
    for step in SOLVER_RELAX_STEPS[1:]:
        if flags.get(step, False):
            chain.append(step)
    return chain




def _planner_engine_version(explicit: str | None = None) -> str:
    if explicit is not None and str(explicit).strip():
        return str(explicit).strip().lower()
    return str(get_settings().planning_engine_version or "v1").strip().lower()


def _planner_compare_enabled() -> bool:
    return bool(get_settings().planning_engine_compare_enabled)


def _solve_dispatch_for_version(
    version: str,
    slots: list["PlanningSlot"],
    battery,
    heat_pump,
    grid,
    ev_sessions: list,
    vehicles: list,
    current_soc_wh: float,
    current_tuv_temp_c: float,
    *,
    tuv_delta_stats: Optional[dict[tuple[int, int], float]] = None,
    operating_mode: str = "AUTO",
    charge_commitment_prev_w: Optional[list[Optional[float]]] = None,
    evening_push_ts_override: Optional[set[int]] = None,
) -> tuple[list["DispatchResult"], int, dict[str, Any]]:
    """
    Router verzí plánovače: "v2" = čisté ekonomické jádro (services.planning.solver_v2,
    bez heuristických penalt; commitment/evening_push override nemá — koncepty v1),
    jinak v1 two-pass. Chybu v2 balí do PlannerSolverError kvůli failure pipeline.
    """
    if str(version).strip().lower() == "v2":
        try:
            return solve_dispatch_v2(
                slots,
                battery,
                heat_pump,
                grid,
                ev_sessions,
                vehicles,
                current_soc_wh,
                current_tuv_temp_c,
                tuv_delta_stats=tuv_delta_stats,
                operating_mode=operating_mode,
                planner_version="v2",
            )
        except PlannerSolverError:
            raise
        except RuntimeError as exc:
            raise PlannerSolverError(f"v2: {exc}", relax_chain=["v2"]) from exc
    return solve_dispatch_two_pass(
        slots,
        battery,
        heat_pump,
        grid,
        ev_sessions,
        vehicles,
        current_soc_wh,
        current_tuv_temp_c,
        tuv_delta_stats=tuv_delta_stats,
        operating_mode=operating_mode,
        charge_commitment_prev_w=charge_commitment_prev_w,
        planner_version=version,
        evening_push_ts_override=evening_push_ts_override,
    )


def _planner_peer_version(version: str) -> str:
    v = str(version).strip().lower()
    if v == "v1":
        return "v2"
    if v == "v2":
        return "v1"
    return "v1"


def _dispatch_result_summary(results: list["DispatchResult"], duration_ms: int, version: str) -> dict[str, Any]:
    charge_slots = [r.interval_start.isoformat() for r in results if r.battery_setpoint_w > 500]
    discharge_slots = [r.interval_start.isoformat() for r in results if r.battery_setpoint_w < -500]
    export_slots = [r.interval_start.isoformat() for r in results if r.grid_setpoint_w < 0]
    return {
        "planner_version": version,
        "solver_duration_ms": int(duration_ms),
        "total_expected_cost_czk": round(sum(float(r.expected_cost_czk) for r in results), 4),
        "charge_slots": len(charge_slots),
        "discharge_slots": len(discharge_slots),
        "export_slots": len(export_slots),
        "first_charge_slot": charge_slots[0] if charge_slots else None,
        "first_discharge_slot": discharge_slots[0] if discharge_slots else None,
        "first_export_slot": export_slots[0] if export_slots else None,
    }


def _dispatch_result_comparison(
    active_results: list["DispatchResult"],
    active_ms: int,
    active_version: str,
    peer_results: list["DispatchResult"],
    peer_ms: int,
    peer_version: str,
) -> dict[str, Any]:
    active_summary = _dispatch_result_summary(active_results, active_ms, active_version)
    peer_summary = _dispatch_result_summary(peer_results, peer_ms, peer_version)
    slot_rows: list[dict[str, Any]] = []
    for a, b in zip(active_results, peer_results):
        row = {
            "interval_start": a.interval_start.isoformat(),
            "active": {
                "battery_setpoint_w": a.battery_setpoint_w,
                "grid_setpoint_w": a.grid_setpoint_w,
                "export_mode": a.export_mode,
                "deye_physical_mode": a.deye_physical_mode,
                "deye_gen_cutoff_enabled": a.deye_gen_cutoff_enabled,
                "pv_a_curtailed_w": a.pv_a_curtailed_w,
                "battery_soc_target": a.battery_soc_target,
                "expected_cost_czk": a.expected_cost_czk,
            },
            "peer": {
                "battery_setpoint_w": b.battery_setpoint_w,
                "grid_setpoint_w": b.grid_setpoint_w,
                "export_mode": b.export_mode,
                "deye_physical_mode": b.deye_physical_mode,
                "deye_gen_cutoff_enabled": b.deye_gen_cutoff_enabled,
                "pv_a_curtailed_w": b.pv_a_curtailed_w,
                "battery_soc_target": b.battery_soc_target,
                "expected_cost_czk": b.expected_cost_czk,
            },
        }
        if row["active"] != row["peer"]:
            slot_rows.append(row)

    total_cost_diff = round(
        float(active_summary["total_expected_cost_czk"]) - float(peer_summary["total_expected_cost_czk"]),
        4,
    )
    return {
        "compare_enabled": True,
        "active": active_summary,
        "peer": peer_summary,
        "diff": {
            "total_expected_cost_czk": total_cost_diff,
            "absolute_total_expected_cost_czk": round(abs(total_cost_diff), 4),
            "changed_slots": len(slot_rows),
        },
        "slot_diffs": slot_rows,
    }


def _maybe_add_planner_comparison(
    *,
    slots: list["PlanningSlot"],
    battery,
    heat_pump,
    grid,
    ev_sessions: list,
    vehicles: list,
    current_soc_wh: float,
    current_tuv_temp_c: float,
    operating_mode: str,
    tuv_delta_stats: Optional[dict[tuple[int, int], float]],
    active_version: str,
    charge_commitment_prev_w: Optional[list[Optional[float]]] = None,
) -> dict[str, Any] | None:
    if not _planner_compare_enabled():
        return None
    peer_version = _planner_peer_version(active_version)
    if peer_version == active_version:
        return None
    try:
        peer_results, peer_ms, peer_snapshot = _solve_dispatch_for_version(
            peer_version,
            slots,
            battery,
            heat_pump,
            grid,
            ev_sessions,
            vehicles,
            current_soc_wh,
            current_tuv_temp_c,
            tuv_delta_stats=tuv_delta_stats,
            operating_mode=operating_mode,
            charge_commitment_prev_w=charge_commitment_prev_w,
            evening_push_ts_override=None,
        )
    except RuntimeError as exc:
        logger.warning(
            "Planner comparison peer (%s) failed, skipping compare run: %s",
            peer_version,
            exc,
        )
        return None
    # active_results / active_ms jsou doplněny později v calleru
    return {
        "peer_version": peer_version,
        "peer_results": peer_results,
        "peer_ms": peer_ms,
        "peer_snapshot": peer_snapshot,
    }


async def _planning_horizon_end(site_id: int, horizon_from: datetime, db) -> Optional[datetime]:
    """Konec horizontu z DB (`fn_planning_horizon_end`); NULL = rolling skip / daily fallback."""
    raw = await db.fetchval(
        "select ems.fn_planning_horizon_end($1::int, $2::timestamptz)",
        site_id,
        horizon_from,
    )
    return _timestamptz_from_db(raw)














# ============================================================
# Datové třídy (lze nahradit pydantic modely)
# ============================================================



# Lookahead pro relax spodní meze SoC: až 36 h od indexu slotu (pevné OTE ceny v horizontu).














# ============================================================
# Korekce forecastu na základě skutečné výroby
# ============================================================





# ============================================================
# LP Solver
# ============================================================

































































































































































def _solve_dispatch_relax_carryover(snap: dict[str, Any]) -> dict[str, Any]:
    """Pass2 two-pass: přenést nouzové relax flagy z pass1, ať pass2 nezačne od strict."""
    inp = snap.get("inputs")
    if not isinstance(inp, dict):
        return {}
    out: dict[str, Any] = {}
    for key in (
        "relaxed_expensive_import",
        "relaxed_neg_buy_charge",
        "relaxed_neg_prep_hold_only",
        "relaxed_neg_prep_window",
        "neg_sell_phases_fallback",
        "relaxed_pos_sell_ge_block",
        "relaxed_solver_masks",
    ):
        if inp.get(key):
            out[key] = True
    return out


def solve_dispatch_two_pass(
    slots: list[PlanningSlot],
    battery,
    heat_pump,
    grid,
    ev_sessions: list,
    vehicles: list,
    current_soc_wh: float,
    current_tuv_temp_c: float,
    *,
    tuv_delta_stats: Optional[dict[tuple[int, int], float]] = None,
    operating_mode: str = "AUTO",
    charge_commitment_prev_w: Optional[list[Optional[float]]] = None,
    planner_version: str | None = None,
    evening_push_ts_override: Optional[set[int]] = None,
) -> tuple[list["DispatchResult"], int, dict[str, Any]]:
    """
    Dva průchody solve_dispatch: pass2 používá acquisition z váženého buy nabíjení v pass1.
    """
    results1, ms1, snap1 = solve_dispatch(
        slots,
        battery,
        heat_pump,
        grid,
        ev_sessions,
        vehicles,
        current_soc_wh,
        current_tuv_temp_c,
        tuv_delta_stats=tuv_delta_stats,
        operating_mode=operating_mode,
        charge_commitment_prev_w=charge_commitment_prev_w,
        planner_version=planner_version,
        evening_push_ts_override=evening_push_ts_override,
    )
    acq1 = float(
        snap1.get("inputs", {}).get("charge_acquisition_buy_czk_kwh")
        or getattr(slots[0], "charge_acquisition_buy_czk_kwh", None)
        or min(float(s.buy_price) for s in slots)
    )
    acq2 = _recompute_charge_acquisition_from_results(slots, results1, battery)
    converged = abs(acq2 - acq1) < ACQUISITION_TWO_PASS_EPS_KWH
    if converged:
        if isinstance(snap1.get("inputs"), dict):
            snap1["inputs"]["acquisition_pass1_czk_kwh"] = round(acq1, 6)
            snap1["inputs"]["acquisition_pass2_czk_kwh"] = round(acq2, 6)
            snap1["inputs"]["two_pass_enabled"] = True
            snap1["inputs"]["two_pass_converged"] = True
            snap1["inputs"]["two_pass_skipped"] = False
        return results1, ms1, snap1

    slots2 = _slots_with_charge_acquisition(slots, acq2)
    relax_carry = _solve_dispatch_relax_carryover(snap1)
    try:
        results2, ms2, snap2 = solve_dispatch(
            slots2,
            battery,
            heat_pump,
            grid,
            ev_sessions,
            vehicles,
            current_soc_wh,
            current_tuv_temp_c,
            tuv_delta_stats=tuv_delta_stats,
            operating_mode=operating_mode,
            charge_commitment_prev_w=charge_commitment_prev_w,
            planner_version=planner_version,
            evening_push_ts_override=None,
            **relax_carry,
        )
    except (RuntimeError, PlannerSolverError) as exc:
        infeasible = isinstance(exc, PlannerSolverError) or "Infeasible" in str(exc)
        if infeasible:
            logger.warning(
                "two_pass pass2 Infeasible (%s), using pass1 solution",
                exc,
            )
            if isinstance(snap1.get("inputs"), dict):
                snap1["inputs"]["two_pass_pass2_infeasible_used_pass1"] = True
            return results1, ms1, snap1
        raise
    if isinstance(snap2.get("inputs"), dict):
        snap2["inputs"]["acquisition_pass1_czk_kwh"] = round(acq1, 6)
        snap2["inputs"]["acquisition_pass2_czk_kwh"] = round(acq2, 6)
        snap2["inputs"]["two_pass_enabled"] = True
        snap2["inputs"]["two_pass_converged"] = False
        snap2["inputs"]["two_pass_skipped"] = False
        snap2["inputs"]["solver_duration_ms_pass1"] = ms1
    return results2, ms1 + ms2, snap2






def solve_dispatch(
    slots: list[PlanningSlot],
    battery,
    heat_pump,
    grid,
    ev_sessions: list,          # aktivní EV sessions [ev1_session, ev2_session]
    vehicles: list,             # [vehicle1, vehicle2]
    current_soc_wh: float,
    current_tuv_temp_c: float,
    *,
    tuv_delta_stats: Optional[dict[tuple[int, int], float]] = None,
    operating_mode: str = "AUTO",
    charge_commitment_prev_w: Optional[list[Optional[float]]] = None,
    planner_version: str | None = None,
    relaxed_expensive_import: bool = False,
    relaxed_neg_buy_charge: bool = False,
    relaxed_neg_prep_hold_only: bool = False,
    relaxed_neg_prep_window: bool = False,
    neg_sell_phases_fallback: bool = False,
    relaxed_pos_sell_ge_block: bool = False,
    relaxed_solver_masks: bool = False,
    evening_push_ts_override: Optional[set[int]] = None,
) -> tuple[list[DispatchResult], int, dict[str, Any]]:
    """
    LP solver pro dispatch optimalizaci.
    Vrátí (výsledky, solver_duration_ms, solver_debug_snapshot).
    relaxed_expensive_import: nouzový režim po Infeasible — síť smí krmit baseload v drahých slotech.
    relaxed_neg_buy_charge: druhý nouzový retry bez neg_buy charge shortfall.
    relaxed_neg_prep_hold_only: třetí retry — bez prep_soc_shortfall a prep hold binárek (evening push zůstává).
    relaxed_neg_prep_window: čtvrtý retry — vypne strict pre-neg bundle; future_neg_buy večerní export zůstává.
    relaxed_pos_sell_ge_block: retry — neaplikovat ge=0 v pos_sell před buy<0.
    relaxed_solver_masks: poslední retry — permissivní SQL masky + vypnutí tvrdých neg-večer větví.
    """
    T = len(slots)
    if T < 1:
        raise RuntimeError("solve_dispatch requires at least one slot")
    if relaxed_solver_masks or relaxed_pos_sell_ge_block:
        slots = _relax_solver_slot_masks(slots)
    any_relaxed = (
        relaxed_expensive_import
        or relaxed_neg_buy_charge
        or relaxed_neg_prep_window
        or neg_sell_phases_fallback
        or relaxed_pos_sell_ge_block
        or relaxed_solver_masks
    )
    prep_hold_relaxed = relaxed_neg_prep_hold_only or relaxed_neg_prep_window
    late_replan_strict_active = False
    strict_late_replan_evening_ts: set[int] = set()
    strict_late_replan_night_ts: set[int] = set()
    EV = len(vehicles)  # počet EV (typicky 2)
    planner_version_resolved = _planner_engine_version(planner_version)
    planner_v2 = planner_version_resolved == "v2"

    EV_ROUNDTRIP_FACTOR = 1.0 / (battery.charge_efficiency * battery.discharge_efficiency)
    cycle_penalty_mult = _pv_scarcity_penalty_multiplier(slots, battery)
    degradation_cost_effective = battery.degradation_cost_czk_kwh * cycle_penalty_mult
    soc_buffer_target_wh, soc_deficit_penalty_czk_kwh = _soc_security_profile(slots, battery)

    prob = pulp.LpProblem("ems_dispatch", pulp.LpMinimize)

    # Penalizace překročení breakeru (Kč/kWh importu nad max_import_power_w).
    # Záměr: breaker je fyzický strop, ale kvůli chybám forecastu a krátkým „extrémním“ oknům
    # (např. záporná nákupní cena) umožníme solveru nominálně jít nad breaker, ovšem pouze za cenu.
    IMPORT_OVER_BREAKER_PENALTY_CZK_KWH = 10.0

    min_soc_wh = float(getattr(battery, "min_soc_wh", battery.reserve_soc_wh))
    buy_extreme_thr = float(getattr(battery, "planner_extreme_buy_threshold_czk_kwh", -5.0))
    floor_pct_raw = getattr(battery, "planner_discharge_floor_percent", None)
    floor_pct = float(floor_pct_raw) if floor_pct_raw is not None else None
    prewin = max(
        0,
        int(
            getattr(
                battery,
                "planner_discharge_relax_prewindow_slots",
                DEFAULT_PLANNER_DISCHARGE_RELAX_PREWINDOW_SLOTS,
            )
        ),
    )
    # Planner floor v Wh (nezávisle na lookahead extrémním buy) – použije se pro kotvu před sell<0.
    abs_min_wh = max(float(battery.usable_capacity_wh) * 0.05, 1.0)
    planner_floor_wh = (
        min_soc_wh
        if floor_pct is None
        else max(abs_min_wh, float(floor_pct) / 100.0 * float(battery.usable_capacity_wh))
    )
    planner_floor_effective_wh = min(min_soc_wh, float(planner_floor_wh))
    soc_min_series = _soc_min_wh_series(
        slots,
        float(battery.usable_capacity_wh),
        min_soc_wh,
        buy_extreme_thr,
        floor_pct,
    )
    # Pokud se blíží první sell<0, dovol hluboký planner floor i bez extrémního buy.
    # Záměr: „dovylít“ baterii před záporným prodejem a pak už baterii v sell<0 okně nevybíjet.
    if floor_pct is not None:
        dist_to_neg_sell = _slots_until_sell_lt(slots, 0.0)
        soc_min_series = [
            min(float(sm), float(planner_floor_effective_wh))
            if dist_to_neg_sell[i] <= prewin
            else float(sm)
            for i, sm in enumerate(soc_min_series)
        ]
    observed_soc_wh = max(
        float(battery.min_soc_wh),
        min(float(current_soc_wh), float(battery.soc_max_wh)),
    )
    soc_headroom_applied_wh: float | None = None
    current_soc_wh, soc_headroom_applied_wh = _planner_soc_for_solver(
        current_soc_wh, battery
    )
    current_soc_wh = max(soc_min_series[0], min(current_soc_wh, float(battery.soc_max_wh)))
    arb_base_wh = max(
        float(getattr(battery, "arb_floor_wh", battery.reserve_soc_wh)),
        min_soc_wh,
    )
    if getattr(battery, "disable_dynamic_arb_floor", False):
        arb_floor_series = [arb_base_wh] * T
    else:
        arb_floor_series = _dynamic_arb_floor_wh_series(
            slots, min_soc_wh, arb_base_wh, float(battery.usable_capacity_wh)
        )
    deferral_slots = _prewindow_deferral_slots(slots, buy_extreme_thr)
    soc_panel_min = _soc_panel_min_wh_series(
        soc_min_series,
        deferral_slots,
        min_soc_wh,
        arb_base_wh,
        prewin,
    )

    # --- Proměnné ---
    # Import ze sítě: tvrdý strop = site breaker (max_import_power_w).
    gi_upper = float(grid.max_import_power_w)
    gi  = [pulp.LpVariable(f"gi_{t}",  0, gi_upper)                        for t in range(T)]
    gi_over = [
        pulp.LpVariable(f"gi_over_{t}", 0, max(0.0, gi_upper - float(grid.max_import_power_w)))
        for t in range(T)
    ]
    ge  = [pulp.LpVariable(f"ge_{t}",  0, grid.max_export_power_w)         for t in range(T)]
    ge_pv = [pulp.LpVariable(f"ge_pv_{t}", 0, grid.max_export_power_w)     for t in range(T)]
    ge_bat = [pulp.LpVariable(f"ge_bat_{t}", 0, grid.max_export_power_w)   for t in range(T)]
    bc_pv = [pulp.LpVariable(f"bc_pv_{t}", 0, battery.max_charge_power_w) for t in range(T)]
    bc_gi = [pulp.LpVariable(f"bc_gi_{t}", 0, battery.max_charge_power_w) for t in range(T)]
    bd  = [pulp.LpVariable(f"bd_{t}",  0, battery.max_discharge_power_w)   for t in range(T)]
    pv_ld = [pulp.LpVariable(f"pv_ld_{t}", 0) for t in range(T)]
    pv_sp = [pulp.LpVariable(f"pv_sp_{t}", 0) for t in range(T)]
    soc = [
        pulp.LpVariable(f"soc_{t}", soc_panel_min[t], battery.soc_max_wh) for t in range(T)
    ]
    w_arb = [pulp.LpVariable(f"w_arb_{t}", cat=pulp.LpBinary) for t in range(T)]
    z_export = [pulp.LpVariable(f"z_export_{t}", cat=pulp.LpBinary) for t in range(T)]
    ca  = [pulp.LpVariable(f"ca_{t}",  0, slots[t].pv_a_forecast_w)        for t in range(T)]
    hp  = [pulp.LpVariable(f"hp_{t}",  0, heat_pump.rated_heating_power_w) for t in range(T)]
    soc_deficit_24h = pulp.LpVariable("soc_deficit_24h", 0, battery.usable_capacity_wh)

    # GEN port cut-off (BA81): binární proměnná pouze pokud je feature povolená v konfiguraci site/invertoru.
    gen_cutoff_enabled = bool(getattr(grid, "deye_gen_microinverter_cutoff_enabled", False))
    z_gen_cutoff = (
        [pulp.LpVariable(f"z_gen_cutoff_{t}", cat=pulp.LpBinary) for t in range(T)]
        if gen_cutoff_enabled
        else None
    )

    om = (operating_mode or "AUTO").strip().upper()
    charge_slots: set[int] = set()
    discharge_export_slots: set[int] = set()
    if om == "AUTO":
        charge_slots = {t for t, s in enumerate(slots) if s.allow_charge}
        charge_slots |= {
            t for t, s in enumerate(slots) if float(s.buy_price) < 0.0
        }
        # Stejně jako R__063 (sell<0 + PV přebytek): shortfall/curtail penalizace i bez block_export.
        charge_slots |= {
            t
            for t, s in enumerate(slots)
            if float(s.sell_price) < 0.0
            and max(
                0,
                int(s.pv_a_forecast_w)
                + int(s.pv_b_forecast_w)
                - int(s.load_baseline_w),
            )
            > 500
        }
        discharge_export_slots = {
            t for t, s in enumerate(slots) if s.allow_discharge_export
        }
    # SELF_SUSTAIN dřív vynucoval ge[t] == 0, což umí udělat MILP infeasible v okamžiku, kdy:
    # - baterie je na max SoC (nelze nabíjet),
    # - PV pole B není curtailable,
    # - a pv_b_forecast_w > load_baseline_w (typicky po ručním snížení baseline).
    # Export v SELF_SUSTAIN proto povolíme jako nouzový ventil, ale silně penalizujeme,
    # aby k němu docházelo jen když už neexistuje jiné fyzikálně možné řešení.
    SELF_SUSTAIN_EXPORT_PENALTY_CZK_KWH = 100.0
    # Penalizace vypnutí GEN portu (mikroinvertory): preferujeme nechat zapnuto a vypnout jen když
    # by to jinak vedlo k nežádoucímu exportu / infeasible řešení.
    GEN_CUTOFF_PENALTY_CZK_KWH = 2.0 if planner_v2 else 5.0

    # Heuristika: pokud existuje necurtailable PV B a v budoucnu v horizontu nastane buy < 0,
    # chceme mít motivaci držet baterii „prázdnější“ pro pozdější výhodný import / bonusové PV B okno.
    # V okně sell < 0 pak preferujeme curtail PV A (místo placeného exportu), a to tak,
    # že dočasně snížíme penalizaci ca[t] (curtailment) na 0.
    has_pv_b = any(float(s.pv_b_forecast_w) > 0.0 for s in slots)
    future_neg_buy_from: list[bool] = [False] * T
    seen_neg_buy = False
    for i in range(T - 1, -1, -1):
        if float(slots[i].buy_price) < 0.0:
            seen_neg_buy = True
        future_neg_buy_from[i] = seen_neg_buy
    future_extreme_buy_from = _future_extreme_buy_from(slots, buy_extreme_thr)
    dist_to_extreme_buy = _slots_until_buy_le(slots, buy_extreme_thr)

    # EV proměnné per vozidlo
    ev_direct  = [[pulp.LpVariable(f"evd_{e}_{t}", 0,
                    min(vehicles[e].max_charge_power_w, grid.max_import_power_w))
                   for t in range(T)] for e in range(EV)]
    ev_via_bat = [[pulp.LpVariable(f"evb_{e}_{t}", 0,
                    vehicles[e].max_charge_power_w)
                   for t in range(T)] for e in range(EV)]

    horizon_slots_h24 = min(T, int(24 / INTERVAL_H))
    avg_buy_terminal = (
        sum(float(slots[t].buy_price) for t in range(horizon_slots_h24)) / horizon_slots_h24
        if horizon_slots_h24 > 0
        else 4.0
    )
    terminal_factor_base = float(battery.planner_terminal_soc_value_factor)

    charge_acq_raw = getattr(slots[0], "charge_acquisition_buy_czk_kwh", None)
    charge_acquisition_czk_kwh = (
        float(charge_acq_raw)
        if charge_acq_raw is not None
        else min(float(s.buy_price) for s in slots)
    )
    soc_headroom_wh = max(2000.0, 0.05 * float(battery.soc_max_wh))

    # Kotva: poslední slot před prvním sell<0 by měl končit u planner floor (pokud relaxace existuje).
    # Slack penalizujeme v objective; samotné omezení přidáme až po definici soc.
    first_neg_sell_idx, pre_neg_export_last_t = _pre_negative_sell_export_window(slots)
    first_neg_buy_idx = next(
        (t for t, s in enumerate(slots) if float(s.buy_price) < 0.0),
        None,
    )
    neg_buy_slot_indices_pre = [
        t for t, s in enumerate(slots) if float(s.buy_price) < 0.0
    ]
    last_neg_sell_by_prague_date: dict[object, int] = {}
    for t_ln, st_ln in enumerate(slots):
        if float(st_ln.sell_price) < 0:
            last_neg_sell_by_prague_date[_prague_calendar_date(st_ln)] = t_ln
    t_pre_neg_peak = _pre_neg_peak_sell_idx(slots, first_neg_sell_idx)
    morning_pre_neg_export_ts = _morning_pre_neg_export_indices(
        slots,
        first_neg_sell_idx,
        degrad_czk_kwh=float(degradation_cost_effective),
    )
    evening_peak_export_ts = _evening_peak_export_indices(
        slots,
        degrad_czk_kwh=float(degradation_cost_effective),
    )
    purchase_fixed_pre = _purchase_pricing_fixed(grid)
    fixed_horizon_min_sell_pre: float | None = None
    if purchase_fixed_pre:
        _pos_sell_prices = [
            float(s.sell_price) for s in slots if float(s.sell_price) >= 0.0
        ]
        if _pos_sell_prices:
            fixed_horizon_min_sell_pre = min(_pos_sell_prices)
    block_export_neg_sell_pre = bool(
        getattr(grid, "block_export_on_negative_sell", False)
    )
    if purchase_fixed_pre and block_export_neg_sell_pre:
        evening_peak_export_ts = sorted(
            set(evening_peak_export_ts)
            | {
                t
                for t, st in enumerate(slots)
                if _in_night_battery_export_window(st)
                and float(st.sell_price) > 0.0
            }
        )
    non_negative_buys_pre = [
        float(s.buy_price) for s in slots if float(s.buy_price) >= 0.0
    ]
    ref_buy_horizon_pre = (
        min(non_negative_buys_pre)
        if non_negative_buys_pre
        else min(float(s.buy_price) for s in slots)
    )
    min_spread_pre = float(degradation_cost_effective)
    fixed_tariff_like_pre = purchase_fixed_pre or _horizon_fixed_tariff_like(slots)
    reserve_soc_wh_solver = float(
        getattr(battery, "reserve_soc_wh", getattr(battery, "min_soc_wh", 0.0))
    )
    if om == "AUTO" and not purchase_fixed_pre and not relaxed_solver_masks:
        strict_late_replan_evening_ts = _strict_late_replan_evening_slot_indices(
            slots,
            first_neg_buy_idx=first_neg_buy_idx,
            observed_soc_wh=observed_soc_wh,
            reserve_soc_wh=reserve_soc_wh_solver,
        )
        late_replan_strict_active = bool(strict_late_replan_evening_ts)
        if late_replan_strict_active:
            slots = [
                replace(
                    s,
                    allow_charge=True,
                    allow_discharge_export=True,
                )
                if i in strict_late_replan_evening_ts
                else s
                for i, s in enumerate(slots)
            ]
            charge_slots |= strict_late_replan_evening_ts
            discharge_export_slots |= strict_late_replan_evening_ts
            if not relaxed_pos_sell_ge_block and not relaxed_solver_masks:
                slots = _relax_solver_slot_masks(slots)
                charge_slots = {t for t, s in enumerate(slots) if s.allow_charge}
                charge_slots |= {
                    t for t, s in enumerate(slots) if float(s.buy_price) < 0.0
                }
                charge_slots |= {
                    t
                    for t, s in enumerate(slots)
                    if float(s.sell_price) < 0.0
                    and max(
                        0,
                        int(s.pv_a_forecast_w)
                        + int(s.pv_b_forecast_w)
                        - int(s.load_baseline_w),
                    )
                    > 500
                }
                discharge_export_slots = {
                    t for t, s in enumerate(slots) if s.allow_discharge_export
                }
    late_replan_solver_relax = (
        late_replan_strict_active
        and not relaxed_solver_masks
    )
    neg_sell_phases_en = (
        om == "AUTO"
        and not purchase_fixed_pre
        and _neg_sell_phases_enabled(battery)
        and not late_replan_strict_active
    )
    neg_sell_phase_by_t: list[str] = ["none"] * T
    neg_sell_soc_target_by_t: list[Optional[float]] = [None] * T
    neg_sell_shortfall_weight_by_t: list[float] = [0.0] * T
    neg_sell_day_meta: dict[str, Any] = {}
    neg_sell_post_detach_prep_ts: set[int] = set()
    if neg_sell_phases_en:
        (
            neg_sell_phase_by_t,
            neg_sell_soc_target_by_t,
            neg_sell_shortfall_weight_by_t,
            neg_sell_day_meta,
        ) = _neg_sell_day_phases(slots, battery)
        neg_sell_post_detach_prep_ts = set(
            neg_sell_day_meta.get("post_detach_prep_ts") or []
        )
    prep_soc_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
    prep_hold_bcpv_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
    prep_hold_curtail_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
    prep_hold_met_binary: dict[int, pulp.LpVariable] = {}
    pre_neg_cushion_by_day: dict[str, bool] = {}
    pre_neg_pv_export_ts: set[int] = set()
    neg_evening_before_neg_ts: set[int] = set()
    neg_evening_push_ts: set[int] = set()
    neg_evening_export_budget_wh: float | None = None
    neg_evening_reserve_anchors: list[tuple[int, float]] = []
    future_neg_buy_discharge_en = False
    if (
        om == "AUTO"
        and not purchase_fixed_pre
        and first_neg_buy_idx is not None
        and first_neg_buy_idx > 0
    ):
        future_neg_buy_discharge_en = _future_neg_buy_discharge_enabled(
            slots,
            battery,
            first_neg_buy_idx=first_neg_buy_idx,
            first_neg_sell_idx=first_neg_sell_idx,
            observed_soc_wh=observed_soc_wh,
            neg_sell_phases_en=neg_sell_phases_en,
            neg_sell_soc_target_by_t=(
                neg_sell_soc_target_by_t if neg_sell_phases_en else None
            ),
        )
    terminal_neg_buy_weight = _terminal_neg_buy_weight(
        slots,
        first_neg_buy_idx=first_neg_buy_idx,
    )
    terminal_factor = terminal_factor_base * (1.0 - terminal_neg_buy_weight)
    # Kč/Wh: ocenění energie ponechané v baterii na konci horizontu (receding horizon kotva).
    terminal_soc_kcz_per_wh = avg_buy_terminal * terminal_factor / 1000.0

    neg_evening_bundle_strict = (
        om == "AUTO"
        and not purchase_fixed_pre
        and neg_sell_phases_en
        and not relaxed_neg_prep_window
        and not late_replan_strict_active
    )
    neg_evening_discharge_active = neg_evening_bundle_strict or future_neg_buy_discharge_en
    if neg_evening_bundle_strict:
        pre_neg_pv_export_ts, pre_neg_cushion_by_day = _pre_neg_pv_export_bundle(
            slots,
            battery,
            observed_soc_wh,
            first_neg_buy_idx,
            neg_sell_phases_en=True,
            soc_target_by_t=neg_sell_soc_target_by_t,
        )
    if neg_evening_discharge_active:
        meta_for_evening = neg_sell_day_meta
        if not (meta_for_evening.get("days")) and first_neg_sell_idx is not None:
            meta_for_evening = {"days": [{"first_neg_idx": first_neg_sell_idx}]}
        neg_evening_before_neg_ts = _evening_discharge_before_neg_day_ts(
            slots,
            meta_for_evening,
        )
        neg_evening_before_neg_ts |= _discharge_before_first_neg_sell_ts(
            slots,
            first_neg_sell_idx,
        )
        neg_evening_reserve_anchors = _neg_evening_reserve_soc_anchors(
            slots,
            meta_for_evening,
            battery,
        )
        reserve_wh = float(
            getattr(battery, "reserve_soc_wh", getattr(battery, "min_soc_wh", 0.0))
        )
        night_buf_wh = _night_baseload_buffer_wh_from_slots(slots, battery)
        neg_evening_export_budget_wh = _neg_evening_discharge_budget_wh(
            observed_soc_wh=observed_soc_wh,
            reserve_soc_wh=reserve_wh,
            night_baseload_buffer_wh=night_buf_wh,
        )
        per_slot_neg_eve_wh = max(
            float(battery.max_discharge_power_w)
            * float(battery.discharge_efficiency)
            * INTERVAL_H,
            0.0,
        )
        neg_evening_push_ts = _neg_evening_before_neg_push_indices(
            slots,
            neg_evening_before_neg_ts,
            export_budget_wh=float(neg_evening_export_budget_wh),
            per_slot_discharge_wh=per_slot_neg_eve_wh,
            discharge_export_ok=discharge_export_slots,
        )
    elif om == "AUTO" and not purchase_fixed_pre:
        legacy_ok = bool(
            first_neg_sell_idx is not None
            and pre_neg_export_last_t is not None
            and _pre_neg_pv_export_forecast_cushion_ok(
                slots,
                battery,
                observed_soc_wh,
                first_neg_sell_idx,
                neg_sell_phases_en=False,
            )
        )
        if legacy_ok:
            pre_neg_pv_export_ts = _pre_neg_pv_export_slot_indices(
                slots,
                first_neg_sell_idx,
                pre_neg_export_last_t,
                first_neg_buy_idx,
            )
    pre_neg_pv_export_forecast_ok = bool(pre_neg_pv_export_ts)
    pre_neg_buy_discharge_ts: set[int] = set()
    if om == "AUTO" and first_neg_buy_idx is not None and first_neg_buy_idx > 0:
        pre_neg_buy_discharge_ts = _pre_neg_buy_discharge_indices(
            slots,
            first_neg_buy_idx,
            charge_acquisition_czk_kwh=charge_acquisition_czk_kwh,
            min_spread=min_spread_pre,
            fixed_tariff=fixed_tariff_like_pre,
        )
    neg_sell_bat_dump_slots = _neg_sell_bat_dump_slots(
        slots,
        operating_mode=om,
        purchase_fixed=purchase_fixed_pre,
        grid=grid,
        buy_extreme_thr=buy_extreme_thr,
        degrad_czk_kwh=float(degradation_cost_effective),
    )
    profitable_export_ts_pre: set[int] = set()
    if om == "AUTO":
        for _t in range(T):
            if _t not in discharge_export_slots:
                continue
            if _slot_profitable_battery_export(
                slots[_t],
                charge_acquisition_czk_kwh=charge_acquisition_czk_kwh,
                min_spread=min_spread_pre,
                fixed_tariff=fixed_tariff_like_pre,
            ):
                profitable_export_ts_pre.add(_t)
            elif (
                purchase_fixed_pre
                and block_export_neg_sell_pre
                and _t in evening_peak_export_ts
                and float(slots[_t].sell_price) > 0.0
            ):
                # KV1: večerní sell může být < fixní buy; peak sloty stejně vývoz bat.
                profitable_export_ts_pre.add(_t)
    evening_push_ts: set[int] = set()
    evening_early_export_penalty_ts: set[int] = set()
    night_self_consume_discourage_ts: set[int] = set()
    post_evening_push_night_ts: set[int] = set()
    degraded_relaxed_night_ts: set[int] = set()
    degraded_evening_export_ts: set[int] = set()
    evening_push_hysteresis_retained = False
    push_override_raw: Optional[set[int]] = None
    push_override_eff: Optional[set[int]] = None
    computed_evening_push_ts: set[int] = set()
    evening_push_hard_suppressed = False
    if om == "AUTO":
        per_slot_discharge_wh_pre = max(
            float(battery.max_discharge_power_w)
            * float(battery.discharge_efficiency)
            * INTERVAL_H,
            0.0,
        )
        export_cap_push_w = _battery_export_cap_w(battery, grid)
        per_slot_push_wh_pre = min(
            per_slot_discharge_wh_pre,
            export_cap_push_w * float(battery.discharge_efficiency) * INTERVAL_H,
        )
        discharge_buf_pre = float(getattr(battery, "discharge_slot_buffer", 0) or 0)
        discharge_floor_wh = _planner_discharge_floor_wh(battery)
        kv1_evening_push_pre = _kv1_block_export_fixed_evening_push(
            grid,
            purchase_fixed=purchase_fixed_pre,
        )
        computed_evening_push_ts = set(
            _evening_battery_export_push_indices(
                slots,
                charge_acquisition_czk_kwh=charge_acquisition_czk_kwh,
                degrad_czk_kwh=float(degradation_cost_effective),
                current_soc_wh=float(current_soc_wh),
                min_soc_wh=float(discharge_floor_wh),
                soc_max_wh=float(battery.soc_max_wh),
                per_slot_discharge_wh=per_slot_push_wh_pre,
                discharge_slot_buffer=discharge_buf_pre,
                discharge_export_ok=discharge_export_slots,
                first_neg_sell_idx=first_neg_sell_idx,
                kv1_evening_push=kv1_evening_push_pre,
                purchase_fixed=purchase_fixed_pre,
            )
        )
        push_override_raw = _evening_push_override_for_solve(
            evening_push_ts_override,
            relaxed_expensive_import=relaxed_expensive_import,
            relaxed_neg_buy_charge=relaxed_neg_buy_charge,
            relaxed_neg_prep_hold_only=relaxed_neg_prep_hold_only,
            relaxed_neg_prep_window=relaxed_neg_prep_window,
            neg_sell_phases_fallback=neg_sell_phases_fallback,
            relaxed_pos_sell_ge_block=relaxed_pos_sell_ge_block,
            relaxed_solver_masks=relaxed_solver_masks,
        )
        push_override_eff = None
        if push_override_raw:
            push_override_eff = _filter_evening_push_override_indices(
                slots,
                push_override_raw,
                battery=battery,
                grid=grid,
                discharge_export_ok=discharge_export_slots,
            )
        evening_push_hysteresis_retained = False
        if push_override_eff:
            evening_push_ts = push_override_eff
            evening_push_hysteresis_retained = True
        else:
            evening_push_ts = computed_evening_push_ts
        if not evening_push_ts:
            evening_push_ts = _evening_push_peak_fallback_indices(
                slots,
                charge_acquisition_czk_kwh=charge_acquisition_czk_kwh,
                min_spread=float(degradation_cost_effective),
                discharge_export_ok=discharge_export_slots,
                first_neg_sell_idx=first_neg_sell_idx,
                kv1_evening_push=kv1_evening_push_pre,
                purchase_fixed=purchase_fixed_pre,
            )
        # Tvrdý ge_bat push vypnout jen při neg_sell fallback (ne při prep relax — v64).
        evening_push_hard_suppressed = bool(
            neg_sell_phases_fallback or late_replan_strict_active
        )
    else:
        evening_push_hard_suppressed = False
    last_pos_sell_pre_neg_buy = _last_non_negative_sell_before_neg_buy(
        slots, first_neg_buy_idx
    )
    pos_sell_pre_neg_buy_ts = _positive_sell_pre_neg_buy_indices(
        slots, first_neg_buy_idx
    )
    pos_sell_pre_neg_buy_ge_exempt_ts = _pos_sell_pre_neg_buy_evening_export_exempt_ts(
        slots,
        pos_sell_pre_neg_buy_ts,
        evening_peak_export_ts,
        charge_acquisition_czk_kwh=charge_acquisition_czk_kwh,
        min_spread=float(degradation_cost_effective),
        fixed_tariff=fixed_tariff_like_pre,
        future_neg_buy_discharge_en=future_neg_buy_discharge_en,
    )
    if strict_late_replan_evening_ts:
        pos_sell_pre_neg_buy_ge_exempt_ts |= strict_late_replan_evening_ts
    pre_neg_buy_empty_ts = _pre_neg_buy_empty_discharge_indices(
        slots, first_neg_buy_idx, last_pos_sell_pre_neg_buy
    )
    if om == "AUTO":
        evening_export_exempt_ts = (
            set(morning_pre_neg_export_ts)
            | set(pre_neg_buy_discharge_ts)
            | set(pre_neg_buy_empty_ts)
            | set(neg_evening_push_ts)
        )
        if purchase_fixed_pre:
            # Fixní tarif: sell>buy v noci nesmí ge_bat=0 přes evening_early (BA81 úsvit).
            evening_export_exempt_ts |= profitable_export_ts_pre
        evening_early_export_penalty_ts = _evening_early_export_penalty_indices(
            slots,
            discharge_export_slots=discharge_export_slots,
            evening_push_ts=evening_push_ts,
            exempt_ts=evening_export_exempt_ts,
        )
        night_self_consume_discourage_ts = _night_self_consume_discourage_import_indices(
            slots,
            evening_push_ts=evening_push_ts,
            charge_acquisition_czk_kwh=charge_acquisition_czk_kwh,
            min_spread=float(degradation_cost_effective),
            purchase_fixed=purchase_fixed_pre,
        )
        post_evening_push_night_ts = _post_evening_push_night_self_consume_indices(
            slots, evening_push_ts
        )
        night_self_consume_discourage_ts |= post_evening_push_night_ts
        if not relaxed_solver_masks and strict_late_replan_evening_ts:
            strict_late_replan_night_ts = _strict_late_replan_night_self_consume_indices(
                slots,
                evening_export_ts=strict_late_replan_evening_ts,
            )
            night_self_consume_discourage_ts |= strict_late_replan_night_ts
            post_evening_push_night_ts |= strict_late_replan_night_ts
        battery_export_defer_pv_ts = {
            t for t in range(T) if _battery_export_push_defer_to_pv(slots[t])
        }
        # Pozdní replan večer: SQL allow_charge může být false (drahý buy), ale večerní vývoz
        # k reserve před neg dnem vyžaduje souběžně grid import pro load (ne jen bd).
        if neg_evening_discharge_active or evening_push_ts:
            replan_day = _prague_calendar_date(slots[0])
            for t in range(T):
                if _prague_calendar_date(slots[t]) != replan_day:
                    continue
                if float(slots[t].sell_price) < 0.0:
                    continue
                if (
                    t in evening_push_ts
                    or t in neg_evening_push_ts
                    or (
                        _in_evening_push_hour_window(slots[t])
                        and t in discharge_export_slots
                    )
                ):
                    charge_slots.add(t)
            if neg_evening_discharge_active:
                for t in discharge_export_slots:
                    if _prague_calendar_date(slots[t]) == replan_day:
                        charge_slots.add(t)
        if relaxed_pos_sell_ge_block or relaxed_solver_masks:
            # Poslední retry: SQL allow_charge / drahý import nesmí zablokovat fyzicky dosažitelný plán.
            charge_slots = set(range(T))
            discharge_export_slots = {
                t
                for t, s in enumerate(slots)
                if s.allow_discharge_export or float(s.sell_price) >= 0.0
            }
    else:
        battery_export_defer_pv_ts = set()
    if relaxed_solver_masks and om == "AUTO":
        future_neg_buy_discharge_en = False
        neg_evening_discharge_active = False
        neg_evening_push_ts = set()
        neg_evening_before_neg_ts = set()
        neg_evening_reserve_anchors = []
        evening_push_ts = set()
        evening_early_export_penalty_ts = set()
        battery_export_defer_pv_ts = set()
        evening_push_hard_suppressed = True
        degraded_relaxed_night_ts = _degraded_relaxed_night_self_consume_indices(slots)
        reserve_wh_degraded = float(
            getattr(battery, "reserve_soc_wh", getattr(battery, "min_soc_wh", 0.0))
        )
        degraded_evening_export_ts = _degraded_relaxed_evening_export_to_reserve_indices(
            slots,
            observed_soc_wh=observed_soc_wh,
            reserve_soc_wh=reserve_wh_degraded,
            first_neg_buy_idx=first_neg_buy_idx,
        )
        night_self_consume_discourage_ts |= degraded_relaxed_night_ts
        post_evening_push_night_ts |= degraded_relaxed_night_ts
    pre_neg_buy_soc_ceiling_wh = _pre_neg_buy_soc_ceiling_wh(
        slots,
        first_neg_buy_idx=first_neg_buy_idx,
        min_soc_wh=float(min_soc_wh),
        soc_max_wh=float(battery.soc_max_wh),
        max_charge_w=float(battery.max_charge_power_w),
        charge_eff=float(battery.charge_efficiency),
    )
    t_pre_neg_buy_anchor: int | None = (
        first_neg_buy_idx - 1 if first_neg_buy_idx is not None and first_neg_buy_idx > 0 else None
    )
    soc_pre_neg_buy_ceiling_slack: pulp.LpVariable | None = None
    if (
        t_pre_neg_buy_anchor is not None
        and pre_neg_buy_soc_ceiling_wh is not None
    ):
        soc_pre_neg_buy_ceiling_slack = pulp.LpVariable(
            "soc_pre_neg_buy_ceiling_slack_wh",
            0,
            float(battery.usable_capacity_wh),
        )
    pos_sell_soc_shortfall: pulp.LpVariable | None = None
    if last_pos_sell_pre_neg_buy is not None:
        pos_sell_soc_shortfall = pulp.LpVariable(
            "pos_sell_pre_neg_soc_shortfall_wh",
            0,
            float(battery.usable_capacity_wh),
        )

    daytime_en = bool(getattr(battery, "planner_daytime_charge_target_enabled", True))
    safety_pen_czk_per_wh: list[float] = []
    safety_vars: list[Optional[pulp.LpVariable]] = []
    safety_active: list[bool] = []
    post_neg_pv_topup: list[bool] = []
    high_sell_slot: list[bool] = []
    for t in range(T):
        sft = slots[t].safety_soc_target_wh if daytime_en else None
        # High-sell slot: typicky lokální maximum v SQL lookaheadu (future_sell_opportunity_czk_kwh).
        # V těchto slotech safety floor nepoužijeme, aby se zachovala arbitráž na špičkách.
        fso = slots[t].future_sell_opportunity_czk_kwh
        hs = bool(fso is not None and float(slots[t].sell_price) >= float(fso) - 1e-6)
        high_sell_slot.append(hs)

        fb = float(slots[t].future_avoided_buy_czk_kwh or slots[t].buy_price)
        fs = float(slots[t].future_sell_opportunity_czk_kwh or slots[t].sell_price)
        bv = max(fb, fs) - float(degradation_cost_effective)
        bv = max(0.0, min(5.0, bv))
        st_d = _prague_calendar_date(slots[t])
        ln_neg = last_neg_sell_by_prague_date.get(st_d)
        pv_topup_after_neg = bool(
            om == "AUTO"
            and ln_neg is not None
            and t > ln_neg
            and float(slots[t].sell_price) >= 0.0
            and bool(slots[t].is_daytime_pv_surplus_slot)
            and not hs
        )
        post_neg_pv_topup.append(pv_topup_after_neg)
        # Safety deficit penalizujeme jen v PV surplus slotech, a ne ve high-sell špičce.
        # Záměr: safety není obecná „nabij co nejdřív“ motivace; je to preference využít přebytek PV.
        active = bool(
            (
                sft is not None
                and (
                    bool(slots[t].is_daytime_pv_surplus_slot)
                    or (planner_v2 and float(slots[t].buy_price) < 0.0)
                )
                and not hs
            )
            or pv_topup_after_neg
        )
        safety_active.append(active)
        safety_pen_czk_per_wh.append(bv / 1000.0 if active else 0.0)
        if active:
            safety_vars.append(
                pulp.LpVariable(f"safety_def_{t}", 0, float(battery.usable_capacity_wh))
            )
        else:
            safety_vars.append(None)

    commit_pen = float(getattr(battery, "planner_charge_commitment_penalty_czk_kwh", 0.2))
    commit_lp: list[tuple[int, pulp.LpVariable, float]] = []
    commitment_for_solve = charge_commitment_prev_w
    if (
        relaxed_neg_buy_charge
        or relaxed_neg_prep_window
        or neg_sell_phases_fallback
        or late_replan_solver_relax
    ):
        commitment_for_solve = None
    if commitment_for_solve is not None and len(commitment_for_solve) == T:
        for t in range(T):
            prev = commitment_for_solve[t]
            if prev is not None and prev > 500:
                cap_prev = float(prev)
                cv = pulp.LpVariable(f"ccommit_{t}", 0, cap_prev)
                commit_lp.append((t, cv, cap_prev))

    peak_export_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
    pv_charge_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
    pre_neg_pv_charge_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
    pre_neg_pv_export_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
    neg_evening_before_neg_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
    neg_evening_reserve_soc_slack: list[tuple[int, pulp.LpVariable, float]] = []
    neg_sell_bat_dump_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
    neg_sell_soc_underfill: list[tuple[int, pulp.LpVariable, float]] = []
    neg_buy_charge_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
    pre_neg_batt_export_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
    pre_neg_buy_empty_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
    degraded_evening_export_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
    fixed_tariff_like = fixed_tariff_like_pre
    block_export_neg_sell = bool(getattr(grid, "block_export_on_negative_sell", False))
    if om == "AUTO":
        for t in range(T):
            if relaxed_solver_masks and not purchase_fixed_pre:
                continue
            if t not in discharge_export_slots:
                continue
            if t in evening_push_ts:
                continue
            if _in_night_battery_export_window(slots[t]):
                # Spot: večerní export jen v tvrdém push. Fixní: i profitable sell>buy v noci.
                if not (
                    purchase_fixed_pre
                    and _slot_profitable_battery_export(
                        slots[t],
                        charge_acquisition_czk_kwh=charge_acquisition_czk_kwh,
                        min_spread=float(degradation_cost_effective),
                        fixed_tariff=True,
                    )
                ):
                    continue
            if _battery_export_push_defer_to_pv(slots[t]):
                continue
            if not _slot_profitable_battery_export(
                slots[t],
                charge_acquisition_czk_kwh=charge_acquisition_czk_kwh,
                min_spread=float(degradation_cost_effective),
                fixed_tariff=fixed_tariff_like,
            ):
                continue
            cap_w = float(min(
                grid.max_export_power_w,
                battery.max_discharge_power_w,
            ))
            sf = pulp.LpVariable(f"export_shortfall_{t}", 0, cap_w)
            peak_export_shortfall.append((t, sf, cap_w))
        export_cap_w = _battery_export_cap_w(battery, grid)
        for t_pnd in sorted(pre_neg_buy_discharge_ts):
            if _battery_export_push_defer_to_pv(slots[t_pnd]):
                continue
            sf_pnd = pulp.LpVariable(f"pre_neg_bat_export_sf_{t_pnd}", 0, export_cap_w)
            pre_neg_batt_export_shortfall.append((t_pnd, sf_pnd, export_cap_w))
        for t_empty in pre_neg_buy_empty_ts:
            if _battery_export_push_defer_to_pv(slots[t_empty]):
                continue
            sf_e = pulp.LpVariable(f"pre_neg_buy_empty_sf_{t_empty}", 0, export_cap_w)
            pre_neg_buy_empty_shortfall.append((t_empty, sf_e, export_cap_w))
        if relaxed_solver_masks and degraded_evening_export_ts:
            deg_cap = _battery_export_cap_w(battery, grid)
            for t_deg in sorted(degraded_evening_export_ts):
                sf_deg = pulp.LpVariable(
                    f"deg_eve_reserve_export_{t_deg}",
                    0,
                    deg_cap,
                )
                degraded_evening_export_shortfall.append((t_deg, sf_deg, deg_cap))
        if not (relaxed_neg_buy_charge or late_replan_solver_relax):
            neg_buy_slot_indices = [
                t for t, s in enumerate(slots) if float(s.buy_price) < 0.0
            ]
            if neg_buy_slot_indices:
                t_nb_last = max(neg_buy_slot_indices_pre)
                cap_w = float(battery.max_charge_power_w)
                sf_nb = pulp.LpVariable(f"neg_buy_charge_sf_{t_nb_last}", 0, cap_w)
                neg_buy_charge_shortfall.append((t_nb_last, sf_nb, cap_w))
        for t in range(T):
            if float(slots[t].sell_price) >= 0:
                continue
            if float(slots[t].buy_price) < 0.0:
                continue
            if t not in charge_slots:
                continue
            # Před buy<0: nepenalizovat / netlačit PV→bat (jinak 98 % v 09:15 a export v sell<0).
            if first_neg_buy_idx is not None and t < first_neg_buy_idx:
                continue
            pv_surplus_w = max(
                0.0,
                float(slots[t].pv_a_forecast_w)
                + float(slots[t].pv_b_forecast_w)
                - float(slots[t].load_baseline_w),
            )
            if pv_surplus_w <= 500:
                continue
            cap_w = float(min(pv_surplus_w, battery.max_charge_power_w))
            sf_pv = pulp.LpVariable(f"pv_charge_shortfall_{t}", 0, cap_w)
            pv_charge_shortfall.append((t, sf_pv, cap_w))
        if neg_sell_phases_en:
            pv_charge_taken = {t_sf for t_sf, _sf, _c in pv_charge_shortfall}
            for t_ns in range(T):
                if neg_sell_phase_by_t[t_ns] not in ("prep", "tail"):
                    continue
                if t_ns in pv_charge_taken:
                    continue
                if float(slots[t_ns].sell_price) >= 0.0:
                    continue
                pv_surplus_ns = max(
                    0.0,
                    float(slots[t_ns].pv_a_forecast_w)
                    + float(slots[t_ns].pv_b_forecast_w)
                    - float(slots[t_ns].load_baseline_w),
                )
                if pv_surplus_ns <= 500:
                    continue
                cap_ns = float(min(pv_surplus_ns, battery.max_charge_power_w))
                sf_ns = pulp.LpVariable(f"neg_phase_pv_charge_{t_ns}", 0, cap_ns)
                pv_charge_shortfall.append((t_ns, sf_ns, cap_ns))
        for t_pe in sorted(pre_neg_pv_export_ts):
            s_pe = slots[t_pe]
            pv_surplus_pe = max(
                0.0,
                float(s_pe.pv_a_forecast_w)
                + float(s_pe.pv_b_forecast_w)
                - float(s_pe.load_baseline_w),
            )
            cap_pe = float(
                min(
                    pv_surplus_pe,
                    float(grid.max_export_power_w),
                )
            )
            if cap_pe <= 500.0:
                continue
            sf_pe = pulp.LpVariable(f"pre_neg_pv_export_sf_{t_pe}", 0, cap_pe)
            pre_neg_pv_export_shortfall.append((t_pe, sf_pe, cap_pe))
        export_cap_evening = _battery_export_cap_w(battery, grid)
        for t_ev in sorted(neg_evening_push_ts):
            if t_ev not in discharge_export_slots:
                continue
            sf_ev = pulp.LpVariable(
                f"neg_eve_prep_discharge_{t_ev}",
                0,
                export_cap_evening,
            )
            neg_evening_before_neg_shortfall.append((t_ev, sf_ev, export_cap_evening))
        for t_anchor, reserve_tgt in neg_evening_reserve_anchors:
            sl = pulp.LpVariable(
                f"neg_eve_reserve_soc_slack_{t_anchor}",
                0,
                float(NEG_EVENING_RESERVE_SOC_MAX_SLACK_WH),
            )
            neg_evening_reserve_soc_slack.append((t_anchor, sl, float(reserve_tgt)))
            if t_anchor in discharge_export_slots and t_anchor not in {
                t for t, _sf, _c in neg_evening_before_neg_shortfall
            }:
                cap_ev = _battery_export_cap_w(battery, grid)
                sf_ra = pulp.LpVariable(
                    f"neg_eve_reserve_ge_{t_anchor}",
                    0,
                    cap_ev,
                )
                neg_evening_before_neg_shortfall.append((t_anchor, sf_ra, cap_ev))
        for t in range(T):
            if not post_neg_pv_topup[t]:
                continue
            if float(slots[t].sell_price) < 0:
                continue
            pv_surplus_w = max(
                0.0,
                float(slots[t].pv_a_forecast_w)
                + float(slots[t].pv_b_forecast_w)
                - float(slots[t].load_baseline_w),
            )
            if pv_surplus_w <= 500:
                continue
            cap_w = float(min(pv_surplus_w, battery.max_charge_power_w))
            sf_pv = pulp.LpVariable(f"post_neg_pv_shortfall_{t}", 0, cap_w)
            pv_charge_shortfall.append((t, sf_pv, cap_w))
        if neg_sell_phases_en and not (prep_hold_relaxed or late_replan_strict_active):
            for t_ns in range(T):
                phase_ns = neg_sell_phase_by_t[t_ns]
                tgt_ns = neg_sell_soc_target_by_t[t_ns]
                if phase_ns == "none" or tgt_ns is None:
                    continue
                us_prep = pulp.LpVariable(
                    f"neg_sell_prep_soc_{t_ns}",
                    0,
                    float(battery.usable_capacity_wh),
                )
                w_sf = float(neg_sell_shortfall_weight_by_t[t_ns])
                prep_soc_shortfall.append((t_ns, us_prep, w_sf))
            tail_last_by_day: dict[object, int] = {}
            for t_ln, st_ln in enumerate(slots):
                if neg_sell_phase_by_t[t_ln] != "tail":
                    continue
                tail_last_by_day[_prague_calendar_date(st_ln)] = t_ln
            for t_tail_last in tail_last_by_day.values():
                if t_tail_last in charge_slots or relaxed_neg_buy_charge or late_replan_solver_relax:
                    us_tail = pulp.LpVariable(
                        f"neg_sell_tail_soc_{t_tail_last}",
                        0,
                        float(battery.usable_capacity_wh),
                    )
                    neg_sell_soc_underfill.append(
                        (t_tail_last, us_tail, float(battery.soc_max_wh))
                    )
            if not (prep_hold_relaxed or late_replan_strict_active):
                for t_ph in range(T):
                    if neg_sell_phase_by_t[t_ph] != "prep":
                        continue
                    cap_bc = float(battery.max_charge_power_w)
                    prep_hold_met_binary[t_ph] = pulp.LpVariable(
                        f"prep_hold_met_{t_ph}",
                        cat=pulp.LpBinary,
                    )
                    sf_hold = pulp.LpVariable(f"prep_hold_bcpv_{t_ph}", 0, cap_bc)
                    prep_hold_bcpv_shortfall.append((t_ph, sf_hold, cap_bc))
                    cap_ca = float(max(0, slots[t_ph].pv_a_forecast_w))
                    sf_ca = pulp.LpVariable(f"prep_hold_curtail_{t_ph}", 0, cap_ca)
                    prep_hold_curtail_shortfall.append((t_ph, sf_ca, cap_ca))
        elif len(neg_buy_slot_indices_pre) >= 2:
            t_nb_last = max(neg_buy_slot_indices_pre)
            if t_nb_last in charge_slots or relaxed_neg_buy_charge or late_replan_solver_relax:
                us = pulp.LpVariable(
                    f"neg_buy_soc_under_{t_nb_last}",
                    0,
                    float(battery.usable_capacity_wh),
                )
                neg_sell_soc_underfill.append(
                    (t_nb_last, us, float(battery.soc_max_wh))
                )
        for t in range(T):
            if first_neg_buy_idx is None or t >= first_neg_buy_idx:
                continue
            if float(slots[t].sell_price) >= 0.0:
                continue
            if float(slots[t].buy_price) < 0.0:
                continue
            pv_surplus_w = max(
                0.0,
                float(slots[t].pv_a_forecast_w)
                + float(slots[t].pv_b_forecast_w)
                - float(slots[t].load_baseline_w),
            )
            if pv_surplus_w <= 500:
                continue
            cap_w = float(min(pv_surplus_w, battery.max_charge_power_w))
            sf_m = pulp.LpVariable(f"pre_neg_pv_charge_sf_{t}", 0, cap_w)
            pre_neg_pv_charge_shortfall.append((t, sf_m, cap_w))
        for t in neg_sell_bat_dump_slots:
            dump_target_w = _battery_export_cap_w(battery, grid)
            sf_dump = pulp.LpVariable(f"neg_bat_dump_shortfall_{t}", 0, dump_target_w)
            neg_sell_bat_dump_shortfall.append((t, sf_dump, dump_target_w))

    # --- Účelová funkce (jen OTE sloty; terminal SoC shadow price na konci horizontu) ---
    # Kanály: gi×buy, −ge_pv×sell, −ge_bat×sell, +ge_bat×acquisition (export bat. jen v discharge slotách).
    # Viz docs/04-modules/planning-arbitrage-accounting.md — mezi-slotová arbitráž, ne sell vs buy v jednom slotu.
    prob += (
        pulp.lpSum(
            gi[t]  * slots[t].buy_price  * INTERVAL_H / 1000
            - ge_pv[t] * slots[t].sell_price * INTERVAL_H / 1000
            - ge_bat[t] * slots[t].sell_price * INTERVAL_H / 1000
            + (
                ge_pv[t] * SELF_SUSTAIN_EXPORT_PENALTY_CZK_KWH * INTERVAL_H / 1000
                if om == "SELF_SUSTAIN"
                else 0
            )
            + (
                (slots[t].pv_b_forecast_w * z_gen_cutoff[t]) * GEN_CUTOFF_PENALTY_CZK_KWH * INTERVAL_H / 1000
                if z_gen_cutoff is not None
                else 0
            )
            + gi_over[t] * IMPORT_OVER_BREAKER_PENALTY_CZK_KWH * INTERVAL_H / 1000
            + 0.5 * (bc_pv[t] + bc_gi[t] + bd[t]) * degradation_cost_effective * INTERVAL_H / 1000
            - (
                pv_ld[t] * LOAD_FIRST_INCENTIVE_CZK_KWH * INTERVAL_H / 1000
                if om == "AUTO"
                else 0
            )
            + (
                ge_bat[t] * charge_acquisition_czk_kwh * INTERVAL_H / 1000
                if om == "AUTO" and t in discharge_export_slots
                else 0
            )
            - (
                bc_pv[t]
                * NEG_SELL_PV_CHARGE_REWARD_CZK_KWH
                * INTERVAL_H
                / 1000
                if (
                    om == "AUTO"
                    and float(slots[t].sell_price) < 0.0
                    and t in charge_slots
                )
                else 0
            )
            + (
                ge_pv[t]
                * (
                    max(
                        0.05,
                        -float(slots[t].sell_price),
                    )
                    if (
                        neg_sell_phases_en
                        and neg_sell_phase_by_t[t] == "tail"
                    )
                    else NEG_SELL_PV_B_VENT_PENALTY_CZK_KWH
                )
                * INTERVAL_H
                / 1000
                if (
                    om == "AUTO"
                    and float(slots[t].sell_price) < 0.0
                    and not purchase_fixed_pre
                )
                else 0
            )
            + (
                gi[t]
                * max(
                    NIGHT_SELF_CONSUME_IMPORT_SURCHARGE_CZK_KWH,
                    max(
                        0.0,
                        float(slots[t].buy_price) - charge_acquisition_czk_kwh,
                    ),
                )
                * INTERVAL_H
                / 1000
                if om == "AUTO" and t in night_self_consume_discourage_ts
                else 0
            )
            + pulp.lpSum(
                ev_direct[e][t]  * slots[t].buy_price * INTERVAL_H / 1000
                + ev_via_bat[e][t] * slots[t].buy_price * EV_ROUNDTRIP_FACTOR * INTERVAL_H / 1000
                for e in range(EV)
            )
            + ca[t]
            * (
                NEG_SELL_CURTAIL_PENALTY_CZK_KWH
                if (
                    om == "AUTO"
                    and float(slots[t].buy_price) < 0.0
                    and t in charge_slots
                    and not (
                        neg_sell_phases_en and neg_sell_phase_by_t[t] == "prep"
                    )
                )
                else CURTAILMENT_PENALTY
            )
            for t in range(T)
        )
        + soc_deficit_24h * soc_deficit_penalty_czk_kwh / 1000
        - terminal_soc_kcz_per_wh * soc[T - 1]
        + (
            pos_sell_soc_shortfall * POS_SELL_PRE_NEG_SOC_SHORTFALL_PENALTY_CZK_PER_WH
            if pos_sell_soc_shortfall is not None
            else 0
        )
        + (
            soc_pre_neg_buy_ceiling_slack
            * PRE_NEG_BUY_SOC_CEILING_SLACK_PENALTY_CZK_PER_WH
            if soc_pre_neg_buy_ceiling_slack is not None
            else 0
        )
        + pulp.lpSum(
            safety_vars[t] * safety_pen_czk_per_wh[t]
            for t in range(T)
            if safety_vars[t] is not None
        )
        + pulp.lpSum(cv * INTERVAL_H / 1000.0 * commit_pen for _t, cv, _p in commit_lp)
        + pulp.lpSum(
            sf * PEAK_EXPORT_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0
            for _t, sf, _cap in peak_export_shortfall
        )
        + pulp.lpSum(
            sf * NEG_EVENING_PREP_DISCHARGE_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0
            for _t, sf, _cap in degraded_evening_export_shortfall
        )
        + pulp.lpSum(
            sf * PV_CHARGE_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0
            for _t, sf, _cap in pv_charge_shortfall
        )
        + pulp.lpSum(
            us * NEG_SELL_SOC_UNDERFILL_PENALTY_CZK_PER_WH
            for _t, us, _tgt in neg_sell_soc_underfill
        )
        + pulp.lpSum(
            us * w_sf * NEG_SELL_PREP_SOC_SHORTFALL_PENALTY_CZK_PER_WH
            for _t, us, w_sf in prep_soc_shortfall
        )
        + pulp.lpSum(
            sf * NEG_SELL_PREP_HOLD_BCPV_PENALTY_CZK_KWH * INTERVAL_H / 1000.0
            for _t, sf, _cap in prep_hold_bcpv_shortfall
        )
        + pulp.lpSum(
            sf * NEG_SELL_CURTAIL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0
            for _t, sf, _cap in prep_hold_curtail_shortfall
        )
        + pulp.lpSum(
            sf * PRE_NEG_PV_EXPORT_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0
            for _t, sf, _cap in pre_neg_pv_export_shortfall
        )
        + pulp.lpSum(
            sf * NEG_EVENING_PREP_DISCHARGE_SHORTFALL_PENALTY_CZK_KWH
            * INTERVAL_H
            / 1000.0
            for _t, sf, _cap in neg_evening_before_neg_shortfall
        )
        + pulp.lpSum(
            sl * NEG_EVENING_RESERVE_SOC_SLACK_PENALTY_CZK_PER_WH
            for _t, sl, _tgt in neg_evening_reserve_soc_slack
        )
        + pulp.lpSum(
            bc_pv[t]
            * PRE_NEG_PV_BCPV_DISCOURAGE_CZK_KWH
            * INTERVAL_H
            / 1000.0
            for t in pre_neg_pv_export_ts
        )
        + pulp.lpSum(
            bc_pv[t]
            * NEG_SELL_POST_DETACH_BCPV_DISCOURAGE_CZK_KWH
            * INTERVAL_H
            / 1000.0
            for t in neg_sell_post_detach_prep_ts
        )
        + pulp.lpSum(
            sf * NEG_SELL_BAT_DUMP_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0
            for _t, sf, _cap in neg_sell_bat_dump_shortfall
        )
        + pulp.lpSum(
            sf * NEG_BUY_CHARGE_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0
            for _t, sf, _cap in neg_buy_charge_shortfall
        )
        + pulp.lpSum(
            sf * PRE_NEG_BATT_EXPORT_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0
            for _t, sf, _cap in pre_neg_batt_export_shortfall
        )
        + pulp.lpSum(
            bc_gi[t]
            * PRE_NEG_CHARGE_PENALTY_CZK_KWH
            * INTERVAL_H
            / 1000.0
            for t in range(T)
            if (
                first_neg_buy_idx is not None
                and t < first_neg_buy_idx
                and float(slots[t].buy_price) >= 0.0
            )
        )
        + pulp.lpSum(
            bc_pv[t]
            * PRE_NEG_BUY_PV_CHARGE_PENALTY_CZK_KWH
            * INTERVAL_H
            / 1000.0
            for t in range(T)
            if float(slots[t].buy_price) < 0.0
        )
        + pulp.lpSum(
            sf * PV_CHARGE_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0
            for _t, sf, _cap in pre_neg_pv_charge_shortfall
        )
        + pulp.lpSum(
            sf * PRE_NEG_BUY_EMPTY_EXPORT_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0
            for _t, sf, _cap in pre_neg_buy_empty_shortfall
        )
        + pulp.lpSum(
            -25.0 * z_export[t]
            for t in range(T)
            if t in discharge_export_slots and t in profitable_export_ts_pre
        )
        + pulp.lpSum(
            -EVENING_PUSH_Z_EXPORT_BONUS_CZK * z_export[t]
            for t in evening_push_ts
        )
    )

    # --- Omezení ---
    for t_sf, sf, cap_w in peak_export_shortfall:
        prob += sf >= cap_w - ge_bat[t_sf]
    for t_sf, sf, cap_w in pv_charge_shortfall:
        prob += sf >= cap_w - bc_pv[t_sf]
    for t_sf, sf, cap_w in neg_sell_bat_dump_shortfall:
        prob += sf >= cap_w - ge_bat[t_sf]
    for t_us, us, _w_sf in prep_soc_shortfall:
        tgt_prep = neg_sell_soc_target_by_t[t_us]
        if tgt_prep is not None:
            prob += us >= float(tgt_prep) - soc[t_us]
    for t_us, us, tgt_wh in neg_sell_soc_underfill:
        prob += us >= float(tgt_wh) - soc[t_us]
    m_hold_soc = float(battery.soc_max_wh)
    for t_h, sf_h, cap_h in prep_hold_bcpv_shortfall:
        w_h = prep_hold_met_binary[t_h]
        soc_prev_h = current_soc_wh if t_h == 0 else soc[t_h - 1]
        tgt_hold = neg_sell_soc_target_by_t[t_h]
        hold_thr = float(tgt_hold) if tgt_hold is not None else float(battery.soc_max_wh)
        prob += soc_prev_h >= hold_thr - m_hold_soc * (1 - w_h)
        prob += sf_h >= bc_pv[t_h] - cap_h * w_h
    for t_c, sf_c, cap_c in prep_hold_curtail_shortfall:
        w_c = prep_hold_met_binary[t_c]
        prob += sf_c >= ca[t_c] - cap_c * (1 - w_c)
    for t_sf, sf, cap_w in neg_buy_charge_shortfall:
        # buy<0: bc_pv=0 (import arbitráž); shortfall jen na grid→bat.
        prob += sf >= cap_w - bc_gi[t_sf]
    for t_sf, sf, cap_w in pre_neg_batt_export_shortfall:
        prob += sf >= cap_w - ge_bat[t_sf]
    for t_sf, sf, cap_w in pre_neg_buy_empty_shortfall:
        prob += sf >= cap_w - ge_bat[t_sf]
    for t_sf, sf, cap_w in pre_neg_pv_charge_shortfall:
        prob += sf >= cap_w - bc_pv[t_sf]
    for t_sf, sf, cap_w in pre_neg_pv_export_shortfall:
        prob += sf >= cap_w - ge_pv[t_sf]
    for t_sf, sf, cap_w in neg_evening_before_neg_shortfall:
        prob += sf >= cap_w - ge_bat[t_sf]
    for t_sf, sf, cap_w in degraded_evening_export_shortfall:
        prob += sf >= cap_w - ge_bat[t_sf]
    for t_sl, sl, reserve_tgt in neg_evening_reserve_soc_slack:
        prob += soc[t_sl] <= float(reserve_tgt) + sl
    preneg_export_min_soc_wh = float(min_soc_wh) + max(
        float(battery.max_discharge_power_w)
        * float(battery.discharge_efficiency)
        * INTERVAL_H,
        1000.0,
    )
    per_slot_discharge_wh = max(
        float(battery.max_discharge_power_w)
        * float(battery.discharge_efficiency)
        * INTERVAL_H,
        0.0,
    )
    if om == "AUTO":
        profitable_export_ts = profitable_export_ts_pre
        export_push_w = _battery_export_cap_w(battery, grid)
        discharge_floor_wh = _planner_discharge_floor_wh(battery)
        # Tvrdý ranní/pre-neg export jen ve strict režimu (jinak ~25 % SoC + neg den → Infeasible).
        if not any_relaxed:
            for t_peak in morning_pre_neg_export_ts:
                if t_peak in profitable_export_ts:
                    if _battery_export_push_defer_to_pv(slots[t_peak]):
                        continue
                    prob += ge_bat[t_peak] >= export_push_w * z_export[t_peak]
                    prob += soc[t_peak] >= float(discharge_floor_wh)
            for t_pnd in pre_neg_buy_discharge_ts:
                if _battery_export_push_defer_to_pv(slots[t_pnd]):
                    continue
                prob += ge_bat[t_pnd] >= export_push_w * z_export[t_pnd]
            for t_empty in pre_neg_buy_empty_ts:
                if t_empty in discharge_export_slots:
                    if _battery_export_push_defer_to_pv(slots[t_empty]):
                        continue
                    prob += ge_bat[t_empty] >= export_push_w * z_export[t_empty]
        for t_early in sorted(evening_early_export_penalty_ts):
            prob += ge_bat[t_early] == 0
        if not evening_push_hard_suppressed:
            for t_peak in sorted(evening_push_ts):
                if t_peak not in discharge_export_slots:
                    continue
                if t_peak in battery_export_defer_pv_ts:
                    continue
                push_floor_w = _evening_push_battery_export_w(
                    slots[t_peak], battery, grid
                )
                if push_floor_w >= GE_MIN_EXPORT_W:
                    prob += z_export[t_peak] == 1
                    prob += ge_bat[t_peak] >= push_floor_w
                    prob += soc[t_peak] >= float(discharge_floor_wh)
        for t_pv in sorted(battery_export_defer_pv_ts):
            if t_pv in evening_push_ts:
                continue
            if t_pv in morning_pre_neg_export_ts:
                continue
            if t_pv in pre_neg_buy_discharge_ts:
                continue
            if t_pv in pre_neg_buy_empty_ts:
                continue
            prob += ge_bat[t_pv] == 0
            prob += z_export[t_pv] == 0
        # Nouzový relax: v noci jen vývoz k reserve večer D0; jinak ge_bat=0.
        if relaxed_solver_masks and not purchase_fixed_pre:
            reserve_wh_blk = float(
                getattr(battery, "reserve_soc_wh", getattr(battery, "min_soc_wh", 0.0))
            )
            for t_blk in range(T):
                if t_blk in degraded_evening_export_ts:
                    continue
                if not _in_night_battery_export_window(slots[t_blk]):
                    continue
                prob += ge_bat[t_blk] == 0
                prob += z_export[t_blk] == 0
            for t_ev in sorted(degraded_evening_export_ts):
                m_soc_deg = float(battery.usable_capacity_wh)
                prob += soc[t_ev] >= float(reserve_wh_blk) - m_soc_deg * (
                    1 - z_export[t_ev]
                )
        # Ostatní profitable sloty: měkká shortfall penalizace (ne večerní push).
    if (
        last_pos_sell_pre_neg_buy is not None
        and pos_sell_soc_shortfall is not None
    ):
        prob += (
            soc[last_pos_sell_pre_neg_buy]
            >= float(battery.soc_max_wh) - pos_sell_soc_shortfall
        )
    if (
        t_pre_neg_buy_anchor is not None
        and pre_neg_buy_soc_ceiling_wh is not None
        and soc_pre_neg_buy_ceiling_slack is not None
        and last_pos_sell_pre_neg_buy is not None
    ):
        prob += (
            soc[t_pre_neg_buy_anchor]
            <= float(pre_neg_buy_soc_ceiling_wh) + soc_pre_neg_buy_ceiling_slack
        )

    for t in range(T):
        s = slots[t]
        pv_a_net = s.pv_a_forecast_w - ca[t]

        ev_total_t = pulp.lpSum(ev_direct[e][t] + ev_via_bat[e][t] for e in range(EV))

        # Energetická bilance
        pv_b_effective = (
            float(s.pv_b_forecast_w) * (1 - z_gen_cutoff[t])
            if z_gen_cutoff is not None
            else float(s.pv_b_forecast_w)
        )
        pv_total_ub = float(s.pv_a_forecast_w) + float(s.pv_b_forecast_w)

        # Součet nabíjení z FVE + ze sítě nesmí překročit max_charge_power_w baterie.
        prob += bc_pv[t] + bc_gi[t] <= battery.max_charge_power_w
        # Breaker: import ze site je tvrdě omezen (gi_over jen numerická pojistka).
        prob += gi[t] <= gi_upper

        if om == "AUTO":
            load_site_expr = float(s.load_baseline_w) + ev_total_t + hp[t]
            ev_cap_slot_w = sum(
                float(vehicles[e].max_charge_power_w)
                for e in range(EV)
                if (e == 0 and s.ev1_connected) or (e == 1 and s.ev2_connected)
            )
            max_load_site_w = (
                float(s.load_baseline_w)
                + ev_cap_slot_w
                + float(heat_pump.rated_heating_power_w)
            )
            # BMS: jedno vybíjení — bilance při gi≈0 dá bd≈load+ge_bat; bd+ge_bat≤max by export
            # započítalo dvakrát ((max−load)/2). Exportní sloty: load+ge_bat; jinak bd≤max.
            prob += bd[t] <= battery.max_discharge_power_w
            if t in discharge_export_slots:
                prob += load_site_expr + ge_bat[t] <= battery.max_discharge_power_w
            prob += pv_ld[t] + pv_sp[t] == pv_a_net + pv_b_effective
            prob += pv_ld[t] <= load_site_expr
            prob += pv_ld[t] <= pv_a_net + pv_b_effective
            prob += pv_sp[t] <= pv_total_ub
            prob += pv_sp[t] >= pv_a_net + pv_b_effective - load_site_expr
            prob += bc_pv[t] <= pv_sp[t]
            prob += bc_gi[t] <= gi[t]
            prob += ge_pv[t] <= pv_sp[t]
            prob += bc_pv[t] + ge_pv[t] <= pv_sp[t]
            # Tvrdý load-first (Deye): při dostatečné FVE jen grid-nabíjení (bc_gi); jinak gi smí
            # krmit deficit domu (noc / nízká FVE), ne fiktivně paralelně s plným PV→bc_pv.
            house_grid_import_cap_w = max(
                0.0,
                max_load_site_w - pv_total_ub,
            )
            prob += gi[t] <= bc_gi[t] + house_grid_import_cap_w
            pv_covers_load_site = (
                pv_total_ub >= max_load_site_w + NIGHT_EXPORT_PV_SUNRISE_SURPLUS_W
            )
            if pv_covers_load_site:
                prob += pv_ld[t] >= load_site_expr
            # Vybíjení do domu až po pv_ld; v exportních slotech smí bd→síť.
            if t not in discharge_export_slots:
                prob += bd[t] <= load_site_expr - pv_ld[t]
                if pv_covers_load_site:
                    prob += pv_ld[t] >= load_site_expr - bd[t]
                else:
                    prob += pv_ld[t] >= load_site_expr - gi[t] - bd[t]
            # Plná bilance (pv_ld+pv_sp rozpad je ortogonální k tokům přebytku).
            prob += (
                pv_a_net + pv_b_effective + gi[t] + bd[t]
                == float(s.load_baseline_w) + ev_total_t + hp[t] + bc_pv[t] + bc_gi[t] + ge[t]
            )
        else:
            prob += pv_ld[t] == 0
            prob += pv_sp[t] == pv_a_net + pv_b_effective
            prob += bc_pv[t] <= pv_sp[t]
            prob += bc_gi[t] <= gi[t]
            prob += (
                pv_a_net + pv_b_effective + gi[t] + bd[t]
                == s.load_baseline_w + ev_total_t + hp[t] + bc_pv[t] + bc_gi[t] + ge[t]
            )
            prob += bd[t] + ge_bat[t] <= battery.max_discharge_power_w

        prob += ge[t] == ge_pv[t] + ge_bat[t]
        # Baterie nesmí „přestrojit“ FVE export: jen z pv_sp (po load-first).
        if om == "AUTO":
            prob += ge_bat[t] >= ge[t] - pv_sp[t]
        else:
            prob += ge_bat[t] >= ge[t] - (pv_a_net + pv_b_effective)

        # Měkký breaker cap: gi_over[t] >= max(0, gi[t] - breaker).
        prob += gi_over[t] >= gi[t] - float(grid.max_import_power_w)

        # SoC kontinuita: bd je v bilanci zdroj na AC sběrnici; při exportu z baterie už
        # obsahuje load + ge_bat (ge = ge_pv + ge_bat). ge_bat znovu neodečítat.
        soc_prev = current_soc_wh if t == 0 else soc[t - 1]
        prob += soc[t] == (
            soc_prev
            + (bc_pv[t] + bc_gi[t]) * battery.charge_efficiency * INTERVAL_H
            - bd[t] / battery.discharge_efficiency * INTERVAL_H
        )

        sv = safety_vars[t]
        tgt_s = slots[t].safety_soc_target_wh if daytime_en else None
        if sv is not None:
            eff_tgt_s = float(tgt_s) if tgt_s is not None else float(min_soc_wh)
            if (
                neg_sell_phases_en
                and float(s.sell_price) < 0.0
                and neg_sell_soc_target_by_t[t] is not None
            ):
                eff_tgt_s = max(eff_tgt_s, float(neg_sell_soc_target_by_t[t]))
            elif (
                om == "AUTO"
                and float(s.buy_price) < 0.0
                and t in charge_slots
                and len(neg_buy_slot_indices_pre) >= 2
                and not neg_sell_phases_en
            ):
                # buy<0: cíl soc_max jen při víceslotovém okně (jinak fyzicky neřešitelné).
                eff_tgt_s = max(eff_tgt_s, float(battery.soc_max_wh))
            elif post_neg_pv_topup[t]:
                # Po konci sell<0: dobit z FVE na plno, pak teprve export (kladný sell, ne večerní peak).
                eff_tgt_s = max(eff_tgt_s, float(battery.soc_max_wh))
            prob += sv >= eff_tgt_s - soc[t]

        # ev_via_bat kryto z discharge
        prob += pulp.lpSum(ev_via_bat[e][t] for e in range(EV)) <= bd[t]

        # GEN port cut-off chceme vůbec připustit jen v režimech/politikách, kde má smysl:
        # - SELF_SUSTAIN (no-export intent; typicky ge=0, takže cut-off je bezpečnostní ventil),
        # - BLOCK_EXPORT okna (v projektu reprezentované sloty se sell_price < 0),
        # - případně explicitní no_export politika (pokud bude v kontextu dostupná).
        allow_gen_cutoff = (
            om == "SELF_SUSTAIN"
            or float(s.sell_price) < 0
            or bool(getattr(grid, "no_export", False))
        )
        if z_gen_cutoff is not None and not allow_gen_cutoff:
            prob += z_gen_cutoff[t] == 0

        # Záporná nákupní cena → import jen na load + nabíjení + EV + TČ (stále ≤ breaker).
        if s.buy_price < 0:
            prob += gi[t] <= min(
                gi_upper,
                float(s.load_baseline_w)
                + battery.max_charge_power_w
                + sum(v.max_charge_power_w for v in vehicles)
                + heat_pump.rated_heating_power_w,
            )
            prob += ge[t] == 0
            prob += ge_pv[t] == 0
            prob += ge_bat[t] == 0
            if z_gen_cutoff is not None and float(s.sell_price) < 0.0:
                prob += z_gen_cutoff[t] == 1
            # PV A: měkký tlak curtail (NEG_SELL_CURTAIL při buy<0), ne tvrdé bc_pv=0
            # (s polem B a bilancí může být bc_pv=0 nutné pro řešitelnost krátkých okének).

        # Záporný prodej (sell < 0): výboj baterie jen před extrémně záporným buy (v11).
        # Export FVE při sell<0: spot = nabíjení/curtail A; ventil jen pole B při plné baterii.
        if s.sell_price < 0:
            prob += w_arb[t] == 0
            prob += bd[t] <= pulp.lpSum(ev_via_bat[e][t] for e in range(EV))
            # buy<0: export už zakázán výše; neaplikovat sell<0 ventil (bilance / infeasible).
            if float(s.buy_price) < 0.0:
                continue
            block_neg_sell_export_t = bool(
                getattr(grid, "block_export_on_negative_sell", False)
            )
            if t not in neg_sell_bat_dump_slots:
                prob += ge_bat[t] == 0
            ev_cap_neg = sum(
                float(vehicles[e].max_charge_power_w)
                for e in range(EV)
                if (e == 0 and s.ev1_connected) or (e == 1 and s.ev2_connected)
            )
            load_neg = (
                float(s.load_baseline_w)
                + ev_cap_neg
                + float(heat_pump.rated_heating_power_w)
            )
            pv_surplus_neg_w = max(
                0.0,
                float(s.pv_a_forecast_w) + float(s.pv_b_forecast_w) - load_neg,
            )
            # FVE→síť při záporném výkupu: u KV1 (block_export) jen bc/curtail A;
            # u home-01 s polem B musí přebytek jít do sítě (ge_pv), jinak infeasible.
            block_pv_export_neg_sell = bool(
                getattr(grid, "block_export_on_negative_sell", False)
            ) or (
                float(s.pv_b_forecast_w) <= 0
                and not _pv_forced_vent_export_allowed(
                    t,
                    current_soc_wh=current_soc_wh,
                    battery=battery,
                    soc_headroom_wh=soc_headroom_wh,
                    pv_surplus_w=pv_surplus_neg_w,
                )
            )
            if block_pv_export_neg_sell:
                prob += ge_pv[t] == 0
            # Tvrdý zákaz vývozu jen při block_export_on_negative_sell (KV1).
            if block_neg_sell_export_t:
                prob += ge[t] == 0
                prob += ge_pv[t] == 0
                prob += ge_bat[t] == 0
            elif purchase_fixed_pre:
                # Fixní nákup + spot výkup (BA81, KV1 bez block_export): sell<0 = platíš za vývoz.
                prob += ge[t] == 0
                prob += ge_pv[t] == 0
            elif not purchase_fixed_pre:
                # Spot: sell<0 před buy<0 — PV (A) do baterie, B může jít do sítě (ge_pv≤pv_b).
                # Po buy<0 / mimo ranní pásmo: ventil B jen při plné baterii (nebo tail + sell práh).
                before_first_neg_buy = (
                    first_neg_buy_idx is not None and t < first_neg_buy_idx
                )
                vent_min_sell = getattr(
                    battery, "planner_neg_sell_vent_min_sell_czk_kwh", None
                )
                tail_free_vent = bool(
                    neg_sell_phases_en
                    and neg_sell_phase_by_t[t] == "tail"
                    and vent_min_sell is not None
                    and float(s.sell_price) >= float(vent_min_sell)
                )
                if tail_free_vent and float(s.pv_b_forecast_w) > 0:
                    prob += ge_pv[t] <= float(s.pv_b_forecast_w)
                elif before_first_neg_buy:
                    if float(s.pv_b_forecast_w) > 0:
                        prob += ge_pv[t] <= float(s.pv_b_forecast_w)
                else:
                    soc_prev_neg = current_soc_wh if t == 0 else soc[t - 1]
                    w_pv_b_vent = pulp.LpVariable(
                        f"w_pv_b_vent_neg_{t}", cat=pulp.LpBinary
                    )
                    m_soc_neg = float(battery.soc_max_wh)
                    prob += soc_prev_neg >= (
                        m_soc_neg
                        - soc_headroom_wh
                        - m_soc_neg * (1 - w_pv_b_vent)
                    )
                    prob += ge_pv[t] <= float(s.pv_b_forecast_w) * w_pv_b_vent

            # GEN/MI cut-off ON když LP zakazuje vývoz — bez cut-off únik PV B na GEN portu do sítě.
            if z_gen_cutoff is not None:
                if block_neg_sell_export_t or purchase_fixed_pre:
                    prob += z_gen_cutoff[t] == 1
                elif block_pv_export_neg_sell:
                    prob += z_gen_cutoff[t] == 1

        soc_prev_expr = current_soc_wh if t == 0 else soc[t - 1]
        arb_t = arb_floor_series[t]
        soc_low_t = soc_panel_min[t]
        # Při relaxovaném dnu (soc_low pod DB min_soc Wh) nesmí větev w_arb=1 znovu vynutit arb_t
        # (typicky ~rezerva 20 %) — jinak nejde „vypustit“ baterku k planner floor 5 %.
        if soc_low_t < min_soc_wh - 1e-3:
            arb_cap_t = min(arb_t, soc_low_t)
        else:
            arb_cap_t = arb_t
        if om == "AUTO" and t in discharge_export_slots:
            prob += soc_prev_expr >= (
                arb_cap_t - (arb_cap_t - soc_low_t) * (1 - w_arb[t])
            )
            prob += bd[t] <= (
                battery.max_discharge_power_w * w_arb[t]
                + pulp.lpSum(ev_via_bat[e][t] for e in range(EV))
            )
        elif om == "AUTO":
            # PASSIVE: vlastní spotřeba (bd); export baterie jen ge_bat (ge_bat=0 níže).
            prob += soc_prev_expr >= (
                arb_cap_t - (arb_cap_t - soc_low_t) * (1 - w_arb[t])
            )
            prob += bd[t] <= (
                s.load_baseline_w
                + ev_total_t
                + hp[t]
                + bc_pv[t]
                + bc_gi[t]
            )
        else:
            prob += soc_prev_expr >= (
                arb_cap_t - (arb_cap_t - soc_low_t) * (1 - w_arb[t])
            )
            prob += bd[t] <= (
                s.load_baseline_w
                + ev_total_t
                + hp[t]
                + bc_pv[t]
                + bc_gi[t]
                + battery.max_discharge_power_w * w_arb[t]
            )

        # Významný export z baterie ⇒ koncové SoC ≥ podlaha (FVE export ge_pv bez této podlahy).
        m_ge = float(grid.max_export_power_w)
        m_soc_bigm = float(battery.usable_capacity_wh)
        if t in neg_sell_bat_dump_slots:
            prob += ge_bat[t] <= m_ge
        else:
            prob += ge_bat[t] <= m_ge * z_export[t]
            prob += ge_bat[t] >= GE_MIN_EXPORT_W * z_export[t]
        # Bez hluboké relaxace: export končí ≥ rezerva. Při hluboké relaxaci (soc_panel_min pod min_soc)
        # sladit s LP spodkem — jinak z_export vynutil arb_base a blokoval vývoz k planner floor.
        if (
            om == "AUTO"
            and first_neg_sell_idx is not None
            and t < first_neg_sell_idx
            and floor_pct is not None
        ):
            export_soc_floor_t = float(planner_floor_effective_wh)
        elif om == "AUTO" and t in pre_neg_buy_discharge_ts:
            export_soc_floor_t = float(min_soc_wh)
        elif om == "AUTO" and t in pre_neg_buy_empty_ts:
            export_soc_floor_t = float(min_soc_wh)
        elif (
            om == "AUTO"
            and t in morning_pre_neg_export_ts
            and floor_pct is not None
        ):
            export_soc_floor_t = float(planner_floor_effective_wh)
        elif soc_panel_min[t] < min_soc_wh - 1e-3:
            export_soc_floor_t = float(soc_panel_min[t])
        else:
            export_soc_floor_t = float(arb_base_wh)
            # Večerní exportní slot: podlaha jen min_soc (ne safety ramp), aby šlo vybít při z_export=1.
            # Nouzový relaxed_solver_masks: export nikdy pod reserve_soc (ekonomická podlaha).
            if (
                om == "AUTO"
                and t in discharge_export_slots
                and (
                    t in evening_peak_export_ts
                    or t in neg_evening_push_ts
                )
                and not relaxed_solver_masks
            ):
                export_soc_floor_t = float(min_soc_wh)
            elif relaxed_solver_masks and om == "AUTO":
                export_soc_floor_t = max(
                    export_soc_floor_t,
                    float(getattr(battery, "reserve_soc_wh", arb_base_wh)),
                )
            # Safety export floor: v běžných (ne high-sell) slotech nevybít exportem energii potřebnou pro
            # robustnost/noční baseload. Použije se pouze pokud je safety target v SQL vyplněný.
            tgt_s = slots[t].safety_soc_target_wh if daytime_en else None
            if (
                tgt_s is not None
                and not high_sell_slot[t]
                and t not in profitable_export_ts_pre
                and not (
                    om == "AUTO"
                    and t in discharge_export_slots
                    and t in evening_peak_export_ts
                )
            ):
                export_soc_floor_t = max(
                    export_soc_floor_t,
                    min(
                        float(battery.soc_max_wh),
                        max(min_soc_wh, float(tgt_s)),
                    ),
                )
        prob += soc[t] >= export_soc_floor_t - m_soc_bigm * (1 - z_export[t])

        # EV – limity a připojení
        for e in range(EV):
            connected = (
                (e == 0 and s.ev1_connected) or
                (e == 1 and s.ev2_connected)
            )
            if not connected:
                prob += ev_direct[e][t]  == 0
                prob += ev_via_bat[e][t] == 0
            else:
                prob += ev_direct[e][t] + ev_via_bat[e][t] <= vehicles[e].max_charge_power_w

    for tt, cv, prev in commit_lp:
        prob += cv >= prev - (bc_pv[tt] + bc_gi[tt])

    if om == "SELF_SUSTAIN":
        for t in range(T):
            prob += gi[t] <= slots[t].load_baseline_w
    elif om == "PRESERVE":
        for t in range(T):
            prob += bc_pv[t] == 0
            prob += bc_gi[t] == 0
            prob += bd[t] == 0
    elif om == "CHARGE_CHEAP":
        for t in range(T):
            prob += ge[t] == 0
            prob += ge_pv[t] == 0
            prob += ge_bat[t] == 0
            prob += bd[t] == 0

    # Slot pre-selection (z DB fn_load_planning_slots_full → allow_*)
    if om == "AUTO":
        for t in range(T):
            s = slots[t]
            sell_t_pre = float(s.sell_price)
            pv_surplus_w = max(
                0,
                int(s.pv_a_forecast_w)
                + int(s.pv_b_forecast_w)
                - int(s.load_baseline_w),
            )
            pv_surplus_for_gi = pv_surplus_w
            if (
                t in charge_slots
                and sell_t_pre < 0
                and pv_surplus_for_gi > 0
                and float(s.buy_price) >= 0.0
            ):
                prob += bc_gi[t] == 0
            if float(s.buy_price) < 0.0:
                pass
            elif (
                first_neg_buy_idx is not None
                and first_neg_buy_idx > 0
                and t in pos_sell_pre_neg_buy_ts
                and t not in pos_sell_pre_neg_buy_ge_exempt_ts
                and not relaxed_pos_sell_ge_block
            ):
                prob += ge[t] == 0
                prob += ge_pv[t] == 0
                prob += ge_bat[t] == 0
            elif t not in charge_slots:
                if float(s.buy_price) >= 0.0:
                    prob += bc_gi[t] == 0
                if float(s.buy_price) >= 0.0:
                    if pv_surplus_w <= 0:
                        prob += bc_pv[t] == 0
                    else:
                        prob += bc_pv[t] <= float(pv_surplus_w)
            if (
                t not in discharge_export_slots
                and t not in neg_sell_bat_dump_slots
                and t not in pre_neg_buy_discharge_ts
                and t not in pre_neg_buy_empty_ts
            ):
                prob += ge_bat[t] == 0
                prob += z_export[t] == 0
        for t_pne in pre_neg_pv_export_ts:
            # v33: při dostatečné FVE v sell<0 okně neukládat ranní PV do baterie — export.
            prob += bc_pv[t_pne] == 0

        # v44: neg den — před 1. sell<0 žádné grid→bat (AM sloty za ~3 Kč vs FVE v okně).
        if neg_sell_phases_en and first_neg_sell_idx is not None:
            neg_day = _prague_calendar_date(slots[first_neg_sell_idx])
            for t_blk in range(first_neg_sell_idx):
                if _prague_calendar_date(slots[t_blk]) != neg_day:
                    continue
                prob += bc_gi[t_blk] == 0

        # Ekonomické guardy: ceny v objective nestačí proti maskám / terminal SoC.
        # Referenční buy jen z ne-záporných slotů: jinak jeden buy<0 v horizontu označí
        # téměř všechny sloty jako „drahé“ (gi=0 pro dům) → Infeasible (home-01).
        non_negative_buys = [
            float(s.buy_price) for s in slots if float(s.buy_price) >= 0.0
        ]
        ref_buy_horizon = (
            min(non_negative_buys)
            if non_negative_buys
            else min(float(s.buy_price) for s in slots)
        )
        min_spread = float(degradation_cost_effective)
        for t in range(T):
            s = slots[t]
            buy_t = float(s.buy_price)
            sell_t = float(s.sell_price)
            load_t = float(s.load_baseline_w)
            ev_cap_t = sum(
                float(vehicles[e].max_charge_power_w)
                for e in range(EV)
                if (e == 0 and s.ev1_connected) or (e == 1 and s.ev2_connected)
            )
            pv_surplus_w = max(
                0.0,
                float(s.pv_a_forecast_w) + float(s.pv_b_forecast_w) - load_t,
            )
            # FVE export před sell<0 jen pokud forecast v sell<0 okně pokryje dobítí (v33).
            allow_pre_neg_pv_export = t in pre_neg_pv_export_ts
            pv_store_val = _pv_store_value_czk_kwh(s, min_spread)
            fixed_pre_neg_pv_export = (
                purchase_fixed_pre
                and sell_t >= 0.0
                and pv_surplus_w > NIGHT_EXPORT_PV_SUNRISE_SURPLUS_W
                and (
                    first_neg_sell_idx is None
                    or t < first_neg_sell_idx
                )
            )
            fixed_block_pv_surplus_export = (
                purchase_fixed_pre
                and bool(getattr(grid, "block_export_on_negative_sell", False))
                and sell_t >= 0.0
                and pv_surplus_w > NIGHT_EXPORT_PV_SUNRISE_SURPLUS_W
            )
            # BA81: ge_pv≤pv_b jen při významném poli A — při úsvitu nechat Deye bez plného curtail A.
            fixed_mi_low_pv_surplus_export = (
                purchase_fixed_pre
                and float(s.pv_b_forecast_w) > 0
                and not getattr(grid, "block_export_on_negative_sell", False)
                and sell_t >= 0.0
                and int(s.pv_a_forecast_w) < DAWN_LOW_PV_NO_CURTAIL_W
                and pv_surplus_w > 0.0
            )
            skip_pv_store_block = (
                (
                    float(s.pv_b_forecast_w) > 0
                    and not getattr(grid, "block_export_on_negative_sell", False)
                    and sell_t < 0
                    and buy_t >= 0.0
                    and not purchase_fixed_pre
                    and (
                        first_neg_buy_idx is None
                        or t < first_neg_buy_idx
                    )
                )
                or (
                    # Spot: při sell>=0 neblokovat ge_pv (export vs bc_pv; večerní peak = ge_bat).
                    not purchase_fixed_pre
                    and sell_t >= 0
                    and pv_surplus_w > NIGHT_EXPORT_PV_SUNRISE_SURPLUS_W
                )
                or fixed_pre_neg_pv_export
                or fixed_block_pv_surplus_export
                or fixed_mi_low_pv_surplus_export
            )
            # Spot: mezi-slotová arbitráž — grid→bat jen když buy ≤ charge_acquisition (v61).
            spot_grid_charge_not_cheap_buy = (
                not purchase_fixed_pre
                and buy_t >= 0.0
                and buy_t > charge_acquisition_czk_kwh + min_spread
            )
            fixed_pv_b_export_cap = (
                purchase_fixed_pre
                and float(s.pv_b_forecast_w) > 0
                and not getattr(grid, "block_export_on_negative_sell", False)
                and sell_t >= 0
                and not fixed_pre_neg_pv_export
                and int(s.pv_a_forecast_w) >= DAWN_LOW_PV_NO_CURTAIL_W
            )
            if spot_grid_charge_not_cheap_buy:
                prob += bc_gi[t] == 0
            if (
                purchase_fixed_pre
                and t in evening_push_ts
                and sell_t > buy_t + min_spread
            ):
                prob += bc_pv[t] == 0
                prob += bc_gi[t] == 0
            if fixed_pre_neg_pv_export:
                prob += ge_pv[t] <= max(0.0, pv_surplus_w)
            elif fixed_pv_b_export_cap:
                if z_gen_cutoff is not None:
                    prob += ge_pv[t] <= float(s.pv_b_forecast_w) * (1 - z_gen_cutoff[t])
                else:
                    prob += ge_pv[t] <= max(0.0, float(s.pv_b_forecast_w))
            if (
                not allow_pre_neg_pv_export
                and not skip_pv_store_block
                and not fixed_pv_b_export_cap
                and sell_t < pv_store_val
                and not (
                    sell_t >= 0.0
                    and int(s.pv_a_forecast_w) < DAWN_LOW_PV_NO_CURTAIL_W
                )
                and not _pv_forced_vent_export_allowed(
                    t,
                    current_soc_wh=current_soc_wh,
                    battery=battery,
                    soc_headroom_wh=soc_headroom_wh,
                    pv_surplus_w=pv_surplus_w,
                )
            ):
                prob += ge_pv[t] == 0
            # Při `sell < 0` exportovat MAX pole B (má green bonus 7+ Kč/kWh → čistá hodnota
            # i při sell=-1 = +6 Kč). Pole A green bonus nemá → export A za sell<0 je čistá ztráta.
            # Constraint: ge_pv ≤ pv_b_forecast_w (pole A jde do baterie / curtail).
            # Aplikuje se jen u sites bez block_export_on_negative_sell (home-01 áno; KV1 ne)
            # A jen pokud reálně existuje pole B (pv_b_forecast_w > 0 — jinak by ge_pv ≤ 0
            # zablokovalo legitimní pre-neg-pv export pole A z testů).
            if (
                sell_t < 0
                and buy_t >= 0.0
                and float(s.pv_b_forecast_w) > 0
                and not getattr(grid, "block_export_on_negative_sell", False)
            ):
                prob += ge_pv[t] <= float(s.pv_b_forecast_w)
            # Drahý nákup: dům + TČ z baterie (ne import ze sítě); síť jen EV (+ případně TČ).
            # Spot (home-01): buy > min ne-záporného buy v horizontu.
            # Fixní tarif (KV1): navíc buy > charge_acquisition (konstantní buy ≈ ref).
            expensive_import_slot = buy_t > ref_buy_horizon + min_spread
            if purchase_fixed_pre and buy_t >= 0.0:
                # KV1/BA81: buy skoro konstantní — buy > acq nikdy neplatí, jinak v noci import za 6 Kč.
                expensive_import_slot = True
            elif fixed_tariff_like_pre:
                expensive_import_slot = expensive_import_slot or (
                    buy_t > charge_acquisition_czk_kwh + min_spread
                )
            if expensive_import_slot and buy_t >= 0.0:
                force_night_self_consume = (
                    relaxed_solver_masks
                    and t in degraded_relaxed_night_ts
                    and t not in degraded_evening_export_ts
                )
                if force_night_self_consume or (
                    expensive_import_slot and t not in charge_slots
                ):
                    # Strict: síť jen EV+TČ; baseload z baterie/FVE.
                    # Relaxed: síť smí baseload jen mimo night_self_consume (v46).
                    night_self_consume_slot = (
                        om == "AUTO"
                        and (
                            t in night_self_consume_discourage_ts
                            or t in post_evening_push_night_ts
                            or force_night_self_consume
                        )
                    )
                    if (relaxed_expensive_import or late_replan_solver_relax) and not night_self_consume_slot:
                        prob += gi[t] <= ev_cap_t + hp[t] + float(s.load_baseline_w)
                    else:
                        prob += gi[t] <= ev_cap_t + hp[t]
                    if (
                        force_night_self_consume
                        or (not (relaxed_expensive_import or late_replan_solver_relax) or night_self_consume_slot)
                    ) and om == "AUTO":
                        prob += (
                            bd[t] + pv_ld[t]
                            >= float(s.load_baseline_w) + hp[t]
                        )
            # Anti souběžný vývoz FVE + významný import (mikrocyklus).
            if buy_t > sell_t + min_spread and pv_surplus_w > 0:
                prob += ge_pv[t] <= pv_surplus_w

    # Deadline constraints pro EV
    for e, session in enumerate(ev_sessions):
        if session and session.target_deadline and session.energy_needed_wh > 0:
            t_dl = next(
                (t for t, s in enumerate(slots) if s.interval_start >= session.target_deadline),
                T - 1
            )
            prob += pulp.lpSum(
                (ev_direct[e][t] + ev_via_bat[e][t]) * INTERVAL_H
                for t in range(t_dl + 1)
                if (e == 0 and slots[t].ev1_connected) or (e == 1 and slots[t].ev2_connected)
            ) >= session.energy_needed_wh

    # TUV look-ahead podle tuv_usage_stats (DOW+hodina, konvence jako v DB)
    if (
        tuv_delta_stats
        and heat_pump.rated_heating_power_w > 0
        and getattr(heat_pump, "tuv_min_temp_c", 0) is not None
    ):
        tuv_pred = float(current_tuv_temp_c)
        tgt = float(getattr(heat_pump, "tuv_target_temp_c", 55.0) or 55.0)
        thr = float(heat_pump.tuv_min_temp_c) + 5.0
        for t in range(T):
            dow, hour = _prague_dow_hour(slots[t].interval_start)
            delta = tuv_delta_stats.get((dow, hour), -0.1)
            tuv_pred += float(delta) * INTERVAL_H
            if tuv_pred < thr:
                prob += (
                    pulp.lpSum(hp[s] for s in range(max(0, t - 8), t + 1))
                    >= heat_pump.rated_heating_power_w * 0.5
                )
                tuv_pred = tgt

    # Nouzový ohřev TUV
    if current_tuv_temp_c < heat_pump.tuv_min_temp_c:
        prob += hp[0] >= heat_pump.rated_heating_power_w * 0.8

    # SoC bezpečnostní buffer vyhodnocený až na konci 24h horizontu
    eod_idx = min(T - 1, int(24 / INTERVAL_H) - 1)
    prob += soc_deficit_24h >= soc_buffer_target_wh - soc[eod_idx]

    # --- Řešení (HiGHS přes highspy / PuLP API; bez externí binárky HiGHS_CMD) ---
    t_start = time.monotonic()
    try:
        solver = pulp.getSolver(
            "HiGHS", msg=False, timeLimit=SOLVER_TIME_LIMIT
        )
    except Exception:
        logger.warning("HiGHS nedostupný, používám CBC fallback")
        solver = pulp.PULP_CBC_CMD(msg=False, timeLimit=SOLVER_TIME_LIMIT)
    status = prob.solve(solver)
    duration_ms = int((time.monotonic() - t_start) * 1000)

    if pulp.LpStatus[status] != "Optimal":
        if not relaxed_expensive_import:
            logger.warning(
                "solve_dispatch Infeasible, retry with relaxed_expensive_import "
                "(grid may supply baseload in expensive slots)"
            )
            return solve_dispatch(
                slots,
                battery,
                heat_pump,
                grid,
                ev_sessions,
                vehicles,
                current_soc_wh,
                current_tuv_temp_c,
                tuv_delta_stats=tuv_delta_stats,
                operating_mode=operating_mode,
                charge_commitment_prev_w=charge_commitment_prev_w,
                planner_version=planner_version,
                relaxed_expensive_import=True,
                evening_push_ts_override=evening_push_ts_override,
            )
        if not relaxed_neg_buy_charge:
            logger.warning(
                "solve_dispatch still Infeasible, retry without neg_buy_charge_shortfall"
            )
            return solve_dispatch(
                slots,
                battery,
                heat_pump,
                grid,
                ev_sessions,
                vehicles,
                current_soc_wh,
                current_tuv_temp_c,
                tuv_delta_stats=tuv_delta_stats,
                operating_mode=operating_mode,
                charge_commitment_prev_w=charge_commitment_prev_w,
                planner_version=planner_version,
                relaxed_expensive_import=True,
                relaxed_neg_buy_charge=True,
                evening_push_ts_override=evening_push_ts_override,
            )
        if not relaxed_neg_prep_hold_only:
            logger.warning(
                "solve_dispatch still Infeasible, retry with relaxed_neg_prep_hold_only "
                "(skip prep_soc_shortfall and prep hold binárek; evening push unchanged)"
            )
            return solve_dispatch(
                slots,
                battery,
                heat_pump,
                grid,
                ev_sessions,
                vehicles,
                current_soc_wh,
                current_tuv_temp_c,
                tuv_delta_stats=tuv_delta_stats,
                operating_mode=operating_mode,
                charge_commitment_prev_w=charge_commitment_prev_w,
                planner_version=planner_version,
                relaxed_expensive_import=True,
                relaxed_neg_buy_charge=True,
                relaxed_neg_prep_hold_only=True,
                evening_push_ts_override=evening_push_ts_override,
            )
        if not relaxed_neg_prep_window:
            logger.warning(
                "solve_dispatch still Infeasible, retry with relaxed_neg_prep_window "
                "(skip strict pre-neg bundle; future_neg_buy evening export kept)"
            )
            return solve_dispatch(
                slots,
                battery,
                heat_pump,
                grid,
                ev_sessions,
                vehicles,
                current_soc_wh,
                current_tuv_temp_c,
                tuv_delta_stats=tuv_delta_stats,
                operating_mode=operating_mode,
                charge_commitment_prev_w=charge_commitment_prev_w,
                planner_version=planner_version,
                relaxed_expensive_import=True,
                relaxed_neg_buy_charge=True,
                relaxed_neg_prep_hold_only=True,
                relaxed_neg_prep_window=True,
                neg_sell_phases_fallback=neg_sell_phases_fallback,
                evening_push_ts_override=evening_push_ts_override,
            )
        if not neg_sell_phases_fallback:
            logger.warning(
                "solve_dispatch still Infeasible, retry with neg_sell phases disabled "
                "(prep_soc_percent=100)"
            )
            battery_no_phases = SimpleNamespace(
                **{
                    **vars(battery),
                    "planner_neg_sell_prep_soc_percent": 100.0,
                }
            )
            return solve_dispatch(
                slots,
                battery_no_phases,
                heat_pump,
                grid,
                ev_sessions,
                vehicles,
                current_soc_wh,
                current_tuv_temp_c,
                tuv_delta_stats=tuv_delta_stats,
                operating_mode=operating_mode,
                charge_commitment_prev_w=charge_commitment_prev_w,
                planner_version=planner_version,
                relaxed_expensive_import=True,
                relaxed_neg_buy_charge=True,
                relaxed_neg_prep_hold_only=True,
                relaxed_neg_prep_window=True,
                neg_sell_phases_fallback=True,
                evening_push_ts_override=evening_push_ts_override,
            )
        if not relaxed_pos_sell_ge_block:
            logger.warning(
                "solve_dispatch still Infeasible, retry with relaxed_pos_sell_ge_block "
                "(no ge=0 on pos_sell before buy<0)"
            )
            battery_no_phases = SimpleNamespace(
                **{
                    **vars(battery),
                    "planner_neg_sell_prep_soc_percent": 100.0,
                }
            )
            return solve_dispatch(
                slots,
                battery_no_phases,
                heat_pump,
                grid,
                ev_sessions,
                vehicles,
                current_soc_wh,
                current_tuv_temp_c,
                tuv_delta_stats=tuv_delta_stats,
                operating_mode=operating_mode,
                charge_commitment_prev_w=charge_commitment_prev_w,
                planner_version=planner_version,
                relaxed_expensive_import=True,
                relaxed_neg_buy_charge=True,
                relaxed_neg_prep_hold_only=True,
                relaxed_neg_prep_window=True,
                neg_sell_phases_fallback=True,
                relaxed_pos_sell_ge_block=True,
                evening_push_ts_override=evening_push_ts_override,
            )
        if not relaxed_solver_masks:
            logger.warning(
                "solve_dispatch still Infeasible, retry with relaxed_solver_masks "
                "(permissive slot masks; neg-evening hard bundle off)"
            )
            battery_no_phases = SimpleNamespace(
                **{
                    **vars(battery),
                    "planner_neg_sell_prep_soc_percent": 100.0,
                }
            )
            return solve_dispatch(
                slots,
                battery_no_phases,
                heat_pump,
                grid,
                ev_sessions,
                vehicles,
                current_soc_wh,
                current_tuv_temp_c,
                tuv_delta_stats=tuv_delta_stats,
                operating_mode=operating_mode,
                charge_commitment_prev_w=charge_commitment_prev_w,
                planner_version=planner_version,
                relaxed_expensive_import=True,
                relaxed_neg_buy_charge=True,
                relaxed_neg_prep_hold_only=True,
                relaxed_neg_prep_window=True,
                neg_sell_phases_fallback=True,
                relaxed_pos_sell_ge_block=True,
                relaxed_solver_masks=True,
                evening_push_ts_override=evening_push_ts_override,
            )
        raise PlannerSolverError(
            pulp.LpStatus[status],
            relax_chain=_solver_relax_chain(
                relaxed_expensive_import=relaxed_expensive_import,
                relaxed_neg_buy_charge=relaxed_neg_buy_charge,
                relaxed_neg_prep_hold_only=relaxed_neg_prep_hold_only,
                relaxed_neg_prep_window=relaxed_neg_prep_window,
                neg_sell_phases_fallback=neg_sell_phases_fallback,
                relaxed_pos_sell_ge_block=relaxed_pos_sell_ge_block,
                relaxed_solver_masks=relaxed_solver_masks,
            ),
        )

    # --- Post-processing ---
    results = []
    for t in range(T):
        hp_raw     = pulp.value(hp[t])
        hp_on      = hp_raw > heat_pump.rated_heating_power_w * 0.3
        bc_tot = float(pulp.value(bc_pv[t]) or 0) + float(pulp.value(bc_gi[t]) or 0)
        batt_w     = round(bc_tot - float(pulp.value(bd[t]) or 0))
        ge_bat_w = round(float(pulp.value(ge_bat[t]) or 0))
        ge_pv_w = round(float(pulp.value(ge_pv[t]) or 0))
        grid_w, export_mode = _dispatch_grid_setpoint_w(
            gi_w=float(pulp.value(gi[t]) or 0),
            ge_w=float(pulp.value(ge[t]) or 0),
            ge_bat_w=float(ge_bat_w),
            ge_pv_w=float(ge_pv_w),
            max_export_power_w=int(grid.max_export_power_w),
        )
        soc_pct    = round(pulp.value(soc[t]) / battery.usable_capacity_wh * 100, 1)
        export_limit_w = int(grid.max_export_power_w) if grid_w < 0 else 0

        # Deye: default PASSIVE (střídač pokryje load). CHARGE/SELL jen v maskovaných AUTO slotech.
        deye_mode = "PASSIVE"
        if om == "AUTO":
            if (
                slots[t].allow_discharge_export
                and ge_bat_w >= GE_MIN_EXPORT_W
            ):
                deye_mode = "SELL"
            elif slots[t].allow_charge and batt_w > 0 and grid_w > 0:
                deye_mode = "CHARGE"
        elif batt_w < 0 and grid_w < 0:
            deye_mode = "SELL"
        elif batt_w > 0 and grid_w > 0:
            deye_mode = "CHARGE"

        deye_gen_cutoff = None
        if z_gen_cutoff is not None:
            deye_gen_cutoff = bool(round(float(pulp.value(z_gen_cutoff[t]) or 0)))

        cashflow_czk_t = (
            pulp.value(gi[t]) * slots[t].buy_price  * INTERVAL_H / 1000
            - pulp.value(ge[t]) * slots[t].sell_price * INTERVAL_H / 1000
        )
        ge_bat_value = float(pulp.value(ge_bat[t]) or 0)
        battery_arbitrage_czk_t = (
            ge_bat_value
            * (float(slots[t].sell_price) - float(charge_acquisition_czk_kwh))
            * INTERVAL_H
            / 1000.0
        )
        penalty_terms_t = 0.0
        for _tt, _sf, _cap in peak_export_shortfall:
            if _tt == t:
                penalty_terms_t += (
                    float(pulp.value(_sf) or 0.0)
                    * PEAK_EXPORT_SHORTFALL_PENALTY_CZK_KWH
                    * INTERVAL_H
                    / 1000.0
                )
        for _tt, _sf, _cap in pv_charge_shortfall:
            if _tt == t:
                penalty_terms_t += (
                    float(pulp.value(_sf) or 0.0)
                    * PV_CHARGE_SHORTFALL_PENALTY_CZK_KWH
                    * INTERVAL_H
                    / 1000.0
                )
        for _tt, _sf, _cap in neg_sell_bat_dump_shortfall:
            if _tt == t:
                penalty_terms_t += (
                    float(pulp.value(_sf) or 0.0)
                    * NEG_SELL_BAT_DUMP_SHORTFALL_PENALTY_CZK_KWH
                    * INTERVAL_H
                    / 1000.0
                )
        for _tt, _us, _tgt in neg_sell_soc_underfill:
            if _tt == t:
                penalty_terms_t += (
                    float(pulp.value(_us) or 0.0)
                    * NEG_SELL_SOC_UNDERFILL_PENALTY_CZK_PER_WH
                )
        for _tt, _us, _w in prep_soc_shortfall:
            if _tt == t:
                penalty_terms_t += (
                    float(pulp.value(_us) or 0.0)
                    * float(_w)
                    * NEG_SELL_PREP_SOC_SHORTFALL_PENALTY_CZK_PER_WH
                )
        sv_t = safety_vars[t]
        if sv_t is not None:
            penalty_terms_t += float(pulp.value(sv_t) or 0.0) * safety_pen_czk_per_wh[t]
        for _tt, _cv, _prev in commit_lp:
            if _tt == t:
                penalty_terms_t += float(pulp.value(_cv) or 0.0) * INTERVAL_H / 1000.0 * commit_pen
        penalty_terms_t += float(pulp.value(ca[t]) or 0.0) * CURTAILMENT_PENALTY
        green_bonus_czk_t = float(
            getattr(slots[t], "green_bonus_czk_per_slot", 0.0) or 0.0
        )
        cost = cashflow_czk_t

        results.append(DispatchResult(
            interval_start       = slots[t].interval_start,
            battery_setpoint_w   = batt_w,
            battery_soc_target   = soc_pct,
            grid_setpoint_w      = grid_w,
            export_limit_w       = export_limit_w,
            export_mode          = export_mode,
            deye_physical_mode   = deye_mode,
            deye_gen_cutoff_enabled = deye_gen_cutoff,
            ev1_setpoint_w       = round(pulp.value(ev_direct[0][t]) + pulp.value(ev_via_bat[0][t]))
                                   if slots[t].ev1_connected else None,
            ev2_setpoint_w       = round(pulp.value(ev_direct[1][t]) + pulp.value(ev_via_bat[1][t]))
                                   if slots[t].ev2_connected else None,
            ev1_via_bat_w        = round(pulp.value(ev_via_bat[0][t])),
            ev2_via_bat_w        = round(pulp.value(ev_via_bat[1][t])),
            heat_pump_enabled    = hp_on,
            heat_pump_setpoint_w = heat_pump.rated_heating_power_w if hp_on else 0,
            pv_a_curtailed_w     = round(pulp.value(ca[t])),
            expected_cost_czk    = round(cost, 4),
            effective_buy_price  = slots[t].buy_price,
            effective_sell_price = slots[t].sell_price,
            is_predicted_price   = bool(slots[t].is_predicted_price),
            cashflow_czk         = round(cashflow_czk_t, 4),
            battery_arbitrage_czk = round(battery_arbitrage_czk_t, 4),
            penalty_czk          = round(penalty_terms_t, 4),
            green_bonus_czk      = round(green_bonus_czk_t, 4),
        ))

    sell_rank = sorted(range(T), key=lambda i: float(slots[i].sell_price), reverse=True)[: min(3, T)]
    charge_commit_snapshot = [
        {
            "slot": slots[tt].interval_start.isoformat(),
            "previous_charge_w": prev,
            "shortfall_w": float(pulp.value(cv) or 0.0),
        }
        for tt, cv, prev in commit_lp
    ]
    masks_snap: list[dict[str, Any]] = []
    soc_bounds_snap: list[dict[str, Any]] = []
    objective_terms_snap: list[dict[str, Any]] = []
    for t in range(T):
        st = slots[t]
        masks_snap.append(
            {
                "slot": st.interval_start.isoformat(),
                "allow_charge": bool(st.allow_charge),
                "allow_discharge_export": bool(st.allow_discharge_export),
                "neg_sell_phase": neg_sell_phase_by_t[t] if neg_sell_phases_en else None,
                "neg_sell_soc_target_wh": (
                    float(neg_sell_soc_target_by_t[t])
                    if neg_sell_soc_target_by_t[t] is not None
                    else None
                ),
                "neg_sell_post_detach_prep": (
                    t in neg_sell_post_detach_prep_ts if neg_sell_phases_en else None
                ),
                "pre_neg_pv_export": (
                    t in pre_neg_pv_export_ts if neg_sell_phases_en else None
                ),
                "neg_evening_before_neg": (
                    t in neg_evening_push_ts if neg_sell_phases_en else None
                ),
                "neg_evening_reserve_anchor": (
                    any(t == ta for ta, _ in neg_evening_reserve_anchors)
                    if neg_sell_phases_en
                    else None
                ),
                "evening_push": (
                    t in evening_push_ts if om == "AUTO" else None
                ),
                "evening_early_export_ban": (
                    t in evening_early_export_penalty_ts if om == "AUTO" else None
                ),
                "night_self_consume_discourage_import": (
                    t in night_self_consume_discourage_ts if om == "AUTO" else None
                ),
            }
        )
        tgt_s = st.safety_soc_target_wh if daytime_en else None
        # Export floor pro debug snapshot (kopie logiky z constraintů výše).
        if soc_panel_min[t] < min_soc_wh - 1e-3:
            export_floor_wh = float(soc_panel_min[t])
            export_floor_reason = "deep_relax"
        else:
            export_floor_wh = float(arb_base_wh)
            export_floor_reason = "arb_base"
            if tgt_s is not None and not high_sell_slot[t]:
                export_floor_wh = max(
                    export_floor_wh,
                    min(
                        float(battery.soc_max_wh),
                        max(min_soc_wh, float(tgt_s)),
                    ),
                )
                export_floor_reason = "safety_export_floor"
        soc_bounds_snap.append(
            {
                "slot": st.interval_start.isoformat(),
                "soc_min_wh": float(soc_panel_min[t]),
                "arb_floor_wh": float(arb_floor_series[t]),
                "soc_panel_min_wh": float(soc_panel_min[t]),
                "safety_soc_target_wh": float(tgt_s) if tgt_s is not None else None,
                "export_soc_floor_wh": float(export_floor_wh),
                "export_floor_reason": export_floor_reason,
                "high_sell_slot": bool(high_sell_slot[t]),
            }
        )
        fb = float(st.future_avoided_buy_czk_kwh or st.buy_price)
        fs = float(st.future_sell_opportunity_czk_kwh or st.sell_price)
        bv = max(fb, fs) - float(degradation_cost_effective)
        bv = max(0.0, min(5.0, bv))
        pen_wh = bv / 1000.0 if tgt_s is not None else 0.0
        sv = safety_vars[t]
        sdv = float(pulp.value(sv) or 0.0) if sv is not None else None
        cshort = next((float(pulp.value(cv) or 0.0) for tt, cv, _p in commit_lp if tt == t), None)
        objective_terms_snap.append(
            {
                "slot": st.interval_start.isoformat(),
                "buy_price": float(st.buy_price),
                "sell_price": float(st.sell_price),
                "future_avoided_buy_czk_kwh": float(st.future_avoided_buy_czk_kwh or st.buy_price),
                "future_sell_opportunity_czk_kwh": float(
                    st.future_sell_opportunity_czk_kwh or st.sell_price
                ),
                "battery_value_czk_kwh": float(bv),
                "safety_deficit_penalty_czk_per_wh": float(pen_wh) if safety_active[t] else 0.0,
                "safety_penalty_active": bool(safety_active[t]),
                "safety_deficit_wh": sdv,
                "commitment_shortfall_w": cshort,
                "commitment_penalty_czk_kwh": float(commit_pen) if cshort is not None else None,
                "acquisition_used_czk_kwh": float(charge_acquisition_czk_kwh),
                "grid_charge_suppressed_reason": getattr(
                    st, "grid_charge_suppressed_reason", None
                ),
                "pv_charge_wh_ahead": float(
                    getattr(st, "pv_charge_wh_ahead", 0.0) or 0.0
                ),
                "min_buy_before_cutoff_czk_kwh": (
                    float(st.min_buy_before_cutoff_czk_kwh)
                    if getattr(st, "min_buy_before_cutoff_czk_kwh", None) is not None
                    else None
                ),
            }
        )
    night0 = slots[0]
    solver_snapshot: dict[str, Any] = {
        "version": 1,
        "planner_build_tag": PLANNER_BUILD_TAG,
        "charge_slot_budget": {
            "charge_target_wh": (
                float(slots[0].charge_target_wh)
                if slots[0].charge_target_wh is not None
                else None
            ),
            "pre_window_wh": (
                float(slots[0].pre_window_wh)
                if slots[0].pre_window_wh is not None
                else None
            ),
            "in_window_wh": (
                float(slots[0].in_window_wh)
                if slots[0].in_window_wh is not None
                else None
            ),
            "reliability_factor": 0.85,
            "planner_build_tag": PLANNER_BUILD_TAG,
        },
        "inputs": {
            "current_soc_wh": float(current_soc_wh),
            "observed_soc_wh": float(observed_soc_wh),
            "soc_headroom_applied_wh": soc_headroom_applied_wh,
            "operating_mode": operating_mode,
            "planner_version": planner_version_resolved,
            "battery": {
                "usable_capacity_wh": float(battery.usable_capacity_wh),
                "min_soc_wh": float(battery.min_soc_wh),
                "reserve_soc_wh": float(getattr(battery, "reserve_soc_wh", 0.0)),
                "degradation_cost_czk_kwh": float(battery.degradation_cost_czk_kwh),
                "planner_terminal_soc_value_factor": float(battery.planner_terminal_soc_value_factor),
                "planner_daytime_charge_target_enabled": daytime_en,
                "planner_charge_commitment_penalty_czk_kwh": float(commit_pen),
                "planner_neg_sell_prep_soc_percent": float(
                    getattr(battery, "planner_neg_sell_prep_soc_percent", 80.0)
                ),
                "planner_neg_sell_full_soc_tail_slots": int(
                    getattr(battery, "planner_neg_sell_full_soc_tail_slots", 4)
                ),
                "planner_neg_sell_vent_min_sell_czk_kwh": getattr(
                    battery, "planner_neg_sell_vent_min_sell_czk_kwh", None
                ),
            },
            "neg_sell_phases_enabled": bool(neg_sell_phases_en),
            "neg_sell_b_ramp_v35": bool(neg_sell_phases_en),
            "neg_sell_day_meta": neg_sell_day_meta if neg_sell_phases_en else None,
            "t_detach_idx": (
                neg_sell_day_meta.get("t_detach_idx") if neg_sell_phases_en else None
            ),
            "e_surplus_after_t_wh": (
                neg_sell_day_meta.get("e_surplus_after_t_wh")
                if neg_sell_phases_en
                else None
            ),
            "neg_sell_day_pv_b_usable_wh": (
                _neg_sell_day_pv_b_usable_wh(slots, first_neg_sell_idx, battery)
                if first_neg_sell_idx is not None and neg_sell_phases_en
                else None
            ),
            "pre_neg_pv_export_forecast_ok": bool(pre_neg_pv_export_forecast_ok),
            "pre_neg_cushion_by_day": pre_neg_cushion_by_day or None,
            "pre_neg_pv_export_slots": [
                slots[i].interval_start.isoformat() for i in sorted(pre_neg_pv_export_ts)
            ],
            "neg_evening_before_neg_slots": [
                slots[i].interval_start.isoformat()
                for i in sorted(neg_evening_before_neg_ts)
            ],
            "neg_evening_push_slots": [
                slots[i].interval_start.isoformat()
                for i in sorted(neg_evening_push_ts)
            ],
            "neg_evening_export_budget_wh": (
                float(neg_evening_export_budget_wh)
                if neg_evening_export_budget_wh is not None
                else None
            ),
            "neg_evening_reserve_soc_anchors": [
                {
                    "slot": slots[t_a].interval_start.isoformat(),
                    "target_reserve_soc_wh": float(tgt_wh),
                }
                for t_a, tgt_wh in neg_evening_reserve_anchors
            ],
            "neg_sell_prep_window_v36": bool(neg_sell_phases_en),
            "neg_sell_day_pv_usable_wh": (
                _neg_sell_day_pv_usable_wh(
                    slots,
                    first_neg_sell_idx,
                    max_charge_power_w=float(battery.max_charge_power_w),
                    charge_efficiency=float(battery.charge_efficiency),
                )
                if first_neg_sell_idx is not None
                else None
            ),
            "load_first_enabled": om == "AUTO",
            "relaxed_expensive_import": relaxed_expensive_import,
            "relaxed_neg_buy_charge": relaxed_neg_buy_charge,
            "relaxed_neg_prep_hold_only": relaxed_neg_prep_hold_only,
            "relaxed_neg_prep_window": relaxed_neg_prep_window,
            "neg_sell_phases_fallback": neg_sell_phases_fallback,
            "relaxed_pos_sell_ge_block": relaxed_pos_sell_ge_block,
            "relaxed_solver_masks": relaxed_solver_masks,
            "relax_chain": _solver_relax_chain(
                relaxed_expensive_import=relaxed_expensive_import,
                relaxed_neg_buy_charge=relaxed_neg_buy_charge,
                relaxed_neg_prep_hold_only=relaxed_neg_prep_hold_only,
                relaxed_neg_prep_window=relaxed_neg_prep_window,
                neg_sell_phases_fallback=neg_sell_phases_fallback,
                relaxed_pos_sell_ge_block=relaxed_pos_sell_ge_block,
                relaxed_solver_masks=relaxed_solver_masks,
            ),
            "charge_acquisition_buy_czk_kwh": charge_acquisition_czk_kwh,
            "charge_acquisition_cutoff_at": (
                slots[0].charge_acquisition_cutoff_at.isoformat()
                if slots[0].charge_acquisition_cutoff_at is not None
                else None
            ),
            "evening_push_ts": [
                slots[i].interval_start.isoformat() for i in sorted(evening_push_ts)
            ],
            "evening_push_peak_sell_czk_kwh": (
                _evening_push_peak_sell_czk(slots, evening_push_ts)
                if evening_push_ts
                else _evening_night_peak_sell_czk(slots)
            ),
            "evening_push_hysteresis_retained": bool(evening_push_hysteresis_retained),
            "evening_push_override_dropped_on_retry": bool(
                evening_push_ts_override is not None and push_override_raw is None
            ),
            "evening_push_override_filtered_empty": bool(
                push_override_raw and not push_override_eff
            ),
            "evening_push_hard_suppressed": bool(evening_push_hard_suppressed),
            "future_neg_buy_discharge": bool(future_neg_buy_discharge_en),
            "terminal_neg_buy_weight": float(terminal_neg_buy_weight),
            "terminal_soc_factor_effective": float(terminal_factor),
            "pos_sell_pre_neg_buy_ge_exempt_slots": [
                slots[i].interval_start.isoformat()
                for i in sorted(pos_sell_pre_neg_buy_ge_exempt_ts)
            ],
            "evening_push_peak_fallback_used": bool(
                om == "AUTO"
                and not computed_evening_push_ts
                and bool(evening_push_ts)
                and not push_override_eff
            ),
            "fixed_horizon_min_sell_czk_kwh": fixed_horizon_min_sell_pre,
            "fixed_evening_push_sell_above_buy": bool(purchase_fixed_pre),
            "charge_commitment_ignored_on_relaxed": bool(
                commitment_for_solve is None and charge_commitment_prev_w is not None
            ),
            "morning_pre_neg_export_hard": bool(
                om == "AUTO" and not any_relaxed and bool(morning_pre_neg_export_ts)
            ),
            "any_relaxed_solve": bool(any_relaxed),
            "kv1_evening_push_morning_peak_rule": _kv1_block_export_fixed_evening_push(
                grid,
                purchase_fixed=purchase_fixed_pre,
            ),
            "night_self_consume_discourage_ts": [
                slots[i].interval_start.isoformat()
                for i in sorted(night_self_consume_discourage_ts)
            ],
            "degraded_relaxed_night_ts": [
                slots[i].interval_start.isoformat()
                for i in sorted(degraded_relaxed_night_ts)
            ],
            "degraded_evening_export_ts": [
                slots[i].interval_start.isoformat()
                for i in sorted(degraded_evening_export_ts)
            ],
            "strict_late_replan_evening_ts": [
                slots[i].interval_start.isoformat()
                for i in sorted(strict_late_replan_evening_ts)
            ],
            "strict_late_replan_night_ts": [
                slots[i].interval_start.isoformat()
                for i in sorted(strict_late_replan_night_ts)
            ],
            "late_replan_strict_active": bool(late_replan_strict_active),
            "late_replan_solver_relax": bool(late_replan_solver_relax),
        },
        "masks": masks_snap,
        "soc_bounds": soc_bounds_snap,
        "objective_terms": objective_terms_snap,
        "chosen_slots": {
            "charge_commitment": charge_commit_snapshot,
            "high_sell_windows": [slots[i].interval_start.isoformat() for i in sell_rank],
            "night_window": {
                "definition": "Europe/Prague 20:00–06:00 projected baseload Wh (fn_load_planning_slots_full)",
                "target_wh": night0.night_baseload_target_wh,
                "buffer_wh": night0.night_baseload_buffer_wh,
            },
        },
    }
    return results, duration_ms, solver_snapshot


# ============================================================
# Denní plán (15:00)
# ============================================================

async def run_daily_plan(
    site_id: int,
    db,
    triggered_by: str = "scheduler:daily",
    *,
    planner_version: str | None = None,
) -> tuple[int, int]:
    """
    Hlavní denní plánování. Spouštět v 15:00 po importu cen (14:00)
    a aktualizaci forecastu (14:30).
    Horizont: `ems.fn_planning_horizon_end` (OTE, strop a práh v SQL).
    """
    now = datetime.now(timezone.utc)
    horizon_from = _current_slot_start(now)
    horizon_to = await _planning_horizon_end(site_id, horizon_from, db)
    if horizon_to is None:
        horizon_to = horizon_from + timedelta(hours=_DAILY_FALLBACK_HORIZON_HOURS)
        logger.warning(
            "[site=%s] Daily plan: fn_planning_horizon_end NULL, fallback %.1fh",
            site_id,
            _DAILY_FALLBACK_HORIZON_HOURS,
        )

    logger.info(f"[site={site_id}] Daily plan: {horizon_from} → {horizon_to}")

    battery, hp, grid, vehicles, ev_sessions, soc_wh, tuv_temp, operating_mode, tuv_stats = (
        await _load_site_context(site_id, db)
    )
    planner_version_resolved = _planner_engine_version(planner_version)
    slots = await _load_slots(site_id, horizon_from, horizon_to, db, soc_wh=soc_wh)
    _unlock_late_replan_evening_slots(
        slots,
        current_soc_wh=float(soc_wh),
        reserve_soc_wh=float(
            getattr(battery, "reserve_soc_wh", getattr(battery, "arb_floor_wh", 0.0))
        ),
    )

    om = operating_mode or "AUTO"
    try:
        if planner_version_resolved == "v2":
            results, duration_ms, solver_snapshot = _solve_dispatch_for_version(
                "v2",
                slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp,
                tuv_delta_stats=tuv_stats,
                operating_mode=om,
            )
        elif om == "AUTO":
            results, duration_ms, solver_snapshot = solve_dispatch_two_pass(
                slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp,
                tuv_delta_stats=tuv_stats,
                operating_mode=om,
                planner_version=planner_version_resolved,
            )
        else:
            results, duration_ms, solver_snapshot = solve_dispatch(
                slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp,
                tuv_delta_stats=tuv_stats,
                operating_mode=om,
                planner_version=planner_version_resolved,
            )
    except PlannerSolverError as exc:
        await _save_failed_planning_run(
            site_id,
            horizon_from,
            horizon_to,
            run_type="daily",
            triggered_by=triggered_by,
            replan_from=None,
            soc_wh=soc_wh,
            correction=1.0,
            db=db,
            error=exc,
            slot_count=len(slots),
        )
        raise
    comparison_ctx = _maybe_add_planner_comparison(
        slots=slots,
        battery=battery,
        heat_pump=hp,
        grid=grid,
        ev_sessions=ev_sessions,
        vehicles=vehicles,
        current_soc_wh=soc_wh,
        current_tuv_temp_c=tuv_temp,
        operating_mode=om,
        tuv_delta_stats=tuv_stats,
        active_version=planner_version_resolved,
    )
    if comparison_ctx is not None:
        peer_results = comparison_ctx["peer_results"]
        peer_ms = comparison_ctx["peer_ms"]
        peer_snapshot = comparison_ctx["peer_snapshot"]
        solver_snapshot["comparison"] = _dispatch_result_comparison(
            results,
            duration_ms,
            planner_version_resolved,
            peer_results,
            peer_ms,
            comparison_ctx["peer_version"],
        )

    slot_inputs = _build_slot_inputs(slots, slots)
    run_id = await _save_planning_run(
        site_id,
        results,
        horizon_from,
        horizon_to,
        run_type="daily",
        triggered_by=triggered_by,
        replan_from=None,
        soc_wh=soc_wh,
        duration_ms=duration_ms,
        correction=1.0,
        db=db,
        slot_inputs=slot_inputs,
        solver_snapshot=solver_snapshot,
    )
    if comparison_ctx is not None:
        compare_snapshot = dict(peer_snapshot)
        compare_snapshot["comparison_of_run_id"] = run_id
        compare_snapshot["compare_peer_version"] = comparison_ctx["peer_version"]
        await _save_planning_run(
            site_id,
            comparison_ctx["peer_results"],
            horizon_from,
            horizon_to,
            run_type="daily",
            triggered_by=f"{triggered_by}:compare",
            replan_from=None,
            soc_wh=soc_wh,
            duration_ms=comparison_ctx["peer_ms"],
            correction=1.0,
            db=db,
            slot_inputs=slot_inputs,
            activate_run=False,
            solver_snapshot=compare_snapshot,
        )
    logger.info(f"[site={site_id}] Daily plan done in {duration_ms} ms")
    return run_id, duration_ms


# ============================================================
# Rolling replan (každých 15min)
# ============================================================

async def run_rolling_replan(
    site_id: int,
    db,
    *,
    triggered_by: str = "scheduler:rolling",
    allow_skip: bool = True,
    planner_version: str | None = None,
) -> tuple[Optional[int], Optional[int]]:
    """
    Rolling replan každých 15 minut.
    1. Zjistí aktuální SoC baterie z telemetrie
    2. Spočítá korekční faktor FVE forecastu z poslední hodiny
    3. Aplikuje korekci na forecast zbytku dne (s útlumem)
    4. Spustí solver pro zbývající horizont aktivního plánu
    5. Uloží jako nový planning_run (aktivní plán se stane superseded)

    Pokud allow_skip=True (scheduler) a horizont je vyčerpaný → vrátí (None, None).
    Pokud allow_skip=False (API) → spustí denní plán jako náhradu.
    """
    now = datetime.now(timezone.utc)
    replan_from = _current_slot_start(now)
    planner_version_resolved = _planner_engine_version(planner_version)

    ar_raw = await db.fetchval(
        "select ems.fn_planning_active_run($1::int)",
        site_id,
    )
    ar = ar_raw if isinstance(ar_raw, dict) else json.loads(ar_raw)
    if ar.get("error") == "no_active_plan":
        logger.warning(f"[site={site_id}] Rolling replan: no active plan, triggering daily plan")
        return await run_daily_plan(
            site_id,
            db,
            triggered_by=triggered_by,
            planner_version=planner_version_resolved,
        )

    horizon_to = await _planning_horizon_end(site_id, replan_from, db)
    if horizon_to is None:
        if allow_skip:
            logger.info(
                "[site=%s] Rolling replan: fn_planning_horizon_end NULL (krátký OTE horizont), skipping",
                site_id,
            )
            return None, None
        logger.warning(
            "[site=%s] Rolling replan: fn_planning_horizon_end NULL, running daily plan",
            site_id,
        )
        return await run_daily_plan(
            site_id,
            db,
            triggered_by=triggered_by,
            planner_version=planner_version_resolved,
        )

    if (horizon_to - replan_from).total_seconds() < 1800:
        if allow_skip:
            logger.info(f"[site={site_id}] Rolling replan: horizon almost exhausted, skipping")
            return None, None
        logger.info(f"[site={site_id}] Rolling replan: horizon exhausted, running daily plan")
        return await run_daily_plan(
            site_id,
            db,
            triggered_by=triggered_by,
            planner_version=planner_version_resolved,
        )

    logger.info(
        "[site=%s] Rolling replan from %s → %s (tag=%s)",
        site_id,
        replan_from,
        horizon_to,
        PLANNER_BUILD_TAG,
    )

    battery, hp, grid, vehicles, ev_sessions, soc_wh, tuv_temp, operating_mode, tuv_stats = (
        await _load_site_context(site_id, db)
    )
    if operating_mode != "AUTO":
        logger.info(
            "[site=%s] Rolling replan skipped: operating_mode=%s (not AUTO)",
            site_id,
            operating_mode,
        )
        return None, None

    slots = await _load_slots(site_id, replan_from, horizon_to, db, soc_wh=soc_wh)
    _unlock_late_replan_evening_slots(
        slots,
        current_soc_wh=float(soc_wh),
        reserve_soc_wh=float(
            getattr(battery, "reserve_soc_wh", getattr(battery, "arb_floor_wh", 0.0))
        ),
    )
    # PV forecast korekce je kanonicky v DB (delta + rolling faktor + decay) a do LP vstupuje přes
    # ems.fn_load_planning_slots_full. Pro audit/debug ale chceme ukládat i RAW (bez korekcí).
    correction_factor, correction_log = 1.0, {
        "window_start": None,
        "window_end": None,
        "actual_pv_wh": None,
        "forecast_pv_wh": None,
        "correction_factor": None,
        "reason": "canonical_db",
    }

    # RAW PV pro slot_inputs: přímý součet nejnovějších forecast_pv_interval per array/slot (bez delta/rolling).
    raw_pv_rows = await db.fetchval(
        "select ems.fn_forecast_pv_slots_range_raw_ab($1::int, $2::timestamptz, $3::timestamptz)",
        site_id,
        replan_from,
        horizon_to,
    )
    raw_pv = raw_pv_rows if isinstance(raw_pv_rows, list) else json.loads(raw_pv_rows)
    raw_by_ts: dict[str, tuple[int, int]] = {}
    if isinstance(raw_pv, list):
        for r in raw_pv:
            if not isinstance(r, dict):
                continue
            ts = r.get("interval_start")
            if isinstance(ts, str):
                raw_by_ts[ts] = (
                    int(r.get("pv_a_forecast_raw_w") or 0),
                    int(r.get("pv_b_forecast_raw_w") or 0),
                )
    slots_raw_pv: list[PlanningSlot] = []
    for s in slots:
        key = s.interval_start.isoformat()
        pva, pvb = raw_by_ts.get(key, (s.pv_a_forecast_w, s.pv_b_forecast_w))
        slots_raw_pv.append(replace(s, pv_a_forecast_w=pva, pv_b_forecast_w=pvb))

    commitment_prev = await _load_previous_plan_charge_commitment_prev_w(site_id, slots, db)
    evening_push_override = await _rolling_evening_push_override(
        site_id, slots, battery, soc_wh, db
    )

    om = operating_mode or "AUTO"
    try:
        if planner_version_resolved == "v2":
            results, duration_ms, solver_snapshot = _solve_dispatch_for_version(
                "v2",
                slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp,
                tuv_delta_stats=tuv_stats,
                operating_mode=om,
            )
        elif om == "AUTO":
            results, duration_ms, solver_snapshot = solve_dispatch_two_pass(
                slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp,
                tuv_delta_stats=tuv_stats,
                operating_mode=om,
                charge_commitment_prev_w=commitment_prev,
                planner_version=planner_version_resolved,
                evening_push_ts_override=evening_push_override,
            )
        else:
            results, duration_ms, solver_snapshot = solve_dispatch(
                slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp,
                tuv_delta_stats=tuv_stats,
                operating_mode=om,
                charge_commitment_prev_w=commitment_prev,
                planner_version=planner_version_resolved,
            )
    except PlannerSolverError as exc:
        await _save_failed_planning_run(
            site_id,
            replan_from,
            horizon_to,
            run_type="rolling",
            triggered_by=triggered_by,
            replan_from=replan_from,
            soc_wh=soc_wh,
            correction=correction_factor,
            db=db,
            error=exc,
            slot_count=len(slots),
        )
        raise
    comparison_ctx = _maybe_add_planner_comparison(
        slots=slots,
        battery=battery,
        heat_pump=hp,
        grid=grid,
        ev_sessions=ev_sessions,
        vehicles=vehicles,
        current_soc_wh=soc_wh,
        current_tuv_temp_c=tuv_temp,
        operating_mode=om,
        tuv_delta_stats=tuv_stats,
        active_version=planner_version_resolved,
        charge_commitment_prev_w=commitment_prev,
    )
    if comparison_ctx is not None:
        peer_results = comparison_ctx["peer_results"]
        peer_ms = comparison_ctx["peer_ms"]
        solver_snapshot["comparison"] = _dispatch_result_comparison(
            results,
            duration_ms,
            planner_version_resolved,
            peer_results,
            peer_ms,
            comparison_ctx["peer_version"],
        )

    slot_inputs = _build_slot_inputs(slots_raw_pv, slots)
    run_id = await _save_planning_run(
        site_id,
        results,
        replan_from,
        horizon_to,
        run_type="rolling",
        triggered_by=triggered_by,
        replan_from=replan_from,
        soc_wh=soc_wh,
        duration_ms=duration_ms,
        correction=correction_factor,
        db=db,
        slot_inputs=slot_inputs,
        solver_snapshot=solver_snapshot,
    )
    if comparison_ctx is not None:
        compare_snapshot = dict(comparison_ctx["peer_snapshot"])
        compare_snapshot["comparison_of_run_id"] = run_id
        compare_snapshot["compare_peer_version"] = comparison_ctx["peer_version"]
        await _save_planning_run(
            site_id,
            comparison_ctx["peer_results"],
            replan_from,
            horizon_to,
            run_type="rolling",
            triggered_by=f"{triggered_by}:compare",
            replan_from=replan_from,
            soc_wh=soc_wh,
            duration_ms=comparison_ctx["peer_ms"],
            correction=correction_factor,
            db=db,
            slot_inputs=slot_inputs,
            activate_run=False,
            solver_snapshot=compare_snapshot,
        )

    # Historický log rolling korekce: dřív se psal z Pythonu. Nově se rolling faktor počítá v DB
    # v kanonické PV řadě; log se případně přesune do DB (todo).

    logger.info(f"[site={site_id}] Rolling replan done in {duration_ms} ms (pv=canonical_db)")
    return run_id, duration_ms


async def run_plan_api(
    site_id: int,
    plan_type: str,
    db,
    *,
    triggered_by: str = "api",
    planner_version: str | None = None,
) -> tuple[int, int]:
    """Ruční / UI spuštění plánu. Vždy vrátí (run_id, solver_duration_ms)."""
    pt = plan_type.lower().strip()
    planner_version_resolved = _planner_engine_version(planner_version)
    if pt == "daily":
        return await run_daily_plan(
            site_id,
            db,
            triggered_by=triggered_by,
            planner_version=planner_version_resolved,
        )
    if pt == "rolling":
        rid, ms = await run_rolling_replan(
            site_id,
            db,
            triggered_by=triggered_by,
            allow_skip=False,
            planner_version=planner_version_resolved,
        )
        if rid is None or ms is None:
            raise RuntimeError("Rolling replan did not return a run")
        return rid, ms
    raise ValueError(f"Unknown plan_type: {plan_type!r} (use daily or rolling)")


# ============================================================
# Pomocné funkce
# ============================================================









async def _rolling_evening_push_override(
    site_id: int,
    slots: list[PlanningSlot],
    battery,
    current_soc_wh: float,
    db,
) -> set[int] | None:
    """Rolling: držet evening_push_ts z aktivního runu při malé změně peak sell / SoC."""
    if not slots:
        return None
    row = await db.fetchrow(
        """
        select solver_params
        from ems.planning_run
        where site_id = $1::int
          and status = 'active'
        limit 1
        """,
        site_id,
    )
    if row is None or row["solver_params"] is None:
        return None
    sp = row["solver_params"]
    if isinstance(sp, str):
        sp = json.loads(sp)
    if not isinstance(sp, dict):
        return None
    inputs = sp.get("inputs")
    if not isinstance(inputs, dict):
        return None
    prev_iso = inputs.get("evening_push_ts")
    if not isinstance(prev_iso, list) or not prev_iso:
        return None
    prev_push = _evening_push_ts_from_iso(slots, [str(x) for x in prev_iso])
    if not prev_push:
        return None
    budget_eligible = {
        t
        for seg in _evening_push_soc_budget_calendar_segments(slots, None)
        for t in seg
    }
    if budget_eligible:
        prev_push = {t for t in prev_push if t in budget_eligible}
    if not prev_push:
        return None
    prev_peak = inputs.get("evening_push_peak_sell_czk_kwh")
    prev_soc = inputs.get("current_soc_wh")
    new_peak = _evening_night_peak_sell_czk(slots)
    if not _evening_push_hysteresis_active(
        prev_peak_sell_czk=float(prev_peak) if prev_peak is not None else None,
        new_peak_sell_czk=new_peak,
        prev_soc_wh=float(prev_soc) if prev_soc is not None else None,
        current_soc_wh=float(current_soc_wh),
        usable_capacity_wh=float(battery.usable_capacity_wh),
    ):
        return None
    logger.info(
        "[site=%s] evening_push hysteresis: retaining %d slot(s), peak_sell=%.3f",
        site_id,
        len(prev_push),
        new_peak,
    )
    return prev_push










