From 37df01d43c4307427f42f6da83f990d46be75a69 Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Sat, 6 Jun 2026 23:12:08 +0200 Subject: [PATCH] dalsi fix --- backend/services/planning_engine.py | 74 ++++- backend/tests/test_planning_dispatch_milp.py | 82 ++++++ docs/planning-changelog.md | 19 ++ scripts/repro_home01_23840.py | 269 +++++++++++++++++++ 4 files changed, 443 insertions(+), 1 deletion(-) create mode 100644 scripts/repro_home01_23840.py diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index 353ee4c..9fed8f1 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-ba81-gen-cutoff-exec-v1" +PLANNER_BUILD_TAG = "2026-06-06-home01-late-replan-infeasible-v1" SOLVER_RELAX_STEPS: tuple[str, ...] = ( "strict", "relaxed_expensive_import", @@ -79,6 +79,7 @@ SOLVER_RELAX_STEPS: tuple[str, ...] = ( "relaxed_neg_prep_hold_only", "relaxed_neg_prep_window", "neg_sell_phases_fallback", + "relaxed_pos_sell_ge_block", ) # Ranní slabá FVE: neaplikovat pv_store ge_pv=0 (jinak curtail při sell < večerní peak). DAWN_LOW_PV_NO_CURTAIL_W = 1500 @@ -149,6 +150,7 @@ def _solver_relax_chain( 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, ) -> list[str]: flags = { "relaxed_expensive_import": relaxed_expensive_import, @@ -156,6 +158,7 @@ def _solver_relax_chain( "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, } chain = [SOLVER_RELAX_STEPS[0]] for step in SOLVER_RELAX_STEPS[1:]: @@ -2525,6 +2528,7 @@ def solve_dispatch( 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, evening_push_ts_override: Optional[set[int]] = None, ) -> tuple[list[DispatchResult], int, dict[str, Any]]: """ @@ -2534,6 +2538,7 @@ 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). """ T = len(slots) if T < 1: @@ -2543,6 +2548,7 @@ def solve_dispatch( or relaxed_neg_buy_charge or relaxed_neg_prep_window or neg_sell_phases_fallback + or relaxed_pos_sell_ge_block ) prep_hold_relaxed = relaxed_neg_prep_hold_only or relaxed_neg_prep_window EV = len(vehicles) # počet EV (typicky 2) @@ -3085,6 +3091,36 @@ def solve_dispatch( 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: + # 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() pre_neg_buy_soc_ceiling_wh = _pre_neg_buy_soc_ceiling_wh( @@ -4174,6 +4210,7 @@ def solve_dispatch( 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 @@ -4551,6 +4588,38 @@ def solve_dispatch( 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, + ) raise PlannerSolverError( pulp.LpStatus[status], relax_chain=_solver_relax_chain( @@ -4559,6 +4628,7 @@ 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, ), ) @@ -4910,12 +4980,14 @@ 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, "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, ), "charge_acquisition_buy_czk_kwh": charge_acquisition_czk_kwh, "charge_acquisition_cutoff_at": ( diff --git a/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index 7561d6b..ebd0807 100644 --- a/backend/tests/test_planning_dispatch_milp.py +++ b/backend/tests/test_planning_dispatch_milp.py @@ -3615,6 +3615,88 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase): self.assertFalse(snap_mid["inputs"].get("neg_sell_phases_fallback")) self.assertLess(results_mid[0].grid_setpoint_w, -1000) + def test_home01_late_replan_high_soc_realistic_masks(self) -> None: + """Pozdní replan večer (21:00): SoC ~74 %, SQL masky allow_charge=false večer — musí být Feasible.""" + prague = ZoneInfo("Europe/Prague") + base = datetime(2026, 6, 6, 21, 0, tzinfo=prague).astimezone(timezone.utc) + rows = [ + (5.305729, 3.34, 2731, 0, 0), + (5.162299, 3.23125, 2731, 0, 0), + (4.866865, 3.00725, 2731, 0, 0), + (4.662765, 2.8525, 2731, 0, 0), + (5.18406, 3.24775, 1552, 0, 0), + (4.878076, 3.01575, 1552, 0, 0), + (4.749483, 2.91825, 1552, 0, 0), + (4.460314, 2.699, 1552, 0, 0), + (4.887308, 3.02275, 782, 0, 0), + (4.883351, 3.01975, 782, 0, 0), + (4.660787, 2.851, 782, 0, 0), + (4.484384, 2.71725, 782, 0, 0), + ] + slots: list[PlanningSlot] = [] + for i in range(96): + local = (base + timedelta(minutes=15 * i)).astimezone(prague) + if i < len(rows): + buy, sell, load, pv_a, pv_b = rows[i] + else: + d, h, m = local.day, local.hour, local.minute + hm = h + m / 60.0 + if d == 6: + buy, sell, load, pv_a, pv_b = 4.5, 2.9, 800, 0, 0 + elif hm >= 5.75 and hm < 15: + sell, buy = -0.3, 0.5 + pv_a, pv_b, load = 2000, 2500, 800 + elif 11 <= hm < 14: + sell, buy = -0.8, -0.4 + pv_a, pv_b, load = 4000, 4500, 2000 + else: + sell, buy = 2.5, 3.0 + pv_a, pv_b, load = 200, 200, 500 + pv_surplus = max(0, pv_a + pv_b - load) + h = local.hour + allow_discharge_export = sell >= 0 and (h >= 17 or sell > buy + 0.15) + allow_charge = buy < 0 or (sell < 0 and pv_surplus > 500) + slots.append( + PlanningSlot( + interval_start=base + timedelta(minutes=15 * i), + buy_price=buy, + sell_price=sell, + pv_a_forecast_w=pv_a, + pv_b_forecast_w=pv_b, + load_baseline_w=load, + ev1_connected=False, + ev2_connected=False, + allow_charge=allow_charge, + allow_discharge_export=allow_discharge_export, + ) + ) + bat = _battery(uc_wh=64_000.0, arb_pct=20.0, terminal_soc_value_factor=0.9) + bat.planner_neg_sell_prep_soc_percent = 80 + hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0) + grid = SimpleNamespace( + max_import_power_w=17_000, + max_export_power_w=13_500, + block_export_on_negative_sell=False, + purchase_pricing_mode="spot", + ) + vehicles = [ + SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0), + SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0), + ] + results, _ms, snap = solve_dispatch_two_pass( + slots, + bat, + hp, + grid, + [None, None], + vehicles, + 0.74 * bat.soc_max_wh, + 50.0, + operating_mode="AUTO", + ) + self.assertLess(results[0].grid_setpoint_w, -500) + self.assertLess(results[0].battery_soc_target, 70.0) + def test_kv1_evening_push_profitable_vs_morning_zone_peak(self) -> None: """v52: KV1 večer ≥ ranní max (5–11) − degrad; pod prahem ne.""" prague = ZoneInfo("Europe/Prague") diff --git a/docs/planning-changelog.md b/docs/planning-changelog.md index fd8a314..7e9fe02 100644 --- a/docs/planning-changelog.md +++ b/docs/planning-changelog.md @@ -5,6 +5,25 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen --- +## 2026-06-06 — Pozdní replan večer: Infeasible při vysokém SoC (home-01) + +**Problém:** Po přepnutí na AUTO a ručním replanem (~21:00, SoC **~74 %**, zítra `buy<0` + `sell<0`): všechny retry včetně `neg_sell_phases_fallback` → **`Solver: Infeasible`**. Aktivní zůstal starý plán z 17:00 (import místo večerního vývozu k **reserve ~20 %**). + +**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`):** +- 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`. + +**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`. + +--- + ## 2026-06-06 — BA81 GEN cut-off exekuce při sell<0 (Branch 4) **Problém:** Audit BA81 6. 6. 2026 (07:45–08:30, `sell<0`): plán `grid_setpoint_w=0`, `deye_gen_cutoff_enabled=false`, ale **`actual_grid_export_wh` > 0** a **`flow_pv_to_grid_wh` > 0** (~0,8–1 kW). Reg **145** (`export_ban`) nestačí — mikroinvertory na GEN portu exportují, dokud reg **178** bits 0–1 ≠ cut-off ON. diff --git a/scripts/repro_home01_23840.py b/scripts/repro_home01_23840.py new file mode 100644 index 0000000..41c45b7 --- /dev/null +++ b/scripts/repro_home01_23840.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python3 +"""Repro home-01 Infeasible @ 47360 Wh, replan 2026-06-06 21:00 Prague (run 23840).""" +from __future__ import annotations + +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path +from types import SimpleNamespace +from zoneinfo import ZoneInfo + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "backend")) + +from services.planning_engine import ( # noqa: E402 + PlanningSlot, + solve_dispatch, + solve_dispatch_two_pass, + PlannerSolverError, +) + +# Compact slot data from MCP read-only export (buy, sell, load, pv_a, pv_b) +SLOT_ROWS = [ + (5.305729, 3.34, 2731, 0, 0), + (5.162299, 3.23125, 2731, 0, 0), + (4.866865, 3.00725, 2731, 0, 0), + (4.662765, 2.8525, 2731, 0, 0), + (5.18406, 3.24775, 1552, 0, 0), + (4.878076, 3.01575, 1552, 0, 0), + (4.749483, 2.91825, 1552, 0, 0), + (4.460314, 2.699, 1552, 0, 0), + (4.887308, 3.02275, 782, 0, 0), + (4.883351, 3.01975, 782, 0, 0), + (4.660787, 2.851, 782, 0, 0), + (4.484384, 2.71725, 782, 0, 0), + (4.756077, 2.92325, 453, 0, 0), + (4.349197, 2.61475, 453, 0, 0), + (4.32117, 2.5935, 453, 0, 0), + (4.276657, 2.55975, 453, 0, 0), + (4.22522, 2.52075, 437, 0, 0), + (4.155318, 2.46775, 437, 0, 0), + (3.975289, 2.33125, 437, 0, 0), + (3.76954, 2.17525, 437, 0, 0), + (4.063655, 2.39825, 439, 0, 0), + (3.799215, 2.19775, 439, 0, 0), + (3.759978, 2.168, 439, 0, 0), + (3.48235, 1.9575, 439, 0, 0), + (3.762616, 2.17, 462, 0, 0), + (3.479382, 1.95525, 462, 0, 0), + (3.344854, 1.85325, 462, 0, 0), + (3.025021, 1.61075, 462, 0, 0), + (3.165814, 1.7175, 477, 0, 0), + (2.988092, 1.58275, 477, 0, 0), + (2.8351, 1.46675, 477, 0, 0), + (2.583849, 1.27625, 477, 0, 0), + (2.673864, 1.3445, 464, 10, 9), + (2.195433, 0.98175, 464, 58, 60), + (1.809655, 0.68925, 464, 196, 99), + (0.889722, -0.00825, 464, 75, 0), + (0.778934, -0.09225, 508, 19, 0), + (0.673422, -0.17225, 508, 277, 47), + (0.651001, -0.18925, 508, 240, 0), + (0.574175, -0.2475, 508, 296, 0), + (0.565602, -0.254, 899, 314, 0), + (0.505263, -0.29975, 899, 767, 0), + (0.504933, -0.3, 899, 800, 0), + (0.504658, -0.30025, 899, 1458, 372), + (0.504658, -0.30025, 795, 1989, 1058), + (0.503557, -0.30125, 795, 2254, 1942), + (0.501905, -0.30275, 795, 2440, 2536), + (0.448502, -0.35125, 795, 1778, 1945), + (1.105087, -0.3325, 1847, 3741, 4015), + (1.047004, -0.38525, 1847, 4028, 4369), + (0.989196, -0.43775, 1847, 4843, 5119), + (0.865047, -0.5505, 1847, 5175, 5564), + (0.367571, -0.42475, 1487, 5443, 5934), + (0.354633, -0.4365, 1487, 5597, 6235), + (0.232961, -0.547, 1487, 5764, 6484), + (0.183687, -0.59175, 1487, 5917, 6607), + (0.150929, -0.6215, 2039, 6030, 6797), + (0.103032, -0.665, 2039, 6100, 6837), + (0.11129, -0.6575, 2039, 6243, 7027), + (0.103857, -0.66425, 2039, 4918, 5820), + (0.728235, -0.67475, 5445, 6048, 6988), + (0.61785, -0.775, 5445, 5798, 5612), + (0.61785, -0.775, 5445, 4634, 5683), + (0.588671, -0.8015, 5445, 4653, 5848), + (-0.141137, -0.88675, 6866, 3243, 4495), + (-0.322268, -1.05125, 6866, 3476, 3873), + (-0.60883, -1.3115, 6866, 2855, 3385), + (-0.892363, -1.569, 6866, 5859, 5852), + (-0.70903, -1.4025, 3625, 6375, 6266), + (-0.747568, -1.4375, 3625, 4265, 4247), + (-0.721692, -1.414, 3625, 5850, 5737), + (-0.739861, -1.4305, 3625, 6502, 6411), + (-0.893464, -1.57, 3856, 5448, 5535), + (-0.615987, -1.318, 3856, 3694, 3496), + (-0.381728, -1.10525, 3856, 5591, 5503), + (-0.160957, -0.90475, 3856, 3403, 3478), + (0.366249, -1.0035, 4480, 3905, 4076), + (0.645102, -0.75025, 4480, 4815, 4332), + (0.787695, -0.62075, 4480, 4106, 3720), + (1.001859, -0.42625, 4480, 2989, 2882), + (0.410514, -0.38575, 3747, 3001, 3059), + (0.47713, -0.32525, 3747, 2817, 3173), + (0.50163, -0.303, 3747, 2227, 2956), + (0.493371, -0.3105, 3747, 2487, 3203), + (0.493647, -0.31025, 1282, 1581, 1978), + (1.613139, 0.54025, 1282, 690, 1064), + (3.859225, 2.24325, 1282, 376, 880), + (4.583631, 2.7925, 1282, 392, 687), + (3.208019, 1.7495, 1898, 434, 656), + (4.243355, 2.5345, 1898, 557, 726), + (4.562529, 2.7765, 1898, 423, 609), + (4.798282, 2.95525, 1898, 156, 269), + (4.912267, 2.5595, 1993, 104, 178), + (5.082735, 2.68875, 1993, 42, 43), + (5.263424, 2.82575, 1993, 166, 185), + (5.476756, 2.9875, 1993, 58, 60), + (4.772234, 2.9355, 1513, 0, 0), + (4.780807, 2.942, 1513, 0, 0), + (4.895551, 3.029, 1513, 0, 0), + (4.893573, 3.0275, 1513, 0, 0), +] + +SOC_WH = 47360.0 +PRAGUE = ZoneInfo("Europe/Prague") +BASE = datetime(2026, 6, 6, 21, 0, tzinfo=PRAGUE).astimezone(timezone.utc) + + +def _build_slots(*, permissive: bool, evening_export: bool) -> list[PlanningSlot]: + out: list[PlanningSlot] = [] + for i, (buy, sell, load, pv_a, pv_b) in enumerate(SLOT_ROWS): + ts = BASE + timedelta(minutes=15 * i) + pv_surplus = max(0, pv_a + pv_b - load) + if permissive: + allow_charge = True + allow_discharge_export = True + elif evening_export: + h = ts.astimezone(PRAGUE).hour + allow_discharge_export = sell >= 0 and (h >= 17 or sell > buy + 0.15) + allow_charge = buy < 0 or (sell < 0 and pv_surplus > 500) + else: + allow_discharge_export = sell >= 0 and sell > 2.5 + allow_charge = buy < 0 or (sell < 0 and pv_surplus > 500) + out.append( + PlanningSlot( + interval_start=ts, + buy_price=buy, + sell_price=sell, + pv_a_forecast_w=pv_a, + pv_b_forecast_w=pv_b, + load_baseline_w=load, + ev1_connected=False, + ev2_connected=False, + allow_charge=allow_charge, + allow_discharge_export=allow_discharge_export, + ) + ) + return out + + +def _ctx() -> tuple[SimpleNamespace, SimpleNamespace, SimpleNamespace, list]: + battery = SimpleNamespace( + usable_capacity_wh=64000.0, + min_soc_wh=6400.0, + arb_floor_wh=12800.0, + reserve_soc_wh=12800.0, + soc_max_wh=64000.0, + charge_efficiency=0.95, + discharge_efficiency=0.95, + degradation_cost_czk_kwh=0.15, + max_charge_power_w=18000, + max_discharge_power_w=18000, + charge_slot_buffer=1.3, + discharge_slot_buffer=1.5, + planner_terminal_soc_value_factor=0.9, + planner_discharge_floor_percent=5.0, + planner_extreme_buy_threshold_czk_kwh=-2.0, + planner_daytime_charge_target_enabled=True, + planner_charge_commitment_penalty_czk_kwh=0.2, + planner_night_baseload_buffer_percent=20, + planner_neg_sell_prep_soc_percent=80.0, + planner_neg_sell_full_soc_tail_slots=4, + planner_neg_sell_vent_min_sell_czk_kwh=-0.5, + ) + grid = SimpleNamespace( + max_import_power_w=17000, + max_export_power_w=13500, + block_export_on_negative_sell=False, + deye_gen_microinverter_cutoff_enabled=False, + purchase_pricing_mode="spot", + ) + hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0) + vehicles = [ + SimpleNamespace(max_charge_power_w=11000, battery_capacity_kwh=75.0, default_target_soc_pct=80.0), + SimpleNamespace(max_charge_power_w=7400, battery_capacity_kwh=52.0, default_target_soc_pct=90.0), + ] + return battery, hp, grid, vehicles + + +def _try(label: str, slots: list[PlanningSlot], **kwargs) -> None: + battery, hp, grid, vehicles = _ctx() + try: + _r, _ms, snap = solve_dispatch( + slots, battery, hp, grid, [None, None], vehicles, SOC_WH, 55.0, + operating_mode="AUTO", **kwargs, + ) + inp = snap.get("inputs") or {} + print( + f"OK {label}: future_neg={inp.get('future_neg_buy_discharge')} " + f"push_sup={inp.get('evening_push_hard_suppressed')} " + f"eve={len(inp.get('evening_push_ts') or [])} " + f"neg_eve={len(inp.get('neg_evening_push_slots') or [])}" + ) + except PlannerSolverError as e: + print(f"FAIL {label}: {e}") + + +def main() -> None: + print(f"soc={SOC_WH} slots={len(SLOT_ROWS)} start={BASE.isoformat()}") + for mask_label, permissive, evening in [ + ("permissive", True, False), + ("evening_peak_mask", False, True), + ("tight_mask", False, False), + ]: + slots = _build_slots(permissive=permissive, evening_export=evening) + print(f"\n--- masks: {mask_label} ---") + _try("strict", slots) + _try( + "relaxed_prep_window", + slots, + relaxed_expensive_import=True, + relaxed_neg_buy_charge=True, + relaxed_neg_prep_hold_only=True, + relaxed_neg_prep_window=True, + ) + bat_fb, hp, grid, vehicles = _ctx() + bat_fb = SimpleNamespace(**{**vars(bat_fb), "planner_neg_sell_prep_soc_percent": 100.0}) + try: + _r, _ms, snap = solve_dispatch( + slots, bat_fb, hp, grid, [None, None], vehicles, SOC_WH, 55.0, + operating_mode="AUTO", + 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, + ) + inp = snap.get("inputs") or {} + print( + f"OK neg_sell_phases_fallback: push_sup={inp.get('evening_push_hard_suppressed')}" + ) + except PlannerSolverError as e: + print(f"FAIL neg_sell_phases_fallback: {e}") + + print("\n--- two_pass (production path) ---") + slots = _build_slots(permissive=False, evening_export=True) + battery, hp, grid, vehicles = _ctx() + try: + solve_dispatch_two_pass( + slots, battery, hp, grid, [None, None], vehicles, SOC_WH, 55.0, + operating_mode="AUTO", + ) + print("OK two_pass") + except PlannerSolverError as e: + print(f"FAIL two_pass: {e}") + + +if __name__ == "__main__": + main()