diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index 3cd1316..6fe738e 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -163,12 +163,14 @@ def _select_charge_slots( Logika: 1) Sloty s PV-surplus (pv_a + pv_b > load_baseline) jsou vždy zahrnuty – - jde o „zdarma“ nabíjení z FVE, nemá smysl ho zakazovat. - 2) Zbývající energetický rozpočet (cíl = charge_buf × (soc_max − current_soc), - snížený o očekávaný přínos z PV-surplus slotů) se doplní nejlevnějšími sloty - podle buy_price (nákupní cena ze sítě). - 3) Per-slot kapacita přírůstku SoC = max_charge_power × η × 15 min (plný výkon, - ne limitovaný aktuálním PV-surplus výkonem). + nabíjení z FVE je „zdarma“, solver ho musí mít povolené. Tyto sloty se + NEzapočítávají do grid rozpočtu (v dlouhém horizontu by přetekly target). + 2) Nezávisle na bodu 1 se vybere top-N **grid** slotů seřazených podle + `buy_price` ASC tak, aby pokryly `charge_buf × (soc_max − current_soc)`. + Tím dostane solver k dispozici přístup k nejlevnějšímu nákupu ze sítě, + i když PV v daném slotu spotřebu nepokrývá. + 3) Per-slot kapacita přírůstku SoC = max_charge_power × η × 15 min (plný + výkon, ne limitovaný aktuálním PV-surplus výkonem). Vrací množinu indexů povolených pro `bc[t] > 0` v MILP. Prázdná množina = žádné restrikce. `charge_slot_buffer <= 0` v DB ⇒ všechny sloty povoleny. @@ -186,17 +188,13 @@ def _select_charge_slots( per_slot_full_wh = max_p_w * eta * INTERVAL_H selected: set[int] = set() - pv_budget_wh = 0.0 for t, s in enumerate(slots): pv_surplus_w = max(0, s.pv_a_forecast_w + s.pv_b_forecast_w - s.load_baseline_w) - if pv_surplus_w <= 0: - continue - selected.add(t) - pv_budget_wh += min(float(pv_surplus_w), max_p_w) * eta * INTERVAL_H + if pv_surplus_w > 0: + selected.add(t) - target_wh = energy_to_fill * charge_buf - remaining_wh = max(0.0, target_wh - pv_budget_wh) - if remaining_wh <= 0 or per_slot_full_wh <= 0: + grid_target_wh = energy_to_fill * charge_buf + if grid_target_wh <= 0 or per_slot_full_wh <= 0: return selected grid_candidates = [ @@ -206,7 +204,7 @@ def _select_charge_slots( cumulative = 0.0 for t, _price in grid_candidates: - if cumulative >= remaining_wh: + if cumulative >= grid_target_wh: break selected.add(t) cumulative += per_slot_full_wh diff --git a/backend/tests/test_planning_charge_slot_selection.py b/backend/tests/test_planning_charge_slot_selection.py index 0166343..442629f 100644 --- a/backend/tests/test_planning_charge_slot_selection.py +++ b/backend/tests/test_planning_charge_slot_selection.py @@ -99,6 +99,32 @@ class SelectChargeSlotsTests(unittest.TestCase): ), ) + def test_long_horizon_pv_surplus_does_not_exhaust_grid_budget(self) -> None: + """Regrese: v 96h horizontu nesmí PV-surplus sloty „vyžrat“ grid rozpočet. + + V dřívější verzi se kumulativní PV budget odečítal od `charge_buf × headroom`, + takže v dlouhém horizontu s mnoha PV sloty zbylo 0 na grid filler a levné + grid sloty se nepovolily. Tento test simuluje realistický 96h profil. + """ + # 40 levných grid-only slotů (simulace noční / „inter-peak“ hodiny). + cheap_grid = [_slot(buy=0.4 + 0.01 * i, pv=0, load=2_000) for i in range(40)] + # 100 PV-surplus slotů s velkou výrobou (přes den, přes víc dní). + pv_days = [_slot(buy=1.5, pv=10_000, load=2_000) for _ in range(100)] + slots = cheap_grid + pv_days + battery = _battery( + charge_buf=1.3, uc_wh=64_000.0, soc_max_pct=95.0, max_charge_w=18_000.0 + ) + out = _select_charge_slots(slots, battery, current_soc_wh=0.2 * battery.usable_capacity_wh) + grid_selected = sum(1 for i in range(len(cheap_grid)) if i in out) + self.assertGreaterEqual( + grid_selected, + 5, + msg=( + "V dlouhém horizontu s mnoha PV-surplus sloty musí zůstat dostatek " + "grid slotů povolených pro nabíjení z levného importu." + ), + ) + def test_energy_budget_is_charge_buf_times_headroom(self) -> None: """Součet uvolněné energie se pohybuje okolo charge_buf × (soc_max − current_soc).""" slots = [_slot(buy=float(i + 1), pv=0, load=2_000) for i in range(40)]