dalsi ladenik
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-21 14:10:22 +02:00
parent ba1cdcbee4
commit 739249a244
3 changed files with 116 additions and 16 deletions

View File

@@ -32,10 +32,17 @@ def _future_sell(slots: list[PlanningSlot], t: int) -> float:
return max(tail) if tail else float(slots[t].sell_price)
def _buy_min_next_n(slots: list[PlanningSlot], t: int, n: int = _LOOKAHEAD_SLOTS) -> float | None:
def _buy_min_next_n(
slots: list[PlanningSlot],
t: int,
n: int = _LOOKAHEAD_SLOTS,
*,
export_window_start: datetime | None = None,
) -> float | None:
tail = [
float(slots[i].buy_price)
for i in range(t + 1, min(t + 1 + n, len(slots)))
if export_window_start is None or slots[i].interval_start < export_window_start
]
return min(tail) if tail else None
@@ -74,6 +81,12 @@ def _select_charge_slots(
(float(s.buy_price) for s in slots if _prague_hour(s) >= 12),
default=min(float(s.buy_price) for s in slots),
)
ref_buy_global = min(float(s.buy_price) for s in slots)
export_window_start: datetime | None = None
for s in slots:
if float(s.sell_price) > ref_buy_global + degrad:
if export_window_start is None or s.interval_start < export_window_start:
export_window_start = s.interval_start
eta = float(getattr(battery, "charge_efficiency", 1.0) or 1.0)
max_p_w = float(getattr(battery, "max_charge_power_w", 0.0) or 0.0)
@@ -117,18 +130,26 @@ def _select_charge_slots(
buy = float(s.buy_price)
if buy > ref_buy_seg + _BUY_CHARGE_BAND:
return False
nxt = _buy_min_next_n(slots, t)
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, float, int]:
before_export = 0
if export_window_start is not None and slots[t].interval_start < export_window_start:
before_export = 0
else:
before_export = 1
return (before_export, int(pred), price, t)
if purchase_pricing_mode != "fixed":
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
]
am_candidates.sort(key=lambda x: (int(x[1]), x[2], x[0]))
am_candidates.sort(key=lambda x: _grid_sort_key(x[0], x[1], x[2]))
cum = 0.0
grid_am = 0
for t, _pred, _price in am_candidates:
@@ -144,7 +165,7 @@ def _select_charge_slots(
for t in range(len(slots))
if _grid_b_ok(t, ref_buy_pm) and _prague_hour(slots[t]) >= 12
]
pm_candidates.sort(key=lambda x: (int(x[1]), x[2], x[0]))
pm_candidates.sort(key=lambda x: _grid_sort_key(x[0], x[1], x[2]))
cum = 0.0
grid_pm = 0
for t, _pred, _price in pm_candidates:
@@ -326,6 +347,37 @@ class SelectChargeSlotsTests(unittest.TestCase):
out = _select_charge_slots(slots, battery, current_soc_wh=0.0)
self.assertIn(2, out, "Nejlevnější buy v horizontu (PM) musí být vybrán")
def test_pm_grid_prefers_today_before_export_over_tomorrow_cheaper(self) -> None:
"""Dnes PM levné před večerním exportem má prioritu před zítřejším min(buy)."""
base = datetime(2026, 5, 21, 10, 0, tzinfo=timezone.utc)
slots = [
_slot(buy=0.72, sell=-0.1, pv=500, load=3000, interval_start=base),
_slot(buy=0.68, sell=-0.15, pv=500, load=3000, interval_start=base + timedelta(minutes=15)),
_slot(
buy=5.5,
sell=3.8,
pv=0,
load=2500,
interval_start=base + timedelta(hours=7, minutes=30),
),
_slot(
buy=0.50,
sell=-0.3,
pv=2000,
load=5000,
interval_start=base + timedelta(hours=26),
),
]
battery = _battery(uc_wh=64_000.0)
out = _select_charge_slots(slots, battery, current_soc_wh=0.46 * battery.usable_capacity_wh)
self.assertIn(0, out)
self.assertIn(1, out)
self.assertEqual(
min(out),
0,
"Před exportním oknem musí být vybrány dnešní levné PM sloty dřív než zítřejší min(buy)",
)
def test_cheap_pm_with_pv_surplus_gets_grid_charge(self) -> None:
"""Regrese home-01: levné PM VT (~0,8) i s FVE musí projít grid maskou B."""
base = datetime(2026, 5, 21, 10, 0, tzinfo=timezone.utc)