Merge branch 'worktree-agent-a53f3277d55fecfcb' into dev
All checks were successful
CI and deploy / migration-check (push) Successful in 19s
CI and deploy / deploy (push) Successful in 1m14s

This commit is contained in:
Dusan Vojacek
2026-06-12 19:40:50 +02:00
12 changed files with 425 additions and 57 deletions

View File

@@ -66,7 +66,16 @@ _VEHICLES = [
_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"):
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,
@@ -74,7 +83,7 @@ def _solve(slots, *, battery=None, grid=None, ev_sessions=(None, None), soc0=Non
_HP,
grid or _grid(),
list(ev_sessions),
_VEHICLES,
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,
@@ -296,6 +305,144 @@ class EvOpportunisticTests(unittest.TestCase):
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)]