v2: noční SoC polštář — placená rezerva na neočekávaný noční nákup
All checks were successful
CI and deploy / migration-check (push) Successful in 34s
CI and deploy / deploy (push) Successful in 1m0s

Postřeh uživatele: v1 držel přes noc rezervu nad min_soc (chyba predikce
noční spotřeby = neplánovaný drahý nákup); v2 slot fieldy night_baseload_*
ignoroval a směl plánovat vybití až na min_soc.

Mechanismus ve filozofii v2 (riziko jako cena, ne okno/penalta):
soft floor soc[t] >= min_soc + night_baseload_buffer_wh[t] (z DB
planner_night_baseload_buffer_percent, počítá R__063, klesá k 0 do rána);
porušení placené buy cenou slotu → extrémní sell špička smí polštář
racionálně prodat, běžná noc ne (buy > sell).

Eval na fixtures: v2 stále lepší na všech (+221.9 Kč vs v1; −10 Kč proti
stavu bez polštáře = cena robustnosti). BONUS: těsnější LP zrychlil extrémní
fixtures z 10 s timeoutu na 0.3–2.6 s. +3 testy (drží/spike prodá/feasible).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dusan Vojacek
2026-06-12 09:49:21 +02:00
parent 4095f0f912
commit e464b114b9
2 changed files with 62 additions and 0 deletions

View File

@@ -17,6 +17,11 @@
# - zákaz současného importu a exportu (binárka) # - 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ěží # - 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) # - 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): # Vědomé odchylky od v1 (změří harness):
# - SQL masky allow_charge / allow_discharge_export se IGNORUJÍ (jsou to # - SQL masky allow_charge / allow_discharge_export se IGNORUJÍ (jsou to
@@ -147,6 +152,11 @@ def solve_dispatch_v2(
for e in range(EV) for e in range(EV)
] ]
ev_unmet: list = [] # slack Wh per session (cena V2_EV_UNMET_CZK_KWH) 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: def _connected(e: int, t: int) -> bool:
return bool(slots[t].ev1_connected if e == 0 else slots[t].ev2_connected) 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 += 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}" 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 # tvrdá cenová pravidla
if float(s.buy_price) < 0.0: if float(s.buy_price) < 0.0:
prob += ge_pv[t] + ge_bat[t] == 0, f"neg_buy_noexp_{t}" prob += ge_pv[t] + ge_bat[t] == 0, f"neg_buy_noexp_{t}"
@@ -286,6 +300,13 @@ def solve_dispatch_v2(
) )
if ev_unmet: if ev_unmet:
extras += pulp.lpSum(u * V2_EV_UNMET_CZK_KWH / 1000.0 for u in 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] prob += cash + degradation + extras - terminal * soc[T - 1]
@@ -386,6 +407,8 @@ def solve_dispatch_v2(
"slot_count": T, "slot_count": T,
"ev_sessions": sum(1 for x in ev_sessions if x is not None), "ev_sessions": sum(1 for x in ev_sessions if x is not None),
"masks_ignored": True, "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": { "objective_terms": {
"cash_czk": round(float(pulp.value(cash)), 3), "cash_czk": round(float(pulp.value(cash)), 3),

View File

@@ -154,6 +154,45 @@ class OperatingModeTests(unittest.TestCase):
self.assertLessEqual(r.grid_setpoint_w, 1000, "import ≤ baseline load") 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): class EvDeadlineTests(unittest.TestCase):
def test_ev_energy_delivered_before_deadline(self) -> None: 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)] slots = [_slot(_BASE, i, buy=2.0 if i < 8 else 6.0, sell=1.0, ev1=True) for i in range(16)]