zasadni uprava LP planneru
Some checks failed
CI and deploy / migration-check (push) Failing after 24s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-05-21 11:18:09 +02:00
parent d984716f69
commit 08f1b6741a
7 changed files with 330 additions and 123 deletions

View File

@@ -68,7 +68,7 @@ Když uživatel napíše **„použij MCP“** nebo potřebuje **aktuální řá
7. **Záporná nákupní cena → omezit import** na realistický horní strop (viz `solve_dispatch` v `planning_engine.py` nesmí „nekonečný“ import).
8. **PuLP + HiGHS** pro dispatch; žádný návrat k greedy `fn_plan_day` jako primárnímu řešení (SQL wrapper může zůstat pro uložení výsledků dle docs).
8. **PuLP + HiGHS** pro dispatch; žádný návrat k greedy `fn_plan_day` jako primárnímu řešení (SQL wrapper může zůstat pro uložení výsledků dle docs). **Ekonomika slotů:** masky v `fn_load_planning_slots_full` (store_score, ref_buy horizontu, lookahead VT→NT) + v `solve_dispatch` guardy `sell` vs `buy` (`ge_pv`, `gi`) — plán nesmí paralelně vyvážet FVE za haléře a nabíjet ze sítě za Kč; viz `docs/04-modules/planning.md`.
9. **Zelený bonus je na `asset_pv_array`** (sloupce `green_bonus_*`), **nikdy** v `site_market_config`. Výpočet přes `fn_green_bonus_revenue()`. Bonus se nepočítá v solveru pouze v audit_filler (`fn_fill_audit_interval`).

View File

