# 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 Optional from zoneinfo import ZoneInfo import pulp 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). TERMINAL_SOC_VALUE_FACTOR = 0.9 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 # Dokud je první „extrémní“ buy dál než tento počet 15min slotů, držíme plánovací spodek na rezervě # (arb_base_wh) místo hlubokého planner floor — aby šlo nejdřív vybíjet „standardně“ a hluboký # dump až těsně před oknem záporných cen (operativní buffer). DEFAULT_PLANNER_DISCHARGE_RELAX_PREWINDOW_SLOTS = 8 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")) 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 _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 # 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 _soc_panel_min_wh_series( soc_min_series: list[float], slots_until_buy_extreme: 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í, ale první 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_buy_extreme[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 #: 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 179 bits0–1. #: 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) # ============================================================ # 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 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", ) -> tuple[list[DispatchResult], int]: """ LP solver pro dispatch optimalizaci. Vrátí (výsledky, solver_duration_ms). """ T = len(slots) if T < 1: raise RuntimeError("solve_dispatch requires at least one slot") EV = len(vehicles) # počet EV (typicky 2) 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 soc_min_series = _soc_min_wh_series( slots, float(battery.usable_capacity_wh), min_soc_wh, buy_extreme_thr, floor_pct, ) current_soc_wh = float(current_soc_wh) 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) ) prewin = max( 0, int( getattr( battery, "planner_discharge_relax_prewindow_slots", DEFAULT_PLANNER_DISCHARGE_RELAX_PREWINDOW_SLOTS, ) ), ) slots_until_buy_extreme = _slots_until_buy_le_threshold(slots, buy_extreme_thr) soc_panel_min = _soc_panel_min_wh_series( soc_min_series, slots_until_buy_extreme, min_soc_wh, arb_base_wh, prewin, ) # --- Proměnné --- # gi[t] horní mez: site breaker (max_import_power_w) je fyzický strop. # Pro robustnost (forecast PV/load nemusí sedět) používáme měkký cap: dovolíme gi nominálně # až ~breaker + BMS max charge, ale překročení breakeru je penalizované (viz gi_over). gi_upper = float(grid.max_import_power_w) + float(battery.max_charge_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)] bc = [pulp.LpVariable(f"bc_{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)] 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 ) # 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 ) # Kč/Wh: ocenění energie ponechané v baterii na konci horizontu (receding horizon kotva). terminal_soc_kcz_per_wh = ( avg_buy_terminal * TERMINAL_SOC_VALUE_FACTOR / 1000.0 ) # --- Účelová funkce (jen OTE sloty; terminal SoC shadow price na konci horizontu) --- prob += ( pulp.lpSum( gi[t] * slots[t].buy_price * INTERVAL_H / 1000 - ge[t] * slots[t].sell_price * INTERVAL_H / 1000 + gi_over[t] * IMPORT_OVER_BREAKER_PENALTY_CZK_KWH * INTERVAL_H / 1000 + 0.5 * (bc[t] + bd[t]) * degradation_cost_effective * INTERVAL_H / 1000 + 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] * CURTAILMENT_PENALTY for t in range(T) ) + soc_deficit_24h * soc_deficit_penalty_czk_kwh / 1000 - terminal_soc_kcz_per_wh * soc[T - 1] ) # --- Omezení --- 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) ) prob += ( pv_a_net + pv_b_effective + gi[t] + bd[t] == s.load_baseline_w + ev_total_t + hp[t] + bc[t] + ge[t] ) # 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 soc_prev = current_soc_wh if t == 0 else soc[t - 1] prob += soc[t] == ( soc_prev + bc[t] * battery.charge_efficiency * INTERVAL_H - bd[t] / battery.discharge_efficiency * INTERVAL_H ) # ev_via_bat kryto z discharge prob += pulp.lpSum(ev_via_bat[e][t] for e in range(EV)) <= bd[t] # Záporná prodejní cena: export nepovinně zakazovat — účelovka už obsahuje -ge*sell # (záporné sell zvyšuje náklad exportu). GEN cut-off držíme vypnutý (jinak by z_gen_cutoff # uměle nulovalo forecast pole B). if z_gen_cutoff is not None: prob += z_gen_cutoff[t] == 0 # Záporná nákupní cena → cap import (baseline domu + akumulace + řízené zátěže) if s.buy_price < 0: prob += gi[t] <= ( 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 ) 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 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[t] + battery.max_discharge_power_w * w_arb[t] ) # Významný export ⇒ koncové SoC ≥ podlaha (viz soc_panel_min / arb_base). m_ge = float(grid.max_export_power_w) m_soc_bigm = float(battery.usable_capacity_wh) prob += ge[t] <= m_ge * z_export[t] prob += ge[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 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) 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 om = (operating_mode or "AUTO").strip().upper() if om == "SELF_SUSTAIN": for t in range(T): prob += ge[t] == 0 prob += gi[t] <= slots[t].load_baseline_w elif om == "PRESERVE": for t in range(T): prob += bc[t] == 0 prob += bd[t] == 0 elif om == "CHARGE_CHEAP": for t in range(T): prob += ge[t] == 0 prob += bd[t] == 0 # Slot pre-selection (z DB fn_load_planning_slots_full → allow_*) if om == "AUTO": charge_slots = {t for t, s in enumerate(slots) if s.allow_charge} discharge_export_slots = {t for t, s in enumerate(slots) if s.allow_discharge_export} for t in range(T): if t not in charge_slots: prob += bc[t] == 0 if t not in discharge_export_slots: s = slots[t] ev_total_t = pulp.lpSum( ev_direct[e][t] + ev_via_bat[e][t] for e in range(EV) ) prob += bd[t] <= s.load_baseline_w + ev_total_t + hp[t] # 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': 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 batt_w = round(pulp.value(bc[t]) - pulp.value(bd[t])) grid_w = round(pulp.value(gi[t]) - pulp.value(ge[t])) soc_pct = round(pulp.value(soc[t]) / battery.usable_capacity_wh * 100, 1) # Primární klasifikace fyzického režimu pro Deye: explicitně do plánu (Variant A). # Default PASSIVE; SELL při export+vybíjení; CHARGE při import+nabíjení. deye_mode = "PASSIVE" if 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))) cost = ( pulp.value(gi[t]) * slots[t].buy_price * INTERVAL_H / 1000 - pulp.value(ge[t]) * slots[t].sell_price * INTERVAL_H / 1000 ) results.append(DispatchResult( interval_start = slots[t].interval_start, battery_setpoint_w = batt_w, battery_soc_target = soc_pct, grid_setpoint_w = grid_w, 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), )) return results, duration_ms # ============================================================ # Denní plán (15:00) # ============================================================ async def run_daily_plan(site_id: int, db, triggered_by: str = "scheduler:daily") -> 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) ) slots = await _load_slots(site_id, horizon_from, horizon_to, db, soc_wh=soc_wh) results, duration_ms = solve_dispatch( slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp, tuv_delta_stats=tuv_stats, operating_mode=operating_mode or "AUTO", ) 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, ) 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, ) -> 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) 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) 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) 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) logger.info(f"[site={site_id}] Rolling replan from {replan_from} → {horizon_to}") battery, hp, grid, vehicles, ev_sessions, soc_wh, tuv_temp, operating_mode, tuv_stats = ( await _load_site_context(site_id, db) ) correction_factor, correction_log = await compute_correction_factor(site_id, now, db) slots = await _load_slots(site_id, replan_from, horizon_to, db, soc_wh=soc_wh) slots_before_pv_correction = list(slots) slots = apply_forecast_correction(slots, now, correction_factor) results, duration_ms = solve_dispatch( slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp, tuv_delta_stats=tuv_stats, operating_mode=operating_mode or "AUTO", ) slot_inputs = _build_slot_inputs(slots_before_pv_correction, 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, ) await db.execute( """ select ems.fn_forecast_correction_log_insert( $1::int, $2::timestamptz, $3::timestamptz, $4::numeric, $5::numeric, $6::numeric, $7::int ) """, site_id, correction_log["window_start"], correction_log["window_end"], correction_log.get("actual_pv_wh"), correction_log.get("forecast_pv_wh"), correction_factor, run_id, ) logger.info( f"[site={site_id}] Rolling replan done in {duration_ms} ms " f"(correction={correction_factor:.3f})" ) return run_id, duration_ms async def run_plan_api( site_id: int, plan_type: str, db, *, triggered_by: str = "api", ) -> 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() if pt == "daily": return await run_daily_plan(site_id, db, triggered_by=triggered_by) if pt == "rolling": rid, ms = await run_rolling_replan( site_id, db, triggered_by=triggered_by, allow_skip=False ) 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, ) 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"] grid = SimpleNamespace( max_import_power_w=int(g["max_import_power_w"]), max_export_power_w=int(g["max_export_power_w"]), deye_gen_microinverter_cutoff_enabled=bool(g.get("deye_gen_microinverter_cutoff_enabled") or False), ) 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 _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 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)), ) ) 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, ) -> 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 = { "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, } 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, "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), "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 ) """, site_id, horizon_from, horizon_to, json.dumps(run_meta, default=str), json.dumps(intervals, default=str), ) )