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