diff --git a/backend/services/planning/solver_v2.py b/backend/services/planning/solver_v2.py index c0cb900..6b4a704 100644 --- a/backend/services/planning/solver_v2.py +++ b/backend/services/planning/solver_v2.py @@ -17,6 +17,11 @@ # - zákaz současného importu a exportu (binárka) # - load-first Deye: bc_pv + ge_pv jen z PV přebytku nad zátěží # - EV deadline, TUV look-ahead, provozní režimy (legitimní constraints) +# - noční SoC polštář: plán nesmí kalkulovat s vybitím až na min_soc — chyba +# predikce noční spotřeby by znamenala neplánovaný noční nákup. Velikost +# z DB (planner_night_baseload_buffer_percent → slot.night_baseload_buffer_wh, +# klesá k 0 do rána); porušení je PLACENÉ cenou buy daného slotu (riziko +# zpětného nákupu), takže extrémní sell špička ho smí racionálně prodat. # # Vědomé odchylky od v1 (změří harness): # - SQL masky allow_charge / allow_discharge_export se IGNORUJÍ (jsou to @@ -147,6 +152,11 @@ def solve_dispatch_v2( for e in range(EV) ] ev_unmet: list = [] # slack Wh per session (cena V2_EV_UNMET_CZK_KWH) + nb_buffer_wh = [max(0.0, float(s.night_baseload_buffer_wh or 0.0)) for s in slots] + nb_slack = [ + pulp.LpVariable(f"nbs_{t}", 0, nb_buffer_wh[t]) if nb_buffer_wh[t] > 0 else None + for t in range(T) + ] def _connected(e: int, t: int) -> bool: return bool(slots[t].ev1_connected if e == 0 else slots[t].ev2_connected) @@ -197,6 +207,10 @@ def solve_dispatch_v2( prob += ge_bat[t] <= max_exp * z_exp[t], f"zexp_link_{t}" prob += soc[t] >= arb_floor - (soc_max - soc_min) * (1 - z_exp[t]), f"zexp_floor_{t}" + # noční SoC polštář (viz hlavička): soft floor nad min_soc + if nb_slack[t] is not None: + prob += soc[t] >= soc_min + nb_buffer_wh[t] - nb_slack[t], f"night_buf_{t}" + # tvrdá cenová pravidla if float(s.buy_price) < 0.0: prob += ge_pv[t] + ge_bat[t] == 0, f"neg_buy_noexp_{t}" @@ -286,6 +300,13 @@ def solve_dispatch_v2( ) if ev_unmet: extras += pulp.lpSum(u * V2_EV_UNMET_CZK_KWH / 1000.0 for u in ev_unmet) + nb_terms = [ + nb_slack[t] / 1000.0 * max(0.0, float(slots[t].buy_price)) + for t in range(T) + if nb_slack[t] is not None + ] + if nb_terms: + extras += pulp.lpSum(nb_terms) prob += cash + degradation + extras - terminal * soc[T - 1] @@ -386,6 +407,8 @@ def solve_dispatch_v2( "slot_count": T, "ev_sessions": sum(1 for x in ev_sessions if x is not None), "masks_ignored": True, + "night_buffer_slots": sum(1 for b in nb_buffer_wh if b > 0), + "night_buffer_max_wh": round(max(nb_buffer_wh), 1) if nb_buffer_wh else 0, }, "objective_terms": { "cash_czk": round(float(pulp.value(cash)), 3), diff --git a/backend/tests/test_solver_v2.py b/backend/tests/test_solver_v2.py index 336ff3a..b730894 100644 --- a/backend/tests/test_solver_v2.py +++ b/backend/tests/test_solver_v2.py @@ -154,6 +154,45 @@ class OperatingModeTests(unittest.TestCase): self.assertLessEqual(r.grid_setpoint_w, 1000, "import ≤ baseline load") +class NightReserveTests(unittest.TestCase): + def test_night_discharge_respects_buffer(self) -> None: + # noc: vysoký sell, žádné PV; buffer 2000 Wh nad min → plán nesmí + # kalkulovat s vybitím pod min+buffer (sell < buy ⇒ slack se nevyplatí) + bat = _battery() + slots = [] + for i in range(16): + s = _slot(_BASE, i, buy=6.0, sell=4.5, load=800) + s.night_baseload_buffer_wh = 2000.0 + slots.append(s) + results, _, _ = _solve(slots, battery=bat, soc0=0.6 * bat.usable_capacity_wh) + floor_pct = (bat.min_soc_wh + 2000.0) / bat.usable_capacity_wh * 100.0 + for r in results: + self.assertGreaterEqual(r.battery_soc_target, floor_pct - 0.6) + + def test_extreme_sell_spike_may_sell_reserve(self) -> None: + # sell výrazně nad buy → racionální polštář prodat (placený slack) + bat = _battery() + slots = [] + for i in range(16): + s = _slot(_BASE, i, buy=2.0, sell=12.0, load=300) + s.night_baseload_buffer_wh = 2000.0 + slots.append(s) + results, _, _ = _solve(slots, battery=bat, soc0=0.6 * bat.usable_capacity_wh) + min_soc_pct = min(r.battery_soc_target for r in results) + floor_pct = (bat.min_soc_wh + 2000.0) / bat.usable_capacity_wh * 100.0 + self.assertLess(min_soc_pct, floor_pct - 1.0, "spike má polštář vyprodat") + + def test_start_below_buffer_is_feasible(self) -> None: + bat = _battery() + slots = [] + for i in range(8): + s = _slot(_BASE, i, buy=6.0, sell=1.0, load=1500) + s.night_baseload_buffer_wh = 3000.0 + slots.append(s) + results, _, _ = _solve(slots, battery=bat, soc0=bat.min_soc_wh + 500.0) + self.assertEqual(len(results), 8) + + class EvDeadlineTests(unittest.TestCase): def test_ev_energy_delivered_before_deadline(self) -> None: slots = [_slot(_BASE, i, buy=2.0 if i < 8 else 6.0, sell=1.0, ev1=True) for i in range(16)]