Tvrdá pravidla (neg-buy/neg-sell bloky, arb floor, curtail jen A), arbitráž levná noc → drahý večer, režimy PRESERVE/CHARGE_CHEAP/SELF_SUSTAIN, EV deadline vč. placeného slacku při nesplnitelném deadline. Vše zelené, plná sada beze změny. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
184 lines
7.4 KiB
Python
184 lines
7.4 KiB
Python
"""solver_v2 (čisté jádro): tvrdá pravidla, režimy, EV deadline, arbitráž (bez DB)."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import unittest
|
||
from datetime import datetime, timedelta, timezone
|
||
from types import SimpleNamespace
|
||
|
||
from services.planning.solver_v2 import solve_dispatch_v2
|
||
from services.planning.types import PlanningSlot
|
||
|
||
|
||
def _slot(
|
||
base: datetime,
|
||
i: int,
|
||
*,
|
||
buy: float,
|
||
sell: float,
|
||
pv_a: int = 0,
|
||
pv_b: int = 0,
|
||
load: int = 1000,
|
||
ev1: bool = False,
|
||
) -> PlanningSlot:
|
||
return PlanningSlot(
|
||
interval_start=base + timedelta(minutes=15 * i),
|
||
buy_price=buy,
|
||
sell_price=sell,
|
||
pv_a_forecast_w=pv_a,
|
||
pv_b_forecast_w=pv_b,
|
||
load_baseline_w=load,
|
||
ev1_connected=ev1,
|
||
ev2_connected=False,
|
||
)
|
||
|
||
|
||
def _battery(uc_wh: float = 20_000.0) -> SimpleNamespace:
|
||
return SimpleNamespace(
|
||
usable_capacity_wh=uc_wh,
|
||
min_soc_wh=0.12 * uc_wh,
|
||
arb_floor_wh=0.20 * uc_wh,
|
||
reserve_soc_wh=0.20 * uc_wh,
|
||
soc_max_wh=0.95 * uc_wh,
|
||
charge_efficiency=0.95,
|
||
discharge_efficiency=0.95,
|
||
degradation_cost_czk_kwh=0.5,
|
||
max_charge_power_w=8000,
|
||
max_discharge_power_w=8000,
|
||
planner_terminal_soc_value_factor=0.8,
|
||
)
|
||
|
||
|
||
def _grid(block_neg: bool = False, gen_cutoff: bool = False) -> SimpleNamespace:
|
||
return SimpleNamespace(
|
||
max_import_power_w=17_000,
|
||
max_export_power_w=13_500,
|
||
block_export_on_negative_sell=block_neg,
|
||
deye_gen_microinverter_cutoff_enabled=gen_cutoff,
|
||
)
|
||
|
||
|
||
_HP = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
|
||
_VEHICLES = [
|
||
SimpleNamespace(max_charge_power_w=11_000, battery_capacity_kwh=60.0, default_target_soc_pct=80.0),
|
||
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
|
||
]
|
||
_BASE = datetime(2026, 6, 10, 0, 0, tzinfo=timezone.utc)
|
||
|
||
|
||
def _solve(slots, *, battery=None, grid=None, ev_sessions=(None, None), soc0=None, mode="AUTO"):
|
||
bat = battery or _battery()
|
||
return solve_dispatch_v2(
|
||
slots,
|
||
bat,
|
||
_HP,
|
||
grid or _grid(),
|
||
list(ev_sessions),
|
||
_VEHICLES,
|
||
soc0 if soc0 is not None else 0.5 * bat.usable_capacity_wh,
|
||
50.0,
|
||
operating_mode=mode,
|
||
)
|
||
|
||
|
||
class HardRulesTests(unittest.TestCase):
|
||
def test_negative_buy_blocks_export(self) -> None:
|
||
slots = [_slot(_BASE, i, buy=-2.0, sell=1.5, pv_a=6000, load=500) for i in range(8)]
|
||
results, _, _ = _solve(slots)
|
||
for r in results:
|
||
self.assertGreaterEqual(r.grid_setpoint_w, 0, "buy<0 → žádný export (pumpa)")
|
||
|
||
def test_block_export_on_negative_sell(self) -> None:
|
||
slots = [_slot(_BASE, i, buy=2.0, sell=-0.5, pv_a=8000, load=500) for i in range(8)]
|
||
results, _, _ = _solve(slots, grid=_grid(block_neg=True))
|
||
for r in results:
|
||
self.assertGreaterEqual(r.grid_setpoint_w, 0, "KV1: sell<0 → ge=0")
|
||
|
||
def test_negative_sell_prefers_charge_or_curtail_over_paid_export(self) -> None:
|
||
slots = [_slot(_BASE, i, buy=2.0, sell=-1.0, pv_a=8000, load=500) for i in range(8)]
|
||
results, _, _ = _solve(slots)
|
||
paid_export = sum(-r.grid_setpoint_w for r in results if r.grid_setpoint_w < 0)
|
||
self.assertEqual(paid_export, 0, "spot: za export při sell<0 se platí → ekonomika ho vyloučí")
|
||
|
||
def test_battery_export_requires_arb_floor(self) -> None:
|
||
bat = _battery()
|
||
slots = [_slot(_BASE, i, buy=1.0, sell=8.0, load=500) for i in range(8)]
|
||
results, _, _ = _solve(slots, battery=bat, soc0=0.5 * bat.usable_capacity_wh)
|
||
for r in results:
|
||
if r.grid_setpoint_w < 0 and r.battery_setpoint_w < 0:
|
||
self.assertGreaterEqual(
|
||
r.battery_soc_target / 100.0 * bat.usable_capacity_wh,
|
||
bat.arb_floor_wh - 1.0,
|
||
"export z baterie nesmí podlézt arb floor",
|
||
)
|
||
|
||
def test_curtailment_only_pv_a(self) -> None:
|
||
# extrémně záporný sell bez block_export: pole B nelze omezit, A ano
|
||
slots = [_slot(_BASE, i, buy=2.0, sell=-3.0, pv_a=5000, pv_b=4000, load=300) for i in range(8)]
|
||
bat = _battery(uc_wh=2000.0) # malá baterie, ať se přebytek nevejde
|
||
results, _, _ = _solve(slots, battery=bat, soc0=0.9 * 2000.0)
|
||
self.assertTrue(any(r.pv_a_curtailed_w > 0 for r in results), "A se curtailuje")
|
||
for r in results:
|
||
self.assertLessEqual(r.pv_a_curtailed_w, 5000, "curtail max = výroba A")
|
||
|
||
|
||
class ArbitrageTests(unittest.TestCase):
|
||
def test_cheap_night_charge_expensive_evening_discharge(self) -> None:
|
||
slots = [_slot(_BASE, i, buy=1.0, sell=0.5, load=1000) for i in range(16)]
|
||
slots += [_slot(_BASE, 16 + i, buy=8.0, sell=7.0, load=1000) for i in range(16)]
|
||
results, _, _ = _solve(slots)
|
||
charged = sum(r.battery_setpoint_w for r in results[:16] if r.battery_setpoint_w > 0)
|
||
discharged = sum(-r.battery_setpoint_w for r in results[16:] if r.battery_setpoint_w < 0)
|
||
self.assertGreater(charged, 0, "levná noc → nabíjet")
|
||
self.assertGreater(discharged, 0, "drahý večer → vybíjet")
|
||
|
||
|
||
class OperatingModeTests(unittest.TestCase):
|
||
def _slots(self):
|
||
return [_slot(_BASE, i, buy=1.0, sell=6.0, pv_a=3000, load=1000) for i in range(8)]
|
||
|
||
def test_preserve_locks_battery(self) -> None:
|
||
results, _, _ = _solve(self._slots(), mode="PRESERVE")
|
||
for r in results:
|
||
self.assertEqual(r.battery_setpoint_w, 0)
|
||
|
||
def test_charge_cheap_no_export_no_discharge(self) -> None:
|
||
results, _, _ = _solve(self._slots(), mode="CHARGE_CHEAP")
|
||
for r in results:
|
||
self.assertGreaterEqual(r.grid_setpoint_w, 0)
|
||
self.assertGreaterEqual(r.battery_setpoint_w, 0)
|
||
|
||
def test_self_sustain_import_capped_to_load(self) -> None:
|
||
results, _, _ = _solve(self._slots(), mode="SELF_SUSTAIN")
|
||
for r in results:
|
||
self.assertLessEqual(r.grid_setpoint_w, 1000, "import ≤ baseline load")
|
||
|
||
|
||
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)]
|
||
session = SimpleNamespace(
|
||
target_deadline=_BASE + timedelta(hours=4), # slot 16 → vše do konce
|
||
energy_needed_wh=8000.0,
|
||
)
|
||
results, _, snap = _solve(slots, ev_sessions=(session, None))
|
||
delivered = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results)
|
||
self.assertGreaterEqual(delivered, 8000.0 - 1.0)
|
||
self.assertEqual(snap["objective_terms"]["ev_unmet_wh"], [0.0])
|
||
# levné sloty (0–7) mají dodat většinu energie
|
||
cheap = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results[:8])
|
||
self.assertGreater(cheap, 4000.0, "EV nabíjí přednostně v levných slotech")
|
||
|
||
def test_ev_unreachable_deadline_uses_paid_slack(self) -> None:
|
||
slots = [_slot(_BASE, i, buy=2.0, sell=1.0, ev1=(i == 0)) for i in range(8)]
|
||
session = SimpleNamespace(
|
||
target_deadline=_BASE + timedelta(minutes=15),
|
||
energy_needed_wh=50_000.0, # nesplnitelné za 1 slot
|
||
)
|
||
results, _, snap = _solve(slots, ev_sessions=(session, None))
|
||
self.assertGreater(snap["objective_terms"]["ev_unmet_wh"][0], 0.0, "slack místo infeasible")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|