Files
ems/backend/tests/test_solver_v2.py
Dusan Vojacek 3b5f07b66e feat(planner): EV účtování v2 — headroom fix, deadline boundary, min. výkon WB, via-bat reporting
Hloubková diagnóza EV potvrdila: oportunitní ekonomika via-baterie je v LP
správně, ale okraje lhaly nebo byly nevykonatelné:

- V099 + R__039: ems.ev_session.opportunistic_value_czk_kwh (NULL = zdědit
  z asset_vehicle, 0 = vypnout pro session); headroom_wh z max(target_soc,
  soc_at_connect) — „nenabíjet" (nízký target) už paradoxně NEzvětšuje
  oportunistickou vrstvu; vehicles JSON nese min_power_w wallboxu.
- R__015: patch klíč opportunistic_value_czk_kwh (validace >= 0).
- solver_v2: (a) deadline suma range(t_dl) — slot začínající v deadline už
  nepatří „do deadline"; (b) Σ ev_direct <= gi + PV (fyzikální split);
  (c) binárka ev_on → setpoint ∈ {0} ∪ [min_power_w, max] (konec 400–900 W
  nevykonatelných setpointů); (d) bez session EV == 0 (stop-session i golden
  fixtures — žádné pumpování při buy<0); dekompozice total == needed − unmet
  + opp i pro needed = 0; (e) battery_arbitrage_czk = via_bat kWh × oportunitní
  cena (min sell exportního slotu téhož pražského dne, jinak terminal value)
  místo konstantní 0. Oportunismus PO deadline zůstává POVOLENÝ (rozhodnutí:
  auto často doma, odjezd řeší rolling replan).
- R__033: fn_plan_current_bundle.intervals + ev1/ev2_via_bat_w (UI nemá cenit
  EV kWh z baterie slotovým buy).

Golden gate beze změny snapshotů (v1 nedotčen, fixtures bez EV sessions);
solver_v2_eval před/po identický (CELKEM −1283.5 Kč, Δ −221.9 vs v1);
tests/test_solver_v2.py +7 testů; plná sada 310 passed / 4 xfailed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 19:31:56 +02:00

473 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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é 400900 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 (07) 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()