Files
ems/backend/tests/test_planning_dispatch_milp.py
Dusan Vojacek 430e081841
Some checks failed
CI and deploy / migration-check (push) Failing after 26s
CI and deploy / deploy (push) Has been skipped
oprave vercerniho nevyprodeje
2026-06-01 18:24:57 +02:00

5451 lines
208 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.
"""MILP dispatch: dvouúrovňové SoC a záporná nákupní cena (bez DB)."""
from __future__ import annotations
import unittest
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from zoneinfo import ZoneInfo
from services.planning_engine import (
PLANNER_BUILD_TAG,
DispatchResult,
PlanningSlot,
_dynamic_arb_floor_wh_series,
_dispatch_result_comparison,
_evening_battery_export_push_indices,
_evening_peak_export_indices,
_slot_evening_push_profitable,
_evening_push_calendar_segments,
_evening_push_soc_budget_calendar_segments,
_evening_push_discharge_budget_wh,
_evening_push_override_for_solve,
_filter_evening_push_override_indices,
_primary_night_export_segment_indices,
_in_evening_push_hour_window,
_in_night_battery_export_window,
_neg_sell_day_phases,
_neg_sell_phases_enabled,
_neg_evening_before_neg_push_indices,
_neg_evening_discharge_budget_wh,
_neg_evening_reserve_soc_anchors,
_pre_neg_pv_export_bundle,
_pre_neg_pv_export_forecast_cushion_ok_for_day,
_prague_calendar_date,
_prague_hour,
_pre_neg_buy_soc_ceiling_wh,
_pre_neg_peak_sell_idx,
_pre_neg_pv_export_forecast_cushion_ok,
_prague_hour,
_prewindow_deferral_slots,
_slots_until_buy_le_threshold,
_slots_until_sell_lt,
_soc_panel_min_wh_series,
solve_dispatch,
solve_dispatch_two_pass,
)
def _slot(
*,
load: int = 2000,
buy: float = 3.0,
sell: float = 3.0,
pv_a: int = 0,
pv_b: int = 0,
) -> PlanningSlot:
return PlanningSlot(
interval_start=datetime(2026, 4, 3, 12, 0, tzinfo=timezone.utc),
buy_price=buy,
sell_price=sell,
pv_a_forecast_w=pv_a,
pv_b_forecast_w=pv_b,
load_baseline_w=load,
ev1_connected=False,
ev2_connected=False,
is_predicted_price=False,
)
def _battery(
*,
uc_wh: float = 100_000.0,
min_pct: float = 10.0,
arb_pct: float = 20.0,
max_pct: float = 95.0,
terminal_soc_value_factor: float = 0.9,
discharge_slot_buffer: float = 1.5,
) -> SimpleNamespace:
uc = uc_wh
min_wh = min_pct / 100.0 * uc
arb_wh = arb_pct / 100.0 * uc
return SimpleNamespace(
usable_capacity_wh=uc,
min_soc_wh=min_wh,
arb_floor_wh=arb_wh,
reserve_soc_wh=arb_wh,
soc_max_wh=max_pct / 100.0 * uc,
charge_efficiency=0.95,
discharge_efficiency=0.95,
degradation_cost_czk_kwh=0.15,
max_charge_power_w=10_000,
max_discharge_power_w=10_000,
discharge_slot_buffer=discharge_slot_buffer,
planner_terminal_soc_value_factor=terminal_soc_value_factor,
)
class PreNegBuySocPhaseTests(unittest.TestCase):
"""Dvoufázová SoC: plná při posledním sell≥0 před buy<0, strop před buy<0."""
def test_soc_ceiling_accounts_for_neg_buy_window(self) -> None:
base = datetime(2026, 5, 25, 8, 0, tzinfo=timezone.utc)
slots: list[PlanningSlot] = []
for i in range(16):
buy = -0.1 if 6 <= i < 10 else 1.0
sell = -0.3 if i < 6 else (2.5 if i < 10 else -0.2)
slots.append(
PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=buy,
sell_price=sell,
pv_a_forecast_w=6000 if i >= 6 else 4000,
pv_b_forecast_w=3000 if i >= 6 else 2000,
load_baseline_w=2000,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=True,
)
)
bat = _battery(uc_wh=64_000.0, min_pct=10.0, max_pct=95.0)
ceiling = _pre_neg_buy_soc_ceiling_wh(
slots,
first_neg_buy_idx=6,
min_soc_wh=bat.min_soc_wh,
soc_max_wh=bat.soc_max_wh,
max_charge_w=18_000,
charge_eff=0.95,
)
self.assertIsNotNone(ceiling)
assert ceiling is not None
self.assertLess(ceiling, bat.soc_max_wh * 0.85)
class EveningPushBudgetTests(unittest.TestCase):
"""Večerní tvrdý push: všechny profitable peak-band sloty (v38), rozpočet jen brána."""
@staticmethod
def _evening_slots(n: int = 8) -> list[PlanningSlot]:
base = datetime(2026, 5, 25, 15, 0, tzinfo=timezone.utc)
slots: list[PlanningSlot] = []
for i in range(n):
slots.append(
PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=2.0,
sell_price=4.0 + 0.01 * i,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=1000,
ev1_connected=False,
ev2_connected=False,
allow_discharge_export=True,
charge_acquisition_buy_czk_kwh=0.5,
)
)
return slots
def test_budget_scales_with_soc_not_fixed_three(self) -> None:
slots = self._evening_slots(8)
per_slot = 17_000 * 0.95 * 0.25
bat = _battery(uc_wh=64_000.0, min_pct=10.0, max_pct=95.0)
soc_high = 0.92 * bat.soc_max_wh
push_hi = _evening_battery_export_push_indices(
slots,
charge_acquisition_czk_kwh=0.5,
degrad_czk_kwh=0.15,
current_soc_wh=soc_high,
min_soc_wh=bat.min_soc_wh,
soc_max_wh=bat.soc_max_wh,
per_slot_discharge_wh=per_slot,
discharge_slot_buffer=1.5,
)
self.assertGreaterEqual(len(push_hi), 1)
soc_low = bat.min_soc_wh + 100.0
push_lo = _evening_battery_export_push_indices(
slots,
charge_acquisition_czk_kwh=0.5,
degrad_czk_kwh=0.15,
current_soc_wh=soc_low,
min_soc_wh=bat.min_soc_wh,
soc_max_wh=bat.soc_max_wh,
per_slot_discharge_wh=per_slot,
discharge_slot_buffer=1.5,
)
self.assertEqual(len(push_lo), 0)
def test_night_window_includes_midnight_excludes_pv_sunrise(self) -> None:
"""23:30 a 00:00 jeden peak; po východu FVE (pv > load) už ne."""
prague = ZoneInfo("Europe/Prague")
slots = [
PlanningSlot(
interval_start=datetime(2026, 5, 25, 23, 15, tzinfo=prague),
buy_price=5.0,
sell_price=3.323,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=1800,
ev1_connected=False,
ev2_connected=False,
allow_discharge_export=True,
),
PlanningSlot(
interval_start=datetime(2026, 5, 25, 23, 30, tzinfo=prague),
buy_price=5.0,
sell_price=3.286,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=1800,
ev1_connected=False,
ev2_connected=False,
allow_discharge_export=True,
),
PlanningSlot(
interval_start=datetime(2026, 5, 26, 0, 0, tzinfo=prague),
buy_price=5.6,
sell_price=3.586,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=1800,
ev1_connected=False,
ev2_connected=False,
allow_discharge_export=True,
),
PlanningSlot(
interval_start=datetime(2026, 5, 26, 6, 0, tzinfo=prague),
buy_price=4.0,
sell_price=3.0,
pv_a_forecast_w=10_000,
pv_b_forecast_w=0,
load_baseline_w=1800,
ev1_connected=False,
ev2_connected=False,
allow_discharge_export=True,
),
]
self.assertTrue(_in_night_battery_export_window(slots[2]))
self.assertFalse(_in_night_battery_export_window(slots[3]))
peak_ts = _evening_peak_export_indices(slots, degrad_czk_kwh=0.15)
self.assertIn(2, peak_ts, "půlnoc musí být v nočním peak pásmu")
self.assertNotIn(3, peak_ts)
bat = _battery(uc_wh=64_000.0, min_pct=12.0, max_pct=95.0)
per_slot = 18_000 * 0.95 * 0.25
push = _evening_battery_export_push_indices(
slots,
charge_acquisition_czk_kwh=0.5,
degrad_czk_kwh=0.15,
current_soc_wh=0.9 * bat.soc_max_wh,
min_soc_wh=bat.min_soc_wh,
soc_max_wh=bat.soc_max_wh,
per_slot_discharge_wh=per_slot,
discharge_slot_buffer=1.5,
discharge_export_ok={0, 1, 2},
)
self.assertNotIn(2, push, "v43: push jen ≥17h, ne půlnoc")
self.assertIn(1, push, "23:30 večerní push")
self.assertEqual(max(float(slots[t].sell_price) for t in push), 3.323)
def test_no_predawn_push_before_17h(self) -> None:
"""v43: žádný tvrdý push ve 0206h (sell < buy v dead of night)."""
prague = ZoneInfo("Europe/Prague")
base = datetime(2026, 5, 26, 2, 0, tzinfo=prague)
slots = [
PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=4.8,
sell_price=2.9 - 0.01 * i,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=500,
ev1_connected=False,
ev2_connected=False,
allow_discharge_export=True,
charge_acquisition_buy_czk_kwh=0.5,
)
for i in range(8)
]
bat = _battery(uc_wh=64_000.0, min_pct=10.0, max_pct=95.0)
per_slot = 13_500 * 0.95 * 0.25
push = _evening_battery_export_push_indices(
slots,
charge_acquisition_czk_kwh=0.5,
degrad_czk_kwh=0.15,
current_soc_wh=0.85 * bat.soc_max_wh,
min_soc_wh=bat.min_soc_wh,
soc_max_wh=bat.soc_max_wh,
per_slot_discharge_wh=per_slot,
discharge_slot_buffer=1.5,
discharge_export_ok=set(range(8)),
)
self.assertEqual(push, [])
def test_evening_push_budget_only_primary_night_episode(self) -> None:
"""v49: Wh z current_soc jen pro první noční epizodu — ne zítřejší večer po dni FVE."""
prague = ZoneInfo("Europe/Prague")
slots: list[PlanningSlot] = []
for h, m in ((18, 0), (18, 15), (18, 30)):
slots.append(
PlanningSlot(
interval_start=datetime(2026, 5, 25, h, m, tzinfo=prague),
buy_price=5.0,
sell_price=4.0 + 0.1 * (h - 18),
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=800,
ev1_connected=False,
ev2_connected=False,
allow_discharge_export=True,
charge_acquisition_buy_czk_kwh=0.5,
)
)
# Denní FVE mezi večery → druhá noční epizoda (zítřejší večer nesmí brát SoC rozpočet).
slots.append(
PlanningSlot(
interval_start=datetime(2026, 5, 26, 11, 0, tzinfo=prague),
buy_price=3.0,
sell_price=2.0,
pv_a_forecast_w=3000,
pv_b_forecast_w=0,
load_baseline_w=800,
ev1_connected=False,
ev2_connected=False,
allow_discharge_export=True,
charge_acquisition_buy_czk_kwh=0.5,
)
)
for h, m in ((18, 0), (18, 15), (18, 30)):
slots.append(
PlanningSlot(
interval_start=datetime(2026, 5, 26, h, m, tzinfo=prague),
buy_price=5.0,
sell_price=4.0 + 0.1 * (h - 18),
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=800,
ev1_connected=False,
ev2_connected=False,
allow_discharge_export=True,
charge_acquisition_buy_czk_kwh=0.5,
)
)
self.assertEqual(len(_evening_push_calendar_segments(slots, set(range(len(slots))))), 2)
n = len(slots)
budget_segs = _evening_push_soc_budget_calendar_segments(
slots, discharge_export_ok=set(range(n))
)
self.assertEqual(len(budget_segs), 1)
self.assertTrue(all(slots[t].interval_start.day == 25 for seg in budget_segs for t in seg))
primary = _primary_night_export_segment_indices(slots)
self.assertEqual(primary, {0, 1, 2})
bat = _battery(uc_wh=64_000.0, min_pct=10.0, max_pct=95.0)
per_slot = 13_500 * 0.95 * 0.25
push = _evening_battery_export_push_indices(
slots,
charge_acquisition_czk_kwh=0.5,
degrad_czk_kwh=0.15,
current_soc_wh=0.9 * bat.soc_max_wh,
min_soc_wh=bat.min_soc_wh,
soc_max_wh=bat.soc_max_wh,
per_slot_discharge_wh=per_slot,
discharge_slot_buffer=1.5,
discharge_export_ok=set(range(n)),
)
day25 = {t for t in push if slots[t].interval_start.day == 25}
day26 = {t for t in push if slots[t].interval_start.day == 26}
self.assertGreaterEqual(len(day25), 1)
self.assertEqual(day26, set(), "zítřejší večer nesmí krást dnešní Wh rozpočet")
def test_evening_push_budget_matches_r063_formula(self) -> None:
bat = _battery(uc_wh=64_000.0, min_pct=10.0, max_pct=95.0)
soc = 0.85 * bat.soc_max_wh
floor = max(bat.min_soc_wh, bat.reserve_soc_wh)
budget = _evening_push_discharge_budget_wh(
current_soc_wh=soc,
discharge_floor_wh=floor,
soc_max_wh=bat.soc_max_wh,
discharge_slot_buffer=1.5,
)
exportable_full = bat.soc_max_wh - floor
available = soc - floor
self.assertAlmostEqual(budget, min(available, exportable_full * 1.5))
def test_push_slot_count_follows_wh_budget_not_fixed_top_n(self) -> None:
"""v38: počet push slotů = floor(rozpočet Wh / per_slot), sell desc — ne pevné top-3."""
prague = ZoneInfo("Europe/Prague")
sells = [10.0, 10.0, 10.0, 5.0, 4.0, 3.0]
base = datetime(2026, 5, 25, 18, 0, tzinfo=prague)
slots = [
PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=2.0,
sell_price=sells[i],
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=800,
ev1_connected=False,
ev2_connected=False,
allow_discharge_export=True,
charge_acquisition_buy_czk_kwh=0.5,
)
for i in range(6)
]
bat = _battery(uc_wh=64_000.0, min_pct=10.0, max_pct=95.0)
per_slot = 17_000 * 0.95 * 0.25
floor = bat.min_soc_wh
soc_for_budget = floor + 3.2 * per_slot
budget = _evening_push_discharge_budget_wh(
current_soc_wh=soc_for_budget,
discharge_floor_wh=floor,
soc_max_wh=bat.soc_max_wh,
discharge_slot_buffer=1.5,
)
expected_n = max(0, int(budget // per_slot))
push = _evening_battery_export_push_indices(
slots,
charge_acquisition_czk_kwh=0.5,
degrad_czk_kwh=0.15,
current_soc_wh=soc_for_budget,
min_soc_wh=floor,
soc_max_wh=bat.soc_max_wh,
per_slot_discharge_wh=per_slot,
discharge_slot_buffer=1.5,
)
self.assertEqual(len(push), expected_n)
self.assertEqual(push[:3], [0, 1, 2], "nejdražší sloty první")
self.assertNotIn(3, push)
# Více SoC → více push slotů (dynamicky, ne strop 3).
push_hi = _evening_battery_export_push_indices(
slots,
charge_acquisition_czk_kwh=0.5,
degrad_czk_kwh=0.15,
current_soc_wh=0.9 * bat.soc_max_wh,
min_soc_wh=bat.min_soc_wh,
soc_max_wh=bat.soc_max_wh,
per_slot_discharge_wh=per_slot,
discharge_slot_buffer=1.5,
)
self.assertGreaterEqual(len(push_hi), len(push))
class SlotsUntilSellNegativeTests(unittest.TestCase):
def test_slots_until_first_negative_sell(self) -> None:
base = datetime(2026, 4, 3, 0, 0, tzinfo=timezone.utc)
slots: list[PlanningSlot] = []
for i in range(10):
slots.append(
PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=1.0,
sell_price=2.0 if i < 4 else -0.5,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=500,
ev1_connected=False,
ev2_connected=False,
)
)
dist = _slots_until_sell_lt(slots, 0.0)
self.assertEqual(dist[0], 4)
self.assertEqual(dist[3], 1)
self.assertEqual(dist[4], 0)
def test_prewindow_deferral_prefers_sell_anchor(self) -> None:
"""Když existuje záporný prodej, kotva je vzdálenost k němu, ne k extrémnímu buy."""
base = datetime(2026, 4, 3, 0, 0, tzinfo=timezone.utc)
slots: list[PlanningSlot] = []
for i in range(8):
slots.append(
PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=-50.0,
sell_price=1.0 if i < 2 else -0.1,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=500,
ev1_connected=False,
ev2_connected=False,
)
)
adv = _prewindow_deferral_slots(slots, -2.0)
self.assertEqual(adv[0], 2)
def test_prewindow_deferral_falls_back_to_buy_when_no_negative_sell(self) -> None:
base = datetime(2026, 4, 3, 0, 0, tzinfo=timezone.utc)
slots: list[PlanningSlot] = []
for i in range(10):
slots.append(
PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=3.0 if i < 7 else -10.0,
sell_price=2.0,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=500,
ev1_connected=False,
ev2_connected=False,
)
)
adv = _prewindow_deferral_slots(slots, -2.0)
self.assertEqual(adv[0], 7)
class SlotsUntilBuyExtremeTests(unittest.TestCase):
def test_slots_until_first_extreme(self) -> None:
base = datetime(2026, 4, 3, 0, 0, tzinfo=timezone.utc)
slots: list[PlanningSlot] = []
for i in range(10):
slots.append(
PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=1.0,
sell_price=1.0,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=500,
ev1_connected=False,
ev2_connected=False,
)
)
slots[-1] = PlanningSlot(
interval_start=slots[-1].interval_start,
buy_price=-10.0,
sell_price=0.0,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=500,
ev1_connected=False,
ev2_connected=False,
)
dist = _slots_until_buy_le_threshold(slots, -2.0)
self.assertEqual(dist[0], 9)
self.assertEqual(dist[8], 1)
self.assertEqual(dist[9], 0)
def test_prewindow_clamps_relaxed_floor_until_close(self) -> None:
sm = [5000.0] * 10
dist = [9, 8, 7, 6, 5, 4, 3, 2, 1, 0] # obecná kotva (sell nebo buy)
panel = _soc_panel_min_wh_series(sm, dist, 10_000.0, 20_000.0, 2)
self.assertEqual(panel[0], 20_000.0)
self.assertEqual(panel[6], 20_000.0)
self.assertEqual(panel[7], 5000.0)
self.assertEqual(panel[9], 5000.0)
class DynamicArbFloorTests(unittest.TestCase):
def test_more_pv_ahead_lowers_floor(self) -> None:
"""Čím víc FVE ve lookahead, tím nižší ekonomická podlaha v prvním slotu."""
min_w = 1_000.0
base_w = 2_000.0
uc = 10_000.0
s0 = _slot()
s_low_pv = replace_slot(s0, pv_a=100, pv_b=0)
s_high_pv = replace_slot(s0, pv_a=50_000, pv_b=0)
ser_low = _dynamic_arb_floor_wh_series([s_low_pv] * 40, min_w, base_w, uc)
ser_high = _dynamic_arb_floor_wh_series([s_high_pv] * 40, min_w, base_w, uc)
self.assertLess(ser_high[0], ser_low[0])
self.assertGreaterEqual(ser_low[0], min_w)
self.assertLessEqual(ser_low[0], base_w)
def replace_slot(
s: PlanningSlot,
*,
pv_a: int | None = None,
pv_b: int | None = None,
load: int | None = None,
) -> PlanningSlot:
return PlanningSlot(
interval_start=s.interval_start,
buy_price=s.buy_price,
sell_price=s.sell_price,
pv_a_forecast_w=pv_a if pv_a is not None else s.pv_a_forecast_w,
pv_b_forecast_w=pv_b if pv_b is not None else s.pv_b_forecast_w,
load_baseline_w=load if load is not None else s.load_baseline_w,
ev1_connected=s.ev1_connected,
ev2_connected=s.ev2_connected,
is_predicted_price=s.is_predicted_price,
)
class PlanningDispatchMilpTests(unittest.TestCase):
def test_dispatch_result_comparison_marks_changed_slots(self) -> None:
dt = datetime(2026, 4, 3, 12, 0, tzinfo=timezone.utc)
active = [
DispatchResult(
interval_start=dt,
battery_setpoint_w=1000,
battery_soc_target=50.0,
grid_setpoint_w=0,
export_limit_w=0,
export_mode="NONE",
deye_physical_mode="PASSIVE",
deye_gen_cutoff_enabled=False,
ev1_setpoint_w=None,
ev2_setpoint_w=None,
ev1_via_bat_w=0,
ev2_via_bat_w=0,
heat_pump_enabled=False,
heat_pump_setpoint_w=0,
pv_a_curtailed_w=0,
expected_cost_czk=1.0,
effective_buy_price=1.0,
effective_sell_price=1.0,
is_predicted_price=False,
cashflow_czk=1.0,
battery_arbitrage_czk=0.0,
penalty_czk=0.0,
green_bonus_czk=0.0,
)
]
peer = [
DispatchResult(
interval_start=dt,
battery_setpoint_w=2000,
battery_soc_target=55.0,
grid_setpoint_w=-1000,
export_limit_w=1000,
export_mode="PV_SURPLUS",
deye_physical_mode="SELL",
deye_gen_cutoff_enabled=True,
ev1_setpoint_w=None,
ev2_setpoint_w=None,
ev1_via_bat_w=0,
ev2_via_bat_w=0,
heat_pump_enabled=False,
heat_pump_setpoint_w=0,
pv_a_curtailed_w=200,
expected_cost_czk=2.0,
effective_buy_price=1.0,
effective_sell_price=1.0,
is_predicted_price=False,
cashflow_czk=2.0,
battery_arbitrage_czk=0.0,
penalty_czk=0.0,
green_bonus_czk=0.0,
)
]
cmp = _dispatch_result_comparison(active, 10, "v1", peer, 12, "v2")
self.assertEqual(cmp["active"]["planner_version"], "v1")
self.assertEqual(cmp["peer"]["planner_version"], "v2")
self.assertEqual(cmp["diff"]["changed_slots"], 1)
self.assertEqual(len(cmp["slot_diffs"]), 1)
def test_planner_version_is_recorded_in_snapshot(self) -> None:
slots = [_slot(load=500, buy=1.0, sell=1.0, pv_a=0, pv_b=0) for _ in range(2)]
battery = _battery()
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
grid = SimpleNamespace(max_import_power_w=20_000, max_export_power_w=20_000)
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
results, _ms, snap = solve_dispatch(
slots,
battery,
hp,
grid,
[],
vehicles,
current_soc_wh=0.5 * battery.usable_capacity_wh,
current_tuv_temp_c=50.0,
planner_version="v2",
)
self.assertEqual(len(results), 2)
self.assertEqual(snap["inputs"]["planner_version"], "v2")
def test_neg_sell_with_future_neg_buy_prefers_curtail_pv_a_over_export(self) -> None:
"""
v25: sell<0 před buy<0 — PV A smí do baterie (bc_pv), ne export za záporný sell.
Curtail PV A (ca) až v okně buy<0 (slot 1).
"""
slots = [
_slot(load=0, buy=3.0, sell=-0.1, pv_a=5000, pv_b=0),
_slot(load=0, buy=-10.0, sell=1.0, pv_a=0, pv_b=5000),
]
battery = _battery(uc_wh=50_000.0)
hp = SimpleNamespace(
rated_heating_power_w=0,
tuv_min_temp_c=45.0,
tuv_target_temp_c=55.0,
)
grid = SimpleNamespace(max_import_power_w=20_000, max_export_power_w=20_000)
vehicles = [
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
]
soc0 = 0.50 * battery.usable_capacity_wh
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
soc0,
50.0,
tuv_delta_stats=None,
operating_mode="AUTO",
)
self.assertEqual(len(results), 2)
self.assertGreater(results[0].battery_setpoint_w, 500)
self.assertEqual(results[0].pv_a_curtailed_w, 0)
self.assertGreater(results[1].grid_setpoint_w, 1000)
def test_pv_surplus_export_uses_hard_export_cap(self) -> None:
slots = [
PlanningSlot(
interval_start=datetime(2026, 4, 3, 12, 0, tzinfo=timezone.utc),
buy_price=3.0,
sell_price=2.5,
pv_a_forecast_w=20_000,
pv_b_forecast_w=0,
load_baseline_w=0,
ev1_connected=False,
ev2_connected=False,
is_predicted_price=False,
allow_charge=False,
allow_discharge_export=False,
),
]
battery = _battery()
hp = SimpleNamespace(
rated_heating_power_w=0,
tuv_min_temp_c=45.0,
tuv_target_temp_c=55.0,
)
grid = SimpleNamespace(max_import_power_w=20_000, max_export_power_w=13_500)
vehicles = [
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
]
soc0 = battery.soc_max_wh
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
soc0,
50.0,
tuv_delta_stats=None,
operating_mode="AUTO",
)
self.assertEqual(len(results), 1)
self.assertEqual(results[0].export_mode, "PV_SURPLUS")
self.assertEqual(results[0].export_limit_w, 13_500)
self.assertGreater(results[0].pv_a_curtailed_w, 0)
def test_two_tier_soc_solves_optimal(self) -> None:
slots = [_slot()]
battery = _battery()
hp = SimpleNamespace(
rated_heating_power_w=0,
tuv_min_temp_c=45.0,
tuv_target_temp_c=55.0,
)
grid = SimpleNamespace(max_import_power_w=15_000, max_export_power_w=15_000)
vehicles = [
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
]
soc0 = 0.15 * battery.usable_capacity_wh
results, ms, _ = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
soc0,
50.0,
tuv_delta_stats=None,
operating_mode="AUTO",
)
self.assertGreaterEqual(ms, 0)
self.assertEqual(len(results), 1)
def test_deep_discharge_allows_covering_load_only(self) -> None:
slots = [
_slot(load=3000, buy=1.0, sell=6.0, pv_a=0, pv_b=0),
_slot(load=3000, buy=1.0, sell=6.0, pv_a=0, pv_b=0),
]
battery = _battery(uc_wh=50_000.0)
hp = SimpleNamespace(
rated_heating_power_w=0,
tuv_min_temp_c=45.0,
tuv_target_temp_c=55.0,
)
grid = SimpleNamespace(max_import_power_w=20_000, max_export_power_w=20_000)
vehicles = [
SimpleNamespace(
max_charge_power_w=11_000,
battery_capacity_kwh=50.0,
default_target_soc_pct=80.0,
),
SimpleNamespace(
max_charge_power_w=11_000,
battery_capacity_kwh=50.0,
default_target_soc_pct=80.0,
),
]
soc0 = 0.12 * battery.usable_capacity_wh
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
soc0,
50.0,
tuv_delta_stats=None,
operating_mode="AUTO",
)
self.assertEqual(len(results), 2)
def test_negative_buy_price_allows_import_for_baseline(self) -> None:
slots = [_slot(load=6000, buy=-0.5, sell=2.0)]
battery = _battery()
hp = SimpleNamespace(
rated_heating_power_w=8000,
tuv_min_temp_c=45.0,
tuv_target_temp_c=55.0,
)
grid = SimpleNamespace(max_import_power_w=25_000, max_export_power_w=15_000)
vehicles = [
SimpleNamespace(
max_charge_power_w=11_000,
battery_capacity_kwh=50.0,
default_target_soc_pct=80.0,
),
SimpleNamespace(
max_charge_power_w=11_000,
battery_capacity_kwh=50.0,
default_target_soc_pct=80.0,
),
]
soc0 = 0.5 * battery.usable_capacity_wh
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
soc0,
50.0,
tuv_delta_stats=None,
operating_mode="AUTO",
)
self.assertGreaterEqual(results[0].grid_setpoint_w, 0)
def test_export_implies_end_soc_at_least_reserve(self) -> None:
"""Bez arbitrážní relaxace: při ge >= 1 W musí koncové soc[t] >= arb_base_wh (rezerva z DB)."""
slots = [
_slot(load=500, buy=2.0, sell=8.0, pv_a=0, pv_b=0),
_slot(load=500, buy=2.0, sell=8.0, pv_a=0, pv_b=0),
]
battery = _battery(uc_wh=100_000.0, min_pct=10.0, arb_pct=20.0)
hp = SimpleNamespace(
rated_heating_power_w=0,
tuv_min_temp_c=45.0,
tuv_target_temp_c=55.0,
)
grid = SimpleNamespace(max_import_power_w=50_000, max_export_power_w=50_000)
vehicles = [
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
]
soc0 = 0.22 * battery.usable_capacity_wh
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
soc0,
50.0,
tuv_delta_stats=None,
operating_mode="AUTO",
)
reserve_pct = 20.0
for r in results:
if r.grid_setpoint_w < 0:
self.assertGreaterEqual(
r.battery_soc_target,
reserve_pct - 0.2,
msg="export slot must end at or above reserve SoC",
)
def test_export_before_extreme_negative_buy_can_end_below_reserve(self) -> None:
"""
Při relaxovaném soc_min (záporný buy v lookahead) smí významný export skončit u planner floor,
ne u provozní rezervy — jinak nejde ráno vypustit do sítě a nachystat kapacitu před levným nákupem.
"""
base = datetime(2026, 4, 3, 6, 0, tzinfo=timezone.utc)
s0 = PlanningSlot(
interval_start=base,
buy_price=2.5,
sell_price=2.0,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=400,
ev1_connected=False,
ev2_connected=False,
is_predicted_price=False,
allow_charge=True,
allow_discharge_export=True,
)
s1 = PlanningSlot(
interval_start=base + timedelta(minutes=15),
buy_price=-12.0,
sell_price=-0.5,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=400,
ev1_connected=False,
ev2_connected=False,
is_predicted_price=False,
allow_charge=True,
allow_discharge_export=True,
)
slots = [s0, s1]
battery = _battery(uc_wh=10_000.0, min_pct=10.0, arb_pct=20.0)
battery.planner_extreme_buy_threshold_czk_kwh = -2.0
battery.planner_discharge_floor_percent = 5.0
battery.max_charge_power_w = 50_000
battery.max_discharge_power_w = 50_000
hp = SimpleNamespace(
rated_heating_power_w=0,
tuv_min_temp_c=45.0,
tuv_target_temp_c=55.0,
)
grid = SimpleNamespace(max_import_power_w=50_000, max_export_power_w=50_000)
vehicles = [
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
]
soc0 = 0.88 * battery.usable_capacity_wh
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
soc0,
50.0,
tuv_delta_stats=None,
operating_mode="AUTO",
)
self.assertEqual(len(results), 2)
if results[0].grid_setpoint_w < 0:
self.assertLess(
results[0].battery_soc_target,
22.0,
msg="with relaxed soc_min, morning export should finish below reserve %",
)
def test_negative_sell_forbids_battery_export_arbitrage(self) -> None:
"""
Pokud sell < 0, solver nesmí vybíjet baterii do sítě pro arbitráž (dump musí proběhnout předtím).
V okně sell<0 smí export vzniknout jen z přebytku FVE; zde ale FVE=0, takže očekáváme grid_setpoint>=0.
"""
base = datetime(2026, 4, 3, 6, 0, tzinfo=timezone.utc)
s0 = PlanningSlot(
interval_start=base,
buy_price=2.0,
sell_price=2.0,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=0,
ev1_connected=False,
ev2_connected=False,
is_predicted_price=False,
allow_charge=True,
allow_discharge_export=True,
)
s1 = PlanningSlot(
interval_start=base + timedelta(minutes=15),
buy_price=2.0,
sell_price=-0.5,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=0,
ev1_connected=False,
ev2_connected=False,
is_predicted_price=False,
allow_charge=True,
allow_discharge_export=True,
)
s2 = PlanningSlot(
interval_start=base + timedelta(minutes=30),
buy_price=-15.0,
sell_price=-1.0,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=0,
ev1_connected=False,
ev2_connected=False,
is_predicted_price=False,
allow_charge=True,
allow_discharge_export=True,
)
slots = [s0, s1, s2]
battery = _battery(uc_wh=10_000.0, min_pct=10.0, arb_pct=20.0)
battery.planner_extreme_buy_threshold_czk_kwh = -2.0
battery.planner_discharge_floor_percent = 5.0
battery.max_charge_power_w = 50_000
battery.max_discharge_power_w = 50_000
hp = SimpleNamespace(
rated_heating_power_w=0,
tuv_min_temp_c=45.0,
tuv_target_temp_c=55.0,
)
grid = SimpleNamespace(max_import_power_w=50_000, max_export_power_w=50_000)
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
soc0 = 0.9 * battery.usable_capacity_wh
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
soc0,
50.0,
tuv_delta_stats=None,
operating_mode="AUTO",
)
self.assertEqual(len(results), 3)
# V sell<0 slotu bez FVE a bez zátěže nesmí být export (to by muselo být z baterie).
self.assertGreaterEqual(results[1].grid_setpoint_w, 0)
# A zároveň nesmí být baterie ve výboji (dump musí proběhnout předtím).
self.assertGreaterEqual(results[1].battery_setpoint_w, 0)
def test_anchor_hits_floor_before_first_negative_sell(self) -> None:
"""
v25: před buy<0 — SoC u posledního sell≥0 blízko max, před prvním buy<0 pod stropem
(_pre_neg_buy_soc_ceiling_wh), ne kotva na planner floor před sell<0.
"""
base = datetime(2026, 4, 3, 6, 0, tzinfo=timezone.utc)
# Slot 0-1: sell >= 0; slot 2: první sell < 0; slot 3: extrémně záporný buy (motivace k bufferu).
slots = [
PlanningSlot(
interval_start=base,
buy_price=3.0,
sell_price=1.0,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=0,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=True,
),
PlanningSlot(
interval_start=base + timedelta(minutes=15),
buy_price=3.0,
sell_price=0.5,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=0,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=True,
),
PlanningSlot(
interval_start=base + timedelta(minutes=30),
buy_price=3.0,
sell_price=-0.2,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=0,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=True,
),
PlanningSlot(
interval_start=base + timedelta(minutes=45),
buy_price=-20.0,
sell_price=-1.0,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=0,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=True,
),
]
battery = _battery(uc_wh=20_000.0, min_pct=10.0, arb_pct=20.0)
battery.planner_extreme_buy_threshold_czk_kwh = -2.0
battery.planner_discharge_floor_percent = 5.0
battery.max_charge_power_w = 50_000
battery.max_discharge_power_w = 50_000
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
grid = SimpleNamespace(max_import_power_w=50_000, max_export_power_w=50_000)
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
soc0 = 0.9 * battery.usable_capacity_wh
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
soc0,
50.0,
tuv_delta_stats=None,
operating_mode="AUTO",
)
last_pos = 1
pre_buy = 2
self.assertGreaterEqual(
results[last_pos].battery_soc_target or 0,
60.0,
msg="poslední sell≥0 před buy<0: směr k plné baterii (bez exportu)",
)
self.assertLessEqual(
results[pre_buy].battery_soc_target or 100.0,
75.0,
msg="slot před buy<0: rezerva pro import v buy<0 okně",
)
def test_anchor_uses_planner_floor_even_without_extreme_buy(self) -> None:
"""
v25: bez buy<0 v horizontu — žádný strop před buy<0; poslední sell≥0 může držet vysoké SoC.
"""
base = datetime(2026, 4, 3, 6, 0, tzinfo=timezone.utc)
slots = [
PlanningSlot(
interval_start=base,
buy_price=3.0,
sell_price=3.06,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=0,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=True,
),
PlanningSlot(
interval_start=base + timedelta(minutes=15),
buy_price=3.0,
sell_price=2.0,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=0,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=True,
),
PlanningSlot(
interval_start=base + timedelta(minutes=30),
buy_price=3.0,
sell_price=-0.2,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=0,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=True,
),
]
battery = _battery(uc_wh=20_000.0, min_pct=12.0, arb_pct=20.0)
battery.planner_extreme_buy_threshold_czk_kwh = -2.0
battery.planner_discharge_floor_percent = 5.0
battery.max_charge_power_w = 50_000
battery.max_discharge_power_w = 50_000
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
grid = SimpleNamespace(max_import_power_w=50_000, max_export_power_w=50_000)
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
soc0 = 0.9 * battery.usable_capacity_wh
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
soc0,
50.0,
tuv_delta_stats=None,
operating_mode="AUTO",
)
self.assertEqual(results[0].grid_setpoint_w, 0)
self.assertEqual(results[0].battery_setpoint_w, 0)
def test_grid_import_soft_cap_penalizes_breaker_overdraw(self) -> None:
"""
Soft cap: solver může nominálně překročit breaker, ale jen pokud se to vyplatí.
Při běžné (nezáporné) nákupní ceně by měl držet import <= breaker.
"""
slots = [_slot(load=3700, buy=0.4, sell=-0.3, pv_a=0, pv_b=1500)]
battery = _battery(uc_wh=64_000.0, min_pct=12.0, arb_pct=20.0)
battery.max_charge_power_w = 18_000
battery.max_discharge_power_w = 18_000
hp = SimpleNamespace(
rated_heating_power_w=0,
tuv_min_temp_c=45.0,
tuv_target_temp_c=55.0,
)
grid = SimpleNamespace(max_import_power_w=17_000, max_export_power_w=13_500)
vehicles = [
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
]
soc0 = 0.55 * battery.usable_capacity_wh
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
soc0,
50.0,
tuv_delta_stats=None,
operating_mode="AUTO",
)
self.assertEqual(len(results), 1)
self.assertLessEqual(
results[0].grid_setpoint_w,
grid.max_import_power_w,
msg="soft cap: for normal buy price, planned grid import should not exceed breaker",
)
def test_grid_import_soft_cap_allows_overdraw_when_extremely_negative(self) -> None:
"""
Regrese: při extrémně záporné nákupní ceně může solver překročit breaker (za cenu penalizace),
aby stihl krátké okno nabíjení. Překročení nesmí být 'zadarmo' (kontrolujeme alespoň, že existuje).
"""
# Dvouslotový scénář: v 1. slotu extrémně záporná cena, ve 2. slotu drahá.
# Terminal SoC kotva pak nepenalizuje držení energie (průměrná buy je ~0) a solver má motivaci
# v 1. slotu nabít na max, i kdyby to znamenalo malé překročení breakeru.
s0 = _slot(load=0, buy=-20.0, sell=-0.3, pv_a=0, pv_b=0)
s1 = replace_slot(s0, load=0)
s1 = PlanningSlot(
interval_start=s0.interval_start + timedelta(minutes=15),
buy_price=20.0,
sell_price=-0.3,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=0,
ev1_connected=False,
ev2_connected=False,
is_predicted_price=False,
)
slots = [s0, s1]
battery = _battery(uc_wh=64_000.0, min_pct=12.0, arb_pct=20.0)
battery.max_charge_power_w = 18_000
battery.max_discharge_power_w = 18_000
hp = SimpleNamespace(
rated_heating_power_w=0,
tuv_min_temp_c=45.0,
tuv_target_temp_c=55.0,
)
grid = SimpleNamespace(max_import_power_w=17_000, max_export_power_w=13_500)
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
soc0 = 0.15 * battery.usable_capacity_wh
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
soc0,
50.0,
tuv_delta_stats=None,
operating_mode="AUTO",
)
self.assertEqual(len(results), 2)
self.assertGreater(
results[0].battery_setpoint_w + max(0, results[0].grid_setpoint_w),
2_000,
msg="záporný buy má vést k nabíjení baterie nebo importu",
)
def test_block_export_on_negative_sell_no_grid_export_pv_surplus(self) -> None:
"""site_grid_connection.block_export_on_negative_sell → ge=0 při sell<0."""
slots = [
PlanningSlot(
interval_start=datetime(2026, 4, 3, 12, 0, tzinfo=timezone.utc),
buy_price=5.25,
sell_price=-0.5,
pv_a_forecast_w=7000,
pv_b_forecast_w=0,
load_baseline_w=500,
ev1_connected=False,
ev2_connected=False,
is_predicted_price=False,
allow_charge=True,
allow_discharge_export=False,
)
]
battery = _battery(uc_wh=20_000.0, arb_pct=15.0, max_pct=95.0)
hp = SimpleNamespace(
rated_heating_power_w=0,
tuv_min_temp_c=45.0,
tuv_target_temp_c=55.0,
)
grid = SimpleNamespace(
max_import_power_w=17_000,
max_export_power_w=8000,
block_export_on_negative_sell=True,
)
vehicles = [
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
]
soc0 = 0.34 * battery.usable_capacity_wh
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
soc0,
50.0,
tuv_delta_stats=None,
operating_mode="AUTO",
)
self.assertEqual(len(results), 1)
self.assertGreaterEqual(results[0].grid_setpoint_w, 0, "no grid export")
self.assertGreater(results[0].battery_setpoint_w, 0, "surplus PV should charge")
class NegativeSellPvChargeTests(unittest.TestCase):
"""BA81: při sell<0 a velké FVE A má jít výkon do baterie, ne do curtailment."""
def test_negative_sell_charges_near_max_in_each_morning_slot(self) -> None:
"""Více slotů sell<0 za sebou — každý má jít ~max_charge, ne jen první."""
base = datetime(2026, 5, 24, 6, 0, tzinfo=timezone.utc)
slots: list[PlanningSlot] = []
for i in range(6):
slots.append(
PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=3.088,
sell_price=-0.5,
pv_a_forecast_w=12_000,
pv_b_forecast_w=0,
load_baseline_w=400,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=False,
)
)
battery = _battery(uc_wh=12_500.0, terminal_soc_value_factor=0.2)
battery.max_charge_power_w = 6_250
battery.max_discharge_power_w = 6_250
hp = SimpleNamespace(
rated_heating_power_w=0,
tuv_min_temp_c=45.0,
tuv_target_temp_c=55.0,
)
grid = SimpleNamespace(
max_import_power_w=17_000,
max_export_power_w=16_000,
block_export_on_negative_sell=False,
)
vehicles = [
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
]
soc0 = 0.30 * battery.usable_capacity_wh
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
soc0,
50.0,
operating_mode="AUTO",
)
high_power = [r.battery_setpoint_w for r in results if r.battery_setpoint_w > 5_500]
self.assertGreaterEqual(
len(high_power),
4,
f"očekáváno ≥4/6 slotů na ~max_charge, got {[r.battery_setpoint_w for r in results]}",
)
def test_negative_sell_prefers_full_pv_charge_over_curtail(self) -> None:
slots = [
PlanningSlot(
interval_start=datetime(2026, 5, 24, 9, 0, tzinfo=timezone.utc),
buy_price=3.088,
sell_price=-0.9,
pv_a_forecast_w=13_500,
pv_b_forecast_w=0,
load_baseline_w=500,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=False,
)
]
battery = _battery(uc_wh=12_500.0, terminal_soc_value_factor=0.2)
battery.max_charge_power_w = 6_250
battery.max_discharge_power_w = 6_250
hp = SimpleNamespace(
rated_heating_power_w=0,
tuv_min_temp_c=45.0,
tuv_target_temp_c=55.0,
)
grid = SimpleNamespace(
max_import_power_w=17_000,
max_export_power_w=16_000,
block_export_on_negative_sell=False,
)
vehicles = [
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
]
soc0 = 0.33 * battery.usable_capacity_wh
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
soc0,
50.0,
operating_mode="AUTO",
)
r0 = results[0]
self.assertGreater(
r0.battery_setpoint_w,
5_500,
"při sell<0 a PV≈13 kW má baterie nabíjet blízko max_charge (6,25 kW)",
)
# Přebytek nad max_charge jde do curtail (ne ~3 kW nabíjení + 9 kW curtail při plné baterii).
self.assertGreater(
r0.battery_setpoint_w,
r0.pv_a_curtailed_w * 0.5,
"nabíjení má dominovat nad curtailmentem",
)
def test_negative_sell_charges_from_plateau_soc_without_allow_charge_mask(self) -> None:
"""BA81: allow_charge=false z DB nesmí vypnout shortfall — charge_slots z sell<0 + PV."""
base = datetime(2026, 5, 24, 4, 15, tzinfo=timezone.utc)
slots: list[PlanningSlot] = []
for i in range(6):
h = 6 + (i * 15) // 60
m = (i * 15) % 60
hour_f = max(0.0, min(1.0, (h + m / 60.0 - 6.0) / 14.0))
safety = 3750.0 + 2500.0 * hour_f
slots.append(
PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=3.088,
sell_price=-0.3,
pv_a_forecast_w=9000,
pv_b_forecast_w=800,
load_baseline_w=150,
ev1_connected=False,
ev2_connected=False,
allow_charge=False,
allow_discharge_export=False,
safety_soc_target_wh=safety,
is_daytime_pv_surplus_slot=True,
future_sell_opportunity_czk_kwh=3.7,
)
)
battery = _battery(uc_wh=12_500.0, terminal_soc_value_factor=0.2)
battery.max_charge_power_w = 6_250
battery.max_discharge_power_w = 6_250
hp = SimpleNamespace(
rated_heating_power_w=0,
tuv_min_temp_c=45.0,
tuv_target_temp_c=55.0,
)
grid = SimpleNamespace(
max_import_power_w=17_000,
max_export_power_w=16_000,
block_export_on_negative_sell=False,
)
vehicles = [
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
]
soc0 = 0.508 * battery.usable_capacity_wh
results, _ms, snap = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
soc0,
50.0,
operating_mode="AUTO",
)
self.assertEqual(snap.get("planner_build_tag"), PLANNER_BUILD_TAG)
self.assertGreater(
results[0].battery_setpoint_w,
2_500,
f"první sell<0 slot má nabíjet z PV, got {[r.battery_setpoint_w for r in results]}",
)
self.assertGreaterEqual(
max(r.battery_soc_target for r in results),
round(float(battery.soc_max_wh) / battery.usable_capacity_wh * 100, 1) - 0.5,
"neg okno má dobít na planner soc_max, ne ~92 %",
)
def test_fixed_tariff_evening_export_when_sell_above_buy(self) -> None:
"""BA81: sell 3,7 > buy 3,088 musí exportovat (acq 3,61 + 0,3 by dříve blokovalo)."""
slots = [
PlanningSlot(
interval_start=datetime(2026, 5, 24, 17, 0, tzinfo=timezone.utc),
buy_price=3.088,
sell_price=3.75,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=400,
ev1_connected=False,
ev2_connected=False,
allow_charge=False,
allow_discharge_export=True,
charge_acquisition_buy_czk_kwh=3.613,
)
]
battery = _battery(uc_wh=12_500.0, terminal_soc_value_factor=0.0)
battery.max_discharge_power_w = 6_250
battery.planner_daytime_charge_target_enabled = False
hp = SimpleNamespace(
rated_heating_power_w=0,
tuv_min_temp_c=45.0,
tuv_target_temp_c=55.0,
)
grid = SimpleNamespace(
max_import_power_w=17_000,
max_export_power_w=16_000,
block_export_on_negative_sell=False,
purchase_pricing_mode="fixed",
sale_pricing_mode="spot",
)
vehicles = [
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
]
soc0 = 0.95 * battery.usable_capacity_wh
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
soc0,
50.0,
operating_mode="AUTO",
)
r0 = results[0]
export_w = max(0, -r0.grid_setpoint_w) + max(0, -r0.battery_setpoint_w)
self.assertGreater(
export_w,
0,
"kladný sell>buy: alespoň částečný výdej (jednoslotový horizont — plný push až v integračním testu)",
)
def test_fixed_tariff_post_neg_pv_b_full_soc_feasible(self) -> None:
"""BA81: plná baterie + sell<0 + odpoledne pv_b — ge_pv==0 z pv_store dříve dělalo Infeasible."""
slots = [
PlanningSlot(
interval_start=datetime(2026, 5, 24, 6, 0, tzinfo=timezone.utc)
+ timedelta(minutes=15 * i),
buy_price=3.088,
sell_price=-0.8,
pv_a_forecast_w=12_000,
pv_b_forecast_w=0,
load_baseline_w=500,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=False,
charge_acquisition_buy_czk_kwh=3.61,
)
for i in range(6)
]
slots.append(
PlanningSlot(
interval_start=datetime(2026, 5, 24, 12, 0, tzinfo=timezone.utc),
buy_price=3.088,
sell_price=3.2,
pv_a_forecast_w=0,
pv_b_forecast_w=2_500,
load_baseline_w=500,
ev1_connected=False,
ev2_connected=False,
allow_charge=False,
allow_discharge_export=False,
charge_acquisition_buy_czk_kwh=3.61,
future_sell_opportunity_czk_kwh=3.76,
)
)
battery = _battery(uc_wh=12_500.0, terminal_soc_value_factor=0.0)
battery.max_charge_power_w = 6_250
battery.max_discharge_power_w = 6_250
battery.degradation_cost_czk_kwh = 0.3
battery.planner_daytime_charge_target_enabled = False
hp = SimpleNamespace(
rated_heating_power_w=0,
tuv_min_temp_c=45.0,
tuv_target_temp_c=55.0,
)
grid = SimpleNamespace(
max_import_power_w=17_000,
max_export_power_w=16_000,
block_export_on_negative_sell=False,
)
vehicles = [
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
]
soc0 = 0.95 * battery.usable_capacity_wh
results, _ms, snap = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
soc0,
50.0,
operating_mode="AUTO",
)
self.assertEqual(snap.get("planner_build_tag"), PLANNER_BUILD_TAG)
self.assertEqual(len(results), len(slots))
def test_gen_cutoff_full_soc_neg_sell_with_pv_b_feasible(self) -> None:
"""BA81: 100 % SoC + sell<0 + GEN cut-off — dříve ge==0 → Infeasible."""
slots = [
PlanningSlot(
interval_start=datetime(2026, 5, 24, 9, 0, tzinfo=timezone.utc)
+ timedelta(minutes=15 * i),
buy_price=3.088,
sell_price=-1.5,
pv_a_forecast_w=8_000,
pv_b_forecast_w=2_800,
load_baseline_w=500,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=False,
charge_acquisition_buy_czk_kwh=3.61,
is_daytime_pv_surplus_slot=True,
safety_soc_target_wh=6_250.0,
)
for i in range(8)
]
battery = _battery(uc_wh=12_500.0, terminal_soc_value_factor=0.0)
battery.soc_max_wh = 12_500.0
battery.max_charge_power_w = 6_250
battery.max_discharge_power_w = 6_250
battery.degradation_cost_czk_kwh = 0.3
battery.planner_daytime_charge_target_enabled = True
hp = SimpleNamespace(
rated_heating_power_w=0,
tuv_min_temp_c=0.0,
tuv_target_temp_c=55.0,
)
grid = SimpleNamespace(
max_import_power_w=17_000,
max_export_power_w=16_000,
block_export_on_negative_sell=False,
deye_gen_microinverter_cutoff_enabled=True,
)
vehicles = [
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
]
results, _ms, snap = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
12_500.0,
55.0,
operating_mode="AUTO",
)
self.assertEqual(snap.get("planner_build_tag"), PLANNER_BUILD_TAG)
self.assertEqual(len(results), len(slots))
def test_fixed_tariff_neg_sell_no_grid_export(self) -> None:
"""BA81: sell<0 nesmí vést do sítě (záporná výkupní cena) — jen nabíjení/curtail."""
slots = [
PlanningSlot(
interval_start=datetime(2026, 5, 25, 7, 0, tzinfo=timezone.utc)
+ timedelta(minutes=15 * i),
buy_price=3.088,
sell_price=-0.5,
pv_a_forecast_w=10_000,
pv_b_forecast_w=2_500,
load_baseline_w=300,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=False,
charge_acquisition_buy_czk_kwh=3.61,
)
for i in range(4)
]
battery = _battery(uc_wh=12_500.0, terminal_soc_value_factor=0.0)
battery.soc_max_wh = 12_500.0
battery.max_charge_power_w = 6_250
grid = SimpleNamespace(
max_import_power_w=17_000,
max_export_power_w=16_000,
block_export_on_negative_sell=False,
deye_gen_microinverter_cutoff_enabled=True,
purchase_pricing_mode="fixed",
sale_pricing_mode="spot",
)
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=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
]
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
8_000.0,
50.0,
operating_mode="AUTO",
)
for r in results:
self.assertGreaterEqual(r.battery_setpoint_w, 0, "neg sell má nabíjet")
self.assertGreaterEqual(r.grid_setpoint_w, 0, "neg sell bez exportu do sítě")
def test_ba81_fixed_purchase_nt_vt_buy_spread_neg_sell_no_export(self) -> None:
"""BA81: NT/VT buy v horizontu (rozptyl >0,25) — záporný sell stále bez exportu."""
slots = [
PlanningSlot(
interval_start=datetime(2026, 5, 25, 7, 0, tzinfo=timezone.utc)
+ timedelta(minutes=15 * i),
buy_price=3.088 if i % 2 == 0 else 4.086,
sell_price=-0.5,
pv_a_forecast_w=10_000,
pv_b_forecast_w=2_500,
load_baseline_w=300,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=False,
charge_acquisition_buy_czk_kwh=3.61,
)
for i in range(4)
]
self.assertGreater(
max(s.buy_price for s in slots) - min(s.buy_price for s in slots),
0.25,
)
battery = _battery(uc_wh=12_500.0, terminal_soc_value_factor=0.0)
battery.soc_max_wh = 12_500.0
battery.max_charge_power_w = 6_250
grid = SimpleNamespace(
max_import_power_w=17_000,
max_export_power_w=16_000,
block_export_on_negative_sell=False,
deye_gen_microinverter_cutoff_enabled=True,
purchase_pricing_mode="fixed",
sale_pricing_mode="spot",
)
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=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
]
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
8_000.0,
50.0,
operating_mode="AUTO",
)
for r in results:
self.assertGreaterEqual(r.grid_setpoint_w, 0)
class AutoPvSurplusExportTests(unittest.TestCase):
"""Plná baterie + vysoká FVE: export přebytku (ge_pv), ne curtailment, bez SELL."""
def test_pv_surplus_exports_when_battery_export_disallowed(self) -> None:
slots = [
PlanningSlot(
interval_start=datetime(2026, 5, 17, 10, 0, tzinfo=timezone.utc),
buy_price=1.20,
sell_price=0.80,
pv_a_forecast_w=0,
pv_b_forecast_w=12_000,
load_baseline_w=2000,
ev1_connected=False,
ev2_connected=False,
is_predicted_price=False,
allow_charge=False,
allow_discharge_export=False,
),
]
battery = _battery(uc_wh=20_000.0, min_pct=10.0, arb_pct=20.0)
battery.planner_terminal_soc_value_factor = 0.0
battery.planner_daytime_charge_target_enabled = False
hp = SimpleNamespace(
rated_heating_power_w=0,
tuv_min_temp_c=45.0,
tuv_target_temp_c=55.0,
)
grid = SimpleNamespace(max_import_power_w=17_000, max_export_power_w=8000)
vehicles = [
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
]
soc0 = 0.95 * battery.soc_max_wh
results, _, _ = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
soc0,
50.0,
tuv_delta_stats=None,
operating_mode="AUTO",
)
self.assertLess(results[0].grid_setpoint_w, 0, "PV surplus should export to grid")
self.assertEqual(results[0].deye_physical_mode, "PASSIVE")
self.assertEqual(results[0].export_mode, "PV_SURPLUS")
self.assertLess(results[0].pv_a_curtailed_w, 5000, "should not curtail all PV")
class AutoPassiveSelfConsumptionTests(unittest.TestCase):
"""AUTO bez allow_discharge_export: vlastní spotřeba, ne export do sítě."""
def test_expensive_slot_prefers_battery_over_grid_import(self) -> None:
base = datetime(2026, 5, 16, 22, 0, tzinfo=timezone.utc)
slots = [
PlanningSlot(
interval_start=base,
buy_price=4.80,
sell_price=2.90,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=1200,
ev1_connected=False,
ev2_connected=False,
is_predicted_price=False,
allow_charge=False,
allow_discharge_export=False,
),
PlanningSlot(
interval_start=base + timedelta(minutes=15),
buy_price=0.50,
sell_price=-0.20,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=1200,
ev1_connected=False,
ev2_connected=False,
is_predicted_price=False,
allow_charge=True,
allow_discharge_export=False,
),
]
battery = _battery(uc_wh=20_000.0, min_pct=10.0, arb_pct=20.0)
battery.planner_terminal_soc_value_factor = 0.0
battery.planner_daytime_charge_target_enabled = False
hp = SimpleNamespace(
rated_heating_power_w=0,
tuv_min_temp_c=45.0,
tuv_target_temp_c=55.0,
)
grid = SimpleNamespace(max_import_power_w=17_000, max_export_power_w=8000)
vehicles = [
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
]
soc0 = 0.23 * battery.usable_capacity_wh
results, _, _ = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
soc0,
50.0,
tuv_delta_stats=None,
operating_mode="AUTO",
)
self.assertLess(
results[0].battery_setpoint_w,
0,
msg="expensive slot should discharge for self-consumption before cheap charge",
)
self.assertLessEqual(
results[0].grid_setpoint_w,
0,
msg="expensive slot: baseline load ze baterie, ne import ze sítě",
)
self.assertEqual(results[0].deye_physical_mode, "PASSIVE")
def test_fixed_tariff_expensive_slot_discharges_not_grid_load(self) -> None:
"""KV1 typ: konstantní buy — porovnání vůči charge_acquisition, ne min(buy)."""
slots = [
PlanningSlot(
interval_start=datetime(2026, 5, 21, 22, 0, tzinfo=timezone.utc),
buy_price=6.35,
sell_price=2.0,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=320,
ev1_connected=False,
ev2_connected=False,
allow_charge=False,
allow_discharge_export=False,
charge_acquisition_buy_czk_kwh=0.55,
)
]
battery = _battery(uc_wh=20_000.0, min_pct=10.0, arb_pct=20.0)
battery.planner_terminal_soc_value_factor = 0.0
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
grid = SimpleNamespace(max_import_power_w=17_000, max_export_power_w=8000)
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
soc0 = 0.4 * battery.usable_capacity_wh
results, _, _ = solve_dispatch(
slots, battery, hp, grid, [None, None], vehicles, soc0, 50.0, operating_mode="AUTO"
)
self.assertLessEqual(results[0].grid_setpoint_w, 0)
self.assertLess(results[0].battery_setpoint_w, -100)
def test_expensive_slot_uses_hp_variable_not_rated(self) -> None:
"""Regrese: bd+pv_ld >= load+hp[t], ne load+hp_rated (jinak Infeasible bez PV)."""
slots = [
PlanningSlot(
interval_start=datetime(2026, 5, 22, 20, 0, tzinfo=timezone.utc),
buy_price=3.0,
sell_price=2.0,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=1961,
ev1_connected=False,
ev2_connected=False,
allow_charge=False,
allow_discharge_export=False,
charge_acquisition_buy_czk_kwh=0.52,
),
PlanningSlot(
interval_start=datetime(2026, 5, 22, 20, 15, tzinfo=timezone.utc),
buy_price=-5.0,
sell_price=2.0,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=1961,
ev1_connected=False,
ev2_connected=False,
allow_charge=False,
allow_discharge_export=False,
charge_acquisition_buy_czk_kwh=0.52,
),
]
battery = _battery(uc_wh=64_000.0, min_pct=12.0, arb_pct=20.0)
battery.planner_terminal_soc_value_factor = 0.0
hp = SimpleNamespace(rated_heating_power_w=3500, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
grid = SimpleNamespace(max_import_power_w=17_000, max_export_power_w=13_500)
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
results, _, _ = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
20_000.0,
50.0,
operating_mode="AUTO",
)
self.assertEqual(len(results), 2)
def test_negative_buy_in_horizon_does_not_block_all_grid_import(self) -> None:
"""Jeden slot buy<0 nesmí z min(buy) udělat všechny sloty expensive_import (gi=0 pro dům)."""
base = datetime(2026, 5, 22, 13, 15, tzinfo=timezone.utc)
slots = [
PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=-0.54 if i == 15 else (0.8 + i * 0.05),
sell_price=-0.06 if i < 3 else 2.0,
pv_a_forecast_w=0,
pv_b_forecast_w=max(0, 5000 - i * 100) if i < 25 else 0,
load_baseline_w=5316 if i < 10 else 3392,
ev1_connected=False,
ev2_connected=False,
allow_charge=(i == 15 or i < 3),
allow_discharge_export=False,
charge_acquisition_buy_czk_kwh=0.94,
future_sell_opportunity_czk_kwh=5.5,
)
for i in range(20)
]
battery = _battery(uc_wh=64_000.0, min_pct=12.0, arb_pct=20.0)
battery.planner_terminal_soc_value_factor = 0.0
battery.planner_discharge_floor_percent = 5.0
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
grid = SimpleNamespace(max_import_power_w=17_000, max_export_power_w=13_500)
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
results, _, _ = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
float(battery.soc_max_wh),
50.0,
operating_mode="AUTO",
)
self.assertEqual(len(results), 20)
def test_spot_low_acquisition_does_not_mark_all_slots_expensive(self) -> None:
"""Spot + charge_acquisition ~0,9 nesmí z buy>acq udělat gi=0 pro dům ve všech slotech."""
base = datetime(2026, 5, 22, 10, 0, tzinfo=timezone.utc)
slots = [
PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=2.5 + 0.1 * i,
sell_price=3.0,
pv_a_forecast_w=2000,
pv_b_forecast_w=3000,
load_baseline_w=2000,
ev1_connected=False,
ev2_connected=False,
allow_charge=False,
allow_discharge_export=False,
charge_acquisition_buy_czk_kwh=0.94,
future_sell_opportunity_czk_kwh=5.5,
)
for i in range(24)
]
battery = _battery(uc_wh=64_000.0, min_pct=12.0, arb_pct=20.0)
battery.planner_terminal_soc_value_factor = 0.0
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
grid = SimpleNamespace(max_import_power_w=17_000, max_export_power_w=13_500)
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
results, _, _ = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
30_000.0,
50.0,
operating_mode="AUTO",
)
self.assertEqual(len(results), 24)
class AutoPassiveNoLoadFollowingDischargeTests(unittest.TestCase):
"""AUTO bez allow_discharge_export: žádný export do sítě (Deye PASSIVE)."""
def test_no_grid_export_on_inflated_baseline_without_discharge_mask(self) -> None:
slots = [
PlanningSlot(
interval_start=datetime(2026, 5, 16, 9, 45, tzinfo=timezone.utc),
buy_price=0.77,
sell_price=0.09,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=8542,
ev1_connected=False,
ev2_connected=False,
is_predicted_price=False,
allow_charge=False,
allow_discharge_export=False,
)
]
battery = _battery(uc_wh=20_000.0, min_pct=10.0, arb_pct=20.0)
hp = SimpleNamespace(
rated_heating_power_w=0,
tuv_min_temp_c=45.0,
tuv_target_temp_c=55.0,
)
grid = SimpleNamespace(max_import_power_w=17_000, max_export_power_w=8000)
vehicles = [
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
]
soc0 = 0.45 * battery.usable_capacity_wh
results, _, _ = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
soc0,
50.0,
tuv_delta_stats=None,
operating_mode="AUTO",
)
self.assertEqual(len(results), 1)
self.assertEqual(results[0].deye_physical_mode, "PASSIVE")
self.assertGreaterEqual(
results[0].grid_setpoint_w,
0,
msg="must not export to grid when allow_discharge_export=false",
)
class TerminalSocShadowTests(unittest.TestCase):
"""Terminal SoC shadow price v objective drží konec horizontu nad holým minimem."""
def test_terminal_soc_shadow_price_prevents_drain(self) -> None:
base = datetime(2026, 4, 3, 12, 0, tzinfo=timezone.utc)
slots = []
for i in range(3):
slots.append(
PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=2.0,
sell_price=0.6,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=600,
ev1_connected=False,
ev2_connected=False,
is_predicted_price=False,
)
)
slots.append(
PlanningSlot(
interval_start=base + timedelta(minutes=45),
buy_price=2.0,
sell_price=14.0,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=600,
ev1_connected=False,
ev2_connected=False,
is_predicted_price=False,
)
)
battery = _battery(uc_wh=12_000.0, min_pct=12.0, arb_pct=20.0)
hp = SimpleNamespace(
rated_heating_power_w=0,
tuv_min_temp_c=45.0,
tuv_target_temp_c=55.0,
)
grid = SimpleNamespace(max_import_power_w=20_000, max_export_power_w=20_000)
vehicles = [
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
]
soc0 = 0.5 * battery.usable_capacity_wh
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
soc0,
50.0,
tuv_delta_stats=None,
operating_mode="AUTO",
)
self.assertEqual(len(results), 4)
# Bez shadow price by solver mohl končit u min SoC; kotva drží znatelnou rezervu.
self.assertGreaterEqual(
results[-1].battery_soc_target,
15.0,
msg="terminal SoC shadow price should keep end-of-horizon SoC above bare minimum",
)
class SpreadGuardHome01EconomicsTests(unittest.TestCase):
"""Regrese: sell≪buy (VT) nesmí vést k PV exportu + masivnímu grid importu ve stejném slotu."""
def test_loss_making_morning_and_vt_slot_avoid_export_and_grid_charge(self) -> None:
from test_planning_charge_slot_selection import (
_battery as mask_battery,
_select_charge_slots,
_select_discharge_export_slots,
)
base = datetime(2026, 5, 21, 8, 0, tzinfo=timezone.utc)
raw: list[tuple[float, float, int, int]] = [
(1.55, 0.01, 6_000, 2_000),
(1.55, 0.01, 6_500, 2_000),
(1.49, -0.04, 0, 3_500),
(0.86, 0.01, 0, 3_500),
(0.86, 0.01, 0, 3_500),
(0.86, 0.01, 5_000, 2_000),
]
slots: list[PlanningSlot] = []
for i, (buy, sell, pv, load) in enumerate(raw):
slots.append(
PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=buy,
sell_price=sell,
pv_a_forecast_w=0,
pv_b_forecast_w=pv,
load_baseline_w=load,
ev1_connected=False,
ev2_connected=False,
is_predicted_price=False,
)
)
mb = mask_battery(uc_wh=64_000.0)
soc0 = 0.31 * mb.usable_capacity_wh
charge = _select_charge_slots(slots, mb, soc0)
discharge = _select_discharge_export_slots(slots, mb, soc0)
for t, s in enumerate(slots):
s.allow_charge = t in charge
s.allow_discharge_export = t in discharge
battery = _battery(uc_wh=64_000.0, terminal_soc_value_factor=0.9)
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
grid = SimpleNamespace(max_import_power_w=20_000, max_export_power_w=20_000)
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
soc0,
50.0,
operating_mode="AUTO",
)
self.assertEqual(len(results), len(slots))
morning = results[0]
vt_before_nt = results[2]
self.assertLessEqual(morning.grid_setpoint_w, slots[0].load_baseline_w + 4_500)
self.assertNotEqual(morning.export_mode, "PV_SURPLUS")
self.assertGreaterEqual(
vt_before_nt.grid_setpoint_w,
-6_500,
msg="před NT: žádný masivní export při téměř nulovém sell",
)
self.assertLessEqual(vt_before_nt.battery_setpoint_w, 10_500)
class ChargeAcquisitionArbitrageTests(unittest.TestCase):
"""Mezi-slotová arbitráž: večerní export při nízké charge_acquisition z SQL."""
def test_evening_peak_battery_export_at_site_cap(self) -> None:
"""Nejvyšší večerní sell: výrazný export; levnější večerní sloty bez předčasného vývozu."""
prague = ZoneInfo("Europe/Prague")
base = datetime(2026, 5, 25, 17, 0, tzinfo=prague)
sells = [3.5, 3.7, 4.04, 3.75, 3.8, 3.6]
slots = [
PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=0.8,
sell_price=sell,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=800,
ev1_connected=False,
ev2_connected=False,
allow_charge=False,
allow_discharge_export=True,
charge_acquisition_buy_czk_kwh=0.8,
)
for i, sell in enumerate(sells)
]
battery = _battery(uc_wh=64_000.0, terminal_soc_value_factor=0.0)
battery.max_discharge_power_w = 6250
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
grid = SimpleNamespace(max_import_power_w=16_000, max_export_power_w=16_000)
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
results, _ms, snap = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
0.85 * battery.soc_max_wh,
50.0,
operating_mode="AUTO",
)
self.assertEqual(snap["planner_build_tag"], PLANNER_BUILD_TAG)
peak_idx = sells.index(4.04)
peak = results[peak_idx]
self.assertIn(peak.export_mode, ("BATTERY_SELL", "PV_SURPLUS"))
self.assertGreater(abs(peak.grid_setpoint_w), 5000)
# v38: sloty mimo push s sell pod peakeps nesmí BATTERY_SELL (evening_early).
push_iso = set(snap["inputs"].get("evening_push_ts") or [])
for i, r in enumerate(results):
if slots[i].interval_start.isoformat() in push_iso:
continue
if float(sells[i]) >= 4.04 - 0.05:
continue
self.assertNotEqual(
r.export_mode,
"BATTERY_SELL",
msg=f"slot {i} sell={sells[i]} must not battery-export when not in push",
)
def test_midnight_higher_sell_gets_battery_export(self) -> None:
"""v43: push jen ≥17h — export v 23:30, ne predawn půlnoc."""
prague = ZoneInfo("Europe/Prague")
slots = [
PlanningSlot(
interval_start=datetime(2026, 5, 25, 23, 15, tzinfo=prague),
buy_price=5.28,
sell_price=3.323,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=1800,
ev1_connected=False,
ev2_connected=False,
allow_charge=False,
allow_discharge_export=True,
charge_acquisition_buy_czk_kwh=0.8,
),
PlanningSlot(
interval_start=datetime(2026, 5, 25, 23, 30, tzinfo=prague),
buy_price=5.23,
sell_price=3.286,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=1800,
ev1_connected=False,
ev2_connected=False,
allow_charge=False,
allow_discharge_export=True,
charge_acquisition_buy_czk_kwh=0.8,
),
PlanningSlot(
interval_start=datetime(2026, 5, 26, 0, 0, tzinfo=prague),
buy_price=5.63,
sell_price=3.586,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=1800,
ev1_connected=False,
ev2_connected=False,
allow_charge=False,
allow_discharge_export=True,
charge_acquisition_buy_czk_kwh=0.8,
),
]
battery = _battery(uc_wh=64_000.0, terminal_soc_value_factor=0.0)
battery.max_discharge_power_w = 18_000
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
grid = SimpleNamespace(max_import_power_w=17_000, max_export_power_w=13_500)
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
results, _, snap = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
0.9 * battery.soc_max_wh,
50.0,
operating_mode="AUTO",
)
self.assertEqual(snap["planner_build_tag"], PLANNER_BUILD_TAG)
r_evening = results[0]
self.assertEqual(r_evening.export_mode, "BATTERY_SELL")
self.assertGreaterEqual(abs(r_evening.grid_setpoint_w), 12_500)
self.assertNotEqual(results[2].export_mode, "BATTERY_SELL")
def test_evening_push_export_near_site_cap_home01(self) -> None:
"""home-01 večer: export ≈ min(13.5 kW, 18 kW load), ne (maxload)/2."""
prague = ZoneInfo("Europe/Prague")
base = datetime(2026, 5, 25, 18, 45, tzinfo=prague)
slots = [
PlanningSlot(
interval_start=base,
buy_price=3.0,
sell_price=4.4,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=1797,
ev1_connected=False,
ev2_connected=False,
allow_charge=False,
allow_discharge_export=True,
charge_acquisition_buy_czk_kwh=0.8,
)
]
battery = _battery(uc_wh=64_000.0, terminal_soc_value_factor=0.0)
battery.max_discharge_power_w = 18_000
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
grid = SimpleNamespace(max_import_power_w=17_000, max_export_power_w=13_500)
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
results, _ms, snap = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
0.9 * battery.soc_max_wh,
50.0,
operating_mode="AUTO",
)
self.assertEqual(snap["planner_build_tag"], PLANNER_BUILD_TAG)
r = results[0]
self.assertEqual(r.export_mode, "BATTERY_SELL")
self.assertGreaterEqual(abs(r.grid_setpoint_w), 12_500)
self.assertLessEqual(abs(r.grid_setpoint_w), 13_500)
def test_evening_battery_export_when_sell_above_acquisition(self) -> None:
base = datetime(2026, 5, 21, 10, 0, tzinfo=timezone.utc)
cheap = (0.75, 0.25)
peak = (3.5, 4.8)
slots: list[PlanningSlot] = []
for i in range(6):
buy, sell = cheap if i < 2 else peak
slots.append(
PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=buy,
sell_price=sell,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=800,
ev1_connected=False,
ev2_connected=False,
is_predicted_price=False,
allow_charge=i < 2,
allow_discharge_export=i >= 2,
charge_acquisition_buy_czk_kwh=0.75,
charge_acquisition_cutoff_at=base + timedelta(minutes=30),
)
)
battery = _battery(uc_wh=64_000.0, terminal_soc_value_factor=0.0)
battery.max_charge_power_w = 17_000
battery.max_discharge_power_w = 17_000
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
grid = SimpleNamespace(max_import_power_w=20_000, max_export_power_w=20_000)
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
soc0 = 0.78 * battery.usable_capacity_wh
results, _ms, snap = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
soc0,
50.0,
operating_mode="AUTO",
)
self.assertAlmostEqual(
snap["inputs"]["charge_acquisition_buy_czk_kwh"],
0.75,
places=2,
)
evening = results[3]
self.assertLess(
evening.grid_setpoint_w,
-1_000,
msg="high sell vs low acquisition should motivate grid export",
)
self.assertLess(evening.battery_setpoint_w, -500)
def test_evening_export_in_all_top_three_peak_slots_not_only_last(self) -> None:
"""MILP v41: plný export ve všech slotech se shodnou max sell v nočním úseku."""
prague = ZoneInfo("Europe/Prague")
sells = [10.0, 10.0, 10.0, 5.0, 4.0, 3.0]
base = datetime(2026, 5, 25, 18, 0, tzinfo=prague)
slots = [
PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=2.0,
sell_price=sells[i],
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=800,
ev1_connected=False,
ev2_connected=False,
allow_discharge_export=True,
charge_acquisition_buy_czk_kwh=0.5,
)
for i in range(6)
]
battery = _battery(uc_wh=64_000.0, terminal_soc_value_factor=0.0)
battery.max_discharge_power_w = 17_000
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
grid = SimpleNamespace(max_import_power_w=20_000, max_export_power_w=20_000)
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
soc0 = 0.85 * battery.soc_max_wh
results, _ms, snap = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
soc0,
50.0,
operating_mode="AUTO",
)
self.assertEqual(snap["planner_build_tag"], PLANNER_BUILD_TAG)
push_iso = snap["inputs"].get("evening_push_ts") or []
self.assertGreaterEqual(len(push_iso), 2)
for i in range(2):
self.assertIn(
slots[i].interval_start.isoformat(),
push_iso,
msg=f"slot {i} sell={sells[i]} must be in evening_push_ts",
)
for i in range(2):
r = results[i]
self.assertLess(
r.grid_setpoint_w,
-500,
msg=f"slot {i} sell={sells[i]} should export, not defer to cheaper later slot",
)
self.assertEqual(r.export_mode, "BATTERY_SELL")
def test_evening_no_spread_export_below_segment_peak_home01(self) -> None:
"""Spot večer sell≥buy: push jen top sell sloty; levnější mimo push bez exportu."""
prague = ZoneInfo("Europe/Prague")
sells = [3.834, 3.518, 3.204, 3.204, 3.136, 3.020]
base = datetime(2026, 5, 29, 20, 15, tzinfo=prague)
slots = [
PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=3.0,
sell_price=sells[i],
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=2973,
ev1_connected=False,
ev2_connected=False,
allow_charge=False,
allow_discharge_export=True,
charge_acquisition_buy_czk_kwh=0.8,
)
for i in range(6)
]
battery = _battery(uc_wh=64_000.0, terminal_soc_value_factor=0.0)
battery.max_discharge_power_w = 18_000
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
grid = SimpleNamespace(max_import_power_w=17_000, max_export_power_w=13_500)
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
results, _ms, snap = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
0.81 * battery.soc_max_wh,
50.0,
operating_mode="AUTO",
)
self.assertEqual(snap["planner_build_tag"], PLANNER_BUILD_TAG)
push_iso = set(snap["inputs"].get("evening_push_ts") or [])
self.assertGreaterEqual(len(push_iso), 3, "rozpočet Wh → víc než jeden push slot")
self.assertIn(slots[0].interval_start.isoformat(), push_iso)
self.assertGreaterEqual(abs(results[0].grid_setpoint_w), 12_500)
for i, r in enumerate(results):
iso = slots[i].interval_start.isoformat()
if iso in push_iso:
self.assertEqual(r.export_mode, "BATTERY_SELL")
self.assertLessEqual(r.grid_setpoint_w, -12_500)
else:
self.assertGreaterEqual(
r.grid_setpoint_w,
-500,
msg=f"slot {i} sell={sells[i]} mimo push nesmí exportovat",
)
def test_evening_push_respects_wh_budget_not_all_profitable_slots(self) -> None:
"""Při malém SoC jen top-N drahých slotů; zbytek noci ge_bat=0."""
prague = ZoneInfo("Europe/Prague")
sells = [4.0 - 0.05 * i for i in range(10)]
base = datetime(2026, 5, 25, 18, 0, tzinfo=prague)
slots = [
PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=2.0,
sell_price=sells[i],
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=1800,
ev1_connected=False,
ev2_connected=False,
allow_discharge_export=True,
charge_acquisition_buy_czk_kwh=0.5,
)
for i in range(10)
]
battery = _battery(uc_wh=64_000.0, terminal_soc_value_factor=0.0)
battery.max_discharge_power_w = 18_000
per_slot = min(18_000, 13_500) * 0.95 * 0.25
soc_limited = battery.min_soc_wh + 3.2 * per_slot
push = _evening_battery_export_push_indices(
slots,
charge_acquisition_czk_kwh=0.5,
degrad_czk_kwh=0.15,
current_soc_wh=soc_limited,
min_soc_wh=battery.min_soc_wh,
soc_max_wh=battery.soc_max_wh,
per_slot_discharge_wh=per_slot,
discharge_slot_buffer=1.5,
)
self.assertGreaterEqual(len(push), 3)
self.assertLessEqual(len(push), 4)
self.assertEqual(push, [0, 1, 2, 3][: len(push)])
def test_evening_push_override_cleared_on_relaxed_retry(self) -> None:
"""v53: hysterézní override se nepřenáší do Infeasible retry větví."""
kept = _evening_push_override_for_solve(
{2, 5},
relaxed_expensive_import=False,
relaxed_neg_buy_charge=False,
relaxed_neg_prep_window=False,
neg_sell_phases_fallback=False,
)
self.assertEqual(kept, {2, 5})
dropped = _evening_push_override_for_solve(
{2, 5},
relaxed_expensive_import=True,
relaxed_neg_buy_charge=False,
relaxed_neg_prep_window=False,
neg_sell_phases_fallback=False,
)
self.assertIsNone(dropped)
def test_evening_push_override_filters_defer_pv(self) -> None:
prague = ZoneInfo("Europe/Prague")
slots = [
PlanningSlot(
interval_start=datetime(2026, 5, 30, 18, 0, tzinfo=prague).astimezone(timezone.utc),
buy_price=3.0,
sell_price=4.0,
pv_a_forecast_w=8000,
pv_b_forecast_w=0,
load_baseline_w=500,
ev1_connected=False,
ev2_connected=False,
allow_discharge_export=True,
),
PlanningSlot(
interval_start=datetime(2026, 5, 30, 22, 0, tzinfo=prague).astimezone(timezone.utc),
buy_price=3.0,
sell_price=6.0,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=500,
ev1_connected=False,
ev2_connected=False,
allow_discharge_export=True,
),
]
battery = _battery(uc_wh=64_000.0)
battery.max_discharge_power_w = 18_000
grid = SimpleNamespace(max_export_power_w=13_500)
out = _filter_evening_push_override_indices(
slots,
{0, 1},
battery=battery,
grid=grid,
discharge_export_ok={0, 1},
)
self.assertNotIn(0, out)
self.assertIn(1, out)
def test_relaxed_expensive_import_keeps_evening_push(self) -> None:
"""v57: relaxed_expensive_import nesmí vymazat evening_push (regrese v55)."""
prague = ZoneInfo("Europe/Prague")
slots = [
PlanningSlot(
interval_start=datetime(2026, 5, 30, 22, 0, tzinfo=prague).astimezone(timezone.utc),
buy_price=3.0,
sell_price=9.5,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=500,
ev1_connected=False,
ev2_connected=False,
allow_discharge_export=True,
),
]
battery = _battery(uc_wh=64_000.0)
battery.max_discharge_power_w = 18_000
grid = SimpleNamespace(
max_export_power_w=13_500,
max_import_power_w=17_000,
block_export_on_negative_sell=False,
purchase_pricing_mode="spot",
)
results, _ms, snap = solve_dispatch(
slots,
battery,
SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0),
grid,
[None, None],
[
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
],
current_soc_wh=50_000.0,
current_tuv_temp_c=55.0,
relaxed_expensive_import=True,
)
push_iso = snap["inputs"].get("evening_push_ts") or []
self.assertEqual(len(push_iso), 1)
self.assertFalse(snap["inputs"].get("evening_push_hard_suppressed"))
self.assertLess(results[0].grid_setpoint_w, -1000)
def test_relaxed_neg_prep_suppresses_hard_push_only(self) -> None:
"""v57: relaxed_neg_prep_window vypne jen tvrdý push, ne seznam slotů."""
prague = ZoneInfo("Europe/Prague")
slots = [
PlanningSlot(
interval_start=datetime(2026, 5, 30, 18, 0, tzinfo=prague).astimezone(timezone.utc),
buy_price=3.0,
sell_price=4.0,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=500,
ev1_connected=False,
ev2_connected=False,
allow_discharge_export=True,
),
PlanningSlot(
interval_start=datetime(2026, 5, 30, 22, 0, tzinfo=prague).astimezone(timezone.utc),
buy_price=3.0,
sell_price=6.0,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=500,
ev1_connected=False,
ev2_connected=False,
allow_discharge_export=True,
),
]
battery = _battery(uc_wh=64_000.0)
battery.max_discharge_power_w = 18_000
battery.planner_neg_sell_prep_soc_percent = 80
battery.planner_neg_sell_full_soc_tail_slots = 4
grid = SimpleNamespace(
max_export_power_w=13_500,
max_import_power_w=17_000,
block_export_on_negative_sell=False,
purchase_pricing_mode="spot",
)
_results, _ms, snap = solve_dispatch(
slots,
battery,
SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0),
grid,
[None, None],
[
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
],
current_soc_wh=16_000.0,
current_tuv_temp_c=55.0,
relaxed_neg_prep_window=True,
)
self.assertTrue(snap["inputs"].get("evening_push_hard_suppressed"))
push_iso = snap["inputs"].get("evening_push_ts") or []
self.assertGreaterEqual(len(push_iso), 1)
def test_kv1_evening_push_profitable_vs_morning_zone_peak(self) -> None:
"""v52: KV1 večer ≥ ranní max (511) degrad; pod prahem ne."""
prague = ZoneInfo("Europe/Prague")
neg = datetime(2026, 5, 31, 8, 15, tzinfo=prague).astimezone(timezone.utc)
slots = [
PlanningSlot(
interval_start=datetime(2026, 5, 31, 6, 0, tzinfo=prague).astimezone(timezone.utc),
buy_price=6.35,
sell_price=2.55,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=400,
ev1_connected=False,
ev2_connected=False,
),
PlanningSlot(
interval_start=neg,
buy_price=6.35,
sell_price=-0.3,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=400,
ev1_connected=False,
ev2_connected=False,
),
]
eve = PlanningSlot(
interval_start=datetime(2026, 5, 30, 22, 0, tzinfo=prague).astimezone(timezone.utc),
buy_price=6.35,
sell_price=3.30,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=400,
ev1_connected=False,
ev2_connected=False,
)
self.assertTrue(
_slot_evening_push_profitable(
eve,
charge_acquisition_czk_kwh=6.35,
min_spread=0.3,
slots=slots,
first_neg_sell_idx=1,
kv1_evening_push=True,
)
)
eve_low = PlanningSlot(
interval_start=eve.interval_start,
buy_price=6.35,
sell_price=2.20,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=400,
ev1_connected=False,
ev2_connected=False,
)
self.assertFalse(
_slot_evening_push_profitable(
eve_low,
charge_acquisition_czk_kwh=6.35,
min_spread=0.3,
slots=slots,
first_neg_sell_idx=1,
kv1_evening_push=True,
)
)
def test_evening_push_ok_when_sell_below_buy_vs_acq(self) -> None:
"""v47: večer sell<buy ale >acq — push pro vyprázdnění před neg dnem."""
slot = PlanningSlot(
interval_start=datetime(
2026, 5, 30, 20, 0, tzinfo=ZoneInfo("Europe/Prague")
).astimezone(timezone.utc),
buy_price=5.5,
sell_price=3.3,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=1400,
ev1_connected=False,
ev2_connected=False,
allow_discharge_export=True,
)
self.assertTrue(
_slot_evening_push_profitable(
slot, charge_acquisition_czk_kwh=0.61, min_spread=0.15
)
)
def test_night_self_consume_prefers_battery_over_grid(self) -> None:
"""v43/v46: mimo push baterie krmí dům, ne import za ~5 Kč."""
prague = ZoneInfo("Europe/Prague")
base = datetime(2026, 5, 29, 20, 0, tzinfo=prague)
slot_specs = [
(3.0, 3.9),
(3.0, 3.8),
(5.0, 3.1),
(5.0, 3.0),
]
slots = [
PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=buy,
sell_price=sell,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=2000,
ev1_connected=False,
ev2_connected=False,
allow_discharge_export=True,
charge_acquisition_buy_czk_kwh=0.7,
)
for i, (buy, sell) in enumerate(slot_specs)
]
battery = _battery(uc_wh=64_000.0, terminal_soc_value_factor=0.0)
battery.max_discharge_power_w = 18_000
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
grid = SimpleNamespace(max_import_power_w=17_000, max_export_power_w=13_500)
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
results, _ms, snap = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
0.75 * battery.soc_max_wh,
50.0,
operating_mode="AUTO",
)
push_iso = set(snap["inputs"].get("evening_push_ts") or [])
for i in (2, 3):
iso = slots[i].interval_start.isoformat()
if iso not in push_iso:
self.assertLessEqual(
results[i].battery_setpoint_w,
-1500,
msg=f"slot {i} mimo push: vlastní spotřeba z baterie",
)
self.assertLessEqual(
results[i].grid_setpoint_w,
500,
msg=f"slot {i} mimo push: ne import pro dům",
)
def test_no_pv_export_at_low_sell_when_evening_peak_much_higher(self) -> None:
"""Odpolední sell ~1,4 a večer ~5,5 — PV do baterie, ne FVE→síť za haléř."""
base = datetime(2026, 5, 21, 12, 0, tzinfo=timezone.utc)
afternoon = PlanningSlot(
interval_start=base,
buy_price=4.5,
sell_price=1.4,
pv_a_forecast_w=8000,
pv_b_forecast_w=0,
load_baseline_w=2500,
ev1_connected=False,
ev2_connected=False,
allow_charge=False,
allow_discharge_export=False,
charge_acquisition_buy_czk_kwh=0.82,
future_sell_opportunity_czk_kwh=5.5,
)
cheap = PlanningSlot(
interval_start=base + timedelta(hours=20),
buy_price=0.5,
sell_price=-0.2,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=2000,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=False,
charge_acquisition_buy_czk_kwh=0.82,
future_sell_opportunity_czk_kwh=5.5,
)
peak = PlanningSlot(
interval_start=base + timedelta(hours=7),
buy_price=7.0,
sell_price=5.5,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=2500,
ev1_connected=False,
ev2_connected=False,
allow_charge=False,
allow_discharge_export=True,
charge_acquisition_buy_czk_kwh=0.82,
future_sell_opportunity_czk_kwh=5.5,
)
slots = [afternoon, peak, cheap]
battery = _battery(uc_wh=64_000.0, min_pct=12.0, arb_pct=20.0)
battery.max_charge_power_w = 18_000
battery.max_discharge_power_w = 18_000
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
grid = SimpleNamespace(max_import_power_w=17_000, max_export_power_w=13_500)
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
soc0 = 0.5 * battery.usable_capacity_wh
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
soc0,
50.0,
operating_mode="AUTO",
)
pm = results[0]
self.assertGreaterEqual(
pm.grid_setpoint_w,
-50,
"low sell with high evening peak: keep PV for battery, not grid dump",
)
self.assertGreater(
pm.battery_setpoint_w,
500,
"PV surplus should charge battery ahead of evening export",
)
class Home01RegressionTests(unittest.TestCase):
"""Definition of Done: home-01 arbitráž archetypy (bez DB)."""
@staticmethod
def _solve_auto(
slots: list[PlanningSlot],
battery: SimpleNamespace,
soc0: float,
*,
two_pass: bool = True,
) -> tuple[list[DispatchResult], dict]:
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
grid = SimpleNamespace(max_import_power_w=20_000, max_export_power_w=20_000)
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
fn = solve_dispatch_two_pass if two_pass else solve_dispatch
results, _ms, snap = fn(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
soc0,
50.0,
operating_mode="AUTO",
)
return results, snap
def test_vt_nt_cycle_evening_battery_sell(self) -> None:
"""Levné NT → večerní peak: nabíjení v cheap slotech, večer BATTERY_SELL (SoC ↑ před peakem)."""
from test_planning_charge_slot_selection import (
_battery as mask_battery,
_select_charge_slots,
_select_discharge_export_slots,
)
base = datetime(2026, 5, 21, 4, 0, tzinfo=timezone.utc)
prices: list[tuple[float, float, int, int]] = [
(0.42, -0.20, 0, 2300),
(0.44, -0.19, 0, 2350),
(0.46, -0.18, 0, 2380),
(0.48, -0.18, 0, 2400),
(0.50, -0.15, 0, 2600),
(0.52, -0.14, 0, 2700),
(0.55, -0.12, 0, 2800),
(0.58, -0.11, 0, 2850),
(0.62, -0.10, 0, 2900),
(0.68, -0.09, 0, 2950),
(0.72, -0.08, 500, 3000),
(0.76, -0.07, 1500, 3100),
(0.80, -0.05, 2000, 3200),
(7.20, 5.50, 0, 2500),
(7.00, 5.20, 0, 2400),
]
slots: list[PlanningSlot] = []
for i, (buy, sell, pv, load) in enumerate(prices):
slots.append(
PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=buy,
sell_price=sell,
pv_a_forecast_w=pv,
pv_b_forecast_w=0,
load_baseline_w=load,
ev1_connected=False,
ev2_connected=False,
is_predicted_price=False,
)
)
mb = mask_battery(uc_wh=64_000.0, charge_buf=1.5, discharge_buf=1.0)
soc0 = 0.10 * mb.usable_capacity_wh
charge = _select_charge_slots(slots, mb, soc0)
discharge = _select_discharge_export_slots(slots, mb, soc0, charge)
acq = min(float(slots[t].buy_price) for t in charge) if charge else 0.9
cutoff = min(
(slots[t].interval_start for t in discharge),
default=slots[-1].interval_start,
)
for t, s in enumerate(slots):
s.allow_charge = t in charge or float(s.buy_price) < 1.0
# Export jen při skutečné večerní špičce (sell ≥ 5), ne při mezilehlém 4.8 Kč.
s.allow_discharge_export = t in discharge and float(s.sell_price) >= 5.0
s.charge_acquisition_buy_czk_kwh = acq
s.charge_acquisition_cutoff_at = cutoff
battery = _battery(uc_wh=64_000.0, min_pct=12.0, arb_pct=20.0, terminal_soc_value_factor=0.2)
battery.max_charge_power_w = 17_000
battery.max_discharge_power_w = 17_000
soc_start_pct = 100.0 * soc0 / battery.usable_capacity_wh
results, snap = self._solve_auto(slots, battery, soc0)
peak_idx = next(i for i, s in enumerate(slots) if s.sell_price >= 5.0)
pre_peak = results[peak_idx - 1] if peak_idx > 0 else results[0]
self.assertGreater(
pre_peak.battery_soc_target,
soc_start_pct + 25.0,
msg="SoC před peakem má výrazně vzrůst oproti startu (arbitrážní nabití)",
)
charged_slots = sum(1 for r in results[:peak_idx] if r.battery_setpoint_w > 500 or r.grid_setpoint_w > 500)
self.assertGreater(charged_slots, 2, "levné sloty mají nabíjet ze sítě nebo PV")
evening = results[peak_idx]
total_export_w = max(0, -evening.grid_setpoint_w) + max(0, -evening.battery_setpoint_w)
self.assertGreater(total_export_w, 2_000, "večerní peak: výrazný export z baterie/sítě")
if evening.grid_setpoint_w < 0:
self.assertEqual(evening.export_mode, "BATTERY_SELL")
inputs = snap.get("inputs") or {}
self.assertTrue(inputs.get("two_pass_enabled"))
def test_neg_sell_pv_to_battery_not_grid_when_soc_has_room(self) -> None:
"""sell<0, spot, PV B: při SoC pod stropem jen nabíjení/curtail, ne PV_SURPLUS export."""
slots = [
PlanningSlot(
interval_start=datetime(2026, 5, 25, 8, 0, tzinfo=timezone.utc)
+ timedelta(minutes=15 * i),
buy_price=0.5,
sell_price=-0.4,
pv_a_forecast_w=8000,
pv_b_forecast_w=2000,
load_baseline_w=400,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=False,
)
for i in range(4)
]
battery = _battery(uc_wh=64_000.0, terminal_soc_value_factor=0.2)
battery.max_charge_power_w = 18_000
grid = SimpleNamespace(
max_import_power_w=17_000,
max_export_power_w=13_500,
block_export_on_negative_sell=False,
purchase_pricing_mode="spot",
)
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=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
30_000.0,
50.0,
operating_mode="AUTO",
)
for r in results:
self.assertGreaterEqual(r.grid_setpoint_w, 0, "neg sell bez exportu při volné kapacitě baterie")
self.assertGreater(r.battery_setpoint_w, 0, "neg sell má nabíjet z FVE")
def test_neg_sell_full_battery_exports_at_most_pv_b_not_full_surplus(self) -> None:
"""Plná baterie + sell<0: max export jen pole B (~5 kW), ne pv_a+pv_b (~9 kW)."""
slots = [
PlanningSlot(
interval_start=datetime(2026, 5, 25, 7, 30, tzinfo=timezone.utc)
+ timedelta(minutes=15 * i),
buy_price=0.5,
sell_price=-0.4,
pv_a_forecast_w=4700,
pv_b_forecast_w=5100,
load_baseline_w=400,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=False,
)
for i in range(3)
]
battery = _battery(uc_wh=64_000.0, terminal_soc_value_factor=0.2)
battery.max_charge_power_w = 18_000
battery.soc_max_wh = 64_000.0
grid = SimpleNamespace(
max_import_power_w=17_000,
max_export_power_w=13_500,
block_export_on_negative_sell=False,
purchase_pricing_mode="spot",
)
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=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
soc0 = float(battery.soc_max_wh) - 500.0
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
soc0,
50.0,
operating_mode="AUTO",
)
for r in results:
export_w = max(0, -int(r.grid_setpoint_w or 0))
if export_w > 0:
self.assertLessEqual(
export_w,
5_500,
"při plné baterii jen ventil pole B, ne celý PV přebytek",
)
def test_neg_sell_bat_dump_slot_selection(self) -> None:
"""sell<0 těsně před buy<=-2: slot je v neg_sell_bat_dump_slots (ge_bat povolen)."""
from services.planning_engine import _neg_sell_bat_dump_slots
slots = [
PlanningSlot(
interval_start=datetime(2026, 4, 4, 5, 0, tzinfo=timezone.utc),
buy_price=0.3,
sell_price=-0.35,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=800,
ev1_connected=False,
ev2_connected=False,
allow_charge=False,
allow_discharge_export=False,
),
PlanningSlot(
interval_start=datetime(2026, 4, 4, 5, 15, tzinfo=timezone.utc),
buy_price=-10.0,
sell_price=-0.2,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=800,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=False,
),
]
grid = SimpleNamespace(
block_export_on_negative_sell=False,
purchase_pricing_mode="spot",
)
dump = _neg_sell_bat_dump_slots(
slots,
operating_mode="AUTO",
purchase_fixed=False,
grid=grid,
buy_extreme_thr=-2.0,
degrad_czk_kwh=0.15,
)
self.assertEqual(dump, {0})
def test_no_fve_dump_at_low_sell_with_evening_peak(self) -> None:
"""Odpolední sell ~1,4 vs večer ~5,5 — žádný PV_SURPLUS export, nabíjení z FVE."""
base = datetime(2026, 5, 21, 14, 0, tzinfo=timezone.utc)
afternoon = PlanningSlot(
interval_start=base,
buy_price=4.5,
sell_price=1.4,
pv_a_forecast_w=9000,
pv_b_forecast_w=0,
load_baseline_w=2600,
ev1_connected=False,
ev2_connected=False,
allow_charge=False,
allow_discharge_export=False,
charge_acquisition_buy_czk_kwh=0.78,
future_sell_opportunity_czk_kwh=5.5,
)
peak = PlanningSlot(
interval_start=base + timedelta(hours=5),
buy_price=7.0,
sell_price=5.5,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=2400,
ev1_connected=False,
ev2_connected=False,
allow_charge=False,
allow_discharge_export=True,
charge_acquisition_buy_czk_kwh=0.78,
future_sell_opportunity_czk_kwh=5.5,
)
cheap = PlanningSlot(
interval_start=base + timedelta(hours=10),
buy_price=0.55,
sell_price=-0.1,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=2000,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=False,
charge_acquisition_buy_czk_kwh=0.78,
future_sell_opportunity_czk_kwh=5.5,
)
slots = [afternoon, peak, cheap]
battery = _battery(uc_wh=64_000.0)
battery.max_charge_power_w = 18_000
soc0 = 0.48 * battery.usable_capacity_wh
results, _ = self._solve_auto(slots, battery, soc0)
pm = results[0]
self.assertNotEqual(pm.export_mode, "PV_SURPLUS")
self.assertGreater(pm.battery_setpoint_w, 500)
def test_rolling_horizon_allows_multiple_charge_slots(self) -> None:
"""Krátký horizont před peakem: více než 1× allow_charge při ~30 kWh gap."""
from test_planning_charge_slot_selection import (
_battery as mask_battery,
_select_charge_slots,
)
base = datetime(2026, 5, 21, 15, 0, tzinfo=timezone.utc)
slots: list[PlanningSlot] = []
for i in range(5):
buy = 0.65 + 0.05 * i if i < 3 else 6.0
sell = -0.1 if i < 3 else 5.2
slots.append(
PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=buy,
sell_price=sell,
pv_a_forecast_w=1500,
pv_b_forecast_w=0,
load_baseline_w=3000,
ev1_connected=False,
ev2_connected=False,
is_predicted_price=False,
)
)
mb = mask_battery(uc_wh=64_000.0, charge_buf=1.3)
soc0 = 0.22 * mb.usable_capacity_wh
charge = _select_charge_slots(slots, mb, soc0)
self.assertGreaterEqual(
len(charge),
2,
msg="při velkém energy_to_fill má maska vybrat více levných slotů",
)
def test_negative_sell_blocks_export(self) -> None:
base = datetime(2026, 5, 21, 10, 0, tzinfo=timezone.utc)
slots = [
PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=1.0,
sell_price=-0.8 if i < 2 else 2.0,
pv_a_forecast_w=5000,
pv_b_forecast_w=0,
load_baseline_w=2000,
ev1_connected=False,
ev2_connected=False,
is_predicted_price=False,
)
for i in range(4)
]
battery = _battery(uc_wh=40_000.0)
results, _ = self._solve_auto(slots, battery, 0.5 * battery.usable_capacity_wh)
for i in range(2):
self.assertGreaterEqual(results[i].grid_setpoint_w, -50)
self.assertNotEqual(results[i].export_mode, "PV_SURPLUS")
@staticmethod
def _home01_run16522_slots() -> list[PlanningSlot]:
from test_planning_charge_slot_selection import (
_battery as mask_battery,
_select_charge_slots,
_select_discharge_export_slots,
)
from zoneinfo import ZoneInfo
prague = ZoneInfo("Europe/Prague")
base = datetime(2026, 5, 24, 0, 0, tzinfo=prague)
hour_specs: list[tuple[int, int, dict]] = [
(0, 5, {"buy": 4.7, "sell": 2.9}),
(5, 7, {"buy": 5.0, "sell": 3.0, "pv_b": 400}),
(7, 11, {"buy": 4.5, "sell": 2.8, "pv_a": 3000, "pv_b": 2000}),
(11, 14, {"buy": 0.5, "sell": -0.4, "pv_a": 6000, "pv_b": 5000}),
(14, 17, {"buy": 1.0, "sell": -0.3, "pv_a": 5000, "pv_b": 4000}),
(17, 19, {"buy": 4.5, "sell": 3.0}),
(19, 22, {"buy": 6.5, "sell": 4.0}),
(22, 24, {"buy": 4.8, "sell": 3.0}),
]
slots: list[PlanningSlot] = []
for h0, h1, kw in hour_specs:
for h in range(h0, h1):
for minute in (0, 15, 30, 45):
t = base.replace(hour=h, minute=minute).astimezone(timezone.utc)
slots.append(
PlanningSlot(
interval_start=t,
buy_price=float(kw["buy"]),
sell_price=float(kw["sell"]),
pv_a_forecast_w=int(kw.get("pv_a", 0)),
pv_b_forecast_w=int(kw.get("pv_b", 0)),
load_baseline_w=500,
ev1_connected=False,
ev2_connected=False,
is_predicted_price=False,
)
)
mb = mask_battery(charge_buf=1.3, uc_wh=64_000.0, soc_max_pct=95.0)
soc0 = 30_000.0
charge = _select_charge_slots(slots, mb, soc0)
discharge = _select_discharge_export_slots(slots, mb, soc0, charge)
acq = (
sum(float(slots[t].buy_price) for t in charge) / len(charge)
if charge
else min(float(s.buy_price) for s in slots)
)
cutoff = min(
(slots[t].interval_start for t in discharge),
default=slots[-1].interval_start,
)
for t, s in enumerate(slots):
s.allow_charge = t in charge or float(s.buy_price) < 0
s.allow_discharge_export = t in discharge
s.charge_acquisition_buy_czk_kwh = acq
s.charge_acquisition_cutoff_at = cutoff
return slots
def _home01_battery(self, soc: float = 30_000.0) -> SimpleNamespace:
b = _battery(
uc_wh=64_000.0,
min_pct=11.0,
arb_pct=20.0,
terminal_soc_value_factor=0.2,
)
b.max_charge_power_w = 17_000
b.max_discharge_power_w = 17_000
b.charge_slot_buffer = 1.3
b.planner_daytime_charge_target_enabled = True
return b
def _home01_grid(self) -> SimpleNamespace:
return SimpleNamespace(
max_import_power_w=17_000,
max_export_power_w=13_500,
block_export_on_negative_sell=False,
purchase_pricing_mode="spot",
)
def test_home01_no_night_charge_before_pv_day(self) -> None:
"""Pattern run 16522: 22:00-24:00 bez grid importu >15 kW pred PV dnem."""
from zoneinfo import ZoneInfo
slots = self._home01_run16522_slots()
results, _snap = self._solve_auto(
slots,
self._home01_battery(),
30_000.0,
)
prague = ZoneInfo("Europe/Prague")
for r in results:
h = r.interval_start.astimezone(prague).hour
if h in (22, 23):
self.assertLess(
r.grid_setpoint_w,
15_000,
f"slot {r.interval_start}: grid={r.grid_setpoint_w} >= 15 kW",
)
def test_two_pass_converged_after_filter(self) -> None:
"""Po self-konzistentni masce B: acquisition pass1 ~ pass2."""
slots = self._home01_run16522_slots()
_results, snap = self._solve_auto(slots, self._home01_battery(), 30_000.0)
inputs = snap.get("inputs") or {}
self.assertTrue(
inputs.get("two_pass_converged"),
f"acquisition diverguje: {inputs}",
)
class LoadFirstDispatchTests(unittest.TestCase):
"""Deye load-first: PV do spotřeby dřív než bc_pv/ge_pv z přebytku."""
@staticmethod
def _solve_auto(
slots: list[PlanningSlot],
battery: SimpleNamespace,
soc0: float,
) -> list[DispatchResult]:
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
grid = SimpleNamespace(max_import_power_w=20_000, max_export_power_w=20_000)
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
results, _, _ = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
soc0,
50.0,
operating_mode="AUTO",
)
return results
def test_high_pv_low_load_prefers_export_over_battery_charge(self) -> None:
"""Mimo grid-charge masku nesmí LP nabíjet z celého PV při malé zátěži."""
base = datetime(2026, 5, 21, 12, 0, tzinfo=timezone.utc)
slots = [
PlanningSlot(
interval_start=base,
buy_price=2.0,
sell_price=4.0,
pv_a_forecast_w=8000,
pv_b_forecast_w=0,
load_baseline_w=500,
ev1_connected=False,
ev2_connected=False,
is_predicted_price=False,
allow_charge=False,
allow_discharge_export=False,
)
]
battery = _battery(uc_wh=50_000.0)
soc0 = 0.5 * battery.usable_capacity_wh
r = self._solve_auto(slots, battery, soc0)[0]
self.assertLessEqual(
r.battery_setpoint_w,
200,
msg="load-first: přebytek FVE má jít do exportu, ne do bc_pv",
)
self.assertLess(
r.grid_setpoint_w,
-400,
msg="očekáván PV export (přebytek po load-first)",
)
self.assertEqual(r.export_mode, "PV_SURPLUS")
def test_neg_sell_prep_no_fictitious_grid_import_for_load(self) -> None:
"""sell<0 prep: FVE >> load → dům z PV, ne grid_setpoint == load_baseline."""
base = datetime(2026, 5, 26, 7, 45, tzinfo=timezone.utc)
slots = [
PlanningSlot(
interval_start=base,
buy_price=1.45,
sell_price=-0.07,
pv_a_forecast_w=3137,
pv_b_forecast_w=3418,
load_baseline_w=447,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=False,
)
]
bat = _battery(uc_wh=64_000.0, max_pct=95.0)
bat.planner_neg_sell_prep_soc_percent = 80.0
bat.planner_neg_sell_full_soc_tail_slots = 4
r = self._solve_auto(slots, bat, 0.24 * bat.soc_max_wh)[0]
self.assertLessEqual(
abs(r.grid_setpoint_w),
100,
msg="tvrdý load-first: žádný fiktivní import = load při vysoké FVE",
)
self.assertGreater(r.battery_setpoint_w, 3000)
class PreNegativeSellExportTests(unittest.TestCase):
"""Před prvním sell<0: export přebytku (BA81/KV1 strategie), ne nabíjení + pozdní vývoz."""
def test_kv1_like_morning_exports_before_negative_sell_window(self) -> None:
base = datetime(2026, 5, 22, 6, 45, tzinfo=timezone.utc)
slots = [
PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=6.35,
sell_price=2.2,
pv_a_forecast_w=5000,
pv_b_forecast_w=0,
load_baseline_w=400,
ev1_connected=False,
ev2_connected=False,
allow_charge=False,
allow_discharge_export=False,
charge_acquisition_buy_czk_kwh=6.35,
future_sell_opportunity_czk_kwh=5.5,
)
for i in range(8)
] + [
PlanningSlot(
interval_start=base + timedelta(hours=2),
buy_price=6.35,
sell_price=-0.3,
pv_a_forecast_w=6000,
pv_b_forecast_w=0,
load_baseline_w=400,
ev1_connected=False,
ev2_connected=False,
allow_charge=False,
allow_discharge_export=False,
charge_acquisition_buy_czk_kwh=6.35,
future_sell_opportunity_czk_kwh=-0.3,
),
]
battery = _battery(uc_wh=12_500.0, min_pct=10.0, arb_pct=30.0)
battery.max_charge_power_w = 6250
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
grid = SimpleNamespace(
max_import_power_w=17_000,
max_export_power_w=8000,
block_export_on_negative_sell=True,
purchase_pricing_mode="fixed",
)
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
soc0 = 0.85 * battery.soc_max_wh
results, _, snap = solve_dispatch(
slots, battery, hp, grid, [None, None], vehicles, soc0, 50.0, operating_mode="AUTO"
)
self.assertLess(results[0].grid_setpoint_w, -500, "ráno: přebytek FVE do sítě před sell<0")
self.assertLess(results[0].pv_a_curtailed_w, 500, "fixed KV1: ne plný curtail při kladném sell")
neg = results[8]
self.assertGreater(neg.battery_setpoint_w, 500, "záporný sell: PV do baterie")
self.assertEqual(neg.export_mode, "NONE")
def test_ba81_fixed_morning_exports_pv_a_not_curtail(self) -> None:
"""BA81: před sell<0 export celého přebytku FVE, ne jen MI (pv_b)."""
prague = ZoneInfo("Europe/Prague")
base = datetime(2026, 5, 27, 7, 30, tzinfo=prague)
slots: list[PlanningSlot] = []
for i in range(12):
sell = 3.2 if i < 8 else -0.2
slots.append(
PlanningSlot(
interval_start=(base + timedelta(minutes=15 * i)).astimezone(timezone.utc),
buy_price=3.088,
sell_price=sell,
pv_a_forecast_w=5000,
pv_b_forecast_w=700,
load_baseline_w=200,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=False,
charge_acquisition_buy_czk_kwh=3.088,
future_sell_opportunity_czk_kwh=6.5,
)
)
battery = _battery(uc_wh=12_500.0, min_pct=10.0, arb_pct=30.0)
battery.max_charge_power_w = 6250
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
grid = SimpleNamespace(
max_import_power_w=17_000,
max_export_power_w=8000,
block_export_on_negative_sell=False,
purchase_pricing_mode="fixed",
)
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
res, _, _ = solve_dispatch(
slots, battery, hp, grid, [None, None], vehicles,
0.95 * battery.soc_max_wh, 50.0, operating_mode="AUTO",
)
r0 = res[0]
self.assertLess(r0.pv_a_curtailed_w, 500, "pole A nesmí jít do curtail při sell>0 před neg")
self.assertLess(r0.grid_setpoint_w, -4000, "export přebytku A+B do site")
def test_ba81_dawn_low_pv_no_full_curtail_for_mi_cap(self) -> None:
"""Úsvit: malé pole A + MI — ne plný curtail A kvůli ge_pv≤pv_b (reg 340)."""
prague = ZoneInfo("Europe/Prague")
slot = PlanningSlot(
interval_start=datetime(2026, 5, 31, 5, 15, tzinfo=prague).astimezone(timezone.utc),
buy_price=3.088,
sell_price=2.95,
pv_a_forecast_w=405,
pv_b_forecast_w=49,
load_baseline_w=400,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=True,
charge_acquisition_buy_czk_kwh=3.088,
future_sell_opportunity_czk_kwh=6.5,
)
battery = _battery(uc_wh=12_500.0, min_pct=10.0, arb_pct=30.0)
battery.max_charge_power_w = 6250
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
grid = SimpleNamespace(
max_import_power_w=17_000,
max_export_power_w=8000,
block_export_on_negative_sell=False,
purchase_pricing_mode="fixed",
)
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
res, _, _ = solve_dispatch(
[slot],
battery,
hp,
grid,
[None, None],
vehicles,
0.12 * battery.soc_max_wh,
50.0,
operating_mode="AUTO",
)
r = res[0]
self.assertLess(
r.pv_a_curtailed_w,
int(slot.pv_a_forecast_w) - 50,
"nesmí useknout celé A kvůli fixed_pv_b_export_cap",
)
def test_rolling_horizon_drains_to_reserve_before_first_neg(self) -> None:
"""Rolling bez D1 večera: výboj před 1. sell<0 na reserve (+ slack)."""
prague = ZoneInfo("Europe/Prague")
base = datetime(2026, 5, 27, 7, 0, tzinfo=prague)
slots: list[PlanningSlot] = []
for i in range(16):
local = base + timedelta(minutes=15 * i)
sell = 3.0 if i < 10 else -0.2
slots.append(
PlanningSlot(
interval_start=local.astimezone(timezone.utc),
buy_price=5.0,
sell_price=sell,
pv_a_forecast_w=3000,
pv_b_forecast_w=1500,
load_baseline_w=500,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=True,
)
)
bat = NegSellSocPhaseTests._phase_battery()
bat.reserve_soc_wh = 0.20 * bat.usable_capacity_wh
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
grid = SimpleNamespace(
max_import_power_w=20_000,
max_export_power_w=13_500,
block_export_on_negative_sell=False,
)
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
res, _, snap = solve_dispatch(
slots, bat, hp, grid, [None, None], vehicles,
0.55 * bat.soc_max_wh, 50.0, operating_mode="AUTO",
)
anchors = snap["inputs"].get("neg_evening_reserve_soc_anchors") or []
self.assertGreaterEqual(len(anchors), 1)
anchor_iso = anchors[-1]["slot"]
idx = next(i for i, s in enumerate(slots) if s.interval_start.isoformat() == anchor_iso)
cap_wh = float(bat.reserve_soc_wh) + 400.0
soc_wh = res[idx].battery_soc_target / 100.0 * bat.soc_max_wh
self.assertLessEqual(soc_wh, cap_wh + 800.0)
def test_kv1_evening_battery_push_when_sell_below_fixed_buy(self) -> None:
"""KV1: večerní sell < fixní buy — přesto vývoz bat (ne jen jeden peak slot)."""
prague = ZoneInfo("Europe/Prague")
base = datetime(2026, 5, 26, 17, 0, tzinfo=prague)
sells = [1.9, 3.0, 3.7, 2.0, 2.8, 3.3, 4.0, 2.9, 3.5, 4.4, 6.57, 5.4, 5.5, 5.1, 5.2, 4.3]
slots: list[PlanningSlot] = []
for i, sell in enumerate(sells):
slots.append(
PlanningSlot(
interval_start=(base + timedelta(minutes=15 * i)).astimezone(timezone.utc),
buy_price=6.35,
sell_price=sell,
pv_a_forecast_w=4500 if i < 4 else (800 if sell < 4 else 200),
pv_b_forecast_w=0,
load_baseline_w=400,
ev1_connected=False,
ev2_connected=False,
allow_charge=False,
allow_discharge_export=sell > 0,
charge_acquisition_buy_czk_kwh=6.35,
future_sell_opportunity_czk_kwh=6.57,
)
)
battery = _battery(uc_wh=12_500.0, min_pct=10.0, arb_pct=30.0)
battery.max_discharge_power_w = 6250
battery.discharge_slot_buffer = 1.5
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
grid = SimpleNamespace(
max_import_power_w=17_000,
max_export_power_w=8000,
block_export_on_negative_sell=True,
purchase_pricing_mode="fixed",
)
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
res, _, _ = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
0.95 * battery.soc_max_wh,
50.0,
operating_mode="AUTO",
)
self.assertLess(res[10].grid_setpoint_w, -500, "20:15 sell<buy: vývoz bat, ne jen peak 19:45")
for i in range(4):
self.assertNotEqual(
res[i].export_mode,
"BATTERY_SELL",
f"slot {i}: při FVE přebytku ne vývoz z baterie",
)
self.assertEqual(res[1].export_mode, "PV_SURPLUS")
self.assertGreater(abs(res[1].grid_setpoint_w), 500)
self.assertLess(abs(res[1].battery_setpoint_w), 500)
def test_kv1_evening_push_when_sell_above_morning_peak_not_at_dawn(self) -> None:
"""v52: večer sell ≥ ranní max (511) před sell<0 — push, ne až úsvit za horší cenu."""
prague = ZoneInfo("Europe/Prague")
base = datetime(2026, 5, 30, 23, 0, tzinfo=prague)
specs: list[tuple[int, float, float]] = []
for i in range(40):
local = base + timedelta(minutes=15 * i)
h = local.hour
if h >= 23 or h <= 2:
sell = 3.25 if i % 2 == 0 else 3.10
pv_a = 0
elif h == 4:
sell = 2.80
pv_a = 900
elif 5 <= h <= 7:
sell = 2.55
pv_a = 1200
elif h == 8 and local.minute == 15:
sell = -0.20
pv_a = 4000
elif h >= 8:
sell = -0.30
pv_a = 4500
else:
sell = 3.00
pv_a = 0
specs.append((pv_a, sell))
slots: list[PlanningSlot] = []
for i, (pv_a, sell) in enumerate(specs):
local = base + timedelta(minutes=15 * i)
slots.append(
PlanningSlot(
interval_start=local.astimezone(timezone.utc),
buy_price=6.3525,
sell_price=sell,
pv_a_forecast_w=pv_a,
pv_b_forecast_w=0,
load_baseline_w=400,
ev1_connected=False,
ev2_connected=False,
allow_charge=sell < 0,
allow_discharge_export=sell >= 0,
charge_acquisition_buy_czk_kwh=6.3525,
)
)
battery = _battery(uc_wh=12_500.0, min_pct=10.0, arb_pct=30.0)
battery.reserve_soc_percent = 30.0
battery.max_discharge_power_w = 6250
battery.discharge_slot_buffer = 1.5
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
grid = SimpleNamespace(
max_import_power_w=17_000,
max_export_power_w=8000,
block_export_on_negative_sell=True,
purchase_pricing_mode="fixed",
)
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
res, _, snap = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
0.62 * battery.soc_max_wh,
50.0,
operating_mode="AUTO",
)
self.assertTrue(snap["inputs"].get("kv1_evening_push_morning_peak_rule"))
push_iso = set(snap["inputs"].get("evening_push_ts") or [])
self.assertTrue(
any(
slots[i].interval_start.isoformat() in push_iso
for i in range(4)
if _in_evening_push_hour_window(slots[i])
),
"večerní push musí zahrnout slot ≥17h",
)
eve_idx = next(
i
for i in range(len(slots))
if _in_evening_push_hour_window(slots[i])
and float(slots[i].sell_price) >= 3.0
)
self.assertLess(
res[eve_idx].grid_setpoint_w,
-2000,
"večer ~3,3 Kč: vývoz do sítě, ne jen úsvit",
)
dawn_idx = next(i for i, s in enumerate(slots) if _prague_hour(s) == 4)
if slots[dawn_idx].interval_start.isoformat() not in push_iso:
self.assertLess(
abs(res[dawn_idx].battery_setpoint_w),
4000,
"úsvit není jediný velký bat export pokud večer push proběhl",
)
class Home01PvStoreValueTests(unittest.TestCase):
"""FVE: spot sell<0 → nabít/vent B; sell>=0 → LP volí export vs bc (ne tvrdý curtail)."""
def test_morning_pre_neg_discharge_exports_pv_not_full_curtail(self) -> None:
"""07:00 archetyp: sell>0 + PV před buy<0 — FVE do sítě, ne plný ge_bat push + curtail."""
prague = ZoneInfo("Europe/Prague")
base = datetime(2026, 5, 26, 7, 0, tzinfo=prague)
slots: list[PlanningSlot] = []
for i in range(12):
slots.append(
PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=5.9 if i == 0 else (0.9 if i < 8 else -0.5),
sell_price=3.79 if i == 0 else (3.2 if i < 8 else -0.3),
pv_a_forecast_w=629 if i == 0 else (3000 if i < 4 else 8000),
pv_b_forecast_w=0,
load_baseline_w=2000,
ev1_connected=False,
ev2_connected=False,
allow_charge=False,
allow_discharge_export=True,
charge_acquisition_buy_czk_kwh=0.8,
)
)
battery = _battery(uc_wh=64_000.0, min_pct=12.0, arb_pct=20.0)
battery.max_discharge_power_w = 18_000
battery.planner_terminal_soc_value_factor = 0.0
battery.planner_daytime_charge_target_enabled = False
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
grid = SimpleNamespace(max_import_power_w=17_000, max_export_power_w=13_500)
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
results, _, snap = solve_dispatch(
slots, battery, hp, grid, [None, None], vehicles, 0.55 * battery.soc_max_wh, 50.0, operating_mode="AUTO"
)
self.assertEqual(snap["planner_build_tag"], PLANNER_BUILD_TAG)
r0 = results[0]
self.assertLess(
r0.pv_a_curtailed_w,
500,
"nesmí useknout celou FVE kvůli plnému ge_bat push (archetyp 07:00)",
)
self.assertNotEqual(r0.export_mode, "BATTERY_SELL")
if r0.grid_setpoint_w < -500:
self.assertEqual(r0.export_mode, "PV_SURPLUS")
def test_positive_sell_full_battery_exports_pv_not_curtail(self) -> None:
"""Odpoledne sell ~3 Kč, večer ~6,6 — plná baterie: export FVE, ne pv_store curtail."""
slots = [
PlanningSlot(
interval_start=datetime(2026, 5, 25, 12, 0, tzinfo=timezone.utc),
buy_price=2.5,
sell_price=3.0,
pv_a_forecast_w=10_000,
pv_b_forecast_w=0,
load_baseline_w=1800,
ev1_connected=False,
ev2_connected=False,
allow_charge=False,
allow_discharge_export=False,
future_sell_opportunity_czk_kwh=6.6,
)
]
battery = _battery(uc_wh=64_000.0, min_pct=12.0, arb_pct=20.0)
battery.max_charge_power_w = 18_000
battery.planner_terminal_soc_value_factor = 0.0
battery.planner_daytime_charge_target_enabled = False
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
grid = SimpleNamespace(max_import_power_w=17_000, max_export_power_w=13_500)
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
soc0 = 0.98 * battery.soc_max_wh
results, _, _ = solve_dispatch(
slots, battery, hp, grid, [None, None], vehicles, soc0, 50.0, operating_mode="AUTO"
)
r = results[0]
self.assertLess(r.grid_setpoint_w, -500, "přebytek FVE do sítě při kladném sell")
self.assertLess(r.pv_a_curtailed_w, 5000, "nesmí useknout celé pole A kvůli pv_store")
def test_pv_b_low_sell_charges_not_exports(self) -> None:
"""08:30 archetyp: sell ~0,09, večer ~5,5 → bc, ne ge_pv."""
slots = [
PlanningSlot(
interval_start=datetime(2026, 5, 22, 6, 30, tzinfo=timezone.utc),
buy_price=1.017,
sell_price=0.088,
pv_a_forecast_w=0,
pv_b_forecast_w=5313,
load_baseline_w=1961,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=False,
charge_acquisition_buy_czk_kwh=0.526,
future_sell_opportunity_czk_kwh=5.5,
)
]
battery = _battery(uc_wh=64_000.0, min_pct=12.0, arb_pct=20.0)
battery.max_charge_power_w = 18_000
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
grid = SimpleNamespace(max_import_power_w=17_000, max_export_power_w=13_500)
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
soc0 = 0.45 * battery.usable_capacity_wh
results, _, _ = solve_dispatch(
slots, battery, hp, grid, [None, None], vehicles, soc0, 50.0, operating_mode="AUTO"
)
r = results[0]
self.assertGreaterEqual(r.grid_setpoint_w, 0, "nízký sell: žádný export FVE")
self.assertGreater(r.battery_setpoint_w, 500, "přebytek PV do baterie")
def test_negative_sell_no_pv_export_when_battery_has_room(self) -> None:
slots = [
PlanningSlot(
interval_start=datetime(2026, 5, 22, 7, 45, tzinfo=timezone.utc),
buy_price=0.55,
sell_price=-0.266,
pv_a_forecast_w=0,
pv_b_forecast_w=5474,
load_baseline_w=1961,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=False,
charge_acquisition_buy_czk_kwh=0.526,
future_sell_opportunity_czk_kwh=5.5,
)
]
battery = _battery(uc_wh=64_000.0)
battery.max_charge_power_w = 18_000
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
grid = SimpleNamespace(max_import_power_w=17_000, max_export_power_w=13_500)
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
soc0 = 0.5 * battery.usable_capacity_wh
results, _, _ = solve_dispatch(
slots, battery, hp, grid, [None, None], vehicles, soc0, 50.0, operating_mode="AUTO"
)
self.assertGreaterEqual(results[0].grid_setpoint_w, 0)
class SitePowerCapTests(unittest.TestCase):
"""Tvrdé limity site import a součtu nabíjení baterie."""
def test_grid_charge_respects_import_and_battery_caps(self) -> None:
"""home-01 typ: CHARGE slot nesmí překročit 17 kW import ani 18 kW do baterie."""
base = datetime(2026, 5, 22, 8, 45, tzinfo=timezone.utc)
slots = [
PlanningSlot(
interval_start=base,
buy_price=0.7,
sell_price=2.5,
pv_a_forecast_w=5000,
pv_b_forecast_w=0,
load_baseline_w=1961,
ev1_connected=False,
ev2_connected=False,
is_predicted_price=False,
allow_charge=True,
allow_discharge_export=False,
)
]
battery = _battery(uc_wh=64_000.0)
battery.max_charge_power_w = 18_000
battery.max_discharge_power_w = 18_000
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
grid = SimpleNamespace(max_import_power_w=17_000, max_export_power_w=13_500)
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
soc0 = 0.5 * battery.usable_capacity_wh
results, _, _ = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
soc0,
50.0,
operating_mode="AUTO",
)
r = results[0]
self.assertLessEqual(
r.grid_setpoint_w,
17_000,
msg="import ze site ≤ max_import_power_w",
)
self.assertGreaterEqual(r.grid_setpoint_w, 0)
self.assertLessEqual(
r.battery_setpoint_w,
18_000,
msg="nabíjení baterie ≤ max_charge_power_w",
)
self.assertGreater(r.battery_setpoint_w, 0)
def test_battery_export_respects_site_export_cap(self) -> None:
"""SELL slot: vývoz ze site ≤ max_export; vybíjení baterie ≤ max_discharge."""
base = datetime(2026, 5, 22, 18, 0, tzinfo=timezone.utc)
slots = [
PlanningSlot(
interval_start=base,
buy_price=0.5,
sell_price=6.0,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=2500,
ev1_connected=False,
ev2_connected=False,
is_predicted_price=False,
allow_charge=False,
allow_discharge_export=True,
)
]
battery = _battery(uc_wh=64_000.0, min_pct=12.0, arb_pct=20.0)
battery.max_charge_power_w = 18_000
battery.max_discharge_power_w = 18_000
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
grid = SimpleNamespace(max_import_power_w=17_000, max_export_power_w=13_500)
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
soc0 = 0.85 * battery.usable_capacity_wh
results, _, _ = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
soc0,
50.0,
operating_mode="AUTO",
)
r = results[0]
self.assertLessEqual(
-r.grid_setpoint_w,
13_500,
msg="export ze site ≤ max_export_power_w",
)
self.assertLessEqual(
r.export_limit_w,
13_500,
msg="export_limit_w odpovídá site limitu",
)
self.assertLessEqual(abs(r.battery_setpoint_w), 18_000)
class PlannerArbitrageImprovementsTests(unittest.TestCase):
"""Regrese: záporný buy, peak sell před sell<0, večerní export cap."""
def test_pre_neg_peak_ignores_earlier_day_in_horizon(self) -> None:
"""Horizont přes půlnoc: peak je na dni záporného sell, ne včerejší večer."""
base = datetime(2026, 5, 22, 18, 0, tzinfo=timezone.utc)
slots = [
PlanningSlot(
interval_start=base,
buy_price=4.0,
sell_price=4.6,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=1000,
ev1_connected=False,
ev2_connected=False,
),
PlanningSlot(
interval_start=base + timedelta(hours=12),
buy_price=4.0,
sell_price=3.06,
pv_a_forecast_w=3000,
pv_b_forecast_w=0,
load_baseline_w=1000,
ev1_connected=False,
ev2_connected=False,
),
PlanningSlot(
interval_start=base + timedelta(hours=12, minutes=45),
buy_price=0.5,
sell_price=-0.1,
pv_a_forecast_w=5000,
pv_b_forecast_w=0,
load_baseline_w=1000,
ev1_connected=False,
ev2_connected=False,
),
]
first_neg = 2
self.assertEqual(_pre_neg_peak_sell_idx(slots, first_neg), 1)
def test_pre_neg_peak_ignores_midnight_on_same_day(self) -> None:
"""Půlnoc může mít vyšší sell než ráno — peak musí být v pásmu 511, ne 00:00."""
base = datetime(2026, 5, 22, 22, 0, tzinfo=timezone.utc)
slots = [
PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=4.0,
sell_price=3.72 if i == 0 else (3.06 if i == 28 else 2.0),
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=1000,
ev1_connected=False,
ev2_connected=False,
)
for i in range(36)
] + [
PlanningSlot(
interval_start=base + timedelta(minutes=15 * 36),
buy_price=0.5,
sell_price=-0.1,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=1000,
ev1_connected=False,
ev2_connected=False,
),
]
first_neg = 36
peak_idx = _pre_neg_peak_sell_idx(slots, first_neg)
self.assertIsNotNone(peak_idx)
self.assertGreater(_prague_hour(slots[peak_idx]), 4)
self.assertLess(_prague_hour(slots[peak_idx]), 12)
def test_pre_neg_peak_idx_is_highest_positive_sell(self) -> None:
base = datetime(2026, 5, 23, 4, 0, tzinfo=timezone.utc)
slots = [
PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=4.0,
sell_price=3.06 if i == 1 else (1.99 if i == 3 else 2.5),
pv_a_forecast_w=1000,
pv_b_forecast_w=0,
load_baseline_w=1000,
ev1_connected=False,
ev2_connected=False,
)
for i in range(6)
] + [
PlanningSlot(
interval_start=base + timedelta(minutes=15 * 6),
buy_price=0.5,
sell_price=-0.1,
pv_a_forecast_w=4000,
pv_b_forecast_w=0,
load_baseline_w=1000,
ev1_connected=False,
ev2_connected=False,
),
]
self.assertEqual(_pre_neg_peak_sell_idx(slots, 6), 1)
def test_morning_battery_export_at_peak_sell_before_negative_window(self) -> None:
base = datetime(2026, 5, 23, 4, 0, tzinfo=timezone.utc)
sells = [2.5, 3.06, 2.8, 1.99, 1.3, 0.34]
slots = [
PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=4.0,
sell_price=sell,
pv_a_forecast_w=3000,
pv_b_forecast_w=0,
load_baseline_w=1000,
ev1_connected=False,
ev2_connected=False,
allow_charge=False,
allow_discharge_export=(i == 1),
future_sell_opportunity_czk_kwh=3.06,
)
for i, sell in enumerate(sells)
] + [
PlanningSlot(
interval_start=base + timedelta(minutes=15 * len(sells)),
buy_price=0.5,
sell_price=-0.1,
pv_a_forecast_w=5000,
pv_b_forecast_w=0,
load_baseline_w=1000,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=False,
)
]
battery = _battery(uc_wh=64_000.0, min_pct=10.0, arb_pct=20.0)
battery.planner_discharge_floor_percent = 5.0
battery.max_discharge_power_w = 18_000
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
grid = SimpleNamespace(max_import_power_w=17_000, max_export_power_w=13_500)
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
results, _, _ = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
0.5 * battery.soc_max_wh,
50.0,
operating_mode="AUTO",
)
peak_export = max(0, -results[1].grid_setpoint_w) + max(0, -results[1].battery_setpoint_w)
late_export = max(0, -results[3].grid_setpoint_w) + max(0, -results[3].battery_setpoint_w)
self.assertGreater(peak_export, late_export)
def test_negative_buy_grid_charge_without_allow_charge_mask(self) -> None:
base = datetime(2026, 5, 23, 11, 0, tzinfo=timezone.utc)
slots = [
PlanningSlot(
interval_start=base,
buy_price=-0.54,
sell_price=-1.25,
pv_a_forecast_w=8000,
pv_b_forecast_w=5000,
load_baseline_w=2000,
ev1_connected=False,
ev2_connected=False,
allow_charge=False,
allow_discharge_export=False,
)
]
battery = _battery(uc_wh=64_000.0)
battery.max_charge_power_w = 18_000
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
grid = SimpleNamespace(max_import_power_w=17_000, max_export_power_w=13_500)
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
results, _, _ = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
0.4 * battery.soc_max_wh,
50.0,
operating_mode="AUTO",
)
r = results[0]
self.assertGreater(r.grid_setpoint_w, 3000)
self.assertGreater(r.battery_setpoint_w, 1000)
def test_high_sell_discharge_slot_pushes_export_toward_site_cap(self) -> None:
base = datetime(2026, 5, 23, 18, 0, tzinfo=timezone.utc)
slots = [
PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=5.0,
sell_price=4.6,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=1500,
ev1_connected=False,
ev2_connected=False,
allow_charge=False,
allow_discharge_export=True,
charge_acquisition_buy_czk_kwh=0.8,
future_sell_opportunity_czk_kwh=2.0,
)
for i in range(3)
]
battery = _battery(uc_wh=64_000.0, min_pct=12.0, arb_pct=20.0)
battery.planner_terminal_soc_value_factor = 0.15
battery.max_discharge_power_w = 18_000
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
grid = SimpleNamespace(max_import_power_w=17_000, max_export_power_w=13_500)
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
results, _, _ = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
0.8 * battery.soc_max_wh,
50.0,
operating_mode="AUTO",
)
r = results[1]
total_export = max(0, -r.grid_setpoint_w) + max(0, -r.battery_setpoint_w)
self.assertGreaterEqual(total_export, 11_000)
self.assertEqual(r.export_mode, "BATTERY_SELL")
class NegSellSocPhaseTests(unittest.TestCase):
"""Fázované SoC v okně sell<0 (v35): rampa z PV B, tail, vent B s prahem."""
@staticmethod
def _phase_battery(**kw: float) -> SimpleNamespace:
bat = _battery(uc_wh=64_000.0, max_pct=95.0)
bat.planner_neg_sell_prep_soc_percent = kw.get("prep_pct", 80.0)
bat.planner_neg_sell_full_soc_tail_slots = int(kw.get("tail_slots", 4))
vent = kw.get("vent_min", -1.0)
bat.planner_neg_sell_vent_min_sell_czk_kwh = None if vent is None else float(vent)
return bat
@staticmethod
def _neg_sell_slots(
n: int,
*,
sell: float = -0.2,
pv_a: int = 8000,
pv_b: int = 4000,
) -> list[PlanningSlot]:
base = datetime(2026, 6, 10, 8, 0, tzinfo=ZoneInfo("Europe/Prague")).astimezone(timezone.utc)
out: list[PlanningSlot] = []
for i in range(n):
out.append(
PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=2.0,
sell_price=sell,
pv_a_forecast_w=pv_a,
pv_b_forecast_w=pv_b,
load_baseline_w=2000,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=False,
)
)
return out
def test_phases_enabled_helper(self) -> None:
bat = self._phase_battery()
self.assertTrue(_neg_sell_phases_enabled(bat))
bat_legacy = self._phase_battery(prep_pct=100.0)
self.assertFalse(_neg_sell_phases_enabled(bat_legacy))
def test_day_phases_tail_last_four(self) -> None:
slots = self._neg_sell_slots(10)
bat = self._phase_battery(tail_slots=4)
phases, targets, _w, meta = _neg_sell_day_phases(slots, bat)
self.assertEqual(phases[5], "prep")
self.assertEqual(phases[9], "tail")
self.assertEqual(phases.count("tail"), 4)
self.assertAlmostEqual(float(targets[9] or 0), bat.soc_max_wh, delta=50.0)
self.assertTrue(meta.get("neg_sell_b_ramp_v35"))
prep_targets = [float(targets[t] or 0) for t in range(6) if phases[t] == "prep"]
self.assertGreater(len(prep_targets), 1)
for a, b in zip(prep_targets, prep_targets[1:]):
self.assertGreaterEqual(b, a - 1.0)
def test_b_ramp_t_detach_and_surplus_meta(self) -> None:
slots = self._neg_sell_slots(12, pv_b=6000)
bat = self._phase_battery(tail_slots=4)
_ph, _tg, _w, meta = _neg_sell_day_phases(slots, bat)
self.assertIsNotNone(meta.get("t_detach_idx"))
self.assertGreaterEqual(int(meta["t_detach_idx"]), 0)
self.assertLessEqual(int(meta["t_detach_idx"]), 8)
self.assertGreater(float(meta.get("e_surplus_after_t_wh") or 0), 0.0)
self.assertIn("post_detach_prep_ts", meta)
def test_prep_leaves_headroom_when_pv_a_b_forecast_high(self) -> None:
"""v44: zpětná soc_need z A+B FVE, ne jen B — 1. sell<0 cíl pod soc_max."""
slots = self._neg_sell_slots(12, pv_a=8000, pv_b=6000)
bat = self._phase_battery(tail_slots=4)
_ph, targets, _w, meta = _neg_sell_day_phases(slots, bat)
first_neg = int(meta["days"][0]["first_neg_idx"])
tgt_first = float(targets[first_neg] or 0)
self.assertLess(tgt_first, bat.soc_max_wh * 0.95)
def test_t_detach_not_first_neg_on_long_sunny_day(self) -> None:
"""Bod T až po nabití rampy (~85 % soc_max), ne na prvním sell<0 slotu."""
slots = self._neg_sell_slots(24, pv_b=7000, pv_a=5000)
bat = self._phase_battery(tail_slots=4)
_ph, _tg, _w, meta = _neg_sell_day_phases(slots, bat)
day = meta["days"][0]
self.assertGreater(
int(day["t_detach_idx"]),
int(day["first_neg_idx"]),
"t_detach must be after first neg slot on long window",
)
def test_prep_reaches_soc_by_mid_window(self) -> None:
slots = self._neg_sell_slots(12)
bat = self._phase_battery()
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
grid = SimpleNamespace(
max_import_power_w=20_000,
max_export_power_w=13_500,
block_export_on_negative_sell=False,
)
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
results, _, snap = solve_dispatch(
slots,
bat,
hp,
grid,
[None, None],
vehicles,
0.35 * bat.soc_max_wh,
50.0,
operating_mode="AUTO",
)
self.assertEqual(snap.get("planner_build_tag"), PLANNER_BUILD_TAG)
self.assertTrue(snap.get("inputs", {}).get("neg_sell_phases_enabled"))
self.assertTrue(snap.get("inputs", {}).get("neg_sell_b_ramp_v35"))
self.assertIsNotNone(snap.get("inputs", {}).get("t_detach_idx"))
# Nabíjení z FVE v sell<0: SoC roste, tail má vyšší cíl než začátek okna.
self.assertGreater(results[-1].battery_soc_target, results[0].battery_soc_target)
self.assertGreaterEqual(results[-1].battery_soc_target, 75.0)
masks = snap.get("masks") or []
phases = {m.get("neg_sell_phase") for m in masks if isinstance(m, dict)}
self.assertIn("prep", phases)
self.assertIn("tail", phases)
def test_hold_curtails_pv_a_when_soc_high(self) -> None:
slots = self._neg_sell_slots(8)
bat = self._phase_battery()
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
grid = SimpleNamespace(
max_import_power_w=20_000,
max_export_power_w=13_500,
block_export_on_negative_sell=False,
)
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
results, _, _ = solve_dispatch(
slots,
bat,
hp,
grid,
[None, None],
vehicles,
0.85 * bat.soc_max_wh,
50.0,
operating_mode="AUTO",
)
curtailed_any = any(r.pv_a_curtailed_w > 500 for r in results)
self.assertTrue(
curtailed_any,
"při vysokém SoC v prep fázi očekáván curtail A (pv_a_curtailed_w)",
)
def test_tail_allows_b_vent_when_sell_above_threshold(self) -> None:
slots = self._neg_sell_slots(8, sell=-0.5)
bat = self._phase_battery(vent_min=-1.0)
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
grid = SimpleNamespace(
max_import_power_w=20_000,
max_export_power_w=13_500,
block_export_on_negative_sell=False,
)
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
results, _, _ = solve_dispatch(
slots,
bat,
hp,
grid,
[None, None],
vehicles,
0.82 * bat.soc_max_wh,
50.0,
operating_mode="AUTO",
)
tail_export = max(0, -results[-1].grid_setpoint_w)
self.assertGreater(tail_export, 200)
def test_tail_blocks_voluntary_vent_when_sell_too_negative(self) -> None:
slots = self._neg_sell_slots(8, sell=-12.0, pv_b=6000)
bat = self._phase_battery(vent_min=-1.0)
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
grid = SimpleNamespace(
max_import_power_w=20_000,
max_export_power_w=13_500,
block_export_on_negative_sell=False,
)
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
results, _, _ = solve_dispatch(
slots,
bat,
hp,
grid,
[None, None],
vehicles,
0.82 * bat.soc_max_wh,
50.0,
operating_mode="AUTO",
)
self.assertLessEqual(max(0, -results[-1].grid_setpoint_w), 500)
class PreNegPvExportForecastTests(unittest.TestCase):
"""v33/v35: export FVE před sell<0 jen pokud forecast B v sell<0 okně pokryje soc_need z rampy."""
@staticmethod
def _slots_morning_then_neg(n: int = 22, *, neg_pv_scale: float = 1.0) -> list[PlanningSlot]:
base = datetime(2026, 6, 10, 6, 0, tzinfo=timezone.utc)
out: list[PlanningSlot] = []
for i in range(n):
sell = -0.25 if i >= 6 else (2.8 if i < 4 else 1.2)
if i >= 6:
pv_a = (8000 + (i - 6) * 500) * neg_pv_scale
# v35 cushion: usable jen z B — dostatečný B pro rampu v test_cushion_ok
pv_b = 9500.0 * neg_pv_scale
else:
pv_a = 1500 + i * 400
pv_b = 1500.0
future_sell = 6.5 if sell >= 0 else None
out.append(
PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=2.0,
sell_price=sell,
pv_a_forecast_w=pv_a,
pv_b_forecast_w=pv_b,
load_baseline_w=450,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=False,
future_sell_opportunity_czk_kwh=future_sell,
)
)
return out
def test_cushion_ok_when_neg_window_pv_large(self) -> None:
slots = self._slots_morning_then_neg()
bat = _battery(uc_wh=64_000.0, max_pct=95.0)
bat.planner_neg_sell_prep_soc_percent = 80.0
bat.planner_neg_sell_full_soc_tail_slots = 4
self.assertTrue(
_pre_neg_pv_export_forecast_cushion_ok(
slots,
bat,
0.30 * bat.soc_max_wh,
6,
neg_sell_phases_en=True,
)
)
def test_cushion_fail_when_neg_window_pv_tiny(self) -> None:
slots = self._slots_morning_then_neg(neg_pv_scale=0.05)
bat = _battery(uc_wh=64_000.0, max_pct=95.0)
bat.planner_neg_sell_prep_soc_percent = 80.0
bat.planner_neg_sell_full_soc_tail_slots = 4
self.assertFalse(
_pre_neg_pv_export_forecast_cushion_ok(
slots,
bat,
0.30 * bat.soc_max_wh,
6,
neg_sell_phases_en=True,
)
)
def test_morning_exports_pv_when_cushion_ok(self) -> None:
slots = self._slots_morning_then_neg()
bat = _battery(uc_wh=64_000.0, max_pct=95.0)
bat.planner_neg_sell_prep_soc_percent = 80.0
bat.planner_neg_sell_full_soc_tail_slots = 4
bat.planner_neg_sell_vent_min_sell_czk_kwh = -1.0
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
grid = SimpleNamespace(
max_import_power_w=20_000,
max_export_power_w=13_500,
block_export_on_negative_sell=False,
)
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
results, _, snap = solve_dispatch(
slots,
bat,
hp,
grid,
[None, None],
vehicles,
0.30 * bat.soc_max_wh,
50.0,
operating_mode="AUTO",
)
self.assertTrue(snap["inputs"].get("pre_neg_pv_export_forecast_ok"))
self.assertIn(
slots[2].interval_start.isoformat(),
snap["inputs"].get("pre_neg_pv_export_slots") or [],
)
self.assertLess(results[2].grid_setpoint_w, -500)
def test_morning_charges_when_cushion_fail(self) -> None:
slots = self._slots_morning_then_neg(neg_pv_scale=0.05)
bat = _battery(uc_wh=64_000.0, max_pct=95.0)
bat.planner_neg_sell_prep_soc_percent = 80.0
bat.planner_neg_sell_full_soc_tail_slots = 4
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
grid = SimpleNamespace(
max_import_power_w=20_000,
max_export_power_w=13_500,
block_export_on_negative_sell=False,
)
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
results, _, snap = solve_dispatch(
slots,
bat,
hp,
grid,
[None, None],
vehicles,
0.30 * bat.soc_max_wh,
50.0,
operating_mode="AUTO",
)
self.assertFalse(snap["inputs"].get("pre_neg_pv_export_forecast_ok"))
self.assertGreater(results[2].battery_setpoint_w, 2000)
class NegSellPrepWindowV36Tests(unittest.TestCase):
"""v36: pre-neg per den, opravený bod T, večerní výboj před neg dnem."""
def test_pre_neg_bundle_second_calendar_day(self) -> None:
# Dva pražské dny: den 1 odpoledne neg, den 2 ráno před neg.
base = datetime(2026, 6, 10, 10, 0, tzinfo=ZoneInfo("Europe/Prague")).astimezone(
timezone.utc
)
slots: list[PlanningSlot] = []
for i in range(120):
local = (base + timedelta(minutes=15 * i)).astimezone(
ZoneInfo("Europe/Prague")
)
h = local.hour + local.minute / 60.0
if local.date().day == 10:
sell = -0.2 if h >= 14 else 2.5
elif local.date().day == 11:
sell = -0.2 if 9 <= h < 15 else 2.8
else:
sell = 2.5
slots.append(
PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=2.0,
sell_price=sell,
pv_a_forecast_w=7000,
pv_b_forecast_w=9000,
load_baseline_w=500,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=True,
)
)
bat = _battery(uc_wh=64_000.0, max_pct=95.0)
bat.planner_neg_sell_prep_soc_percent = 80.0
bat.planner_neg_sell_full_soc_tail_slots = 4
_ph, tg, _w, meta = _neg_sell_day_phases(slots, bat)
export_ts, cushion = _pre_neg_pv_export_bundle(
slots,
bat,
0.35 * bat.soc_max_wh,
None,
neg_sell_phases_en=True,
soc_target_by_t=tg,
)
self.assertGreaterEqual(len(cushion), 2)
self.assertGreater(len(export_ts), 0)
if len(meta.get("days", [])) >= 2:
second_first = int(meta["days"][1]["first_neg_idx"])
second_morning = [
t
for t in export_ts
if t < second_first and float(slots[t].sell_price) >= 0.0
]
self.assertGreater(
len(second_morning),
0,
"morning before 2nd neg day should allow pre-neg export",
)
def test_evening_reserve_anchor_before_neg_day(self) -> None:
base = datetime(2026, 6, 10, 10, 0, tzinfo=ZoneInfo("Europe/Prague")).astimezone(
timezone.utc
)
slots: list[PlanningSlot] = []
for i in range(120):
local = (base + timedelta(minutes=15 * i)).astimezone(
ZoneInfo("Europe/Prague")
)
h = local.hour + local.minute / 60.0
if local.date().day == 10:
sell = -0.2 if h >= 14 else 2.5
elif local.date().day == 11:
sell = -0.2 if 9 <= h < 15 else 2.8
else:
sell = 2.5
slots.append(
PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=2.0,
sell_price=sell,
pv_a_forecast_w=3000,
pv_b_forecast_w=3000,
load_baseline_w=1500,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=True,
)
)
bat = _battery(uc_wh=64_000.0, max_pct=95.0, arb_pct=20.0)
bat.planner_neg_sell_prep_soc_percent = 80.0
bat.planner_neg_sell_full_soc_tail_slots = 4
_ph, _tg, _w, meta = _neg_sell_day_phases(slots, bat)
anchors = _neg_evening_reserve_soc_anchors(slots, meta, bat)
self.assertGreaterEqual(len(anchors), 1)
t_a, tgt = anchors[0]
self.assertAlmostEqual(tgt, bat.reserve_soc_wh, delta=100.0)
self.assertEqual(_prague_calendar_date(slots[t_a]).day, 10)
# Kotva pro den 11: večer 10.6. (i když odpoledne 10.6. už bylo sell<0).
if len(meta["days"]) >= 2:
day11_first = int(meta["days"][1]["first_neg_idx"])
prev = _prague_calendar_date(slots[day11_first]) - timedelta(days=1)
a11 = [(t, w) for t, w in anchors if _prague_calendar_date(slots[t]) == prev]
self.assertGreaterEqual(len(a11), 1)
def test_evening_reserve_soc_near_reserve_after_discharge(self) -> None:
"""v36d: capped slack + večerní ge_bat → SoC u kotvy ≤ reserve + max slack."""
base = datetime(2026, 6, 10, 10, 0, tzinfo=ZoneInfo("Europe/Prague")).astimezone(
timezone.utc
)
slots: list[PlanningSlot] = []
for i in range(96):
local = (base + timedelta(minutes=15 * i)).astimezone(
ZoneInfo("Europe/Prague")
)
h = local.hour + local.minute / 60.0
if local.date().day == 10:
sell = 3.2
else:
sell = -0.2 if 9 <= h < 15 else 2.8
slots.append(
PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=2.0,
sell_price=sell,
pv_a_forecast_w=4000,
pv_b_forecast_w=5000,
load_baseline_w=500,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=True,
)
)
bat = _battery(uc_wh=64_000.0, max_pct=95.0, arb_pct=20.0)
bat.reserve_soc_wh = 0.20 * bat.usable_capacity_wh
bat.planner_neg_sell_prep_soc_percent = 80.0
bat.planner_neg_sell_full_soc_tail_slots = 4
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
grid = SimpleNamespace(
max_import_power_w=20_000,
max_export_power_w=13_500,
block_export_on_negative_sell=False,
)
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
results, _, snap = solve_dispatch(
slots,
bat,
hp,
grid,
[None, None],
vehicles,
0.55 * bat.soc_max_wh,
50.0,
operating_mode="AUTO",
)
self.assertEqual(snap.get("planner_build_tag"), PLANNER_BUILD_TAG)
anchors = snap["inputs"].get("neg_evening_reserve_soc_anchors") or []
self.assertGreaterEqual(len(anchors), 1)
anchor_iso = anchors[-1]["slot"]
anchor_idx = next(
i for i, s in enumerate(slots) if s.interval_start.isoformat() == anchor_iso
)
cap_wh = float(bat.reserve_soc_wh) + 400.0
soc_wh = results[anchor_idx].battery_soc_target / 100.0 * bat.soc_max_wh
self.assertLessEqual(soc_wh, cap_wh + 800.0)
eve_slots = snap["inputs"].get("neg_evening_before_neg_slots") or []
self.assertGreater(len(eve_slots), 8)
push_slots = snap["inputs"].get("neg_evening_push_slots") or []
self.assertGreater(len(push_slots), 0)
self.assertIn("observed_soc_wh", snap["inputs"])
self.assertIsNotNone(snap["inputs"].get("neg_evening_export_budget_wh"))
class NegDayPvHeadroomV44Tests(unittest.TestCase):
"""v44: neg den — žádný grid před sell<0; headroom pro FVE + levný buy v okně."""
def test_no_grid_charge_before_first_negative_sell(self) -> None:
prague = ZoneInfo("Europe/Prague")
base = datetime(2026, 5, 30, 5, 45, tzinfo=prague)
slots: list[PlanningSlot] = []
first_neg_idx: int | None = None
for i in range(24):
local = base + timedelta(minutes=15 * i)
sell = (
-0.18
if local.hour > 7 or (local.hour == 7 and local.minute >= 45)
else 3.0
)
if first_neg_idx is None and sell < 0:
first_neg_idx = i
buy = 3.2 if local.hour < 8 else 0.48
allow_chg = sell < 0
slots.append(
PlanningSlot(
interval_start=local.astimezone(timezone.utc),
buy_price=buy,
sell_price=sell,
pv_a_forecast_w=4000 if local.hour >= 8 else 500,
pv_b_forecast_w=3000 if local.hour >= 8 else 500,
load_baseline_w=2000,
ev1_connected=False,
ev2_connected=False,
allow_charge=allow_chg,
allow_discharge_export=True,
)
)
self.assertIsNotNone(first_neg_idx)
bat = NegSellSocPhaseTests._phase_battery()
hp = SimpleNamespace(
rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0
)
grid = SimpleNamespace(
max_import_power_w=17_000,
max_export_power_w=13_500,
block_export_on_negative_sell=False,
)
vehicles = [
SimpleNamespace(
max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0
),
SimpleNamespace(
max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0
),
]
res, _, _ = solve_dispatch(
slots,
bat,
hp,
grid,
[None, None],
vehicles,
0.50 * bat.soc_max_wh,
50.0,
operating_mode="AUTO",
)
assert first_neg_idx is not None
for t in range(first_neg_idx):
self.assertLessEqual(
res[t].battery_setpoint_w,
200,
msg=f"grid/PV bat charge before neg at slot {t}",
)
self.assertLess(
res[first_neg_idx].battery_soc_target,
92.0,
"baterie nesmí být plná těsně před sell<0 oknem",
)
class ObservedSocNegPrepTests(unittest.TestCase):
"""v40: neg-prep a večerní výboj z pozorovaného SoC (telemetrie), ne z LP trajektorie."""
def test_cushion_ok_when_observed_above_prep_target(self) -> None:
base = datetime(2026, 6, 11, 6, 0, tzinfo=ZoneInfo("Europe/Prague")).astimezone(
timezone.utc
)
slots: list[PlanningSlot] = []
for i in range(48):
local = (base + timedelta(minutes=15 * i)).astimezone(
ZoneInfo("Europe/Prague")
)
sell = -0.2 if local.hour >= 10 else 3.0
slots.append(
PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=2.0,
sell_price=sell,
pv_a_forecast_w=5000,
pv_b_forecast_w=8000,
load_baseline_w=500,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=True,
)
)
bat = _battery(uc_wh=64_000.0, max_pct=95.0)
bat.planner_neg_sell_prep_soc_percent = 80.0
bat.planner_neg_sell_full_soc_tail_slots = 4
_ph, tg, _w, _meta = _neg_sell_day_phases(slots, bat)
first_neg = next(i for i, s in enumerate(slots) if float(s.sell_price) < 0)
tgt = float(tg[first_neg] or bat.soc_max_wh)
observed_high = tgt + 5000.0
self.assertTrue(
_pre_neg_pv_export_forecast_cushion_ok_for_day(
slots,
bat,
first_neg,
observed_high,
neg_sell_phases_en=True,
soc_target_by_t=tg,
),
"pozorované SoC nad prep cílem → cushion bez forecastu",
)
def test_pre_neg_bundle_uses_observed_not_model_chain(self) -> None:
base = datetime(2026, 6, 10, 10, 0, tzinfo=ZoneInfo("Europe/Prague")).astimezone(
timezone.utc
)
slots: list[PlanningSlot] = []
for i in range(120):
local = (base + timedelta(minutes=15 * i)).astimezone(
ZoneInfo("Europe/Prague")
)
h = local.hour + local.minute / 60.0
if local.date().day == 10:
sell = -0.2 if h >= 14 else 2.5
elif local.date().day == 11:
sell = -0.2 if 9 <= h < 15 else 2.8
else:
sell = 2.5
slots.append(
PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=2.0,
sell_price=sell,
pv_a_forecast_w=7000,
pv_b_forecast_w=9000,
load_baseline_w=500,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=True,
)
)
bat = _battery(uc_wh=64_000.0, max_pct=95.0)
bat.planner_neg_sell_prep_soc_percent = 80.0
bat.planner_neg_sell_full_soc_tail_slots = 4
_ph, tg, _w, meta = _neg_sell_day_phases(slots, bat)
observed = 0.72 * bat.soc_max_wh
export_ts, cushion = _pre_neg_pv_export_bundle(
slots,
bat,
observed,
None,
neg_sell_phases_en=True,
soc_target_by_t=tg,
)
self.assertTrue(all(cushion.values()))
if len(meta.get("days", [])) >= 2:
second_first = int(meta["days"][1]["first_neg_idx"])
second_morning = [
t for t in export_ts if t < second_first and float(slots[t].sell_price) >= 0.0
]
self.assertGreater(len(second_morning), 0)
def test_neg_evening_push_scales_with_observed_soc(self) -> None:
base = datetime(2026, 6, 10, 18, 0, tzinfo=ZoneInfo("Europe/Prague")).astimezone(
timezone.utc
)
slots: list[PlanningSlot] = []
for i in range(16):
slots.append(
PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=2.0,
sell_price=3.0 + i * 0.1,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=500,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=True,
)
)
candidates = set(range(16))
bat = _battery(uc_wh=64_000.0, max_pct=95.0, arb_pct=20.0)
reserve = float(bat.reserve_soc_wh)
per_slot = 3000.0
budget_low = _neg_evening_discharge_budget_wh(
observed_soc_wh=reserve + 2000.0,
reserve_soc_wh=reserve,
night_baseload_buffer_wh=1000.0,
)
budget_high = _neg_evening_discharge_budget_wh(
observed_soc_wh=0.70 * bat.soc_max_wh,
reserve_soc_wh=reserve,
night_baseload_buffer_wh=1000.0,
)
push_low = _neg_evening_before_neg_push_indices(
slots,
candidates,
export_budget_wh=budget_low,
per_slot_discharge_wh=per_slot,
)
push_high = _neg_evening_before_neg_push_indices(
slots,
candidates,
export_budget_wh=budget_high,
per_slot_discharge_wh=per_slot,
)
self.assertLess(len(push_low), len(push_high))
class SocBalanceDischargeTests(unittest.TestCase):
"""SoC bilance: při exportu z baterie stačí bd (ge_bat je v bilanci už započtený)."""
def test_export_slot_soc_drop_not_double_ge_bat(self) -> None:
base = datetime(2026, 5, 28, 18, 0, tzinfo=timezone.utc)
slots = [
PlanningSlot(
interval_start=base,
buy_price=2.0,
sell_price=9.5,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=800,
ev1_connected=False,
ev2_connected=False,
allow_charge=False,
allow_discharge_export=True,
)
]
bat = _battery(uc_wh=64_000.0, max_pct=95.0, arb_pct=20.0)
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
grid = SimpleNamespace(
max_import_power_w=20_000,
max_export_power_w=13_500,
block_export_on_negative_sell=False,
)
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
start_soc_wh = 0.75 * bat.soc_max_wh
results, _, _ = solve_dispatch(
slots,
bat,
hp,
grid,
[None, None],
vehicles,
start_soc_wh,
50.0,
operating_mode="AUTO",
)
end_soc_wh = results[0].battery_soc_target / 100.0 * bat.usable_capacity_wh
drop_wh = start_soc_wh - end_soc_wh
export_w = max(0, -results[0].grid_setpoint_w)
self.assertGreater(export_w, 2000, "solver should export from battery in peak slot")
load_w = 800
eff = bat.discharge_efficiency
expected_drop_wh = (load_w + export_w) * 0.25 / eff
double_count_drop_wh = (load_w + 2 * export_w) * 0.25 / eff
self.assertLess(
drop_wh,
double_count_drop_wh * 0.92,
"SoC must not drop as if ge_bat were counted twice",
)
self.assertAlmostEqual(
drop_wh,
expected_drop_wh,
delta=expected_drop_wh * 0.12,
msg="SoC drop should match bd ≈ load + export from balance",
)
if __name__ == "__main__":
unittest.main()