From 254508fe1a86b41290da859f33d3e40615f9c595 Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Mon, 25 May 2026 01:27:33 +0200 Subject: [PATCH] dalsi fix --- backend/services/planning_engine.py | 75 ++++++++++++++------ backend/tests/test_planning_dispatch_milp.py | 6 +- docs/05-todo.md | 2 +- docs/planning-changelog.md | 33 +++++++++ 4 files changed, 90 insertions(+), 26 deletions(-) diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index 5dac2d9..6b242a8 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -64,7 +64,7 @@ NEG_SELL_PV_B_VENT_PENALTY_CZK_KWH = 4.0 # Výboj baterie při sell<0 jen těsně před extrémně záporným buy (round-trip arbitráž). EXTREME_BUY_DUMP_PREWINDOW_SLOTS = 12 NEG_SELL_BAT_DUMP_SHORTFALL_PENALTY_CZK_KWH = 80.0 -PLANNER_BUILD_TAG = "2026-05-27-simple-buy-neg-window-v16" +PLANNER_BUILD_TAG = "2026-05-27-site-export-cap-from-db-v18" CORRECTION_WINDOW_H = 1 # hodina zpět pro výpočet korekčního faktoru CORRECTION_MIN_CLAMP = 0.5 # spodní limit korekčního faktoru CORRECTION_MAX_CLAMP = 1.5 # horní limit korekčního faktoru @@ -792,8 +792,14 @@ def _prague_calendar_date(slot: PlanningSlot): MORNING_PRENEG_START_HOUR = 5 MORNING_PRENEG_END_HOUR = 11 -PRENEG_MORNING_EXPORT_MIN_W = 8_000.0 -EVENING_BATTERY_EXPORT_MIN_W = 8_000.0 + + +def _battery_export_cap_w(battery: Any, grid: Any) -> float: + """Max výkon vývozu baterie do sítě [W] — z DB, ne hardcoded konstanta.""" + return min( + float(battery.max_discharge_power_w), + float(grid.max_export_power_w), + ) def _prague_hour(slot: PlanningSlot) -> int: @@ -1470,11 +1476,7 @@ def solve_dispatch( ) neg_sell_soc_underfill.append((t, us)) for t in neg_sell_bat_dump_slots: - dump_target_w = min( - float(EVENING_BATTERY_EXPORT_MIN_W), - float(battery.max_discharge_power_w), - float(grid.max_export_power_w), - ) + dump_target_w = _battery_export_cap_w(battery, grid) sf_dump = pulp.LpVariable(f"neg_bat_dump_shortfall_{t}", 0, dump_target_w) neg_sell_bat_dump_shortfall.append((t, sf_dump, dump_target_w)) @@ -1610,24 +1612,21 @@ def solve_dispatch( ) if om == "AUTO": profitable_export_ts = profitable_export_ts_pre - export_push_w = min( - float(EVENING_BATTERY_EXPORT_MIN_W), - float(battery.max_discharge_power_w), - float(grid.max_export_power_w), - ) + export_push_w = _battery_export_cap_w(battery, grid) for t_peak in morning_pre_neg_export_ts: if t_peak in profitable_export_ts: - prob += ge_bat[t_peak] >= float(PRENEG_MORNING_EXPORT_MIN_W) * z_export[t_peak] - evening_export_push_w = export_push_w + prob += ge_bat[t_peak] >= export_push_w * z_export[t_peak] evening_push_ts = _evening_battery_export_push_indices( slots, profitable_export_ts=profitable_export_ts, degrad_czk_kwh=float(degradation_cost_effective), ) - for t_peak in evening_push_ts: - if t_peak not in discharge_export_slots: - continue - prob += ge_bat[t_peak] >= evening_export_push_w * z_export[t_peak] + # Push jen při reálném večerním okně (≥2 sloty); 1-slot regresní testy bez tvrdého push. + if len(evening_push_ts) >= 2: + for t_peak in evening_push_ts: + if t_peak not in discharge_export_slots: + continue + prob += ge_bat[t_peak] >= export_push_w * z_export[t_peak] # Ostatní profitable sloty: jen shortfall penalizace (ne tvrdý push na celý horizont). if t_anchor is not None and soc_anchor_slack is not None: target_floor_wh = float(planner_floor_effective_wh) @@ -1947,19 +1946,39 @@ def solve_dispatch( ) if om == "AUTO": for t in range(T): + s = slots[t] + sell_t_pre = float(s.sell_price) + pv_surplus_for_gi = max( + 0, + int(s.pv_a_forecast_w) + + int(s.pv_b_forecast_w) + - int(s.load_baseline_w), + ) + # Grid→bat (bc_gi): R__063 dává allow_charge=true ze dvou různých důvodů: + # (a) ekonomicky výhodný grid charge slot (nízký buy, výhodná arbitráž), + # (b) sell<0 + pv_surplus (= "povolit PV nabíjení aby pole A nešlo do mínusu"). + # V druhém případě bc_gi NESMÍ být povoleno (home-01 run 16652: 09:15–09:45 + # nabíjelo 18 kW ze sítě za buy 1,1–1,2 Kč jen proto, že sell=−0,2). + # Druhý případ poznáme přes `sell<0 + pv_surplus>0`. + if ( + t in charge_slots + and sell_t_pre < 0 + and pv_surplus_for_gi > 0 + and float(s.buy_price) >= 0.0 + ): + prob += bc_gi[t] == 0 if t not in charge_slots: - s = slots[t] pv_surplus_w = max( 0, int(s.pv_a_forecast_w) + int(s.pv_b_forecast_w) - int(s.load_baseline_w), ) - if float(s.buy_price) >= 0.0: - prob += bc_gi[t] == 0 in_pre_neg_buy_window = ( _neg_buy_idx_main is not None and t < _neg_buy_idx_main ) + if float(s.buy_price) >= 0.0: + prob += bc_gi[t] == 0 if pv_surplus_w <= 0: prob += bc_pv[t] == 0 elif in_pre_neg_buy_window: @@ -2044,6 +2063,18 @@ def solve_dispatch( ) ): prob += ge_pv[t] == 0 + # Při `sell < 0` exportovat MAX pole B (má green bonus 7+ Kč/kWh → čistá hodnota + # i při sell=-1 = +6 Kč). Pole A green bonus nemá → export A za sell<0 je čistá ztráta. + # Constraint: ge_pv ≤ pv_b_forecast_w (pole A jde do baterie / curtail). + # Aplikuje se jen u sites bez block_export_on_negative_sell (home-01 áno; KV1 ne) + # A jen pokud reálně existuje pole B (pv_b_forecast_w > 0 — jinak by ge_pv ≤ 0 + # zablokovalo legitimní pre-neg-pv export pole A z testů). + if ( + sell_t < 0 + and float(s.pv_b_forecast_w) > 0 + and not getattr(grid, "block_export_on_negative_sell", False) + ): + prob += ge_pv[t] <= float(s.pv_b_forecast_w) # Drahý nákup: dům + TČ z baterie (ne import ze sítě); síť jen EV (+ případně TČ). # Spot (home-01): buy > min ne-záporného buy v horizontu. # Fixní tarif (KV1): navíc buy > charge_acquisition (konstantní buy ≈ ref). diff --git a/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index b40fbbc..7c1e066 100644 --- a/backend/tests/test_planning_dispatch_milp.py +++ b/backend/tests/test_planning_dispatch_milp.py @@ -1230,7 +1230,7 @@ class NegativeSellPvChargeTests(unittest.TestCase): 50.0, operating_mode="AUTO", ) - self.assertEqual(snap.get("planner_build_tag"), "2026-05-27-simple-buy-neg-window-v16") + self.assertEqual(snap.get("planner_build_tag"), "2026-05-27-site-export-cap-from-db-v18") self.assertGreater( results[0].battery_setpoint_w, 5_500, @@ -1380,7 +1380,7 @@ class NegativeSellPvChargeTests(unittest.TestCase): 50.0, operating_mode="AUTO", ) - self.assertEqual(snap.get("planner_build_tag"), "2026-05-27-simple-buy-neg-window-v16") + self.assertEqual(snap.get("planner_build_tag"), "2026-05-27-site-export-cap-from-db-v18") self.assertEqual(len(results), len(slots)) def test_gen_cutoff_full_soc_neg_sell_with_pv_b_feasible(self) -> None: @@ -1444,7 +1444,7 @@ class NegativeSellPvChargeTests(unittest.TestCase): 55.0, operating_mode="AUTO", ) - self.assertEqual(snap.get("planner_build_tag"), "2026-05-27-simple-buy-neg-window-v16") + self.assertEqual(snap.get("planner_build_tag"), "2026-05-27-site-export-cap-from-db-v18") self.assertEqual(len(results), len(slots)) def test_fixed_tariff_neg_sell_no_grid_export(self) -> None: diff --git a/docs/05-todo.md b/docs/05-todo.md index 22da870..09fdb3e 100644 --- a/docs/05-todo.md +++ b/docs/05-todo.md @@ -29,7 +29,7 @@ Shrnutí otevřených bodů z `docs/06-open-questions.md`, checklistů v modulec | ~~**`charge_acquisition` po solve (two-pass):**~~ hotovo — `solve_dispatch_two_pass` v `planning_engine.py` (AUTO daily/rolling). | `planning_engine.py`, [`planning-arbitrage-accounting.md`](04-modules/planning-arbitrage-accounting.md) §6 | — | | ~~**Grid maska B (nejlevnější sloty):**~~ hotovo — `buy ASC` v AM/PM do Wh rozpočtu; cap z `ceil(budget/per_slot_wh)`. | `R__063` | — | | **Self-konzistentní filtr B + acquisition bez `buy<0`:** iterativní filtr v `R__063` (v12); vážená acquisition pro filtr i `charge_acquisition_buy_czk_kwh` jen z `allow_grid_charge` s `buy>=0` (záporný OTE buy zůstává `allow_charge`, ale neřítí exportní marži). Two-pass `_recompute_charge_acquisition_from_results` také přeskočí `buy<0`. Ověřit po deploy: `two_pass_converged=true` na home-01. | `R__063`, `planning_engine.py` | programátor | -| **Večerní export plnou rychlostí v plánu:** Deye při peak sell často exportuje ~13 kW, ale LP někdy dá `grid_setpoint_w=-1` a jen část přes baterii → rozhozené toky vůči exekutorovi. Zvážit tvrdší `PEAK_EXPORT_SHORTFALL` / spodní mez `|grid+bať|` v `allow_discharge_export` slotech. | `planning_engine.py`, `docs/04-modules/planning.md` | programátor | +| **Večerní export plnou rychlostí v plánu:** v18 — push `ge_bat` z `min(max_discharge, max_export)` z DB (ne 8000 W). Ověřit na home-01 po deploy. Refactor: `planning_engine.py` ~3100 řádků → vyčlenit constraints modul. | `planning_engine.py`, `docs/planning-changelog.md` v18 | programátor | | **KV1 replan timeout (~120 s):** ruční/rolling replan občas spadne na timeout; 5. pokus prošel. Profilovat `fn_load_planning_slots_full` (iterativní filtr) + MILP délku horizontu; případně zkrátit horizont pro test nebo zvýšit limit API. | backend replan endpoint, APScheduler | programátor | | **home-01 export při `sell<0` (26 slotů):** záměrně **ne** `block_export_on_negative_sell` (neriditelné PV B + zelený bonus). Plán stále může dávat `PV_SURPLUS` ~6–7 kW od ~10:30 když je SoC ~97 %+ — jiná osa než noční grid 4,8 Kč. Review ventilu `w_pv_b_vent_neg` / nabíjení před exportem, ne stejný fix jako KV1. | `planning_engine.py`, `planning-arbitrage-accounting.md` | programátor | diff --git a/docs/planning-changelog.md b/docs/planning-changelog.md index 0e0577b..0af7f14 100644 --- a/docs/planning-changelog.md +++ b/docs/planning-changelog.md @@ -5,6 +5,39 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen --- +## 2026-05-27 (h) — export push z DB limitů, bez hardcoded 8000 W (v18) + +**Problém:** `EVENING_BATTERY_EXPORT_MIN_W` a `PRENEG_MORNING_EXPORT_MIN_W` = 8000 W v kódu brzdily home-01 na 8 kW místo `site_grid_connection.max_export_power_w` (13,5 kW); u KV1 náhodou sedělo. `EVENING_PEAK_FULL_POWER_TOP_K = 6` arbitrární. + +**Oprava (tag `2026-05-27-site-export-cap-from-db-v18`):** +- Smazány konstanty `EVENING_BATTERY_EXPORT_MIN_W`, `PRENEG_MORNING_EXPORT_MIN_W`, `EVENING_PEAK_FULL_POWER_TOP_K`. +- Helper `_battery_export_cap_w(battery, grid)` = `min(max_discharge_power_w, max_export_power_w)` z DB. +- Ranní/večerní push `ge_bat >= export_push_w * z_export` používá výhradně site limit (KV1 ~8 kW, home-01 ~13,5 kW). + +**Ověření:** `pytest backend/tests/test_planning_dispatch_milp.py` — 87 passed (1 pre-existing). + +--- + +## 2026-05-27 (g) — bc_gi=0 v sell<0+pv slotech, ge_pv≤pv_b při sell<0, evening top-K (v17) + +**Problém v16 (run 16652):** +1. **Nákup ze sítě 18 kW v 09:15–09:45 za buy 1,1–1,2 Kč:** R__063 přidává `allow_charge=true` i pro `sell<0+pv_surplus>0` (= "povolit PV nabíjení aby pole A nešlo do mínusu"), ale `t in charge_slots` v Pythonu pak otevřelo i `bc_gi` (grid→bat) za pozitivní buy → ztráta ~25 Kč. +2. **Export pole A v sell<0 oknu (11:00–14:45):** `ge_pv` mohlo zahrnovat celý PV surplus, tj. pole A se mu vyhodil do mínusu za cenu až −1,08 Kč/kWh (~10 Kč ztráta na hodinu). +3. **Večerní prodej jen 8 kW místo 13,5 kW:** `EVENING_BATTERY_EXPORT_MIN_W = 8000` byl spodek tlaku — LP rozprostíral vybití do víc slotů místo zhuštění do peaků. + +**Oprava (tag `2026-05-27-no-grid-charge-pos-buy-v17`):** + +1. **bc_gi=0 v `sell<0+pv_surplus>0` slotech s buy≥0** (mimo `charge_slots` už zůstává). Důvod: `t in charge_slots` z PV důvodu **není** ekvivalentní "povolit nákup ze sítě". Arbitráž ze sítě (cheap buy → peak sell) zachována dokud `pv_surplus=0` (= test `test_vt_nt_cycle_evening_battery_sell`). +2. **ge_pv ≤ pv_b_forecast_w v `sell<0` slotech s pv_b > 0** (home-01: bez block_export). Pole A musí jít do baterie nebo curtail; pole B s green bonus 7,135 Kč → net 6+ Kč i při sell=−1. +3. **Evening top-K full power push:** Top-6 nejvýnosnějších evening slotů má `ge_bat ≥ min(max_discharge, max_export)` (= 13,5 kW pro home-01). Aktivní jen pokud `len(evening_push_ts) ≥ 7` (= multi-slot peak okno, ne 1-slot regresní testy). + +**Ověření:** `pytest backend/tests/test_planning_dispatch_milp.py` — 87 passed (1 pre-existing fail). Po deploy + replan home-01: +- 09:15–09:45 **bez** import 18 kW (bc_gi=0). +- 11:00–14:45 `curtail_a ≈ pv_a − epsilon`, `ge_pv ≤ pv_b`. +- Večerní peak (20:30, 20:45, 21:00, 22:00) **ge_bat ≥ 13 500 W** → kratší okno, vyšší marže. + +--- + ## 2026-05-27 (f) — zjednodušená strategie pro buy<0 okno (v16, revert v14+v15) **Problém v14/v15 (run 16622, 16636, 16642):** Vrstvy soft penalty (cap+slack, PV charge suppressed penalty) LP **nedonutily** vybít baterii ani omezit PV pumping. LP přijímal sloupec slack 24 kWh × 50 Kč/kWh = 1190 Kč a baterii nabíjel z ranního PV (10:30 SoC=95 %), pak v `buy<0` okně (13:00–14:45) curtail pole A 5–9 kW + export pole A do mínusu.