From b7903db71490640e2dc26796d84a0e3b0ce6df6c Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Sat, 6 Jun 2026 23:47:12 +0200 Subject: [PATCH] dasli fix --- backend/services/planning_engine.py | 44 +++++++++- backend/tests/test_planning_dispatch_milp.py | 84 ++++++++++++++++++++ docs/planning-changelog.md | 9 ++- 3 files changed, 132 insertions(+), 5 deletions(-) diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index e47bdb1..37be1eb 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-v2" +PLANNER_BUILD_TAG = "2026-06-06-home01-degraded-night-guard-v3" SOLVER_RELAX_STEPS: tuple[str, ...] = ( "strict", "relaxed_expensive_import", @@ -1940,6 +1940,24 @@ def _evening_push_segment_candidates( return out +def _degraded_relaxed_night_self_consume_indices( + slots: list[PlanningSlot], +) -> set[int]: + """ + relaxed_solver_masks: celé noční okno — dům z baterie (až min_soc), ne import za spot buy. + """ + out: set[int] = set() + for t, s in enumerate(slots): + if not _in_night_battery_export_window(s): + continue + if float(s.load_baseline_w) <= 0: + continue + if float(s.buy_price) < 0.0: + continue + out.add(t) + return out + + def _post_evening_push_night_self_consume_indices( slots: list[PlanningSlot], evening_push_ts: set[int], @@ -3027,6 +3045,7 @@ def solve_dispatch( 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() evening_push_hysteresis_retained = False push_override_raw: Optional[set[int]] = None push_override_eff: Optional[set[int]] = None @@ -3195,6 +3214,9 @@ def solve_dispatch( 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) + 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, @@ -3307,6 +3329,8 @@ def solve_dispatch( 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: @@ -3854,6 +3878,13 @@ def solve_dispatch( continue prob += ge_bat[t_pv] == 0 prob += z_export[t_pv] == 0 + # Nouzový relax: spot v noci neexportovat baterii za ~2,5 Kč (žádný tvrdý evening dump). + if relaxed_solver_masks and not purchase_fixed_pre: + for t_blk in range(T): + if not _in_night_battery_export_window(slots[t_blk]): + continue + prob += ge_bat[t_blk] == 0 + prob += z_export[t_blk] == 0 # Ostatní profitable sloty: měkká shortfall penalizace (ne večerní push). if ( last_pos_sell_pre_neg_buy is not None @@ -4195,6 +4226,7 @@ def solve_dispatch( 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 @@ -4202,8 +4234,14 @@ def solve_dispatch( 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 @@ -5149,6 +5187,10 @@ def solve_dispatch( 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) + ], }, "masks": masks_snap, "soc_bounds": soc_bounds_snap, diff --git a/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index 6a6f03c..34b1bac 100644 --- a/backend/tests/test_planning_dispatch_milp.py +++ b/backend/tests/test_planning_dispatch_milp.py @@ -3698,6 +3698,90 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase): self.assertLess(results[0].grid_setpoint_w, -500) self.assertLess(results[0].battery_soc_target, 70.0) + def test_degraded_relaxed_solver_no_night_dump_and_self_consume(self) -> None: + """relaxed_solver_masks: žádný večerní dump za ~2,5 Kč; noc dům z baterie, ne import ~4 Kč.""" + prague = ZoneInfo("Europe/Prague") + base = datetime(2026, 6, 6, 21, 30, tzinfo=prague).astimezone(timezone.utc) + rows: list[tuple[float, float, int]] = [ + (4.66, 2.85, 780), + (4.48, 2.72, 780), + (4.76, 2.92, 450), + (4.35, 2.61, 450), + (4.06, 2.40, 440), + (3.80, 2.20, 440), + (3.76, 2.17, 440), + (3.48, 1.96, 440), + (3.76, 2.17, 460), + (3.48, 1.96, 460), + (3.34, 1.85, 460), + (3.03, 1.61, 460), + ] + slots: list[PlanningSlot] = [] + for i in range(48): + local = (base + timedelta(minutes=15 * i)).astimezone(prague) + if i < len(rows): + buy, sell, load = rows[i] + pv_a, pv_b = 0, 0 + elif local.hour >= 5 and local.hour < 12: + buy, sell, load, pv_a, pv_b = 0.5, -0.3, 800, 2000, 2500 + else: + buy, sell, load, pv_a, pv_b = 3.0, 2.0, 500, 0, 0 + 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=True, + allow_discharge_export=sell >= 0, + ) + ) + bat = _battery(uc_wh=64_000.0, arb_pct=20.0, terminal_soc_value_factor=0.9) + bat.planner_neg_sell_prep_soc_percent = 100.0 + 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), + ] + soc = 0.71 * bat.soc_max_wh + results, _ms, snap = solve_dispatch( + slots, + bat, + hp, + grid, + [None, None], + vehicles, + soc, + 50.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, + relaxed_pos_sell_ge_block=True, + relaxed_solver_masks=True, + ) + inp = snap.get("inputs") or {} + self.assertTrue(inp.get("relaxed_solver_masks")) + self.assertGreater(len(inp.get("degraded_relaxed_night_ts") or []), 0) + for i, r in enumerate(results[:12]): + self.assertGreaterEqual(r.battery_soc_target, 10.0) + if i < 4: + self.assertGreater(r.grid_setpoint_w, -500, msg=f"slot {i} evening dump") + if 8 <= i <= 11 and rows[i][0] > 3.0: + self.assertLessEqual(r.grid_setpoint_w, rows[i][2] + 50, msg=f"slot {i} grid import") + 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 44f8df4..6449447 100644 --- a/docs/planning-changelog.md +++ b/docs/planning-changelog.md @@ -11,18 +11,19 @@ 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`, doplněno **v2**):** +**Oprava (tag `2026-06-06-home01-late-replan-infeasible-v1`, doplněno **v2**, guard **v3**):** - 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). - **`_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`. +- **v3 (`degraded-night-guard-v3`):** v **`relaxed_solver_masks`** — spot **bez nočního/večerního `ge_bat` exportu**; **`_degraded_relaxed_night_self_consume_indices`** + tvrdý expensive-import guard (dům z baterie až **`min_soc`**, ne import za ~4 Kč); exportní podlaha SoC ≥ **`reserve_soc`**. -**Soubory:** `planning_engine.py`, `scripts/repro_home01_23840.py`, test `test_home01_late_replan_high_soc_realistic_masks`. +**Soubory:** `planning_engine.py`, `scripts/repro_home01_23840.py`, testy `test_home01_late_replan_high_soc_realistic_masks`, `test_degraded_relaxed_solver_no_night_dump_and_self_consume`. **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`, `planner_build_tag` končí **`infeasible-v2`**, večerní sloty `grid_setpoint_w < 0`. +- `pytest backend/tests/test_planning_dispatch_milp.py -k "home01_late_replan or degraded_relaxed"` +- Po deployi: aktivní run `planner_build_tag` končí **`degraded-night-guard-v3`**; při `relax_chain` obsahujícím `relaxed_solver_masks` večer **bez** masivního exportu, noc **bez** importu pro baseload nad ~load setpoint. ---