zasadni uprava LP planneru
This commit is contained in:
@@ -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`).
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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řednostně."""
|
||||
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 = [
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 00–12 vs 12–24).
|
||||
-- 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,buy−sell)).
|
||||
-- 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_sell−sell−max(0,buy−sell)); 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:00–06:00 Europe/Prague), safety_soc_target_wh (6–19), '
|
||||
'lookahead max buy/sell pro měkké LP penalizace.';
|
||||
|
||||
@@ -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`:
|
||||
|
||||
@@ -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[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`. 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, buy−sell)`; 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: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()`.
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user