From 64327af8e0651364209147838210e8af3d1e966d Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Wed, 6 May 2026 12:50:05 +0200 Subject: [PATCH] fix KV1/BA81 cyklovani --- backend/services/planning_engine.py | 57 ++++++- .../tests/test_planning_safety_commitment.py | 146 ++++++++++++++++++ docs/04-modules/planning.md | 6 +- 3 files changed, 202 insertions(+), 7 deletions(-) diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index b386ce4..38baf2a 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -621,14 +621,30 @@ def solve_dispatch( 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] = [] + 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)) - safety_pen_czk_per_wh.append(bv / 1000.0 if sft is not None else 0.0) - if sft is not None: + # 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) + and not hs + ) + 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)) ) @@ -801,6 +817,17 @@ def solve_dispatch( export_soc_floor_t = float(soc_panel_min[t]) else: export_soc_floor_t = float(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]: + 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í @@ -977,6 +1004,22 @@ def solve_dispatch( } ) 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(), @@ -984,6 +1027,9 @@ def solve_dispatch( "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) @@ -1004,7 +1050,8 @@ def solve_dispatch( 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), + "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, @@ -1436,7 +1483,9 @@ async def _load_previous_plan_charge_commitment_prev_w( pva = int(r["pva"] or 0) pvb = int(r["pvb"] or 0) lb = int(r["lb"] or 0) - if bw > 500 and (pva + pvb) > lb and gw <= 0: + # Commitment má kotvit jen „nabíjení z PV přebytku“, ne situace kdy plán současně + # výrazně exportuje do sítě (typicky charge while exporting). To by stabilizovalo špatný cyklus. + if bw > 500 and (pva + pvb) > lb and gw <= 0 and gw >= -500: out.append(float(bw)) else: out.append(None) diff --git a/backend/tests/test_planning_safety_commitment.py b/backend/tests/test_planning_safety_commitment.py index b01b54b..6f3a340 100644 --- a/backend/tests/test_planning_safety_commitment.py +++ b/backend/tests/test_planning_safety_commitment.py @@ -56,6 +56,7 @@ def _slot( safety: float | None = None, fut_buy: float | None = None, fut_sell: float | None = None, + daytime_pv_surplus: bool = False, ) -> PlanningSlot: return PlanningSlot( interval_start=t0 + timedelta(minutes=15 * idx), @@ -71,6 +72,7 @@ def _slot( safety_soc_target_wh=safety, future_avoided_buy_czk_kwh=fut_buy, future_sell_opportunity_czk_kwh=fut_sell, + is_daytime_pv_surplus_slot=daytime_pv_surplus, ) @@ -135,6 +137,150 @@ class PlanningSafetyCommitmentTests(unittest.TestCase): ) self.assertEqual(snap2["chosen_slots"]["charge_commitment"], []) + def test_export_floor_uses_safety_target_in_non_high_sell_slot(self) -> None: + """Regrese: safety target nemá tlačit jen přes objective, ale chránit export floor.""" + t0 = datetime(2026, 5, 4, 8, 0, tzinfo=timezone.utc) + # Slot 0 není high-sell (future max sell je vyšší), ale safety target je nad arb_base. + slots = [ + _slot( + t0, + 0, + buy=3.0, + sell=2.0, + pv_a=8000, + load=500, + safety=12_000.0, + fut_sell=6.0, # high-sell somewhere later, not this slot + daytime_pv_surplus=True, + ), + _slot( + t0, + 1, + buy=3.0, + sell=6.0, + pv_a=0, + load=500, + safety=12_000.0, + fut_sell=6.0, + daytime_pv_surplus=False, + ), + ] + hp, grid = _hp(), _grid() + vehicles = [ + SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0) + ] * 2 + _res, _ms, snap = solve_dispatch( + slots, + _bat(arb_floor_wh=4000.0, reserve_soc_wh=4000.0, min_soc_wh=2000.0, soc_max_wh=19_000.0), + hp, + grid, + [None, None], + vehicles, + current_soc_wh=8000.0, + current_tuv_temp_c=50.0, + operating_mode="AUTO", + ) + b0 = snap["soc_bounds"][0] + self.assertEqual(b0["export_floor_reason"], "safety_export_floor") + self.assertEqual(float(b0["export_soc_floor_wh"]), 12_000.0) + self.assertFalse(bool(b0["high_sell_slot"])) + + def test_export_floor_keeps_arb_base_in_high_sell_slot(self) -> None: + """High-sell výjimka: v peak slotu nesmí safety floor blokovat arbitráž.""" + t0 = datetime(2026, 5, 4, 8, 0, tzinfo=timezone.utc) + # Slot 0 je high-sell (sell == future max), safety target je nad arb_base, ale nemá se aplikovat. + slots = [ + _slot( + t0, + 0, + buy=3.0, + sell=6.0, + pv_a=0, + load=500, + safety=12_000.0, + fut_sell=6.0, + daytime_pv_surplus=False, + ), + _slot( + t0, + 1, + buy=3.0, + sell=2.0, + pv_a=0, + load=500, + safety=12_000.0, + fut_sell=6.0, + daytime_pv_surplus=False, + ), + ] + hp, grid = _hp(), _grid() + vehicles = [ + SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0) + ] * 2 + _res, _ms, snap = solve_dispatch( + slots, + _bat(arb_floor_wh=4000.0, reserve_soc_wh=4000.0, min_soc_wh=2000.0, soc_max_wh=19_000.0), + hp, + grid, + [None, None], + vehicles, + current_soc_wh=8000.0, + current_tuv_temp_c=50.0, + operating_mode="AUTO", + ) + b0 = snap["soc_bounds"][0] + self.assertTrue(bool(b0["high_sell_slot"])) + self.assertEqual(b0["export_floor_reason"], "arb_base") + self.assertEqual(float(b0["export_soc_floor_wh"]), 4000.0) + + def test_safety_penalty_only_active_in_daytime_pv_surplus_slots(self) -> None: + t0 = datetime(2026, 5, 4, 8, 0, tzinfo=timezone.utc) + slots = [ + _slot( + t0, + 0, + buy=3.0, + sell=2.0, + pv_a=8000, + load=500, + safety=12_000.0, + fut_sell=6.0, + daytime_pv_surplus=True, + ), + _slot( + t0, + 1, + buy=3.0, + sell=2.0, + pv_a=0, + load=500, + safety=12_000.0, + fut_sell=6.0, + daytime_pv_surplus=False, + ), + ] + hp, grid = _hp(), _grid() + vehicles = [ + SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0) + ] * 2 + _res, _ms, snap = solve_dispatch( + slots, + _bat(), + hp, + grid, + [None, None], + vehicles, + current_soc_wh=8000.0, + current_tuv_temp_c=50.0, + operating_mode="AUTO", + ) + t0o = snap["objective_terms"][0] + t1o = snap["objective_terms"][1] + self.assertTrue(bool(t0o["safety_penalty_active"])) + self.assertGreater(float(t0o["safety_deficit_penalty_czk_per_wh"]), 0.0) + self.assertFalse(bool(t1o["safety_penalty_active"])) + self.assertEqual(float(t1o["safety_deficit_penalty_czk_per_wh"]), 0.0) + if __name__ == "__main__": unittest.main() diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index bdc4f86..6c3d2c6 100644 --- a/docs/04-modules/planning.md +++ b/docs/04-modules/planning.md @@ -10,15 +10,15 @@ - **Dynamický horizont (jen OTE):** konec plánu z **`ems.fn_planning_horizon_end(site_id, horizon_start)`** (výchozí strop **36 h**, minimum pro rolling **1 h** – obojí jako defaultní argumenty v SQL, úprava přes repeatable migraci). Pomocná `ems.fn_last_effective_ote` vrací konec posledního OTE intervalu. Rolling replan při `NULL` přeskočí; denní plán použije krátký (1 h) fallback v Pythonu. Sloty v solveru jsou bez predikovaných cen v rámci tohoto horizontu. - **Terminal SoC shadow price:** v objective je člen `−(avg_buy_prvních_24h × planner_terminal_soc_value_factor / 1000) × soc[T−1]` (Kč), kde faktor je **`ems.asset_battery.planner_terminal_soc_value_factor`** přes **`ems.fn_planning_site_context`** (default v DB **0.9**); viz sekci *Tuning pro malé baterie* níže. Účel: konec horizontu nemusí končit zbytečně vyprázdněnou baterií (receding horizon). - **Masky `allow_charge` / `allow_discharge_export` (anti-mikrocyklování):** generuje `ems.fn_load_planning_slots_full`. Důležité: pokud rolling replan startuje s baterií na 100 %, `allow_charge` se nesmí stát globálně `false` pro celý horizont – jinak solver nemá motivaci baterii před PV špičkou „uvolnit“ (headroom), protože ji pak nesmí z PV znovu nabít. Aktuálně se v tomto případě `allow_charge` ponechá povolené alespoň pro sloty s `pv_surplus_w > 0`. -- **Denní safety charge (měkké LP, ne maska):** `fn_load_planning_slots_full` (**V077+**) vrací navíc odhad nočního baseload Wh (20:00–06:00 Europe/Prague), buffer % z `asset_battery.planner_night_baseload_buffer_percent`, lookahead `future_*_czk_kwh`, volitelný `safety_soc_target_wh` (6–19) a flag `is_daytime_pv_surplus_slot`. `planning_engine.solve_dispatch()` přidá proměnné deficit vůči cíli a penalizaci `max(future_buy, future_sell) − degradace` (clamp), aby šlo prodat ve velmi drahém sell okně i přes deficit. Tvrdé `allow_charge` se kvůli tomu nemění. -- **Rolling charge commitment:** při `run_rolling_replan` se z aktivního plánu načtou sloty, kde dříve platilo `battery_setpoint_w > 500`, `pv_a+pv_b > load_baseline`, `grid_setpoint_w ≤ 0`; měkká penalizace proti snížení `bc[t]` oproti předchozímu plánu (`planner_charge_commitment_penalty_czk_kwh` na `asset_battery`). Implementace: `_load_previous_plan_charge_commitment_prev_w`, volitelný argument `charge_commitment_prev_w` u `solve_dispatch()`. +- **Denní safety charge (měkké LP, ne maska):** `fn_load_planning_slots_full` (**V077+**) vrací navíc odhad nočního baseload Wh (20:00–06:00 Europe/Prague), buffer % z `asset_battery.planner_night_baseload_buffer_percent`, lookahead `future_*_czk_kwh`, volitelný `safety_soc_target_wh` (6–19) a flag `is_daytime_pv_surplus_slot`.\n+\n+ V solveru (`planning_engine.solve_dispatch()`):\n+ - `safety_soc_target_wh` se používá primárně jako **ochrana exportu z baterie**: v běžných slotech (mimo high‑sell špičky) se při aktivním exportu vynutí `soc[t] ≥ max(arb_base_wh, safety_soc_target_wh)`.\n+ - safety deficit penalizace v objective běží jen v `is_daytime_pv_surplus_slot` (a ne v high‑sell špičce), aby solver neměl motivaci dělat obecné „nabij co nejdřív“ chování.\n+ Tvrdé `allow_charge` se kvůli tomu nemění. +- **Rolling charge commitment:** při `run_rolling_replan` se z aktivního plánu načtou sloty, kde dříve platilo `battery_setpoint_w > 500`, `pv_a+pv_b > load_baseline`, `grid_setpoint_w ≤ 0` a současně **není výrazný export** (`grid_setpoint_w ≥ −500`). To je záměr: commitment má kotvit „nabíjení z PV přebytku“, ne „charge while exporting“. Měkká penalizace proti snížení `bc[t]` oproti předchozímu plánu je řízená `planner_charge_commitment_penalty_czk_kwh` na `asset_battery`. Implementace: `_load_previous_plan_charge_commitment_prev_w`, volitelný argument `charge_commitment_prev_w` u `solve_dispatch()`. - **Debug snapshot:** každý běh ukládá JSON do `ems.planning_run.solver_params` (sekce `version`, `inputs`, `masks`, `soc_bounds`, `objective_terms`, `chosen_slots`) přes `fn_planning_run_commit` (`p_run_meta->'solver_params'`). Read-model: **`select ems.fn_planning_run_debug();`** (`R__087_fn_planning_run_debug.sql`). - **Runtime guard v exportu setpointů (legacy):** - při `AUTO` + `is_predicted_price=true` se na exportní vrstvě vynutí PASSIVE/no-export chování (u nových plánů by `is_predicted_price` v horizontu nemělo nastat). - **Ekonomika baterie:** - `min_soc_percent` = nejnižší SoC v LP a runtime clamp telemetrie; u **více paralelních stringů** držet **nad** holým BMS minimem (typicky **11–12 %**; migrace **V029** + komentář v DB, u `home-01` cílený UPDATE z 10 %), - `reserve_soc_percent` = ekonomická („arbitrážní“) podlaha – pod ní MILP s `w_arb` omezuje vybíjení podle začátku slotu a FVE lookahead (`arb_floor_series`; typicky 20 %), - - **Export ze site:** binárka `z_export[t]` – pokud `grid_export ≥ 1` W, musí být **koncové** `soc[t] ≥ arb_base_wh` (fixní z DB, **ne** dynamicky snížená `arb_floor_series`), + - **Export ze site:** binárka `z_export[t]` – pokud `grid_export ≥ 1` W, musí být **koncové** `soc[t] ≥ export_soc_floor_wh`, kde:\n+ - při hluboké relaxaci (`soc_panel_min` pod `min_soc`) je `export_soc_floor_wh = soc_panel_min[t]`,\n+ - jinak je `export_soc_floor_wh = arb_base_wh`, a v běžných slotech se safety targetem navíc `max(arb_base_wh, safety_soc_target_wh)` (mimo high‑sell špičky). `arb_floor_series` se pro `z_export` nepoužívá. - `degradation_cost_czk_kwh` (např. 0.15) / penalizace cyklu v objective symetrická (`0.5*(charge+discharge)`). - **PV-aware nejistota:** - objective používá `pv_scarcity_factor` (0.65..1.0), odvozený z forecastu slunce,