dalsi fix - chtel drzet baterii prakticky porad a neprodat ani nejeet passive mode
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-16 15:52:14 +02:00
parent 1426c0e153
commit a17c22d475
3 changed files with 115 additions and 14 deletions

View File

@@ -1,15 +1,14 @@
"""Pre-selection nabíjecích slotů (anti-micro-cycling) referenční Python.
"""Pre-selection nabíjecích a exportních slotů referenční Python.
Logika je v DB: ems.fn_load_planning_slots_full. Tento soubor drží kopii algoritmu
pro rychlé unit testy bez PostgreSQL.
Logika je v DB: ems.fn_load_planning_slots_full. Kopie algoritmu pro unit testy bez PG.
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.
Charge mask:
A) PV-surplus: sell_price ASC, dokud PV nepokryje charge target.
B) Non-PV: AM/PM 50/50, OTE-first, buy_price ASC.
Discharge-export mask:
ref_buy = min(buy) mezi allow_charge sloty (arbitráž mezi sloty, ne sell vs buy ve stejném).
Top sloty dle sell_price desc kde sell > ref_buy + degradation.
"""
from __future__ import annotations
@@ -108,6 +107,54 @@ def _select_charge_slots(
return selected
def _select_discharge_export_slots(
slots: list[PlanningSlot],
battery: SimpleNamespace,
current_soc_wh: float,
charge_slots: set[int] | None = None,
) -> set[int]:
"""Kopie logiky z ems.fn_load_planning_slots_full (discharge-export mask)."""
discharge_buf = float(getattr(battery, "discharge_slot_buffer", 0) or 0)
if discharge_buf <= 0:
return set(range(len(slots)))
min_soc_wh = float(getattr(battery, "min_soc_wh", 0) or 0)
soc_max_wh = float(battery.soc_max_wh)
exportable_wh = soc_max_wh - min_soc_wh
if exportable_wh <= 0:
return set()
degrad = float(getattr(battery, "degradation_cost_czk_kwh", 0.15) or 0.15)
eta = float(getattr(battery, "discharge_efficiency", 1.0) or 1.0)
max_p_w = float(getattr(battery, "max_discharge_power_w", 0.0) or 0.0)
per_slot_wh = max_p_w * eta * INTERVAL_H
discharge_target_wh = exportable_wh * discharge_buf
if charge_slots is None:
charge_slots = _select_charge_slots(slots, battery, current_soc_wh)
ref_buy = min(
(float(slots[t].buy_price) for t in charge_slots),
default=min(float(s.buy_price) for s in slots),
)
candidates = [
(t, float(slots[t].sell_price))
for t in range(len(slots))
if float(slots[t].sell_price) > ref_buy + degrad
]
candidates.sort(key=lambda x: (-x[1], -x[0]))
selected: set[int] = set()
cum = 0.0
for t, _sell in candidates:
if cum >= discharge_target_wh or per_slot_wh <= 0:
break
selected.add(t)
cum += per_slot_wh
return selected
def _prague_hour(s: PlanningSlot) -> int:
dt = s.interval_start
if dt.tzinfo is None:
@@ -140,17 +187,28 @@ def _slot(
def _battery(
*,
charge_buf: float = 1.3,
discharge_buf: float = 1.5,
uc_wh: float = 64_000.0,
soc_max_pct: float = 95.0,
min_soc_pct: float = 10.0,
max_charge_w: float = 18_000.0,
max_discharge_w: float = 18_000.0,
charge_eff: float = 0.95,
discharge_eff: float = 0.95,
degrad: float = 0.15,
) -> SimpleNamespace:
uc = uc_wh
return SimpleNamespace(
usable_capacity_wh=uc_wh,
soc_max_wh=soc_max_pct / 100.0 * uc_wh,
usable_capacity_wh=uc,
min_soc_wh=min_soc_pct / 100.0 * uc,
soc_max_wh=soc_max_pct / 100.0 * uc,
max_charge_power_w=max_charge_w,
max_discharge_power_w=max_discharge_w,
charge_efficiency=charge_eff,
discharge_efficiency=discharge_eff,
charge_slot_buffer=charge_buf,
discharge_slot_buffer=discharge_buf,
degradation_cost_czk_kwh=degrad,
)
@@ -241,5 +299,36 @@ class SelectChargeSlotsTests(unittest.TestCase):
)
class SelectDischargeExportSlotsTests(unittest.TestCase):
def test_evening_sell_allowed_when_cheaper_than_ref_charge_buy(self) -> None:
"""Regrese home-01: večer sell 3.3 > ref_buy 0.5 + degrad i když buy ve slotu je 5.6."""
slots = [
_slot(buy=0.50, sell=-0.30, hour_utc=6),
_slot(buy=0.51, sell=-0.29, hour_utc=7),
_slot(buy=5.60, sell=3.30, hour_utc=16),
_slot(buy=5.98, sell=3.37, hour_utc=17),
]
battery = _battery(uc_wh=64_000.0)
charge = _select_charge_slots(slots, battery, current_soc_wh=0.2 * battery.usable_capacity_wh)
discharge = _select_discharge_export_slots(
slots, battery, current_soc_wh=0.2 * battery.usable_capacity_wh, charge_slots=charge
)
self.assertIn(0, charge)
self.assertIn(2, discharge, "Evening sell must qualify vs ref buy, not same-slot buy")
self.assertIn(3, discharge)
def test_export_excluded_when_sell_below_ref_buy_plus_degradation(self) -> None:
slots = [
_slot(buy=0.40, sell=0.10, hour_utc=8),
_slot(buy=4.00, sell=0.50, hour_utc=18),
]
battery = _battery(uc_wh=10_000.0, discharge_buf=2.0)
charge = _select_charge_slots(slots, battery, current_soc_wh=0.0)
discharge = _select_discharge_export_slots(
slots, battery, current_soc_wh=0.0, charge_slots=charge
)
self.assertNotIn(1, discharge, "sell 0.5 < ref 0.4 + 0.15")
if __name__ == "__main__":
unittest.main()