Restore _evening_peak_export_indices filter so push slots are chosen from profitable peak-band nights, then ranked by sell until the Wh budget is exhausted—not all profitable night slots and not a fixed top-3. Docs and tests match v39 SoC balance tag. Co-authored-by: Cursor <cursoragent@cursor.com>
4730 lines
184 KiB
Python
4730 lines
184 KiB
Python
# 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-05-28-evening-export-soc-balance-v39"
|
||
# 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 D−1 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 (0–5h 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
|
||
peer_results, peer_ms, peer_snapshot = 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=peer_version,
|
||
)
|
||
# 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 bits0–1 (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_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_b_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,
|
||
soc_at_day_start_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].
|
||
"""
|
||
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)
|
||
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),
|
||
)
|
||
else:
|
||
target_wh = float(battery.soc_max_wh)
|
||
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 - float(soc_at_day_start_wh))
|
||
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,
|
||
current_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."""
|
||
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,
|
||
current_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,
|
||
current_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).
|
||
"""
|
||
by_day = _neg_sell_indices_by_prague_day(slots)
|
||
export_ts: set[int] = set()
|
||
cushion_by_day: dict[str, bool] = {}
|
||
soc_est = float(current_soc_wh)
|
||
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,
|
||
soc_est,
|
||
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,
|
||
)
|
||
tgt0 = (
|
||
float(soc_target_by_t[first_t])
|
||
if soc_target_by_t and soc_target_by_t[first_t] is not None
|
||
else float(battery.soc_max_wh)
|
||
)
|
||
soc_est = max(float(battery.min_soc_wh), min(float(battery.soc_max_wh), tgt0))
|
||
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 D−1 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 D−1 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 _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 D−1 (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 (max−load)/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.
|
||
gi−ge 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 5–11 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 plný ge_bat push (pre-neg / ranní větve).
|
||
Exportní cap má pokrýt ge_pv; baterii řeší večerní push a sell<0 okna.
|
||
"""
|
||
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 0–5h (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 _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 _evening_push_discharge_budget_wh(
|
||
*,
|
||
current_soc_wh: float,
|
||
min_soc_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).
|
||
Tvrdý push nesmí překročit energii nad min_soc na začátku horizontu (jinak Infeasible).
|
||
"""
|
||
exportable_full_wh = max(0.0, float(soc_max_wh) - float(min_soc_wh))
|
||
available_wh = max(0.0, float(current_soc_wh) - float(min_soc_wh))
|
||
buf = float(discharge_slot_buffer)
|
||
if buf <= 0.0:
|
||
return available_wh
|
||
return min(available_wh, exportable_full_wh * buf)
|
||
|
||
|
||
def _evening_battery_export_push_indices(
|
||
slots: list[PlanningSlot],
|
||
*,
|
||
profitable_export_ts: set[int],
|
||
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,
|
||
evening_start_hour: int = 17,
|
||
) -> list[int]:
|
||
"""
|
||
Noční push: plný ge_bat v tolika nejdražších peak-band slotech, kolik unese Wh rozpočet.
|
||
|
||
Kandidáti = profitable ∩ noční okno ∩ večerní peak pásmo (max sell v úseku − degrad, R__063).
|
||
Řazení sell desc; přidávat sloty dokud kumulované Wh ≤ push_budget. Žádné pevné top-N.
|
||
per_slot_discharge_wh: volající předá min(BMS, export cap) × účinnost × 0,25 h.
|
||
"""
|
||
if per_slot_discharge_wh <= 0.0:
|
||
return []
|
||
peak_ts = set(
|
||
_evening_peak_export_indices(
|
||
slots,
|
||
degrad_czk_kwh=degrad_czk_kwh,
|
||
evening_start_hour=evening_start_hour,
|
||
)
|
||
)
|
||
candidates = [
|
||
t
|
||
for t, s in enumerate(slots)
|
||
if t in peak_ts
|
||
and t in profitable_export_ts
|
||
and float(s.sell_price) >= 0.0
|
||
]
|
||
if not candidates:
|
||
return []
|
||
push_budget_wh = _evening_push_discharge_budget_wh(
|
||
current_soc_wh=current_soc_wh,
|
||
min_soc_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 []
|
||
ranked = sorted(
|
||
candidates,
|
||
key=lambda i: (float(slots[i].sell_price), -i),
|
||
reverse=True,
|
||
)
|
||
out: list[int] = []
|
||
cum_wh = 0.0
|
||
for t in ranked:
|
||
if cum_wh + per_slot_discharge_wh > push_budget_wh + 1e-6:
|
||
break
|
||
out.append(t)
|
||
cum_wh += per_slot_discharge_wh
|
||
return sorted(out)
|
||
|
||
|
||
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],
|
||
*,
|
||
profitable_export_ts: set[int],
|
||
discharge_export_slots: set[int],
|
||
evening_push_ts: set[int],
|
||
) -> set[int]:
|
||
"""ge_bat=0 pro profitable noční sloty pod peak−eps mimo evening_push (v38: i po prvním push)."""
|
||
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 profitable_export_ts or t_ev not in discharge_export_slots:
|
||
continue
|
||
if t_ev in evening_push_ts:
|
||
continue
|
||
peak_sell = _night_peak_sell_czk_kwh(slots, t_ev)
|
||
if float(s_ev.sell_price) < peak_sell - EVENING_PEAK_SELL_EPS_CZK_KWH:
|
||
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_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)
|
||
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=evening_push_ts_override,
|
||
)
|
||
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 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,
|
||
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.
|
||
"""
|
||
T = len(slots)
|
||
if T < 1:
|
||
raise RuntimeError("solve_dispatch requires at least one slot")
|
||
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)
|
||
]
|
||
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)
|
||
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_reserve_anchors: list[tuple[int, float]] = []
|
||
if om == "AUTO" and not purchase_fixed_pre and neg_sell_phases_en:
|
||
pre_neg_pv_export_ts, pre_neg_cushion_by_day = _pre_neg_pv_export_bundle(
|
||
slots,
|
||
battery,
|
||
current_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,
|
||
)
|
||
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,
|
||
current_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()
|
||
evening_push_hysteresis_retained = 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)
|
||
computed_evening_push_ts = set(
|
||
_evening_battery_export_push_indices(
|
||
slots,
|
||
profitable_export_ts=profitable_export_ts_pre,
|
||
degrad_czk_kwh=float(degradation_cost_effective),
|
||
current_soc_wh=float(current_soc_wh),
|
||
min_soc_wh=float(min_soc_wh),
|
||
soc_max_wh=float(battery.soc_max_wh),
|
||
per_slot_discharge_wh=per_slot_push_wh_pre,
|
||
discharge_slot_buffer=discharge_buf_pre,
|
||
)
|
||
)
|
||
if evening_push_ts_override is not None:
|
||
evening_push_ts = set(evening_push_ts_override)
|
||
evening_push_hysteresis_retained = True
|
||
else:
|
||
evening_push_ts = computed_evening_push_ts
|
||
evening_early_export_penalty_ts = _evening_early_export_penalty_indices(
|
||
slots,
|
||
profitable_export_ts=profitable_export_ts_pre,
|
||
discharge_export_slots=discharge_export_slots,
|
||
evening_push_ts=evening_push_ts,
|
||
)
|
||
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
|
||
)
|
||
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]] = []
|
||
if charge_commitment_prev_w is not None and len(charge_commitment_prev_w) == T:
|
||
for t in range(T):
|
||
prev = charge_commitment_prev_w[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 _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_before_neg_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:
|
||
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))
|
||
)
|
||
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
|
||
)
|
||
+ 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)
|
||
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]
|
||
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
|
||
for t_peak in sorted(evening_push_ts):
|
||
if t_peak not in discharge_export_slots:
|
||
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
|
||
# 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 ((max−load)/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_before_neg_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
|
||
|
||
# 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 > 500.0
|
||
and (
|
||
first_neg_sell_idx is None
|
||
or t < first_neg_sell_idx
|
||
)
|
||
)
|
||
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 > 500
|
||
) or fixed_pre_neg_pv_export
|
||
# BA81: export pole B jen při kladném sell mimo pre-neg okno (jinak jen ge_pv≤pv_b → curtail A).
|
||
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
|
||
)
|
||
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 _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í krmit baseload (nouzový režim).
|
||
prob += gi[t] <= ev_cap_t + hp[t] + (
|
||
float(s.load_baseline_w) if relaxed_expensive_import else 0.0
|
||
)
|
||
if not relaxed_expensive_import 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,
|
||
)
|
||
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,
|
||
)
|
||
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_before_neg_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
|
||
),
|
||
}
|
||
)
|
||
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),
|
||
"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_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,
|
||
"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),
|
||
},
|
||
"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:00–06: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(f"[site={site_id}] Rolling replan from {replan_from} → {horizon_to}")
|
||
|
||
battery, hp, grid, vehicles, ev_sessions, soc_wh, tuv_temp, operating_mode, tuv_stats = (
|
||
await _load_site_context(site_id, db)
|
||
)
|
||
|
||
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
|
||
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,
|
||
)
|
||
)
|