@@ -1057,6 +1057,44 @@ def solve_dispatch(
prob += ge_bat[t] == 0
prob += z_export[t] == 0
# Ekonomické guardy: ceny v objective nestačí proti maskám / terminal SoC.
ref_buy_horizon = min(float(s.buy_price) for s in slots)
min_spread = float(degradation_cost_effective)
hp_rated_w = float(heat_pump.rated_heating_power_w)
soc_headroom_wh = max(
2000.0, 0.05 * float(battery.soc_max_wh)
)
for t in range(T):
s = slots[t]
buy_t = float(s.buy_price)
sell_t = float(s.sell_price)
load_t = float(s.load_baseline_w)
ev_cap_t = sum(
float(vehicles[e].max_charge_power_w)
for e in range(EV)
if (e == 0 and s.ev1_connected) or (e == 1 and s.ev2_connected)
)
pv_surplus_w = max(
0.0,
float(s.pv_a_forecast_w) + float(s.pv_b_forecast_w) - load_t,
)
# Ztrátový export FVE (sell ≪ buy): zakázat jen pokud jde energii do baterie.
# Výjimky: plná baterie (ventil), neriťitelné pv_b s přebytkem.
if sell_t < buy_t - min_spread:
block_loss_pv_export = not (
float(s.pv_b_forecast_w) > 0 and pv_surplus_w > 0
)
if t == 0 and current_soc_wh >= float(battery.soc_max_wh) - soc_headroom_wh:
block_loss_pv_export = False
if block_loss_pv_export:
prob += ge_pv[t] == 0
# Drahý nákup oproti horizontu: import jen na load + EV + TČ, ne na grid-nabíjení.
if buy_t >= 0 and buy_t > ref_buy_horizon + min_spread:
prob += gi[t] <= load_t + ev_cap_t + hp_rated_w
# Anti souběžný vývoz FVE + významný import (mikrocyklus).
if buy_t > sell_t + min_spread and pv_surplus_w > 0:
prob += ge_pv[t] <= pv_surplus_w
# Deadline constraints pro EV
for e, session in enumerate(ev_sessions):
if session and session.target_deadline and session.energy_needed_wh > 0:

View File

@@ -3,11 +3,11 @@
Logika je v DB: ems.fn_load_planning_slots_full. Kopie algoritmu pro unit testy bez PG.
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.
A) PV-surplus: store_score DESC, dokud PV nepokryje charge target.
B) Non-PV: AM/PM, OTE-first, buy≤ref+degrad, lookahead, cap 6 slotů.
Discharge-export mask:
ref_buy = min(buy) mezi allow_charge sloty (arbitráž mezi sloty, ne sell vs buy ve stejném).
ref_buy = min(buy) celého horizontu.
Top sloty dle sell_price desc kde sell > ref_buy + degradation.
"""
@@ -21,6 +21,31 @@ from zoneinfo import ZoneInfo
from services.planning_engine import INTERVAL_H, PlanningSlot
_PRAGUE = ZoneInfo("Europe/Prague")
_LOOKAHEAD_SLOTS = 4
_GRID_CHARGE_CAP_AM = 6
_GRID_CHARGE_CAP_PM = 6
_BUY_LOOKAHEAD_EPS = 0.05
def _future_sell(slots: list[PlanningSlot], t: int) -> float:
tail = [float(slots[i].sell_price) for i in range(t + 1, len(slots))]
return max(tail) if tail else float(slots[t].sell_price)
def _buy_min_next_n(slots: list[PlanningSlot], t: int, n: int = _LOOKAHEAD_SLOTS) -> float | None:
tail = [
float(slots[i].buy_price)
for i in range(t + 1, min(t + 1 + n, len(slots)))
]
return min(tail) if tail else None
def _store_score(slots: list[PlanningSlot], t: int) -> float:
s = slots[t]
buy = float(s.buy_price)
sell = float(s.sell_price)
fso = _future_sell(slots, t)
return fso - sell - max(0.0, buy - sell)
def _select_charge_slots(
@@ -39,12 +64,21 @@ def _select_charge_slots(
if energy_to_fill <= 0:
return set(range(len(slots)))
reserve_wh = float(getattr(battery, "reserve_soc_wh", 0) or 0)
degrad = float(getattr(battery, "degradation_cost_czk_kwh", 0.15) or 0.15)
ref_buy = min(float(s.buy_price) for s in 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
if current_soc_wh >= reserve_wh:
charge_target_wh = max(energy_to_fill, 0.0)
else:
charge_target_wh = min(
max(energy_to_fill, 0.0) * charge_buf,
max(energy_to_fill, 0.0),
)
# 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:
@@ -59,56 +93,67 @@ def _select_charge_slots(
selected: set[int] = set()
# A) PV-surplus: cheapest sell_price first
# A) PV-surplus: highest store_score 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:
pv_candidates.append((t, float(s.sell_price), float(pv_surplus_w)))
if pv_surplus_w > 0 and float(s.sell_price) >= float(s.buy_price) - degrad:
pv_candidates.append((t, _store_score(slots, t), float(pv_surplus_w)))
pv_candidates.sort(key=lambda x: (x[1], x[0]))
pv_candidates.sort(key=lambda x: (-x[1], x[0]))
cum = 0.0
for t, _sell, pv_surplus_w in pv_candidates:
for t, _score, pv_surplus_w in pv_candidates:
if cum >= charge_target_wh:
break
selected.add(t)
cum += min(pv_surplus_w, max_p_w) * eta * INTERVAL_H
# B) Non-PV grid charge — jen spot nákup (u fixed je buy všude stejný → jen FVE)
if purchase_pricing_mode == "fixed":
return selected
# B) Non-PV: AM budget (OTE-first)
def _grid_b_ok(t: int) -> bool:
s = slots[t]
if max(0, s.pv_a_forecast_w + s.pv_b_forecast_w - s.load_baseline_w) > 0:
return False
buy = float(s.buy_price)
sell = float(s.sell_price)
if buy > ref_buy + degrad:
return False
nxt = _buy_min_next_n(slots, t)
if nxt is not None and buy > nxt + _BUY_LOOKAHEAD_EPS:
return False
return True
# B) AM
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
if t not in selected and _grid_b_ok(t) and _prague_hour(slots[t]) < 12
]
am_candidates.sort(key=lambda x: (int(x[1]), x[2], x[0]))
cum = 0.0
grid_am = 0
for t, _pred, _price in am_candidates:
if cum >= chg_am or per_slot_full_wh <= 0:
if cum >= chg_am or per_slot_full_wh <= 0 or grid_am >= _GRID_CHARGE_CAP_AM:
break
selected.add(t)
cum += per_slot_full_wh
grid_am += 1
# 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
if t not in selected and _grid_b_ok(t) and _prague_hour(slots[t]) >= 12
]
pm_candidates.sort(key=lambda x: (int(x[1]), x[2], x[0]))
cum = 0.0
grid_pm = 0
for t, _pred, _price in pm_candidates:
if cum >= chg_pm or per_slot_full_wh <= 0:
if cum >= chg_pm or per_slot_full_wh <= 0 or grid_pm >= _GRID_CHARGE_CAP_PM:
break
selected.add(t)
cum += per_slot_full_wh
grid_pm += 1
return selected
@@ -138,13 +183,7 @@ def _select_discharge_export_slots(
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),
)
ref_buy = min(float(s.buy_price) for s in slots)
if purchase_pricing_mode == "fixed":
sell_min = degrad
@@ -182,9 +221,12 @@ def _slot(
load: int = 2_000,
hour_utc: int = 12,
predicted: bool = False,
interval_start: datetime | None = None,
) -> PlanningSlot:
if interval_start is None:
interval_start = datetime(2026, 5, 19, hour_utc, 0, tzinfo=timezone.utc)
return PlanningSlot(
interval_start=datetime(2026, 5, 19, hour_utc, 0, tzinfo=timezone.utc),
interval_start=interval_start,
buy_price=buy,
sell_price=sell,
pv_a_forecast_w=0,
@@ -203,6 +245,7 @@ def _battery(
uc_wh: float = 64_000.0,
soc_max_pct: float = 95.0,
min_soc_pct: float = 10.0,
reserve_soc_pct: float = 20.0,
max_charge_w: float = 18_000.0,
max_discharge_w: float = 18_000.0,
charge_eff: float = 0.95,
@@ -213,6 +256,7 @@ def _battery(
return SimpleNamespace(
usable_capacity_wh=uc,
min_soc_wh=min_soc_pct / 100.0 * uc,
reserve_soc_wh=reserve_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,
@@ -239,81 +283,82 @@ class SelectChargeSlotsTests(unittest.TestCase):
)
self.assertEqual(out, set(range(3)))
def test_pv_surplus_cheapest_sell_price_selected(self) -> None:
"""PV-surplus sloty s nejnižší sell_price se vybírají přednost."""
def test_pv_surplus_high_store_score_selected(self) -> None:
"""Slot s vyšším store_score (lepší uložení vs export) má přednost."""
slots = [
_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),
_slot(buy=1.5, sell=0.01, pv=8_000, load=2_000, hour_utc=8),
_slot(buy=1.5, sell=0.50, pv=8_000, load=2_000, hour_utc=9),
_slot(buy=0.5, sell=0.40, pv=8_000, load=2_000, hour_utc=10),
]
battery = _battery(
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(0, out, "Cheapest sell_price PV slot must be selected")
self.assertNotIn(1, out, "Expensive sell_price PV slot should be excluded")
self.assertIn(2, out, "Slot s lepší marží (nižší buy) má být vybrán")
self.assertNotIn(0, out, "Ztrátový sell≪buy slot nemá grid charge z masky A")
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."""
"""Levný PM slot; AM s dražším buy než min v lookahead může být vynechán."""
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
_slot(buy=0.5, hour_utc=4),
_slot(buy=3.0, hour_utc=5),
_slot(buy=0.4, hour_utc=14),
_slot(buy=9.9, hour_utc=15),
]
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")
self.assertIn(2, out, "Nejlevnější buy v horizontu (PM) musí být vybrán")
def test_vt_before_nt_skips_expensive_pm_slot(self) -> None:
"""Regrese home-01: 12:45 VT drahý, za 15 min NT levný → PM grid charge ne v 12:45."""
base = datetime(2026, 5, 21, 10, 45, tzinfo=timezone.utc)
slots = [
_slot(
buy=1.49,
sell=-0.04,
pv=0,
load=3_500,
interval_start=base,
),
_slot(
buy=0.86,
sell=0.01,
pv=0,
load=3_500,
interval_start=base + timedelta(minutes=15),
),
_slot(
buy=0.86,
sell=0.01,
pv=0,
load=3_500,
interval_start=base + timedelta(minutes=30),
),
]
battery = _battery(uc_wh=64_000.0)
soc = 0.31 * battery.usable_capacity_wh
out = _select_charge_slots(slots, battery, current_soc_wh=soc)
self.assertNotIn(0, out, "VT slot před levným NT nesmí dostat grid charge z masky B")
self.assertIn(1, out, "NT slot může být vybrán")
def test_ote_slots_prioritized_over_predicted(self) -> None:
"""OTE sloty (is_predicted_price=false) mají přednost před predikovanými."""
"""Při stejné ceně má OTE (is_predicted=false) přednost před predikovaným."""
slots = [
_slot(buy=3.56, hour_utc=13, predicted=False), # OTE, dražší
_slot(buy=2.00, hour_utc=13, predicted=True), # predicted, levnější
_slot(buy=2.00, sell=2.0, hour_utc=13, predicted=False),
_slot(buy=2.00, sell=2.0, hour_utc=13, predicted=True),
]
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: sloty bez PV-surplus se vybírají přes AM/PM grid budget."""
slots = [
_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)
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."""
cheap_grid = [_slot(buy=0.4 + 0.01 * i, pv=0, load=2_000) for i in range(40)]
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
)
out = _select_charge_slots(slots, battery, current_soc_wh=0.2 * battery.usable_capacity_wh)
grid_selected = sum(1 for i in range(len(cheap_grid)) if i in out)
self.assertGreaterEqual(
grid_selected,
5,
"V dlouhém horizontu s mnoha PV-surplus sloty musí zůstat dostatek "
"grid slotů povolených pro nabíjení z levného importu.",
)
self.assertIn(0, out)
self.assertNotIn(1, out)
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),
@@ -325,8 +370,7 @@ class SelectDischargeExportSlotsTests(unittest.TestCase):
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(2, discharge)
self.assertIn(3, discharge)
def test_export_excluded_when_sell_below_ref_buy_plus_degradation(self) -> None:
@@ -335,16 +379,13 @@ class SelectDischargeExportSlotsTests(unittest.TestCase):
_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
slots, battery, current_soc_wh=0.0
)
self.assertNotIn(1, discharge, "sell 0.5 < ref 0.4 + 0.15")
self.assertNotIn(1, discharge)
class FixedPurchasePricingTests(unittest.TestCase):
"""purchase_pricing_mode=fixed: žádné grid CHARGE, export dle sell."""
def test_fixed_skips_non_pv_grid_charge_slots(self) -> None:
slots = [
_slot(buy=6.35, sell=2.0, hour_utc=14, load=500),
@@ -357,7 +398,7 @@ class FixedPurchasePricingTests(unittest.TestCase):
current_soc_wh=0.4 * battery.usable_capacity_wh,
purchase_pricing_mode="fixed",
)
self.assertEqual(out, set(), "fixed buy must not enable non-PV grid charge")
self.assertEqual(out, set())
def test_fixed_allows_discharge_on_high_sell(self) -> None:
slots = [

View File

@@ -1290,5 +1290,74 @@ class TerminalSocShadowTests(unittest.TestCase):
)
class SpreadGuardHome01EconomicsTests(unittest.TestCase):
"""Regrese: sell≪buy (VT) nesmí vést k PV exportu + masivnímu grid importu ve stejném slotu."""
def test_loss_making_morning_and_vt_slot_avoid_export_and_grid_charge(self) -> None:
from test_planning_charge_slot_selection import (
_battery as mask_battery,
_select_charge_slots,
_select_discharge_export_slots,
)
base = datetime(2026, 5, 21, 8, 0, tzinfo=timezone.utc)
raw: list[tuple[float, float, int, int]] = [
(1.55, 0.01, 6_000, 2_000),
(1.55, 0.01, 6_500, 2_000),
(1.49, -0.04, 0, 3_500),
(0.86, 0.01, 0, 3_500),
(0.86, 0.01, 0, 3_500),
(0.86, 0.01, 5_000, 2_000),
]
slots: list[PlanningSlot] = []
for i, (buy, sell, pv, load) in enumerate(raw):
slots.append(
PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=buy,
sell_price=sell,
pv_a_forecast_w=0,
pv_b_forecast_w=pv,
load_baseline_w=load,
ev1_connected=False,
ev2_connected=False,
is_predicted_price=False,
)
)
mb = mask_battery(uc_wh=64_000.0)
soc0 = 0.31 * mb.usable_capacity_wh
charge = _select_charge_slots(slots, mb, soc0)
discharge = _select_discharge_export_slots(slots, mb, soc0)
for t, s in enumerate(slots):
s.allow_charge = t in charge
s.allow_discharge_export = t in discharge
battery = _battery(uc_wh=64_000.0, terminal_soc_value_factor=0.9)
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
grid = SimpleNamespace(max_import_power_w=20_000, max_export_power_w=20_000)
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
soc0,
50.0,
operating_mode="AUTO",
)
self.assertEqual(len(results), len(slots))
morning = results[0]
vt_before_nt = results[2]
self.assertLessEqual(morning.grid_setpoint_w, slots[0].load_baseline_w + 500)
self.assertNotEqual(morning.export_mode, "PV_SURPLUS")
self.assertLessEqual(vt_before_nt.grid_setpoint_w, 4_000)
self.assertLessEqual(vt_before_nt.battery_setpoint_w, 2_000)
if __name__ == "__main__":
unittest.main()

