prepsani s opusem dle planu
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-24 22:44:21 +02:00
parent 2d021b15c3
commit 8bef1c6da6
11 changed files with 720 additions and 16 deletions

View File

@@ -233,6 +233,10 @@ class PlanningDispatchMilpTests(unittest.TestCase):
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 = [
@@ -256,6 +260,10 @@ class PlanningDispatchMilpTests(unittest.TestCase):
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")
@@ -1222,7 +1230,7 @@ class NegativeSellPvChargeTests(unittest.TestCase):
50.0,
operating_mode="AUTO",
)
self.assertEqual(snap.get("planner_build_tag"), "2026-05-26-neg-sell-bat-dump-extreme-buy-v11")
self.assertEqual(snap.get("planner_build_tag"), "2026-05-27-self-consistent-grid-mask-v12")
self.assertGreater(
results[0].battery_setpoint_w,
5_500,
@@ -1372,7 +1380,7 @@ class NegativeSellPvChargeTests(unittest.TestCase):
50.0,
operating_mode="AUTO",
)
self.assertEqual(snap.get("planner_build_tag"), "2026-05-26-neg-sell-bat-dump-extreme-buy-v11")
self.assertEqual(snap.get("planner_build_tag"), "2026-05-27-self-consistent-grid-mask-v12")
self.assertEqual(len(results), len(slots))
def test_gen_cutoff_full_soc_neg_sell_with_pv_b_feasible(self) -> None:
@@ -1436,7 +1444,7 @@ class NegativeSellPvChargeTests(unittest.TestCase):
55.0,
operating_mode="AUTO",
)
self.assertEqual(snap.get("planner_build_tag"), "2026-05-26-neg-sell-bat-dump-extreme-buy-v11")
self.assertEqual(snap.get("planner_build_tag"), "2026-05-27-self-consistent-grid-mask-v12")
self.assertEqual(len(results), len(slots))
def test_fixed_tariff_neg_sell_no_grid_export(self) -> None:
@@ -2583,6 +2591,116 @@ class Home01RegressionTests(unittest.TestCase):
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."""