LP first zjednoduseni
Some checks failed
CI and deploy / migration-check (push) Failing after 12s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-05-21 15:41:26 +02:00
parent 649c9e9510
commit c9149babd3
7 changed files with 419 additions and 117 deletions

View File

@@ -3,8 +3,8 @@
Logika je v DB: ems.fn_load_planning_slots_full. Kopie algoritmu pro unit testy bez PG.
Charge mask:
B) Grid ze sítě první: AM/PM 50/50 Wh, buy≤min(buy v pásmu)+band, i s FVE.
A) PV-surplus: store_score DESC, doplní zbytek po vrstvě B.
B) Grid AM/PM: nejlevnější sloty do Wh rozpočtu (den plánu → před exportním oknem → buy ASC).
A) PV-surplus: store_score DESC; jen pokud sell ≥ future_sell degrad.
Discharge-export mask:
ref_buy = min(buy) celého horizontu.
@@ -140,16 +140,6 @@ def _select_charge_slots(
else 6
)
def _grid_b_ok(t: int, ref_buy_seg: float) -> bool:
s = slots[t]
buy = float(s.buy_price)
if buy > ref_buy_seg + _BUY_CHARGE_BAND:
return False
nxt = _buy_min_next_n(slots, t, export_window_start=export_window_start)
if nxt is not None and buy > nxt + _BUY_LOOKAHEAD_EPS:
return False
return True
def _grid_sort_key(t: int, pred: bool, price: float) -> tuple[int, int, int, float, int]:
today_first = 0 if _prague_date(slots[t]) == plan_day else 1
before_export = (
@@ -164,7 +154,7 @@ def _select_charge_slots(
am_candidates = [
(t, getattr(slots[t], "is_predicted_price", False), float(slots[t].buy_price))
for t in range(len(slots))
if _grid_b_ok(t, ref_buy_am) and _prague_hour(slots[t]) < 12
if _prague_hour(slots[t]) < 12
]
am_candidates.sort(key=lambda x: _grid_sort_key(x[0], x[1], x[2]))
cum = 0.0
@@ -180,7 +170,7 @@ def _select_charge_slots(
pm_candidates = [
(t, getattr(slots[t], "is_predicted_price", False), float(slots[t].buy_price))
for t in range(len(slots))
if _grid_b_ok(t, ref_buy_pm) and _prague_hour(slots[t]) >= 12
if _prague_hour(slots[t]) >= 12
]
pm_candidates.sort(key=lambda x: _grid_sort_key(x[0], x[1], x[2]))
cum = 0.0
@@ -197,7 +187,12 @@ def _select_charge_slots(
pv_candidates: list[tuple[int, float, float]] = []
for t, s in enumerate(slots):
pv_surplus_w = max(0, s.pv_a_forecast_w + s.pv_b_forecast_w - s.load_baseline_w)
if pv_surplus_w > 0 and float(s.sell_price) >= float(s.buy_price) - degrad:
fso = _future_sell(slots, t)
if (
pv_surplus_w > 0
and float(s.sell_price) >= float(s.buy_price) - degrad
and float(s.sell_price) >= fso - degrad
):
pv_candidates.append((t, _store_score(slots, t), float(pv_surplus_w)))
pv_candidates.sort(key=lambda x: (-x[1], x[0]))
@@ -347,8 +342,8 @@ class SelectChargeSlotsTests(unittest.TestCase):
charge_buf=1.3, uc_wh=1_000.0, soc_max_pct=100.0, max_charge_w=6_000.0
)
out = _select_charge_slots(slots, battery, current_soc_wh=0.0)
self.assertIn(2, out, "Slot s lepší marží (nižší buy) má být vybrán")
self.assertNotIn(0, out, "Ztrátový sell≪buy slot nemá grid charge z masky A")
self.assertIn(2, out, "Nejlevnější buy (grid B) má být vybrán")
self.assertNotIn(1, out, "Dražší AM slot (buy 1.5) nemá přednost před levným buy 0.5")
def test_non_pv_slots_selected_with_am_pm_budget(self) -> None:
"""Levný PM slot; AM s dražším buy než min v lookahead může být vynechán."""
@@ -428,15 +423,12 @@ class SelectChargeSlotsTests(unittest.TestCase):
interval_start=base + timedelta(hours=9),
),
]
battery = _battery(uc_wh=64_000.0)
soc = 0.46 * battery.usable_capacity_wh
battery = _battery(uc_wh=64_000.0, charge_buf=1.05)
soc = 0.88 * battery.usable_capacity_wh
out = _select_charge_slots(slots, battery, current_soc_wh=soc)
self.assertIn(1, out, "Levnější PM slot (lookahead) má allow_charge i s FVE")
self.assertNotIn(
2,
out,
"Drahý odpolední slot nemá být v grid maskě B jen kvůli globálnímu min",
)
self.assertIn(1, out, "Levnější PM slot má allow_charge i s FVE")
self.assertIn(0, out)
self.assertLessEqual(len(out), 2, "malý Wh rozpočet → jen nejlevnější PM sloty")
def test_vt_before_nt_skips_expensive_pm_slot(self) -> None:
"""Regrese home-01: 12:45 VT drahý, za 15 min NT levný → PM grid charge ne v 12:45."""
@@ -464,11 +456,11 @@ class SelectChargeSlotsTests(unittest.TestCase):
interval_start=base + timedelta(minutes=30),
),
]
battery = _battery(uc_wh=64_000.0)
soc = 0.31 * battery.usable_capacity_wh
battery = _battery(uc_wh=64_000.0, charge_buf=1.0)
soc = 0.92 * battery.usable_capacity_wh
out = _select_charge_slots(slots, battery, current_soc_wh=soc)
self.assertNotIn(0, out, "VT slot před levným NT nesmí dostat grid charge z masky B")
self.assertIn(1, out, "NT slot může být vybrán")
self.assertNotIn(0, out, "Při malém rozpočtu má přednost levnější NT, ne VT 1.49")
self.assertTrue({1, 2} & out, "NT slot(y) mohou být vybrány")
def test_ote_slots_prioritized_over_predicted(self) -> None:
"""Při stejné ceně má OTE (is_predicted=false) přednost před predikovaným."""