"""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", vehicles=None, ): bat = battery or _battery() return solve_dispatch_v2( slots, bat, _HP, grid or _grid(), list(ev_sessions), vehicles if vehicles is not None else _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 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 DaytimeSafetyRampTests(unittest.TestCase): def test_morning_tops_up_reserve_before_selling(self) -> None: # KV1 scénář: ráno baterie u dna, fixní buy 6.35 >> sell 2.5, PV jede; # s rampou (target 30 % usable) musí nejdřív dotáhnout rezervu, ne prodávat bat = _battery() bat.planner_safety_soc_risk_factor = 0.05 target_wh = 0.30 * bat.usable_capacity_wh slots = [] for i in range(16): s = _slot(_BASE, i, buy=6.35, sell=2.5, pv_a=6000, load=800) s.safety_soc_target_wh = target_wh slots.append(s) results, _, _ = _solve(slots, battery=bat, soc0=0.11 * bat.usable_capacity_wh) soc_pct = [r.battery_soc_target for r in results] first_reach = next((i for i, v in enumerate(soc_pct) if v >= 29.5), None) self.assertIsNotNone(first_reach, "rampa má dotáhnout na rezervu") exported_before = sum( -r.grid_setpoint_w for r in results[:first_reach] if r.grid_setpoint_w < 0 ) self.assertLess( exported_before, 500 * max(1, first_reach), "před dosažením rezervy se nemá významně prodávat", ) def test_sell_spike_beats_ramp(self) -> None: # extrémní sell nad buy → deficit je racionální podstoupit bat = _battery() bat.planner_safety_soc_risk_factor = 0.05 slots = [] for i in range(16): s = _slot(_BASE, i, buy=2.0, sell=14.0, pv_a=2000, load=300) s.safety_soc_target_wh = 0.5 * bat.usable_capacity_wh slots.append(s) results, _, _ = _solve(slots, battery=bat, soc0=0.45 * bat.usable_capacity_wh) total_export = sum(-r.grid_setpoint_w for r in results if r.grid_setpoint_w < 0) self.assertGreater(total_export, 5000, "spike má vyprodat i pod target") class PvRiskFrontloadTests(unittest.TestCase): def test_neg_window_charges_asap(self) -> None: # sell<0 okno, PV >> load, prázdnější baterie: s frontload prémií musí # nabíjení běžet plným tempem od začátku (ne odložené na konec okna) bat = _battery() bat.planner_pv_risk_frontload_czk_kwh = 0.05 slots = [_slot(_BASE, i, buy=2.0, sell=-0.5, pv_a=12000, load=500) for i in range(12)] results, _, _ = _solve(slots, battery=bat, soc0=0.2 * bat.usable_capacity_wh) # max tempo: 8 kW × 0.25 h × 0.95 eff = 1.9 kWh/slot = 9.5 p.b. na 20 kWh soc_mid = results[3].battery_soc_target self.assertGreaterEqual( soc_mid, 20.0 + 4 * 9.0, "frontload: prvni 4 sloty maji nabijet plnym vykonem", ) class EvOpportunisticTests(unittest.TestCase): def _session(self, needed=4000.0, headroom=20000.0, opp=1.0): return SimpleNamespace( target_deadline=_BASE + timedelta(hours=2), energy_needed_wh=needed, headroom_wh=headroom, opportunistic_value_czk_kwh=opp, ) def test_negative_prices_fill_beyond_target(self) -> None: # buy<0 celé okno → nad target se vyplatí brát (hodnota 1 Kč/kWh + platí ti síť) slots = [_slot(_BASE, i, buy=-1.0, sell=-0.5, ev1=True, load=300) for i in range(16)] results, _, snap = _solve(slots, ev_sessions=(self._session(), None)) delivered = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results) self.assertGreater(delivered, 4000.0 + 2000.0, "měkký cíl má nasávat") self.assertLessEqual(delivered, 4000.0 + 20000.0 + 1.0, "strop headroom") self.assertGreater(snap["objective_terms"]["ev_opp_wh"][0], 0) def test_normal_prices_no_opportunistic(self) -> None: # běžné ceny (buy 3) > hodnota 1 Kč/kWh → jen tvrdý cíl slots = [_slot(_BASE, i, buy=3.0, sell=2.0, ev1=True, load=300) for i in range(16)] results, _, snap = _solve(slots, ev_sessions=(self._session(), None)) delivered = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results) self.assertLess(delivered, 4000.0 + 200.0) self.assertLess(snap["objective_terms"]["ev_opp_wh"][0], 100.0) def test_cheap_sell_prefers_car_over_grid(self) -> None: # sell 0.3 < opp 1.0, plná domácí baterka, velký PV přebytek # → přebytek do auta, ne za babku do sítě bat = _battery() slots = [_slot(_BASE, i, buy=3.0, sell=0.3, pv_a=9000, load=500, ev1=True) for i in range(16)] results, _, snap = _solve( slots, battery=bat, soc0=bat.soc_max_wh, # baterka plná ev_sessions=(self._session(needed=2000.0, headroom=25000.0), None), ) delivered = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results) exported = sum(-r.grid_setpoint_w * 0.25 for r in results if r.grid_setpoint_w < 0) self.assertGreater(delivered, 15000.0, "přebytek má téct do auta") self.assertLess(exported, delivered, "prodej za 0.3 nemá vyhrát nad autem") def test_total_energy_capped_even_at_negative_buy(self) -> None: # fix latentního bugu: bez headroom (opp=0) nesmí buy<0 pumpovat nad needed slots = [_slot(_BASE, i, buy=-2.0, sell=-1.0, ev1=True, load=300) for i in range(16)] sess = self._session(needed=3000.0, headroom=0.0, opp=0.0) results, _, _ = _solve(slots, ev_sessions=(sess, None)) delivered = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results) self.assertLessEqual(delivered, 3000.0 + 1.0) class EvAccountingTests(unittest.TestCase): """EV účtování 2026-06-12: deadline boundary, stop-session, fyzikální split, min. výkon wallboxu, opp po deadline, battery_arbitrage_czk reporting.""" def test_deadline_boundary_slot_excluded(self) -> None: # slot začínající přesně v deadline (slot 4) už do deadline nepatří; # levné sloty 4..7 nesmí krýt tvrdý cíl (dřív off-by-one t_dl+1) slots = [ _slot(_BASE, i, buy=5.0 if i < 4 else 0.5, sell=0.2, ev1=True) for i in range(8) ] session = SimpleNamespace( target_deadline=_BASE + timedelta(hours=1), # = start slotu 4 energy_needed_wh=4000.0, headroom_wh=0.0, opportunistic_value_czk_kwh=0.0, ) results, _, snap = _solve(slots, ev_sessions=(session, None)) before = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results[:4]) after = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results[4:]) self.assertGreaterEqual(before, 4000.0 - 1.0, "tvrdý cíl jen sloty PŘED deadline") self.assertLessEqual(after, 1.0, "slot v deadline a dál nekryje tvrdý cíl") self.assertEqual(snap["objective_terms"]["ev_unmet_wh"], [0.0]) def test_stop_session_zero_everywhere(self) -> None: # needed 0 + opp 0 (stop-session) → EV nula i při záporných cenách slots = [_slot(_BASE, i, buy=-2.0, sell=-1.0, ev1=True, load=300) for i in range(8)] session = SimpleNamespace( target_deadline=_BASE + timedelta(hours=2), energy_needed_wh=0.0, headroom_wh=0.0, opportunistic_value_czk_kwh=0.0, ) results, _, _ = _solve(slots, ev_sessions=(session, None)) for r in results: self.assertEqual(r.ev1_setpoint_w or 0, 0) def test_no_session_zero_even_at_negative_buy(self) -> None: # připojené auto BEZ session nemá mandát nabíjet (golden fixtures) slots = [_slot(_BASE, i, buy=-2.0, sell=-1.0, ev1=True, load=300) for i in range(8)] results, _, _ = _solve(slots, ev_sessions=(None, None)) for r in results: self.assertEqual(r.ev1_setpoint_w or 0, 0) def test_ev_direct_within_grid_plus_pv(self) -> None: # fyzikální split: direct (= setpoint − via_bat) nesmí překročit gi + PV slots = [ _slot(_BASE, i, buy=2.0, sell=1.0, pv_a=(3000 if i < 4 else 0), ev1=True) for i in range(12) ] bat = _battery() session = SimpleNamespace( target_deadline=_BASE + timedelta(hours=3), energy_needed_wh=10000.0, headroom_wh=0.0, opportunistic_value_czk_kwh=0.0, ) results, _, _ = _solve( slots, battery=bat, soc0=0.9 * bat.usable_capacity_wh, ev_sessions=(session, None), ) for i, r in enumerate(results): direct = (r.ev1_setpoint_w or 0) - r.ev1_via_bat_w gi_w = max(0, r.grid_setpoint_w) pv_w = slots[i].pv_a_forecast_w + slots[i].pv_b_forecast_w self.assertLessEqual(direct, gi_w + pv_w + 2, f"slot {i}: direct > gi+pv") def test_min_power_setpoints_zero_or_above_min(self) -> None: # wallbox min 1380 W (6 A): setpoint ∈ {0} ∪ [1380, max] — žádné 400–900 W vehicles = [ SimpleNamespace( max_charge_power_w=11_000, min_power_w=1380, battery_capacity_kwh=60.0, default_target_soc_pct=80.0, ), _VEHICLES[1], ] # ceny nutí rozprostřít malé množství energie → bez binárky by vyšlo ~86 W/slot slots = [_slot(_BASE, i, buy=2.0 + 0.01 * i, sell=1.0, ev1=True) for i in range(8)] session = SimpleNamespace( target_deadline=_BASE + timedelta(hours=2), energy_needed_wh=690.0, # 2 sloty × 1380 W × 0.25 h headroom_wh=0.0, opportunistic_value_czk_kwh=0.0, ) results, _, _ = _solve(slots, ev_sessions=(session, None), vehicles=vehicles) delivered = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results) self.assertGreaterEqual(delivered, 690.0 - 1.0) for i, r in enumerate(results): sp = r.ev1_setpoint_w or 0 self.assertTrue( sp == 0 or sp >= 1379, f"slot {i}: setpoint {sp} W je pod minimem wallboxu", ) def test_opportunistic_after_deadline_allowed(self) -> None: # ROZHODNUTO 2026-06-12: opp vrstva NENÍ omezená deadline — záporné ceny # po deadline smí téct do auta (odjezd řeší rolling replan) slots = [ _slot(_BASE, i, buy=(3.0 if i < 4 else -1.5), sell=(1.0 if i < 4 else -0.5), ev1=True, load=300) for i in range(16) ] session = SimpleNamespace( target_deadline=_BASE + timedelta(hours=1), # slot 4 energy_needed_wh=2000.0, headroom_wh=20000.0, opportunistic_value_czk_kwh=1.0, ) results, _, snap = _solve(slots, ev_sessions=(session, None)) after_deadline = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results[4:]) total = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results) self.assertGreater(after_deadline, 0.0, "opp po deadline musí zůstat povolené") self.assertLessEqual(total, 2000.0 + 20000.0 + 1.0, "strop needed + headroom") self.assertGreater(snap["objective_terms"]["ev_opp_wh"][0], 0.0) def test_battery_arbitrage_reported_for_via_bat(self) -> None: # EV kryté z baterie (noc, drahý buy, plná baterie) → via_bat > 0 a # battery_arbitrage_czk nese oportunitní cenu (ne konstantní 0) bat = _battery() slots = [_slot(_BASE, i, buy=8.0, sell=1.0, ev1=True, load=300) for i in range(8)] session = SimpleNamespace( target_deadline=_BASE + timedelta(hours=2), energy_needed_wh=6000.0, headroom_wh=0.0, opportunistic_value_czk_kwh=0.0, ) results, _, _ = _solve( slots, battery=bat, soc0=bat.soc_max_wh, ev_sessions=(session, None) ) via = sum(r.ev1_via_bat_w for r in results) self.assertGreater(via, 0, "drahý buy + plná baterie → EV z baterie") arb = sum(r.battery_arbitrage_czk for r in results) self.assertGreater(arb, 0.0, "via_bat sloty musí reportovat oportunitní Kč") for r in results: if r.ev1_via_bat_w == 0: self.assertEqual(r.battery_arbitrage_czk, 0.0) 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()