# 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, aktualizace 2026-04) - **SQL-first:** horizont a sloty z DB funkcí (`fn_planning_horizon_end`, `fn_load_planning_slots_full`, …); viz **`CLAUDE.md`** → sekce *SQL-first a read-model*. - **Dynamický horizont (jen OTE):** konec plánu z **`ems.fn_planning_horizon_end(site_id, horizon_start)`** (výchozí strop **36 h**, minimum pro rolling **1 h** – obojí jako defaultní argumenty v SQL, úprava přes repeatable migraci). Pomocná `ems.fn_last_effective_ote` vrací konec posledního OTE intervalu. Rolling replan při `NULL` přeskočí; denní plán použije krátký (1 h) fallback v Pythonu. Sloty v solveru jsou bez predikovaných cen v rámci tohoto horizontu. - **Terminal SoC shadow price:** v objective je člen `−(avg_buy_prvních_24h × planner_terminal_soc_value_factor / 1000) × soc[T−1]` (Kč), kde faktor je **`ems.asset_battery.planner_terminal_soc_value_factor`** přes **`ems.fn_planning_site_context`** (default v DB **0.9**); viz sekci *Tuning pro malé baterie* níže. Účel: konec horizontu nemusí končit zbytečně vyprázdněnou baterií (receding horizon). - **Masky `allow_charge` / `allow_discharge_export` (anti-mikrocyklování):** generuje `ems.fn_load_planning_slots_full`. Důležité: pokud rolling replan startuje s baterií na 100 %, `allow_charge` se nesmí stát globálně `false` pro celý horizont – jinak solver nemá motivaci baterii před PV špičkou „uvolnit“ (headroom), protože ji pak nesmí z PV znovu nabít. Aktuálně se v tomto případě `allow_charge` ponechá povolené alespoň pro sloty s `pv_surplus_w > 0`. - **Runtime guard v exportu setpointů (legacy):** - při `AUTO` + `is_predicted_price=true` se na exportní vrstvě vynutí PASSIVE/no-export chování (u nových plánů by `is_predicted_price` v horizontu nemělo nastat). - **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í. - **Výběr exportních slotů (`allow_discharge_export`):** `ems.fn_load_planning_slots_full` omezuje, ve kterých slotech smí solver vybíjet baterii „nad rámec spotřeby“ pro export do sítě (anti-mikrocyklování). Aktuálně se sloty pro exportní vybíjení vybírají **globálně** podle `sell_price desc` přes celé okno (ne 50/50 AM/PM), aby solver neodkládal vybíjení do levnějších ranních slotů, pokud jsou dražší sloty už večer. - **Záporná nákupní cena:** - horní mez `grid_import` zahrnuje `load_baseline_w` + nabíjení/EV/TČ (bez nekonečného importu). - **Záporná prodejní cena → tvrdý zákaz vývozu (`ge = 0`)** (`planning_engine.solve_dispatch`): platí ve slotu kde `sell_price < 0`, pokud lokality zapne některou z opcí — - **`asset_inverter.deye_gen_microinverter_cutoff_enabled`** (`deye-main`) — spojeno s MILP binárkami GEN cut-off (BA81), - **nebo** **`ems.site_grid_connection.block_export_on_negative_sell`** (migrace **V074**, default **false**) — bez GEN registrů na Deye; vhodné např. pro **KV1** (fixní nákup, bez nutnosti vést výkon neriťitelného pole B do sítě). **home-01** nech **false**, jinak může být horizont při přebytku z pole B a plné/nedostupné baterii **infeasible** (solver export potřebuje jako fyzikální ventil tam, kde FVE B nelze štípnout ani odpojit modelem bez GEN řádku). - **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. - **Kalibrace PV forecastu (delta profil):** tabulka `ems.site_pv_forecast_calibration` drží per `site_id` mimo jiné `delta_learn_min_ts` (dolní mez řádků z `forecast_accuracy` pro učení delty), volitelně `pv_curtailment_policy_effective_from` a přepsání parametrů (`top_n_days`, `half_life_days`, …; **V076** navíc `reference_day_weight_mult` pro „připnuté“ dny níže). **`ems.site_pv_forecast_reference_day`** (**V076**) umožňuje zvýšit váhu konkrétních kalendářních dnů (datum ve `site.timezone` jako u časování slotů) při agregaci δ z `forecast_accuracy` (`fn_pv_forecast_delta_profile`); hromadný zápis **`ems.fn_pv_forecast_sync_reference_days`**, detail **`docs/04-modules/forecast.md`**. `ems.fn_fill_forecast_accuracy` nastavuje `learning_eligible` / `learning_exclude_reason` (sloty před cutoffem, nebo se škrcením / gen cut-off / záznamem v `ems.cutoff_switch_log` po účinnosti policy se z učení vyřadí; u škrcení zůstává `actual_power_w` NULL). Telemetrie: `ems.telemetry_inverter.is_export_limited` nebo `pv_derating_flags <> 0` v okně 15min → stejné vyloučení (`telemetry_derating`). `ems.fn_pv_forecast_delta_profile` vrací `deltas_by_array` i součtové `deltas`; `ems.fn_load_planning_slots_full` aplikuje stejnou **per-pole** korekci jako UI (`fn_forecast_pv_slots_range_corrected`); pokud v JSON profilu chybí `deltas_by_array`, použije se souhrnné `deltas` rozpuštěné podle podílu výkonu pole na slotu (solver má tak stále použitou korekci i bez per-pole JSON). Solver optimalizuje celý horizont (typicky do konce známých OTE dat, strop z `fn_planning_horizon_end`) 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 ### Verifikace (DB) Pro kontrolu masek nabíjení: ```sql select * from ems.fn_load_planning_slots_full(, , , ) where allow_charge is true order by interval_start; ``` - Pokud `current_soc_wh` odpovídá plné baterii (`soc_max_wh`), měly by být `allow_charge=true` alespoň sloty s PV přebytkem (`pv_surplus_w > 0`). --- ## 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Č) - Bez tvrdého zákazu `ge = 0` při záporném výkupu (viz výše **`block_export_on_negative_sell` / GEN cut-off**) může MILP vývoz zvolit i ekonomicky proti „bonusové“ náplni baterie; u **home-01** jde o záměrný trade-off (zelený bonus pole B, prostor baterie na záporný nákup). S **`block_export_on_negative_sell = true`** (typicky **KV1**) musí přebytek jít do baterie / curtail A, ne do sítě. > Poznámka: výše platí pro **home-01** (pv-b jako ongrid GEN se zeleným bonusem), kde pole B **nechceme curtailovat**. > U instalací typu **BA81** je na GEN portu typicky **AC coupling (mikroinvertory)** bez bonusu – výkon nelze plynule škrtit, > ale lze ho **tvrdě odpojit (cut-off)** přes Deye reg **179** (viz `modbus-registers.md`). To je samostatná logika níže. ### 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 #### Plánovací strop `gi[t]` vs. fyzický jistič V LP má `grid_import[t]` (proměnná `gi`) horní mez **`max_import_power_w + battery.max_charge_power_w`**, ne jen `max_import_power_w`. Důvod: - Ceny se mění co 15 min a cílem je nabíjet baterii v **cenově nejlepších oknech** na BMS max (17–18 kW), i když baseline zátěž doma navíc sežere část jističe. - O fyzické dodržení jističe se stará **Deye reg 128** (grid charge current) + firmware — v reálném čase sníží `bc`, když `load + bc` přesáhne breaker. - Pokud bychom `gi[t] ≤ max_import_power_w` nechali jako tvrdé LP omezení, LP by v slotech s vyšší `load_baseline_w` zbytečně osekával `bc` dolů (viděno např. 2026-04-19 13:30: load 3.7 kW, breaker 17 kW → `bc ≤ 17 − 3.7 + pv_b ≈ 14.7 kW`, i když BMS zvládne 18 kW). Optimistický `gi` horní strop umožní plánovat plné využití BMS v cenových oknech; reálný HW nikdy nepřetáhne jistič. - **Trade-off**: `expected_cost` v plánu může být mírně optimistický (LP spočítá s ~20 kW importem, reálně občas míň kvůli skokům domácí zátěže). Rozdíl se automaticky dohání rolling replanem co 15 min. --- ## 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 + bms_max_charge) | Nákup ze sítě v W; breaker fyzicky drží Deye reg 128 | | `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). # Výjimka: pokud existuje PV B a v budoucnu v horizontu nastane buy < 0, pak v okně sell < 0 # solver preferuje curtail PV A před placeným exportem (penalizace curtailmentu se v těchto slotech snižuje na 0). + 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 + battery.max_charge_power_w # LP soft; fyzicky drží Deye reg 128 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. ``` ### BA81 / GEN port (mikroinvertory): kdy dává smysl „Grid export cut-off“ Kontext (instalace typu BA81): - **PV1/PV2** (DC stringy na Deye) jsou **řiditelné** – při zákazu exportu je Deye umí stáhnout až k nule. - **GEN port** (AC coupling / mikroinvertory) **řiditelný výkonově není** – vyrábí „co dá slunce“. Při `sell_price < 0` tedy nastává problém: - baterie má **omezený nabíjecí výkon** (např. BA81 cca **6 kW**) a navíc při vysokém SoC má reálně menší „přijímací schopnost“, - pokud výroba na GEN portu převýší okamžitou spotřebu + možný charge do baterie, zbytek fyzicky teče do sítě (nechtěný export za zápornou cenu). Řešení na hardware úrovni: - **Deye reg 178 bits0–1** („MI export to Grid cutoff“, často uváděno jako “register 179” v 1-based značení) umožní GEN port **tvrdě odpojit**. #### Správné rozhodovací pravidlo (záměr) Cut-off nechceme spínat „vždy když sell<0“, protože při zataženu / malé výrobě jsou i malé watty z GEN užitečné. Chceme spínat pouze tehdy, když je v daném slotu očekávaný **přebytek z GEN**, který není kam dát: \[ pv\_gen\_w \;>\; load\_w \;+\; batt\_charge\_cap\_w \;+\; flexible\_load\_w \] kde: - `pv_gen_w` ≈ `pv_b_forecast_solver_w` (GEN/mikroinvertory) - `batt_charge_cap_w` = min(`battery.max_charge_power_w`, \((soc_{max}-soc)_{wh} / 0.25h\)) – tj. výkonově omezené a SoC-headroom omezené - `flexible_load_w` = plánované EV/TČ setpointy v daném slotu (pokud jsou připojené / povolené) #### Implementace v EMS (aktuální chování) - Cut-off se řeší přímo v LP binární proměnnou `z_gen_cutoff[t]` (0/1), která modeluje, zda je GEN port odpojen. - Efektivní výkon z GEN do bilance: `pv_b_effective[t] = pv_b_forecast_w * (1 - z_gen_cutoff[t])` - Solver nechá GEN připojený vždy, když je výkon užitečný (sníží import / nabije baterii / pokryje zátěž). - `z_gen_cutoff[t]` je **vůbec povolené jen** v režimech/politikách, kde to dává smysl: - `SELF_SUSTAIN` - sloty s `sell_price < 0` („BLOCK_EXPORT“ okna) - (případně) explicitní `no_export` politika, pokud je v kontextu dostupná Mimo tyto případy je `z_gen_cutoff[t]` vynucené na `0`. - Cut-off je v účelové funkci **penalizované** (za „zahozenou“ GEN výrobu), aby se zapínalo jen jako poslední možnost. - Výstup se ukládá do `planning_interval.deye_gen_cutoff_enabled` (nullable) a exporter pak nastaví bity reg 178. **Scope / bezpečnost:** proměnná i flag existují jen na lokalitách, kde je zapnutý `asset_inverter.deye_gen_microinverter_cutoff_enabled` (tj. kde je GEN port s mikroinvertory reálně zapojen). Jinde se nic neřeší ani nezobrazuje. ### 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é --- # gi horní mez = breaker + BMS max_charge (LP optimistický strop, Deye reg 128 chrání fyzicky) gi_upper = grid.max_import_power_w + battery.max_charge_power_w grid_import = [pulp.LpVariable(f"gi_{t}", 0, gi_upper) 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.'; ``` **Fyzická realizace na hybridu (bez změny solveru):** při `ems.fn_site_has_active_green_bonus_pv(site_id)` a nenulovém součtu `nominal_power_wp` controllable polí na invertoru exportér mapuje `pv_a_forecast_solver_w` / `pv_a_curtailed_w` na zápis **holding registru 340** (max solar power, W) — viz [`control.md`](control.md) sekce *PV A curtailment* a [`modbus-registers.md`](modbus-registers.md) reg 340. --- ## Tuning pro malé baterie (např. BA81) ### Terminal SoC shadow price (kritický parametr) V účelové funkci LP je člen **„terminal SoC shadow price“**: energie ponechaná v baterii na konci horizontu je oceněná jako záporný příspěvek k nákladům (solver má motivaci držet část SoC, pokud se to ekonomicky vyplatí oproti okamžitému vývozu / nákupu). **Výpočet (zjednodušeně):** `terminal_soc_kcz_per_wh = (průměr buy v prvních 24 h slotů) × planner_terminal_soc_value_factor / 1000` a v objective se přičítá `- terminal_soc_kcz_per_wh × soc[T−1]` (viz `solve_dispatch` v `backend/services/planning_engine.py`). **Kde se bere faktor (jediný kanonický zdroj):** 1. Sloupec **`ems.asset_battery.planner_terminal_soc_value_factor`** (`NOT NULL`, default **0.9** — migrace **V062**, idempotentní upevnění **V069**). 2. Hodnota se do solveru dostává výhradně přes **`ems.fn_planning_site_context(site_id)`** → pole `battery.planner_terminal_soc_value_factor` v JSONu. 3. Backend v **`_load_site_context()`** mapuje JSON na `SimpleNamespace` a **`solve_dispatch()` už nemá žádný skrytý fallback z kódu** — chybí-li klíč v JSONu, je to chyba konfigurace / nasazení. > **Historická chyba (opraveno):** dříve `fn_planning_site_context` sloupec z tabulky **nepropisoval** do `battery` JSONu a Python atribut **vůbec nenačítal**, takže se v praxi používala **pevná 0.9** z kódu bez ohledu na DB. To umělo zcela převrátit chování (např. BA81 s **0.2** v tabulce se chovalo jako **0.9**). Po opravě musí projít **repeatable** `R__039_fn_planning_site_context.sql` i backend. ### Doporučené hodnoty Pokud solver „šetří baterku“ a raději importuje ze sítě (kvůli terminal SoC shadow price), lze per baterii upravit váhu této kotvy: - `ems.asset_battery.planner_terminal_soc_value_factor` - **`0.0`** = žádná motivace držet SoC na konci horizontu (agresivnější arbitráž / vybití) - **`0.9`** = výchozí default v DB (konzervativnější držení energie) Pro BA81 typicky dává smysl menší hodnota (např. **0–0.3**), aby solver klidně „vylil“ baterii do sítě při kladné `sell_price` a nechal si kapacitu na nabití v oknech záporných cen. ## Konfigurace (env proměnné) ```env 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ů)