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
|
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()
|
||||||
|
|||||||
@@ -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:00–12:00 vs 12:00–24:00; chybějící segment dostane celý budget.
|
-- AM/PM rozpočet grid charging (Europe/Prague 00–12 vs 12–24).
|
||||||
-- 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 3–4 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 00–12 a 12–24 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:00–06:00 Europe/Prague), safety_soc_target_wh (6–19), '
|
'Denní safety vstupy: night_baseload_* (20:00–06:00 Europe/Prague), safety_soc_target_wh (6–19), '
|
||||||
'lookahead max buy/sell pro měkké LP penalizace.';
|
'lookahead max buy/sell pro měkké LP penalizace.';
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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[T−1]` (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[T−1]` (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:00–06:00 Europe/Prague), buffer % z `asset_battery.planner_night_baseload_buffer_percent`, lookahead `future_*_czk_kwh`, volitelný `safety_soc_target_wh` (6–19) 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 high‑sell š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 high‑sell š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:00–06:00 Europe/Prague), buffer % z `asset_battery.planner_night_baseload_buffer_percent`, lookahead `future_*_czk_kwh`, volitelný `safety_soc_target_wh` (6–19) 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 high‑sell š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 high‑sell š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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
Reference in New Issue
Block a user