"""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()