Merge branch 'worktree-agent-a53f3277d55fecfcb' into dev
This commit is contained in:
@@ -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é 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)]
|
||||
|
||||
Reference in New Issue
Block a user