563 lines
33 KiB
Markdown
563 lines
33 KiB
Markdown
# 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`.
|
||
- **Denní safety charge (měkké LP, ne maska):** `fn_load_planning_slots_full` (**V077+**) vrací navíc odhad nočního baseload Wh (20:00–06:00 Europe/Prague), buffer % z `asset_battery.planner_night_baseload_buffer_percent`, lookahead `future_*_czk_kwh`, volitelný `safety_soc_target_wh` (6–19) a flag `is_daytime_pv_surplus_slot`. `planning_engine.solve_dispatch()` přidá proměnné deficit vůči cíli a penalizaci `max(future_buy, future_sell) − degradace` (clamp), aby šlo prodat ve velmi drahém sell okně i přes deficit. Tvrdé `allow_charge` se kvůli tomu nemění.
|
||
- **Rolling charge commitment:** při `run_rolling_replan` se z aktivního plánu načtou sloty, kde dříve platilo `battery_setpoint_w > 500`, `pv_a+pv_b > load_baseline`, `grid_setpoint_w ≤ 0`; měkká penalizace proti snížení `bc[t]` oproti předchozímu plánu (`planner_charge_commitment_penalty_czk_kwh` na `asset_battery`). Implementace: `_load_previous_plan_charge_commitment_prev_w`, volitelný argument `charge_commitment_prev_w` u `solve_dispatch()`.
|
||
- **Debug snapshot:** každý běh ukládá JSON do `ems.planning_run.solver_params` (sekce `version`, `inputs`, `masks`, `soc_bounds`, `objective_terms`, `chosen_slots`) přes `fn_planning_run_commit` (`p_run_meta->'solver_params'`). Read-model: **`select ems.fn_planning_run_debug(<run_id>);`** (`R__087_fn_planning_run_debug.sql`).
|
||
- **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).
|
||
- **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**
|
||
- 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])),
|
||
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)
|
||
|
||
Kromě **`planner_terminal_soc_value_factor`** existují od **V077** měkké mechanismy **denní safety charge** a **rolling charge commitment** (viz výše) — malé instalace nelze spolehlivě stabilizovat jen slepým zvyšováním terminal faktoru na **0.9**.
|
||
|
||
### 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ů)
|