Files
ems/docs/04-modules/planning.md
Dusan Vojacek e8eb867a2a
Some checks failed
CI and deploy / migration-check (pull_request) Failing after 15s
CI and deploy / deploy (pull_request) Has been skipped
refactor export limit semantics
2026-05-03 22:24:35 +02:00

558 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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[T1]` (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 **1112 %**; 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).
- **Export bez forecastového capu:** solver ukládá explicitní `planning_interval.export_limit_w` jako tvrdý site/inverter limit a `planning_interval.export_mode` (`NONE` / `PV_SURPLUS` / `BATTERY_SELL`). Exportér z plánu neodvozuje žádný forecastový strop exportu.
- **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(<site_id>, <from_utc>, <to_utc>, <current_soc_wh>)
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**
-**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 (1718 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 0rated_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 1112 %)
# 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 bits01** („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])),
export_limit_w = int(grid.max_export_power_w) if round(pulp.value(grid_import[t]) - pulp.value(grid_export[t])) < 0 else 0,
export_mode = "BATTERY_SELL" if round(pulp.value(batt_charge[t]) - pulp.value(batt_discharge[t])) < 0 and round(pulp.value(grid_import[t]) - pulp.value(grid_export[t])) < 0 else ("PV_SURPLUS" if round(pulp.value(grid_import[t]) - pulp.value(grid_export[t])) < 0 else "NONE"),
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[T1]` (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ř. **00.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ů)