5353 lines
204 KiB
Python
5353 lines
204 KiB
Python
"""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 02–06h (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 peak−eps 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 (max−load)/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_kv1_evening_push_profitable_vs_morning_zone_peak(self) -> None:
|
||
"""v52: KV1 večer ≥ ranní max (5–11) − 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 D−1 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 (5–11) 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 5–11, 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()
|