From 2a963c979307d32e2fb4bf0a086bcf2ec55c7887 Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Sat, 6 Jun 2026 22:23:59 +0200 Subject: [PATCH] =?UTF-8?q?Branch=201:=20failed=20run=20journal=20+=20bise?= =?UTF-8?q?ct=20Infeasible=20+=20granul=C3=A1rn=C3=AD=20relaxace=20(bez=20?= =?UTF-8?q?vypnut=C3=AD=20evening=20push)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/services/planning_engine.py | 252 +++++++++++++++--- backend/tests/test_planning_dispatch_milp.py | 89 +++++++ .../V084__planning_run_failed_status.sql | 14 + db/routines/R__091_fn_planning_run_fail.sql | 50 ++++ docs/04-modules/planning.md | 8 +- docs/planning-changelog.md | 19 ++ scripts/diagnose_home01_infeasible.py | 233 +++++++++++++--- 7 files changed, 593 insertions(+), 72 deletions(-) create mode 100644 db/migration/V084__planning_run_failed_status.sql create mode 100644 db/routines/R__091_fn_planning_run_fail.sql diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index 1d22ad4..eb4c506 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -71,7 +71,15 @@ 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-01-kv1-fixed-night-self-consume-v62" +PLANNER_BUILD_TAG = "2026-06-06-infeasible-journal-granular-prep-relax-v63" +SOLVER_RELAX_STEPS: tuple[str, ...] = ( + "strict", + "relaxed_expensive_import", + "relaxed_neg_buy_charge", + "relaxed_neg_prep_hold_only", + "relaxed_neg_prep_window", + "neg_sell_phases_fallback", +) # Ranní slabá FVE: neaplikovat pv_store ge_pv=0 (jinak curtail při sell < večerní peak). DAWN_LOW_PV_NO_CURTAIL_W = 1500 # BA81/KV1: PV→bat jen v těsné blízkosti nejnižšího sell v horizontu (≈ poledne), ne při ~3 Kč ráno. @@ -117,6 +125,42 @@ ARB_FLOOR_E_REF_FRAC = 0.5 # má scale Wh = tato frakce usable_capacity (0.. _PRAGUE_TZ = ZoneInfo("Europe/Prague") +class PlannerSolverError(RuntimeError): + """Solver selhal po vyčerpání retry řetězce (typicky Infeasible).""" + + def __init__( + self, + solver_status: str, + *, + relax_chain: list[str] | None = None, + ) -> None: + self.solver_status = solver_status + self.relax_chain = list(relax_chain or []) + super().__init__(f"Solver: {solver_status}") + + +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, +) -> 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, + } + chain = [SOLVER_RELAX_STEPS[0]] + for step in SOLVER_RELAX_STEPS[1:]: + if flags.get(step, False): + chain.append(step) + return chain + + def _timestamptz_from_db(val: object) -> Optional[datetime]: if val is None: return None @@ -2171,6 +2215,7 @@ def _solve_dispatch_relax_carryover(snap: dict[str, Any]) -> 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", ): @@ -2273,6 +2318,7 @@ def _evening_push_override_for_solve( *, relaxed_expensive_import: bool, relaxed_neg_buy_charge: bool, + relaxed_neg_prep_hold_only: bool, relaxed_neg_prep_window: bool, neg_sell_phases_fallback: bool, ) -> Optional[set[int]]: @@ -2329,6 +2375,7 @@ def solve_dispatch( 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, evening_push_ts_override: Optional[set[int]] = None, @@ -2338,7 +2385,8 @@ def solve_dispatch( 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_window: třetí retry — bez tvrdého večerního push/kotvy a prep hold binárek (sell<0 okno). + 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 — navíc vypne neg-evening bundle a tvrdý evening push. """ T = len(slots) if T < 1: @@ -2349,6 +2397,7 @@ def solve_dispatch( or relaxed_neg_prep_window or neg_sell_phases_fallback ) + prep_hold_relaxed = relaxed_neg_prep_hold_only or relaxed_neg_prep_window EV = len(vehicles) # počet EV (typicky 2) planner_version_resolved = _planner_engine_version(planner_version) planner_v2 = planner_version_resolved == "v2" @@ -2779,6 +2828,7 @@ def solve_dispatch( 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, ) @@ -3118,7 +3168,7 @@ def solve_dispatch( 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 relaxed_neg_prep_window: + if neg_sell_phases_en and not prep_hold_relaxed: 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] @@ -3146,7 +3196,7 @@ def solve_dispatch( neg_sell_soc_underfill.append( (t_tail_last, us_tail, float(battery.soc_max_wh)) ) - if not relaxed_neg_prep_window: + if not prep_hold_relaxed: for t_ph in range(T): if neg_sell_phase_by_t[t_ph] != "prep": continue @@ -4256,10 +4306,10 @@ def solve_dispatch( relaxed_neg_buy_charge=True, evening_push_ts_override=evening_push_ts_override, ) - if not relaxed_neg_prep_window: + if not relaxed_neg_prep_hold_only: logger.warning( - "solve_dispatch still Infeasible, retry with relaxed_neg_prep_window " - "(skip evening push/anchors and prep hold hard constraints)" + "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, @@ -4276,6 +4326,30 @@ def solve_dispatch( 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 neg-evening bundle and tvrdý evening push)" + ) + 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, @@ -4306,11 +4380,21 @@ def solve_dispatch( 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, ) - raise RuntimeError(f"Solver: {pulp.LpStatus[status]}") + 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, + ), + ) # --- Post-processing --- results = [] @@ -4638,8 +4722,16 @@ def solve_dispatch( "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, + "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, + ), "charge_acquisition_buy_czk_kwh": charge_acquisition_czk_kwh, "charge_acquisition_cutoff_at": ( slots[0].charge_acquisition_cutoff_at.isoformat() @@ -4740,20 +4832,36 @@ async def run_daily_plan( slots = await _load_slots(site_id, horizon_from, horizon_to, db, soc_wh=soc_wh) om = operating_mode or "AUTO" - if 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, + try: + if 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, @@ -4954,23 +5062,39 @@ async def run_rolling_replan( ) om = operating_mode or "AUTO" - if 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, + try: + if 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, @@ -5523,3 +5647,57 @@ async def _save_planning_run( activate_run, ) ) + + +async def _save_failed_planning_run( + site_id: int, + horizon_from: datetime, + horizon_to: datetime, + *, + run_type: str, + triggered_by: str, + replan_from: datetime | None, + soc_wh: float, + correction: float, + db, + error: PlannerSolverError, + slot_count: int | None = None, +) -> int: + """Uloží neúspěšný běh plánovače (status=failed); aktivní plán nemění.""" + run_meta: dict[str, Any] = { + "run_type": run_type, + "triggered_by": triggered_by, + "replan_from": replan_from.isoformat() if replan_from else None, + "soc_at_replan_wh": soc_wh, + "solver_duration_ms": 0, + "forecast_correction_factor": correction, + "error_text": str(error), + "solver_params": { + "status": "failed", + "planner_build_tag": PLANNER_BUILD_TAG, + "solver_status": error.solver_status, + "relax_chain": error.relax_chain, + "slot_count": slot_count, + }, + } + run_id = int( + await db.fetchval( + """ + select ems.fn_planning_run_fail( + $1::int, $2::timestamptz, $3::timestamptz, $4::jsonb + ) + """, + site_id, + horizon_from, + horizon_to, + json.dumps(run_meta, default=str), + ) + ) + logger.error( + "[site=%s] Planning solver failed run_id=%s: %s relax_chain=%s", + site_id, + run_id, + error, + error.relax_chain, + ) + return run_id diff --git a/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index d3334cb..9f48785 100644 --- a/backend/tests/test_planning_dispatch_milp.py +++ b/backend/tests/test_planning_dispatch_milp.py @@ -21,6 +21,7 @@ from services.planning_engine import ( _evening_push_discharge_budget_wh, _evening_push_override_for_solve, _filter_evening_push_override_indices, + _solver_relax_chain, _primary_night_export_segment_indices, _in_evening_push_hour_window, _in_night_battery_export_window, @@ -3219,14 +3220,25 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase): {2, 5}, relaxed_expensive_import=False, relaxed_neg_buy_charge=False, + relaxed_neg_prep_hold_only=False, relaxed_neg_prep_window=False, neg_sell_phases_fallback=False, ) self.assertEqual(kept, {2, 5}) + kept_prep_hold = _evening_push_override_for_solve( + {2, 5}, + relaxed_expensive_import=False, + relaxed_neg_buy_charge=False, + relaxed_neg_prep_hold_only=True, + relaxed_neg_prep_window=False, + neg_sell_phases_fallback=False, + ) + self.assertEqual(kept_prep_hold, {2, 5}) dropped = _evening_push_override_for_solve( {2, 5}, relaxed_expensive_import=True, relaxed_neg_buy_charge=False, + relaxed_neg_prep_hold_only=False, relaxed_neg_prep_window=False, neg_sell_phases_fallback=False, ) @@ -3369,6 +3381,83 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase): push_iso = snap["inputs"].get("evening_push_ts") or [] self.assertGreaterEqual(len(push_iso), 1) + def test_relaxed_neg_prep_hold_only_keeps_hard_push(self) -> None: + """v63: prep_hold_only uvolní prep hold, ne tvrdý evening push.""" + prague = ZoneInfo("Europe/Prague") + slots = [ + PlanningSlot( + interval_start=datetime(2026, 5, 30, 18, 0, tzinfo=prague).astimezone(timezone.utc), + buy_price=3.0, + sell_price=4.0, + pv_a_forecast_w=0, + pv_b_forecast_w=0, + load_baseline_w=500, + ev1_connected=False, + ev2_connected=False, + allow_discharge_export=True, + ), + PlanningSlot( + interval_start=datetime(2026, 5, 30, 22, 0, tzinfo=prague).astimezone(timezone.utc), + buy_price=3.0, + sell_price=6.0, + pv_a_forecast_w=0, + pv_b_forecast_w=0, + load_baseline_w=500, + ev1_connected=False, + ev2_connected=False, + allow_discharge_export=True, + ), + ] + battery = _battery(uc_wh=64_000.0) + battery.max_discharge_power_w = 18_000 + battery.planner_neg_sell_prep_soc_percent = 80 + battery.planner_neg_sell_full_soc_tail_slots = 4 + grid = SimpleNamespace( + max_export_power_w=13_500, + max_import_power_w=17_000, + block_export_on_negative_sell=False, + purchase_pricing_mode="spot", + ) + _results, _ms, snap = solve_dispatch( + slots, + battery, + SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0), + grid, + [None, None], + [ + 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), + ], + current_soc_wh=16_000.0, + current_tuv_temp_c=55.0, + relaxed_expensive_import=True, + relaxed_neg_buy_charge=True, + relaxed_neg_prep_hold_only=True, + ) + self.assertTrue(snap["inputs"].get("relaxed_neg_prep_hold_only")) + self.assertFalse(snap["inputs"].get("relaxed_neg_prep_window")) + self.assertFalse(snap["inputs"].get("evening_push_hard_suppressed")) + self.assertIn( + "relaxed_neg_prep_hold_only", + snap["inputs"].get("relax_chain") or [], + ) + + def test_solver_relax_chain_order(self) -> None: + chain = _solver_relax_chain( + relaxed_expensive_import=True, + relaxed_neg_buy_charge=True, + relaxed_neg_prep_hold_only=True, + ) + self.assertEqual( + chain, + [ + "strict", + "relaxed_expensive_import", + "relaxed_neg_buy_charge", + "relaxed_neg_prep_hold_only", + ], + ) + 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/db/migration/V084__planning_run_failed_status.sql b/db/migration/V084__planning_run_failed_status.sql new file mode 100644 index 0000000..941a092 --- /dev/null +++ b/db/migration/V084__planning_run_failed_status.sql @@ -0,0 +1,14 @@ +-- Journal neúspěšných běhů plánovače (Solver: Infeasible po celém retry řetězci). + +alter table ems.planning_run + add column if not exists error_text text; + +comment on column ems.planning_run.error_text is + 'Chybová zpráva u status=failed (typicky Solver: Infeasible); aktivní plán se nemění.'; + +comment on column ems.planning_run.status is + 'Stav plánu: draft, approved, active, superseded, comparison (shadow běh), failed (solver selhal).'; + +create index if not exists idx_planning_run_site_failed + on ems.planning_run (site_id, created_at desc) + where status = 'failed'; diff --git a/db/routines/R__091_fn_planning_run_fail.sql b/db/routines/R__091_fn_planning_run_fail.sql new file mode 100644 index 0000000..3c46797 --- /dev/null +++ b/db/routines/R__091_fn_planning_run_fail.sql @@ -0,0 +1,50 @@ +-- neúspěšný běh plánovače bez aktivace a bez supersede aktivního plánu + +create or replace function ems.fn_planning_run_fail( + p_site_id int, + p_horizon_start timestamptz, + p_horizon_end timestamptz, + p_run_meta jsonb +) +returns int +language plpgsql +as $fn$ +declare + v_run_id int; +begin + insert into ems.planning_run ( + site_id, horizon_start, horizon_end, status, + run_type, triggered_by, replan_from, + soc_at_replan_wh, solver_duration_ms, forecast_correction_factor, + solver_params, error_text + ) values ( + p_site_id, + p_horizon_start, + p_horizon_end, + 'failed', + nullif(trim(p_run_meta->>'run_type'), ''), + nullif(trim(p_run_meta->>'triggered_by'), ''), + case + when p_run_meta ? 'replan_from' and (p_run_meta->>'replan_from') is not null + and (p_run_meta->>'replan_from') <> 'null' + then (p_run_meta->>'replan_from')::timestamptz + else null::timestamptz + end, + (p_run_meta->>'soc_at_replan_wh')::numeric, + coalesce((p_run_meta->>'solver_duration_ms')::int, 0), + coalesce((p_run_meta->>'forecast_correction_factor')::numeric, 1.0), + case + when p_run_meta ? 'solver_params' and jsonb_typeof(p_run_meta->'solver_params') = 'object' + then p_run_meta->'solver_params' + else null::jsonb + end, + nullif(trim(p_run_meta->>'error_text'), '') + ) + returning id into v_run_id; + + return v_run_id; +end; +$fn$; + +comment on function ems.fn_planning_run_fail is + 'Uloží planning_run se statusem failed; neaktivuje plán a nesupersededuje active.'; diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index 51129a9..45633f0 100644 --- a/docs/04-modules/planning.md +++ b/docs/04-modules/planning.md @@ -113,7 +113,7 @@ flowchart TD 5. **v45 — neg okno + noc z baterie:** - **`neg_window_grid_charge`:** v sell<0 okně neg dne grid nabíjení i bez `pv_surplus` (07:45+); - **`night_self_consume_discourage`** na **celé** noční okno mimo push; - - při `relaxed_neg_prep_window` bez prep shortfall penalizace. + - při `relaxed_neg_prep_hold_only` nebo `relaxed_neg_prep_window` bez prep shortfall penalizace. 6. **v47 — po večerním pushu noc z baterie:** - večerní push zůstává **sell > acq+spread** (sell<buy je záměr před neg dnem); @@ -140,6 +140,12 @@ flowchart TD 15. **v61 — spot: grid→bat jen při buy ≤ acq:** `sell < buy` ve slotu **není** kritérium (marže); zákaz nabíjení při **`buy > charge_acquisition + degrad`**. Zrušeno v60. Tag **`2026-06-01-spot-grid-charge-at-acq-buy-v61`**. +16. **v63 — Infeasible journal + granulární prep relax (Branch 1):** + - Retry řetězec: strict → `relaxed_expensive_import` → `relaxed_neg_buy_charge` → **`relaxed_neg_prep_hold_only`** (jen prep hold / prep_soc shortfall) → **`relaxed_neg_prep_window`** (navíc vypne neg-evening bundle + tvrdý push) → `neg_sell_phases_fallback`. + - Snap: `relax_chain`, `relaxed_neg_prep_hold_only`; `evening_push_hard_suppressed` jen od `relaxed_neg_prep_window`. + - Selhání po celém řetězci → `planning_run.status = failed`, sloupec `error_text`, `ems.fn_planning_run_fail` (aktivní plán se nemění). + - Diagnostika: `scripts/diagnose_home01_infeasible.py --print-export-sql --run-id `. Tag **`2026-06-06-infeasible-journal-granular-prep-relax-v63`**. + **Funkce:** … home-01 **v61**; BA81/KV1 fixed **v59** (+ `R__063`). ### Rozpočet nabíjecích slotů (plánováno, 2026-06) diff --git a/docs/planning-changelog.md b/docs/planning-changelog.md index ae09f91..8a2968d 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 — Infeasible journal + granulární prep relax (v63, Branch 1) + +**Problém:** home-01 run 23784 prošel až **`relaxed_neg_prep_window`** (3. retry) → `evening_push_hard_suppressed`, prázdné `neg_evening_push_slots`, SoC ~80 % ve špičce + import @ ~5 Kč. Selhání **`Solver: Infeasible`** se neukládalo do DB (jen log backendu). + +**Změna (v63):** +- Nový krok **`relaxed_neg_prep_hold_only`**: uvolní jen `prep_soc_shortfall` + prep hold binárky; **neg-evening bundle a tvrdý evening push zůstávají**. +- **`relaxed_neg_prep_window`** až jako 4. krok (full prep relax včetně neg-evening a `evening_push_hard_suppressed`). +- **`PlannerSolverError`** + `relax_chain` ve snap; po vyčerpání retry → **`fn_planning_run_fail`** (`status=failed`, `error_text`, migrace **V084**). +- **`scripts/diagnose_home01_infeasible.py`**: `--print-export-sql`, bisect všech relax kroků. + +**Soubory:** `backend/services/planning_engine.py`, `db/migration/V084__planning_run_failed_status.sql`, `db/routines/R__091_fn_planning_run_fail.sql`, `scripts/diagnose_home01_infeasible.py`, `backend/tests/test_planning_dispatch_milp.py`. + +**Ověření:** +- `pytest backend/tests/test_planning_dispatch_milp.py -k "prep_hold or relax_chain or evening_push_override"` +- MCP po neúspěšném API replanu: `select id, status, error_text, solver_params->'relax_chain' from ems.planning_run where site_id=2 and status='failed' order by created_at desc limit 3;` +- Úspěšný rolling: `relaxed_neg_prep_hold_only: true` bez `relaxed_neg_prep_window` a `evening_push_hard_suppressed: false`. + +--- + ## Plánováno — rozpočet nabíjecích slotů (charge-slot-budget, neimplementováno) **Stav:** pouze dokumentace (2026-06); implementace později. diff --git a/scripts/diagnose_home01_infeasible.py b/scripts/diagnose_home01_infeasible.py index 61b0194..d4f7f11 100644 --- a/scripts/diagnose_home01_infeasible.py +++ b/scripts/diagnose_home01_infeasible.py @@ -1,7 +1,21 @@ #!/usr/bin/env python3 -"""Bisect Infeasible na reálných slotech home-01 (MCP run 16674). PYTHONPATH=backend.""" +"""Bisect Infeasible na reálných slotech home-01 (fixture z MCP). + +Export fixture z MCP (server user-postgres-ems, nástroj query): + + python scripts/diagnose_home01_infeasible.py --print-export-sql --run-id 23784 + + # výstup SQL vlož do MCP query; JSON ulož např.: + # scripts/home01_run23784_slots.json + +Spuštění bisectu: + + PYTHONPATH=backend python scripts/diagnose_home01_infeasible.py \\ + --fixture scripts/home01_run23784_slots.json --soc-wh 51840 +""" from __future__ import annotations +import argparse import json import sys from datetime import datetime, timezone @@ -10,13 +24,42 @@ from types import SimpleNamespace sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "backend")) -from services.planning_engine import PlanningSlot, solve_dispatch, solve_dispatch_two_pass, PLANNER_BUILD_TAG +from services.planning_engine import ( # noqa: E402 + PLANNER_BUILD_TAG, + PlanningSlot, + SOLVER_RELAX_STEPS, + solve_dispatch, + solve_dispatch_two_pass, +) -# Export z MCP: planning_interval run_id=16674 + fn_planning_site_context(2) -SLOTS_JSON = Path(__file__).with_name("home01_run16706_slots.json") -if not SLOTS_JSON.exists(): - SLOTS_JSON = Path(__file__).with_name("home01_run16674_slots.json") -SOC_WH = 37120.0 +DEFAULT_FIXTURE = Path(__file__).with_name("home01_run16674_slots.json") +DEFAULT_SOC_WH = 37120.0 + + +def export_slots_sql(run_id: int) -> str: + """SQL pro MCP export slotů z fn_load_planning_slots_full pro daný run.""" + return f""" +select json_agg(row order by row.interval_start) +from ( + select + s.interval_start, + s.buy_price::float8 as buy, + s.sell_price::float8 as sell, + s.load_baseline_w as load, + s.pv_a_forecast_w as pv_a, + s.pv_b_forecast_w as pv_b, + s.allow_charge, + s.allow_discharge_export + from ems.planning_run pr + cross join lateral ems.fn_load_planning_slots_full( + pr.site_id, + coalesce(pr.replan_from, pr.horizon_start), + pr.horizon_end, + coalesce(pr.soc_at_replan_wh, 0) + ) s + where pr.id = {run_id} +) row; +""".strip() def _ctx() -> tuple[SimpleNamespace, SimpleNamespace, SimpleNamespace, list]: @@ -39,12 +82,16 @@ def _ctx() -> tuple[SimpleNamespace, SimpleNamespace, SimpleNamespace, list]: 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=10.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 = [ @@ -54,12 +101,27 @@ def _ctx() -> tuple[SimpleNamespace, SimpleNamespace, SimpleNamespace, list]: return battery, hp, grid, vehicles -def load_slots(*, permissive_masks: bool) -> list[PlanningSlot]: - rows = json.loads(SLOTS_JSON.read_text()) +def load_slots( + rows: list[dict], + *, + permissive_masks: bool, + use_row_masks: bool, +) -> list[PlanningSlot]: out: list[PlanningSlot] = [] for r in rows: ts = datetime.fromisoformat(r["interval_start"].replace("Z", "+00:00")) pv_surplus = max(0, int(r["pv_a"]) + int(r["pv_b"]) - int(r["load"])) + if permissive_masks: + allow_charge = True + allow_discharge_export = True + elif use_row_masks and "allow_charge" in r: + allow_charge = bool(r.get("allow_charge")) + allow_discharge_export = bool(r.get("allow_discharge_export", True)) + else: + allow_charge = float(r["buy"]) < 0 or ( + float(r["sell"]) < 0 and pv_surplus > 500 + ) + allow_discharge_export = float(r["sell"]) >= 0 out.append( PlanningSlot( interval_start=ts, @@ -70,53 +132,156 @@ def load_slots(*, permissive_masks: bool) -> list[PlanningSlot]: load_baseline_w=int(r["load"]), ev1_connected=False, ev2_connected=False, - allow_charge=True if permissive_masks else (float(r["buy"]) < 0 or (float(r["sell"]) < 0 and pv_surplus > 500)), - allow_discharge_export=permissive_masks, + allow_charge=allow_charge, + allow_discharge_export=allow_discharge_export, ) ) return out -def try_solve(label: str, slots: list[PlanningSlot], **kwargs) -> str: +def try_solve( + label: str, + slots: list[PlanningSlot], + soc_wh: float, + **kwargs, +) -> tuple[str, dict | None]: battery, hp, grid, vehicles = _ctx() try: if kwargs.pop("two_pass", False): - solve_dispatch_two_pass( - slots, battery, hp, grid, [None, None], vehicles, SOC_WH, 55.0, - operating_mode="AUTO", **kwargs, + _results, _ms, snap = solve_dispatch_two_pass( + slots, + battery, + hp, + grid, + [None, None], + vehicles, + soc_wh, + 55.0, + operating_mode="AUTO", + **kwargs, ) else: - solve_dispatch( - slots, battery, hp, grid, [None, None], vehicles, SOC_WH, 55.0, - operating_mode="AUTO", **kwargs, + _results, _ms, snap = solve_dispatch( + slots, + battery, + hp, + grid, + [None, None], + vehicles, + soc_wh, + 55.0, + operating_mode="AUTO", + **kwargs, ) - return f"OK {label}" + inp = snap.get("inputs") or {} + relax = inp.get("relax_chain") or [] + push = len(inp.get("evening_push_ts") or []) + suppressed = inp.get("evening_push_hard_suppressed") + return ( + f"OK {label} relax={relax[-1] if relax else 'strict'} " + f"push_slots={push} hard_suppressed={suppressed}", + inp, + ) except Exception as e: - return f"FAIL {label}: {e}" + return f"FAIL {label}: {e}", None def main() -> None: - if not SLOTS_JSON.exists(): - print(f"Chybí {SLOTS_JSON} — spusť export z MCP (run 16674).", file=sys.stderr) + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--fixture", type=Path, default=DEFAULT_FIXTURE) + parser.add_argument("--soc-wh", type=float, default=DEFAULT_SOC_WH) + parser.add_argument("--run-id", type=int, help="MCP run id pro --print-export-sql") + parser.add_argument( + "--print-export-sql", + action="store_true", + help="Vytiskne SQL pro export slotů z MCP", + ) + args = parser.parse_args() + + if args.print_export_sql: + run_id = args.run_id or 23784 + print(export_slots_sql(run_id)) + print( + f"\n-- Ulož json_agg výsledek do {args.fixture.name} " + f"(nebo jiné cesty přes --fixture).", + file=sys.stderr, + ) + return + + fixture = args.fixture + if not fixture.exists(): + print( + f"Chybí {fixture}. Spusť:\n" + f" python {Path(__file__).name} --print-export-sql --run-id 23784", + file=sys.stderr, + ) sys.exit(1) - print("tag", PLANNER_BUILD_TAG) - print("slots", len(json.loads(SLOTS_JSON.read_text()))) - neg_buy = [r for r in json.loads(SLOTS_JSON.read_text()) if r["buy"] < 0] - print("neg_buy slots", len(neg_buy), "first", neg_buy[0]["interval_start"] if neg_buy else None) + rows = json.loads(fixture.read_text()) + neg_buy = [r for r in rows if r["buy"] < 0] + neg_sell = [r for r in rows if r["sell"] < 0] - cases = [ + print("tag", PLANNER_BUILD_TAG) + print("fixture", fixture) + print("slots", len(rows)) + print("soc_wh", args.soc_wh) + print("neg_buy slots", len(neg_buy), "first", neg_buy[0]["interval_start"] if neg_buy else None) + print("neg_sell slots", len(neg_sell), "first", neg_sell[0]["interval_start"] if neg_sell else None) + print("relax steps", list(SOLVER_RELAX_STEPS)) + print() + + cases: list[tuple[str, dict]] = [ ("permissive masks, 1-pass", dict(permissive_masks=True, two_pass=False)), ("permissive masks, 2-pass", dict(permissive_masks=True, two_pass=True)), - ("realistic masks, 1-pass", dict(permissive_masks=False, two_pass=False)), - ("realistic masks, 2-pass", dict(permissive_masks=False, two_pass=True)), - ("realistic + relaxed_expensive", dict(permissive_masks=False, two_pass=False, relaxed_expensive_import=True)), - ("realistic + both relaxed", dict(permissive_masks=False, two_pass=False, relaxed_expensive_import=True, relaxed_neg_buy_pressure=True)), + ("realistic masks, 1-pass auto-retry", dict(permissive_masks=False, two_pass=False)), + ("realistic masks, 2-pass auto-retry", dict(permissive_masks=False, two_pass=True)), + ("realistic + row masks from fixture", dict(use_row_masks=True, two_pass=False)), + ("strict only (no auto retry)", dict( + permissive_masks=False, + relaxed_expensive_import=False, + relaxed_neg_buy_charge=False, + relaxed_neg_prep_hold_only=False, + relaxed_neg_prep_window=False, + neg_sell_phases_fallback=False, + )), + ("+ relaxed_expensive_import", dict( + permissive_masks=False, + relaxed_expensive_import=True, + )), + ("+ relaxed_neg_buy_charge", dict( + permissive_masks=False, + relaxed_expensive_import=True, + relaxed_neg_buy_charge=True, + )), + ("+ relaxed_neg_prep_hold_only (evening push kept)", dict( + permissive_masks=False, + relaxed_expensive_import=True, + relaxed_neg_buy_charge=True, + relaxed_neg_prep_hold_only=True, + )), + ("+ relaxed_neg_prep_window (full prep relax)", dict( + permissive_masks=False, + relaxed_expensive_import=True, + relaxed_neg_buy_charge=True, + relaxed_neg_prep_hold_only=True, + relaxed_neg_prep_window=True, + )), + ("+ neg_sell_phases_fallback", dict( + permissive_masks=False, + 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, + )), ] + for label, kw in cases: - masks = kw.pop("permissive_masks") - slots = load_slots(permissive_masks=masks) - print(try_solve(label, slots, **kw)) + permissive = kw.pop("permissive_masks", False) + use_row_masks = kw.pop("use_row_masks", False) + slots = load_slots(rows, permissive_masks=permissive, use_row_masks=use_row_masks) + msg, _inp = try_solve(label, slots, args.soc_wh, **kw) + print(msg) if __name__ == "__main__":