Files
ems/backend/services/planning_engine.py
Dusan Vojacek 0dcf11d471
Some checks failed
CI and deploy / migration-check (push) Failing after 50s
CI and deploy / deploy (push) Has been skipped
oprava ranniho nenabijeni
2026-06-01 18:50:03 +02:00

5493 lines
212 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# backend/services/planning_engine.py
#
# EMS Platform plánovací engine
# Obsahuje: hlavní denní plán + rolling 15min replan
#
# Spouštění (APScheduler v lifespan.py):
# scheduler.add_job(run_daily_plan, 'cron', hour=15, minute=0)
# scheduler.add_job(run_rolling_replan, 'cron', minute='*/15')
# Horizont: ems.fn_planning_horizon_end (OTE + strop/min v SQL).
import json
import logging
import time
from dataclasses import dataclass, replace
from datetime import datetime, timezone, timedelta
from types import SimpleNamespace
from typing import Any, Optional
from zoneinfo import ZoneInfo
import pulp
from app.config import get_settings
logger = logging.getLogger(__name__)
# ============================================================
# Konstanty
# ============================================================
# Když DB vrátí NULL (skoro žádná OTE data), denní plán použije krátký fallback (soulad s min hodinami ve fn_planning_horizon_end).
_DAILY_FALLBACK_HORIZON_HOURS = 1.0
# Shadow cena zbytkové energie na konci horizontu: - (avg_buy * FACTOR / 1000) * soc[T-1] (Kč; soc v Wh).
INTERVAL_H = 0.25 # 15 minut v hodinách
CURTAILMENT_PENALTY = 0.001 # Kč/Wh malá penalizace za omezení FVE pole A
SOLVER_TIME_LIMIT = 10 # sekund
# MILP: významný export ge (W) ⇒ koncové soc[t] ≥ podlaha; mimo arbitrážní relax je to arb_base_wh
# (rezerva z DB). Při relaxaci spodku před extrémně záporným buy je podlaha soc_panel_min[t]
# (planner floor), jinak by šlo jen do zátěže a nešlo by „vypustit do sítě“ před levným nákupem.
GE_MIN_EXPORT_W = 1.0
# Dvouprůchodové solve: stop když acquisition z pass1 vs pass2 se liší méně než (Kč/kWh).
ACQUISITION_TWO_PASS_EPS_KWH = 0.05
# Load-first (Deye): PV nejdřív pokryje load+EV+TČ; bc_pv/ge_pv jen z pv_sp (přebytek).
LOAD_FIRST_INCENTIVE_CZK_KWH = 0.05
# Dokud je kotva pro hluboký dump (první sell < 0 v horizontu, jinak první extrémní buy) dál než
# tento počet 15min slotů, držíme plánovací spodek na rezervě (arb_base_wh) místo planner floor —
# priorita: beze „ztráty na prodeji“ (sell >= 0) držet buffer, hluboký vývoz až těsně před záporným prodejem.
DEFAULT_PLANNER_DISCHARGE_RELAX_PREWINDOW_SLOTS = 8
# Měkká kotva: chceme být u planner floor už v posledním slotu před prvním sell < 0.
# Penalizace je v Kč/Wh (např. 0.20 = 200 Kč/kWh). Musí být dost velká, aby přebila
# bezpečnostní SoC buffer + terminal shadow cenu a solver skutečně „dovylil“ před sell<0.
PRENEG_SELL_SOC_ANCHOR_SLACK_PENALTY_CZK_PER_WH = 0.20
PEAK_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 80.0
# Měkký tlak: v okně sell<0 + block_export využít PV přebytek do baterie (ne curtail).
PV_CHARGE_SHORTFALL_PENALTY_CZK_KWH = 120.0
# Curtailment při sell<0 + allow_charge: nesmí být téměř zdarma oproti nabíjení (BA81).
NEG_SELL_CURTAIL_PENALTY_CZK_KWH = 1.0
# Odměna v objective za FVE→baterie při sell<0 (doplňuje shortfall; BA81 fixed tarif).
NEG_SELL_PV_CHARGE_REWARD_CZK_KWH = 0.8
# Měkký tlak: v okně sell<0 dobít na soc_max (ne zastavit na ~94 % kvůli curtail).
NEG_SELL_SOC_UNDERFILL_PENALTY_CZK_PER_WH = 0.35
# Jen ventil nekontrolovatelného pole B při plné baterii a sell<0 (spot); ne celý PV přebytek.
NEG_SELL_PV_B_VENT_PENALTY_CZK_KWH = 4.0
# Fáze sell<0 (v32): ASAP na prep_soc %, tail rampa na soc_max.
NEG_SELL_PREP_SOC_SHORTFALL_PENALTY_CZK_PER_WH = 0.85
NEG_SELL_PREP_HOLD_BCPV_PENALTY_CZK_KWH = 60.0
# Výboj baterie při sell<0 jen těsně před extrémně záporným buy (round-trip arbitráž).
EXTREME_BUY_DUMP_PREWINDOW_SLOTS = 12
NEG_SELL_BAT_DUMP_SHORTFALL_PENALTY_CZK_KWH = 80.0
NEG_BUY_CHARGE_SHORTFALL_PENALTY_CZK_KWH = 100.0
PRE_NEG_CHARGE_PENALTY_CZK_KWH = 400.0
PRE_NEG_BATT_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 80.0
PRE_NEG_BATT_EXPORT_MIN_SELL_CZK_KWH = 1.0
PLANNER_BUILD_TAG = "2026-06-01-fixed-pv-export-min-sell-charge-v58"
# Ranní slabá FVE: neaplikovat pv_store ge_pv=0 (jinak curtail při sell < večerní peak).
DAWN_LOW_PV_NO_CURTAIL_W = 1500
# BA81/KV1: PV→bat jen v těsné blízkosti nejnižšího sell v horizontu (≈ poledne), ne při ~3 Kč ráno.
FIXED_PV_CHARGE_ONLY_NEAR_MIN_SELL_CZK_KWH = 0.20
# Mimo evening_push: preferovat bd pro dům místo gi, když buy >> acq (účinná cena importu).
NIGHT_SELF_CONSUME_IMPORT_SURCHARGE_CZK_KWH = 4.0
# Po t_detach v prep: necpát PV do bat (měkké; tvrdý hold přes soc_target z rampy).
NEG_SELL_POST_DETACH_BCPV_DISCOURAGE_CZK_KWH = 250.0
# Večer před neg dnem: výboj do sítě (měkký shortfall na ge_bat).
NEG_EVENING_PREP_DISCHARGE_SHORTFALL_PENALTY_CZK_KWH = 120.0
# Kotva: SoC na konci večera D1 a těsně před 1. sell<0 ráno D ≤ reserve_soc.
NEG_EVENING_RESERVE_SOC_MAX_SLACK_WH = 400.0
NEG_EVENING_RESERVE_SOC_SLACK_PENALTY_CZK_PER_WH = 55.0
# Před prvním sell<0: export FVE jen pokud predikce v sell<0 okně pokryje dobítí na prep cíl.
PRE_NEG_PV_EXPORT_FORECAST_MARGIN = 1.15
PRE_NEG_PV_EXPORT_MIN_NEEDED_WH = 2500.0
PRE_NEG_PV_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 55.0
PRE_NEG_PV_BCPV_DISCOURAGE_CZK_KWH = 90.0
POS_SELL_PRE_NEG_SOC_SHORTFALL_PENALTY_CZK_PER_WH = 0.30
PRE_NEG_BUY_SOC_CEILING_SLACK_PENALTY_CZK_PER_WH = 0.25
PRE_NEG_BUY_EMPTY_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 80.0
EVENING_PEAK_SELL_EPS_CZK_KWH = 0.05
# Rolling replan: držet evening_push_ts při malé změně peak sell / SoC.
EVENING_PUSH_HYSTERESIS_SELL_PEAK_DELTA_CZK_KWH = 0.5
EVENING_PUSH_HYSTERESIS_SOC_PCT = 5.0
# Noční výprodej baterie: večer (≥17h) + ráno do východu FVE (05h Prague), jedna špička přes půlnoc.
NIGHT_EXPORT_EVENING_START_HOUR = 17
NIGHT_EXPORT_MORNING_END_HOUR = 5
NIGHT_EXPORT_PV_SUNRISE_SURPLUS_W = 500.0
# Převáží terminal SoC shadow price při krátkém večerním horizontu (home-01).
EVENING_PUSH_Z_EXPORT_BONUS_CZK = 2500.0
# buy<0: preferovat import před PV A→bat (měkké; tvrdé bc_pv=0 láme bilanci s polem B).
PRE_NEG_BUY_PV_CHARGE_PENALTY_CZK_KWH = 250.0
CORRECTION_WINDOW_H = 1 # hodina zpět pro výpočet korekčního faktoru
CORRECTION_MIN_CLAMP = 0.5 # spodní limit korekčního faktoru
CORRECTION_MAX_CLAMP = 1.5 # horní limit korekčního faktoru
# Útlum korekce: čím dál od aktuálního času, tím méně korigujeme forecast
CORRECTION_DECAY_SLOTS = 16 # po 16 slotech (4h) klesne korekce na 0
# Dynamická ekonomická podlaha (MILP w_arb): lookahead FVE energie v dalších slotech
ARB_LOOKAHEAD_SLOTS = 32 # 8 h při INTERVAL_H=0.25
ARB_FLOOR_E_REF_FRAC = 0.5 # má scale Wh = tato frakce usable_capacity (0..1)
_PRAGUE_TZ = ZoneInfo("Europe/Prague")
def _timestamptz_from_db(val: object) -> Optional[datetime]:
if val is None:
return None
if isinstance(val, datetime):
return val if val.tzinfo else val.replace(tzinfo=timezone.utc)
return datetime.fromisoformat(str(val).replace("Z", "+00:00"))
def _planner_engine_version(explicit: str | None = None) -> str:
if explicit is not None and str(explicit).strip():
return str(explicit).strip().lower()
return str(get_settings().planning_engine_version or "v1").strip().lower()
def _planner_compare_enabled() -> bool:
return bool(get_settings().planning_engine_compare_enabled)
def _planner_peer_version(version: str) -> str:
v = str(version).strip().lower()
if v == "v1":
return "v2"
if v == "v2":
return "v1"
return "v1"
def _dispatch_result_summary(results: list["DispatchResult"], duration_ms: int, version: str) -> dict[str, Any]:
charge_slots = [r.interval_start.isoformat() for r in results if r.battery_setpoint_w > 500]
discharge_slots = [r.interval_start.isoformat() for r in results if r.battery_setpoint_w < -500]
export_slots = [r.interval_start.isoformat() for r in results if r.grid_setpoint_w < 0]
return {
"planner_version": version,
"solver_duration_ms": int(duration_ms),
"total_expected_cost_czk": round(sum(float(r.expected_cost_czk) for r in results), 4),
"charge_slots": len(charge_slots),
"discharge_slots": len(discharge_slots),
"export_slots": len(export_slots),
"first_charge_slot": charge_slots[0] if charge_slots else None,
"first_discharge_slot": discharge_slots[0] if discharge_slots else None,
"first_export_slot": export_slots[0] if export_slots else None,
}
def _dispatch_result_comparison(
active_results: list["DispatchResult"],
active_ms: int,
active_version: str,
peer_results: list["DispatchResult"],
peer_ms: int,
peer_version: str,
) -> dict[str, Any]:
active_summary = _dispatch_result_summary(active_results, active_ms, active_version)
peer_summary = _dispatch_result_summary(peer_results, peer_ms, peer_version)
slot_rows: list[dict[str, Any]] = []
for a, b in zip(active_results, peer_results):
row = {
"interval_start": a.interval_start.isoformat(),
"active": {
"battery_setpoint_w": a.battery_setpoint_w,
"grid_setpoint_w": a.grid_setpoint_w,
"export_mode": a.export_mode,
"deye_physical_mode": a.deye_physical_mode,
"deye_gen_cutoff_enabled": a.deye_gen_cutoff_enabled,
"pv_a_curtailed_w": a.pv_a_curtailed_w,
"battery_soc_target": a.battery_soc_target,
"expected_cost_czk": a.expected_cost_czk,
},
"peer": {
"battery_setpoint_w": b.battery_setpoint_w,
"grid_setpoint_w": b.grid_setpoint_w,
"export_mode": b.export_mode,
"deye_physical_mode": b.deye_physical_mode,
"deye_gen_cutoff_enabled": b.deye_gen_cutoff_enabled,
"pv_a_curtailed_w": b.pv_a_curtailed_w,
"battery_soc_target": b.battery_soc_target,
"expected_cost_czk": b.expected_cost_czk,
},
}
if row["active"] != row["peer"]:
slot_rows.append(row)
total_cost_diff = round(
float(active_summary["total_expected_cost_czk"]) - float(peer_summary["total_expected_cost_czk"]),
4,
)
return {
"compare_enabled": True,
"active": active_summary,
"peer": peer_summary,
"diff": {
"total_expected_cost_czk": total_cost_diff,
"absolute_total_expected_cost_czk": round(abs(total_cost_diff), 4),
"changed_slots": len(slot_rows),
},
"slot_diffs": slot_rows,
}
def _maybe_add_planner_comparison(
*,
slots: list["PlanningSlot"],
battery,
heat_pump,
grid,
ev_sessions: list,
vehicles: list,
current_soc_wh: float,
current_tuv_temp_c: float,
operating_mode: str,
tuv_delta_stats: Optional[dict[tuple[int, int], float]],
active_version: str,
charge_commitment_prev_w: Optional[list[Optional[float]]] = None,
) -> dict[str, Any] | None:
if not _planner_compare_enabled():
return None
peer_version = _planner_peer_version(active_version)
if peer_version == active_version:
return None
try:
peer_results, peer_ms, peer_snapshot = solve_dispatch_two_pass(
slots,
battery,
heat_pump,
grid,
ev_sessions,
vehicles,
current_soc_wh,
current_tuv_temp_c,
tuv_delta_stats=tuv_delta_stats,
operating_mode=operating_mode,
charge_commitment_prev_w=charge_commitment_prev_w,
planner_version=peer_version,
evening_push_ts_override=None,
)
except RuntimeError as exc:
logger.warning(
"Planner comparison peer (%s) failed, skipping compare run: %s",
peer_version,
exc,
)
return None
# active_results / active_ms jsou doplněny později v calleru
return {
"peer_version": peer_version,
"peer_results": peer_results,
"peer_ms": peer_ms,
"peer_snapshot": peer_snapshot,
}
async def _planning_horizon_end(site_id: int, horizon_from: datetime, db) -> Optional[datetime]:
"""Konec horizontu z DB (`fn_planning_horizon_end`); NULL = rolling skip / daily fallback."""
raw = await db.fetchval(
"select ems.fn_planning_horizon_end($1::int, $2::timestamptz)",
site_id,
horizon_from,
)
return _timestamptz_from_db(raw)
def _pv_scarcity_penalty_multiplier(slots: list["PlanningSlot"], battery) -> float:
"""
Měkká úprava ekonomiky cyklu podle očekávaného slunečního zisku.
- málo očekávané FVE energie -> nižší penalizace cyklu (podpora precharge ze sítě),
- hodně očekávané FVE energie -> standardní penalizace.
"""
horizon_slots = min(len(slots), int(24 / INTERVAL_H)) # konzervativní 1 den dopředu
if horizon_slots <= 0:
return 1.0
pv_kwh = 0.0
for s in slots[:horizon_slots]:
pv_kwh += max(0.0, float(s.pv_a_forecast_w + s.pv_b_forecast_w)) * INTERVAL_H / 1000.0
batt_kwh = max(1.0, float(getattr(battery, "usable_capacity_wh", 0.0)) / 1000.0)
# coverage = kolikanásobek baterie očekáváme ze slunce v horizontu.
coverage = pv_kwh / batt_kwh
coverage_clamped = max(0.0, min(1.0, coverage))
# 0.65 při nízkém slunci, 1.0 při vysokém slunci.
return 0.65 + 0.35 * coverage_clamped
def _pv_coverage_ratio(slots: list["PlanningSlot"], battery, hours: int = 24) -> float:
horizon_slots = min(len(slots), int(hours / INTERVAL_H))
if horizon_slots <= 0:
return 1.0
pv_kwh = 0.0
for s in slots[:horizon_slots]:
pv_kwh += max(0.0, float(s.pv_a_forecast_w + s.pv_b_forecast_w)) * INTERVAL_H / 1000.0
batt_kwh = max(1.0, float(getattr(battery, "usable_capacity_wh", 0.0)) / 1000.0)
return max(0.0, min(1.0, pv_kwh / batt_kwh))
def _dynamic_arb_floor_wh_series(
slots: list["PlanningSlot"],
min_soc_wh: float,
arb_base_wh: float,
usable_wh: float,
) -> list[float]:
"""
Časově proměnná ekonomická podlaha Wh pro MILP (nad min_soc_wh).
Hodně očekávané FVE energie v dalších ARB_LOOKAHEAD_SLOTS → podlaha klesá k min_soc_wh;
málo slunce → zůstává u arb_base_wh (typicky reserve z DB).
"""
T = len(slots)
if T == 0:
return []
e_ref = max(1.0, ARB_FLOOR_E_REF_FRAC * float(usable_wh))
spread = max(0.0, float(arb_base_wh) - float(min_soc_wh))
out: list[float] = []
for t in range(T):
e_pv_wh = 0.0
for k in range(t, min(T, t + ARB_LOOKAHEAD_SLOTS)):
s = slots[k]
e_pv_wh += max(0, s.pv_a_forecast_w + s.pv_b_forecast_w) * INTERVAL_H
f = min(1.0, e_pv_wh / e_ref) if e_ref > 1e-9 else 1.0
arb_t = float(min_soc_wh) + (1.0 - f) * spread
out.append(arb_t)
return out
def _soc_security_profile(slots: list["PlanningSlot"], battery) -> tuple[float, float]:
"""
Při nízkém očekávaném slunci drží solver vyšší SoC buffer:
- cílový buffer: reserve + až 20 % usable capacity,
- ekonomická penalizace deficitu vůči bufferu z průměrné ceny.
"""
coverage = _pv_coverage_ratio(slots, battery, hours=24)
scarcity = 1.0 - coverage
usable_wh = float(getattr(battery, "usable_capacity_wh", 0.0))
reserve_wh = float(getattr(battery, "reserve_soc_wh", 0.0))
soc_max_wh = float(getattr(battery, "soc_max_wh", usable_wh))
extra_buffer_wh = 0.35 * usable_wh * scarcity
target_wh = min(soc_max_wh, reserve_wh + extra_buffer_wh)
h24 = min(len(slots), int(24 / INTERVAL_H))
avg_buy = (
sum(float(s.buy_price) for s in slots[:h24]) / h24
if h24 > 0
else 4.0
)
penalty_czk_kwh = max(0.1, avg_buy * 1.00 * scarcity)
return target_wh, penalty_czk_kwh
def _slot_float_nullable(d: dict[str, Any], key: str) -> float | None:
v = d.get(key)
if v is None:
return None
return float(v)
def _prague_dow_hour(interval_start: datetime) -> tuple[int, int]:
"""DOW v konvenci PostgreSQL EXTRACT(DOW, Europe/Prague): 0=Ne … 6=So."""
dt = interval_start
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
loc = dt.astimezone(_PRAGUE_TZ)
return (loc.weekday() + 1) % 7, loc.hour
# ============================================================
# Datové třídy (lze nahradit pydantic modely)
# ============================================================
@dataclass
class PlanningSlot:
interval_start: datetime
buy_price: float # Kč/kWh
sell_price: float # Kč/kWh
pv_a_forecast_w: int # W pole A (řiditelné)
pv_b_forecast_w: int # W pole B (zelený bonus, pevné)
load_baseline_w: int # W predikce bazální spotřeby
ev1_connected: bool
ev2_connected: bool
is_predicted_price: bool = False
allow_charge: bool = True
allow_discharge_export: bool = True
#: Měkké LP vstupy z `ems.fn_load_planning_slots_full` (mimo masky allow_*).
night_baseload_target_wh: float | None = None
night_baseload_buffer_wh: float | None = None
safety_soc_target_wh: float | None = None
future_avoided_buy_czk_kwh: float | None = None
future_sell_opportunity_czk_kwh: float | None = None
is_daytime_pv_surplus_slot: bool = False
#: Vážená nákupní / opportunity cena zásoby před prvním exportním oknem (SQL odhad z masek).
charge_acquisition_buy_czk_kwh: float | None = None
charge_acquisition_cutoff_at: datetime | None = None
min_buy_before_cutoff_czk_kwh: float | None = None
pv_charge_wh_ahead: float | None = None
neg_buy_wh_ahead: float | None = None
grid_charge_suppressed_reason: str | None = None
#: Pomocny atribut pro green_bonus v planning_interval (Kc/slot); lite default 0.
green_bonus_czk_per_slot: float = 0.0
# Lookahead pro relax spodní meze SoC: až 36 h od indexu slotu (pevné OTE ceny v horizontu).
SOC_MIN_RELAX_LOOKAHEAD_SLOTS = 144
def _soc_min_wh_series(
slots: list[PlanningSlot],
usable_wh: float,
base_min_wh: float,
buy_extreme_threshold: float,
planner_discharge_floor_pct: float | None,
) -> list[float]:
"""
Spodní mez SoC (Wh) pro každý slot: při extrémně záporném buy v lookahead povolit hlubší vybíjení
až na planner_discharge_floor_percent (jinak min_soc z DB). Absolutní minimum 5 % usable.
"""
t_len = len(slots)
abs_min_wh = max(usable_wh * 0.05, 1.0)
if planner_discharge_floor_pct is None:
relaxed_wh = base_min_wh
else:
relaxed_wh = max(abs_min_wh, float(planner_discharge_floor_pct) / 100.0 * usable_wh)
effective_relaxed = min(base_min_wh, relaxed_wh)
out: list[float] = []
for t in range(t_len):
j_end = min(t_len, t + SOC_MIN_RELAX_LOOKAHEAD_SLOTS)
min_buy_fwd = min(float(slots[k].buy_price) for k in range(t, j_end))
if min_buy_fwd <= buy_extreme_threshold:
out.append(float(effective_relaxed))
else:
out.append(float(base_min_wh))
return out
def _slots_until_buy_le_threshold(
slots: list[PlanningSlot], buy_threshold: float
) -> list[int]:
"""
Pro slot t: kolik slotů (0 = tento slot) do nejbližšího k>=t s buy_price <= buy_threshold.
Pokud v [t, T) žádný takový není, vrátí T + 1 (větší než jakýkoli rozumný prewindow).
"""
t_len = len(slots)
sentinel = t_len + 1
next_le = sentinel
next_at_or_after: list[int] = [sentinel] * t_len
for t in range(t_len - 1, -1, -1):
if float(slots[t].buy_price) <= buy_threshold:
next_le = t
next_at_or_after[t] = next_le
out: list[int] = []
for t in range(t_len):
nxt = next_at_or_after[t]
if nxt >= t_len:
out.append(sentinel)
else:
out.append(nxt - t)
return out
def _slots_until_sell_lt(slots: list[PlanningSlot], sell_upper: float) -> list[int]:
"""
Pro slot t: kolik slotů (0 = tento slot) do nejbližšího k>=t s sell_price < sell_upper.
Typicky sell_upper=0 (první záporný / „ztrátový“ prodej z pohledu OTE).
Pokud v [t, T) žádný takový není, vrátí T + 1.
"""
t_len = len(slots)
sentinel = t_len + 1
next_lt = sentinel
next_at_or_after: list[int] = [sentinel] * t_len
for t in range(t_len - 1, -1, -1):
if float(slots[t].sell_price) < sell_upper:
next_lt = t
next_at_or_after[t] = next_lt
out: list[int] = []
for t in range(t_len):
nxt = next_at_or_after[t]
if nxt >= t_len:
out.append(sentinel)
else:
out.append(nxt - t)
return out
def _prewindow_deferral_slots(
slots: list[PlanningSlot], buy_extreme_threshold: float, sell_upper: float = 0.0
) -> list[int]:
"""
Vzdálenost (v 15min slotech) pro zpoždění hlubokého planner flooru:
primárně do prvního sell < sell_upper (poslední „bez ztráty na prodeji“ je k-1),
pokud v horizontu není záporný prodej, fallback na první buy <= buy_extreme_threshold.
"""
t_len = len(slots)
sell_d = _slots_until_sell_lt(slots, sell_upper)
buy_d = _slots_until_buy_le_threshold(slots, buy_extreme_threshold)
sentinel = t_len + 1
out: list[int] = []
for t in range(t_len):
if sell_d[t] < sentinel:
out.append(sell_d[t])
else:
out.append(buy_d[t])
return out
def _soc_panel_min_wh_series(
soc_min_series: list[float],
slots_until_relax_anchor: list[int],
min_soc_wh: float,
arb_base_wh: float,
prewindow_slots: int,
) -> list[float]:
"""
Zpoždění hluboké relaxace: pokud je lookahead extrémní (soc_min pod min_soc), ale kotva
(záporný prodej / fallback extrémní buy) je dál než prewindow_slots, drž spodek na
max(relax_wh, arb_base_wh) — prakticky na rezervě.
"""
t_len = len(soc_min_series)
out: list[float] = []
for t in range(t_len):
sm = float(soc_min_series[t])
if sm < min_soc_wh - 1e-3 and slots_until_relax_anchor[t] > prewindow_slots:
out.append(max(sm, float(arb_base_wh)))
else:
out.append(sm)
return out
@dataclass
class DispatchResult:
interval_start: datetime
battery_setpoint_w: int # kladné = nabíjení, záporné = vybíjení
battery_soc_target: float # % SoC na konci intervalu
grid_setpoint_w: int # kladné = import, záporné = export
export_limit_w: int # tvrdý limit exportu do sítě; 0 = bez exportu
export_mode: str # NONE / PV_SURPLUS / BATTERY_SELL
#: Explicitní fyzický režim Deye pro control exporter (PASSIVE / SELL / CHARGE).
#: Cíl: odstranit heuristiky z exporteru a nést záměr přímo v plánu.
deye_physical_mode: str
#: True = v daném slotu odpojit GEN port (MI export cutoff) přes reg 178 bits01 (0-based; v UI často jako "register 179").
#: None = lokalita tuto funkci nemá / nepoužívá.
deye_gen_cutoff_enabled: bool | None
ev1_setpoint_w: Optional[int]
ev2_setpoint_w: Optional[int]
ev1_via_bat_w: int
ev2_via_bat_w: int
heat_pump_enabled: bool
heat_pump_setpoint_w: int
pv_a_curtailed_w: int
expected_cost_czk: float
effective_buy_price: float
effective_sell_price: float
is_predicted_price: bool # shodné s PlanningSlot (chybí OTE v efektivní ceně → fn_get_predicted_price)
cashflow_czk: float
battery_arbitrage_czk: float
penalty_czk: float
green_bonus_czk: float
# ============================================================
# Korekce forecastu na základě skutečné výroby
# ============================================================
async def compute_correction_factor(
site_id: int,
now: datetime,
db,
window_h: float = CORRECTION_WINDOW_H,
) -> tuple[float, dict]:
"""
Spočítá korekční faktor FVE forecastu z posledních window_h hodin.
Vrátí (factor, log_data) kde factor je v rozsahu [CORRECTION_MIN_CLAMP, CORRECTION_MAX_CLAMP].
factor = 1.0 pokud není dostatek dat nebo je rozdíl zanedbatelný.
"""
window_start = now - timedelta(hours=window_h)
raw = await db.fetchval(
"""
select ems.fn_pv_forecast_correction_factor(
$1::int, $2::timestamptz, $3::timestamptz,
$4::numeric, $5::numeric
)
""",
site_id,
window_start,
now,
CORRECTION_MIN_CLAMP,
CORRECTION_MAX_CLAMP,
)
j = raw if isinstance(raw, dict) else json.loads(raw)
factor = float(j.get("correction_factor", 1.0))
# JSON z DB má často ISO řetězce; asyncpg u $2/$3 vyžaduje datetime
ws = _parse_json_dt(j.get("window_start")) or window_start
we = _parse_json_dt(j.get("window_end")) or now
log_data = {
"window_start": ws,
"window_end": we,
"actual_pv_wh": j.get("actual_pv_wh"),
"forecast_pv_wh": j.get("forecast_pv_wh"),
"correction_factor": factor,
"reason": j.get("reason", "ok"),
}
if j.get("raw_factor") is not None:
log_data["raw_factor"] = j["raw_factor"]
return factor, log_data
def apply_forecast_correction(
slots: list[PlanningSlot],
now: datetime,
factor: float,
decay_slots: int = CORRECTION_DECAY_SLOTS,
) -> list[PlanningSlot]:
"""
Aplikuje korekční faktor na FVE forecast zbývajících slotů.
Korekce se lineárně utlumuje: na 1. slotu plná korekce,
na decay_slots-tém slotu žádná korekce.
Příklad: factor=0.85, slot 0 → pv_a *= 0.85, slot 8 → pv_a *= 0.925, slot 16+ → žádná korekce
"""
corrected = []
for i, slot in enumerate(slots):
if factor == 1.0 or i >= decay_slots:
corrected.append(slot)
continue
# Lineární útlum: weight klesá od 1.0 (slot 0) do 0.0 (slot decay_slots)
weight = 1.0 - (i / decay_slots)
effective_factor = 1.0 + (factor - 1.0) * weight
corrected.append(
replace(
slot,
pv_a_forecast_w=max(0, int(slot.pv_a_forecast_w * effective_factor)),
pv_b_forecast_w=max(0, int(slot.pv_b_forecast_w * effective_factor)),
)
)
return corrected
# ============================================================
# LP Solver
# ============================================================
def _recompute_charge_acquisition_from_results(
slots: list[PlanningSlot],
results: list["DispatchResult"],
battery,
) -> float:
"""Vážený buy z nabíjecích slotů (grid import + bat charge) z prvního solve."""
wh_total = 0.0
cost = 0.0
for s, r in zip(slots, results):
if not s.allow_charge:
continue
# Zaporne buy sloty (OTE) nejsou grid acquisition pro arbitraz exportu baterie.
if float(s.buy_price) < 0:
continue
gi_w = max(0, int(r.grid_setpoint_w or 0))
bc_w = max(0, int(r.battery_setpoint_w or 0))
wh = (gi_w + bc_w) * INTERVAL_H
if wh <= 0:
continue
wh_total += wh
cost += float(s.buy_price) * wh
if wh_total <= 0:
raw = getattr(slots[0], "charge_acquisition_buy_czk_kwh", None)
if raw is not None:
return float(raw)
return min(float(s.buy_price) for s in slots)
return cost / wh_total
def _slots_with_charge_acquisition(
slots: list[PlanningSlot],
acquisition_czk_kwh: float,
) -> list[PlanningSlot]:
return [
replace(s, charge_acquisition_buy_czk_kwh=acquisition_czk_kwh)
for s in slots
]
def _pv_store_value_czk_kwh(slot: PlanningSlot, min_spread: float) -> float:
"""
Práh pro tvrdý zákaz ge_pv (sell pod budoucím max sell v horizontu).
U spotu při sell >= 0 se neaplikuje — export vs. nabíjení řeší LP; baterii
na večerní peak drží ge_bat (evening_early / push), ne ge_pv == 0.
"""
future = float(
slot.future_sell_opportunity_czk_kwh
if slot.future_sell_opportunity_czk_kwh is not None
else slot.sell_price
)
return future - min_spread
def _slot_profitable_battery_export(
slot: PlanningSlot,
*,
charge_acquisition_czk_kwh: float,
min_spread: float,
fixed_tariff: bool,
) -> bool:
"""
Export z baterie do sítě má kladnou marži.
Spot: sell > charge_acquisition + spread (energie ze sítě / vážený nákup).
Fixní tarif (BA81/KV1): stejně jako R__063 discharge maska — sell > buy + spread;
acquisition může být nafouknutá grid nabíjením a blokovat večerní špičku (3,7 < 3,9).
"""
sell_t = float(slot.sell_price)
acq = float(charge_acquisition_czk_kwh)
if fixed_tariff:
buy_t = float(slot.buy_price)
if buy_t >= 0.0:
return sell_t > buy_t + min_spread
return sell_t > acq + min_spread
def _purchase_pricing_fixed(grid: Any) -> bool:
"""Režim nákupu z DB (`site_market_config.purchase_pricing_mode`), ne odhad z rozptylu buy."""
return (
str(getattr(grid, "purchase_pricing_mode", "spot") or "spot").strip().lower()
== "fixed"
)
def _horizon_fixed_tariff_like(slots: list[PlanningSlot]) -> bool:
"""
Heuristika pro drahý import / charge_acquisition: buy v horizontu je prakticky konstantní.
U spotu (home-01) nesmí expensive_import používat charge_acquisition — jinak
buy > ~1 Kč označí téměř všechny sloty jako drahé (gi=0 pro dům) → Infeasible.
BA81 má fixní nákup v DB, ale NT/VT → buy skáče; proto neg-sell export řídí _purchase_pricing_fixed.
"""
buys = [float(s.buy_price) for s in slots if float(s.buy_price) >= 0.0]
if not buys:
return False
if len(buys) == 1:
return True
return max(buys) - min(buys) < 0.25
def _future_extreme_buy_from(
slots: list[PlanningSlot],
buy_thr: float,
) -> list[bool]:
"""True v t, pokud v některém budoucím slotu buy <= buy_thr."""
t_len = len(slots)
out = [False] * t_len
seen = False
for i in range(t_len - 1, -1, -1):
if float(slots[i].buy_price) <= buy_thr:
seen = True
out[i] = seen
return out
def _neg_sell_bat_dump_slots(
slots: list[PlanningSlot],
*,
operating_mode: str,
purchase_fixed: bool,
grid: Any,
buy_extreme_thr: float,
degrad_czk_kwh: float,
) -> set[int]:
"""Sloty, kde smí ge_bat>0 při sell<0 (výboj před extrémně záporným buy)."""
if operating_mode != "AUTO" or purchase_fixed:
return set()
if bool(getattr(grid, "block_export_on_negative_sell", False)):
return set()
t_len = len(slots)
future_extreme = _future_extreme_buy_from(slots, buy_extreme_thr)
dist = _slots_until_buy_le(slots, buy_extreme_thr)
out: set[int] = set()
for t, s in enumerate(slots):
if float(s.sell_price) >= 0.0:
continue
future_min = min(
(float(slots[j].buy_price) for j in range(t + 1, t_len)),
default=float(s.buy_price),
)
if (
future_extreme[t]
and 0 < dist[t] <= EXTREME_BUY_DUMP_PREWINDOW_SLOTS
and future_min < float(s.sell_price) - degrad_czk_kwh
):
out.add(t)
return out
def _slots_until_buy_le(
slots: list[PlanningSlot],
buy_thr: float,
) -> list[int]:
"""Počet slotů do nejbližšího buy <= thr (0 = v tomto slotu, T = nikdy)."""
t_len = len(slots)
dist = [t_len] * t_len
next_idx = t_len
for i in range(t_len - 1, -1, -1):
if float(slots[i].buy_price) <= buy_thr:
next_idx = i
dist[i] = (next_idx - i) if next_idx < t_len else t_len
return dist
def _pre_negative_sell_export_window(
slots: list[PlanningSlot],
) -> tuple[int | None, int | None]:
"""Index prvního sell<0 a posledního slotu před ním (pro strategii „vyvézt dřív“)."""
first_neg = next(
(i for i, s in enumerate(slots) if float(s.sell_price) < 0),
None,
)
if first_neg is None or first_neg <= 0:
return first_neg, None
return first_neg, first_neg - 1
def _prague_calendar_date(slot: PlanningSlot):
dt = slot.interval_start
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(ZoneInfo("Europe/Prague")).date()
def _neg_sell_phases_enabled(battery: Any) -> bool:
# Bez atributů z DB (unit testy) = legacy; z DB default 80 % / 4 sloty (V083).
prep_pct = float(getattr(battery, "planner_neg_sell_prep_soc_percent", 100.0))
tail_slots = int(getattr(battery, "planner_neg_sell_full_soc_tail_slots", 0))
return prep_pct < 100.0 - 1e-6 and tail_slots > 0
def _neg_sell_indices_by_prague_day(
slots: list[PlanningSlot],
) -> dict[object, list[int]]:
by_day: dict[object, list[int]] = {}
for t, st in enumerate(slots):
if float(st.sell_price) < 0.0:
by_day.setdefault(_prague_calendar_date(st), []).append(t)
for day in by_day:
by_day[day].sort()
return by_day
def _neg_sell_t_detach_index(
indices: list[int],
charge_b: dict[int, float],
soc_need: dict[int, float],
tail_start: int,
soc_max: float,
*,
margin: float = 1.05,
min_gap_wh: float = 500.0,
detach_soc_frac: float = 0.85,
) -> int:
"""
Bod T: první prep slot, kde (1) soc_need[t] ≥ detach_soc_frac × soc_max a
(2) zbývající B-nabití od t do konce pokryje mezeru do 100 %.
Dřívější chyba: soc_need[t] ≤ soc_need[tail_start] platilo hned na začátku okna.
"""
if not indices:
return 0
suffix_from: dict[int, float] = {}
run = 0.0
for t in reversed(indices):
run += float(charge_b.get(t, 0.0))
suffix_from[t] = run
thresh_wh = max(
soc_max * detach_soc_frac,
float(soc_need.get(tail_start, soc_max)) * 0.92,
)
for t in indices:
if t >= tail_start:
continue
need_t = float(soc_need.get(t, soc_max))
if need_t < thresh_wh:
continue
gap_rem = soc_max - need_t
if gap_rem <= min_gap_wh:
return t
if suffix_from.get(t, 0.0) >= gap_rem * margin:
return t
return tail_start
def _neg_sell_pv_b_charge_wh(slot: PlanningSlot, battery: Any) -> float:
"""Odhad Wh nabitelné jen z PV B v jednom sell<0 slotu (surplus nad load, cap výkonu)."""
pv_surplus_b = max(0.0, float(slot.pv_b_forecast_w) - float(slot.load_baseline_w))
if pv_surplus_b <= 500.0:
return 0.0
cap_w = min(pv_surplus_b, float(battery.max_charge_power_w))
return cap_w * INTERVAL_H * float(battery.charge_efficiency)
def _neg_sell_pv_forecast_charge_wh(slot: PlanningSlot, battery: Any) -> float:
"""Odhad Wh z FVE A+B v sell<0 slotu pro zpětnou projekci soc_need (v44)."""
pv_surplus = max(
0.0,
float(slot.pv_a_forecast_w)
+ float(slot.pv_b_forecast_w)
- float(slot.load_baseline_w),
)
if pv_surplus <= 500.0:
return 0.0
cap_w = min(pv_surplus, float(battery.max_charge_power_w))
return cap_w * INTERVAL_H * float(battery.charge_efficiency)
def _neg_sell_day_pv_b_usable_wh(
slots: list[PlanningSlot],
first_neg_sell_idx: int | None,
battery: Any,
) -> float:
"""Součet B-nabíjení ve všech sell<0 slotech téhož pražského dne."""
if first_neg_sell_idx is None:
return 0.0
neg_day = _prague_calendar_date(slots[first_neg_sell_idx])
total = 0.0
for s in slots:
if _prague_calendar_date(s) != neg_day:
continue
if float(s.sell_price) >= 0.0:
continue
total += _neg_sell_pv_b_charge_wh(s, battery)
return total
def _neg_sell_e_surplus_after_t_wh(
slots: list[PlanningSlot],
t_detach: int,
last_neg: int,
battery: Any,
) -> float:
"""Integrál přebytku FVE nad load+bat cap od t_detach do last_neg (Wh)."""
total = 0.0
for t in range(t_detach, last_neg + 1):
if t < 0 or t >= len(slots):
continue
st = slots[t]
if float(st.sell_price) >= 0.0:
continue
pv_surplus = max(
0.0,
float(st.pv_a_forecast_w)
+ float(st.pv_b_forecast_w)
- float(st.load_baseline_w),
)
if pv_surplus <= 500.0:
continue
cap_charge_wh = (
min(pv_surplus, float(battery.max_charge_power_w))
* INTERVAL_H
* float(battery.charge_efficiency)
)
total += max(0.0, pv_surplus * INTERVAL_H - cap_charge_wh)
return total
def _neg_sell_day_phases(
slots: list[PlanningSlot],
battery: Any,
) -> tuple[list[str], list[Optional[float]], list[float], dict[str, Any]]:
"""
Per slot: phase (none|prep|tail), soc_target_wh (rampa z PV B, ne fixní %), shortfall váha.
V35: zpětná projekce soc_need z B od tail.
V36: t_detach = první prep slot kde suffix B-nabití pokryje (soc_max soc_need[t]).
"""
t_len = len(slots)
phases: list[str] = ["none"] * t_len
soc_targets: list[Optional[float]] = [None] * t_len
shortfall_weights: list[float] = [0.0] * t_len
tail_n = int(getattr(battery, "planner_neg_sell_full_soc_tail_slots", 0))
soc_max = float(battery.soc_max_wh)
min_soc = float(battery.min_soc_wh)
post_detach_prep_ts: set[int] = set()
day_meta: list[dict[str, Any]] = []
by_day: dict[object, list[int]] = {}
for t, st in enumerate(slots):
if float(st.sell_price) < 0.0:
by_day.setdefault(_prague_calendar_date(st), []).append(t)
for day, indices in by_day.items():
if not indices:
continue
indices.sort()
last_t = indices[-1]
tail_start = max(indices[0], last_t - tail_n + 1) if tail_n > 0 else last_t + 1
charge_b = {
t: _neg_sell_pv_forecast_charge_wh(slots[t], battery) for t in indices
}
soc_need: dict[int, float] = {last_t: soc_max}
for i in range(len(indices) - 1, 0, -1):
t_cur = indices[i]
t_prev = indices[i - 1]
soc_need[t_prev] = max(min_soc, soc_need[t_cur] - charge_b[t_cur])
t_detach = _neg_sell_t_detach_index(
indices,
charge_b,
soc_need,
tail_start,
soc_max,
)
soc_detach_wh = float(soc_need.get(t_detach, soc_max))
e_surplus = _neg_sell_e_surplus_after_t_wh(slots, t_detach, last_t, battery)
for t in indices:
if t >= tail_start:
phases[t] = "tail"
if tail_n <= 1:
soc_targets[t] = soc_max
else:
pos = t - tail_start
frac = pos / float(max(1, tail_n - 1))
lo = float(soc_need.get(tail_start, soc_max))
soc_targets[t] = lo + frac * (soc_max - lo)
else:
phases[t] = "prep"
soc_targets[t] = float(soc_need[t])
if t >= t_detach:
post_detach_prep_ts.add(t)
shortfall_weights[t] = float(last_t - t + 1) / float(len(indices))
day_meta.append(
{
"prague_date": str(day),
"first_neg_idx": indices[0],
"last_neg_idx": last_t,
"tail_start_idx": tail_start,
"t_detach_idx": t_detach,
"soc_detach_wh": soc_detach_wh,
"e_surplus_after_t_wh": e_surplus,
"soc_ramp_wh": [
{
"slot": slots[t].interval_start.isoformat(),
"soc_need_wh": float(soc_need[t]),
"phase": phases[t],
"soc_target_wh": float(soc_targets[t] or 0.0),
}
for t in indices
],
}
)
meta: dict[str, Any] = {
"neg_sell_b_ramp_v35": True,
"neg_sell_prep_window_v36": True,
"days": day_meta,
"post_detach_prep_ts": sorted(post_detach_prep_ts),
}
if day_meta:
meta["t_detach_idx"] = day_meta[0]["t_detach_idx"]
meta["e_surplus_after_t_wh"] = day_meta[0]["e_surplus_after_t_wh"]
return phases, soc_targets, shortfall_weights, meta
def _neg_sell_day_pv_usable_wh(
slots: list[PlanningSlot],
first_neg_sell_idx: int | None,
*,
max_charge_power_w: float,
charge_efficiency: float,
) -> float:
"""
Odhad Wh nabitelné z FVE v sell<0 slotech téhož pražského dne (forecast surplus × cap nabíjení).
"""
if first_neg_sell_idx is None:
return 0.0
neg_day = _prague_calendar_date(slots[first_neg_sell_idx])
total_wh = 0.0
for s in slots:
if _prague_calendar_date(s) != neg_day:
continue
if float(s.sell_price) >= 0.0:
continue
pv_surplus_w = max(
0.0,
float(s.pv_a_forecast_w)
+ float(s.pv_b_forecast_w)
- float(s.load_baseline_w),
)
if pv_surplus_w <= 500.0:
continue
cap_w = min(pv_surplus_w, float(max_charge_power_w))
total_wh += cap_w * INTERVAL_H * float(charge_efficiency)
return total_wh
def _pre_neg_pv_export_forecast_cushion_ok_for_day(
slots: list[PlanningSlot],
battery: Any,
first_neg_t: int,
observed_soc_wh: float,
*,
neg_sell_phases_en: bool,
soc_target_by_t: list[Optional[float]] | None = None,
) -> bool:
"""
Cushion pro jeden pražský den: usable A+B v sell<0 okně pokryje dobítí na soc_need[first_neg].
Vstup SoC = pozorovaná telemetrie (ne trajektorie z předchozího solve).
"""
if first_neg_t < 0 or first_neg_t >= len(slots):
return False
if neg_sell_phases_en and soc_target_by_t is not None:
tgt = soc_target_by_t[first_neg_t]
target_wh = float(tgt) if tgt is not None else float(battery.soc_max_wh)
else:
target_wh = float(battery.soc_max_wh)
soc_obs = max(
float(battery.min_soc_wh),
min(float(observed_soc_wh), float(battery.soc_max_wh)),
)
if soc_obs >= target_wh - 1e-3:
return True
usable_wh = _neg_sell_day_pv_usable_wh(
slots,
first_neg_t,
max_charge_power_w=float(battery.max_charge_power_w),
charge_efficiency=float(battery.charge_efficiency),
)
needed_wh = max(0.0, target_wh - soc_obs)
if needed_wh < PRE_NEG_PV_EXPORT_MIN_NEEDED_WH:
return True
return usable_wh >= needed_wh * PRE_NEG_PV_EXPORT_FORECAST_MARGIN
def _pre_neg_pv_export_forecast_cushion_ok(
slots: list[PlanningSlot],
battery: Any,
observed_soc_wh: float,
first_neg_sell_idx: int | None,
*,
neg_sell_phases_en: bool,
soc_target_by_t: list[Optional[float]] | None = None,
) -> bool:
"""Zpětná kompatibilita: cushion pro první sell<0 v horizontu (pozorované SoC)."""
if first_neg_sell_idx is None or first_neg_sell_idx <= 0:
return False
targets = soc_target_by_t
if neg_sell_phases_en and targets is None:
_ph, targets, _w, _meta = _neg_sell_day_phases(slots, battery)
return _pre_neg_pv_export_forecast_cushion_ok_for_day(
slots,
battery,
first_neg_sell_idx,
observed_soc_wh,
neg_sell_phases_en=neg_sell_phases_en,
soc_target_by_t=targets,
)
def _pre_neg_pv_export_slot_indices_for_day(
slots: list[PlanningSlot],
first_neg_t: int,
first_neg_buy_idx: int | None,
) -> set[int]:
"""Kladný sell téhož dne před prvním sell<0, PV přebytek."""
if first_neg_t <= 0:
return set()
neg_day = _prague_calendar_date(slots[first_neg_t])
out: set[int] = set()
for t in range(first_neg_t):
if _prague_calendar_date(slots[t]) != neg_day:
continue
if float(slots[t].sell_price) < 0.0:
continue
if first_neg_buy_idx is not None and t >= first_neg_buy_idx:
continue
if _slot_pv_surplus_w(slots[t]) <= NIGHT_EXPORT_PV_SUNRISE_SURPLUS_W:
continue
out.add(t)
return out
def _pre_neg_pv_export_bundle(
slots: list[PlanningSlot],
battery: Any,
observed_soc_wh: float,
first_neg_buy_idx: int | None,
*,
neg_sell_phases_en: bool,
soc_target_by_t: list[Optional[float]] | None = None,
) -> tuple[set[int], dict[str, bool]]:
"""
v36: pre-neg export per pražský den s vlastním cushion (A+B v neg okně dne).
v40: cushion vždy z pozorovaného SoC (telemetrie), bez řetězení modelových cílů mezi dny.
"""
by_day = _neg_sell_indices_by_prague_day(slots)
export_ts: set[int] = set()
cushion_by_day: dict[str, bool] = {}
for day in sorted(by_day.keys()):
indices = by_day[day]
if not indices:
continue
first_t = indices[0]
ok = _pre_neg_pv_export_forecast_cushion_ok_for_day(
slots,
battery,
first_t,
observed_soc_wh,
neg_sell_phases_en=neg_sell_phases_en,
soc_target_by_t=soc_target_by_t,
)
cushion_by_day[str(day)] = ok
if ok:
export_ts |= _pre_neg_pv_export_slot_indices_for_day(
slots,
first_t,
first_neg_buy_idx,
)
return export_ts, cushion_by_day
def _pre_neg_pv_export_slot_indices(
slots: list[PlanningSlot],
first_neg_sell_idx: int | None,
pre_neg_export_last_t: int | None,
first_neg_buy_idx: int | None,
) -> set[int]:
"""Legacy: jen před globálním prvním sell<0 (v36 preferuj _pre_neg_pv_export_bundle)."""
if first_neg_sell_idx is None or pre_neg_export_last_t is None:
return set()
out: set[int] = set()
for t in range(pre_neg_export_last_t + 1):
if float(slots[t].sell_price) < 0.0:
continue
if first_neg_buy_idx is not None and t >= first_neg_buy_idx:
continue
if _slot_pv_surplus_w(slots[t]) <= NIGHT_EXPORT_PV_SUNRISE_SURPLUS_W:
continue
out.add(t)
return out
def _discharge_before_first_neg_sell_ts(
slots: list[PlanningSlot],
first_neg_sell_idx: int | None,
) -> set[int]:
"""Všechny kladné-sell sloty před 1. sell<0 (funguje i v rolling bez D1 večera v horizontu)."""
if first_neg_sell_idx is None or first_neg_sell_idx <= 0:
return set()
return {
t
for t in range(first_neg_sell_idx)
if float(slots[t].sell_price) >= 0.0
}
def _evening_discharge_before_neg_day_ts(
slots: list[PlanningSlot],
neg_sell_day_meta: dict[str, Any],
) -> set[int]:
"""
Večer/noc kalendářního dne D1 před pražským dnem D s sell<0: příprava headroomu.
"""
from datetime import timedelta
out: set[int] = set()
for day_info in neg_sell_day_meta.get("days") or []:
first_neg = int(day_info.get("first_neg_idx", -1))
if first_neg < 0 or first_neg >= len(slots):
continue
neg_date = _prague_calendar_date(slots[first_neg])
prev_date = neg_date - timedelta(days=1)
for t, st in enumerate(slots):
if _prague_calendar_date(st) != prev_date:
continue
if float(st.sell_price) < 0.0:
continue
h = _prague_hour(st)
if not (17 <= h <= 23 or _in_night_battery_export_window(st)):
continue
if float(st.sell_price) < 0.0:
continue
out.add(t)
return out
def _night_baseload_buffer_wh_from_slots(
slots: list[PlanningSlot],
battery: Any,
) -> float:
"""Buffer Wh nad reserve pro noc (R__063 nebo % z asset_battery)."""
if not slots:
return 0.0
slot0 = slots[0]
buf = getattr(slot0, "night_baseload_buffer_wh", None)
if buf is not None:
return max(0.0, float(buf))
target = getattr(slot0, "night_baseload_target_wh", None)
if target is not None:
pct = float(getattr(battery, "planner_night_baseload_buffer_percent", 20.0) or 20.0)
return max(0.0, float(target) * pct / 100.0)
return 0.0
def _neg_evening_discharge_budget_wh(
*,
observed_soc_wh: float,
reserve_soc_wh: float,
night_baseload_buffer_wh: float,
) -> float:
"""Wh k výboji nad reserve + noční buffer — z telemetrie, ne z LP trajektorie."""
return max(
0.0,
float(observed_soc_wh) - float(reserve_soc_wh) - float(night_baseload_buffer_wh),
)
def _neg_evening_before_neg_push_indices(
slots: list[PlanningSlot],
candidate_ts: set[int],
*,
export_budget_wh: float,
per_slot_discharge_wh: float,
discharge_export_ok: set[int] | None = None,
) -> set[int]:
"""Nejdražší kladné-sell sloty v kandidátech, dokud budget z pozorovaného SoC."""
if export_budget_wh < per_slot_discharge_wh * 0.5 or not candidate_ts:
return set()
eligible = {
t
for t in candidate_ts
if discharge_export_ok is None or t in discharge_export_ok
}
if not eligible:
return set()
ranked = sorted(
eligible,
key=lambda t: (float(slots[t].sell_price), -t),
reverse=True,
)
out: set[int] = set()
cum_wh = 0.0
for t in ranked:
if float(slots[t].sell_price) < 0.0:
continue
if cum_wh + per_slot_discharge_wh > export_budget_wh + 1e-6:
break
out.add(t)
cum_wh += per_slot_discharge_wh
return out
def _neg_evening_reserve_soc_anchors(
slots: list[PlanningSlot],
neg_sell_day_meta: dict[str, Any],
battery: Any,
) -> list[tuple[int, float]]:
"""
Kotvy SoC ≤ reserve_soc před neg oknem:
- večer D1 (23:45) pokud je v horizontu,
- slot těsně před 1. sell<0 (rolling: ráno bez včerejška v okně).
"""
from datetime import timedelta
reserve_wh = float(
getattr(battery, "reserve_soc_wh", getattr(battery, "min_soc_wh", 0.0))
)
out: list[tuple[int, float]] = []
seen: set[int] = set()
for day_info in neg_sell_day_meta.get("days") or []:
first_neg = int(day_info.get("first_neg_idx", -1))
if first_neg < 0 or first_neg >= len(slots):
continue
neg_date = _prague_calendar_date(slots[first_neg])
prev_date = neg_date - timedelta(days=1)
eve_slots = [
t
for t, st in enumerate(slots)
if _prague_calendar_date(st) == prev_date
and (
17 <= _prague_hour(st) <= 23
or _in_night_battery_export_window(st)
)
]
if eve_slots:
t_eve = max(eve_slots)
if t_eve not in seen:
out.append((t_eve, reserve_wh))
seen.add(t_eve)
if first_neg > 0:
t_pre = first_neg - 1
if (
t_pre not in seen
and float(slots[t_pre].sell_price) >= 0.0
):
out.append((t_pre, reserve_wh))
seen.add(t_pre)
return out
MORNING_PRENEG_START_HOUR = 5
MORNING_PRENEG_END_HOUR = 11
def _battery_export_cap_w(battery: Any, grid: Any) -> float:
"""Max výkon vývozu baterie do sítě [W] — z DB, ne hardcoded konstanta."""
return min(
float(battery.max_discharge_power_w),
float(grid.max_export_power_w),
)
def _evening_push_battery_export_w(
slot: PlanningSlot,
battery: Any,
grid: Any,
) -> float:
"""
Tvrdý push ge_bat: min(site/inverter export cap, BMS load).
Stejná fyzika jako Deye SELL — load pokryje baterie, zbytek výkonu jde do sítě
(ne (maxload)/2 z dvojího započtení bd+ge_bat v LP).
"""
cap = _battery_export_cap_w(battery, grid)
load_w = max(0.0, float(slot.load_baseline_w))
discharge_headroom = max(
0.0,
float(battery.max_discharge_power_w) - load_w,
)
return min(cap, discharge_headroom)
def _dispatch_grid_setpoint_w(
*,
gi_w: float,
ge_w: float,
ge_bat_w: float,
ge_pv_w: float,
max_export_power_w: int,
) -> tuple[int, str]:
"""
grid_setpoint pro export do sítě (záporný W) a export_mode.
gige může být ~0 při load-first, i když ge_bat exportuje — Deye reg 143 potřebuje |grid_setpoint|.
"""
ge_total = max(0.0, float(ge_w))
ge_bat_v = max(0.0, float(ge_bat_w))
cap = float(max_export_power_w)
if ge_bat_v >= GE_MIN_EXPORT_W and ge_bat_v >= max(ge_total * 0.5, 500.0):
export_w = min(cap, max(ge_total, ge_bat_v + max(0.0, float(ge_pv_w))))
return -int(round(export_w)), "BATTERY_SELL"
if ge_total >= GE_MIN_EXPORT_W:
return -int(round(min(cap, ge_total))), "PV_SURPLUS"
return round(float(gi_w) - ge_total), "NONE"
def _prague_hour(slot: PlanningSlot) -> int:
dt = slot.interval_start
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(ZoneInfo("Europe/Prague")).hour
def _morning_pre_neg_zone_peak_sell(
slots: list[PlanningSlot],
first_neg_sell_idx: int | None,
) -> float | None:
"""Max kladný sell v pásmu 511 Prague před prvním sell<0 (shodně s R__063)."""
if first_neg_sell_idx is None or first_neg_sell_idx <= 0:
return None
neg_day = _prague_calendar_date(slots[first_neg_sell_idx])
sells = [
float(slots[i].sell_price)
for i in range(first_neg_sell_idx)
if float(slots[i].sell_price) >= 0.0
and _prague_calendar_date(slots[i]) == neg_day
and MORNING_PRENEG_START_HOUR <= _prague_hour(slots[i]) <= MORNING_PRENEG_END_HOUR
]
if not sells:
return None
return max(sells)
def _pre_neg_peak_sell_idx(
slots: list[PlanningSlot],
first_neg_sell_idx: int | None,
) -> int | None:
"""Nejvyšší kladný sell v ranním pásmu před prvním sell<0 (ne půlnoc celého dne)."""
if first_neg_sell_idx is None or first_neg_sell_idx <= 0:
return None
zone_peak = _morning_pre_neg_zone_peak_sell(slots, first_neg_sell_idx)
if zone_peak is None:
return None
neg_day = _prague_calendar_date(slots[first_neg_sell_idx])
positive = [
(i, float(slots[i].sell_price))
for i in range(first_neg_sell_idx)
if float(slots[i].sell_price) >= 0.0
and _prague_calendar_date(slots[i]) == neg_day
and MORNING_PRENEG_START_HOUR <= _prague_hour(slots[i]) <= MORNING_PRENEG_END_HOUR
]
if not positive:
return None
return max(positive, key=lambda x: (x[1], x[0]))[0]
def _morning_pre_neg_export_indices(
slots: list[PlanningSlot],
first_neg_sell_idx: int | None,
*,
degrad_czk_kwh: float,
) -> list[int]:
"""Všechny ranní peak sloty (sell ≥ zónový max degrad) před prvním sell<0."""
zone_peak = _morning_pre_neg_zone_peak_sell(slots, first_neg_sell_idx)
if zone_peak is None or first_neg_sell_idx is None or first_neg_sell_idx <= 0:
return []
neg_day = _prague_calendar_date(slots[first_neg_sell_idx])
out: list[int] = []
for i in range(first_neg_sell_idx):
if (
float(slots[i].sell_price) >= zone_peak - degrad_czk_kwh
and float(slots[i].sell_price) >= 0.0
and _prague_calendar_date(slots[i]) == neg_day
and MORNING_PRENEG_START_HOUR <= _prague_hour(slots[i]) <= MORNING_PRENEG_END_HOUR
):
out.append(i)
return out
def _pre_neg_buy_discharge_indices(
slots: list[PlanningSlot],
first_neg_buy_idx: int | None,
*,
charge_acquisition_czk_kwh: float,
min_spread: float,
fixed_tariff: bool,
) -> set[int]:
"""
Sloty před prvním buy<0: výboj baterie do sítě při kladném sell (včetně noci).
Bez rozšíření discharge_export_slots (v19b — jinak w_arb → Infeasible).
"""
if first_neg_buy_idx is None or first_neg_buy_idx <= 0:
return set()
out: set[int] = set()
for i in range(first_neg_buy_idx):
s = slots[i]
if float(s.buy_price) < 0.0:
continue
if float(s.sell_price) < PRE_NEG_BATT_EXPORT_MIN_SELL_CZK_KWH:
continue
if not _slot_profitable_battery_export(
s,
charge_acquisition_czk_kwh=charge_acquisition_czk_kwh,
min_spread=min_spread,
fixed_tariff=fixed_tariff,
):
continue
out.add(i)
return out
def _slot_pv_surplus_w(slot: PlanningSlot) -> float:
load_w = float(slot.load_baseline_w)
pv_w = float(slot.pv_a_forecast_w) + float(slot.pv_b_forecast_w)
return max(0.0, pv_w - load_w)
def _battery_export_push_defer_to_pv(slot: PlanningSlot) -> bool:
"""
Při kladném sell a PV přebytku nevnucovat vývoz z baterie (ge_bat / z_export).
Platí pro ranní pre-neg, večerní push i KV1 odpoledne (block_export + fixní tarif):
přetok řeší ge_pv / Deye PASSIVE, ne BATTERY_SELL.
"""
if float(slot.sell_price) < 0.0:
return False
return _slot_pv_surplus_w(slot) > NIGHT_EXPORT_PV_SUNRISE_SURPLUS_W
def _in_night_battery_export_window(slot: PlanningSlot) -> bool:
"""
Noční okno pro večerní push / peak sell: >=17h Prague, nebo 05h (přes půlnoc).
Končí prvním slotem s významným PV přebytkem (východ FVE), ne kalendářním dnem.
"""
if _slot_pv_surplus_w(slot) > NIGHT_EXPORT_PV_SUNRISE_SURPLUS_W:
return False
h = _prague_hour(slot)
if h >= NIGHT_EXPORT_EVENING_START_HOUR:
return True
return h <= NIGHT_EXPORT_MORNING_END_HOUR
def _in_evening_push_hour_window(slot: PlanningSlot) -> bool:
"""Tvrdý večerní push jen ≥17h Prague — ne noční vývoz ve 0206h (sell < buy)."""
return _prague_hour(slot) >= NIGHT_EXPORT_EVENING_START_HOUR
def _night_export_window_segments(slots: list[PlanningSlot]) -> list[list[int]]:
"""Souvislé úseky nočního okna v horizontu (oddělené denní pauzou / východem FVE)."""
segments: list[list[int]] = []
current: list[int] = []
for t, s in enumerate(slots):
if _in_night_battery_export_window(s):
current.append(t)
else:
if current:
segments.append(current)
current = []
if current:
segments.append(current)
return segments
def _night_peak_sell_czk_kwh(slots: list[PlanningSlot], slot_index: int) -> float:
"""Max sell v nočním úseku, do kterého slot patří (pro evening_early)."""
for seg in _night_export_window_segments(slots):
if slot_index in seg:
return max(float(slots[t].sell_price) for t in seg)
return 0.0
def _evening_peak_export_indices(
slots: list[PlanningSlot],
*,
degrad_czk_kwh: float,
evening_start_hour: int = 17,
) -> list[int]:
"""
Noční špičky sell: jeden peak na souvislý úsek (17h → půlnoc → ráno do východu FVE),
ne per kalendářní den (oprava 23:30 vs 00:00).
"""
_ = evening_start_hour # kompatibilita volání; okno řídí NIGHT_EXPORT_* konstanty
out: list[int] = []
for seg in _night_export_window_segments(slots):
if not seg:
continue
peak = max(float(slots[t].sell_price) for t in seg)
if peak <= 0.0:
continue
for t in seg:
if float(slots[t].sell_price) >= peak - degrad_czk_kwh:
out.append(t)
return sorted(out)
def _planner_discharge_floor_wh(battery: Any) -> float:
"""Provozní podlaha vývoje: reserve_soc (domluva), ne jen min_soc."""
return max(
float(getattr(battery, "min_soc_wh", 0.0)),
float(getattr(battery, "reserve_soc_wh", 0.0)),
)
def _evening_push_discharge_budget_wh(
*,
current_soc_wh: float,
discharge_floor_wh: float,
soc_max_wh: float,
discharge_slot_buffer: float,
) -> float:
"""
Rozpočet Wh pro tvrdý večerní push — stejný princip jako R__063 (discharge_slot_buffer).
Podlaha = reserve_soc (typ. 20 %), ne min_soc (10 %).
"""
exportable_full_wh = max(0.0, float(soc_max_wh) - float(discharge_floor_wh))
available_wh = max(0.0, float(current_soc_wh) - float(discharge_floor_wh))
buf = float(discharge_slot_buffer)
if buf <= 0.0:
return available_wh
return min(available_wh, exportable_full_wh * buf)
def _kv1_block_export_fixed_evening_push(
grid: Any,
*,
purchase_fixed: bool,
) -> bool:
"""KV1: fixní buy + block_export — večerní push jiná profitabilita než acq+spread."""
return purchase_fixed and bool(
getattr(grid, "block_export_on_negative_sell", False)
)
def _slot_evening_push_profitable(
slot: PlanningSlot,
*,
charge_acquisition_czk_kwh: float,
min_spread: float,
slots: list[PlanningSlot] | None = None,
first_neg_sell_idx: int | None = None,
kv1_evening_push: bool = False,
) -> bool:
"""
Push večerní špičky.
Spot / obecně: sell > acq+spread (zásoba z levného nabití).
KV1 (fixed + block_export, v52): sell ≥ max sell v pásmu 511 před 1. sell<0 spread
— neprodávat večer levněji než plánované ranní maximum; bez neg dne v horizontu sell ≥ 1 Kč.
"""
sell_t = float(slot.sell_price)
if kv1_evening_push:
if sell_t < PRE_NEG_BATT_EXPORT_MIN_SELL_CZK_KWH:
return False
if slots is not None:
zone_peak = _morning_pre_neg_zone_peak_sell(slots, first_neg_sell_idx)
if zone_peak is not None:
return sell_t >= float(zone_peak) - float(min_spread)
return True
return sell_t > float(charge_acquisition_czk_kwh) + float(min_spread)
def _evening_push_segment_candidates(
slots: list[PlanningSlot],
seg: list[int],
*,
charge_acquisition_czk_kwh: float,
min_spread: float,
discharge_export_ok: set[int] | None = None,
first_neg_sell_idx: int | None = None,
kv1_evening_push: bool = False,
) -> list[int]:
"""Profitable sloty v nočním úseku — výběr pořadí a strop dělá rozpočet Wh (sell desc)."""
if not seg:
return []
out: list[int] = []
for t in seg:
if discharge_export_ok is not None and t not in discharge_export_ok:
continue
if not _in_evening_push_hour_window(slots[t]):
continue
if not _slot_evening_push_profitable(
slots[t],
charge_acquisition_czk_kwh=charge_acquisition_czk_kwh,
min_spread=min_spread,
slots=slots,
first_neg_sell_idx=first_neg_sell_idx,
kv1_evening_push=kv1_evening_push,
):
continue
out.append(t)
return out
def _post_evening_push_night_self_consume_indices(
slots: list[PlanningSlot],
evening_push_ts: set[int],
) -> set[int]:
"""
Po posledním evening_push daného večera až do rána: dům z baterie, ne import za ~5 Kč.
"""
if not evening_push_ts:
return set()
last_push_by_day: dict[object, int] = {}
for t in evening_push_ts:
last_push_by_day[_prague_calendar_date(slots[t])] = max(
last_push_by_day.get(_prague_calendar_date(slots[t]), -1),
t,
)
out: set[int] = set()
for t, s in enumerate(slots):
day = _prague_calendar_date(s)
t_last = last_push_by_day.get(day)
if t_last is None or t <= t_last:
continue
if t in evening_push_ts:
continue
if not _in_night_battery_export_window(s):
continue
if float(s.buy_price) <= 0.0:
continue
if float(s.load_baseline_w) <= 0:
continue
out.add(t)
return out
def _evening_push_calendar_segments(
slots: list[PlanningSlot],
discharge_export_ok: set[int] | None = None,
) -> list[list[int]]:
"""Kalendářní večery (≥17h) v nočním okně — každý den vlastní push rozpočet."""
by_date: dict[object, list[int]] = {}
for t, s in enumerate(slots):
if not _in_evening_push_hour_window(s):
continue
if not _in_night_battery_export_window(s):
continue
if discharge_export_ok is not None and t not in discharge_export_ok:
continue
by_date.setdefault(_prague_calendar_date(s), []).append(t)
return [sorted(v) for v in by_date.values() if v]
def _primary_night_export_segment_indices(slots: list[PlanningSlot]) -> set[int]:
"""
První noční epizoda v horizontu (17h → půlnoc → do východu FVE), která platí pro
rozpočet Wh z aktuální SoC. Další večery v horizontu (po dni FVE / nabíjení) se
plánují až vlastním rolling replanem — nesdílí dnešní baterii.
"""
segs = _night_export_window_segments(slots)
if not segs:
return set()
for seg in segs:
if 0 in seg:
return set(seg)
return set(segs[0])
def _evening_push_soc_budget_calendar_segments(
slots: list[PlanningSlot],
discharge_export_ok: set[int] | None = None,
) -> list[list[int]]:
"""Kalendářní večery jen v primární noční epizodě — vhodné pro push_budget z current_soc."""
primary = _primary_night_export_segment_indices(slots)
if not primary:
return []
return [
seg
for seg in _evening_push_calendar_segments(slots, discharge_export_ok)
if seg and all(t in primary for t in seg)
]
def _night_self_consume_discourage_import_indices(
slots: list[PlanningSlot],
*,
evening_push_ts: set[int],
charge_acquisition_czk_kwh: float,
min_spread: float,
) -> set[int]:
"""
Noční sloty mimo evening_push: penalizace importu pro dům (preferovat bd).
v45: celé noční okno, ne jen evening_early_export_ban subset.
"""
out: set[int] = set()
for t, s in enumerate(slots):
if t in evening_push_ts:
continue
if not _in_night_battery_export_window(s):
continue
buy_t = float(s.buy_price)
if buy_t <= float(charge_acquisition_czk_kwh) + float(min_spread):
continue
if float(s.load_baseline_w) <= 0:
continue
out.add(t)
return out
def _evening_battery_export_push_indices(
slots: list[PlanningSlot],
*,
charge_acquisition_czk_kwh: float,
degrad_czk_kwh: float,
current_soc_wh: float,
min_soc_wh: float,
soc_max_wh: float,
per_slot_discharge_wh: float,
discharge_slot_buffer: float,
discharge_export_ok: set[int] | None = None,
evening_start_hour: int = 17,
first_neg_sell_idx: int | None = None,
kv1_evening_push: bool = False,
) -> list[int]:
"""
Večerní push (≥17h): plný ge_bat v nejdražších slotách (sell desc), rozpočet Wh
z aktuální SoC jen pro **primární noční epizodu** (dnešní večer → ráno).
Zítřejší večer v horizontu se nekrade polovinou budgetu (v43 split) — nabije se
přes den / neg okno; push přidá zítřejší rolling replan.
per_slot_discharge_wh: min(BMS, export cap) × účinnost × 0,25 h.
"""
_ = evening_start_hour # kompatibilita volání
if per_slot_discharge_wh <= 0.0:
return []
push_budget_wh = _evening_push_discharge_budget_wh(
current_soc_wh=current_soc_wh,
discharge_floor_wh=min_soc_wh,
soc_max_wh=soc_max_wh,
discharge_slot_buffer=discharge_slot_buffer,
)
if push_budget_wh < per_slot_discharge_wh * 0.5:
return []
evening_segments = _evening_push_soc_budget_calendar_segments(
slots,
discharge_export_ok=discharge_export_ok,
)
if not evening_segments:
return []
candidates: list[int] = []
seen: set[int] = set()
for seg in evening_segments:
for t in _evening_push_segment_candidates(
slots,
seg,
charge_acquisition_czk_kwh=charge_acquisition_czk_kwh,
min_spread=degrad_czk_kwh,
discharge_export_ok=discharge_export_ok,
first_neg_sell_idx=first_neg_sell_idx,
kv1_evening_push=kv1_evening_push,
):
if t not in seen:
seen.add(t)
candidates.append(t)
if not candidates:
return []
ranked = sorted(
candidates,
key=lambda i: (float(slots[i].sell_price), -i),
reverse=True,
)
remaining_wh = float(push_budget_wh)
out: list[int] = []
for t in ranked:
if remaining_wh + 1e-6 < per_slot_discharge_wh:
break
out.append(t)
remaining_wh -= per_slot_discharge_wh
return sorted(out)
def _evening_push_peak_fallback_indices(
slots: list[PlanningSlot],
*,
charge_acquisition_czk_kwh: float,
min_spread: float,
discharge_export_ok: set[int] | None,
first_neg_sell_idx: int | None,
kv1_evening_push: bool,
) -> set[int]:
"""Alespoň jeden večerní peak slot (sell desc), když rozpočet Wh nevybral žádný push."""
best_t: int | None = None
best_sell = -1.0
for t, s in enumerate(slots):
if discharge_export_ok is not None and t not in discharge_export_ok:
continue
if not _in_evening_push_hour_window(s):
continue
if not _slot_evening_push_profitable(
s,
charge_acquisition_czk_kwh=charge_acquisition_czk_kwh,
min_spread=min_spread,
slots=slots,
first_neg_sell_idx=first_neg_sell_idx,
kv1_evening_push=kv1_evening_push,
):
continue
sell_t = float(s.sell_price)
if sell_t > best_sell:
best_sell = sell_t
best_t = t
return {best_t} if best_t is not None else set()
def _evening_night_peak_sell_czk(slots: list[PlanningSlot]) -> float:
sells = [
float(s.sell_price)
for s in slots
if _in_night_battery_export_window(s) and float(s.sell_price) >= 0.0
]
return max(sells) if sells else 0.0
def _evening_push_peak_sell_czk(slots: list[PlanningSlot], push_ts: set[int]) -> float:
if not push_ts:
return 0.0
return max(float(slots[t].sell_price) for t in push_ts)
def _evening_push_ts_from_iso(slots: list[PlanningSlot], iso_slots: list[str]) -> set[int]:
by_iso = {s.interval_start.isoformat(): t for t, s in enumerate(slots)}
return {by_iso[iso] for iso in iso_slots if iso in by_iso}
def _evening_push_hysteresis_active(
*,
prev_peak_sell_czk: float | None,
new_peak_sell_czk: float,
prev_soc_wh: float | None,
current_soc_wh: float,
usable_capacity_wh: float,
) -> bool:
if prev_peak_sell_czk is None:
return False
if abs(new_peak_sell_czk - float(prev_peak_sell_czk)) >= (
EVENING_PUSH_HYSTERESIS_SELL_PEAK_DELTA_CZK_KWH
):
return False
if prev_soc_wh is not None and usable_capacity_wh > 1e-6:
delta_pct = (
abs(float(current_soc_wh) - float(prev_soc_wh))
/ float(usable_capacity_wh)
* 100.0
)
if delta_pct >= EVENING_PUSH_HYSTERESIS_SOC_PCT:
return False
return True
def _evening_early_export_penalty_indices(
slots: list[PlanningSlot],
*,
discharge_export_slots: set[int],
evening_push_ts: set[int],
exempt_ts: set[int] | None = None,
) -> set[int]:
"""
ge_bat=0 v nočním okně mimo tvrdý evening_push (a mimo pre-neg / neg-evening větve).
"""
exempt = exempt_ts or set()
out: set[int] = set()
for t_ev, s_ev in enumerate(slots):
if not _in_night_battery_export_window(s_ev):
continue
if t_ev not in discharge_export_slots:
continue
if t_ev in evening_push_ts or t_ev in exempt:
continue
out.add(t_ev)
return out
def _last_non_negative_sell_before_neg_buy(
slots: list[PlanningSlot],
first_neg_buy_idx: int | None,
) -> int | None:
if first_neg_buy_idx is None or first_neg_buy_idx <= 0:
return None
candidates = [
i for i in range(first_neg_buy_idx) if float(slots[i].sell_price) >= 0.0
]
return max(candidates) if candidates else None
def _positive_sell_pre_neg_buy_indices(
slots: list[PlanningSlot],
first_neg_buy_idx: int | None,
) -> list[int]:
if first_neg_buy_idx is None or first_neg_buy_idx <= 0:
return []
return [
t
for t in range(first_neg_buy_idx)
if float(slots[t].sell_price) >= 0.0
]
def _pre_neg_buy_empty_discharge_indices(
slots: list[PlanningSlot],
first_neg_buy_idx: int | None,
last_pos_sell_idx: int | None,
) -> list[int]:
"""Sloty mezi posledním sell≥0 a prvním buy<0 — vyprázdnit před levným importem."""
if first_neg_buy_idx is None or first_neg_buy_idx <= 0:
return []
if last_pos_sell_idx is None:
return []
start = last_pos_sell_idx + 1
end = first_neg_buy_idx - 1
if start > end:
return []
return list(range(start, end + 1))
def _pre_neg_buy_soc_ceiling_wh(
slots: list[PlanningSlot],
*,
first_neg_buy_idx: int | None,
min_soc_wh: float,
soc_max_wh: float,
max_charge_w: float,
charge_eff: float,
evening_start_hour: int = 17,
) -> float | None:
"""
Horní SoC těsně před prvním buy<0: pod soc_max musí vejít import v buy<0,
PV B v tom okně a rezerva na odpolední sell<0 (stejný den, před večerem).
"""
if first_neg_buy_idx is None or first_neg_buy_idx <= 0:
return None
per_slot_chg = max(0.0, float(max_charge_w) * float(charge_eff) * INTERVAL_H)
neg_buy_ts = [t for t, s in enumerate(slots) if float(s.buy_price) < 0.0]
if not neg_buy_ts:
return None
last_neg_buy = max(neg_buy_ts)
neg_day = _prague_calendar_date(slots[first_neg_buy_idx])
grid_wh = len(neg_buy_ts) * per_slot_chg
pv_b_wh = 0.0
for t in neg_buy_ts:
s = slots[t]
sur = max(
0.0,
float(s.pv_a_forecast_w) + float(s.pv_b_forecast_w) - float(s.load_baseline_w),
)
pv_b_wh += min(sur, float(max_charge_w)) * float(charge_eff) * INTERVAL_H
post_wh = 0.0
for t in range(last_neg_buy + 1, len(slots)):
s = slots[t]
if _prague_calendar_date(s) != neg_day:
continue
if float(s.buy_price) < 0.0:
continue
if float(s.sell_price) >= 0.0:
break
if _prague_hour(s) >= evening_start_hour:
break
sur = max(0.0, float(s.pv_b_forecast_w) - float(s.load_baseline_w) * 0.25)
post_wh += min(sur, float(max_charge_w)) * float(charge_eff) * INTERVAL_H
buffer_wh = max(per_slot_chg * 2.0, 3000.0)
needed = grid_wh + pv_b_wh + post_wh + buffer_wh
ceiling = float(soc_max_wh) - needed
floor = float(min_soc_wh) + max(per_slot_chg, 1000.0)
return max(floor, min(float(soc_max_wh) - per_slot_chg, ceiling))
def _planner_soc_for_solver(
current_soc_wh: float,
battery,
) -> tuple[float, float | None]:
"""
SoC pro MILP. Při telemetrii na soc_max a dlouhém sell<0 s vysokou FVE bez rezervy pod stropem
je model neřešitelný (nelze nabít / odvést přebytek). Necháme min. ~650 Wh pod soc_max.
"""
soc_max = float(battery.soc_max_wh)
soc_min = float(battery.min_soc_wh)
soc = max(soc_min, min(float(current_soc_wh), soc_max))
charge_slot_wh = (
float(battery.max_charge_power_w)
* INTERVAL_H
/ max(float(battery.charge_efficiency), 1e-6)
)
headroom = max(650.0, 0.382 * charge_slot_wh)
if soc > soc_max - headroom:
return max(soc_min, soc_max - headroom), headroom
return soc, None
def _pv_forced_vent_export_allowed(
t: int,
*,
current_soc_wh: float,
battery,
soc_headroom_wh: float,
pv_surplus_w: float,
) -> bool:
"""Přebytek FVE do sítě jen když baterie na konci předchozího slotu nemá kapacitu."""
if pv_surplus_w <= 0:
return False
if t == 0:
return current_soc_wh >= float(battery.soc_max_wh) - soc_headroom_wh
return False
def _solve_dispatch_relax_carryover(snap: dict[str, Any]) -> dict[str, Any]:
"""Pass2 two-pass: neopakovat Infeasible řetězec, pokud pass1 skončil v nouzovém režimu."""
inp = snap.get("inputs")
if not isinstance(inp, dict):
return {}
out: dict[str, Any] = {}
for key in (
"relaxed_expensive_import",
"relaxed_neg_buy_charge",
"relaxed_neg_prep_window",
"neg_sell_phases_fallback",
):
if inp.get(key):
out[key] = True
return out
def solve_dispatch_two_pass(
slots: list[PlanningSlot],
battery,
heat_pump,
grid,
ev_sessions: list,
vehicles: list,
current_soc_wh: float,
current_tuv_temp_c: float,
*,
tuv_delta_stats: Optional[dict[tuple[int, int], float]] = None,
operating_mode: str = "AUTO",
charge_commitment_prev_w: Optional[list[Optional[float]]] = None,
planner_version: str | None = None,
evening_push_ts_override: Optional[set[int]] = None,
) -> tuple[list["DispatchResult"], int, dict[str, Any]]:
"""
Dva průchody solve_dispatch: pass2 používá acquisition z váženého buy nabíjení v pass1.
"""
results1, ms1, snap1 = solve_dispatch(
slots,
battery,
heat_pump,
grid,
ev_sessions,
vehicles,
current_soc_wh,
current_tuv_temp_c,
tuv_delta_stats=tuv_delta_stats,
operating_mode=operating_mode,
charge_commitment_prev_w=charge_commitment_prev_w,
planner_version=planner_version,
evening_push_ts_override=evening_push_ts_override,
)
acq1 = float(
snap1.get("inputs", {}).get("charge_acquisition_buy_czk_kwh")
or getattr(slots[0], "charge_acquisition_buy_czk_kwh", None)
or min(float(s.buy_price) for s in slots)
)
acq2 = _recompute_charge_acquisition_from_results(slots, results1, battery)
converged = abs(acq2 - acq1) < ACQUISITION_TWO_PASS_EPS_KWH
if converged:
if isinstance(snap1.get("inputs"), dict):
snap1["inputs"]["acquisition_pass1_czk_kwh"] = round(acq1, 6)
snap1["inputs"]["acquisition_pass2_czk_kwh"] = round(acq2, 6)
snap1["inputs"]["two_pass_enabled"] = True
snap1["inputs"]["two_pass_converged"] = True
snap1["inputs"]["two_pass_skipped"] = False
return results1, ms1, snap1
slots2 = _slots_with_charge_acquisition(slots, acq2)
relax_carry = _solve_dispatch_relax_carryover(snap1)
try:
results2, ms2, snap2 = solve_dispatch(
slots2,
battery,
heat_pump,
grid,
ev_sessions,
vehicles,
current_soc_wh,
current_tuv_temp_c,
tuv_delta_stats=tuv_delta_stats,
operating_mode=operating_mode,
charge_commitment_prev_w=charge_commitment_prev_w,
planner_version=planner_version,
evening_push_ts_override=None,
**relax_carry,
)
except RuntimeError as exc:
if "Infeasible" in str(exc):
logger.warning(
"two_pass pass2 Infeasible (%s), using pass1 solution",
exc,
)
if isinstance(snap1.get("inputs"), dict):
snap1["inputs"]["two_pass_pass2_infeasible_used_pass1"] = True
return results1, ms1, snap1
raise
if isinstance(snap2.get("inputs"), dict):
snap2["inputs"]["acquisition_pass1_czk_kwh"] = round(acq1, 6)
snap2["inputs"]["acquisition_pass2_czk_kwh"] = round(acq2, 6)
snap2["inputs"]["two_pass_enabled"] = True
snap2["inputs"]["two_pass_converged"] = False
snap2["inputs"]["two_pass_skipped"] = False
snap2["inputs"]["solver_duration_ms_pass1"] = ms1
return results2, ms1 + ms2, snap2
def _evening_push_override_for_solve(
evening_push_ts_override: Optional[set[int]],
*,
relaxed_expensive_import: bool,
relaxed_neg_buy_charge: bool,
relaxed_neg_prep_window: bool,
neg_sell_phases_fallback: bool,
) -> Optional[set[int]]:
"""Po Infeasible nesmí retry držet hysterézní push z minulého běhu."""
if evening_push_ts_override is None:
return None
if (
relaxed_expensive_import
or relaxed_neg_buy_charge
or relaxed_neg_prep_window
or neg_sell_phases_fallback
):
return None
return set(evening_push_ts_override)
def _filter_evening_push_override_indices(
slots: list[PlanningSlot],
override_ts: set[int],
*,
battery: Any,
grid: Any,
discharge_export_ok: set[int] | None,
) -> set[int]:
"""Hysterézní push jen na sloty, kde dnes smí a dává smysl tvrdý ge_bat push."""
out: set[int] = set()
for t in override_ts:
if t < 0 or t >= len(slots):
continue
if discharge_export_ok is not None and t not in discharge_export_ok:
continue
if _battery_export_push_defer_to_pv(slots[t]):
continue
push_floor_w = _evening_push_battery_export_w(slots[t], battery, grid)
if push_floor_w < GE_MIN_EXPORT_W:
continue
out.add(t)
return out
def solve_dispatch(
slots: list[PlanningSlot],
battery,
heat_pump,
grid,
ev_sessions: list, # aktivní EV sessions [ev1_session, ev2_session]
vehicles: list, # [vehicle1, vehicle2]
current_soc_wh: float,
current_tuv_temp_c: float,
*,
tuv_delta_stats: Optional[dict[tuple[int, int], float]] = None,
operating_mode: str = "AUTO",
charge_commitment_prev_w: Optional[list[Optional[float]]] = None,
planner_version: str | None = None,
relaxed_expensive_import: bool = False,
relaxed_neg_buy_charge: bool = False,
relaxed_neg_prep_window: bool = False,
neg_sell_phases_fallback: bool = False,
evening_push_ts_override: Optional[set[int]] = None,
) -> tuple[list[DispatchResult], int, dict[str, Any]]:
"""
LP solver pro dispatch optimalizaci.
Vrátí (výsledky, solver_duration_ms, solver_debug_snapshot).
relaxed_expensive_import: nouzový režim po Infeasible — síť smí krmit baseload v drahých slotech.
relaxed_neg_buy_charge: druhý nouzový retry bez neg_buy charge shortfall.
relaxed_neg_prep_window: třetí retry — bez tvrdého večerního push/kotvy a prep hold binárek (sell<0 okno).
"""
T = len(slots)
if T < 1:
raise RuntimeError("solve_dispatch requires at least one slot")
any_relaxed = (
relaxed_expensive_import
or relaxed_neg_buy_charge
or relaxed_neg_prep_window
or neg_sell_phases_fallback
)
EV = len(vehicles) # počet EV (typicky 2)
planner_version_resolved = _planner_engine_version(planner_version)
planner_v2 = planner_version_resolved == "v2"
EV_ROUNDTRIP_FACTOR = 1.0 / (battery.charge_efficiency * battery.discharge_efficiency)
cycle_penalty_mult = _pv_scarcity_penalty_multiplier(slots, battery)
degradation_cost_effective = battery.degradation_cost_czk_kwh * cycle_penalty_mult
soc_buffer_target_wh, soc_deficit_penalty_czk_kwh = _soc_security_profile(slots, battery)
prob = pulp.LpProblem("ems_dispatch", pulp.LpMinimize)
# Penalizace překročení breakeru (Kč/kWh importu nad max_import_power_w).
# Záměr: breaker je fyzický strop, ale kvůli chybám forecastu a krátkým „extrémním“ oknům
# (např. záporná nákupní cena) umožníme solveru nominálně jít nad breaker, ovšem pouze za cenu.
IMPORT_OVER_BREAKER_PENALTY_CZK_KWH = 10.0
min_soc_wh = float(getattr(battery, "min_soc_wh", battery.reserve_soc_wh))
buy_extreme_thr = float(getattr(battery, "planner_extreme_buy_threshold_czk_kwh", -5.0))
floor_pct_raw = getattr(battery, "planner_discharge_floor_percent", None)
floor_pct = float(floor_pct_raw) if floor_pct_raw is not None else None
prewin = max(
0,
int(
getattr(
battery,
"planner_discharge_relax_prewindow_slots",
DEFAULT_PLANNER_DISCHARGE_RELAX_PREWINDOW_SLOTS,
)
),
)
# Planner floor v Wh (nezávisle na lookahead extrémním buy) použije se pro kotvu před sell<0.
abs_min_wh = max(float(battery.usable_capacity_wh) * 0.05, 1.0)
planner_floor_wh = (
min_soc_wh
if floor_pct is None
else max(abs_min_wh, float(floor_pct) / 100.0 * float(battery.usable_capacity_wh))
)
planner_floor_effective_wh = min(min_soc_wh, float(planner_floor_wh))
soc_min_series = _soc_min_wh_series(
slots,
float(battery.usable_capacity_wh),
min_soc_wh,
buy_extreme_thr,
floor_pct,
)
# Pokud se blíží první sell<0, dovol hluboký planner floor i bez extrémního buy.
# Záměr: „dovylít“ baterii před záporným prodejem a pak už baterii v sell<0 okně nevybíjet.
if floor_pct is not None:
dist_to_neg_sell = _slots_until_sell_lt(slots, 0.0)
soc_min_series = [
min(float(sm), float(planner_floor_effective_wh))
if dist_to_neg_sell[i] <= prewin
else float(sm)
for i, sm in enumerate(soc_min_series)
]
observed_soc_wh = max(
float(battery.min_soc_wh),
min(float(current_soc_wh), float(battery.soc_max_wh)),
)
soc_headroom_applied_wh: float | None = None
current_soc_wh, soc_headroom_applied_wh = _planner_soc_for_solver(
current_soc_wh, battery
)
current_soc_wh = max(soc_min_series[0], min(current_soc_wh, float(battery.soc_max_wh)))
arb_base_wh = max(
float(getattr(battery, "arb_floor_wh", battery.reserve_soc_wh)),
min_soc_wh,
)
if getattr(battery, "disable_dynamic_arb_floor", False):
arb_floor_series = [arb_base_wh] * T
else:
arb_floor_series = _dynamic_arb_floor_wh_series(
slots, min_soc_wh, arb_base_wh, float(battery.usable_capacity_wh)
)
deferral_slots = _prewindow_deferral_slots(slots, buy_extreme_thr)
soc_panel_min = _soc_panel_min_wh_series(
soc_min_series,
deferral_slots,
min_soc_wh,
arb_base_wh,
prewin,
)
# --- Proměnné ---
# Import ze sítě: tvrdý strop = site breaker (max_import_power_w).
gi_upper = float(grid.max_import_power_w)
gi = [pulp.LpVariable(f"gi_{t}", 0, gi_upper) for t in range(T)]
gi_over = [
pulp.LpVariable(f"gi_over_{t}", 0, max(0.0, gi_upper - float(grid.max_import_power_w)))
for t in range(T)
]
ge = [pulp.LpVariable(f"ge_{t}", 0, grid.max_export_power_w) for t in range(T)]
ge_pv = [pulp.LpVariable(f"ge_pv_{t}", 0, grid.max_export_power_w) for t in range(T)]
ge_bat = [pulp.LpVariable(f"ge_bat_{t}", 0, grid.max_export_power_w) for t in range(T)]
bc_pv = [pulp.LpVariable(f"bc_pv_{t}", 0, battery.max_charge_power_w) for t in range(T)]
bc_gi = [pulp.LpVariable(f"bc_gi_{t}", 0, battery.max_charge_power_w) for t in range(T)]
bd = [pulp.LpVariable(f"bd_{t}", 0, battery.max_discharge_power_w) for t in range(T)]
pv_ld = [pulp.LpVariable(f"pv_ld_{t}", 0) for t in range(T)]
pv_sp = [pulp.LpVariable(f"pv_sp_{t}", 0) for t in range(T)]
soc = [
pulp.LpVariable(f"soc_{t}", soc_panel_min[t], battery.soc_max_wh) for t in range(T)
]
w_arb = [pulp.LpVariable(f"w_arb_{t}", cat=pulp.LpBinary) for t in range(T)]
z_export = [pulp.LpVariable(f"z_export_{t}", cat=pulp.LpBinary) for t in range(T)]
ca = [pulp.LpVariable(f"ca_{t}", 0, slots[t].pv_a_forecast_w) for t in range(T)]
hp = [pulp.LpVariable(f"hp_{t}", 0, heat_pump.rated_heating_power_w) for t in range(T)]
soc_deficit_24h = pulp.LpVariable("soc_deficit_24h", 0, battery.usable_capacity_wh)
# GEN port cut-off (BA81): binární proměnná pouze pokud je feature povolená v konfiguraci site/invertoru.
gen_cutoff_enabled = bool(getattr(grid, "deye_gen_microinverter_cutoff_enabled", False))
z_gen_cutoff = (
[pulp.LpVariable(f"z_gen_cutoff_{t}", cat=pulp.LpBinary) for t in range(T)]
if gen_cutoff_enabled
else None
)
om = (operating_mode or "AUTO").strip().upper()
charge_slots: set[int] = set()
discharge_export_slots: set[int] = set()
if om == "AUTO":
charge_slots = {t for t, s in enumerate(slots) if s.allow_charge}
charge_slots |= {
t for t, s in enumerate(slots) if float(s.buy_price) < 0.0
}
# Stejně jako R__063 (sell<0 + PV přebytek): shortfall/curtail penalizace i bez block_export.
charge_slots |= {
t
for t, s in enumerate(slots)
if float(s.sell_price) < 0.0
and max(
0,
int(s.pv_a_forecast_w)
+ int(s.pv_b_forecast_w)
- int(s.load_baseline_w),
)
> 500
}
discharge_export_slots = {
t for t, s in enumerate(slots) if s.allow_discharge_export
}
# SELF_SUSTAIN dřív vynucoval ge[t] == 0, což umí udělat MILP infeasible v okamžiku, kdy:
# - baterie je na max SoC (nelze nabíjet),
# - PV pole B není curtailable,
# - a pv_b_forecast_w > load_baseline_w (typicky po ručním snížení baseline).
# Export v SELF_SUSTAIN proto povolíme jako nouzový ventil, ale silně penalizujeme,
# aby k němu docházelo jen když už neexistuje jiné fyzikálně možné řešení.
SELF_SUSTAIN_EXPORT_PENALTY_CZK_KWH = 100.0
# Penalizace vypnutí GEN portu (mikroinvertory): preferujeme nechat zapnuto a vypnout jen když
# by to jinak vedlo k nežádoucímu exportu / infeasible řešení.
GEN_CUTOFF_PENALTY_CZK_KWH = 2.0 if planner_v2 else 5.0
# Heuristika: pokud existuje necurtailable PV B a v budoucnu v horizontu nastane buy < 0,
# chceme mít motivaci držet baterii „prázdnější“ pro pozdější výhodný import / bonusové PV B okno.
# V okně sell < 0 pak preferujeme curtail PV A (místo placeného exportu), a to tak,
# že dočasně snížíme penalizaci ca[t] (curtailment) na 0.
has_pv_b = any(float(s.pv_b_forecast_w) > 0.0 for s in slots)
future_neg_buy_from: list[bool] = [False] * T
seen_neg_buy = False
for i in range(T - 1, -1, -1):
if float(slots[i].buy_price) < 0.0:
seen_neg_buy = True
future_neg_buy_from[i] = seen_neg_buy
future_extreme_buy_from = _future_extreme_buy_from(slots, buy_extreme_thr)
dist_to_extreme_buy = _slots_until_buy_le(slots, buy_extreme_thr)
# EV proměnné per vozidlo
ev_direct = [[pulp.LpVariable(f"evd_{e}_{t}", 0,
min(vehicles[e].max_charge_power_w, grid.max_import_power_w))
for t in range(T)] for e in range(EV)]
ev_via_bat = [[pulp.LpVariable(f"evb_{e}_{t}", 0,
vehicles[e].max_charge_power_w)
for t in range(T)] for e in range(EV)]
horizon_slots_h24 = min(T, int(24 / INTERVAL_H))
avg_buy_terminal = (
sum(float(slots[t].buy_price) for t in range(horizon_slots_h24)) / horizon_slots_h24
if horizon_slots_h24 > 0
else 4.0
)
terminal_factor = float(battery.planner_terminal_soc_value_factor)
# Kč/Wh: ocenění energie ponechané v baterii na konci horizontu (receding horizon kotva).
terminal_soc_kcz_per_wh = (
avg_buy_terminal * terminal_factor / 1000.0
)
charge_acq_raw = getattr(slots[0], "charge_acquisition_buy_czk_kwh", None)
charge_acquisition_czk_kwh = (
float(charge_acq_raw)
if charge_acq_raw is not None
else min(float(s.buy_price) for s in slots)
)
soc_headroom_wh = max(2000.0, 0.05 * float(battery.soc_max_wh))
# Kotva: poslední slot před prvním sell<0 by měl končit u planner floor (pokud relaxace existuje).
# Slack penalizujeme v objective; samotné omezení přidáme až po definici soc.
first_neg_sell_idx, pre_neg_export_last_t = _pre_negative_sell_export_window(slots)
first_neg_buy_idx = next(
(t for t, s in enumerate(slots) if float(s.buy_price) < 0.0),
None,
)
neg_buy_slot_indices_pre = [
t for t, s in enumerate(slots) if float(s.buy_price) < 0.0
]
last_neg_sell_by_prague_date: dict[object, int] = {}
for t_ln, st_ln in enumerate(slots):
if float(st_ln.sell_price) < 0:
last_neg_sell_by_prague_date[_prague_calendar_date(st_ln)] = t_ln
t_pre_neg_peak = _pre_neg_peak_sell_idx(slots, first_neg_sell_idx)
morning_pre_neg_export_ts = _morning_pre_neg_export_indices(
slots,
first_neg_sell_idx,
degrad_czk_kwh=float(degradation_cost_effective),
)
evening_peak_export_ts = _evening_peak_export_indices(
slots,
degrad_czk_kwh=float(degradation_cost_effective),
)
purchase_fixed_pre = _purchase_pricing_fixed(grid)
fixed_horizon_min_sell_pre: float | None = None
if purchase_fixed_pre:
_pos_sell_prices = [
float(s.sell_price) for s in slots if float(s.sell_price) >= 0.0
]
if _pos_sell_prices:
fixed_horizon_min_sell_pre = min(_pos_sell_prices)
block_export_neg_sell_pre = bool(
getattr(grid, "block_export_on_negative_sell", False)
)
if purchase_fixed_pre and block_export_neg_sell_pre:
evening_peak_export_ts = sorted(
set(evening_peak_export_ts)
| {
t
for t, st in enumerate(slots)
if _in_night_battery_export_window(st)
and float(st.sell_price) > 0.0
}
)
non_negative_buys_pre = [
float(s.buy_price) for s in slots if float(s.buy_price) >= 0.0
]
ref_buy_horizon_pre = (
min(non_negative_buys_pre)
if non_negative_buys_pre
else min(float(s.buy_price) for s in slots)
)
min_spread_pre = float(degradation_cost_effective)
fixed_tariff_like_pre = purchase_fixed_pre or _horizon_fixed_tariff_like(slots)
neg_sell_phases_en = (
om == "AUTO"
and not purchase_fixed_pre
and _neg_sell_phases_enabled(battery)
)
neg_sell_phase_by_t: list[str] = ["none"] * T
neg_sell_soc_target_by_t: list[Optional[float]] = [None] * T
neg_sell_shortfall_weight_by_t: list[float] = [0.0] * T
neg_sell_day_meta: dict[str, Any] = {}
neg_sell_post_detach_prep_ts: set[int] = set()
if neg_sell_phases_en:
(
neg_sell_phase_by_t,
neg_sell_soc_target_by_t,
neg_sell_shortfall_weight_by_t,
neg_sell_day_meta,
) = _neg_sell_day_phases(slots, battery)
neg_sell_post_detach_prep_ts = set(
neg_sell_day_meta.get("post_detach_prep_ts") or []
)
prep_soc_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
prep_hold_bcpv_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
prep_hold_curtail_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
prep_hold_met_binary: dict[int, pulp.LpVariable] = {}
pre_neg_cushion_by_day: dict[str, bool] = {}
pre_neg_pv_export_ts: set[int] = set()
neg_evening_before_neg_ts: set[int] = set()
neg_evening_push_ts: set[int] = set()
neg_evening_export_budget_wh: float | None = None
neg_evening_reserve_anchors: list[tuple[int, float]] = []
if (
om == "AUTO"
and not purchase_fixed_pre
and neg_sell_phases_en
and not relaxed_neg_prep_window
):
pre_neg_pv_export_ts, pre_neg_cushion_by_day = _pre_neg_pv_export_bundle(
slots,
battery,
observed_soc_wh,
first_neg_buy_idx,
neg_sell_phases_en=True,
soc_target_by_t=neg_sell_soc_target_by_t,
)
neg_evening_before_neg_ts = _evening_discharge_before_neg_day_ts(
slots,
neg_sell_day_meta,
)
neg_evening_before_neg_ts |= _discharge_before_first_neg_sell_ts(
slots,
first_neg_sell_idx,
)
neg_evening_reserve_anchors = _neg_evening_reserve_soc_anchors(
slots,
neg_sell_day_meta,
battery,
)
reserve_wh = float(
getattr(battery, "reserve_soc_wh", getattr(battery, "min_soc_wh", 0.0))
)
night_buf_wh = _night_baseload_buffer_wh_from_slots(slots, battery)
neg_evening_export_budget_wh = _neg_evening_discharge_budget_wh(
observed_soc_wh=observed_soc_wh,
reserve_soc_wh=reserve_wh,
night_baseload_buffer_wh=night_buf_wh,
)
per_slot_neg_eve_wh = max(
float(battery.max_discharge_power_w)
* float(battery.discharge_efficiency)
* INTERVAL_H,
0.0,
)
neg_evening_push_ts = _neg_evening_before_neg_push_indices(
slots,
neg_evening_before_neg_ts,
export_budget_wh=float(neg_evening_export_budget_wh),
per_slot_discharge_wh=per_slot_neg_eve_wh,
discharge_export_ok=discharge_export_slots,
)
elif om == "AUTO" and not purchase_fixed_pre:
legacy_ok = bool(
first_neg_sell_idx is not None
and pre_neg_export_last_t is not None
and _pre_neg_pv_export_forecast_cushion_ok(
slots,
battery,
observed_soc_wh,
first_neg_sell_idx,
neg_sell_phases_en=False,
)
)
if legacy_ok:
pre_neg_pv_export_ts = _pre_neg_pv_export_slot_indices(
slots,
first_neg_sell_idx,
pre_neg_export_last_t,
first_neg_buy_idx,
)
pre_neg_pv_export_forecast_ok = bool(pre_neg_pv_export_ts)
pre_neg_buy_discharge_ts: set[int] = set()
if om == "AUTO" and first_neg_buy_idx is not None and first_neg_buy_idx > 0:
pre_neg_buy_discharge_ts = _pre_neg_buy_discharge_indices(
slots,
first_neg_buy_idx,
charge_acquisition_czk_kwh=charge_acquisition_czk_kwh,
min_spread=min_spread_pre,
fixed_tariff=fixed_tariff_like_pre,
)
neg_sell_bat_dump_slots = _neg_sell_bat_dump_slots(
slots,
operating_mode=om,
purchase_fixed=purchase_fixed_pre,
grid=grid,
buy_extreme_thr=buy_extreme_thr,
degrad_czk_kwh=float(degradation_cost_effective),
)
profitable_export_ts_pre: set[int] = set()
if om == "AUTO":
for _t in range(T):
if _t not in discharge_export_slots:
continue
if _slot_profitable_battery_export(
slots[_t],
charge_acquisition_czk_kwh=charge_acquisition_czk_kwh,
min_spread=min_spread_pre,
fixed_tariff=fixed_tariff_like_pre,
):
profitable_export_ts_pre.add(_t)
elif (
purchase_fixed_pre
and block_export_neg_sell_pre
and _t in evening_peak_export_ts
and float(slots[_t].sell_price) > 0.0
):
# KV1: večerní sell může být < fixní buy; peak sloty stejně vývoz bat.
profitable_export_ts_pre.add(_t)
evening_push_ts: set[int] = set()
evening_early_export_penalty_ts: set[int] = set()
night_self_consume_discourage_ts: set[int] = set()
post_evening_push_night_ts: set[int] = set()
evening_push_hysteresis_retained = False
push_override_raw: Optional[set[int]] = None
push_override_eff: Optional[set[int]] = None
computed_evening_push_ts: set[int] = set()
evening_push_hard_suppressed = False
if om == "AUTO":
per_slot_discharge_wh_pre = max(
float(battery.max_discharge_power_w)
* float(battery.discharge_efficiency)
* INTERVAL_H,
0.0,
)
export_cap_push_w = _battery_export_cap_w(battery, grid)
per_slot_push_wh_pre = min(
per_slot_discharge_wh_pre,
export_cap_push_w * float(battery.discharge_efficiency) * INTERVAL_H,
)
discharge_buf_pre = float(getattr(battery, "discharge_slot_buffer", 0) or 0)
discharge_floor_wh = _planner_discharge_floor_wh(battery)
kv1_evening_push_pre = _kv1_block_export_fixed_evening_push(
grid,
purchase_fixed=purchase_fixed_pre,
)
computed_evening_push_ts = set(
_evening_battery_export_push_indices(
slots,
charge_acquisition_czk_kwh=charge_acquisition_czk_kwh,
degrad_czk_kwh=float(degradation_cost_effective),
current_soc_wh=float(current_soc_wh),
min_soc_wh=float(discharge_floor_wh),
soc_max_wh=float(battery.soc_max_wh),
per_slot_discharge_wh=per_slot_push_wh_pre,
discharge_slot_buffer=discharge_buf_pre,
discharge_export_ok=discharge_export_slots,
first_neg_sell_idx=first_neg_sell_idx,
kv1_evening_push=kv1_evening_push_pre,
)
)
push_override_raw = _evening_push_override_for_solve(
evening_push_ts_override,
relaxed_expensive_import=relaxed_expensive_import,
relaxed_neg_buy_charge=relaxed_neg_buy_charge,
relaxed_neg_prep_window=relaxed_neg_prep_window,
neg_sell_phases_fallback=neg_sell_phases_fallback,
)
push_override_eff = None
if push_override_raw:
push_override_eff = _filter_evening_push_override_indices(
slots,
push_override_raw,
battery=battery,
grid=grid,
discharge_export_ok=discharge_export_slots,
)
evening_push_hysteresis_retained = False
if push_override_eff:
evening_push_ts = push_override_eff
evening_push_hysteresis_retained = True
else:
evening_push_ts = computed_evening_push_ts
if not evening_push_ts:
evening_push_ts = _evening_push_peak_fallback_indices(
slots,
charge_acquisition_czk_kwh=charge_acquisition_czk_kwh,
min_spread=float(degradation_cost_effective),
discharge_export_ok=discharge_export_slots,
first_neg_sell_idx=first_neg_sell_idx,
kv1_evening_push=kv1_evening_push_pre,
)
# Tvrdý ge_bat push vypnout jen v prep/fallback retry (ne při rei — jinak zmizí vývoz v špičce).
evening_push_hard_suppressed = bool(
relaxed_neg_prep_window or neg_sell_phases_fallback
)
else:
evening_push_hard_suppressed = False
last_pos_sell_pre_neg_buy = _last_non_negative_sell_before_neg_buy(
slots, first_neg_buy_idx
)
pos_sell_pre_neg_buy_ts = _positive_sell_pre_neg_buy_indices(
slots, first_neg_buy_idx
)
pre_neg_buy_empty_ts = _pre_neg_buy_empty_discharge_indices(
slots, first_neg_buy_idx, last_pos_sell_pre_neg_buy
)
if om == "AUTO":
evening_export_exempt_ts = (
set(morning_pre_neg_export_ts)
| set(pre_neg_buy_discharge_ts)
| set(pre_neg_buy_empty_ts)
| set(neg_evening_push_ts)
)
if purchase_fixed_pre:
# Fixní tarif: sell>buy v noci nesmí ge_bat=0 přes evening_early (BA81 úsvit).
evening_export_exempt_ts |= profitable_export_ts_pre
evening_early_export_penalty_ts = _evening_early_export_penalty_indices(
slots,
discharge_export_slots=discharge_export_slots,
evening_push_ts=evening_push_ts,
exempt_ts=evening_export_exempt_ts,
)
night_self_consume_discourage_ts = _night_self_consume_discourage_import_indices(
slots,
evening_push_ts=evening_push_ts,
charge_acquisition_czk_kwh=charge_acquisition_czk_kwh,
min_spread=float(degradation_cost_effective),
)
post_evening_push_night_ts = _post_evening_push_night_self_consume_indices(
slots, evening_push_ts
)
night_self_consume_discourage_ts |= post_evening_push_night_ts
battery_export_defer_pv_ts = {
t for t in range(T) if _battery_export_push_defer_to_pv(slots[t])
}
else:
battery_export_defer_pv_ts = set()
pre_neg_buy_soc_ceiling_wh = _pre_neg_buy_soc_ceiling_wh(
slots,
first_neg_buy_idx=first_neg_buy_idx,
min_soc_wh=float(min_soc_wh),
soc_max_wh=float(battery.soc_max_wh),
max_charge_w=float(battery.max_charge_power_w),
charge_eff=float(battery.charge_efficiency),
)
t_pre_neg_buy_anchor: int | None = (
first_neg_buy_idx - 1 if first_neg_buy_idx is not None and first_neg_buy_idx > 0 else None
)
soc_pre_neg_buy_ceiling_slack: pulp.LpVariable | None = None
if (
t_pre_neg_buy_anchor is not None
and pre_neg_buy_soc_ceiling_wh is not None
):
soc_pre_neg_buy_ceiling_slack = pulp.LpVariable(
"soc_pre_neg_buy_ceiling_slack_wh",
0,
float(battery.usable_capacity_wh),
)
pos_sell_soc_shortfall: pulp.LpVariable | None = None
if last_pos_sell_pre_neg_buy is not None:
pos_sell_soc_shortfall = pulp.LpVariable(
"pos_sell_pre_neg_soc_shortfall_wh",
0,
float(battery.usable_capacity_wh),
)
daytime_en = bool(getattr(battery, "planner_daytime_charge_target_enabled", True))
safety_pen_czk_per_wh: list[float] = []
safety_vars: list[Optional[pulp.LpVariable]] = []
safety_active: list[bool] = []
post_neg_pv_topup: list[bool] = []
high_sell_slot: list[bool] = []
for t in range(T):
sft = slots[t].safety_soc_target_wh if daytime_en else None
# High-sell slot: typicky lokální maximum v SQL lookaheadu (future_sell_opportunity_czk_kwh).
# V těchto slotech safety floor nepoužijeme, aby se zachovala arbitráž na špičkách.
fso = slots[t].future_sell_opportunity_czk_kwh
hs = bool(fso is not None and float(slots[t].sell_price) >= float(fso) - 1e-6)
high_sell_slot.append(hs)
fb = float(slots[t].future_avoided_buy_czk_kwh or slots[t].buy_price)
fs = float(slots[t].future_sell_opportunity_czk_kwh or slots[t].sell_price)
bv = max(fb, fs) - float(degradation_cost_effective)
bv = max(0.0, min(5.0, bv))
st_d = _prague_calendar_date(slots[t])
ln_neg = last_neg_sell_by_prague_date.get(st_d)
pv_topup_after_neg = bool(
om == "AUTO"
and ln_neg is not None
and t > ln_neg
and float(slots[t].sell_price) >= 0.0
and bool(slots[t].is_daytime_pv_surplus_slot)
and not hs
)
post_neg_pv_topup.append(pv_topup_after_neg)
# Safety deficit penalizujeme jen v PV surplus slotech, a ne ve high-sell špičce.
# Záměr: safety není obecná „nabij co nejdřív“ motivace; je to preference využít přebytek PV.
active = bool(
(
sft is not None
and (
bool(slots[t].is_daytime_pv_surplus_slot)
or (planner_v2 and float(slots[t].buy_price) < 0.0)
)
and not hs
)
or pv_topup_after_neg
)
safety_active.append(active)
safety_pen_czk_per_wh.append(bv / 1000.0 if active else 0.0)
if active:
safety_vars.append(
pulp.LpVariable(f"safety_def_{t}", 0, float(battery.usable_capacity_wh))
)
else:
safety_vars.append(None)
commit_pen = float(getattr(battery, "planner_charge_commitment_penalty_czk_kwh", 0.2))
commit_lp: list[tuple[int, pulp.LpVariable, float]] = []
commitment_for_solve = charge_commitment_prev_w
if (
relaxed_neg_buy_charge
or relaxed_neg_prep_window
or neg_sell_phases_fallback
):
commitment_for_solve = None
if commitment_for_solve is not None and len(commitment_for_solve) == T:
for t in range(T):
prev = commitment_for_solve[t]
if prev is not None and prev > 500:
cap_prev = float(prev)
cv = pulp.LpVariable(f"ccommit_{t}", 0, cap_prev)
commit_lp.append((t, cv, cap_prev))
peak_export_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
pv_charge_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
pre_neg_pv_charge_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
pre_neg_pv_export_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
neg_evening_before_neg_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
neg_evening_reserve_soc_slack: list[tuple[int, pulp.LpVariable, float]] = []
neg_sell_bat_dump_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
neg_sell_soc_underfill: list[tuple[int, pulp.LpVariable, float]] = []
neg_buy_charge_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
pre_neg_batt_export_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
pre_neg_buy_empty_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
fixed_tariff_like = fixed_tariff_like_pre
block_export_neg_sell = bool(getattr(grid, "block_export_on_negative_sell", False))
if om == "AUTO":
for t in range(T):
if t not in discharge_export_slots:
continue
if t in evening_push_ts:
continue
if _in_night_battery_export_window(slots[t]):
# Spot: večerní export jen v tvrdém push. Fixní: i profitable sell>buy v noci.
if not (
purchase_fixed_pre
and _slot_profitable_battery_export(
slots[t],
charge_acquisition_czk_kwh=charge_acquisition_czk_kwh,
min_spread=float(degradation_cost_effective),
fixed_tariff=True,
)
):
continue
if _battery_export_push_defer_to_pv(slots[t]):
continue
if not _slot_profitable_battery_export(
slots[t],
charge_acquisition_czk_kwh=charge_acquisition_czk_kwh,
min_spread=float(degradation_cost_effective),
fixed_tariff=fixed_tariff_like,
):
continue
cap_w = float(min(
grid.max_export_power_w,
battery.max_discharge_power_w,
))
sf = pulp.LpVariable(f"export_shortfall_{t}", 0, cap_w)
peak_export_shortfall.append((t, sf, cap_w))
export_cap_w = _battery_export_cap_w(battery, grid)
for t_pnd in sorted(pre_neg_buy_discharge_ts):
if _battery_export_push_defer_to_pv(slots[t_pnd]):
continue
sf_pnd = pulp.LpVariable(f"pre_neg_bat_export_sf_{t_pnd}", 0, export_cap_w)
pre_neg_batt_export_shortfall.append((t_pnd, sf_pnd, export_cap_w))
for t_empty in pre_neg_buy_empty_ts:
if _battery_export_push_defer_to_pv(slots[t_empty]):
continue
sf_e = pulp.LpVariable(f"pre_neg_buy_empty_sf_{t_empty}", 0, export_cap_w)
pre_neg_buy_empty_shortfall.append((t_empty, sf_e, export_cap_w))
if not relaxed_neg_buy_charge:
neg_buy_slot_indices = [
t for t, s in enumerate(slots) if float(s.buy_price) < 0.0
]
if neg_buy_slot_indices:
t_nb_last = max(neg_buy_slot_indices_pre)
cap_w = float(battery.max_charge_power_w)
sf_nb = pulp.LpVariable(f"neg_buy_charge_sf_{t_nb_last}", 0, cap_w)
neg_buy_charge_shortfall.append((t_nb_last, sf_nb, cap_w))
for t in range(T):
if float(slots[t].sell_price) >= 0:
continue
if float(slots[t].buy_price) < 0.0:
continue
if t not in charge_slots:
continue
# Před buy<0: nepenalizovat / netlačit PV→bat (jinak 98 % v 09:15 a export v sell<0).
if first_neg_buy_idx is not None and t < first_neg_buy_idx:
continue
pv_surplus_w = max(
0.0,
float(slots[t].pv_a_forecast_w)
+ float(slots[t].pv_b_forecast_w)
- float(slots[t].load_baseline_w),
)
if pv_surplus_w <= 500:
continue
cap_w = float(min(pv_surplus_w, battery.max_charge_power_w))
sf_pv = pulp.LpVariable(f"pv_charge_shortfall_{t}", 0, cap_w)
pv_charge_shortfall.append((t, sf_pv, cap_w))
if neg_sell_phases_en:
pv_charge_taken = {t_sf for t_sf, _sf, _c in pv_charge_shortfall}
for t_ns in range(T):
if neg_sell_phase_by_t[t_ns] not in ("prep", "tail"):
continue
if t_ns in pv_charge_taken:
continue
if float(slots[t_ns].sell_price) >= 0.0:
continue
pv_surplus_ns = max(
0.0,
float(slots[t_ns].pv_a_forecast_w)
+ float(slots[t_ns].pv_b_forecast_w)
- float(slots[t_ns].load_baseline_w),
)
if pv_surplus_ns <= 500:
continue
cap_ns = float(min(pv_surplus_ns, battery.max_charge_power_w))
sf_ns = pulp.LpVariable(f"neg_phase_pv_charge_{t_ns}", 0, cap_ns)
pv_charge_shortfall.append((t_ns, sf_ns, cap_ns))
for t_pe in sorted(pre_neg_pv_export_ts):
s_pe = slots[t_pe]
pv_surplus_pe = max(
0.0,
float(s_pe.pv_a_forecast_w)
+ float(s_pe.pv_b_forecast_w)
- float(s_pe.load_baseline_w),
)
cap_pe = float(
min(
pv_surplus_pe,
float(grid.max_export_power_w),
)
)
if cap_pe <= 500.0:
continue
sf_pe = pulp.LpVariable(f"pre_neg_pv_export_sf_{t_pe}", 0, cap_pe)
pre_neg_pv_export_shortfall.append((t_pe, sf_pe, cap_pe))
export_cap_evening = _battery_export_cap_w(battery, grid)
for t_ev in sorted(neg_evening_push_ts):
if t_ev not in discharge_export_slots:
continue
sf_ev = pulp.LpVariable(
f"neg_eve_prep_discharge_{t_ev}",
0,
export_cap_evening,
)
neg_evening_before_neg_shortfall.append((t_ev, sf_ev, export_cap_evening))
for t_anchor, reserve_tgt in neg_evening_reserve_anchors:
sl = pulp.LpVariable(
f"neg_eve_reserve_soc_slack_{t_anchor}",
0,
float(NEG_EVENING_RESERVE_SOC_MAX_SLACK_WH),
)
neg_evening_reserve_soc_slack.append((t_anchor, sl, float(reserve_tgt)))
if t_anchor in discharge_export_slots and t_anchor not in {
t for t, _sf, _c in neg_evening_before_neg_shortfall
}:
cap_ev = _battery_export_cap_w(battery, grid)
sf_ra = pulp.LpVariable(
f"neg_eve_reserve_ge_{t_anchor}",
0,
cap_ev,
)
neg_evening_before_neg_shortfall.append((t_anchor, sf_ra, cap_ev))
for t in range(T):
if not post_neg_pv_topup[t]:
continue
if float(slots[t].sell_price) < 0:
continue
pv_surplus_w = max(
0.0,
float(slots[t].pv_a_forecast_w)
+ float(slots[t].pv_b_forecast_w)
- float(slots[t].load_baseline_w),
)
if pv_surplus_w <= 500:
continue
cap_w = float(min(pv_surplus_w, battery.max_charge_power_w))
sf_pv = pulp.LpVariable(f"post_neg_pv_shortfall_{t}", 0, cap_w)
pv_charge_shortfall.append((t, sf_pv, cap_w))
if neg_sell_phases_en and not relaxed_neg_prep_window:
for t_ns in range(T):
phase_ns = neg_sell_phase_by_t[t_ns]
tgt_ns = neg_sell_soc_target_by_t[t_ns]
if phase_ns == "none" or tgt_ns is None:
continue
us_prep = pulp.LpVariable(
f"neg_sell_prep_soc_{t_ns}",
0,
float(battery.usable_capacity_wh),
)
w_sf = float(neg_sell_shortfall_weight_by_t[t_ns])
prep_soc_shortfall.append((t_ns, us_prep, w_sf))
tail_last_by_day: dict[object, int] = {}
for t_ln, st_ln in enumerate(slots):
if neg_sell_phase_by_t[t_ln] != "tail":
continue
tail_last_by_day[_prague_calendar_date(st_ln)] = t_ln
for t_tail_last in tail_last_by_day.values():
if t_tail_last in charge_slots or relaxed_neg_buy_charge:
us_tail = pulp.LpVariable(
f"neg_sell_tail_soc_{t_tail_last}",
0,
float(battery.usable_capacity_wh),
)
neg_sell_soc_underfill.append(
(t_tail_last, us_tail, float(battery.soc_max_wh))
)
if not relaxed_neg_prep_window:
for t_ph in range(T):
if neg_sell_phase_by_t[t_ph] != "prep":
continue
cap_bc = float(battery.max_charge_power_w)
prep_hold_met_binary[t_ph] = pulp.LpVariable(
f"prep_hold_met_{t_ph}",
cat=pulp.LpBinary,
)
sf_hold = pulp.LpVariable(f"prep_hold_bcpv_{t_ph}", 0, cap_bc)
prep_hold_bcpv_shortfall.append((t_ph, sf_hold, cap_bc))
cap_ca = float(max(0, slots[t_ph].pv_a_forecast_w))
sf_ca = pulp.LpVariable(f"prep_hold_curtail_{t_ph}", 0, cap_ca)
prep_hold_curtail_shortfall.append((t_ph, sf_ca, cap_ca))
elif len(neg_buy_slot_indices_pre) >= 2:
t_nb_last = max(neg_buy_slot_indices_pre)
if t_nb_last in charge_slots or relaxed_neg_buy_charge:
us = pulp.LpVariable(
f"neg_buy_soc_under_{t_nb_last}",
0,
float(battery.usable_capacity_wh),
)
neg_sell_soc_underfill.append(
(t_nb_last, us, float(battery.soc_max_wh))
)
for t in range(T):
if first_neg_buy_idx is None or t >= first_neg_buy_idx:
continue
if float(slots[t].sell_price) >= 0.0:
continue
if float(slots[t].buy_price) < 0.0:
continue
pv_surplus_w = max(
0.0,
float(slots[t].pv_a_forecast_w)
+ float(slots[t].pv_b_forecast_w)
- float(slots[t].load_baseline_w),
)
if pv_surplus_w <= 500:
continue
cap_w = float(min(pv_surplus_w, battery.max_charge_power_w))
sf_m = pulp.LpVariable(f"pre_neg_pv_charge_sf_{t}", 0, cap_w)
pre_neg_pv_charge_shortfall.append((t, sf_m, cap_w))
for t in neg_sell_bat_dump_slots:
dump_target_w = _battery_export_cap_w(battery, grid)
sf_dump = pulp.LpVariable(f"neg_bat_dump_shortfall_{t}", 0, dump_target_w)
neg_sell_bat_dump_shortfall.append((t, sf_dump, dump_target_w))
# --- Účelová funkce (jen OTE sloty; terminal SoC shadow price na konci horizontu) ---
# Kanály: gi×buy, ge_pv×sell, ge_bat×sell, +ge_bat×acquisition (export bat. jen v discharge slotách).
# Viz docs/04-modules/planning-arbitrage-accounting.md — mezi-slotová arbitráž, ne sell vs buy v jednom slotu.
prob += (
pulp.lpSum(
gi[t] * slots[t].buy_price * INTERVAL_H / 1000
- ge_pv[t] * slots[t].sell_price * INTERVAL_H / 1000
- ge_bat[t] * slots[t].sell_price * INTERVAL_H / 1000
+ (
ge_pv[t] * SELF_SUSTAIN_EXPORT_PENALTY_CZK_KWH * INTERVAL_H / 1000
if om == "SELF_SUSTAIN"
else 0
)
+ (
(slots[t].pv_b_forecast_w * z_gen_cutoff[t]) * GEN_CUTOFF_PENALTY_CZK_KWH * INTERVAL_H / 1000
if z_gen_cutoff is not None
else 0
)
+ gi_over[t] * IMPORT_OVER_BREAKER_PENALTY_CZK_KWH * INTERVAL_H / 1000
+ 0.5 * (bc_pv[t] + bc_gi[t] + bd[t]) * degradation_cost_effective * INTERVAL_H / 1000
- (
pv_ld[t] * LOAD_FIRST_INCENTIVE_CZK_KWH * INTERVAL_H / 1000
if om == "AUTO"
else 0
)
+ (
ge_bat[t] * charge_acquisition_czk_kwh * INTERVAL_H / 1000
if om == "AUTO" and t in discharge_export_slots
else 0
)
- (
bc_pv[t]
* NEG_SELL_PV_CHARGE_REWARD_CZK_KWH
* INTERVAL_H
/ 1000
if (
om == "AUTO"
and float(slots[t].sell_price) < 0.0
and t in charge_slots
)
else 0
)
+ (
ge_pv[t]
* (
max(
0.05,
-float(slots[t].sell_price),
)
if (
neg_sell_phases_en
and neg_sell_phase_by_t[t] == "tail"
)
else NEG_SELL_PV_B_VENT_PENALTY_CZK_KWH
)
* INTERVAL_H
/ 1000
if (
om == "AUTO"
and float(slots[t].sell_price) < 0.0
and not purchase_fixed_pre
)
else 0
)
+ (
gi[t]
* max(
NIGHT_SELF_CONSUME_IMPORT_SURCHARGE_CZK_KWH,
max(
0.0,
float(slots[t].buy_price) - charge_acquisition_czk_kwh,
),
)
* INTERVAL_H
/ 1000
if om == "AUTO" and t in night_self_consume_discourage_ts
else 0
)
+ pulp.lpSum(
ev_direct[e][t] * slots[t].buy_price * INTERVAL_H / 1000
+ ev_via_bat[e][t] * slots[t].buy_price * EV_ROUNDTRIP_FACTOR * INTERVAL_H / 1000
for e in range(EV)
)
+ ca[t]
* (
NEG_SELL_CURTAIL_PENALTY_CZK_KWH
if (
om == "AUTO"
and float(slots[t].buy_price) < 0.0
and t in charge_slots
and not (
neg_sell_phases_en and neg_sell_phase_by_t[t] == "prep"
)
)
else CURTAILMENT_PENALTY
)
for t in range(T)
)
+ soc_deficit_24h * soc_deficit_penalty_czk_kwh / 1000
- terminal_soc_kcz_per_wh * soc[T - 1]
+ (
pos_sell_soc_shortfall * POS_SELL_PRE_NEG_SOC_SHORTFALL_PENALTY_CZK_PER_WH
if pos_sell_soc_shortfall is not None
else 0
)
+ (
soc_pre_neg_buy_ceiling_slack
* PRE_NEG_BUY_SOC_CEILING_SLACK_PENALTY_CZK_PER_WH
if soc_pre_neg_buy_ceiling_slack is not None
else 0
)
+ pulp.lpSum(
safety_vars[t] * safety_pen_czk_per_wh[t]
for t in range(T)
if safety_vars[t] is not None
)
+ pulp.lpSum(cv * INTERVAL_H / 1000.0 * commit_pen for _t, cv, _p in commit_lp)
+ pulp.lpSum(
sf * PEAK_EXPORT_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0
for _t, sf, _cap in peak_export_shortfall
)
+ pulp.lpSum(
sf * PV_CHARGE_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0
for _t, sf, _cap in pv_charge_shortfall
)
+ pulp.lpSum(
us * NEG_SELL_SOC_UNDERFILL_PENALTY_CZK_PER_WH
for _t, us, _tgt in neg_sell_soc_underfill
)
+ pulp.lpSum(
us * w_sf * NEG_SELL_PREP_SOC_SHORTFALL_PENALTY_CZK_PER_WH
for _t, us, w_sf in prep_soc_shortfall
)
+ pulp.lpSum(
sf * NEG_SELL_PREP_HOLD_BCPV_PENALTY_CZK_KWH * INTERVAL_H / 1000.0
for _t, sf, _cap in prep_hold_bcpv_shortfall
)
+ pulp.lpSum(
sf * NEG_SELL_CURTAIL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0
for _t, sf, _cap in prep_hold_curtail_shortfall
)
+ pulp.lpSum(
sf * PRE_NEG_PV_EXPORT_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0
for _t, sf, _cap in pre_neg_pv_export_shortfall
)
+ pulp.lpSum(
sf * NEG_EVENING_PREP_DISCHARGE_SHORTFALL_PENALTY_CZK_KWH
* INTERVAL_H
/ 1000.0
for _t, sf, _cap in neg_evening_before_neg_shortfall
)
+ pulp.lpSum(
sl * NEG_EVENING_RESERVE_SOC_SLACK_PENALTY_CZK_PER_WH
for _t, sl, _tgt in neg_evening_reserve_soc_slack
)
+ pulp.lpSum(
bc_pv[t]
* PRE_NEG_PV_BCPV_DISCOURAGE_CZK_KWH
* INTERVAL_H
/ 1000.0
for t in pre_neg_pv_export_ts
)
+ pulp.lpSum(
bc_pv[t]
* NEG_SELL_POST_DETACH_BCPV_DISCOURAGE_CZK_KWH
* INTERVAL_H
/ 1000.0
for t in neg_sell_post_detach_prep_ts
)
+ pulp.lpSum(
sf * NEG_SELL_BAT_DUMP_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0
for _t, sf, _cap in neg_sell_bat_dump_shortfall
)
+ pulp.lpSum(
sf * NEG_BUY_CHARGE_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0
for _t, sf, _cap in neg_buy_charge_shortfall
)
+ pulp.lpSum(
sf * PRE_NEG_BATT_EXPORT_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0
for _t, sf, _cap in pre_neg_batt_export_shortfall
)
+ pulp.lpSum(
bc_gi[t]
* PRE_NEG_CHARGE_PENALTY_CZK_KWH
* INTERVAL_H
/ 1000.0
for t in range(T)
if (
first_neg_buy_idx is not None
and t < first_neg_buy_idx
and float(slots[t].buy_price) >= 0.0
)
)
+ pulp.lpSum(
bc_pv[t]
* PRE_NEG_BUY_PV_CHARGE_PENALTY_CZK_KWH
* INTERVAL_H
/ 1000.0
for t in range(T)
if float(slots[t].buy_price) < 0.0
)
+ pulp.lpSum(
sf * PV_CHARGE_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0
for _t, sf, _cap in pre_neg_pv_charge_shortfall
)
+ pulp.lpSum(
sf * PRE_NEG_BUY_EMPTY_EXPORT_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0
for _t, sf, _cap in pre_neg_buy_empty_shortfall
)
+ pulp.lpSum(
-25.0 * z_export[t]
for t in range(T)
if t in discharge_export_slots and t in profitable_export_ts_pre
)
+ pulp.lpSum(
-EVENING_PUSH_Z_EXPORT_BONUS_CZK * z_export[t]
for t in evening_push_ts
)
)
# --- Omezení ---
for t_sf, sf, cap_w in peak_export_shortfall:
prob += sf >= cap_w - ge_bat[t_sf]
for t_sf, sf, cap_w in pv_charge_shortfall:
prob += sf >= cap_w - bc_pv[t_sf]
for t_sf, sf, cap_w in neg_sell_bat_dump_shortfall:
prob += sf >= cap_w - ge_bat[t_sf]
for t_us, us, _w_sf in prep_soc_shortfall:
tgt_prep = neg_sell_soc_target_by_t[t_us]
if tgt_prep is not None:
prob += us >= float(tgt_prep) - soc[t_us]
for t_us, us, tgt_wh in neg_sell_soc_underfill:
prob += us >= float(tgt_wh) - soc[t_us]
m_hold_soc = float(battery.soc_max_wh)
for t_h, sf_h, cap_h in prep_hold_bcpv_shortfall:
w_h = prep_hold_met_binary[t_h]
soc_prev_h = current_soc_wh if t_h == 0 else soc[t_h - 1]
tgt_hold = neg_sell_soc_target_by_t[t_h]
hold_thr = float(tgt_hold) if tgt_hold is not None else float(battery.soc_max_wh)
prob += soc_prev_h >= hold_thr - m_hold_soc * (1 - w_h)
prob += sf_h >= bc_pv[t_h] - cap_h * w_h
for t_c, sf_c, cap_c in prep_hold_curtail_shortfall:
w_c = prep_hold_met_binary[t_c]
prob += sf_c >= ca[t_c] - cap_c * (1 - w_c)
for t_sf, sf, cap_w in neg_buy_charge_shortfall:
# buy<0: bc_pv=0 (import arbitráž); shortfall jen na grid→bat.
prob += sf >= cap_w - bc_gi[t_sf]
for t_sf, sf, cap_w in pre_neg_batt_export_shortfall:
prob += sf >= cap_w - ge_bat[t_sf]
for t_sf, sf, cap_w in pre_neg_buy_empty_shortfall:
prob += sf >= cap_w - ge_bat[t_sf]
for t_sf, sf, cap_w in pre_neg_pv_charge_shortfall:
prob += sf >= cap_w - bc_pv[t_sf]
for t_sf, sf, cap_w in pre_neg_pv_export_shortfall:
prob += sf >= cap_w - ge_pv[t_sf]
for t_sf, sf, cap_w in neg_evening_before_neg_shortfall:
prob += sf >= cap_w - ge_bat[t_sf]
for t_sl, sl, reserve_tgt in neg_evening_reserve_soc_slack:
prob += soc[t_sl] <= float(reserve_tgt) + sl
preneg_export_min_soc_wh = float(min_soc_wh) + max(
float(battery.max_discharge_power_w)
* float(battery.discharge_efficiency)
* INTERVAL_H,
1000.0,
)
per_slot_discharge_wh = max(
float(battery.max_discharge_power_w)
* float(battery.discharge_efficiency)
* INTERVAL_H,
0.0,
)
if om == "AUTO":
profitable_export_ts = profitable_export_ts_pre
export_push_w = _battery_export_cap_w(battery, grid)
discharge_floor_wh = _planner_discharge_floor_wh(battery)
# Tvrdý ranní/pre-neg export jen ve strict režimu (jinak ~25 % SoC + neg den → Infeasible).
if not any_relaxed:
for t_peak in morning_pre_neg_export_ts:
if t_peak in profitable_export_ts:
if _battery_export_push_defer_to_pv(slots[t_peak]):
continue
prob += ge_bat[t_peak] >= export_push_w * z_export[t_peak]
prob += soc[t_peak] >= float(discharge_floor_wh)
for t_pnd in pre_neg_buy_discharge_ts:
if _battery_export_push_defer_to_pv(slots[t_pnd]):
continue
prob += ge_bat[t_pnd] >= export_push_w * z_export[t_pnd]
for t_empty in pre_neg_buy_empty_ts:
if t_empty in discharge_export_slots:
if _battery_export_push_defer_to_pv(slots[t_empty]):
continue
prob += ge_bat[t_empty] >= export_push_w * z_export[t_empty]
for t_early in sorted(evening_early_export_penalty_ts):
prob += ge_bat[t_early] == 0
if not evening_push_hard_suppressed:
for t_peak in sorted(evening_push_ts):
if t_peak not in discharge_export_slots:
continue
if t_peak in battery_export_defer_pv_ts:
continue
push_floor_w = _evening_push_battery_export_w(
slots[t_peak], battery, grid
)
if push_floor_w >= GE_MIN_EXPORT_W:
prob += z_export[t_peak] == 1
prob += ge_bat[t_peak] >= push_floor_w
prob += soc[t_peak] >= float(discharge_floor_wh)
for t_pv in sorted(battery_export_defer_pv_ts):
if t_pv in evening_push_ts:
continue
if t_pv in morning_pre_neg_export_ts:
continue
if t_pv in pre_neg_buy_discharge_ts:
continue
if t_pv in pre_neg_buy_empty_ts:
continue
prob += ge_bat[t_pv] == 0
prob += z_export[t_pv] == 0
# Ostatní profitable sloty: měkká shortfall penalizace (ne večerní push).
if (
last_pos_sell_pre_neg_buy is not None
and pos_sell_soc_shortfall is not None
):
prob += (
soc[last_pos_sell_pre_neg_buy]
>= float(battery.soc_max_wh) - pos_sell_soc_shortfall
)
if (
t_pre_neg_buy_anchor is not None
and pre_neg_buy_soc_ceiling_wh is not None
and soc_pre_neg_buy_ceiling_slack is not None
and last_pos_sell_pre_neg_buy is not None
):
prob += (
soc[t_pre_neg_buy_anchor]
<= float(pre_neg_buy_soc_ceiling_wh) + soc_pre_neg_buy_ceiling_slack
)
for t in range(T):
s = slots[t]
pv_a_net = s.pv_a_forecast_w - ca[t]
ev_total_t = pulp.lpSum(ev_direct[e][t] + ev_via_bat[e][t] for e in range(EV))
# Energetická bilance
pv_b_effective = (
float(s.pv_b_forecast_w) * (1 - z_gen_cutoff[t])
if z_gen_cutoff is not None
else float(s.pv_b_forecast_w)
)
pv_total_ub = float(s.pv_a_forecast_w) + float(s.pv_b_forecast_w)
# Součet nabíjení z FVE + ze sítě nesmí překročit max_charge_power_w baterie.
prob += bc_pv[t] + bc_gi[t] <= battery.max_charge_power_w
# Breaker: import ze site je tvrdě omezen (gi_over jen numerická pojistka).
prob += gi[t] <= gi_upper
if om == "AUTO":
load_site_expr = float(s.load_baseline_w) + ev_total_t + hp[t]
ev_cap_slot_w = 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)
)
max_load_site_w = (
float(s.load_baseline_w)
+ ev_cap_slot_w
+ float(heat_pump.rated_heating_power_w)
)
# BMS: jedno vybíjení — bilance při gi≈0 dá bd≈load+ge_bat; bd+ge_bat≤max by export
# započítalo dvakrát ((maxload)/2). Exportní sloty: load+ge_bat; jinak bd≤max.
prob += bd[t] <= battery.max_discharge_power_w
if t in discharge_export_slots:
prob += load_site_expr + ge_bat[t] <= battery.max_discharge_power_w
prob += pv_ld[t] + pv_sp[t] == pv_a_net + pv_b_effective
prob += pv_ld[t] <= load_site_expr
prob += pv_ld[t] <= pv_a_net + pv_b_effective
prob += pv_sp[t] <= pv_total_ub
prob += pv_sp[t] >= pv_a_net + pv_b_effective - load_site_expr
prob += bc_pv[t] <= pv_sp[t]
prob += bc_gi[t] <= gi[t]
prob += ge_pv[t] <= pv_sp[t]
prob += bc_pv[t] + ge_pv[t] <= pv_sp[t]
# Tvrdý load-first (Deye): při dostatečné FVE jen grid-nabíjení (bc_gi); jinak gi smí
# krmit deficit domu (noc / nízká FVE), ne fiktivně paralelně s plným PV→bc_pv.
house_grid_import_cap_w = max(
0.0,
max_load_site_w - pv_total_ub,
)
prob += gi[t] <= bc_gi[t] + house_grid_import_cap_w
pv_covers_load_site = (
pv_total_ub >= max_load_site_w + NIGHT_EXPORT_PV_SUNRISE_SURPLUS_W
)
if pv_covers_load_site:
prob += pv_ld[t] >= load_site_expr
# Vybíjení do domu až po pv_ld; v exportních slotech smí bd→síť.
if t not in discharge_export_slots:
prob += bd[t] <= load_site_expr - pv_ld[t]
if pv_covers_load_site:
prob += pv_ld[t] >= load_site_expr - bd[t]
else:
prob += pv_ld[t] >= load_site_expr - gi[t] - bd[t]
# Plná bilance (pv_ld+pv_sp rozpad je ortogonální k tokům přebytku).
prob += (
pv_a_net + pv_b_effective + gi[t] + bd[t]
== float(s.load_baseline_w) + ev_total_t + hp[t] + bc_pv[t] + bc_gi[t] + ge[t]
)
else:
prob += pv_ld[t] == 0
prob += pv_sp[t] == pv_a_net + pv_b_effective
prob += bc_pv[t] <= pv_sp[t]
prob += bc_gi[t] <= gi[t]
prob += (
pv_a_net + pv_b_effective + gi[t] + bd[t]
== s.load_baseline_w + ev_total_t + hp[t] + bc_pv[t] + bc_gi[t] + ge[t]
)
prob += bd[t] + ge_bat[t] <= battery.max_discharge_power_w
prob += ge[t] == ge_pv[t] + ge_bat[t]
# Baterie nesmí „přestrojit“ FVE export: jen z pv_sp (po load-first).
if om == "AUTO":
prob += ge_bat[t] >= ge[t] - pv_sp[t]
else:
prob += ge_bat[t] >= ge[t] - (pv_a_net + pv_b_effective)
# Měkký breaker cap: gi_over[t] >= max(0, gi[t] - breaker).
prob += gi_over[t] >= gi[t] - float(grid.max_import_power_w)
# SoC kontinuita: bd je v bilanci zdroj na AC sběrnici; při exportu z baterie už
# obsahuje load + ge_bat (ge = ge_pv + ge_bat). ge_bat znovu neodečítat.
soc_prev = current_soc_wh if t == 0 else soc[t - 1]
prob += soc[t] == (
soc_prev
+ (bc_pv[t] + bc_gi[t]) * battery.charge_efficiency * INTERVAL_H
- bd[t] / battery.discharge_efficiency * INTERVAL_H
)
sv = safety_vars[t]
tgt_s = slots[t].safety_soc_target_wh if daytime_en else None
if sv is not None:
eff_tgt_s = float(tgt_s) if tgt_s is not None else float(min_soc_wh)
if (
neg_sell_phases_en
and float(s.sell_price) < 0.0
and neg_sell_soc_target_by_t[t] is not None
):
eff_tgt_s = max(eff_tgt_s, float(neg_sell_soc_target_by_t[t]))
elif (
om == "AUTO"
and float(s.buy_price) < 0.0
and t in charge_slots
and len(neg_buy_slot_indices_pre) >= 2
and not neg_sell_phases_en
):
# buy<0: cíl soc_max jen při víceslotovém okně (jinak fyzicky neřešitelné).
eff_tgt_s = max(eff_tgt_s, float(battery.soc_max_wh))
elif post_neg_pv_topup[t]:
# Po konci sell<0: dobit z FVE na plno, pak teprve export (kladný sell, ne večerní peak).
eff_tgt_s = max(eff_tgt_s, float(battery.soc_max_wh))
prob += sv >= eff_tgt_s - soc[t]
# ev_via_bat kryto z discharge
prob += pulp.lpSum(ev_via_bat[e][t] for e in range(EV)) <= bd[t]
# GEN port cut-off chceme vůbec připustit jen v režimech/politikách, kde má smysl:
# - SELF_SUSTAIN (no-export intent; typicky ge=0, takže cut-off je bezpečnostní ventil),
# - BLOCK_EXPORT okna (v projektu reprezentované sloty se sell_price < 0),
# - případně explicitní no_export politika (pokud bude v kontextu dostupná).
allow_gen_cutoff = (
om == "SELF_SUSTAIN"
or float(s.sell_price) < 0
or bool(getattr(grid, "no_export", False))
)
if z_gen_cutoff is not None and not allow_gen_cutoff:
prob += z_gen_cutoff[t] == 0
# Záporná nákupní cena → import jen na load + nabíjení + EV + TČ (stále ≤ breaker).
if s.buy_price < 0:
prob += gi[t] <= min(
gi_upper,
float(s.load_baseline_w)
+ battery.max_charge_power_w
+ sum(v.max_charge_power_w for v in vehicles)
+ heat_pump.rated_heating_power_w,
)
prob += ge[t] == 0
prob += ge_pv[t] == 0
prob += ge_bat[t] == 0
# PV A: měkký tlak curtail (NEG_SELL_CURTAIL při buy<0), ne tvrdé bc_pv=0
# (s polem B a bilancí může být bc_pv=0 nutné pro řešitelnost krátkých okének).
# Záporný prodej (sell < 0): výboj baterie jen před extrémně záporným buy (v11).
# Export FVE při sell<0: spot = nabíjení/curtail A; ventil jen pole B při plné baterii.
if s.sell_price < 0:
prob += w_arb[t] == 0
prob += bd[t] <= pulp.lpSum(ev_via_bat[e][t] for e in range(EV))
# buy<0: export už zakázán výše; neaplikovat sell<0 ventil (bilance / infeasible).
if float(s.buy_price) < 0.0:
continue
block_neg_sell_export_t = bool(
getattr(grid, "block_export_on_negative_sell", False)
)
if t not in neg_sell_bat_dump_slots:
prob += ge_bat[t] == 0
ev_cap_neg = 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)
)
load_neg = (
float(s.load_baseline_w)
+ ev_cap_neg
+ float(heat_pump.rated_heating_power_w)
)
pv_surplus_neg_w = max(
0.0,
float(s.pv_a_forecast_w) + float(s.pv_b_forecast_w) - load_neg,
)
# FVE→síť při záporném výkupu: u KV1 (block_export) jen bc/curtail A;
# u home-01 s polem B musí přebytek jít do sítě (ge_pv), jinak infeasible.
block_pv_export_neg_sell = bool(
getattr(grid, "block_export_on_negative_sell", False)
) or (
float(s.pv_b_forecast_w) <= 0
and not _pv_forced_vent_export_allowed(
t,
current_soc_wh=current_soc_wh,
battery=battery,
soc_headroom_wh=soc_headroom_wh,
pv_surplus_w=pv_surplus_neg_w,
)
)
if block_pv_export_neg_sell:
prob += ge_pv[t] == 0
# Tvrdý zákaz vývozu jen při block_export_on_negative_sell (KV1).
if block_neg_sell_export_t:
prob += ge[t] == 0
prob += ge_pv[t] == 0
prob += ge_bat[t] == 0
elif purchase_fixed_pre:
# Fixní nákup + spot výkup (BA81, KV1 bez block_export): sell<0 = platíš za vývoz.
prob += ge[t] == 0
prob += ge_pv[t] == 0
elif not purchase_fixed_pre:
# Spot: sell<0 před buy<0 — PV (A) do baterie, B může jít do sítě (ge_pv≤pv_b).
# Po buy<0 / mimo ranní pásmo: ventil B jen při plné baterii (nebo tail + sell práh).
before_first_neg_buy = (
first_neg_buy_idx is not None and t < first_neg_buy_idx
)
vent_min_sell = getattr(
battery, "planner_neg_sell_vent_min_sell_czk_kwh", None
)
tail_free_vent = bool(
neg_sell_phases_en
and neg_sell_phase_by_t[t] == "tail"
and vent_min_sell is not None
and float(s.sell_price) >= float(vent_min_sell)
)
if tail_free_vent and float(s.pv_b_forecast_w) > 0:
prob += ge_pv[t] <= float(s.pv_b_forecast_w)
elif before_first_neg_buy:
if float(s.pv_b_forecast_w) > 0:
prob += ge_pv[t] <= float(s.pv_b_forecast_w)
else:
soc_prev_neg = current_soc_wh if t == 0 else soc[t - 1]
w_pv_b_vent = pulp.LpVariable(
f"w_pv_b_vent_neg_{t}", cat=pulp.LpBinary
)
m_soc_neg = float(battery.soc_max_wh)
prob += soc_prev_neg >= (
m_soc_neg
- soc_headroom_wh
- m_soc_neg * (1 - w_pv_b_vent)
)
prob += ge_pv[t] <= float(s.pv_b_forecast_w) * w_pv_b_vent
soc_prev_expr = current_soc_wh if t == 0 else soc[t - 1]
arb_t = arb_floor_series[t]
soc_low_t = soc_panel_min[t]
# Při relaxovaném dnu (soc_low pod DB min_soc Wh) nesmí větev w_arb=1 znovu vynutit arb_t
# (typicky ~rezerva 20 %) — jinak nejde „vypustit“ baterku k planner floor 5 %.
if soc_low_t < min_soc_wh - 1e-3:
arb_cap_t = min(arb_t, soc_low_t)
else:
arb_cap_t = arb_t
if om == "AUTO" and t in discharge_export_slots:
prob += soc_prev_expr >= (
arb_cap_t - (arb_cap_t - soc_low_t) * (1 - w_arb[t])
)
prob += bd[t] <= (
battery.max_discharge_power_w * w_arb[t]
+ pulp.lpSum(ev_via_bat[e][t] for e in range(EV))
)
elif om == "AUTO":
# PASSIVE: vlastní spotřeba (bd); export baterie jen ge_bat (ge_bat=0 níže).
prob += soc_prev_expr >= (
arb_cap_t - (arb_cap_t - soc_low_t) * (1 - w_arb[t])
)
prob += bd[t] <= (
s.load_baseline_w
+ ev_total_t
+ hp[t]
+ bc_pv[t]
+ bc_gi[t]
)
else:
prob += soc_prev_expr >= (
arb_cap_t - (arb_cap_t - soc_low_t) * (1 - w_arb[t])
)
prob += bd[t] <= (
s.load_baseline_w
+ ev_total_t
+ hp[t]
+ bc_pv[t]
+ bc_gi[t]
+ battery.max_discharge_power_w * w_arb[t]
)
# Významný export z baterie ⇒ koncové SoC ≥ podlaha (FVE export ge_pv bez této podlahy).
m_ge = float(grid.max_export_power_w)
m_soc_bigm = float(battery.usable_capacity_wh)
if t in neg_sell_bat_dump_slots:
prob += ge_bat[t] <= m_ge
else:
prob += ge_bat[t] <= m_ge * z_export[t]
prob += ge_bat[t] >= GE_MIN_EXPORT_W * z_export[t]
# Bez hluboké relaxace: export končí ≥ rezerva. Při hluboké relaxaci (soc_panel_min pod min_soc)
# sladit s LP spodkem — jinak z_export vynutil arb_base a blokoval vývoz k planner floor.
if (
om == "AUTO"
and first_neg_sell_idx is not None
and t < first_neg_sell_idx
and floor_pct is not None
):
export_soc_floor_t = float(planner_floor_effective_wh)
elif om == "AUTO" and t in pre_neg_buy_discharge_ts:
export_soc_floor_t = float(min_soc_wh)
elif om == "AUTO" and t in pre_neg_buy_empty_ts:
export_soc_floor_t = float(min_soc_wh)
elif (
om == "AUTO"
and t in morning_pre_neg_export_ts
and floor_pct is not None
):
export_soc_floor_t = float(planner_floor_effective_wh)
elif soc_panel_min[t] < min_soc_wh - 1e-3:
export_soc_floor_t = float(soc_panel_min[t])
else:
export_soc_floor_t = float(arb_base_wh)
# Večerní exportní slot: podlaha jen min_soc (ne safety ramp), aby šlo vybít při z_export=1.
if (
om == "AUTO"
and t in discharge_export_slots
and (
t in evening_peak_export_ts
or t in neg_evening_push_ts
)
):
export_soc_floor_t = float(min_soc_wh)
# Safety export floor: v běžných (ne high-sell) slotech nevybít exportem energii potřebnou pro
# robustnost/noční baseload. Použije se pouze pokud je safety target v SQL vyplněný.
tgt_s = slots[t].safety_soc_target_wh if daytime_en else None
if (
tgt_s is not None
and not high_sell_slot[t]
and t not in profitable_export_ts_pre
and not (
om == "AUTO"
and t in discharge_export_slots
and t in evening_peak_export_ts
)
):
export_soc_floor_t = max(
export_soc_floor_t,
min(
float(battery.soc_max_wh),
max(min_soc_wh, float(tgt_s)),
),
)
prob += soc[t] >= export_soc_floor_t - m_soc_bigm * (1 - z_export[t])
# EV limity a připojení
for e in range(EV):
connected = (
(e == 0 and s.ev1_connected) or
(e == 1 and s.ev2_connected)
)
if not connected:
prob += ev_direct[e][t] == 0
prob += ev_via_bat[e][t] == 0
else:
prob += ev_direct[e][t] + ev_via_bat[e][t] <= vehicles[e].max_charge_power_w
for tt, cv, prev in commit_lp:
prob += cv >= prev - (bc_pv[tt] + bc_gi[tt])
if om == "SELF_SUSTAIN":
for t in range(T):
prob += gi[t] <= slots[t].load_baseline_w
elif om == "PRESERVE":
for t in range(T):
prob += bc_pv[t] == 0
prob += bc_gi[t] == 0
prob += bd[t] == 0
elif om == "CHARGE_CHEAP":
for t in range(T):
prob += ge[t] == 0
prob += ge_pv[t] == 0
prob += ge_bat[t] == 0
prob += bd[t] == 0
# Slot pre-selection (z DB fn_load_planning_slots_full → allow_*)
if om == "AUTO":
for t in range(T):
s = slots[t]
sell_t_pre = float(s.sell_price)
pv_surplus_w = max(
0,
int(s.pv_a_forecast_w)
+ int(s.pv_b_forecast_w)
- int(s.load_baseline_w),
)
pv_surplus_for_gi = pv_surplus_w
if (
t in charge_slots
and sell_t_pre < 0
and pv_surplus_for_gi > 0
and float(s.buy_price) >= 0.0
):
prob += bc_gi[t] == 0
if float(s.buy_price) < 0.0:
pass
elif (
first_neg_buy_idx is not None
and first_neg_buy_idx > 0
and t in pos_sell_pre_neg_buy_ts
):
prob += ge[t] == 0
prob += ge_pv[t] == 0
prob += ge_bat[t] == 0
elif t not in charge_slots:
if float(s.buy_price) >= 0.0:
prob += bc_gi[t] == 0
if float(s.buy_price) >= 0.0:
if pv_surplus_w <= 0:
prob += bc_pv[t] == 0
else:
prob += bc_pv[t] <= float(pv_surplus_w)
if (
t not in discharge_export_slots
and t not in neg_sell_bat_dump_slots
and t not in pre_neg_buy_discharge_ts
and t not in pre_neg_buy_empty_ts
):
prob += ge_bat[t] == 0
prob += z_export[t] == 0
for t_pne in pre_neg_pv_export_ts:
# v33: při dostatečné FVE v sell<0 okně neukládat ranní PV do baterie — export.
prob += bc_pv[t_pne] == 0
# v44: neg den — před 1. sell<0 žádné grid→bat (AM sloty za ~3 Kč vs FVE v okně).
if neg_sell_phases_en and first_neg_sell_idx is not None:
neg_day = _prague_calendar_date(slots[first_neg_sell_idx])
for t_blk in range(first_neg_sell_idx):
if _prague_calendar_date(slots[t_blk]) != neg_day:
continue
prob += bc_gi[t_blk] == 0
# Ekonomické guardy: ceny v objective nestačí proti maskám / terminal SoC.
# Referenční buy jen z ne-záporných slotů: jinak jeden buy<0 v horizontu označí
# téměř všechny sloty jako „drahé“ (gi=0 pro dům) → Infeasible (home-01).
non_negative_buys = [
float(s.buy_price) for s in slots if float(s.buy_price) >= 0.0
]
ref_buy_horizon = (
min(non_negative_buys)
if non_negative_buys
else min(float(s.buy_price) for s in slots)
)
min_spread = float(degradation_cost_effective)
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,
)
# FVE export před sell<0 jen pokud forecast v sell<0 okně pokryje dobítí (v33).
allow_pre_neg_pv_export = t in pre_neg_pv_export_ts
pv_store_val = _pv_store_value_czk_kwh(s, min_spread)
fixed_pre_neg_pv_export = (
purchase_fixed_pre
and sell_t >= 0.0
and pv_surplus_w > NIGHT_EXPORT_PV_SUNRISE_SURPLUS_W
and (
first_neg_sell_idx is None
or t < first_neg_sell_idx
)
)
fixed_block_pv_surplus_export = (
purchase_fixed_pre
and bool(getattr(grid, "block_export_on_negative_sell", False))
and sell_t >= 0.0
and pv_surplus_w > NIGHT_EXPORT_PV_SUNRISE_SURPLUS_W
)
# BA81: ge_pv≤pv_b jen při významném poli A — při úsvitu nechat Deye bez plného curtail A.
fixed_mi_low_pv_surplus_export = (
purchase_fixed_pre
and float(s.pv_b_forecast_w) > 0
and not getattr(grid, "block_export_on_negative_sell", False)
and sell_t >= 0.0
and int(s.pv_a_forecast_w) < DAWN_LOW_PV_NO_CURTAIL_W
and pv_surplus_w > 0.0
)
skip_pv_store_block = (
(
float(s.pv_b_forecast_w) > 0
and not getattr(grid, "block_export_on_negative_sell", False)
and sell_t < 0
and buy_t >= 0.0
and not purchase_fixed_pre
and (
first_neg_buy_idx is None
or t < first_neg_buy_idx
)
)
or (
# Spot: při sell>=0 neblokovat ge_pv (export vs bc_pv; večerní peak = ge_bat).
not purchase_fixed_pre
and sell_t >= 0
and pv_surplus_w > NIGHT_EXPORT_PV_SUNRISE_SURPLUS_W
)
or fixed_pre_neg_pv_export
or fixed_block_pv_surplus_export
or fixed_mi_low_pv_surplus_export
or (
purchase_fixed_pre
and fixed_horizon_min_sell_pre is not None
and sell_t >= 0.0
and pv_surplus_w > NIGHT_EXPORT_PV_SUNRISE_SURPLUS_W
and sell_t
> fixed_horizon_min_sell_pre
+ FIXED_PV_CHARGE_ONLY_NEAR_MIN_SELL_CZK_KWH
)
)
fixed_high_sell_no_pv_charge = (
purchase_fixed_pre
and fixed_horizon_min_sell_pre is not None
and sell_t >= 0.0
and pv_surplus_w > NIGHT_EXPORT_PV_SUNRISE_SURPLUS_W
and sell_t
> fixed_horizon_min_sell_pre + FIXED_PV_CHARGE_ONLY_NEAR_MIN_SELL_CZK_KWH
)
fixed_pv_b_export_cap = (
purchase_fixed_pre
and float(s.pv_b_forecast_w) > 0
and not getattr(grid, "block_export_on_negative_sell", False)
and sell_t >= 0
and not fixed_pre_neg_pv_export
and int(s.pv_a_forecast_w) >= DAWN_LOW_PV_NO_CURTAIL_W
)
if fixed_high_sell_no_pv_charge:
prob += bc_pv[t] == 0
prob += bc_gi[t] == 0
if fixed_pre_neg_pv_export:
prob += ge_pv[t] <= max(0.0, pv_surplus_w)
elif fixed_pv_b_export_cap:
if z_gen_cutoff is not None:
prob += ge_pv[t] <= float(s.pv_b_forecast_w) * (1 - z_gen_cutoff[t])
else:
prob += ge_pv[t] <= max(0.0, float(s.pv_b_forecast_w))
if (
not allow_pre_neg_pv_export
and not skip_pv_store_block
and not fixed_pv_b_export_cap
and sell_t < pv_store_val
and not (
sell_t >= 0.0
and int(s.pv_a_forecast_w) < DAWN_LOW_PV_NO_CURTAIL_W
)
and not _pv_forced_vent_export_allowed(
t,
current_soc_wh=current_soc_wh,
battery=battery,
soc_headroom_wh=soc_headroom_wh,
pv_surplus_w=pv_surplus_w,
)
):
prob += ge_pv[t] == 0
# Při `sell < 0` exportovat MAX pole B (má green bonus 7+ Kč/kWh → čistá hodnota
# i při sell=-1 = +6 Kč). Pole A green bonus nemá → export A za sell<0 je čistá ztráta.
# Constraint: ge_pv ≤ pv_b_forecast_w (pole A jde do baterie / curtail).
# Aplikuje se jen u sites bez block_export_on_negative_sell (home-01 áno; KV1 ne)
# A jen pokud reálně existuje pole B (pv_b_forecast_w > 0 — jinak by ge_pv ≤ 0
# zablokovalo legitimní pre-neg-pv export pole A z testů).
if (
sell_t < 0
and buy_t >= 0.0
and float(s.pv_b_forecast_w) > 0
and not getattr(grid, "block_export_on_negative_sell", False)
):
prob += ge_pv[t] <= float(s.pv_b_forecast_w)
# Drahý nákup: dům + TČ z baterie (ne import ze sítě); síť jen EV (+ případně TČ).
# Spot (home-01): buy > min ne-záporného buy v horizontu.
# Fixní tarif (KV1): navíc buy > charge_acquisition (konstantní buy ≈ ref).
expensive_import_slot = buy_t > ref_buy_horizon + min_spread
if fixed_tariff_like_pre:
expensive_import_slot = expensive_import_slot or (
buy_t > charge_acquisition_czk_kwh + min_spread
)
if expensive_import_slot and t not in charge_slots and buy_t >= 0.0:
# Strict: síť jen EV+TČ; baseload z baterie/FVE.
# Relaxed: síť smí baseload jen mimo night_self_consume (v46).
night_self_consume_slot = (
om == "AUTO"
and (
t in night_self_consume_discourage_ts
or t in post_evening_push_night_ts
)
)
if relaxed_expensive_import and not night_self_consume_slot:
prob += gi[t] <= ev_cap_t + hp[t] + float(s.load_baseline_w)
else:
prob += gi[t] <= ev_cap_t + hp[t]
if (not relaxed_expensive_import or night_self_consume_slot) and om == "AUTO":
prob += (
bd[t] + pv_ld[t]
>= float(s.load_baseline_w) + hp[t]
)
# 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:
t_dl = next(
(t for t, s in enumerate(slots) if s.interval_start >= session.target_deadline),
T - 1
)
prob += pulp.lpSum(
(ev_direct[e][t] + ev_via_bat[e][t]) * INTERVAL_H
for t in range(t_dl + 1)
if (e == 0 and slots[t].ev1_connected) or (e == 1 and slots[t].ev2_connected)
) >= session.energy_needed_wh
# TUV look-ahead podle tuv_usage_stats (DOW+hodina, konvence jako v DB)
if (
tuv_delta_stats
and heat_pump.rated_heating_power_w > 0
and getattr(heat_pump, "tuv_min_temp_c", 0) is not None
):
tuv_pred = float(current_tuv_temp_c)
tgt = float(getattr(heat_pump, "tuv_target_temp_c", 55.0) or 55.0)
thr = float(heat_pump.tuv_min_temp_c) + 5.0
for t in range(T):
dow, hour = _prague_dow_hour(slots[t].interval_start)
delta = tuv_delta_stats.get((dow, hour), -0.1)
tuv_pred += float(delta) * INTERVAL_H
if tuv_pred < thr:
prob += (
pulp.lpSum(hp[s] for s in range(max(0, t - 8), t + 1))
>= heat_pump.rated_heating_power_w * 0.5
)
tuv_pred = tgt
# Nouzový ohřev TUV
if current_tuv_temp_c < heat_pump.tuv_min_temp_c:
prob += hp[0] >= heat_pump.rated_heating_power_w * 0.8
# SoC bezpečnostní buffer vyhodnocený až na konci 24h horizontu
eod_idx = min(T - 1, int(24 / INTERVAL_H) - 1)
prob += soc_deficit_24h >= soc_buffer_target_wh - soc[eod_idx]
# --- Řešení (HiGHS přes highspy / PuLP API; bez externí binárky HiGHS_CMD) ---
t_start = time.monotonic()
try:
solver = pulp.getSolver(
"HiGHS", msg=False, timeLimit=SOLVER_TIME_LIMIT
)
except Exception:
logger.warning("HiGHS nedostupný, používám CBC fallback")
solver = pulp.PULP_CBC_CMD(msg=False, timeLimit=SOLVER_TIME_LIMIT)
status = prob.solve(solver)
duration_ms = int((time.monotonic() - t_start) * 1000)
if pulp.LpStatus[status] != "Optimal":
if not relaxed_expensive_import:
logger.warning(
"solve_dispatch Infeasible, retry with relaxed_expensive_import "
"(grid may supply baseload in expensive slots)"
)
return solve_dispatch(
slots,
battery,
heat_pump,
grid,
ev_sessions,
vehicles,
current_soc_wh,
current_tuv_temp_c,
tuv_delta_stats=tuv_delta_stats,
operating_mode=operating_mode,
charge_commitment_prev_w=charge_commitment_prev_w,
planner_version=planner_version,
relaxed_expensive_import=True,
evening_push_ts_override=evening_push_ts_override,
)
if not relaxed_neg_buy_charge:
logger.warning(
"solve_dispatch still Infeasible, retry without neg_buy_charge_shortfall"
)
return solve_dispatch(
slots,
battery,
heat_pump,
grid,
ev_sessions,
vehicles,
current_soc_wh,
current_tuv_temp_c,
tuv_delta_stats=tuv_delta_stats,
operating_mode=operating_mode,
charge_commitment_prev_w=charge_commitment_prev_w,
planner_version=planner_version,
relaxed_expensive_import=True,
relaxed_neg_buy_charge=True,
evening_push_ts_override=evening_push_ts_override,
)
if not relaxed_neg_prep_window:
logger.warning(
"solve_dispatch still Infeasible, retry with relaxed_neg_prep_window "
"(skip evening push/anchors and prep hold hard constraints)"
)
return solve_dispatch(
slots,
battery,
heat_pump,
grid,
ev_sessions,
vehicles,
current_soc_wh,
current_tuv_temp_c,
tuv_delta_stats=tuv_delta_stats,
operating_mode=operating_mode,
charge_commitment_prev_w=charge_commitment_prev_w,
planner_version=planner_version,
relaxed_expensive_import=True,
relaxed_neg_buy_charge=True,
relaxed_neg_prep_window=True,
neg_sell_phases_fallback=neg_sell_phases_fallback,
evening_push_ts_override=evening_push_ts_override,
)
if not neg_sell_phases_fallback:
logger.warning(
"solve_dispatch still Infeasible, retry with neg_sell phases disabled "
"(prep_soc_percent=100)"
)
battery_no_phases = SimpleNamespace(
**{
**vars(battery),
"planner_neg_sell_prep_soc_percent": 100.0,
}
)
return solve_dispatch(
slots,
battery_no_phases,
heat_pump,
grid,
ev_sessions,
vehicles,
current_soc_wh,
current_tuv_temp_c,
tuv_delta_stats=tuv_delta_stats,
operating_mode=operating_mode,
charge_commitment_prev_w=charge_commitment_prev_w,
planner_version=planner_version,
relaxed_expensive_import=True,
relaxed_neg_buy_charge=True,
relaxed_neg_prep_window=True,
neg_sell_phases_fallback=True,
evening_push_ts_override=evening_push_ts_override,
)
raise RuntimeError(f"Solver: {pulp.LpStatus[status]}")
# --- Post-processing ---
results = []
for t in range(T):
hp_raw = pulp.value(hp[t])
hp_on = hp_raw > heat_pump.rated_heating_power_w * 0.3
bc_tot = float(pulp.value(bc_pv[t]) or 0) + float(pulp.value(bc_gi[t]) or 0)
batt_w = round(bc_tot - float(pulp.value(bd[t]) or 0))
ge_bat_w = round(float(pulp.value(ge_bat[t]) or 0))
ge_pv_w = round(float(pulp.value(ge_pv[t]) or 0))
grid_w, export_mode = _dispatch_grid_setpoint_w(
gi_w=float(pulp.value(gi[t]) or 0),
ge_w=float(pulp.value(ge[t]) or 0),
ge_bat_w=float(ge_bat_w),
ge_pv_w=float(ge_pv_w),
max_export_power_w=int(grid.max_export_power_w),
)
soc_pct = round(pulp.value(soc[t]) / battery.usable_capacity_wh * 100, 1)
export_limit_w = int(grid.max_export_power_w) if grid_w < 0 else 0
# Deye: default PASSIVE (střídač pokryje load). CHARGE/SELL jen v maskovaných AUTO slotech.
deye_mode = "PASSIVE"
if om == "AUTO":
if (
slots[t].allow_discharge_export
and ge_bat_w >= GE_MIN_EXPORT_W
):
deye_mode = "SELL"
elif slots[t].allow_charge and batt_w > 0 and grid_w > 0:
deye_mode = "CHARGE"
elif batt_w < 0 and grid_w < 0:
deye_mode = "SELL"
elif batt_w > 0 and grid_w > 0:
deye_mode = "CHARGE"
deye_gen_cutoff = None
if z_gen_cutoff is not None:
deye_gen_cutoff = bool(round(float(pulp.value(z_gen_cutoff[t]) or 0)))
cashflow_czk_t = (
pulp.value(gi[t]) * slots[t].buy_price * INTERVAL_H / 1000
- pulp.value(ge[t]) * slots[t].sell_price * INTERVAL_H / 1000
)
ge_bat_value = float(pulp.value(ge_bat[t]) or 0)
battery_arbitrage_czk_t = (
ge_bat_value
* (float(slots[t].sell_price) - float(charge_acquisition_czk_kwh))
* INTERVAL_H
/ 1000.0
)
penalty_terms_t = 0.0
for _tt, _sf, _cap in peak_export_shortfall:
if _tt == t:
penalty_terms_t += (
float(pulp.value(_sf) or 0.0)
* PEAK_EXPORT_SHORTFALL_PENALTY_CZK_KWH
* INTERVAL_H
/ 1000.0
)
for _tt, _sf, _cap in pv_charge_shortfall:
if _tt == t:
penalty_terms_t += (
float(pulp.value(_sf) or 0.0)
* PV_CHARGE_SHORTFALL_PENALTY_CZK_KWH
* INTERVAL_H
/ 1000.0
)
for _tt, _sf, _cap in neg_sell_bat_dump_shortfall:
if _tt == t:
penalty_terms_t += (
float(pulp.value(_sf) or 0.0)
* NEG_SELL_BAT_DUMP_SHORTFALL_PENALTY_CZK_KWH
* INTERVAL_H
/ 1000.0
)
for _tt, _us, _tgt in neg_sell_soc_underfill:
if _tt == t:
penalty_terms_t += (
float(pulp.value(_us) or 0.0)
* NEG_SELL_SOC_UNDERFILL_PENALTY_CZK_PER_WH
)
for _tt, _us, _w in prep_soc_shortfall:
if _tt == t:
penalty_terms_t += (
float(pulp.value(_us) or 0.0)
* float(_w)
* NEG_SELL_PREP_SOC_SHORTFALL_PENALTY_CZK_PER_WH
)
sv_t = safety_vars[t]
if sv_t is not None:
penalty_terms_t += float(pulp.value(sv_t) or 0.0) * safety_pen_czk_per_wh[t]
for _tt, _cv, _prev in commit_lp:
if _tt == t:
penalty_terms_t += float(pulp.value(_cv) or 0.0) * INTERVAL_H / 1000.0 * commit_pen
penalty_terms_t += float(pulp.value(ca[t]) or 0.0) * CURTAILMENT_PENALTY
green_bonus_czk_t = float(
getattr(slots[t], "green_bonus_czk_per_slot", 0.0) or 0.0
)
cost = cashflow_czk_t
results.append(DispatchResult(
interval_start = slots[t].interval_start,
battery_setpoint_w = batt_w,
battery_soc_target = soc_pct,
grid_setpoint_w = grid_w,
export_limit_w = export_limit_w,
export_mode = export_mode,
deye_physical_mode = deye_mode,
deye_gen_cutoff_enabled = deye_gen_cutoff,
ev1_setpoint_w = round(pulp.value(ev_direct[0][t]) + pulp.value(ev_via_bat[0][t]))
if slots[t].ev1_connected else None,
ev2_setpoint_w = round(pulp.value(ev_direct[1][t]) + pulp.value(ev_via_bat[1][t]))
if slots[t].ev2_connected else None,
ev1_via_bat_w = round(pulp.value(ev_via_bat[0][t])),
ev2_via_bat_w = round(pulp.value(ev_via_bat[1][t])),
heat_pump_enabled = hp_on,
heat_pump_setpoint_w = heat_pump.rated_heating_power_w if hp_on else 0,
pv_a_curtailed_w = round(pulp.value(ca[t])),
expected_cost_czk = round(cost, 4),
effective_buy_price = slots[t].buy_price,
effective_sell_price = slots[t].sell_price,
is_predicted_price = bool(slots[t].is_predicted_price),
cashflow_czk = round(cashflow_czk_t, 4),
battery_arbitrage_czk = round(battery_arbitrage_czk_t, 4),
penalty_czk = round(penalty_terms_t, 4),
green_bonus_czk = round(green_bonus_czk_t, 4),
))
sell_rank = sorted(range(T), key=lambda i: float(slots[i].sell_price), reverse=True)[: min(3, T)]
charge_commit_snapshot = [
{
"slot": slots[tt].interval_start.isoformat(),
"previous_charge_w": prev,
"shortfall_w": float(pulp.value(cv) or 0.0),
}
for tt, cv, prev in commit_lp
]
masks_snap: list[dict[str, Any]] = []
soc_bounds_snap: list[dict[str, Any]] = []
objective_terms_snap: list[dict[str, Any]] = []
for t in range(T):
st = slots[t]
masks_snap.append(
{
"slot": st.interval_start.isoformat(),
"allow_charge": bool(st.allow_charge),
"allow_discharge_export": bool(st.allow_discharge_export),
"neg_sell_phase": neg_sell_phase_by_t[t] if neg_sell_phases_en else None,
"neg_sell_soc_target_wh": (
float(neg_sell_soc_target_by_t[t])
if neg_sell_soc_target_by_t[t] is not None
else None
),
"neg_sell_post_detach_prep": (
t in neg_sell_post_detach_prep_ts if neg_sell_phases_en else None
),
"pre_neg_pv_export": (
t in pre_neg_pv_export_ts if neg_sell_phases_en else None
),
"neg_evening_before_neg": (
t in neg_evening_push_ts if neg_sell_phases_en else None
),
"neg_evening_reserve_anchor": (
any(t == ta for ta, _ in neg_evening_reserve_anchors)
if neg_sell_phases_en
else None
),
"evening_push": (
t in evening_push_ts if om == "AUTO" else None
),
"evening_early_export_ban": (
t in evening_early_export_penalty_ts if om == "AUTO" else None
),
"night_self_consume_discourage_import": (
t in night_self_consume_discourage_ts if om == "AUTO" else None
),
}
)
tgt_s = st.safety_soc_target_wh if daytime_en else None
# Export floor pro debug snapshot (kopie logiky z constraintů výše).
if soc_panel_min[t] < min_soc_wh - 1e-3:
export_floor_wh = float(soc_panel_min[t])
export_floor_reason = "deep_relax"
else:
export_floor_wh = float(arb_base_wh)
export_floor_reason = "arb_base"
if tgt_s is not None and not high_sell_slot[t]:
export_floor_wh = max(
export_floor_wh,
min(
float(battery.soc_max_wh),
max(min_soc_wh, float(tgt_s)),
),
)
export_floor_reason = "safety_export_floor"
soc_bounds_snap.append(
{
"slot": st.interval_start.isoformat(),
"soc_min_wh": float(soc_panel_min[t]),
"arb_floor_wh": float(arb_floor_series[t]),
"soc_panel_min_wh": float(soc_panel_min[t]),
"safety_soc_target_wh": float(tgt_s) if tgt_s is not None else None,
"export_soc_floor_wh": float(export_floor_wh),
"export_floor_reason": export_floor_reason,
"high_sell_slot": bool(high_sell_slot[t]),
}
)
fb = float(st.future_avoided_buy_czk_kwh or st.buy_price)
fs = float(st.future_sell_opportunity_czk_kwh or st.sell_price)
bv = max(fb, fs) - float(degradation_cost_effective)
bv = max(0.0, min(5.0, bv))
pen_wh = bv / 1000.0 if tgt_s is not None else 0.0
sv = safety_vars[t]
sdv = float(pulp.value(sv) or 0.0) if sv is not None else None
cshort = next((float(pulp.value(cv) or 0.0) for tt, cv, _p in commit_lp if tt == t), None)
objective_terms_snap.append(
{
"slot": st.interval_start.isoformat(),
"buy_price": float(st.buy_price),
"sell_price": float(st.sell_price),
"future_avoided_buy_czk_kwh": float(st.future_avoided_buy_czk_kwh or st.buy_price),
"future_sell_opportunity_czk_kwh": float(
st.future_sell_opportunity_czk_kwh or st.sell_price
),
"battery_value_czk_kwh": float(bv),
"safety_deficit_penalty_czk_per_wh": float(pen_wh) if safety_active[t] else 0.0,
"safety_penalty_active": bool(safety_active[t]),
"safety_deficit_wh": sdv,
"commitment_shortfall_w": cshort,
"commitment_penalty_czk_kwh": float(commit_pen) if cshort is not None else None,
"acquisition_used_czk_kwh": float(charge_acquisition_czk_kwh),
"grid_charge_suppressed_reason": getattr(
st, "grid_charge_suppressed_reason", None
),
"pv_charge_wh_ahead": float(
getattr(st, "pv_charge_wh_ahead", 0.0) or 0.0
),
"min_buy_before_cutoff_czk_kwh": (
float(st.min_buy_before_cutoff_czk_kwh)
if getattr(st, "min_buy_before_cutoff_czk_kwh", None) is not None
else None
),
}
)
night0 = slots[0]
solver_snapshot: dict[str, Any] = {
"version": 1,
"planner_build_tag": PLANNER_BUILD_TAG,
"inputs": {
"current_soc_wh": float(current_soc_wh),
"observed_soc_wh": float(observed_soc_wh),
"soc_headroom_applied_wh": soc_headroom_applied_wh,
"operating_mode": operating_mode,
"planner_version": planner_version_resolved,
"battery": {
"usable_capacity_wh": float(battery.usable_capacity_wh),
"min_soc_wh": float(battery.min_soc_wh),
"reserve_soc_wh": float(getattr(battery, "reserve_soc_wh", 0.0)),
"degradation_cost_czk_kwh": float(battery.degradation_cost_czk_kwh),
"planner_terminal_soc_value_factor": float(battery.planner_terminal_soc_value_factor),
"planner_daytime_charge_target_enabled": daytime_en,
"planner_charge_commitment_penalty_czk_kwh": float(commit_pen),
"planner_neg_sell_prep_soc_percent": float(
getattr(battery, "planner_neg_sell_prep_soc_percent", 80.0)
),
"planner_neg_sell_full_soc_tail_slots": int(
getattr(battery, "planner_neg_sell_full_soc_tail_slots", 4)
),
"planner_neg_sell_vent_min_sell_czk_kwh": getattr(
battery, "planner_neg_sell_vent_min_sell_czk_kwh", None
),
},
"neg_sell_phases_enabled": bool(neg_sell_phases_en),
"neg_sell_b_ramp_v35": bool(neg_sell_phases_en),
"neg_sell_day_meta": neg_sell_day_meta if neg_sell_phases_en else None,
"t_detach_idx": (
neg_sell_day_meta.get("t_detach_idx") if neg_sell_phases_en else None
),
"e_surplus_after_t_wh": (
neg_sell_day_meta.get("e_surplus_after_t_wh")
if neg_sell_phases_en
else None
),
"neg_sell_day_pv_b_usable_wh": (
_neg_sell_day_pv_b_usable_wh(slots, first_neg_sell_idx, battery)
if first_neg_sell_idx is not None and neg_sell_phases_en
else None
),
"pre_neg_pv_export_forecast_ok": bool(pre_neg_pv_export_forecast_ok),
"pre_neg_cushion_by_day": pre_neg_cushion_by_day or None,
"pre_neg_pv_export_slots": [
slots[i].interval_start.isoformat() for i in sorted(pre_neg_pv_export_ts)
],
"neg_evening_before_neg_slots": [
slots[i].interval_start.isoformat()
for i in sorted(neg_evening_before_neg_ts)
],
"neg_evening_push_slots": [
slots[i].interval_start.isoformat()
for i in sorted(neg_evening_push_ts)
],
"neg_evening_export_budget_wh": (
float(neg_evening_export_budget_wh)
if neg_evening_export_budget_wh is not None
else None
),
"neg_evening_reserve_soc_anchors": [
{
"slot": slots[t_a].interval_start.isoformat(),
"target_reserve_soc_wh": float(tgt_wh),
}
for t_a, tgt_wh in neg_evening_reserve_anchors
],
"neg_sell_prep_window_v36": bool(neg_sell_phases_en),
"neg_sell_day_pv_usable_wh": (
_neg_sell_day_pv_usable_wh(
slots,
first_neg_sell_idx,
max_charge_power_w=float(battery.max_charge_power_w),
charge_efficiency=float(battery.charge_efficiency),
)
if first_neg_sell_idx is not None
else None
),
"load_first_enabled": om == "AUTO",
"relaxed_expensive_import": relaxed_expensive_import,
"relaxed_neg_buy_charge": relaxed_neg_buy_charge,
"relaxed_neg_prep_window": relaxed_neg_prep_window,
"neg_sell_phases_fallback": neg_sell_phases_fallback,
"charge_acquisition_buy_czk_kwh": charge_acquisition_czk_kwh,
"charge_acquisition_cutoff_at": (
slots[0].charge_acquisition_cutoff_at.isoformat()
if slots[0].charge_acquisition_cutoff_at is not None
else None
),
"evening_push_ts": [
slots[i].interval_start.isoformat() for i in sorted(evening_push_ts)
],
"evening_push_peak_sell_czk_kwh": (
_evening_push_peak_sell_czk(slots, evening_push_ts)
if evening_push_ts
else _evening_night_peak_sell_czk(slots)
),
"evening_push_hysteresis_retained": bool(evening_push_hysteresis_retained),
"evening_push_override_dropped_on_retry": bool(
evening_push_ts_override is not None and push_override_raw is None
),
"evening_push_override_filtered_empty": bool(
push_override_raw and not push_override_eff
),
"evening_push_hard_suppressed": bool(evening_push_hard_suppressed),
"evening_push_peak_fallback_used": bool(
om == "AUTO"
and not computed_evening_push_ts
and bool(evening_push_ts)
and not push_override_eff
),
"fixed_horizon_min_sell_czk_kwh": fixed_horizon_min_sell_pre,
"fixed_pv_charge_near_min_sell_margin_czk_kwh": (
FIXED_PV_CHARGE_ONLY_NEAR_MIN_SELL_CZK_KWH if purchase_fixed_pre else None
),
"charge_commitment_ignored_on_relaxed": bool(
commitment_for_solve is None and charge_commitment_prev_w is not None
),
"morning_pre_neg_export_hard": bool(
om == "AUTO" and not any_relaxed and bool(morning_pre_neg_export_ts)
),
"any_relaxed_solve": bool(any_relaxed),
"kv1_evening_push_morning_peak_rule": _kv1_block_export_fixed_evening_push(
grid,
purchase_fixed=purchase_fixed_pre,
),
"night_self_consume_discourage_ts": [
slots[i].interval_start.isoformat()
for i in sorted(night_self_consume_discourage_ts)
],
},
"masks": masks_snap,
"soc_bounds": soc_bounds_snap,
"objective_terms": objective_terms_snap,
"chosen_slots": {
"charge_commitment": charge_commit_snapshot,
"high_sell_windows": [slots[i].interval_start.isoformat() for i in sell_rank],
"night_window": {
"definition": "Europe/Prague 20:0006:00 projected baseload Wh (fn_load_planning_slots_full)",
"target_wh": night0.night_baseload_target_wh,
"buffer_wh": night0.night_baseload_buffer_wh,
},
},
}
return results, duration_ms, solver_snapshot
# ============================================================
# Denní plán (15:00)
# ============================================================
async def run_daily_plan(
site_id: int,
db,
triggered_by: str = "scheduler:daily",
*,
planner_version: str | None = None,
) -> tuple[int, int]:
"""
Hlavní denní plánování. Spouštět v 15:00 po importu cen (14:00)
a aktualizaci forecastu (14:30).
Horizont: `ems.fn_planning_horizon_end` (OTE, strop a práh v SQL).
"""
now = datetime.now(timezone.utc)
horizon_from = _current_slot_start(now)
horizon_to = await _planning_horizon_end(site_id, horizon_from, db)
if horizon_to is None:
horizon_to = horizon_from + timedelta(hours=_DAILY_FALLBACK_HORIZON_HOURS)
logger.warning(
"[site=%s] Daily plan: fn_planning_horizon_end NULL, fallback %.1fh",
site_id,
_DAILY_FALLBACK_HORIZON_HOURS,
)
logger.info(f"[site={site_id}] Daily plan: {horizon_from}{horizon_to}")
battery, hp, grid, vehicles, ev_sessions, soc_wh, tuv_temp, operating_mode, tuv_stats = (
await _load_site_context(site_id, db)
)
planner_version_resolved = _planner_engine_version(planner_version)
slots = await _load_slots(site_id, horizon_from, horizon_to, db, soc_wh=soc_wh)
om = operating_mode or "AUTO"
if om == "AUTO":
results, duration_ms, solver_snapshot = solve_dispatch_two_pass(
slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp,
tuv_delta_stats=tuv_stats,
operating_mode=om,
planner_version=planner_version_resolved,
)
else:
results, duration_ms, solver_snapshot = solve_dispatch(
slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp,
tuv_delta_stats=tuv_stats,
operating_mode=om,
planner_version=planner_version_resolved,
)
comparison_ctx = _maybe_add_planner_comparison(
slots=slots,
battery=battery,
heat_pump=hp,
grid=grid,
ev_sessions=ev_sessions,
vehicles=vehicles,
current_soc_wh=soc_wh,
current_tuv_temp_c=tuv_temp,
operating_mode=om,
tuv_delta_stats=tuv_stats,
active_version=planner_version_resolved,
)
if comparison_ctx is not None:
peer_results = comparison_ctx["peer_results"]
peer_ms = comparison_ctx["peer_ms"]
peer_snapshot = comparison_ctx["peer_snapshot"]
solver_snapshot["comparison"] = _dispatch_result_comparison(
results,
duration_ms,
planner_version_resolved,
peer_results,
peer_ms,
comparison_ctx["peer_version"],
)
slot_inputs = _build_slot_inputs(slots, slots)
run_id = await _save_planning_run(
site_id,
results,
horizon_from,
horizon_to,
run_type="daily",
triggered_by=triggered_by,
replan_from=None,
soc_wh=soc_wh,
duration_ms=duration_ms,
correction=1.0,
db=db,
slot_inputs=slot_inputs,
solver_snapshot=solver_snapshot,
)
if comparison_ctx is not None:
compare_snapshot = dict(peer_snapshot)
compare_snapshot["comparison_of_run_id"] = run_id
compare_snapshot["compare_peer_version"] = comparison_ctx["peer_version"]
await _save_planning_run(
site_id,
comparison_ctx["peer_results"],
horizon_from,
horizon_to,
run_type="daily",
triggered_by=f"{triggered_by}:compare",
replan_from=None,
soc_wh=soc_wh,
duration_ms=comparison_ctx["peer_ms"],
correction=1.0,
db=db,
slot_inputs=slot_inputs,
activate_run=False,
solver_snapshot=compare_snapshot,
)
logger.info(f"[site={site_id}] Daily plan done in {duration_ms} ms")
return run_id, duration_ms
# ============================================================
# Rolling replan (každých 15min)
# ============================================================
async def run_rolling_replan(
site_id: int,
db,
*,
triggered_by: str = "scheduler:rolling",
allow_skip: bool = True,
planner_version: str | None = None,
) -> tuple[Optional[int], Optional[int]]:
"""
Rolling replan každých 15 minut.
1. Zjistí aktuální SoC baterie z telemetrie
2. Spočítá korekční faktor FVE forecastu z poslední hodiny
3. Aplikuje korekci na forecast zbytku dne (s útlumem)
4. Spustí solver pro zbývající horizont aktivního plánu
5. Uloží jako nový planning_run (aktivní plán se stane superseded)
Pokud allow_skip=True (scheduler) a horizont je vyčerpaný → vrátí (None, None).
Pokud allow_skip=False (API) → spustí denní plán jako náhradu.
"""
now = datetime.now(timezone.utc)
replan_from = _current_slot_start(now)
planner_version_resolved = _planner_engine_version(planner_version)
ar_raw = await db.fetchval(
"select ems.fn_planning_active_run($1::int)",
site_id,
)
ar = ar_raw if isinstance(ar_raw, dict) else json.loads(ar_raw)
if ar.get("error") == "no_active_plan":
logger.warning(f"[site={site_id}] Rolling replan: no active plan, triggering daily plan")
return await run_daily_plan(
site_id,
db,
triggered_by=triggered_by,
planner_version=planner_version_resolved,
)
horizon_to = await _planning_horizon_end(site_id, replan_from, db)
if horizon_to is None:
if allow_skip:
logger.info(
"[site=%s] Rolling replan: fn_planning_horizon_end NULL (krátký OTE horizont), skipping",
site_id,
)
return None, None
logger.warning(
"[site=%s] Rolling replan: fn_planning_horizon_end NULL, running daily plan",
site_id,
)
return await run_daily_plan(
site_id,
db,
triggered_by=triggered_by,
planner_version=planner_version_resolved,
)
if (horizon_to - replan_from).total_seconds() < 1800:
if allow_skip:
logger.info(f"[site={site_id}] Rolling replan: horizon almost exhausted, skipping")
return None, None
logger.info(f"[site={site_id}] Rolling replan: horizon exhausted, running daily plan")
return await run_daily_plan(
site_id,
db,
triggered_by=triggered_by,
planner_version=planner_version_resolved,
)
logger.info(
"[site=%s] Rolling replan from %s%s (tag=%s)",
site_id,
replan_from,
horizon_to,
PLANNER_BUILD_TAG,
)
battery, hp, grid, vehicles, ev_sessions, soc_wh, tuv_temp, operating_mode, tuv_stats = (
await _load_site_context(site_id, db)
)
if operating_mode != "AUTO":
logger.info(
"[site=%s] Rolling replan skipped: operating_mode=%s (not AUTO)",
site_id,
operating_mode,
)
return None, None
slots = await _load_slots(site_id, replan_from, horizon_to, db, soc_wh=soc_wh)
# PV forecast korekce je kanonicky v DB (delta + rolling faktor + decay) a do LP vstupuje přes
# ems.fn_load_planning_slots_full. Pro audit/debug ale chceme ukládat i RAW (bez korekcí).
correction_factor, correction_log = 1.0, {
"window_start": None,
"window_end": None,
"actual_pv_wh": None,
"forecast_pv_wh": None,
"correction_factor": None,
"reason": "canonical_db",
}
# RAW PV pro slot_inputs: přímý součet nejnovějších forecast_pv_interval per array/slot (bez delta/rolling).
raw_pv_rows = await db.fetchval(
"select ems.fn_forecast_pv_slots_range_raw_ab($1::int, $2::timestamptz, $3::timestamptz)",
site_id,
replan_from,
horizon_to,
)
raw_pv = raw_pv_rows if isinstance(raw_pv_rows, list) else json.loads(raw_pv_rows)
raw_by_ts: dict[str, tuple[int, int]] = {}
if isinstance(raw_pv, list):
for r in raw_pv:
if not isinstance(r, dict):
continue
ts = r.get("interval_start")
if isinstance(ts, str):
raw_by_ts[ts] = (
int(r.get("pv_a_forecast_raw_w") or 0),
int(r.get("pv_b_forecast_raw_w") or 0),
)
slots_raw_pv: list[PlanningSlot] = []
for s in slots:
key = s.interval_start.isoformat()
pva, pvb = raw_by_ts.get(key, (s.pv_a_forecast_w, s.pv_b_forecast_w))
slots_raw_pv.append(replace(s, pv_a_forecast_w=pva, pv_b_forecast_w=pvb))
commitment_prev = await _load_previous_plan_charge_commitment_prev_w(site_id, slots, db)
evening_push_override = await _rolling_evening_push_override(
site_id, slots, battery, soc_wh, db
)
om = operating_mode or "AUTO"
if om == "AUTO":
results, duration_ms, solver_snapshot = solve_dispatch_two_pass(
slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp,
tuv_delta_stats=tuv_stats,
operating_mode=om,
charge_commitment_prev_w=commitment_prev,
planner_version=planner_version_resolved,
evening_push_ts_override=evening_push_override,
)
else:
results, duration_ms, solver_snapshot = solve_dispatch(
slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp,
tuv_delta_stats=tuv_stats,
operating_mode=om,
charge_commitment_prev_w=commitment_prev,
planner_version=planner_version_resolved,
)
comparison_ctx = _maybe_add_planner_comparison(
slots=slots,
battery=battery,
heat_pump=hp,
grid=grid,
ev_sessions=ev_sessions,
vehicles=vehicles,
current_soc_wh=soc_wh,
current_tuv_temp_c=tuv_temp,
operating_mode=om,
tuv_delta_stats=tuv_stats,
active_version=planner_version_resolved,
charge_commitment_prev_w=commitment_prev,
)
if comparison_ctx is not None:
peer_results = comparison_ctx["peer_results"]
peer_ms = comparison_ctx["peer_ms"]
solver_snapshot["comparison"] = _dispatch_result_comparison(
results,
duration_ms,
planner_version_resolved,
peer_results,
peer_ms,
comparison_ctx["peer_version"],
)
slot_inputs = _build_slot_inputs(slots_raw_pv, slots)
run_id = await _save_planning_run(
site_id,
results,
replan_from,
horizon_to,
run_type="rolling",
triggered_by=triggered_by,
replan_from=replan_from,
soc_wh=soc_wh,
duration_ms=duration_ms,
correction=correction_factor,
db=db,
slot_inputs=slot_inputs,
solver_snapshot=solver_snapshot,
)
if comparison_ctx is not None:
compare_snapshot = dict(comparison_ctx["peer_snapshot"])
compare_snapshot["comparison_of_run_id"] = run_id
compare_snapshot["compare_peer_version"] = comparison_ctx["peer_version"]
await _save_planning_run(
site_id,
comparison_ctx["peer_results"],
replan_from,
horizon_to,
run_type="rolling",
triggered_by=f"{triggered_by}:compare",
replan_from=replan_from,
soc_wh=soc_wh,
duration_ms=comparison_ctx["peer_ms"],
correction=correction_factor,
db=db,
slot_inputs=slot_inputs,
activate_run=False,
solver_snapshot=compare_snapshot,
)
# Historický log rolling korekce: dřív se psal z Pythonu. Nově se rolling faktor počítá v DB
# v kanonické PV řadě; log se případně přesune do DB (todo).
logger.info(f"[site={site_id}] Rolling replan done in {duration_ms} ms (pv=canonical_db)")
return run_id, duration_ms
async def run_plan_api(
site_id: int,
plan_type: str,
db,
*,
triggered_by: str = "api",
planner_version: str | None = None,
) -> tuple[int, int]:
"""Ruční / UI spuštění plánu. Vždy vrátí (run_id, solver_duration_ms)."""
pt = plan_type.lower().strip()
planner_version_resolved = _planner_engine_version(planner_version)
if pt == "daily":
return await run_daily_plan(
site_id,
db,
triggered_by=triggered_by,
planner_version=planner_version_resolved,
)
if pt == "rolling":
rid, ms = await run_rolling_replan(
site_id,
db,
triggered_by=triggered_by,
allow_skip=False,
planner_version=planner_version_resolved,
)
if rid is None or ms is None:
raise RuntimeError("Rolling replan did not return a run")
return rid, ms
raise ValueError(f"Unknown plan_type: {plan_type!r} (use daily or rolling)")
# ============================================================
# Pomocné funkce
# ============================================================
def _current_slot_start(dt: datetime) -> datetime:
"""Zaokrouhlí čas dolů na začátek aktuálního 15min slotu."""
minute = (dt.minute // 15) * 15
return dt.replace(minute=minute, second=0, microsecond=0)
def _parse_json_dt(val: object) -> Optional[datetime]:
if val is None:
return None
if isinstance(val, datetime):
return val if val.tzinfo else val.replace(tzinfo=timezone.utc)
return datetime.fromisoformat(str(val).replace("Z", "+00:00"))
def _ev_session_from_json(obj: object) -> Optional[SimpleNamespace]:
if obj is None or obj == []:
return None
if isinstance(obj, str):
obj = json.loads(obj)
if not isinstance(obj, dict):
return None
td = _parse_json_dt(obj.get("target_deadline"))
if td is None:
return None
return SimpleNamespace(
target_deadline=td,
energy_needed_wh=float(obj["energy_needed_wh"]),
)
async def _load_site_context(site_id: int, db):
"""
Načte baterii, TČ, síť, 2× vozidlo, otevřené EV session, SoC, TUV, režim a TUV statistiky (SQL).
"""
raw = await db.fetchval(
"select ems.fn_planning_site_context($1::int)",
site_id,
)
ctx = raw if isinstance(raw, dict) else json.loads(raw)
if ctx.get("error") == "unknown_site":
raise RuntimeError(f"Site not found: {site_id}")
b = ctx["battery"]
ec_i = int(b["max_charge_power_w"])
ed_i = int(b["max_discharge_power_w"])
planner_soc_max = float(b.get("planner_soc_max_wh", b["soc_max_wh"]))
floor_pct = b.get("planner_discharge_floor_percent")
buy_thr = b.get("planner_extreme_buy_threshold_czk_kwh")
relax_prewin = b.get("planner_discharge_relax_prewindow_slots")
battery = SimpleNamespace(
usable_capacity_wh=float(b["usable_capacity_wh"]),
min_soc_wh=float(b["min_soc_wh"]),
arb_floor_wh=float(b["arb_floor_wh"]),
reserve_soc_wh=float(b["reserve_soc_wh"]),
soc_max_wh=planner_soc_max,
charge_efficiency=float(b["charge_efficiency"]),
discharge_efficiency=float(b["discharge_efficiency"]),
degradation_cost_czk_kwh=float(b["degradation_cost_czk_kwh"]),
max_charge_power_w=ec_i,
max_discharge_power_w=ed_i,
charge_slot_buffer=float(b["charge_slot_buffer"])
if b.get("charge_slot_buffer") is not None
else 0,
discharge_slot_buffer=float(b["discharge_slot_buffer"])
if b.get("discharge_slot_buffer") is not None
else 0,
planner_extreme_buy_threshold_czk_kwh=float(buy_thr) if buy_thr is not None else -5.0,
planner_discharge_floor_percent=float(floor_pct) if floor_pct is not None else None,
planner_discharge_relax_prewindow_slots=int(relax_prewin)
if relax_prewin is not None
else DEFAULT_PLANNER_DISCHARGE_RELAX_PREWINDOW_SLOTS,
planner_terminal_soc_value_factor=float(b["planner_terminal_soc_value_factor"]),
planner_daytime_charge_target_enabled=bool(
b.get("planner_daytime_charge_target_enabled", True)
),
planner_night_baseload_buffer_percent=float(
b.get("planner_night_baseload_buffer_percent") or 20.0
),
planner_daytime_charge_price_quantile=float(
b.get("planner_daytime_charge_price_quantile") or 0.70
),
planner_charge_commitment_penalty_czk_kwh=float(
b.get("planner_charge_commitment_penalty_czk_kwh") or 0.20
),
planner_neg_sell_prep_soc_percent=float(
b.get("planner_neg_sell_prep_soc_percent") or 80.0
),
planner_neg_sell_full_soc_tail_slots=int(
b.get("planner_neg_sell_full_soc_tail_slots") or 4
),
planner_neg_sell_vent_min_sell_czk_kwh=(
float(b["planner_neg_sell_vent_min_sell_czk_kwh"])
if b.get("planner_neg_sell_vent_min_sell_czk_kwh") is not None
else None
),
)
hpj = ctx["heat_pump"]
heat_pump = SimpleNamespace(
rated_heating_power_w=int(hpj["rated_heating_power_w"]),
tuv_min_temp_c=float(hpj["tuv_min_temp_c"]),
tuv_target_temp_c=float(hpj["tuv_target_temp_c"]),
)
g = ctx["grid"]
m = ctx.get("market") or {}
grid = SimpleNamespace(
max_import_power_w=int(g["max_import_power_w"]),
max_export_power_w=int(g["max_export_power_w"]),
block_export_on_negative_sell=bool(g.get("block_export_on_negative_sell") or False),
deye_gen_microinverter_cutoff_enabled=bool(g.get("deye_gen_microinverter_cutoff_enabled") or False),
purchase_pricing_mode=str(m.get("purchase_pricing_mode") or "spot").strip().lower(),
sale_pricing_mode=str(m.get("sale_pricing_mode") or "spot").strip().lower(),
)
vehicles: list[SimpleNamespace] = []
for v in ctx.get("vehicles") or []:
vehicles.append(
SimpleNamespace(
max_charge_power_w=int(v["max_charge_power_w"]),
battery_capacity_kwh=float(v["battery_capacity_kwh"]),
default_target_soc_pct=float(v["default_target_soc_pct"]),
)
)
while len(vehicles) < 2:
vehicles.append(
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
)
)
ev_raw = ctx.get("ev_sessions") or []
ev_sessions = [
_ev_session_from_json(ev_raw[0]) if len(ev_raw) > 0 else None,
_ev_session_from_json(ev_raw[1]) if len(ev_raw) > 1 else None,
]
soc_wh = float(ctx["soc_wh"])
tuv_temp = float(ctx["tuv_temp"])
operating_mode = ctx.get("operating_mode")
tuv_stats: dict[tuple[int, int], float] = {}
for row in ctx.get("tuv_delta_stats") or []:
tuv_stats[(int(row["dow"]), int(row["hour"]))] = float(row["delta"])
return (
battery,
heat_pump,
grid,
vehicles,
ev_sessions,
soc_wh,
tuv_temp,
operating_mode,
tuv_stats,
)
async def _rolling_evening_push_override(
site_id: int,
slots: list[PlanningSlot],
battery,
current_soc_wh: float,
db,
) -> set[int] | None:
"""Rolling: držet evening_push_ts z aktivního runu při malé změně peak sell / SoC."""
if not slots:
return None
row = await db.fetchrow(
"""
select solver_params
from ems.planning_run
where site_id = $1::int
and status = 'active'
limit 1
""",
site_id,
)
if row is None or row["solver_params"] is None:
return None
sp = row["solver_params"]
if isinstance(sp, str):
sp = json.loads(sp)
if not isinstance(sp, dict):
return None
inputs = sp.get("inputs")
if not isinstance(inputs, dict):
return None
prev_iso = inputs.get("evening_push_ts")
if not isinstance(prev_iso, list) or not prev_iso:
return None
prev_push = _evening_push_ts_from_iso(slots, [str(x) for x in prev_iso])
if not prev_push:
return None
budget_eligible = {
t
for seg in _evening_push_soc_budget_calendar_segments(slots, None)
for t in seg
}
if budget_eligible:
prev_push = {t for t in prev_push if t in budget_eligible}
if not prev_push:
return None
prev_peak = inputs.get("evening_push_peak_sell_czk_kwh")
prev_soc = inputs.get("current_soc_wh")
new_peak = _evening_night_peak_sell_czk(slots)
if not _evening_push_hysteresis_active(
prev_peak_sell_czk=float(prev_peak) if prev_peak is not None else None,
new_peak_sell_czk=new_peak,
prev_soc_wh=float(prev_soc) if prev_soc is not None else None,
current_soc_wh=float(current_soc_wh),
usable_capacity_wh=float(battery.usable_capacity_wh),
):
return None
logger.info(
"[site=%s] evening_push hysteresis: retaining %d slot(s), peak_sell=%.3f",
site_id,
len(prev_push),
new_peak,
)
return prev_push
async def _load_previous_plan_charge_commitment_prev_w(
site_id: int,
slots: list[PlanningSlot],
db,
) -> list[Optional[float]]:
"""
Pro rolling replan: z aktivního plánu načte battery_setpoint_w pro shodné sloty.
Kotva měkkého commitmentu jen když předchozí plán chtěl nabíjet z PV přebytku (viz podmínky).
"""
if not slots:
return []
rows = await db.fetch(
"""
select pi.interval_start,
pi.battery_setpoint_w,
pi.grid_setpoint_w,
coalesce(pi.pv_a_forecast_solver_w, 0) as pva,
coalesce(pi.pv_b_forecast_solver_w, 0) as pvb,
coalesce(pi.load_baseline_w, 0) as lb
from ems.planning_interval pi
inner join ems.planning_run pr on pr.id = pi.run_id
where pr.site_id = $1::int
and pr.status = 'active'
""",
site_id,
)
by_start = {r["interval_start"]: r for r in rows}
out: list[Optional[float]] = []
for s in slots:
r = by_start.get(s.interval_start)
if r is None:
out.append(None)
continue
bw = int(r["battery_setpoint_w"] or 0)
gw = int(r["grid_setpoint_w"] or 0)
pva = int(r["pva"] or 0)
pvb = int(r["pvb"] or 0)
lb = int(r["lb"] or 0)
# Commitment má kotvit jen „nabíjení z PV přebytku“, ne situace kdy plán současně
# výrazně exportuje do sítě (typicky charge while exporting). To by stabilizovalo špatný cyklus.
if bw > 500 and (pva + pvb) > lb and gw <= 0 and gw >= -500:
out.append(float(bw))
else:
out.append(None)
return out
async def _load_slots(
site_id: int,
from_dt: datetime,
to_dt: datetime,
db,
*,
soc_wh: float,
) -> list[PlanningSlot]:
"""15min sloty z ems.fn_load_planning_slots_full."""
rows = await db.fetch(
"""
select slot_ord, interval_start, buy_price, sell_price, is_predicted_price,
pv_a_forecast_w, pv_b_forecast_w, load_baseline_w,
ev1_connected, ev2_connected, allow_charge, allow_discharge_export,
night_baseload_target_wh, night_baseload_buffer_wh, safety_soc_target_wh,
future_avoided_buy_czk_kwh, future_sell_opportunity_czk_kwh,
is_daytime_pv_surplus_slot,
charge_acquisition_buy_czk_kwh, charge_acquisition_cutoff_at,
min_buy_before_cutoff_czk_kwh, pv_charge_wh_ahead, neg_buy_wh_ahead,
grid_charge_suppressed_reason
from ems.fn_load_planning_slots_full(
$1::int, $2::timestamptz, $3::timestamptz, $4::numeric
)
""",
site_id,
from_dt,
to_dt,
soc_wh,
)
out: list[PlanningSlot] = []
for r in rows:
d = dict(r)
out.append(
PlanningSlot(
interval_start=d["interval_start"],
buy_price=float(d["buy_price"]),
sell_price=float(d["sell_price"]),
pv_a_forecast_w=int(d["pv_a_forecast_w"] or 0),
pv_b_forecast_w=int(d["pv_b_forecast_w"] or 0),
load_baseline_w=int(d["load_baseline_w"] or 0),
ev1_connected=bool(d["ev1_connected"]),
ev2_connected=bool(d["ev2_connected"]),
is_predicted_price=bool(d.get("is_predicted_price")),
allow_charge=bool(d.get("allow_charge", True)),
allow_discharge_export=bool(d.get("allow_discharge_export", True)),
night_baseload_target_wh=_slot_float_nullable(d, "night_baseload_target_wh"),
night_baseload_buffer_wh=_slot_float_nullable(d, "night_baseload_buffer_wh"),
safety_soc_target_wh=_slot_float_nullable(d, "safety_soc_target_wh"),
future_avoided_buy_czk_kwh=_slot_float_nullable(d, "future_avoided_buy_czk_kwh"),
future_sell_opportunity_czk_kwh=_slot_float_nullable(
d, "future_sell_opportunity_czk_kwh"
),
is_daytime_pv_surplus_slot=bool(d.get("is_daytime_pv_surplus_slot", False)),
charge_acquisition_buy_czk_kwh=_slot_float_nullable(
d, "charge_acquisition_buy_czk_kwh"
),
charge_acquisition_cutoff_at=d.get("charge_acquisition_cutoff_at"),
min_buy_before_cutoff_czk_kwh=_slot_float_nullable(
d, "min_buy_before_cutoff_czk_kwh"
),
pv_charge_wh_ahead=_slot_float_nullable(d, "pv_charge_wh_ahead"),
neg_buy_wh_ahead=_slot_float_nullable(d, "neg_buy_wh_ahead"),
grid_charge_suppressed_reason=d.get("grid_charge_suppressed_reason"),
)
)
if not out:
raise RuntimeError(
"No planning slots available check market prices and horizon settings"
)
if any(s.is_predicted_price for s in out):
logger.warning(
"[site=%s] Unexpected predicted-price slots in planning horizon",
site_id,
)
return out
def _build_slot_inputs(
slots_raw_pv: list[PlanningSlot],
slots_solver: list[PlanningSlot],
) -> list[tuple[int, int, int, int, int]]:
"""(load_baseline_w, pv_a_raw, pv_b_raw, pv_a_solver, pv_b_solver) pro každý slot."""
if len(slots_raw_pv) != len(slots_solver):
raise ValueError("slots_raw_pv and slots_solver length mismatch")
out: list[tuple[int, int, int, int, int]] = []
for raw, sol in zip(slots_raw_pv, slots_solver):
out.append(
(
int(raw.load_baseline_w),
int(raw.pv_a_forecast_w),
int(raw.pv_b_forecast_w),
int(sol.pv_a_forecast_w),
int(sol.pv_b_forecast_w),
)
)
return out
async def _save_planning_run(
site_id, results, horizon_from, horizon_to,
run_type, triggered_by, replan_from,
soc_wh, duration_ms, correction, db,
slot_inputs: Optional[list[tuple[int, int, int, int, int]]] = None,
*,
activate_run: bool = True,
solver_snapshot: Optional[dict[str, Any]] = None,
) -> int:
"""Uloží výsledky solveru přes ems.fn_planning_run_commit."""
if slot_inputs is not None and len(slot_inputs) != len(results):
raise ValueError("slot_inputs and results length mismatch")
run_meta: dict[str, Any] = {
"run_type": run_type,
"triggered_by": triggered_by,
"replan_from": replan_from.isoformat() if replan_from else None,
"soc_at_replan_wh": soc_wh,
"solver_duration_ms": duration_ms,
"forecast_correction_factor": correction,
}
if solver_snapshot is not None:
run_meta["solver_params"] = solver_snapshot
intervals: list[dict] = []
for i, r in enumerate(results):
row: dict = {
"interval_start": r.interval_start.isoformat()
if hasattr(r.interval_start, "isoformat")
else r.interval_start,
"battery_setpoint_w": r.battery_setpoint_w,
"battery_soc_target_pct": r.battery_soc_target,
"grid_setpoint_w": r.grid_setpoint_w,
"export_limit_w": r.export_limit_w,
"export_mode": r.export_mode,
"deye_physical_mode": r.deye_physical_mode,
"deye_gen_cutoff_enabled": r.deye_gen_cutoff_enabled,
"ev1_setpoint_w": r.ev1_setpoint_w,
"ev2_setpoint_w": r.ev2_setpoint_w,
"ev1_via_bat_w": r.ev1_via_bat_w,
"ev2_via_bat_w": r.ev2_via_bat_w,
"heat_pump_enabled": r.heat_pump_enabled,
"heat_pump_setpoint_w": r.heat_pump_setpoint_w,
"pv_a_curtailed_w": r.pv_a_curtailed_w,
"expected_cost_czk": float(r.expected_cost_czk),
"cashflow_czk": float(r.cashflow_czk),
"battery_arbitrage_czk": float(r.battery_arbitrage_czk),
"penalty_czk": float(r.penalty_czk),
"green_bonus_czk": float(r.green_bonus_czk),
"effective_buy_price": float(r.effective_buy_price),
"effective_sell_price": float(r.effective_sell_price),
"is_predicted_price": r.is_predicted_price,
}
if slot_inputs is not None:
si = slot_inputs[i]
row["load_baseline_w"] = si[0]
row["pv_a_forecast_raw_w"] = si[1]
row["pv_b_forecast_raw_w"] = si[2]
row["pv_a_forecast_solver_w"] = si[3]
row["pv_b_forecast_solver_w"] = si[4]
intervals.append(row)
return int(
await db.fetchval(
"""
select ems.fn_planning_run_commit(
$1::int, $2::timestamptz, $3::timestamptz,
$4::jsonb, $5::jsonb, $6::boolean
)
""",
site_id,
horizon_from,
horizon_to,
json.dumps(run_meta, default=str),
json.dumps(intervals, default=str),
activate_run,
)
)