diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index 880875f..92ba8c6 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-05-29-evening-peak-only-export-v41" +PLANNER_BUILD_TAG = "2026-05-29-evening-push-budget-rank-v42" # Po t_detach v prep: necpát PV do bat (měkké; tvrdý hold přes soc_target z rampy). NEG_SELL_POST_DETACH_BCPV_DISCOURAGE_CZK_KWH = 250.0 # Večer před neg dnem: výboj do sítě (měkký shortfall na ge_bat). @@ -1642,22 +1642,26 @@ def _slot_evening_push_profitable( return float(slot.sell_price) > float(charge_acquisition_czk_kwh) + float(min_spread) -def _evening_push_peak_candidates(slots: list[PlanningSlot]) -> list[int]: - """ - Kandidáti tvrdého večerního push: sloty na **max sell** v nočním úseku - (ne široké pásmo peak−degrad — ten rozplizňoval export do levnějších slotů). - """ - candidates: list[int] = [] - for seg in _night_export_window_segments(slots): - if not seg: +def _evening_push_segment_candidates( + slots: list[PlanningSlot], + seg: list[int], + *, + charge_acquisition_czk_kwh: float, + min_spread: float, +) -> list[int]: + """Profitable sloty v nočním úseku — výběr pořadí a strop dělá rozpočet Wh (sell desc).""" + if not seg: + return [] + out: list[int] = [] + for t in seg: + if not _slot_evening_push_profitable( + slots[t], + charge_acquisition_czk_kwh=charge_acquisition_czk_kwh, + min_spread=min_spread, + ): continue - seg_peak = max(float(slots[t].sell_price) for t in seg) - if seg_peak <= 0.0: - continue - for t in seg: - if float(slots[t].sell_price) >= seg_peak - 1e-6: - candidates.append(t) - return candidates + out.append(t) + return out def _evening_battery_export_push_indices( @@ -1673,24 +1677,13 @@ def _evening_battery_export_push_indices( evening_start_hour: int = 17, ) -> list[int]: """ - Noční push: plný ge_bat v tolika nejdražších peak slotech (shodná max sell v úseku), - kolik unese Wh rozpočet. Řazení sell desc; přidávat sloty dokud kumulované Wh ≤ push_budget. + Noční push: plný ge_bat v tolika nejdražších slotách (sell desc v rámci úseku), + kolik unese Wh rozpočet — ne jen jeden slot s exact max sell (v41). per_slot_discharge_wh: volající předá min(BMS, export cap) × účinnost × 0,25 h. """ _ = evening_start_hour # kompatibilita volání if per_slot_discharge_wh <= 0.0: return [] - candidates = [ - t - for t in _evening_push_peak_candidates(slots) - if _slot_evening_push_profitable( - slots[t], - charge_acquisition_czk_kwh=charge_acquisition_czk_kwh, - min_spread=degrad_czk_kwh, - ) - ] - if not candidates: - return [] push_budget_wh = _evening_push_discharge_budget_wh( current_soc_wh=current_soc_wh, min_soc_wh=min_soc_wh, @@ -1699,18 +1692,27 @@ def _evening_battery_export_push_indices( ) if push_budget_wh < per_slot_discharge_wh * 0.5: return [] - ranked = sorted( - candidates, - key=lambda i: (float(slots[i].sell_price), -i), - reverse=True, - ) out: list[int] = [] - cum_wh = 0.0 - for t in ranked: - if cum_wh + per_slot_discharge_wh > push_budget_wh + 1e-6: - break - out.append(t) - cum_wh += per_slot_discharge_wh + remaining_wh = float(push_budget_wh) + for seg in _night_export_window_segments(slots): + candidates = _evening_push_segment_candidates( + slots, + seg, + charge_acquisition_czk_kwh=charge_acquisition_czk_kwh, + min_spread=degrad_czk_kwh, + ) + if not candidates: + continue + ranked = sorted( + candidates, + key=lambda i: (float(slots[i].sell_price), -i), + reverse=True, + ) + for t in ranked: + if remaining_wh + 1e-6 < per_slot_discharge_wh: + break + out.append(t) + remaining_wh -= per_slot_discharge_wh return sorted(out) diff --git a/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index c3bb916..77b2bd1 100644 --- a/backend/tests/test_planning_dispatch_milp.py +++ b/backend/tests/test_planning_dispatch_milp.py @@ -243,7 +243,8 @@ class EveningPushBudgetTests(unittest.TestCase): per_slot_discharge_wh=per_slot, discharge_slot_buffer=1.5, ) - self.assertEqual(push, [2], "push jen slot(y) s max sell v nočním úseku") + self.assertIn(2, push, "00:00 max sell musí být v push") + self.assertEqual(max(float(slots[t].sell_price) for t in push), 3.586) def test_evening_push_budget_matches_r063_formula(self) -> None: bat = _battery(uc_wh=64_000.0, min_pct=10.0, max_pct=95.0) @@ -2621,7 +2622,7 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase): self.assertEqual(r.export_mode, "BATTERY_SELL") def test_evening_no_spread_export_below_segment_peak_home01(self) -> None: - """home-01 večer: plný export jen v max-sell slotu, ne rozpliznutí do levnějších sousedů.""" + """home-01 večer: plný export v top push slotech dle rozpočtu Wh, ne v levnějších mimo push.""" prague = ZoneInfo("Europe/Prague") sells = [3.834, 3.518, 3.204, 3.204, 3.136, 3.020] base = datetime(2026, 5, 29, 20, 15, tzinfo=prague) @@ -2662,14 +2663,58 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase): ) self.assertEqual(snap["planner_build_tag"], PLANNER_BUILD_TAG) push_iso = set(snap["inputs"].get("evening_push_ts") or []) + self.assertGreaterEqual(len(push_iso), 3, "rozpočet Wh → víc než jeden push slot") self.assertIn(slots[0].interval_start.isoformat(), push_iso) self.assertGreaterEqual(abs(results[0].grid_setpoint_w), 12_500) - for i in range(1, 6): - self.assertGreaterEqual( - results[i].grid_setpoint_w, - -500, - msg=f"slot {i} sell={sells[i]} must not export below segment peak", + for i, r in enumerate(results): + iso = slots[i].interval_start.isoformat() + if iso in push_iso: + self.assertEqual(r.export_mode, "BATTERY_SELL") + self.assertLessEqual(r.grid_setpoint_w, -12_500) + else: + self.assertGreaterEqual( + r.grid_setpoint_w, + -500, + msg=f"slot {i} sell={sells[i]} mimo push nesmí exportovat", + ) + + def test_evening_push_respects_wh_budget_not_all_profitable_slots(self) -> None: + """Při malém SoC jen top-N drahých slotů; zbytek noci ge_bat=0.""" + prague = ZoneInfo("Europe/Prague") + sells = [4.0 - 0.05 * i for i in range(10)] + base = datetime(2026, 5, 25, 18, 0, tzinfo=prague) + slots = [ + PlanningSlot( + interval_start=base + timedelta(minutes=15 * i), + buy_price=2.0, + sell_price=sells[i], + pv_a_forecast_w=0, + pv_b_forecast_w=0, + load_baseline_w=1800, + ev1_connected=False, + ev2_connected=False, + allow_discharge_export=True, + charge_acquisition_buy_czk_kwh=0.5, ) + for i in range(10) + ] + battery = _battery(uc_wh=64_000.0, terminal_soc_value_factor=0.0) + battery.max_discharge_power_w = 18_000 + per_slot = min(18_000, 13_500) * 0.95 * 0.25 + soc_limited = battery.min_soc_wh + 3.2 * per_slot + push = _evening_battery_export_push_indices( + slots, + charge_acquisition_czk_kwh=0.5, + degrad_czk_kwh=0.15, + current_soc_wh=soc_limited, + min_soc_wh=battery.min_soc_wh, + soc_max_wh=battery.soc_max_wh, + per_slot_discharge_wh=per_slot, + discharge_slot_buffer=1.5, + ) + self.assertGreaterEqual(len(push), 3) + self.assertLessEqual(len(push), 4) + self.assertEqual(push, [0, 1, 2, 3][: len(push)]) def test_no_pv_export_at_low_sell_when_evening_peak_much_higher(self) -> None: """Odpolední sell ~1,4 a večer ~5,5 — PV do baterie, ne FVE→síť za haléř.""" diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index 7b1e7ee..2ba710b 100644 --- a/docs/04-modules/planning.md +++ b/docs/04-modules/planning.md @@ -97,14 +97,14 @@ flowchart TD - v **celém nočním okně** pro **všechny** sloty s `allow_discharge_export` **mimo** `evening_push_ts` (výjimky: pre-neg / neg-evening větve); - **nezakazuje** přebytek FVE do sítě (`ge_pv`). -3. **v41 — plný výkon jen ve slotech s max sell v nočním úseku** (`evening_push_ts`): - - kandidáti: profitable ∩ noční okno ∩ **`sell = max` v nočním úseku** (ne široké pásmo peak−degrad); - - push = nejdražší kandidáti **`sell` desc**, dokud `kumulované_Wh ≤ push_budget`; `per_slot` ≈ min(BMS, export cap) × účinnost × 0,25 h; +3. **v42 — plný výkon v top push slotech dle Wh rozpočtu** (`evening_push_ts`): + - kandidáti: profitable ∩ noční okno (**všechny** sloty s `sell > acq+spread`, ne jen exact max sell — oprava v41); + - push = nejdražší kandidáti **`sell` desc**, dokud `kumulované_Wh ≤ push_budget` (globálně přes noční úseky); typicky **~11–14 slotů** při plné baterii home-01; - **`peak_export_shortfall`** se v nočním okně neaplikuje (jinak LP rozplizňoval export do levnějších sousedních slotů); - **v28 push fyzika:** cap `ge_bat ≈ min(export_cap, max_discharge − load)`; - - **výsledek:** plný export (~13,5 kW u home-01) v max-sell slotu; **sousední levnější sloty neprodávají** (`export_mode=NONE`). + - **výsledek:** plný export (~13,5 kW u home-01) v nejdražších push slotech; **levnější sloty mimo push neprodávají** (`export_mode=NONE`). -**Není to** „prodávat v každém lehce ziskovém večerním slotu“ — je to „prodávat **plným výkonem** jen ve **max-sell** nočních slotech, kolik unese baterie“. +**Není to** „prodávat v každém lehce ziskovém večerním slotu“ — je to „prodávat **plným výkonem** jen v **nejdražších** nočních slotech, kolik unese baterie (Wh rozpočet)“. #### Co v26 opravilo oproti starému chování @@ -114,7 +114,7 @@ flowchart TD | Měkká `peak_export_shortfall` → často ~50 % výkonu v mnoha slotech | Na `evening_push` slotech tvrdý push na cap; shortfall na push vypnutý | | `grid_setpoint = gi − ge` → Deye vidí ~0 W při velkém `ge_bat` | `_dispatch_grid_setpoint_w` z reálného exportu | -**Funkce:** `_evening_push_peak_candidates`, `_evening_battery_export_push_indices`, `_evening_early_export_penalty_indices`, `_rolling_evening_push_override`, `_evening_push_discharge_budget_wh`, `_evening_push_battery_export_w`, `_dispatch_grid_setpoint_w` v `planning_engine.py`. Tag: **`2026-05-29-evening-peak-only-export-v41`** (dříve v38). +**Funkce:** `_evening_push_segment_candidates`, `_evening_battery_export_push_indices`, `_evening_early_export_penalty_indices`, `_rolling_evening_push_override`, `_evening_push_discharge_budget_wh`, `_evening_push_battery_export_w`, `_dispatch_grid_setpoint_w` v `planning_engine.py`. Tag: **`2026-05-29-evening-push-budget-rank-v42`** (v41: exact max sell; dříve v38). ### Arbitráž baterie — účtování mezi sloty (povinné čtení) diff --git a/docs/planning-changelog.md b/docs/planning-changelog.md index edd8e52..45f6a60 100644 --- a/docs/planning-changelog.md +++ b/docs/planning-changelog.md @@ -5,6 +5,21 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen --- +## 2026-05-29 — Večerní push: rozpočet Wh × sell desc (v42) + +**Problém:** v41 bral push kandidáty jen jako sloty s **`sell = max`** v nočním úseku → při ~48 kWh rozpočtu často **jediný** push slot (~13,5 kW), zbytek energie „visel“ v baterii; levnější profitable sloty byly zákázané (`evening_early`), ale dražší sousední sloty pod maximem se nevyužily. + +**Změna (v42):** +- Kandidáti = **všechny profitable** sloty v nočním okně (`acq+spread`, ne fixní buy). +- Push = **sell desc** greedy fill, dokud `kumulované_Wh ≤ push_budget` (globální rozpočet přes noční úseky). +- `evening_early` (`ge_bat=0` mimo push) a vypnutý `peak_export_shortfall` v noci **beze změny**. + +**Soubory:** `backend/services/planning_engine.py` (`_evening_push_segment_candidates`, `_evening_battery_export_push_indices`), `backend/tests/test_planning_dispatch_milp.py` (`test_evening_no_spread_export_below_segment_peak_home01`, `test_evening_push_respects_wh_budget_not_all_profitable_slots`). Tag **`2026-05-29-evening-push-budget-rank-v42`**. + +**Ověření:** `pytest … -k evening_no_spread`; MCP: `solver_params->'inputs'->'evening_push_ts'` — délka ≈ `floor(budget_wh / per_slot_wh)`; každý push slot → `|grid_setpoint_w|` ≈ 12,5–13,5 kW; sloty mimo push → bez exportu. + +--- + ## 2026-05-29 — Večerní export jen ve špičkových slotech (v41) **Problém:** home-01 večer ~7,5 kW export v mnoha levnějších slotech (~3,2 Kč) místo plného **13,5 kW** v max-sell slotu. Tři důvody: (1) `evening_push` kandidáti = široké pásmo **peak−degrad** (0,15 Kč); (2) měkká penalizace **`peak_export_shortfall`** tlačila `ge_bat` i v levnějších nočních slotech; (3) push se neaktivoval, když horizont měl **konstantní buy** → mylně „fixní tarif“ a `sell < buy` (přitom večerní export dává smysl vůči `acq+spread`).