diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index b23141a..880875f 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-neg-prep-infeasible-relax-v40b" +PLANNER_BUILD_TAG = "2026-05-29-evening-peak-only-export-v41" # 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). @@ -1632,10 +1632,38 @@ def _evening_push_discharge_budget_wh( return min(available_wh, exportable_full_wh * buf) +def _slot_evening_push_profitable( + slot: PlanningSlot, + *, + charge_acquisition_czk_kwh: float, + min_spread: float, +) -> bool: + """Push večerní špičky: spot marže (acq+spread), ne fixní buy z konstantního horizontu.""" + 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: + 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 + + def _evening_battery_export_push_indices( slots: list[PlanningSlot], *, - profitable_export_ts: set[int], + charge_acquisition_czk_kwh: float, degrad_czk_kwh: float, current_soc_wh: float, min_soc_wh: float, @@ -1645,27 +1673,21 @@ def _evening_battery_export_push_indices( evening_start_hour: int = 17, ) -> list[int]: """ - Noční push: plný ge_bat v tolika nejdražších peak-band slotech, kolik unese Wh rozpočet. - - 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. + 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. 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 [] - 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 t in peak_ts - and t in profitable_export_ts - and float(s.sell_price) >= 0.0 + 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 [] @@ -1740,22 +1762,23 @@ def _evening_push_hysteresis_active( def _evening_early_export_penalty_indices( slots: list[PlanningSlot], *, - profitable_export_ts: set[int], discharge_export_slots: set[int], evening_push_ts: set[int], + exempt_ts: set[int] | None = None, ) -> set[int]: - """ge_bat=0 pro profitable noční sloty pod peak−eps mimo evening_push (v38: i po prvním push).""" + """ + ge_bat=0 v nočním okně mimo tvrdý evening_push (a mimo pre-neg / neg-evening větve). + """ + exempt = exempt_ts or set() out: set[int] = set() for t_ev, s_ev in enumerate(slots): if not _in_night_battery_export_window(s_ev): continue - if t_ev not in profitable_export_ts or t_ev not in discharge_export_slots: + if t_ev not in discharge_export_slots: continue - if t_ev in evening_push_ts: + if t_ev in evening_push_ts or t_ev in exempt: continue - peak_sell = _night_peak_sell_czk_kwh(slots, t_ev) - if float(s_ev.sell_price) < peak_sell - EVENING_PEAK_SELL_EPS_CZK_KWH: - out.add(t_ev) + out.add(t_ev) return out @@ -2392,7 +2415,7 @@ def solve_dispatch( computed_evening_push_ts = set( _evening_battery_export_push_indices( slots, - profitable_export_ts=profitable_export_ts_pre, + charge_acquisition_czk_kwh=charge_acquisition_czk_kwh, degrad_czk_kwh=float(degradation_cost_effective), current_soc_wh=float(current_soc_wh), min_soc_wh=float(min_soc_wh), @@ -2406,12 +2429,6 @@ def solve_dispatch( evening_push_hysteresis_retained = True else: evening_push_ts = computed_evening_push_ts - evening_early_export_penalty_ts = _evening_early_export_penalty_indices( - slots, - profitable_export_ts=profitable_export_ts_pre, - discharge_export_slots=discharge_export_slots, - evening_push_ts=evening_push_ts, - ) last_pos_sell_pre_neg_buy = _last_non_negative_sell_before_neg_buy( slots, first_neg_buy_idx ) @@ -2421,6 +2438,19 @@ def solve_dispatch( pre_neg_buy_empty_ts = _pre_neg_buy_empty_discharge_indices( slots, first_neg_buy_idx, last_pos_sell_pre_neg_buy ) + if om == "AUTO": + evening_export_exempt_ts = ( + set(morning_pre_neg_export_ts) + | set(pre_neg_buy_discharge_ts) + | set(pre_neg_buy_empty_ts) + | set(neg_evening_push_ts) + ) + evening_early_export_penalty_ts = _evening_early_export_penalty_indices( + slots, + discharge_export_slots=discharge_export_slots, + evening_push_ts=evening_push_ts, + exempt_ts=evening_export_exempt_ts, + ) pre_neg_buy_soc_ceiling_wh = _pre_neg_buy_soc_ceiling_wh( slots, first_neg_buy_idx=first_neg_buy_idx, @@ -2530,6 +2560,9 @@ def solve_dispatch( continue if t in evening_push_ts: continue + if _in_night_battery_export_window(slots[t]): + # Večerní export jen v tvrdém push; jinak by shortfall rozplizňoval ge_bat. + continue if _battery_export_push_defer_to_pv(slots[t]): continue if not _slot_profitable_battery_export( diff --git a/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index 9ef4665..c3bb916 100644 --- a/backend/tests/test_planning_dispatch_milp.py +++ b/backend/tests/test_planning_dispatch_milp.py @@ -153,10 +153,9 @@ class EveningPushBudgetTests(unittest.TestCase): per_slot = 17_000 * 0.95 * 0.25 bat = _battery(uc_wh=64_000.0, min_pct=10.0, max_pct=95.0) soc_high = 0.92 * bat.soc_max_wh - profitable = set(range(len(slots))) push_hi = _evening_battery_export_push_indices( slots, - profitable_export_ts=profitable, + charge_acquisition_czk_kwh=0.5, degrad_czk_kwh=0.15, current_soc_wh=soc_high, min_soc_wh=bat.min_soc_wh, @@ -164,11 +163,11 @@ class EveningPushBudgetTests(unittest.TestCase): per_slot_discharge_wh=per_slot, discharge_slot_buffer=1.5, ) - self.assertGreaterEqual(len(push_hi), 3) + self.assertGreaterEqual(len(push_hi), 1) soc_low = bat.min_soc_wh + 100.0 push_lo = _evening_battery_export_push_indices( slots, - profitable_export_ts=profitable, + charge_acquisition_czk_kwh=0.5, degrad_czk_kwh=0.15, current_soc_wh=soc_low, min_soc_wh=bat.min_soc_wh, @@ -236,7 +235,7 @@ class EveningPushBudgetTests(unittest.TestCase): per_slot = 18_000 * 0.95 * 0.25 push = _evening_battery_export_push_indices( slots, - profitable_export_ts={0, 1, 2, 3}, + charge_acquisition_czk_kwh=0.5, degrad_czk_kwh=0.15, current_soc_wh=0.9 * bat.soc_max_wh, min_soc_wh=bat.min_soc_wh, @@ -244,8 +243,7 @@ class EveningPushBudgetTests(unittest.TestCase): per_slot_discharge_wh=per_slot, discharge_slot_buffer=1.5, ) - self.assertIn(2, push, "nejvyšší sell 00:00 má být v push (top-3 v nočním úseku)") - self.assertEqual(max(float(slots[t].sell_price) for t in push), 3.586) + self.assertEqual(push, [2], "push jen slot(y) s max sell v nočním úseku") 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) @@ -263,7 +261,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.92, 9.88, 5.0, 4.0, 3.0] + sells = [10.0, 10.0, 10.0, 5.0, 4.0, 3.0] base = datetime(2026, 5, 25, 18, 0, tzinfo=prague) slots = [ PlanningSlot( @@ -282,8 +280,6 @@ class EveningPushBudgetTests(unittest.TestCase): ] bat = _battery(uc_wh=64_000.0, min_pct=10.0, max_pct=95.0) per_slot = 17_000 * 0.95 * 0.25 - profitable = set(range(len(slots))) - # Rozpočet na ~3 plné sloty (ne celá baterie — jinak by šlo až 6 slotů). soc_three_slots = bat.min_soc_wh + 3.2 * per_slot budget = _evening_push_discharge_budget_wh( current_soc_wh=soc_three_slots, @@ -291,13 +287,10 @@ class EveningPushBudgetTests(unittest.TestCase): soc_max_wh=bat.soc_max_wh, discharge_slot_buffer=1.5, ) - expected_n = min( - len(slots), - max(0, int(budget // per_slot)), - ) + expected_n = min(3, max(0, int(budget // per_slot))) push = _evening_battery_export_push_indices( slots, - profitable_export_ts=profitable, + charge_acquisition_czk_kwh=0.5, degrad_czk_kwh=0.15, current_soc_wh=soc_three_slots, min_soc_wh=bat.min_soc_wh, @@ -311,7 +304,7 @@ class EveningPushBudgetTests(unittest.TestCase): # Více SoC → více push slotů (dynamicky, ne strop 3). push_hi = _evening_battery_export_push_indices( slots, - profitable_export_ts=profitable, + charge_acquisition_czk_kwh=0.5, degrad_czk_kwh=0.15, current_soc_wh=0.9 * bat.soc_max_wh, min_soc_wh=bat.min_soc_wh, @@ -2570,9 +2563,9 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase): self.assertLess(evening.battery_setpoint_w, -500) 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.""" + """MILP v41: plný export ve všech slotech se shodnou max sell v nočním úseku.""" prague = ZoneInfo("Europe/Prague") - sells = [10.0, 9.92, 9.88, 5.0, 4.0, 3.0] + sells = [10.0, 10.0, 10.0, 5.0, 4.0, 3.0] base = datetime(2026, 5, 25, 18, 0, tzinfo=prague) slots = [ PlanningSlot( @@ -2627,6 +2620,57 @@ 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ů.""" + 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) + slots = [ + PlanningSlot( + interval_start=base + timedelta(minutes=15 * i), + buy_price=5.5, + sell_price=sells[i], + pv_a_forecast_w=0, + pv_b_forecast_w=0, + load_baseline_w=2973, + ev1_connected=False, + ev2_connected=False, + allow_charge=False, + allow_discharge_export=True, + charge_acquisition_buy_czk_kwh=0.8, + ) + for i in range(6) + ] + battery = _battery(uc_wh=64_000.0, terminal_soc_value_factor=0.0) + battery.max_discharge_power_w = 18_000 + 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) + 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), + ] + results, _ms, snap = solve_dispatch( + slots, + battery, + hp, + grid, + [None, None], + vehicles, + 0.81 * battery.soc_max_wh, + 50.0, + operating_mode="AUTO", + ) + self.assertEqual(snap["planner_build_tag"], PLANNER_BUILD_TAG) + push_iso = set(snap["inputs"].get("evening_push_ts") or []) + 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", + ) + 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éř.""" base = datetime(2026, 5, 21, 12, 0, tzinfo=timezone.utc) diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index 5e12860..7b1e7ee 100644 --- a/docs/04-modules/planning.md +++ b/docs/04-modules/planning.md @@ -93,20 +93,18 @@ flowchart TD 1. **SQL masky (R__063, vrstva 2)** — které večerní sloty *smí* export z baterie vůbec (`allow_discharge_export`): mimo jiné sloty v pásmu „denní večerní max − degrad“ (SQL), plus globální Wh rozpočet (vrstva 1). -2. **v38 — zákaz předčasného / levného večerního vývozu** (`evening_early_export_penalty_ts` → tvrdé `ge_bat[t] = 0`): - - v **nočním okně** pro profitable sloty **mimo** `evening_push_ts` (včetně slotů **po** prvním push — v27 je omezoval jen na čas před prvním push); - - pokud `sell < max_sell_v_nočním_úseku − 0,05` (v30: max přes půlnoc); +2. **v41 — zákaz večerního vývozu mimo špičku** (`evening_early_export_penalty_ts` → tvrdé `ge_bat[t] = 0`): + - 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. **v38 — plný výkon v top večerních slotech** (`evening_push_ts`): - - kandidáti: profitable ∩ noční okno ∩ **peak pásmo** (`max sell v úseku − degrad`, shodně s R__063); +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; - - **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. + - **`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`). -**Není to** „prodávat jen v jednom jediném nejdražším slotu“ — je to „prodávat **plným výkonem** v **tolika nejdražších večerní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 ve **max-sell** nočních slotech, kolik unese baterie“. #### Co v26 opravilo oproti starému chování @@ -116,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_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-28-evening-export-dynamic-v38`. +**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). ### Arbitráž baterie — účtování mezi sloty (povinné čtení) diff --git a/docs/planning-changelog.md b/docs/planning-changelog.md index 25b9ac3..edd8e52 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í 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`). + +**Změna (v41):** +- Push kandidáti = sloty se **`sell = max`** v nočním úseku + marže **`acq+spread`** (spot), ne `buy+spread`. +- **`evening_early_export_ban`:** `ge_bat=0` ve **všech** nočních exportních slotech mimo `evening_push` (výjimky: pre-neg / neg-evening větve). +- **`peak_export_shortfall`** se v nočním okně neaplikuje. + +**Soubory:** `backend/services/planning_engine.py` (`_evening_push_peak_candidates`, `_evening_early_export_penalty_indices`), `backend/tests/test_planning_dispatch_milp.py` (`test_evening_no_spread_export_below_segment_peak_home01`). Tag **`2026-05-29-evening-peak-only-export-v41`**. + +**Ověření:** `pytest … -k evening_no_spread_export_below_segment_peak_home01`; MCP: večerní slot s max sell → `|grid_setpoint_w|` ≈ 12,5–13,5 kW; sousední levnější sloty → `export_mode=NONE`, `grid_setpoint_w≥0`. + +--- + ## 2026-05-29 — Infeasible rolling: relax neg-prep okno (v40b) **Problém:** Po načtení OTE na **30. 5.** (neg sell) rolling/home-01 končil `Solver: Infeasible` od ~13:15; ruční replan stejně. Plán zůstal na runu z 13:00 (horizont jen do 22:00). Log často prázdný — výjimka se loguje na `WARNING`, scheduler ji polyká.