fix cyklovani
Some checks failed
CI and deploy / migration-check (push) Failing after 26s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-05-15 17:47:20 +02:00
parent 30f16a14c2
commit d89d8b1e3a
6 changed files with 273 additions and 122 deletions

View File

@@ -2,16 +2,27 @@
Logika je v DB: ems.fn_load_planning_slots_full. Tento soubor drží kopii algoritmu Logika je v DB: ems.fn_load_planning_slots_full. Tento soubor drží kopii algoritmu
pro rychlé unit testy bez PostgreSQL. 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 from __future__ import annotations
import unittest import unittest
from datetime import datetime, timezone from datetime import datetime, timezone, timedelta
from types import SimpleNamespace from types import SimpleNamespace
from zoneinfo import ZoneInfo
from services.planning_engine import INTERVAL_H, PlanningSlot from services.planning_engine import INTERVAL_H, PlanningSlot
_PRAGUE = ZoneInfo("Europe/Prague")
def _select_charge_slots( def _select_charge_slots(
slots: list[PlanningSlot], slots: list[PlanningSlot],
@@ -25,40 +36,96 @@ def _select_charge_slots(
energy_to_fill = float(battery.soc_max_wh) - float(current_soc_wh) energy_to_fill = float(battery.soc_max_wh) - float(current_soc_wh)
if energy_to_fill <= 0: if energy_to_fill <= 0:
return set() return set(range(len(slots)))
eta = float(getattr(battery, "charge_efficiency", 1.0) or 1.0) 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) 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 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() selected: set[int] = set()
# A) PV-surplus: cheapest sell_price first
pv_candidates: list[tuple[int, float, float]] = []
for t, s in enumerate(slots): 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) pv_surplus_w = max(0, s.pv_a_forecast_w + s.pv_b_forecast_w - s.load_baseline_w)
if pv_surplus_w > 0: 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 pv_candidates.sort(key=lambda x: (x[1], x[0]))
if grid_target_wh <= 0 or per_slot_full_wh <= 0: cum = 0.0
return selected for t, _sell, pv_surplus_w in pv_candidates:
if cum >= charge_target_wh:
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:
break break
selected.add(t) 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 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( 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, buy_price=buy,
sell_price=sell, sell_price=sell,
pv_a_forecast_w=0, 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, load_baseline_w=load,
ev1_connected=False, ev1_connected=False,
ev2_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) out = _select_charge_slots(slots, battery, current_soc_wh=0.0)
self.assertEqual(out, set(range(4))) self.assertEqual(out, set(range(4)))
def test_pv_surplus_slot_always_selected_regardless_of_buy_price(self) -> None: def test_returns_all_when_battery_is_full(self) -> None:
"""Slot s PV-surplus má být in, i když má nejvyšší buy_price.""" slots = [_slot(buy=0.1) for _ in range(3)]
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
]
battery = _battery() battery = _battery()
out = _select_charge_slots(slots, battery, current_soc_wh=0.0) out = _select_charge_slots(
self.assertIn(1, out) 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: def test_pv_surplus_cheapest_sell_price_selected(self) -> None:
"""Mezi neprvními sloty se vybere ten s nejnižší buy_price.""" """PV-surplus sloty s nejnižší sell_price se vybírají přednostně."""
slots = [ slots = [
_slot(buy=3.0, pv=0, load=2_000, sell=0.1), _slot(buy=1.0, sell=2.0, pv=8_000, load=2_000),
_slot(buy=0.4, pv=0, load=2_000, sell=0.3), _slot(buy=1.0, sell=5.0, pv=8_000, load=2_000),
_slot(buy=1.2, pv=0, load=2_000, sell=0.2), _slot(buy=1.0, sell=3.0, pv=8_000, load=2_000),
] ]
battery = _battery( 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) 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: 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 = [ slots = [
_slot(buy=0.4, pv=3_320, load=3_747), _slot(buy=0.4, pv=3_320, load=3_747, hour_utc=13),
_slot(buy=0.42, pv=2_116, load=3_747), _slot(buy=0.42, pv=2_116, load=3_747, hour_utc=13),
_slot(buy=0.44, pv=1_649, load=3_747), _slot(buy=0.44, pv=1_649, load=3_747, hour_utc=13),
_slot(buy=0.47, pv=1_276, load=3_747), _slot(buy=0.47, pv=1_276, load=3_747, hour_utc=13),
_slot(buy=1.13, pv=1_286, load=523),
_slot(buy=1.60, pv=1_020, load=523),
] ]
battery = _battery() battery = _battery()
out = _select_charge_slots(slots, battery, current_soc_wh=0.2 * battery.usable_capacity_wh) out = _select_charge_slots(slots, battery, current_soc_wh=0.2 * battery.usable_capacity_wh)
for idx in (0, 1, 2, 3): for idx in (0, 1, 2, 3):
self.assertIn( self.assertIn(idx, out)
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)."
),
)
def test_long_horizon_pv_surplus_does_not_exhaust_grid_budget(self) -> None: 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. """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).
cheap_grid = [_slot(buy=0.4 + 0.01 * i, pv=0, load=2_000) for i in range(40)] 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, sell=1.5, pv=10_000, load=2_000) for _ in range(100)]
pv_days = [_slot(buy=1.5, pv=10_000, load=2_000) for _ in range(100)]
slots = cheap_grid + pv_days slots = cheap_grid + pv_days
battery = _battery( 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=64_000.0, soc_max_pct=95.0, max_charge_w=18_000.0
@@ -159,36 +236,10 @@ class SelectChargeSlotsTests(unittest.TestCase):
self.assertGreaterEqual( self.assertGreaterEqual(
grid_selected, grid_selected,
5, 5,
msg=( "V dlouhém horizontu s mnoha PV-surplus sloty musí zůstat dostatek "
"V dlouhém horizontu s mnoha PV-surplus sloty musí zůstat dostatek " "grid slotů povolených pro nabíjení z levného importu.",
"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__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@@ -57,8 +57,6 @@ declare
v_n_pm int; v_n_pm int;
v_chg_am_wh numeric; v_chg_am_wh numeric;
v_chg_pm_wh numeric; v_chg_pm_wh numeric;
v_dis_am_wh numeric;
v_dis_pm_wh numeric;
v_reserve_wh numeric; v_reserve_wh numeric;
v_daytime_en boolean; v_daytime_en boolean;
v_night_buf_pct numeric; v_night_buf_pct numeric;
@@ -229,24 +227,18 @@ begin
v_per_slot_discharge_wh := v_max_discharge_w * v_discharge_eff * 0.25; v_per_slot_discharge_wh := v_max_discharge_w * v_discharge_eff * 0.25;
v_energy_to_fill := v_soc_max_wh - p_current_soc_wh; v_energy_to_fill := v_soc_max_wh - p_current_soc_wh;
v_exportable := v_soc_max_wh - v_min_soc_wh; v_exportable := v_soc_max_wh - v_min_soc_wh;
v_grid_target_wh := v_energy_to_fill * v_charge_buf; v_grid_target_wh := greatest(v_energy_to_fill, 0) * v_charge_buf;
v_discharge_target_wh := v_exportable * v_discharge_buf; v_discharge_target_wh := v_exportable * v_discharge_buf;
-- Rozpočet na půl dne (Europe/Prague): 00:0012:00 vs 12:0024:00; chybějící segment dostane celý budget. -- AM/PM rozpočet grid charging (Europe/Prague 0012 vs 1224).
-- Nabíjecí rozpočet dál dělíme 50/50 (kvůli rozprostření v rámci dne), ale exportní vybíjení volíme globálně podle sell_price. -- Chybějící segment dostane celý budget.
select select
coalesce( coalesce(count(*) filter (
count(*) filter ( where extract(hour from wk.interval_start at time zone 'Europe/Prague') < 12
where extract(hour from wk.interval_start at time zone 'Europe/Prague') < 12 ), 0)::int,
), coalesce(count(*) filter (
0 where extract(hour from wk.interval_start at time zone 'Europe/Prague') >= 12
)::int, ), 0)::int
coalesce(
count(*) filter (
where extract(hour from wk.interval_start at time zone 'Europe/Prague') >= 12
),
0
)::int
into v_n_am, v_n_pm into v_n_am, v_n_pm
from _ems_plan_slot_wk wk; from _ems_plan_slot_wk wk;
@@ -261,35 +253,65 @@ begin
v_chg_pm_wh := v_grid_target_wh - v_chg_am_wh; v_chg_pm_wh := v_grid_target_wh - v_chg_am_wh;
end if; end if;
-- charge mask (sloupce temp tabulky kvalifikujeme: RETURNS TABLE dělá PL proměnné stejných jmen) -- charge mask: dvě nezávislé vrstvy
--
-- A) PV-surplus sloty (pv_surplus_w > 0): ranking dle sell_price ASC.
-- Nejlevnější PV-surplus sloty vybereme, dokud kumulativní
-- PV surplus nepokryje charge target (energy_to_fill × charge_buf).
-- Zbylé PV-surplus sloty mají allow_charge = false → PV jde do sítě.
-- Toto je hlavní mechanismus proti mikro-cyklování z PV:
-- v drahých slotech se PV prodává přímo, nabíjení jen v levných.
--
-- B) Non-PV sloty (pv_surplus_w <= 0): AM/PM budget, OTE-first.
-- Nejlevnější non-PV sloty (dle buy_price) s prioritou OTE cen
-- před predikovanými (is_predicted_price::int ASC). AM a PM mají
-- oddělený rozpočet (50/50), aby solver nekoncentroval veškeré
-- nabíjení/vybíjení do jediné půlky dne (double-cycle ochrana).
-- OTE-first: levné OTE sloty aktuálního dne nesmí být vytlačeny
-- levnějšími predikovanými cenami vzdálených dní (den 34 z 96h).
if v_charge_buf <= 0 then if v_charge_buf <= 0 then
update _ems_plan_slot_wk wk set allow_charge = true; update _ems_plan_slot_wk wk set allow_charge = true;
elsif v_energy_to_fill <= 0 then elsif v_energy_to_fill <= 0 then
-- Pokud rolling replan startuje s baterií plnou, nechceme zablokovat budoucí nabíjení po vybití. update _ems_plan_slot_wk wk set allow_charge = true;
-- Povolit alespoň nabíjení v PV surplus slotech, aby solver mohl vytvořit headroom a pak ho znovu zaplnit z FVE.
update _ems_plan_slot_wk wk set allow_charge = (wk.pv_surplus_w > 0);
else else
update _ems_plan_slot_wk wk set allow_charge = (wk.pv_surplus_w > 0); update _ems_plan_slot_wk wk set allow_charge = false;
-- A) PV-surplus: cheapest sell_price first
v_cum := 0;
for r_slot in
select wk.slot_ord, wk.pv_surplus_w
from _ems_plan_slot_wk wk
where wk.pv_surplus_w > 0
order by wk.sell_price, wk.slot_ord
loop
exit when v_cum >= v_grid_target_wh;
update _ems_plan_slot_wk wk set allow_charge = true where wk.slot_ord = r_slot.slot_ord;
v_cum := v_cum + least(r_slot.pv_surplus_w, v_max_charge_w) * v_charge_eff * 0.25;
end loop;
-- B) Non-PV AM: OTE-first, then predicted, ordered by buy_price
v_cum := 0; v_cum := 0;
for r_slot in for r_slot in
select wk.slot_ord select wk.slot_ord
from _ems_plan_slot_wk wk from _ems_plan_slot_wk wk
where wk.pv_surplus_w <= 0 where wk.pv_surplus_w <= 0
and extract(hour from wk.interval_start at time zone 'Europe/Prague') < 12 and extract(hour from wk.interval_start at time zone 'Europe/Prague') < 12
order by wk.buy_price, wk.slot_ord order by wk.is_predicted_price::int, wk.buy_price, wk.slot_ord
loop loop
exit when v_cum >= v_chg_am_wh; exit when v_cum >= v_chg_am_wh;
exit when v_per_slot_charge_wh <= 0; exit when v_per_slot_charge_wh <= 0;
update _ems_plan_slot_wk wk set allow_charge = true where wk.slot_ord = r_slot.slot_ord; update _ems_plan_slot_wk wk set allow_charge = true where wk.slot_ord = r_slot.slot_ord;
v_cum := v_cum + v_per_slot_charge_wh; v_cum := v_cum + v_per_slot_charge_wh;
end loop; end loop;
-- B) Non-PV PM: OTE-first, then predicted, ordered by buy_price
v_cum := 0; v_cum := 0;
for r_slot in for r_slot in
select wk.slot_ord select wk.slot_ord
from _ems_plan_slot_wk wk from _ems_plan_slot_wk wk
where wk.pv_surplus_w <= 0 where wk.pv_surplus_w <= 0
and extract(hour from wk.interval_start at time zone 'Europe/Prague') >= 12 and extract(hour from wk.interval_start at time zone 'Europe/Prague') >= 12
order by wk.buy_price, wk.slot_ord order by wk.is_predicted_price::int, wk.buy_price, wk.slot_ord
loop loop
exit when v_cum >= v_chg_pm_wh; exit when v_cum >= v_chg_pm_wh;
exit when v_per_slot_charge_wh <= 0; exit when v_per_slot_charge_wh <= 0;
@@ -409,7 +431,9 @@ $fn$;
comment on function ems.fn_load_planning_slots_full is comment on function ems.fn_load_planning_slots_full is
'15min sloty s cenami, forecastem, baseline a maskami proti mikro-cyklu (charge/discharge-export). ' '15min sloty s cenami, forecastem, baseline a maskami proti mikro-cyklu (charge/discharge-export). '
'Masky charge/discharge-export se berou zvlášť pro 0012 a 1224 Europe/Prague (polovina budgetu na segment). ' 'Charge mask: PV-surplus sloty rankované dle sell_price ASC nejlevnější pokrývají charge target, zbytek → PV do sítě; '
'non-PV sloty dle buy_price s AM/PM rozpočtem 50/50 a OTE-first prioritou (is_predicted_price::int ASC). '
'Discharge-export mask: nejdražší sell_price sloty globálně. '
'Strop SoC pro výpočet energie k dobití: coalesce(planner_max_soc_percent, max_soc_percent). ' 'Strop SoC pro výpočet energie k dobití: coalesce(planner_max_soc_percent, max_soc_percent). '
'Denní safety vstupy: night_baseload_* (20:0006:00 Europe/Prague), safety_soc_target_wh (619), ' 'Denní safety vstupy: night_baseload_* (20:0006:00 Europe/Prague), safety_soc_target_wh (619), '
'lookahead max buy/sell pro měkké LP penalizace.'; 'lookahead max buy/sell pro měkké LP penalizace.';

View File

@@ -147,6 +147,13 @@ Tato logika je implementovaná přímo ve `build_buy_prices_96()` v `scripts/ana
Skript navíc v `solve_one_day()` explicitně zakazuje současný import a export do sítě v jednom 15min slotu a zároveň současné nabíjení a vybíjení baterie. Tím se eliminuje artefakt, kdy by při výhodnějším `buy` než `sell` model vytvářel umělý „loop“ bez fyzického významu. Skript navíc v `solve_one_day()` explicitně zakazuje současný import a export do sítě v jednom 15min slotu a zároveň současné nabíjení a vybíjení baterie. Tím se eliminuje artefakt, kdy by při výhodnějším `buy` než `sell` model vytvářel umělý „loop“ bez fyzického významu.
Pro delší běhy (měsíce / rok) lze runtime řídit přímo z CLI:
- `--solver-time-limit-sec` = CBC limit na jeden den
- `--progress-every-days` = po kolika dnech skript vytiskne průběh (`0` = ticho)
To je důležité hlavně po zavedení binárních proměnných pro zákaz současného `import+export` a `charge+discharge`, protože roční běhy jsou výrazně pomalejší než původní čisté LP.
Ověření: Ověření:
- spusť skript nad krátkým vzorkem OTE (`--price-csv` nebo `--db`) a zkontroluj vypsané shrnutí režimu nákupu - spusť skript nad krátkým vzorkem OTE (`--price-csv` nebo `--db`) a zkontroluj vypsané shrnutí režimu nákupu

View File

@@ -9,7 +9,10 @@
- **SQL-first:** horizont a sloty z DB funkcí (`fn_planning_horizon_end`, `fn_load_planning_slots_full`, …); viz **`CLAUDE.md`** → sekce *SQL-first a read-model*. - **SQL-first:** horizont a sloty z DB funkcí (`fn_planning_horizon_end`, `fn_load_planning_slots_full`, …); viz **`CLAUDE.md`** → sekce *SQL-first a read-model*.
- **Dynamický horizont (jen OTE):** konec plánu z **`ems.fn_planning_horizon_end(site_id, horizon_start)`** (výchozí strop **36 h**, minimum pro rolling **1 h** obojí jako defaultní argumenty v SQL, úprava přes repeatable migraci). Pomocná `ems.fn_last_effective_ote` vrací konec posledního OTE intervalu. Rolling replan při `NULL` přeskočí; denní plán použije krátký (1 h) fallback v Pythonu. Sloty v solveru jsou bez predikovaných cen v rámci tohoto horizontu. - **Dynamický horizont (jen OTE):** konec plánu z **`ems.fn_planning_horizon_end(site_id, horizon_start)`** (výchozí strop **36 h**, minimum pro rolling **1 h** obojí jako defaultní argumenty v SQL, úprava přes repeatable migraci). Pomocná `ems.fn_last_effective_ote` vrací konec posledního OTE intervalu. Rolling replan při `NULL` přeskočí; denní plán použije krátký (1 h) fallback v Pythonu. Sloty v solveru jsou bez predikovaných cen v rámci tohoto horizontu.
- **Terminal SoC shadow price:** v objective je člen `(avg_buy_prvních_24h × planner_terminal_soc_value_factor / 1000) × soc[T1]` (Kč), kde faktor je **`ems.asset_battery.planner_terminal_soc_value_factor`** přes **`ems.fn_planning_site_context`** (default v DB **0.9**); viz sekci *Tuning pro malé baterie* níže. Účel: konec horizontu nemusí končit zbytečně vyprázdněnou baterií (receding horizon). - **Terminal SoC shadow price:** v objective je člen `(avg_buy_prvních_24h × planner_terminal_soc_value_factor / 1000) × soc[T1]` (Kč), kde faktor je **`ems.asset_battery.planner_terminal_soc_value_factor`** přes **`ems.fn_planning_site_context`** (default v DB **0.9**); viz sekci *Tuning pro malé baterie* níže. Účel: konec horizontu nemusí končit zbytečně vyprázdněnou baterií (receding horizon).
- **Masky `allow_charge` / `allow_discharge_export` (anti-mikrocyklování):** generuje `ems.fn_load_planning_slots_full`. Důležité: pokud rolling replan startuje s baterií na 100 %, `allow_charge` se nesmí stát globálně `false` pro celý horizont jinak solver nemá motivaci baterii před PV špičkou „uvolnit“ (headroom), protože ji pak nesmí z PV znovu nabít. Aktuálně se v tomto případě `allow_charge` ponechá povolené alespoň pro sloty s `pv_surplus_w > 0`. - **Masky `allow_charge` / `allow_discharge_export` (anti-mikrocyklování):** generuje `ems.fn_load_planning_slots_full`. Dvě nezávislé vrstvy pro nabíjení:
- **PV-surplus sloty** (`pv_surplus_w > 0`): ranking dle `sell_price ASC`. Nejlevnější PV-surplus sloty se vybírají, dokud kumulativní PV surplus × η_charge nepokryje `energy_to_fill × charge_slot_buffer`. Zbylé PV-surplus sloty mají `allow_charge=false` → PV jde rovnou do sítě. V drahých slotech se PV prodává, v levných nabíjí baterie.
- **Non-PV sloty** (`pv_surplus_w <= 0`): AM/PM rozpočet 50/50, řazení dle `is_predicted_price::int ASC, buy_price ASC`. OTE ceny mají přednost před predikovanými levné OTE sloty aktuálního dne nemohou být vytlačeny predikovanými cenami vzdálených dnů. AM/PM split zabraňuje double-cycle (koncentrace nabíjení/vybíjení do jedné půlky dne).
- Pokud `energy_to_fill <= 0` (baterie plná) nebo `charge_slot_buffer = 0`: všechny sloty povoleny.
- **Denní safety charge (měkké LP, ne maska):** `fn_load_planning_slots_full` (**V077+**) vrací navíc odhad nočního baseload Wh (20:0006:00 Europe/Prague), buffer % z `asset_battery.planner_night_baseload_buffer_percent`, lookahead `future_*_czk_kwh`, volitelný `safety_soc_target_wh` (619) a flag `is_daytime_pv_surplus_slot`.\n+\n+ V solveru (`planning_engine.solve_dispatch()`):\n+ - `safety_soc_target_wh` se používá primárně jako **ochrana exportu z baterie**: v běžných slotech (mimo highsell špičky) se při aktivním exportu vynutí `soc[t] ≥ max(arb_base_wh, safety_soc_target_wh)`.\n+ - safety deficit penalizace v objective běží jen v `is_daytime_pv_surplus_slot` (a ne v highsell špičce), aby solver neměl motivaci dělat obecné „nabij co nejdřív“ chování.\n+ Tvrdé `allow_charge` se kvůli tomu nemění. - **Denní safety charge (měkké LP, ne maska):** `fn_load_planning_slots_full` (**V077+**) vrací navíc odhad nočního baseload Wh (20:0006:00 Europe/Prague), buffer % z `asset_battery.planner_night_baseload_buffer_percent`, lookahead `future_*_czk_kwh`, volitelný `safety_soc_target_wh` (619) a flag `is_daytime_pv_surplus_slot`.\n+\n+ V solveru (`planning_engine.solve_dispatch()`):\n+ - `safety_soc_target_wh` se používá primárně jako **ochrana exportu z baterie**: v běžných slotech (mimo highsell špičky) se při aktivním exportu vynutí `soc[t] ≥ max(arb_base_wh, safety_soc_target_wh)`.\n+ - safety deficit penalizace v objective běží jen v `is_daytime_pv_surplus_slot` (a ne v highsell špičce), aby solver neměl motivaci dělat obecné „nabij co nejdřív“ chování.\n+ Tvrdé `allow_charge` se kvůli tomu nemění.
- **Rolling charge commitment:** při `run_rolling_replan` se z aktivního plánu načtou sloty, kde dříve platilo `battery_setpoint_w > 500`, `pv_a+pv_b > load_baseline`, `grid_setpoint_w ≤ 0` a současně **není výrazný export** (`grid_setpoint_w ≥ 500`). To je záměr: commitment má kotvit „nabíjení z PV přebytku“, ne „charge while exporting“. Měkká penalizace proti snížení `bc[t]` oproti předchozímu plánu je řízená `planner_charge_commitment_penalty_czk_kwh` na `asset_battery`. Implementace: `_load_previous_plan_charge_commitment_prev_w`, volitelný argument `charge_commitment_prev_w` u `solve_dispatch()`. - **Rolling charge commitment:** při `run_rolling_replan` se z aktivního plánu načtou sloty, kde dříve platilo `battery_setpoint_w > 500`, `pv_a+pv_b > load_baseline`, `grid_setpoint_w ≤ 0` a současně **není výrazný export** (`grid_setpoint_w ≥ 500`). To je záměr: commitment má kotvit „nabíjení z PV přebytku“, ne „charge while exporting“. Měkká penalizace proti snížení `bc[t]` oproti předchozímu plánu je řízená `planner_charge_commitment_penalty_czk_kwh` na `asset_battery`. Implementace: `_load_previous_plan_charge_commitment_prev_w`, volitelný argument `charge_commitment_prev_w` u `solve_dispatch()`.
- **Debug snapshot:** každý běh ukládá JSON do `ems.planning_run.solver_params` (sekce `version`, `inputs`, `masks`, `soc_bounds`, `objective_terms`, `chosen_slots`) přes `fn_planning_run_commit` (`p_run_meta->'solver_params'`). Read-model: **`select ems.fn_planning_run_debug(<run_id>);`** (`R__087_fn_planning_run_debug.sql`). - **Debug snapshot:** každý běh ukládá JSON do `ems.planning_run.solver_params` (sekce `version`, `inputs`, `masks`, `soc_bounds`, `objective_terms`, `chosen_slots`) přes `fn_planning_run_commit` (`p_run_meta->'solver_params'`). Read-model: **`select ems.fn_planning_run_debug(<run_id>);`** (`R__087_fn_planning_run_debug.sql`).
@@ -53,7 +56,9 @@ where allow_charge is true
order by interval_start; order by interval_start;
``` ```
- Pokud `current_soc_wh` odpovídá plné baterii (`soc_max_wh`), měly by být `allow_charge=true` alespoň sloty s PV přebytkem (`pv_surplus_w > 0`). - PV-surplus sloty: `allow_charge=true` jen pro nejlevnější (dle `sell_price`), dokud se nepokryje charge target.
- Non-PV sloty: AM/PM budget, OTE sloty mají přednost před predikovanými (ORDER BY `is_predicted_price::int, buy_price`).
- Pokud `current_soc_wh` odpovídá plné baterii (`soc_max_wh`), jsou povoleny všechny sloty.
--- ---

View File

@@ -164,7 +164,6 @@ Jak to je v implementaci:
### CHARGE_CHEAP ### CHARGE_CHEAP
- nabíjení ze sítě - nabíjení ze sítě
- export vypnutý
- fyzicky CHARGE - fyzicky CHARGE
### SELF_SUSTAIN ### SELF_SUSTAIN

View File

@@ -42,8 +42,9 @@ NT/VT podle hodin Europe/Prague (--buy-nt-kwh, VT = NT + --buy-vt-surcharge-kwh)
nebo od raw OTE spotu: --buy-spot-add-fixed-kwh / --buy-spot-asym-pct; u všech režimů nebo od raw OTE spotu: --buy-spot-add-fixed-kwh / --buy-spot-asym-pct; u všech režimů
lze přičíst --buy-distribution-kwh a --buy-other-fees-kwh a výslednou cenu násobit lze přičíst --buy-distribution-kwh a --buy-other-fees-kwh a výslednou cenu násobit
--buy-vat-multiplier. Model explicitně zakazuje současný import+export a současné --buy-vat-multiplier. Model explicitně zakazuje současný import+export a současné
nabíjení+vybíjení v jednom slotu. Mikroinvertory / GEN nejsou; zelený bonus není nabíjení+vybíjení v jednom slotu. Dlouhé běhy MILP lze řídit přes
v účelové funkci. Výsledek = screening, ne nabídka. --solver-time-limit-sec a průběžný tisk přes --progress-every-days. Mikroinvertory /
GEN nejsou; zelený bonus není v účelové funkci. Výsledek = screening, ne nabídka.
""" """
from __future__ import annotations from __future__ import annotations
@@ -55,6 +56,7 @@ import sys
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from pathlib import Path from pathlib import Path
from time import perf_counter
from typing import Iterable, Sequence, Mapping from typing import Iterable, Sequence, Mapping
try: try:
@@ -386,6 +388,7 @@ def solve_one_day(
p_batt_w: float, p_batt_w: float,
site: SiteLimits, site: SiteLimits,
soc_start_wh: float, soc_start_wh: float,
solver_time_limit_sec: float,
) -> tuple[float, float, float, float]: ) -> tuple[float, float, float, float]:
""" """
Vrátí (cash_kc, soc_end_wh, curtailed_wh, discharged_wh_sum). Vrátí (cash_kc, soc_end_wh, curtailed_wh, discharged_wh_sum).
@@ -434,7 +437,10 @@ def solve_one_day(
prob += pulp.lpSum(obj) prob += pulp.lpSum(obj)
solver = pulp.PULP_CBC_CMD(msg=False, timeLimit=60) solver_kwargs: dict[str, object] = {"msg": False}
if solver_time_limit_sec > 0:
solver_kwargs["timeLimit"] = solver_time_limit_sec
solver = pulp.PULP_CBC_CMD(**solver_kwargs)
prob.solve(solver) prob.solve(solver)
if prob.status != pulp.LpStatusOptimal: if prob.status != pulp.LpStatusOptimal:
raise RuntimeError(f"LP status {pulp.LpStatus[prob.status]}") raise RuntimeError(f"LP status {pulp.LpStatus[prob.status]}")
@@ -447,7 +453,7 @@ def solve_one_day(
def simulate_year( def simulate_year(
days: Iterable[date], days: Sequence[date],
px_day: dict[date, list[float]], px_day: dict[date, list[float]],
usable_kwh: float, usable_kwh: float,
site: SiteLimits, site: SiteLimits,
@@ -459,6 +465,8 @@ def simulate_year(
load_kw: float, load_kw: float,
shape: Sequence[float], shape: Sequence[float],
monthly_ed_kwh: Mapping[int, float] | None, monthly_ed_kwh: Mapping[int, float] | None,
solver_time_limit_sec: float,
progress_every_days: int,
) -> dict[str, float]: ) -> dict[str, float]:
e_wh = usable_kwh * 1000.0 e_wh = usable_kwh * 1000.0
p_batt = batt_power_cap_w(usable_kwh, site) p_batt = batt_power_cap_w(usable_kwh, site)
@@ -467,10 +475,30 @@ def simulate_year(
curt_total = 0.0 curt_total = 0.0
dis_total = 0.0 dis_total = 0.0
soc_state = 0.5 * (site.soc_min_frac + site.soc_max_frac) * e_wh soc_state = 0.5 * (site.soc_min_frac + site.soc_max_frac) * e_wh
run_days = [d for d in days if d in px_day]
total_days = len(run_days)
started = perf_counter()
n_days = 0 n_days = 0
for d in days: if progress_every_days > 0:
if d not in px_day: limit_msg = (
continue f"{solver_time_limit_sec:g} s/den"
if solver_time_limit_sec > 0
else "bez limitu / den"
)
print(
f"[{usable_kwh:.1f} kWh] start: {total_days} dnů, CBC limit {limit_msg}",
flush=True,
)
for idx, d in enumerate(run_days, start=1):
if progress_every_days > 0 and (
idx == 1 or idx % progress_every_days == 0 or idx == total_days
):
elapsed_sec = perf_counter() - started
print(
f"[{usable_kwh:.1f} kWh] den {idx}/{total_days}: {d.isoformat()} "
f"(elapsed {elapsed_sec:.1f} s)",
flush=True,
)
raw = px_day[d] raw = px_day[d]
p_sell = [effective_sell_kc_kwh(x, sell_margin_fixed, sell_margin_pct) for x in raw] p_sell = [effective_sell_kc_kwh(x, sell_margin_fixed, sell_margin_pct) for x in raw]
p_buy = build_buy_prices_96(raw, buy_cfg) p_buy = build_buy_prices_96(raw, buy_cfg)
@@ -479,12 +507,25 @@ def simulate_year(
else: else:
pv_wh = daily_pv_wh(d, summer_kwh, winter_kwh, shape) pv_wh = daily_pv_wh(d, summer_kwh, winter_kwh, shape)
cash, soc_state, curt, dis = solve_one_day( cash, soc_state, curt, dis = solve_one_day(
pv_wh, load_wh, p_sell, p_buy, e_wh, p_batt, site, soc_state pv_wh,
load_wh,
p_sell,
p_buy,
e_wh,
p_batt,
site,
soc_state,
solver_time_limit_sec,
) )
cash_total += cash cash_total += cash
curt_total += curt curt_total += curt
dis_total += dis dis_total += dis
n_days += 1 n_days += 1
if progress_every_days > 0:
print(
f"[{usable_kwh:.1f} kWh] hotovo za {perf_counter() - started:.1f} s",
flush=True,
)
feq = (dis_total / e_wh / n_days) if n_days and e_wh > 0 else 0.0 feq = (dis_total / e_wh / n_days) if n_days and e_wh > 0 else 0.0
return { return {
"cash_kc": cash_total, "cash_kc": cash_total,
@@ -585,6 +626,18 @@ def main() -> None:
ap.add_argument("--max-import-w", type=float, default=17_000.0) ap.add_argument("--max-import-w", type=float, default=17_000.0)
ap.add_argument("--inv-batt-max-w", type=float, default=12_000.0) ap.add_argument("--inv-batt-max-w", type=float, default=12_000.0)
ap.add_argument("--c-rate", type=float, default=0.5) ap.add_argument("--c-rate", type=float, default=0.5)
ap.add_argument(
"--solver-time-limit-sec",
type=float,
default=60.0,
help="CBC time limit na jeden den; 0 = bez limitu",
)
ap.add_argument(
"--progress-every-days",
type=int,
default=1,
help="Po kolika dnech vytisknout průběh; 0 = tichý režim",
)
ap.add_argument("--capex-per-kwh", type=float, default=0.0, help="CAPEX za 1 kWh rozšíření; vypíše jednoduchou návratnost vs. nejmenší baterie") ap.add_argument("--capex-per-kwh", type=float, default=0.0, help="CAPEX za 1 kWh rozšíření; vypíše jednoduchou návratnost vs. nejmenší baterie")
args = ap.parse_args() args = ap.parse_args()
@@ -599,6 +652,10 @@ def main() -> None:
ap.error("Zvol jen jeden režim základu nákupu: flat, NT/VT, --buy-spot-add-fixed-kwh nebo --buy-spot-asym-pct") ap.error("Zvol jen jeden režim základu nákupu: flat, NT/VT, --buy-spot-add-fixed-kwh nebo --buy-spot-asym-pct")
if args.buy_vat_multiplier <= 0: if args.buy_vat_multiplier <= 0:
ap.error("--buy-vat-multiplier musí být > 0") ap.error("--buy-vat-multiplier musí být > 0")
if args.solver_time_limit_sec < 0:
ap.error("--solver-time-limit-sec musí být >= 0")
if args.progress_every_days < 0:
ap.error("--progress-every-days musí být >= 0")
for hour_arg, hour_value in (("nt-from-hour", args.nt_from_hour), ("nt-to-hour", args.nt_to_hour)): for hour_arg, hour_value in (("nt-from-hour", args.nt_from_hour), ("nt-to-hour", args.nt_to_hour)):
if not (0 <= hour_value <= 23): if not (0 <= hour_value <= 23):
ap.error(f"--{hour_arg} musí být v rozsahu 0..23") ap.error(f"--{hour_arg} musí být v rozsahu 0..23")
@@ -683,6 +740,8 @@ def main() -> None:
args.load_kw, args.load_kw,
shape, shape,
monthly_ed, monthly_ed,
args.solver_time_limit_sec,
args.progress_every_days,
) )
results.append((kwh, r)) results.append((kwh, r))
@@ -725,6 +784,12 @@ def main() -> None:
) )
print(f" Load (konstanta) {args.load_kw} kW") print(f" Load (konstanta) {args.load_kw} kW")
print(f" Limity: export {args.max_export_w} W, import {args.max_import_w} W, P_batt = min({args.c_rate}*E_kWh, {args.inv_batt_max_w} W)") print(f" Limity: export {args.max_export_w} W, import {args.max_import_w} W, P_batt = min({args.c_rate}*E_kWh, {args.inv_batt_max_w} W)")
limit_msg = (
f"{args.solver_time_limit_sec:g} s/den"
if args.solver_time_limit_sec > 0
else "bez limitu / den"
)
print(f" Solver: CBC, limit {limit_msg}, progress every {args.progress_every_days} dnů")
print() print()
print(f"{'kWh':>8} {'P_batt_kW':>10} {'cash_kc/rok':>14} {'Δ vs min':>12} {'curt_MWh/y':>12} {'Feq/den':>8}") print(f"{'kWh':>8} {'P_batt_kW':>10} {'cash_kc/rok':>14} {'Δ vs min':>12} {'curt_MWh/y':>12} {'Feq/den':>8}")