# Modul: Planning (LP Optimalizace) ## Přístup **PuLP + HiGHS solver** – lineární programování (LP) s uvolněním binárních proměnných. ### Implementované provozní změny (2026-03) - **Strict price fail-safe:** - pokud v prvních 36h chybí OTE data (sloty jsou predikované), solver zapíná fail-safe režim, - v predikovaných slotech (`is_predicted_price=true`) je zakázán export do sítě, - baterie se ale dál používá standardně pro interní spotřebu (nabíjení i vybíjení do domu je povoleno). - **Runtime guard v exportu setpointů:** - při `AUTO` + `is_predicted_price=true` se na exportní vrstvě vynutí PASSIVE/no-export chování. - **Ekonomika baterie:** - `min_soc_percent` = nejnižší SoC v LP a runtime clamp telemetrie; u **více paralelních stringů** držet **nad** holým BMS minimem (typicky **11–12 %**; migrace **V029** + komentář v DB, u `home-01` cílený UPDATE z 10 %), - `reserve_soc_percent` = ekonomická („arbitrážní“) podlaha – pod ní MILP s `w_arb` omezuje vybíjení podle začátku slotu a FVE lookahead (`arb_floor_series`; typicky 20 %), - **Export ze site:** binárka `z_export[t]` – pokud `grid_export ≥ 1` W, musí být **koncové** `soc[t] ≥ arb_base_wh` (fixní z DB, **ne** dynamicky snížená `arb_floor_series`), - `degradation_cost_czk_kwh` (např. 0.15) / penalizace cyklu v objective symetrická (`0.5*(charge+discharge)`). - **PV-aware nejistota:** - objective používá `pv_scarcity_factor` (0.65..1.0), odvozený z forecastu slunce, - při slabém slunci je plán ochotnější držet energii v baterii. - **SoC buffer:** - měkký cíl na konci 24h přes `_soc_security_profile` + tvrdé dvouúrovňové pravidlo výše. - **Dynamická ekonomická podlaha (fáze 2):** - `_dynamic_arb_floor_wh_series`: podle součtu FVE výkonu v dalších ~8 h (`ARB_LOOKAHEAD_SLOTS`) se `arb_floor_wh[t]` posouvá mezi `min_soc_wh` a rezervou z DB – silné očekávané slunce ji sníží (ráno / po obloze); vynutit konstantní chování lze `battery.disable_dynamic_arb_floor=True` jen pro testy / ladění. - **Záporná nákupní cena:** - horní mez `grid_import` zahrnuje `load_baseline_w` + nabíjení/EV/TČ (bez nekonečného importu). - **Uložené vstupy plánu** (`planning_interval`): `load_baseline_w`, `pv_*_forecast_raw_w`, `pv_*_forecast_solver_w` pro UI a audit. - **Více FVE polí s různou orientací:** `planning_engine._load_slots` sčítá predikovaný výkon za 15min přes **všechna** `asset_pv_array` dané lokality — `pv_a_forecast_w` = součet řádků s `controllable = true`, `pv_b_forecast_w` = součet s `controllable = false`. Pro každé pole a slot se bere **nejnovější** `forecast_pv_run` (`ORDER BY created_at DESC`, `DISTINCT ON (pv_array_id)`). Curtailment v LP zůstává **jedno** agregované `pv_a` (součet řiditelných polí); per-string curtailment by vyžadovalo rozšíření modelu. Solver optimalizuje celý horizont (typicky 36h) najednou, čímž přirozeně zvládá: - pohled dopředu (ráno ví že přes poledne bude záporná cena → prodává z baterie) - kompromisy mezi prodejem, nabíjením, TČ a EV v globálním optimu --- ## Klíčové předpoklady a specifika home-01 ### FVE pole A (10 kWp, řízené Deye) - Curtailment povolen přes Modbus (Output Power Limit) - Solver může omezit výrobu pokud export nevychází a není kam ukládat - Curtailment má nulový přímý náklad, ale ztrátu příležitosti ### FVE pole B (10 kWp, ongridový na GEN portu) - **Nelze omezit ani řídit** - Má **zelený bonus** (dotace za každé vyrobené kWh bez ohledu na cenu) - Výroba pole B musí být vždy plně spotřebována nebo uložena - Při záporné prodejní ceně má nejvyšší prioritu ukládání (baterie → EV → TČ) - Solver nikdy neexportuje výrobu pole B pokud je prodejní cena záporná ### Export / import limity (home-01) - Max export do sítě: **13.5 kW** (smlouva s distributorem) - Max import ze sítě: dle `site_grid_connection.max_import_power_w` - Konfigurovatelné per site v DB --- ## Energetická bilance (pro každý 15min slot t) ``` pv_a_actual[t] + pv_b[t] + grid_import[t] + battery_discharge[t] = load_baseline[t] + Σ_e (ev_direct[e][t] + ev_via_bat[e][t]) + heat_pump[t] + battery_charge[t] + grid_export[t] + pv_a_curtailed[t] ``` kde: - `pv_a_actual[t]` = `pv_a_forecast[t] − pv_a_curtailed[t]` - `pv_b[t]` = predikce pole B (pevná, nekontrolovatelná) - `grid_import[t]`, `grid_export[t]` ≥ 0 (oddělené proměnné, ne signed) - `ev_direct[e][t]` = přímé napájení EV e ze zdrojů (FVE, síť) – bez průchodu baterií - `ev_via_bat[e][t]` = napájení EV e přes baterii (kryta z `battery_discharge[t]`) **Round-trip efektivita:** Přímé napájení EV je ~10 % levnější než přes baterii (η_charge × η_discharge ≈ 0.95 × 0.95 ≈ 0.90). Solver to vidí v účelové funkci. --- ## Proměnné solveru | Proměnná | Typ | Rozsah | Popis | |---|---|---|---| | `grid_import[t]` | kontinuální | 0 – max_import | Nákup ze sítě v W | | `grid_export[t]` | kontinuální | 0 – max_export (13500) | Prodej do sítě v W | | `battery_charge[t]` | kontinuální | 0 – max_charge | Nabíjení baterie v W | | `battery_discharge[t]` | kontinuální | 0 – max_discharge | Vybíjení baterie v W | | `soc[t]` | kontinuální | soc_min – soc_max | Stav nabití baterie v Wh | | `pv_a_curtailed[t]` | kontinuální | 0 – pv_a_forecast[t] | Omezení výroby pole A v W | | `ev_direct[e][t]` | kontinuální | 0 – min(ev_max, pv_surplus) | Přímé napájení EV e z FVE/sítě (bez průchodu baterií) | | `ev_via_bat[e][t]` | kontinuální | 0 – ev_max | Napájení EV e přes baterii (s round-trip ztrátou) | | `heat_pump[t]` | kontinuální | 0 – hp_rated | Výkon TČ v W (relaxováno z binární) | > **TČ relaxace:** TČ je v realitě ON/OFF (binární). Pro LP ho relaxujeme na spojitou proměnnou 0–rated_power. Post-processing pravidlo pak zaokrouhlí na ON/OFF a zkontroluje `min_run_duration`. V praxi výsledek LP vychází blízko binárnímu řešení. --- ## Účelová funkce (minimalizace nákladů) ```python EV_ROUNDTRIP_FACTOR = 1.0 / (charge_efficiency * discharge_efficiency) # ≈ 1.108 minimize: Σ_t [ # Náklady na nákup ze sítě grid_import[t] * buy_price[t] * interval_h # Příjem z prodeje (záporný náklad) - grid_export[t] * sell_price[t] * interval_h # Náklad degradace baterie (nabíjení i vybíjení) + (battery_charge[t] + battery_discharge[t]) * degradation_cost * interval_h # EV přímé napájení – standardní cena energie + Σ_e ev_direct[e][t] * buy_price[t] * interval_h # EV přes baterii – navýšeno o round-trip ztrátu + degradaci # Solver tak přirozeně preferuje přímé nabíjení nad průchodem baterií + Σ_e ev_via_bat[e][t] * buy_price[t] * EV_ROUNDTRIP_FACTOR * interval_h # Malá penalizace curtailmentu pole A (preferujeme využití FVE) + pv_a_curtailed[t] * CURTAILMENT_PENALTY ] ``` kde `interval_h = 0.25` (15 min = 0.25 h), ceny v Kč/kWh, výkony ve W. --- ## Omezení solveru ### Energetická bilance ```python pv_a_forecast[t] - pv_a_curtailed[t] + pv_b[t] + grid_import[t] + battery_discharge[t] == load_baseline[t] + Σ_e (ev_direct[e][t] + ev_via_bat[e][t]) + heat_pump[t] + battery_charge[t] + grid_export[t] ``` ### Vazba ev_via_bat na battery_discharge ```python # ev_via_bat musí být kryto z vybíjení baterie Σ_e ev_via_bat[e][t] <= battery_discharge[t] ``` ### Limit výkonu EV per vozidlo ```python # Celkový výkon do EV e nesmí překročit min(WB limit, vozidlo max) ev_direct[e][t] + ev_via_bat[e][t] <= min(charger_max_w[e], vehicle_max_w[e]) # Pokud auto není připojeno → nula if not ev_connected[e][t]: ev_direct[e][t] == 0 ev_via_bat[e][t] == 0 ``` ### Deadline charging – hard constraint ```python # Pro každé EV e s nastaveným deadline a known SoC: if ev_session[e].target_deadline and ev_session[e].soc_at_connect_pct is not None: energy_needed_wh = ( (target_soc_pct - soc_at_connect_pct) / 100.0 * vehicle_capacity_wh[e] ) t_deadline = slot_index(ev_session[e].target_deadline) pulp.lpSum( (ev_direct[e][t] + ev_via_bat[e][t]) * interval_h for t in range(t_deadline + 1) if ev_connected[e][t] ) >= energy_needed_wh # Pro Zoe (SoC neznámý) – deadline constraint na kumulativní dodanou energii: # energy_needed = (default_target_soc - estimated_soc_from_session) * capacity ``` ### SoC kontinuita ```python soc[t] == soc[t-1] + battery_charge[t] * charge_efficiency * interval_h - battery_discharge[t] / discharge_efficiency * interval_h soc[0] == current_soc_wh # počáteční podmínka z telemetrie ``` ### SoC limity ```python soc_min_wh <= soc[t] <= soc_max_wh # min_soc_percent z DB (provozní podlaha, často 11–12 %) # Ekonomická podlaha (reserve_soc_percent): w_arb[t] + arb_floor_series[t] – # bd omezeno podle soc na začátku slotu (žádné „nadbytečné“ vybíjení z hlubokého pásma při exportu z AKU). # Při grid_export[t] >= 1 W: soc[t] >= arb_base_wh (rezerva z DB, ne časová řada arb_floor). # Měkký buffer na konci 24h dál přes soc_deficit_24h. ``` ### Limity výkonu ```python 0 <= battery_charge[t] <= battery.max_charge_power_w 0 <= battery_discharge[t] <= battery.max_discharge_power_w 0 <= grid_import[t] <= grid.max_import_power_w 0 <= grid_export[t] <= grid.max_export_power_w # = 13500 pro home-01 0 <= pv_a_curtailed[t] <= pv_a_forecast[t] 0 <= ev_charge[t] <= ev_max_total_w 0 <= heat_pump[t] <= heat_pump.rated_heating_power_w ``` ### Nelze současně nabíjet a vybíjet baterii ```python # Přirozeně vyplyne z optimalizace díky degradation_cost. # Pokud ne, přidat: battery_charge[t] * battery_discharge[t] == 0 # (to by ale byl QP, ne LP – raději nechat degradation_cost dělat práci) ``` ### Záporná prodejní cena – zákaz exportu ```python if sell_price[t] < 0: grid_export[t] == 0 # přidat jako constraint pro daný slot ``` ### Záporná prodejní cena – pole B má prioritu v ukládání ```python # Pokud sell_price[t] < 0, výroba pole B nesmí jít do exportu. # Formulace: grid_export[t] <= grid_import[t] + battery_discharge[t] ... # Jednodušeji: pokud sell_price < 0, přidat constraint grid_export[t] == 0 # (export stejně zakázán výše) a solver automaticky uloží přebytek. ``` ### Záporná nákupní cena – nabíjet ze sítě je výhodné ```python # Pokud buy_price[t] < 0, grid_import[t] je příjem → solver automaticky maximalizuje import. # Omezit maximálním výkonem baterie (aby to mělo smysl): # grid_import[t] <= battery.max_charge_power_w + ev_max_total_w + heat_pump.rated_heating_power_w # (nechceme kupovat víc než spotřebujeme / uložíme) ``` ### TUV minimální teplota – nouzový ohřev vždy ```python # Pokud aktuální teplota zásobníku < tuv_min_temp_c: # heat_pump[t=0] >= heat_pump.rated_heating_power_w * 0.8 # minimálně 80% výkonu v prvním slotu # Toto je tvrdé omezení nezávislé na ceně. ``` --- ## Implementace (Python / PuLP) ```python # backend/services/planning_engine.py import pulp from pulp import HiGHS_CMD def solve_dispatch( site_id: int, slots: list[PlanningSlot], # 15min sloty s cenami, forecasty battery: AssetBattery, heat_pump: AssetHeatPump, grid: SiteGridConnection, current_soc_wh: float, current_tuv_temp_c: float, ev_max_total_w: int, ) -> list[DispatchResult]: T = len(slots) H = 0.25 # interval v hodinách CURTAILMENT_PENALTY = 0.001 # Kč/Wh – malá penalizace aby solver preferoval využití prob = pulp.LpProblem("ems_dispatch", pulp.LpMinimize) # --- Proměnné --- grid_import = [pulp.LpVariable(f"gi_{t}", 0, grid.max_import_power_w) for t in range(T)] grid_export = [pulp.LpVariable(f"ge_{t}", 0, grid.max_export_power_w) for t in range(T)] batt_charge = [pulp.LpVariable(f"bc_{t}", 0, battery.max_charge_power_w) for t in range(T)] batt_discharge = [pulp.LpVariable(f"bd_{t}", 0, battery.max_discharge_power_w) for t in range(T)] soc = [pulp.LpVariable(f"soc_{t}", battery.min_soc_wh, battery.soc_max_wh) for t in range(T)] curtail_a = [pulp.LpVariable(f"ca_{t}", 0, slots[t].pv_a_forecast_w) for t in range(T)] ev_charge = [pulp.LpVariable(f"ev_{t}", 0, ev_max_total_w) for t in range(T)] heat_pump_p = [pulp.LpVariable(f"hp_{t}", 0, heat_pump.rated_heating_power_w) for t in range(T)] # --- Účelová funkce --- prob += pulp.lpSum( grid_import[t] * slots[t].buy_price * H / 1000 # Kč (W→kW) - grid_export[t] * slots[t].sell_price * H / 1000 + (batt_charge[t] + batt_discharge[t]) * battery.degradation_cost_czk_kwh * H / 1000 + curtail_a[t] * CURTAILMENT_PENALTY for t in range(T) ) # --- Omezení --- for t in range(T): s = slots[t] pv_a_net = s.pv_a_forecast_w - curtail_a[t] # Energetická bilance prob += ( pv_a_net + s.pv_b_forecast_w + grid_import[t] + batt_discharge[t] == s.load_baseline_w + ev_charge[t] + heat_pump_p[t] + batt_charge[t] + grid_export[t] ) # SoC kontinuita soc_prev = current_soc_wh if t == 0 else soc[t-1] prob += soc[t] == ( soc_prev + batt_charge[t] * battery.charge_efficiency * H - batt_discharge[t] / battery.discharge_efficiency * H ) # Záporná prodejní cena → zakázat export if s.sell_price < 0: prob += grid_export[t] == 0 # Záporná nákupní cena → omezit import na to co reálně spotřebujeme/uložíme if s.buy_price < 0: prob += grid_import[t] <= ( battery.max_charge_power_w + ev_max_total_w + heat_pump.rated_heating_power_w ) # Nouzový ohřev TUV – pokud zásobník pod minimem if current_tuv_temp_c < heat_pump.tuv_min_temp_c: prob += heat_pump_p[0] >= heat_pump.rated_heating_power_w * 0.8 # --- Řešení --- solver = HiGHS_CMD(msg=False, timeLimit=10) status = prob.solve(solver) if pulp.LpStatus[status] != 'Optimal': raise PlanningError(f"Solver nenašel optimální řešení: {pulp.LpStatus[status]}") # --- Post-processing TČ: relaxovaná → ON/OFF --- results = [] for t in range(T): hp_raw = pulp.value(heat_pump_p[t]) hp_enabled = hp_raw > heat_pump.rated_heating_power_w * 0.3 # threshold pro ON hp_power = heat_pump.rated_heating_power_w if hp_enabled else 0 results.append(DispatchResult( interval_start = slots[t].interval_start, battery_setpoint_w = round(pulp.value(batt_charge[t]) - pulp.value(batt_discharge[t])), battery_soc_target = round(pulp.value(soc[t]) / battery.usable_capacity_wh * 100, 1), grid_setpoint_w = round(pulp.value(grid_import[t]) - pulp.value(grid_export[t])), ev_charge_power_w = round(pulp.value(ev_charge[t])), heat_pump_enabled = hp_enabled, heat_pump_setpoint_w = hp_power, pv_a_curtailed_w = round(pulp.value(curtail_a[t])), expected_cost_czk = round( pulp.value(grid_import[t]) * slots[t].buy_price * H / 1000 - pulp.value(grid_export[t]) * slots[t].sell_price * H / 1000, 4 ), effective_buy_price = slots[t].buy_price, effective_sell_price = slots[t].sell_price, )) return results ``` --- ## Scénáře které solver řeší správně ### Ráno – vysoká FVE předpověď, přes poledne záporná cena ``` Solver ráno (vysoká cena): → vybíjí baterii do sítě (prodej při high price) → exportuje FVE přebytek Přes poledne (záporná nebo nízká cena): → zakáže export (grid_export == 0) → nabíjí baterii z FVE + ze sítě (dostane zaplaceno) → spouští TČ a EV (spotřebovává levnou/zápornou energii) → případně curtailuje pole A pokud je baterie plná a není kam ukládat ``` ### Pole B + záporná cena ``` Pole B vyrábí 10 kWp, sell_price < 0: → grid_export == 0 (constraint) → solver musí interně spotřebovat vše z pole B → prioritně: nabíjení baterie, pak EV, pak TČ → pokud nic nestačí → baterie je plná, EV nepřipojeno, TČ na max: solver ukáže že zbývající výroba pole B nejde spotřebovat → tuto situaci logovat (přebytek nevyužit, bonus přesto inkasován) ``` ### Záporná nákupní cena (platíme za odběr) ``` → solver maximalizuje grid_import (je to příjem) → omezen na max_charge + ev_max + hp_rated (nechceme kupovat zbytečně) → nabíjí baterii na maximum → spouští EV a TČ naplno ``` --- ## DB – rozšíření planning_interval Přidat sloupec `pv_a_curtailed_w` do tabulky: ```sql -- V005__planning_curtailment.sql ALTER TABLE ems.planning_interval ADD COLUMN pv_a_curtailed_w INT NOT NULL DEFAULT 0; COMMENT ON COLUMN ems.planning_interval.pv_a_curtailed_w IS 'Plánované omezení výroby FVE pole A v W (curtailment). 0 = žádné omezení. ' 'Hodnota > 0 znamená že solver rozhodl omezit výrobu pole A přes Modbus.'; ``` --- ## Konfigurace (env proměnné) ```env PLANNING_HORIZON_HOURS=36 PLANNING_SOLVER_TIME_LIMIT_SEC=10 # HiGHS timeout PLANNING_CURTAILMENT_PENALTY=0.001 # Kč/Wh penalizace za omezení FVE PLANNING_HP_RELAXATION_THRESHOLD=0.3 # pod 30% rated = OFF při post-processingu ``` > **Zelený bonus:** Sazba a platnost jsou v `ems.asset_pv_array` (`green_bonus_*`). Bonus **není** v objective function LP solveru – jako aditivní konstanta k nákladům by optimalizaci stejně neměnil. Příjem z bonusu se počítá v **`fn_fill_audit_interval`** přes `ems.fn_green_bonus_revenue()` a ukládá se do `audit_interval.green_bonus_czk`; v přehledech (např. `vw_audit_daily`) je samostatná položka příjmů vedle nákladů ze sítě. Viz `docs/04-modules/market-prices.md` → sekce Zelený bonus. --- ## Závislosti (requirements.txt) ``` pulp>=2.8.0 highspy>=1.7.0 # HiGHS Python binding (rychlejší než HiGHS_CMD) ``` > Preferovat `import highspy` přímý binding místo `HiGHS_CMD` shell volání – výrazně rychlejší. --- ## Otevřené body - [ ] Post-processing min_run_duration pro TČ – po LP výsledku zkontrolovat a upravit krátké ON/OFF sekvence - [x] Zelený bonus v auditu (`fn_fill_audit_interval`, `green_bonus_czk`) – mimo solver - [ ] EV rozdělení výkonu mezi 2 nabíječky – zatím řešeno jako agregát - [ ] Curtailment pole A – ověřit Modbus registr pro Output Power Limit na Deye SUN-20K - [ ] Testovat solver na reálných datech – ověřit čas výpočtu pro 36h horizont (144 slotů)