View File

@@ -63,6 +63,12 @@ declare
v_degrad_czk_kwh numeric;
v_ref_buy_czk_kwh numeric;
v_purchase_pricing_mode text;
v_lookahead_slots int := 4;
v_grid_charge_cap_am int := 6;
v_grid_charge_cap_pm int := 6;
v_buy_lookahead_eps numeric := 0.05;
v_grid_slots_am int := 0;
v_grid_slots_pm int := 0;
begin
drop table if exists _ems_plan_slot_wk;
create temp table _ems_plan_slot_wk on commit drop as
@@ -243,9 +249,56 @@ begin
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_exportable := v_soc_max_wh - v_min_soc_wh;
v_grid_target_wh := greatest(v_energy_to_fill, 0) * v_charge_buf;
-- Rozpočet masek: buffer neinfluje počet slotů nad skutečný deficit; nad reserve jen deficit.
if p_current_soc_wh >= v_reserve_wh then
v_grid_target_wh := greatest(v_energy_to_fill, 0);
else
v_grid_target_wh := least(
greatest(v_energy_to_fill, 0) * v_charge_buf,
greatest(v_energy_to_fill, 0)
);
end if;
v_discharge_target_wh := v_exportable * v_discharge_buf;
-- Referenční nákup pro arbitráž (celý horizont, ne jen allow_charge).
select coalesce(min(wk.buy_price), 0)
into v_ref_buy_czk_kwh
from _ems_plan_slot_wk wk;
-- Lookahead min buy (VT→NT) a store_score pro vrstvu A.
alter table _ems_plan_slot_wk
add column if not exists future_sell_lookahead numeric,
add column if not exists buy_min_next_n numeric,
add column if not exists store_score numeric;
update _ems_plan_slot_wk wk
set
future_sell_lookahead = coalesce(
(
select max(w2.sell_price)
from _ems_plan_slot_wk w2
where w2.slot_ord > wk.slot_ord
),
wk.sell_price
),
buy_min_next_n = (
select min(w2.buy_price)
from _ems_plan_slot_wk w2
where w2.slot_ord > wk.slot_ord
and w2.slot_ord <= wk.slot_ord + v_lookahead_slots
),
store_score =
coalesce(
(
select max(w2.sell_price)
from _ems_plan_slot_wk w2
where w2.slot_ord > wk.slot_ord
),
wk.sell_price
)
- wk.sell_price
- greatest(0::numeric, wk.buy_price - wk.sell_price);
-- AM/PM rozpočet grid charging (Europe/Prague 0012 vs 1224).
-- Chybějící segment dostane celý budget.
select
@@ -269,18 +322,14 @@ begin
v_chg_pm_wh := v_grid_target_wh - v_chg_am_wh;
end if;
-- charge mask: dvě nezávislé vrstvy
-- charge mask: dvě nezávislé vrstvy (tenký anti-mikrocyklus, ekonomika z cen)
--
-- 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.
-- A) PV-surplus: ranking store_score DESC (future_sell sell max(0,buysell)).
-- Sloty s nejvyšší hodnotou uložení vs export pokrývají charge target.
-- Zbylé PV-surplus → allow_charge=false (PV jen do sítě / bc≤surplus v LP).
--
-- B) Non-PV sloty (pv_surplus_w <= 0): AM/PM budget, OTE-first (jen spot nákup).
-- U purchase_pricing_mode = fixed se grid nabíjení neplánuje — buy je
-- v každém slotu stejný, cyklus ze sítě by byl čistá ztráta; nabíjení jen z FVE.
-- B) Non-PV grid: jen spot, buy ≤ ref_buy+degrad, buy ≤ min(next N)+ε,
-- cap K slotů AM/PM; nikdy při sell < buy degrad (ztrátový slot).
if v_charge_buf <= 0 then
update _ems_plan_slot_wk wk set allow_charge = true;
elsif v_energy_to_fill <= 0 then
@@ -288,13 +337,14 @@ begin
else
update _ems_plan_slot_wk wk set allow_charge = false;
-- A) PV-surplus: cheapest sell_price first
-- A) PV-surplus: nejvyšší store_score (ukládat FVE vs exportovat)
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
and wk.sell_price >= wk.buy_price - v_degrad_czk_kwh
order by wk.store_score desc nulls last, 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;
@@ -302,49 +352,54 @@ begin
end loop;
if v_purchase_pricing_mode <> 'fixed' then
-- B) Non-PV AM: OTE-first, then predicted, ordered by buy_price
-- B) Non-PV AM: OTE-first, levný buy + lookahead, cap slotů
v_cum := 0;
v_grid_slots_am := 0;
for r_slot in
select wk.slot_ord
from _ems_plan_slot_wk wk
where wk.pv_surplus_w <= 0
and extract(hour from wk.interval_start at time zone 'Europe/Prague') < 12
and wk.buy_price <= v_ref_buy_czk_kwh + v_degrad_czk_kwh
and (
wk.buy_min_next_n is null
or wk.buy_price <= wk.buy_min_next_n + v_buy_lookahead_eps
)
order by wk.is_predicted_price::int, wk.buy_price, wk.slot_ord
loop
exit when v_cum >= v_chg_am_wh;
exit when v_per_slot_charge_wh <= 0;
exit when v_grid_slots_am >= v_grid_charge_cap_am;
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_grid_slots_am := v_grid_slots_am + 1;
end loop;
-- B) Non-PV PM: OTE-first, then predicted, ordered by buy_price
-- B) Non-PV PM
v_cum := 0;
v_grid_slots_pm := 0;
for r_slot in
select wk.slot_ord
from _ems_plan_slot_wk wk
where wk.pv_surplus_w <= 0
and extract(hour from wk.interval_start at time zone 'Europe/Prague') >= 12
and wk.buy_price <= v_ref_buy_czk_kwh + v_degrad_czk_kwh
and (
wk.buy_min_next_n is null
or wk.buy_price <= wk.buy_min_next_n + v_buy_lookahead_eps
)
order by wk.is_predicted_price::int, wk.buy_price, wk.slot_ord
loop
exit when v_cum >= v_chg_pm_wh;
exit when v_per_slot_charge_wh <= 0;
exit when v_grid_slots_pm >= v_grid_charge_cap_pm;
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_grid_slots_pm := v_grid_slots_pm + 1;
end loop;
end if;
end if;
-- Referenční nákup pro arbitráž exportu: nejlevnější buy mezi sloty, kde lze nabíjet
-- (ne buy ve stejném slotu — střídač nekupuje a neprodává současně).
select coalesce(
min(wk.buy_price) filter (where wk.allow_charge),
min(wk.buy_price)
)
into v_ref_buy_czk_kwh
from _ems_plan_slot_wk wk;
v_ref_buy_czk_kwh := coalesce(v_ref_buy_czk_kwh, 0);
-- discharge-export mask
if v_discharge_buf <= 0 then
update _ems_plan_slot_wk wk set allow_discharge_export = true;
@@ -464,9 +519,9 @@ $fn$;
comment on function ems.fn_load_planning_slots_full is
'15min sloty s cenami, forecastem, baseline a maskami proti mikro-cyklu (charge/discharge-export). '
'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ě. '
'Charge mask A: PV-surplus dle store_score DESC (future_sellsellmax(0,buysell)); zbytek → PV export. '
'Charge mask B: non-PV jen spot, buy≤ref_buy+degrad, lookahead min buy v N slotech, cap 6 slotů AM/PM. '
'ref_buy = min(buy) horizontu. Discharge-export: nejdražší sell kde sell>ref_buy+degrad (spot). '
'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), '
'lookahead max buy/sell pro měkké LP penalizace.';

