fix cyklovani
This commit is contained in:
@@ -2,16 +2,27 @@
|
||||
|
||||
Logika je v DB: ems.fn_load_planning_slots_full. Tento soubor drží kopii algoritmu
|
||||
pro rychlé unit testy bez PostgreSQL.
|
||||
|
||||
Algoritmus (aktuální):
|
||||
A) PV-surplus sloty (pv_surplus > 0): ranking dle sell_price ASC,
|
||||
vyberou se nejlevnější, dokud kumulativní PV surplus nepokryje
|
||||
charge target (energy_to_fill × charge_buf). Zbylé → PV do sítě.
|
||||
B) Non-PV sloty (pv_surplus <= 0): AM/PM rozpočet 50/50,
|
||||
OTE-first priorita (is_predicted_price=false před true),
|
||||
poté seřazené dle buy_price ASC.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from types import SimpleNamespace
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from services.planning_engine import INTERVAL_H, PlanningSlot
|
||||
|
||||
_PRAGUE = ZoneInfo("Europe/Prague")
|
||||
|
||||
|
||||
def _select_charge_slots(
|
||||
slots: list[PlanningSlot],
|
||||
@@ -25,40 +36,96 @@ def _select_charge_slots(
|
||||
|
||||
energy_to_fill = float(battery.soc_max_wh) - float(current_soc_wh)
|
||||
if energy_to_fill <= 0:
|
||||
return set()
|
||||
return set(range(len(slots)))
|
||||
|
||||
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)
|
||||
per_slot_full_wh = max_p_w * eta * INTERVAL_H
|
||||
charge_target_wh = max(energy_to_fill, 0) * charge_buf
|
||||
|
||||
# AM/PM budget
|
||||
n_am = sum(1 for s in slots if _prague_hour(s) < 12)
|
||||
n_pm = len(slots) - n_am
|
||||
if n_am <= 0:
|
||||
chg_am = 0.0
|
||||
chg_pm = charge_target_wh
|
||||
elif n_pm <= 0:
|
||||
chg_am = charge_target_wh
|
||||
chg_pm = 0.0
|
||||
else:
|
||||
chg_am = charge_target_wh / 2.0
|
||||
chg_pm = charge_target_wh - chg_am
|
||||
|
||||
selected: set[int] = set()
|
||||
|
||||
# A) PV-surplus: cheapest sell_price first
|
||||
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:
|
||||
selected.add(t)
|
||||
pv_candidates.append((t, float(s.sell_price), float(pv_surplus_w)))
|
||||
|
||||
grid_target_wh = energy_to_fill * charge_buf
|
||||
if grid_target_wh <= 0 or per_slot_full_wh <= 0:
|
||||
return selected
|
||||
|
||||
grid_candidates = [
|
||||
(t, float(s.buy_price)) for t, s in enumerate(slots) if t not in selected
|
||||
]
|
||||
grid_candidates.sort(key=lambda x: x[1])
|
||||
|
||||
cumulative = 0.0
|
||||
for t, _price in grid_candidates:
|
||||
if cumulative >= grid_target_wh:
|
||||
pv_candidates.sort(key=lambda x: (x[1], x[0]))
|
||||
cum = 0.0
|
||||
for t, _sell, pv_surplus_w in pv_candidates:
|
||||
if cum >= charge_target_wh:
|
||||
break
|
||||
selected.add(t)
|
||||
cumulative += per_slot_full_wh
|
||||
cum += min(pv_surplus_w, max_p_w) * eta * INTERVAL_H
|
||||
|
||||
# B) Non-PV: AM budget (OTE-first)
|
||||
am_candidates = [
|
||||
(t, getattr(slots[t], "is_predicted_price", False), float(slots[t].buy_price))
|
||||
for t in range(len(slots))
|
||||
if t not in selected
|
||||
and max(0, slots[t].pv_a_forecast_w + slots[t].pv_b_forecast_w - slots[t].load_baseline_w) <= 0
|
||||
and _prague_hour(slots[t]) < 12
|
||||
]
|
||||
am_candidates.sort(key=lambda x: (int(x[1]), x[2], x[0]))
|
||||
cum = 0.0
|
||||
for t, _pred, _price in am_candidates:
|
||||
if cum >= chg_am or per_slot_full_wh <= 0:
|
||||
break
|
||||
selected.add(t)
|
||||
cum += per_slot_full_wh
|
||||
|
||||
# B) Non-PV: PM budget (OTE-first)
|
||||
pm_candidates = [
|
||||
(t, getattr(slots[t], "is_predicted_price", False), float(slots[t].buy_price))
|
||||
for t in range(len(slots))
|
||||
if t not in selected
|
||||
and max(0, slots[t].pv_a_forecast_w + slots[t].pv_b_forecast_w - slots[t].load_baseline_w) <= 0
|
||||
and _prague_hour(slots[t]) >= 12
|
||||
]
|
||||
pm_candidates.sort(key=lambda x: (int(x[1]), x[2], x[0]))
|
||||
cum = 0.0
|
||||
for t, _pred, _price in pm_candidates:
|
||||
if cum >= chg_pm or per_slot_full_wh <= 0:
|
||||
break
|
||||
selected.add(t)
|
||||
cum += per_slot_full_wh
|
||||
|
||||
return selected
|
||||
|
||||
|
||||
def _slot(*, buy: float, sell: float = 1.0, pv: int = 0, load: int = 2_000) -> PlanningSlot:
|
||||
def _prague_hour(s: PlanningSlot) -> int:
|
||||
dt = s.interval_start
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(_PRAGUE).hour
|
||||
|
||||
|
||||
def _slot(
|
||||
*,
|
||||
buy: float,
|
||||
sell: float = 1.0,
|
||||
pv: int = 0,
|
||||
load: int = 2_000,
|
||||
hour_utc: int = 12,
|
||||
predicted: bool = False,
|
||||
) -> PlanningSlot:
|
||||
return PlanningSlot(
|
||||
interval_start=datetime(2026, 4, 19, 12, 0, tzinfo=timezone.utc),
|
||||
interval_start=datetime(2026, 5, 19, hour_utc, 0, tzinfo=timezone.utc),
|
||||
buy_price=buy,
|
||||
sell_price=sell,
|
||||
pv_a_forecast_w=0,
|
||||
@@ -66,6 +133,7 @@ def _slot(*, buy: float, sell: float = 1.0, pv: int = 0, load: int = 2_000) -> P
|
||||
load_baseline_w=load,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
is_predicted_price=predicted,
|
||||
)
|
||||
|
||||
|
||||
@@ -93,63 +161,72 @@ class SelectChargeSlotsTests(unittest.TestCase):
|
||||
out = _select_charge_slots(slots, battery, current_soc_wh=0.0)
|
||||
self.assertEqual(out, set(range(4)))
|
||||
|
||||
def test_pv_surplus_slot_always_selected_regardless_of_buy_price(self) -> None:
|
||||
"""Slot s PV-surplus má být in, i když má nejvyšší buy_price."""
|
||||
slots = [
|
||||
_slot(buy=0.5, pv=0, load=2_000), # bez PV, levný grid
|
||||
_slot(buy=9.9, pv=8_000, load=2_000), # velký PV-surplus, drahý grid
|
||||
]
|
||||
def test_returns_all_when_battery_is_full(self) -> None:
|
||||
slots = [_slot(buy=0.1) for _ in range(3)]
|
||||
battery = _battery()
|
||||
out = _select_charge_slots(slots, battery, current_soc_wh=0.0)
|
||||
self.assertIn(1, out)
|
||||
out = _select_charge_slots(
|
||||
slots, battery, current_soc_wh=battery.soc_max_wh + 1.0
|
||||
)
|
||||
self.assertEqual(out, set(range(3)))
|
||||
|
||||
def test_grid_filler_prefers_lowest_buy_price(self) -> None:
|
||||
"""Mezi neprvními sloty se vybere ten s nejnižší buy_price."""
|
||||
def test_pv_surplus_cheapest_sell_price_selected(self) -> None:
|
||||
"""PV-surplus sloty s nejnižší sell_price se vybírají přednostně."""
|
||||
slots = [
|
||||
_slot(buy=3.0, pv=0, load=2_000, sell=0.1),
|
||||
_slot(buy=0.4, pv=0, load=2_000, sell=0.3),
|
||||
_slot(buy=1.2, pv=0, load=2_000, sell=0.2),
|
||||
_slot(buy=1.0, sell=2.0, pv=8_000, load=2_000),
|
||||
_slot(buy=1.0, sell=5.0, pv=8_000, load=2_000),
|
||||
_slot(buy=1.0, sell=3.0, pv=8_000, load=2_000),
|
||||
]
|
||||
battery = _battery(
|
||||
charge_buf=1.3, uc_wh=64_000.0, soc_max_pct=95.0, max_charge_w=18_000.0
|
||||
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(1, out)
|
||||
self.assertIn(0, out, "Cheapest sell_price PV slot must be selected")
|
||||
self.assertNotIn(1, out, "Expensive sell_price PV slot should be excluded")
|
||||
|
||||
def test_non_pv_slots_selected_with_am_pm_budget(self) -> None:
|
||||
"""Non-PV sloty se vybírají dle buy_price v rámci AM/PM rozpočtu."""
|
||||
slots = [
|
||||
_slot(buy=0.5, hour_utc=4), # AM slot, cheap
|
||||
_slot(buy=3.0, hour_utc=5), # AM slot, expensive
|
||||
_slot(buy=0.4, hour_utc=14), # PM slot, cheap
|
||||
_slot(buy=9.9, hour_utc=15), # PM slot, expensive
|
||||
]
|
||||
battery = _battery(
|
||||
charge_buf=1.3, uc_wh=5_000.0, soc_max_pct=100.0, max_charge_w=18_000.0
|
||||
)
|
||||
out = _select_charge_slots(slots, battery, current_soc_wh=0.0)
|
||||
self.assertIn(0, out, "Cheapest AM slot must be selected")
|
||||
self.assertIn(2, out, "Cheapest PM slot must be selected")
|
||||
|
||||
def test_ote_slots_prioritized_over_predicted(self) -> None:
|
||||
"""OTE sloty (is_predicted_price=false) mají přednost před predikovanými."""
|
||||
slots = [
|
||||
_slot(buy=3.56, hour_utc=13, predicted=False), # OTE, dražší
|
||||
_slot(buy=2.00, hour_utc=13, predicted=True), # predicted, levnější
|
||||
]
|
||||
battery = _battery(
|
||||
charge_buf=1.3, uc_wh=3_000.0, soc_max_pct=100.0, max_charge_w=18_000.0
|
||||
)
|
||||
out = _select_charge_slots(slots, battery, current_soc_wh=0.0)
|
||||
self.assertIn(0, out, "OTE slot must be selected even if pricier than predicted")
|
||||
|
||||
def test_does_not_exclude_slot_just_because_pv_below_load(self) -> None:
|
||||
"""Regrese: dřívější logika vyřazovala sloty bez PV-surplus úplně."""
|
||||
"""Regrese: sloty bez PV-surplus se vybírají přes AM/PM grid budget."""
|
||||
slots = [
|
||||
_slot(buy=0.4, pv=3_320, load=3_747),
|
||||
_slot(buy=0.42, pv=2_116, load=3_747),
|
||||
_slot(buy=0.44, pv=1_649, load=3_747),
|
||||
_slot(buy=0.47, pv=1_276, load=3_747),
|
||||
_slot(buy=1.13, pv=1_286, load=523),
|
||||
_slot(buy=1.60, pv=1_020, load=523),
|
||||
_slot(buy=0.4, pv=3_320, load=3_747, hour_utc=13),
|
||||
_slot(buy=0.42, pv=2_116, load=3_747, hour_utc=13),
|
||||
_slot(buy=0.44, pv=1_649, load=3_747, hour_utc=13),
|
||||
_slot(buy=0.47, pv=1_276, load=3_747, hour_utc=13),
|
||||
]
|
||||
battery = _battery()
|
||||
out = _select_charge_slots(slots, battery, current_soc_wh=0.2 * battery.usable_capacity_wh)
|
||||
for idx in (0, 1, 2, 3):
|
||||
self.assertIn(
|
||||
idx,
|
||||
out,
|
||||
msg=(
|
||||
f"Slot {idx} (levný grid nákup ~0.4 Kč) musí být povolen pro "
|
||||
"nabíjení i bez PV-surplus, jinak optimizer skončí s dražším "
|
||||
"nákupem v pozdějších slotech (nelogická ekonomika)."
|
||||
),
|
||||
)
|
||||
self.assertIn(idx, out)
|
||||
|
||||
def test_long_horizon_pv_surplus_does_not_exhaust_grid_budget(self) -> None:
|
||||
"""Regrese: v 96h horizontu nesmí PV-surplus sloty „vyžrat“ grid rozpočet.
|
||||
|
||||
V dřívější verzi se kumulativní PV budget odečítal od `charge_buf × headroom`,
|
||||
takže v dlouhém horizontu s mnoha PV sloty zbylo 0 na grid filler a levné
|
||||
grid sloty se nepovolily. Tento test simuluje realistický 96h profil.
|
||||
"""
|
||||
# 40 levných grid-only slotů (simulace noční / „inter-peak“ hodiny).
|
||||
"""Regrese: v 96h horizontu nesmí PV-surplus sloty „vyžrat" grid rozpočet."""
|
||||
cheap_grid = [_slot(buy=0.4 + 0.01 * i, pv=0, load=2_000) for i in range(40)]
|
||||
# 100 PV-surplus slotů s velkou výrobou (přes den, přes víc dní).
|
||||
pv_days = [_slot(buy=1.5, pv=10_000, load=2_000) for _ in range(100)]
|
||||
pv_days = [_slot(buy=1.5, sell=1.5, pv=10_000, load=2_000) for _ in range(100)]
|
||||
slots = cheap_grid + pv_days
|
||||
battery = _battery(
|
||||
charge_buf=1.3, uc_wh=64_000.0, soc_max_pct=95.0, max_charge_w=18_000.0
|
||||
@@ -159,36 +236,10 @@ class SelectChargeSlotsTests(unittest.TestCase):
|
||||
self.assertGreaterEqual(
|
||||
grid_selected,
|
||||
5,
|
||||
msg=(
|
||||
"V dlouhém horizontu s mnoha PV-surplus sloty musí zůstat dostatek "
|
||||
"grid slotů povolených pro nabíjení z levného importu."
|
||||
),
|
||||
"V dlouhém horizontu s mnoha PV-surplus sloty musí zůstat dostatek "
|
||||
"grid slotů povolených pro nabíjení z levného importu.",
|
||||
)
|
||||
|
||||
def test_energy_budget_is_charge_buf_times_headroom(self) -> None:
|
||||
"""Součet uvolněné energie se pohybuje okolo charge_buf × (soc_max − current_soc)."""
|
||||
slots = [_slot(buy=float(i + 1), pv=0, load=2_000) for i in range(40)]
|
||||
battery = _battery(
|
||||
charge_buf=1.3, uc_wh=64_000.0, soc_max_pct=95.0, max_charge_w=18_000.0
|
||||
)
|
||||
current_soc_wh = 0.2 * battery.usable_capacity_wh
|
||||
target = battery.charge_slot_buffer * (battery.soc_max_wh - current_soc_wh)
|
||||
per_slot_wh = (
|
||||
battery.max_charge_power_w * battery.charge_efficiency * INTERVAL_H
|
||||
)
|
||||
out = _select_charge_slots(slots, battery, current_soc_wh=current_soc_wh)
|
||||
slots_picked = len(out)
|
||||
self.assertLessEqual((slots_picked - 1) * per_slot_wh, target)
|
||||
self.assertGreaterEqual(slots_picked * per_slot_wh, target)
|
||||
|
||||
def test_returns_empty_when_battery_is_full(self) -> None:
|
||||
slots = [_slot(buy=0.1) for _ in range(3)]
|
||||
battery = _battery()
|
||||
out = _select_charge_slots(
|
||||
slots, battery, current_soc_wh=battery.soc_max_wh + 1.0
|
||||
)
|
||||
self.assertEqual(out, set())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user