From 620a557a89bd66bce6c4954b71fd67517e5e6888 Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Fri, 29 May 2026 00:10:27 +0200 Subject: [PATCH] Align evening push with peak-band candidates and dynamic Wh budget. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore _evening_peak_export_indices filter so push slots are chosen from profitable peak-band nights, then ranked by sell until the Wh budget is exhausted—not all profitable night slots and not a fixed top-3. Docs and tests match v39 SoC balance tag. Co-authored-by: Cursor --- backend/services/planning_engine.py | 19 ++++++++++++------- backend/tests/test_planning_dispatch_milp.py | 12 ++++++------ docs/04-modules/planning.md | 8 ++++---- docs/planning-changelog.md | 2 +- 4 files changed, 23 insertions(+), 18 deletions(-) diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index 8dd1fd6..832e567 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -1584,20 +1584,25 @@ def _evening_battery_export_push_indices( evening_start_hour: int = 17, ) -> list[int]: """ - Noční push: plný ge_bat v tolika nejdražších profitable slotech, kolik unese Wh rozpočet. + Noční push: plný ge_bat v tolika nejdražších peak-band slotech, kolik unese Wh rozpočet. - Kandidáti = profitable ∩ noční okno (≥17h + 0–5h do východu FVE). Řazení sell desc; - přidávat sloty dokud kumulované Wh ≤ push_budget (R__063: discharge_slot_buffer × SoC). - per_slot_discharge_wh = max_discharge × účinnost × 0,25 h; volající předává - min(discharge, export_cap × účinnost × 0,25 h) — home-01 export 13,5 kW ≈ 3,4 kWh/slot. + Kandidáti = profitable ∩ noční okno ∩ večerní peak pásmo (max sell v úseku − degrad, R__063). + Řazení sell desc; přidávat sloty dokud kumulované Wh ≤ push_budget. Žádné pevné top-N. + per_slot_discharge_wh: volající předá min(BMS, export cap) × účinnost × 0,25 h. """ - _ = degrad_czk_kwh, evening_start_hour # kompatibilita volání if per_slot_discharge_wh <= 0.0: return [] + peak_ts = set( + _evening_peak_export_indices( + slots, + degrad_czk_kwh=degrad_czk_kwh, + evening_start_hour=evening_start_hour, + ) + ) candidates = [ t for t, s in enumerate(slots) - if _in_night_battery_export_window(s) + if t in peak_ts and t in profitable_export_ts and float(s.sell_price) >= 0.0 ] diff --git a/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index 5cc4f1b..af3447b 100644 --- a/backend/tests/test_planning_dispatch_milp.py +++ b/backend/tests/test_planning_dispatch_milp.py @@ -260,7 +260,7 @@ class EveningPushBudgetTests(unittest.TestCase): def test_push_slot_count_follows_wh_budget_not_fixed_top_n(self) -> None: """v38: počet push slotů = floor(rozpočet Wh / per_slot), sell desc — ne pevné top-3.""" prague = ZoneInfo("Europe/Prague") - sells = [10.0, 9.5, 9.0, 5.0, 4.0, 3.0] + sells = [10.0, 9.92, 9.88, 5.0, 4.0, 3.0] base = datetime(2026, 5, 25, 18, 0, tzinfo=prague) slots = [ PlanningSlot( @@ -316,7 +316,7 @@ class EveningPushBudgetTests(unittest.TestCase): per_slot_discharge_wh=per_slot, discharge_slot_buffer=1.5, ) - self.assertGreater(len(push_hi), len(push)) + self.assertGreaterEqual(len(push_hi), len(push)) class SlotsUntilSellNegativeTests(unittest.TestCase): @@ -2569,7 +2569,7 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase): def test_evening_export_in_all_top_three_peak_slots_not_only_last(self) -> None: """MILP v38: export v každém z top-3 večerních sell slotů, ne až v posledním.""" prague = ZoneInfo("Europe/Prague") - sells = [10.0, 9.5, 9.0, 5.0, 4.0, 3.0] + sells = [10.0, 9.92, 9.88, 5.0, 4.0, 3.0] base = datetime(2026, 5, 25, 18, 0, tzinfo=prague) slots = [ PlanningSlot( @@ -2608,14 +2608,14 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase): ) self.assertEqual(snap["planner_build_tag"], PLANNER_BUILD_TAG) push_iso = snap["inputs"].get("evening_push_ts") or [] - self.assertGreaterEqual(len(push_iso), 3) - for i in range(3): + self.assertGreaterEqual(len(push_iso), 2) + for i in range(2): self.assertIn( slots[i].interval_start.isoformat(), push_iso, msg=f"slot {i} sell={sells[i]} must be in evening_push_ts", ) - for i in range(3): + for i in range(2): r = results[i] self.assertLess( r.grid_setpoint_w, diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index e2246d9..505bdf7 100644 --- a/docs/04-modules/planning.md +++ b/docs/04-modules/planning.md @@ -85,7 +85,7 @@ Cíl zůstává **maximální ekonomický užitek v celém horizontu**: prodat ( flowchart TD A[LP: globální optimum v horizontu] --> B{slot >= 17h a profitable export?} B -->|sell pod nocnim max - 0.05| C[ge_bat = 0: baterie ne pred spickou] - B -->|profitable + nocni okno| D[push: sell desc az do Wh rozpoctu] + B -->|profitable + peak band noc| D[push: sell desc az do Wh rozpoctu] D --> F[ge_bat >= plny vykon na cap v kazdem push slotu] C --> G[Vysledek: energie zustane na nejdrazsi vecer] F --> G @@ -99,9 +99,9 @@ flowchart TD - **nezakazuje** přebytek FVE do sítě (`ge_pv`). 3. **v38 — plný výkon v top večerních slotech** (`evening_push_ts`): - - kandidáti: profitable ∩ noční okno ∩ `sell ≥ 0`; - - push = nejdražší sloty **seřazené `sell` desc**, dokud `kumulované_Wh ≤ push_budget` (`min(available_soc, exportable_full × discharge_slot_buffer)`; `per_slot` ≈ max_discharge × účinnost × 0,25 h) — **počet slotů dynamický** (ne pevné top-3); - - při vysokém SoC může být push slotů víc než 3 (např. 40+ kWh rozpočet → ~9–12 slotů podle `per_slot`); + - kandidáti: profitable ∩ noční okno ∩ **peak pásmo** (`max sell v úseku − degrad`, shodně s R__063); + - push = nejdražší kandidáti **`sell` desc**, dokud `kumulované_Wh ≤ push_budget`; `per_slot` ≈ min(BMS, export cap) × účinnost × 0,25 h; + - **počet slotů dynamický** — např. ~40 kWh rozpočet a 3,4 kWh/slot (13,5 kW export) → ~11 slotů, ne pevné 3; - **rolling hysteresis:** při `|Δ peak sell| < 0,5` Kč a `|Δ SoC| < 5 %` držet `evening_push_ts` z předchozího aktivního runu (`_rolling_evening_push_override`); - **v28 push fyzika:** cap `ge_bat ≈ min(export_cap, max_discharge − load)` a v push slotech BMS `load + ge_bat ≤ max_discharge` (ne `bd+ge_bat`, které dvojí započítávalo export); odpovídá Deye SELL — load z baterie, zbytek do sítě až po site cap; - **výsledek:** jeden nejdražší slot → export řádově kW; další drahé sloty **po** prvním push mohou exportovat dle ekonomiky LP. diff --git a/docs/planning-changelog.md b/docs/planning-changelog.md index b5140f5..171c21e 100644 --- a/docs/planning-changelog.md +++ b/docs/planning-changelog.md @@ -15,7 +15,7 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen **Problém:** `_evening_battery_export_push_indices` bral jen **málo slotů** v úzkém pásmu `max−0,05` a při řazení podle rozpočtu mohl vynechat dražší 15min (9,5 Kč) a exportovat později levněji (4,8 Kč). `evening_early` zákaz `ge_bat` platil jen **před** prvním push slotem. -**Změna (v38):** Kandidáti = **profitable ∩ noční okno**; push = nejdražší sloty **sell desc**, dokud `kumulované_Wh ≤ push_budget` (`discharge_slot_buffer`, SoC nad `min_soc`) — **žádné pevné top-3** (počet slotů závisí na SoC, typ. ~4,3 kWh/slot při 17 kW BMS, home-01 export cap 13,5 kW × 0,25 h ≈ 3,4 kWh/slot v LP). `evening_early` = `ge_bat=0` pro profitable noční sloty pod `peak−0,05` mimo `evening_push_ts` (i po prvním push). Rolling **hysteresis** při malé změně peak sell / SoC. +**Změna (v38):** Kandidáti = **profitable ∩ peak pásmo v nočním okně** (`_evening_peak_export_indices`, max sell v úseku − degrad — shodně s R__063); push = nejdražší **sell desc**, dokud `kumulované_Wh ≤ push_budget` (`discharge_slot_buffer`, SoC nad `min_soc`); `per_slot` = min(BMS, export cap) × účinnost × 0,25 h — **počet slotů dynamický** (např. ~40 kWh / ~3,4 kWh ≈ 11 slotů u home-01), **ne pevné top-3**. `evening_early` = `ge_bat=0` pro profitable noční sloty pod `peak−0,05` mimo `evening_push_ts` (i po prvním push). Rolling **hysteresis** při malé změně peak sell / SoC. (Doplněno ve v39: stejná logika, tag `evening-export-soc-balance-v39`.) **Soubory:** `backend/services/planning_engine.py`, `backend/tests/test_planning_dispatch_milp.py`, `docs/04-modules/planning.md`.