oprava nevyberu maximalnich sell slotu (sahal i na zitejsi vecer)
Some checks failed
CI and deploy / migration-check (push) Failing after 13s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-05-30 22:56:28 +02:00
parent 4f67aad4d8
commit 830aa7a4cc
5 changed files with 227 additions and 59 deletions

View File

@@ -17,7 +17,9 @@ from services.planning_engine import (
_evening_peak_export_indices,
_slot_evening_push_profitable,
_evening_push_calendar_segments,
_evening_push_soc_budget_calendar_segments,
_evening_push_discharge_budget_wh,
_primary_night_export_segment_indices,
_in_evening_push_hour_window,
_in_night_battery_export_window,
_neg_sell_day_phases,
@@ -285,28 +287,64 @@ class EveningPushBudgetTests(unittest.TestCase):
)
self.assertEqual(push, [])
def test_per_calendar_evening_push_budget_split(self) -> None:
"""Dva večery v horizontu → každý dostane část Wh rozpočtu (druhý den ne prázdný)."""
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 day in (25, 26):
for h, m in ((18, 0), (18, 15), (18, 30)):
slots.append(
PlanningSlot(
interval_start=datetime(2026, 5, day, 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,
)
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,
)
segs = _evening_push_calendar_segments(slots, discharge_export_ok=set(range(len(slots))))
self.assertEqual(len(segs), 2)
)
# 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(
@@ -318,24 +356,25 @@ class EveningPushBudgetTests(unittest.TestCase):
soc_max_wh=bat.soc_max_wh,
per_slot_discharge_wh=per_slot,
discharge_slot_buffer=1.5,
discharge_export_ok=set(range(len(slots))),
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.assertGreaterEqual(len(day26), 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,
min_soc_wh=bat.min_soc_wh,
discharge_floor_wh=floor,
soc_max_wh=bat.soc_max_wh,
discharge_slot_buffer=1.5,
)
exportable_full = bat.soc_max_wh - bat.min_soc_wh
available = soc - bat.min_soc_wh
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:
@@ -360,26 +399,27 @@ class EveningPushBudgetTests(unittest.TestCase):
]
bat = _battery(uc_wh=64_000.0, min_pct=10.0, max_pct=95.0)
per_slot = 17_000 * 0.95 * 0.25
soc_three_slots = bat.min_soc_wh + 3.2 * per_slot
floor = bat.min_soc_wh
soc_for_budget = floor + 3.2 * per_slot
budget = _evening_push_discharge_budget_wh(
current_soc_wh=soc_three_slots,
min_soc_wh=bat.min_soc_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 = min(3, max(0, int(budget // per_slot)))
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_three_slots,
min_soc_wh=bat.min_soc_wh,
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, [0, 1, 2], "nejdražší sloty první, ne jeden slot")
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(