diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index 9fed8f1..e47bdb1 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -71,7 +71,7 @@ NEG_BUY_CHARGE_SHORTFALL_PENALTY_CZK_KWH = 100.0 PRE_NEG_CHARGE_PENALTY_CZK_KWH = 400.0 PRE_NEG_BATT_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 80.0 PRE_NEG_BATT_EXPORT_MIN_SELL_CZK_KWH = 1.0 -PLANNER_BUILD_TAG = "2026-06-06-home01-late-replan-infeasible-v1" +PLANNER_BUILD_TAG = "2026-06-06-home01-late-replan-infeasible-v2" SOLVER_RELAX_STEPS: tuple[str, ...] = ( "strict", "relaxed_expensive_import", @@ -80,6 +80,7 @@ SOLVER_RELAX_STEPS: tuple[str, ...] = ( "relaxed_neg_prep_window", "neg_sell_phases_fallback", "relaxed_pos_sell_ge_block", + "relaxed_solver_masks", ) # Ranní slabá FVE: neaplikovat pv_store ge_pv=0 (jinak curtail při sell < večerní peak). DAWN_LOW_PV_NO_CURTAIL_W = 1500 @@ -151,6 +152,7 @@ def _solver_relax_chain( 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, @@ -159,6 +161,7 @@ def _solver_relax_chain( "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:]: @@ -2356,8 +2359,52 @@ def _pv_forced_vent_export_allowed( return False +def _relax_solver_slot_masks(slots: list[PlanningSlot]) -> list[PlanningSlot]: + """Nouzově permissivní allow_* — SQL masky nesmí učinit LP neřešitelným.""" + return [ + replace( + s, + allow_charge=True, + allow_discharge_export=float(s.sell_price) >= 0.0, + ) + for s in slots + ] + + +def _unlock_late_replan_evening_slots( + slots: list[PlanningSlot], + *, + current_soc_wh: float, + reserve_soc_wh: float, +) -> None: + """Pozdní replan: večer D0 povolit grid import + export (SQL allow_charge často false).""" + if not slots or current_soc_wh <= float(reserve_soc_wh) + 500.0: + return + if not any(float(s.buy_price) < 0.0 for s in slots): + return + replan_day = _prague_calendar_date(slots[0]) + unlocked = 0 + for i, s in enumerate(slots): + if _prague_calendar_date(s) != replan_day: + continue + if float(s.sell_price) < 0.0: + continue + if not _in_evening_push_hour_window(s): + continue + if s.allow_charge and s.allow_discharge_export: + continue + slots[i] = replace(s, allow_charge=True, allow_discharge_export=True) + unlocked += 1 + if unlocked: + logger.info( + "Late replan: unlocked evening slot masks on %d slot(s) (soc=%.0f Wh)", + unlocked, + float(current_soc_wh), + ) + + def _solve_dispatch_relax_carryover(snap: dict[str, Any]) -> dict[str, Any]: - """Pass2 two-pass: neopakovat Infeasible řetězec, pokud pass1 skončil v nouzovém režimu.""" + """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 {} @@ -2368,6 +2415,8 @@ def _solve_dispatch_relax_carryover(snap: dict[str, Any]) -> dict[str, Any]: "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 @@ -2443,8 +2492,9 @@ def solve_dispatch_two_pass( evening_push_ts_override=None, **relax_carry, ) - except RuntimeError as exc: - if "Infeasible" in str(exc): + 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, @@ -2471,6 +2521,8 @@ def _evening_push_override_for_solve( relaxed_neg_prep_hold_only: bool, relaxed_neg_prep_window: bool, neg_sell_phases_fallback: bool, + relaxed_pos_sell_ge_block: bool = False, + relaxed_solver_masks: bool = False, ) -> Optional[set[int]]: """Po Infeasible nesmí retry držet hysterézní push z minulého běhu.""" if evening_push_ts_override is None: @@ -2478,8 +2530,11 @@ def _evening_push_override_for_solve( if ( relaxed_expensive_import or relaxed_neg_buy_charge + or relaxed_neg_prep_hold_only or relaxed_neg_prep_window or neg_sell_phases_fallback + or relaxed_pos_sell_ge_block + or relaxed_solver_masks ): return None return set(evening_push_ts_override) @@ -2529,6 +2584,7 @@ def solve_dispatch( 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]]: """ @@ -2538,17 +2594,21 @@ def solve_dispatch( 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: poslední retry — neaplikovat ge=0 v pos_sell před buy<0 (zbylá Infeasible). + 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 EV = len(vehicles) # počet EV (typicky 2) @@ -3013,6 +3073,8 @@ def solve_dispatch( 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: @@ -3113,7 +3175,7 @@ def solve_dispatch( for t in discharge_export_slots: if _prague_calendar_date(slots[t]) == replan_day: charge_slots.add(t) - if relaxed_pos_sell_ge_block: + 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 = { @@ -3123,6 +3185,16 @@ def solve_dispatch( } 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 pre_neg_buy_soc_ceiling_wh = _pre_neg_buy_soc_ceiling_wh( slots, first_neg_buy_idx=first_neg_buy_idx, @@ -4620,6 +4692,39 @@ def solve_dispatch( 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( @@ -4629,6 +4734,7 @@ def solve_dispatch( 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, ), ) @@ -4981,6 +5087,7 @@ def solve_dispatch( "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, @@ -4988,6 +5095,7 @@ def solve_dispatch( 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": ( @@ -5092,6 +5200,13 @@ async def run_daily_plan( ) 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: @@ -5282,6 +5397,13 @@ async def run_rolling_replan( 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, { diff --git a/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index ebd0807..6a6f03c 100644 --- a/backend/tests/test_planning_dispatch_milp.py +++ b/backend/tests/test_planning_dispatch_milp.py @@ -3285,7 +3285,7 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase): self.assertEqual(push, [0, 1, 2, 3][: len(push)]) def test_evening_push_override_cleared_on_relaxed_retry(self) -> None: - """v53: hysterézní override se nepřenáší do Infeasible retry větví.""" + """v53/v2: hysterézní override se nepřenáší do Infeasible retry větví.""" kept = _evening_push_override_for_solve( {2, 5}, relaxed_expensive_import=False, @@ -3295,7 +3295,8 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase): neg_sell_phases_fallback=False, ) self.assertEqual(kept, {2, 5}) - kept_prep_hold = _evening_push_override_for_solve( + # v2: stale override from active plan must drop already at prep_hold_only + dropped_prep_hold = _evening_push_override_for_solve( {2, 5}, relaxed_expensive_import=False, relaxed_neg_buy_charge=False, @@ -3303,7 +3304,7 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase): relaxed_neg_prep_window=False, neg_sell_phases_fallback=False, ) - self.assertEqual(kept_prep_hold, {2, 5}) + self.assertIsNone(dropped_prep_hold) dropped = _evening_push_override_for_solve( {2, 5}, relaxed_expensive_import=True, diff --git a/docs/planning-changelog.md b/docs/planning-changelog.md index 7e9fe02..44f8df4 100644 --- a/docs/planning-changelog.md +++ b/docs/planning-changelog.md @@ -11,16 +11,18 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen **Příčina:** SQL maska `allow_charge=false` ve večerních slotech (drahý `buy`, `sell` < `buy`) + guard drahého importu vyžadoval baseload z baterie (`bd`), zatímco **v64 `future_neg_buy_discharge`** současně vynucoval večerní vývoz — LP bez rozšíření `charge_slots` neměl řešení. -**Oprava (tag `2026-06-06-home01-late-replan-infeasible-v1`):** +**Oprava (tag `2026-06-06-home01-late-replan-infeasible-v1`, doplněno **v2**):** - Při **`future_neg_buy_discharge`**: rozšířit `charge_slots` o večerní / exportní sloty dne replanu (grid smí krmit load během vývozu). -- Nový poslední retry **`relaxed_pos_sell_ge_block`** (+ nouzové rozšíření masek) v `SOLVER_RELAX_STEPS`. +- **`_unlock_late_replan_evening_slots`** po `fn_load_planning_slots_full` — večer D0 `allow_charge` + export z DB. +- Nový retry **`relaxed_pos_sell_ge_block`** (+ **`relaxed_solver_masks`** nouzový) v `SOLVER_RELAX_STEPS`. +- **v2:** two-pass pass2 dědí všechny relax flagy; při pass2 Infeasible fallback na pass1; override push zrušen už od `relaxed_neg_prep_hold_only`. **Soubory:** `planning_engine.py`, `scripts/repro_home01_23840.py`, test `test_home01_late_replan_high_soc_realistic_masks`. **Ověření:** - `PYTHONPATH=backend python3 scripts/repro_home01_23840.py` → `OK two_pass` - `pytest backend/tests/test_planning_dispatch_milp.py -k home01_late_replan` -- Po deployi: ruční replan v AUTO → `planning_run.status=active`, večerní sloty `grid_setpoint_w < 0`. +- Po deployi: ruční replan v AUTO → `planning_run.status=active`, `planner_build_tag` končí **`infeasible-v2`**, večerní sloty `grid_setpoint_w < 0`. ---