View File

@@ -127,6 +127,8 @@ Marže se konfigurují v `site_market_config`:
Denní ekonomika v DB (`ems.fn_economics_daily_for_window`, repeatable `R__068_fn_economics_daily_month.sql`) musí používat stejnou kombinaci jako `fn_effective_buy_price` (komentář ve funkci).
**Plánování:** efektivní `buy_price` per 15min slot už nese skok **VT→NT** (distribuce v `fn_effective_buy_price`). Maska grid nabíjení v `fn_load_planning_slots_full` navíc vyžaduje `buy ≤ min(buy v příštích 4 slotech) + ε`, aby se neplánoval import v posledním VT slotu před levným NT — viz `docs/04-modules/planning.md`.
### Screening skript pro dimenzování baterie
Analytický skript `scripts/analysis/battery_sizing_screen.py` umí pro nákup v režimu spot simulovat dva užitečné screening režimy bez vazby na konkrétní `site_market_config`:

View File

@@ -9,10 +9,12 @@
- **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.
- **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`. 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`**jen pokud** `site_market_config.purchase_pricing_mode <> 'fixed'`. U **fixního nákupu** (KV1) se vrstva B **nepoužívá**: `buy` je v každém slotu stejný, grid nabíjení by byl čistá ztráta cyklu; nabíjení jen z **PV přebytku** (vrstva A).
- Pokud `energy_to_fill <= 0` (baterie plná) nebo `charge_slot_buffer = 0`: všechny sloty povoleny.
- **Masky `allow_charge` / `allow_discharge_export` (tenký anti-mikrocyklus):** generuje `ems.fn_load_planning_slots_full` (`R__063`). Ekonomiku primárně řídí LP podle efektivních cen; masky jen omezují počet slotů pro grid nabíjení / export baterie.
- **PV-surplus (vrstva A):** ranking dle **`store_score DESC`** = `future_sell_opportunity sell max(0, buysell)`; jen sloty s `sell ≥ buy degradation`. Kumulativní PV pokrývá `grid_target` (deficit SoC, nad `reserve_soc` bez násobení `charge_slot_buffer`). Zbytek → `allow_charge=false` (PV jen do sítě / `bc ≤ pv_surplus` v LP).
- **Non-PV grid (vrstva B):** jen **spot** nákup (`purchase_pricing_mode <> 'fixed'`), `buy ≤ min(buy horizontu) + degradation`, **lookahead** `buy ≤ min(buy v příštích 4 slotech) + 0,05 Kč` (VT→NT), max **6 slotů** AM a PM; AM/PM rozpočet 50/50 z `grid_target`. **KV1/fixed:** vrstva B vypnutá.
- **`ref_buy` pro export baterie:** `min(buy_price)` celého horizontu (ne jen z `allow_charge`). Export sloty: `sell > ref_buy + degradation` (spot) / `sell > degradation` (fixed).
- Pokud `energy_to_fill <= 0` nebo `charge_slot_buffer = 0`: všechny sloty povoleny.
- **LP ekonomické guardy** (`solve_dispatch`, AUTO): pokud `sell < buy degradation``ge_pv=0` (výjimka: plná baterie, přebytek **pv_b**). Pokud `buy > min(buy)+degradation``gi` jen na load+EV+TČ. Viz `planning_engine.py` sekce po slot pre-selection.
- **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()`.
- **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`).
@@ -30,7 +32,7 @@
- měkký cíl na konci 24h přes `_soc_security_profile` + tvrdé dvouúrovňové pravidlo výše.
- **Dynamická ekonomická podlaha (fáze 2):**
- `_dynamic_arb_floor_wh_series`: podle součtu FVE výkonu v dalších ~8 h (`ARB_LOOKAHEAD_SLOTS`) se `arb_floor_wh[t]` posouvá mezi `min_soc_wh` a rezervou z DB silné očekávané slunce ji sníží (ráno / po obloze); vynutit konstantní chování lze `battery.disable_dynamic_arb_floor=True` jen pro testy / ladění.
- **Výběr exportních slotů (`allow_discharge_export`):** `ems.fn_load_planning_slots_full` označí jen sloty, kde smí solver **úmyslně** vybíjet baterii do sítě (SELL). Výběr je **globálně** podle `sell_price desc` (ne AM/PM 50/50). **Spot nákup:** `sell_price > ref_buy + degradation_cost_czk_kwh` (`ref_buy` = min `buy` mezi `allow_charge`, arbitráž mezi sloty). **Fixní nákup** (`purchase_pricing_mode = fixed`): `sell_price > degradation_cost_czk_kwh` (prodej na spotu, bez porovnání s fixním 6,35 Kč). V `solve_dispatch` (AUTO) je export rozdělen: **`ge_pv`** (kanál FVE) a **`ge_bat`** (baterie do sítě, jen v `allow_discharge_export`, vázáno na `z_export` a SoC podlahu); platí `ge = ge_pv + ge_bat` a `ge_bat ≥ ge (pv_a + pv_b)` — baterie nesmí „přestrojit“ FVE. Mimo exportní sloty: **`ge_bat = 0`**, **`bd`** smí pokrýt vlastní spotřebu; **`bc`** smí nabíjet jen z **PV přebytku** i bez grid-charge masky (plná baterie + přebytek pole B jinak nejde do sítě). **`deye_physical_mode`** = PASSIVE kromě CHARGE/SELL.
- **Výběr exportních slotů (`allow_discharge_export`):** `ems.fn_load_planning_slots_full` označí jen sloty, kde smí solver **úmyslně** vybíjet baterii do sítě (SELL). Výběr je **globálně** podle `sell_price desc` (ne AM/PM 50/50). **Spot nákup:** `sell_price > ref_buy + degradation_cost_czk_kwh` (`ref_buy` = **min `buy` horizontu**). **Fixní nákup** (`purchase_pricing_mode = fixed`): `sell_price > degradation_cost_czk_kwh` (prodej na spotu, bez porovnání s fixním 6,35 Kč). V `solve_dispatch` (AUTO) je export rozdělen: **`ge_pv`** (kanál FVE) a **`ge_bat`** (baterie do sítě, jen v `allow_discharge_export`, vázáno na `z_export` a SoC podlahu); platí `ge = ge_pv + ge_bat` a `ge_bat ≥ ge (pv_a + pv_b)` — baterie nesmí „přestrojit“ FVE. Mimo exportní sloty: **`ge_bat = 0`**, **`bd`** smí pokrýt vlastní spotřebu; **`bc`** smí nabíjet jen z **PV přebytku** i bez grid-charge masky (plná baterie + přebytek pole B jinak nejde do sítě). **`deye_physical_mode`** = PASSIVE kromě CHARGE/SELL.
- **Záporná nákupní cena:**
- horní mez `grid_import` zahrnuje `load_baseline_w` + nabíjení/EV/TČ (bez nekonečného importu).
- **Záporná prodejní cena → tvrdý zákaz vývozu (`ge = 0`)** (`planning_engine.solve_dispatch`): platí ve slotu kde `sell_price < 0`, pokud lokality zapne některou z opcí —
@@ -56,8 +58,8 @@ where allow_charge is true
order by interval_start;
```
- 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`).
- PV-surplus: `allow_charge=true` pro nejvyšší `store_score`, dokud se nepokryje `grid_target`.
- Non-PV: levný `buy`, lookahead 4 sloty, cap 6/segment; OTE před predikovanými.
- Pokud `current_soc_wh` odpovídá plné baterii (`soc_max_wh`), jsou povoleny všechny sloty.
---