Files
ems/docs/04-modules/planning.md
Dusan Vojacek 877f5b6180
Some checks failed
CI and deploy / migration-check (push) Failing after 18s
CI and deploy / deploy (push) Has been skipped
tuning prodeje
2026-05-29 22:45:02 +02:00

753 lines
55 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).
- **SoC kontinuita a export z baterie:** `soc[t]` klesá při **`bd[t]`** — výkon vybíjení na AC sběrnici z energetické bilance `pv + gi + bd = load + bc + ge`. Při exportu z baterie je v `bd` už započten i tok do sítě (`ge_bat` je součást `ge`); **`ge_bat` se v SoC znovu neodečítá** (dříve double-count → plán klesal ~2× rychleji než BMS ve večerním exportu). Tag `2026-05-28-evening-export-soc-balance-v39`.
- **Masky `allow_charge` / `allow_discharge_export` (tenký anti-mikrocyklus):** generuje `ems.fn_load_planning_slots_full` (`R__063`). Ekonomiku primárně řídí LP podle efektivních cen; masky jen omezují počet slotů pro grid nabíjení / export baterie.
- **PV-surplus (vrstva A):** ranking dle **`store_score DESC`** = `future_sell_opportunity sell max(0, buysell)`; jen sloty s `sell ≥ buy degradation`. Kumulativní PV pokrývá `grid_target` (deficit SoC, nad `reserve_soc` bez násobení `charge_slot_buffer`). Zbytek → `allow_charge=false` (PV jen do sítě / `bc ≤ pv_surplus` v LP).
- **Grid ze sítě (vrstva B, před FVE):** výchozí **AM/PM 50/50** z `grid_target × charge_slot_buffer` (do `soc_max`); **nevyčerpaný AM Wh přejde do PM** (`R__063`). **Spot:** výběr **nejlevnější `buy`** (den plánu → před exportním oknem → `buy ASC`); navíc všechny sloty s **`buy < 0`** → `allow_grid_charge`. Po výběru AM/PM běží **iterativní self-konzistentní filtr** (vyloučí drahé grid sloty, pokud `pv_charge_wh_ahead + neg_buy_wh_ahead >= 60 %` deficitu SoC; failsafe unlock). Debug: `grid_charge_suppressed_reason`. **Fixní tarif (BA81):** stejný AM/PM rozpočet, ale pořadí podle **`slot_ord`** (buy konstantní), jen pokud v horizontu existuje **`sell > buy + degradation`**; jinak jen PV vrstva A. Cap slotů: `ceil(budget/per_slot_wh) × charge_slot_buffer`. **`charge_acquisition`:** vážený `buy` u `allow_grid_charge` před 1. exportem; two-pass v `planning_engine.py`.
- **PV vrstva A:** při `sell ≥ 0` jen pokud `sell ≥ future_sell_opportunity degradation` (držet FVE na večerní peak). Při **`sell < 0`** vrstva A **bez** tohoto filtru (nabít z FVE v záporném výkupním okně). Historie: [`docs/planning-changelog.md`](../planning-changelog.md).
- **LP (AUTO):** objective explicitně `ge_pv×sell ge_bat×sell + ge_bat×acquisition` v exportních slotech; **bez** cross-slot vynucení `ge_pv ≥ surplus`. Guard FVE: `ge_pv=0` jen pokud `sell < charge_acquisition degrad` (ne `sell < buy` ve slotu). Viz [`planning-arbitrage-accounting.md`](planning-arbitrage-accounting.md).
- **Load-first (Deye, AUTO, tvrdý od v34):** proměnné `pv_ld` (PV → load+EV+TČ), `pv_sp` (přebytek), `bc_pv` / `bc_gi`. Plná bilance `pv_a + pv_b + gi + bd = load + ev + hp + bc + ge`; `bc_pv + ge_pv ≤ pv_sp`; **`gi ≤ bc_gi + max(0, max_load pv_forecast)`** (při vysoké FVE žádný fiktivní import = load); při `pv ≥ load + 500 W` **`pv_ld ≥ load`**; mimo `allow_discharge_export`: `bd ≤ load pv_ld`, `pv_ld ≥ load bd`. Tag `2026-05-28-load-first-hard-v34`. Test `LoadFirstDispatchTests`.
- **Tvrdé výkonové limity site/baterie:** `gi ≤ site_grid_connection.max_import_power_w` (breaker); **`bc_pv + bc_gi ≤ asset_battery.max_charge_power_w`**; **`ge ≤ max_export_power_w`** (proměnná `ge`, platí `ge = ge_pv + ge_bat`); **`bd + ge_bat ≤ asset_battery.max_discharge_power_w`** (vybíjení do domu + export z baterie nesmí současně překročit BMS). Dříve LP dovoloval import+nabíjení a dvojnásobné nabíjení; u prodeje hrozilo současné `bd` a `ge_bat` až 2× max discharge — viz `SitePowerCapTests`.
- **Hodnota FVE (PV store value):** tvrdé `ge_pv = 0` jen pokud `sell < future_sell_opportunity degradation` **a** `sell < 0` (spot), nebo u fixního tarifu dle `fixed_pv_b_export_cap`. Při **`sell ≥ 0` (spot home-01, KV1):** `ge_pv` **neblokuje** pv_store — solver volí export vs. `bc_pv` podle `ge_pv×sell` a degradace; **baterii** na večerní peak drží `ge_bat` (`evening_early` / push), ne curtail FVE. **v31:** při `sell ≥ 0` + PV přebytek **není** plný `ge_bat` push z `pre_neg_buy_discharge` / ranních shortfallů (export cap pro FVE). **Před prvním `sell < 0`:** `allow_pre_neg_pv_export`. Tag `2026-05-28-morning-pv-export-priority-v31`. Testy `Home01PvStoreValueTests`, `PreNegativeSellExportTests`.
- **Drahý nákup → vlastní spotřeba z baterie:** mimo `allow_charge` platí `bd + pv_ld ≥ load_baseline + hp[t]` a `gi ≤ EV + hp[t]` (ne `hp_rated`). **Spot:** drahý slot = `buy > min(buy≥0) + degradace`. **Fixní nákup (DB `purchase_pricing_mode=fixed` nebo heuristika rozptylu buy &lt; 0,25):** navíc `buy > charge_acquisition + degradace`. Na spotu **nesmí** `charge_acquisition` (~0,9 Kč) označit všechny sloty jako drahé → Infeasible (home-01). Při **Infeasible** solver jednou opakuje s `relaxed_expensive_import` (síť smí krmit baseload v drahých slotech; v `solver_params.inputs.relaxed_expensive_import=true`). Testy `AutoPassiveSelfConsumptionTests`, `test_spot_low_acquisition_does_not_mark_all_slots_expensive`, `test_negative_buy_in_horizon_does_not_block_all_grid_import`.
- **Záporný výkup (`sell < 0`) bez exportu:** `block_export_on_negative_sell` (KV1) **nebo** `purchase_pricing_mode=fixed` (BA81). **Spot (home-01):** `ge_pv=0` dokud není plná baterie; při plné jen ventil pole B (`ge_pv ≤ pv_b`, `w_pv_b_vent_neg`); výboj baterie při `sell<0` jen **12 slotů** před `buy ≤ planner_extreme_buy_threshold` (default 2), pokud spread do budoucna dává smysl — tag `2026-05-26-neg-sell-bat-dump-extreme-buy-v11`. Večerní discharge maska u spotu: denní peak ≥17:00 (ne `sell > ref_buy` v slotu).
- **Pole B při sell&lt;0 (home-01):** pokud `block_export_on_negative_sell = false`, LP nesmí vynutit `ge_pv = 0` (přebytek neriťitelného PV B). KV1 s `block_export = true` jen curtail A / nabíjení.
- **`ref_buy_min` (brána exportu):** `min(buy_price)` horizontu — jen „existuje levný nákup?“, **ne** průměrná cena nabití přes hodiny. Export sloty: `sell > ref_buy_min + degradation` (spot). Viz [`planning-arbitrage-accounting.md`](planning-arbitrage-accounting.md).
- Pokud `energy_to_fill <= 0` nebo `charge_slot_buffer = 0`: všechny sloty povoleny.
- **LP ekonomické guardy** (`solve_dispatch`, AUTO): `ge_pv=0` pokud `sell < charge_acquisition degradation` (výjimka: plná baterie, přebytek **pv_b**). Pokud `buy > min(buy)+degradation` mimo charge masku → `gi` jen na load+EV+TČ. Viz `planning_engine.py` po slot pre-selection.
- **Denní safety charge (měkké LP, ne maska):** `fn_load_planning_slots_full` (**V077+**) vrací navíc odhad nočního baseload Wh (20:0006:00 Europe/Prague), buffer % z `asset_battery.planner_night_baseload_buffer_percent`, lookahead `future_*_czk_kwh`, volitelný `safety_soc_target_wh` (619) a flag `is_daytime_pv_surplus_slot`.\n+\n+ V solveru (`planning_engine.solve_dispatch()`):\n+ - `safety_soc_target_wh` se používá primárně jako **ochrana exportu z baterie**: v běžných slotech (mimo highsell špičky) se při aktivním exportu vynutí `soc[t] ≥ max(arb_base_wh, safety_soc_target_wh)`.\n+ - safety deficit penalizace v objective běží jen v `is_daytime_pv_surplus_slot` (a ne v highsell špičce), aby solver neměl motivaci dělat obecné „nabij co nejdřív“ chování.\n+ 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` a současně **není výrazný export** (`grid_setpoint_w ≥ 500`). To je záměr: commitment má kotvit „nabíjení z PV přebytku“, ne „charge while exporting“. Měkká penalizace proti snížení `bc[t]` oproti předchozímu plánu je řízená `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 **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] ≥ export_soc_floor_wh`, kde:\n+ - při hluboké relaxaci (`soc_panel_min` pod `min_soc`) je `export_soc_floor_wh = soc_panel_min[t]`,\n+ - jinak je `export_soc_floor_wh = arb_base_wh`, a v běžných slotech se safety targetem navíc `max(arb_base_wh, safety_soc_target_wh)` (mimo highsell špičky). `arb_floor_series` se pro `z_export` nepoužívá.
- `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` (`R__063`). Tři vrstvy:
1. **Globální rozpočet Wh** (`discharge_slot_buffer × exportovatelná kapacita`): sloty podle `sell_price desc`. Před prvním `sell < 0` se z rozpočtu **vynechají** sloty, kde **později tentýž den** existuje `sell` vyšší o více než `degradation` (OTE, ne pevné hodiny 0004).
2. **Večerní špičky per den:** `sell ≥ max(sell) degradation` jen pro hodiny **≥ 17** (Prague), ne globální max horizontu (jinak by vyhrála půlnoc 3,7 Kč místo večera).
3. **Ranní pásmo před prvním `sell < 0`:** hodiny **511** téhož kalendářního dne — všechny sloty s `sell ≥ lokální_max_ráno degradation`; ostatní sloty mezi ranním pásmem a prvním `sell < 0` s nižším sell mají export **zakázán** (žádný dump v 07:30 za 2 Kč). **`charge_acquisition`:** vážený `buy` před prvním exportem **téhož dne** jako záporné výkupní okno.
**Planner tag v25:** v24 + **dvoufáze před `buy<0`:** u posledního `sell≥0` cíl `soc≈max` (bez exportu); mezi tím a `buy<0` výboj na `_pre_neg_buy_soc_ceiling_wh`; v okně `buy<0` jen import (`bc_pv=0`), **curtail PV A jen při `buy<0`**; ranní `sell<0` před `buy<0` smí PV→bat. Viz changelog v25.
**Planner tag v24:** v23 + **večerní tvrdý push** podle rozpočtu Wh (`discharge_slot_buffer`, SoC nad `min_soc`, `per_slot_discharge`) — bez pevného top-3 / `len≥2`. Viz changelog v24.
**Planner tag v26:** v25 + upřesnění večerního exportu — viz sekce **Večerní export z baterie** níže a changelog v26.
**Planner tag v23:** v22b + **výboj baterie do sítě** před `buy<0` (`_pre_neg_buy_discharge_indices`, sell≥1 Kč/kWh, push `ge_bat` z DB limitů). Viz changelog v23.
V `solve_dispatch` (AUTO): **`charge_slots`** = `allow_charge` z DB + **`buy < 0`** + všechny sloty **`sell < 0`** s PV přebytkem > 500 W (i bez `block_export_on_negative_sell`, BA81). **`pv_charge_shortfall`** / **`NEG_SELL_CURTAIL_PENALTY`** platí v těchto slotech. Při **`sell < 0`** (legacy): safety deficit cílí **`soc_max_wh`**; po posledním **`sell < 0`**: **`post_neg_pv_topup`**. **Planner tag v32:** fázované SoC — viz níže.
- **Záporný výkup — strategie home-01 (v32v40 prep hotovo):** **[`planning-neg-sell-strategy.md`](planning-neg-sell-strategy.md)**. **v35:** rampa B. **v36 prep:** oprava **T**, pre-neg per den (cushion A+B), večer D1. **v40:** cushion a večerní výboj z **`observed_soc_wh`** (telemetrie), rozpočet `neg_evening_export_budget_wh` (`2026-05-29-neg-prep-observed-soc-v40`). **v36 termika** (TČ/TUV) — otevřeno.
- **Před sell&lt;0 — export FVE s forecast pojistkou (v33):** `_pre_neg_pv_export_forecast_cushion_ok` — export FVE v kladných slotech před prvním `sell<0` jen pokud součet predikovaného PV přebytku v sell&lt;0 okně (týž pražský den) pokryje dobítí na prep SoC (× **1,15**). Jinak LP raději nabíjí z FVE (riziko deště). Při splněné pojistce: `bc_pv=0` v `pre_neg_pv_export_ts`, shortfall na `ge_pv`, penalizace `bc_pv`. `solver_params.inputs.pre_neg_pv_export_forecast_ok`, `pre_neg_pv_export_slots`. Testy `PreNegPvExportForecastTests`. U **fixního tarifu** s polem B: **`ge_pv ≤ pv_b`** (ne pv_store **`ge_pv = 0`**). Při **`deye_gen_microinverter_cutoff_enabled`**: **`ge == 0` jen** pokud **`block_export_on_negative_sell`** (KV1), ne kvůli samotnému `z_gen_cutoff` (BA81 musí moci exportovat B při plné baterii). Vstupní **`soc_wh`** z telemetrie se před MILP omezí přes **`_planner_soc_for_solver`** (rezerva ~650 Wh pod `soc_max`, jinak Infeasible při 100 % SoC a dlouhém záporném výkupu). **`planner_build_tag`** v `solver_params`. Changelog: [`docs/planning-changelog.md`](../planning-changelog.md).
- **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.
- **Kanonický PV forecast (delta + rolling):** 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`).\n+\n+ **Single source of truth pro solver i UI** je `ems.fn_forecast_pv_slots_range_canonical_ab`, která v jednom místě kombinuje:\n+ - delta profil (aditivní odečet per-array)\n+ - rolling multiplikativní faktor vs telemetrie (`fn_pv_forecast_correction_factor`) s decay.\n+ `ems.fn_load_planning_slots_full` bere PV A/B z této kanonické funkce; UI je čte z `/plan/current` (bundle obsahuje `pv_*_forecast_solver_w` i `pv_forecast_total_w` jako součet).
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
### Večerní / noční export z baterie (v24v30) — co plánovač dělá a co ne
Cíl zůstává **maximální ekonomický užitek v celém horizontu**: prodat (a nabít) v časech, kdy to dává smysl podle cen a kapacity baterie. **v30:** noční okno **přes půlnoc** (17:00 → 05:00 Prague), konec při **východu FVE** (`pv_a+pv_b > load + 500 W`); **tvrdý push baterie** jen v tmavých slotech, ne po východu slunce.
#### Co se řeší jinde (není „večerní v26“)
| Čas / situace | Kde v kódu / SQL | Příklad |
|---------------|------------------|---------|
| Ráno **511** před prvním `sell < 0` | R__063 ranní pásmo + LP `morning_pre_neg_export_ts` | Export před záporným výkupním oknem, ne „před FVE“ jako takové |
| Odpoledne / noc, obecně profitable | `allow_discharge_export` z rozpočtu Wh + LP `peak_export_shortfall` | Kdekoliv v horizontu, pokud marže sedí |
| **≥ 17:00** večer + **05:00** (v30) | v24 Wh push + v26/v28 + **noční peak přes půlnoc** | OTE špička i kolem půlnoci |
| Po východu FVE | konec nočního okna | push / peak jen `pv` pod prahem |
#### Tři vrstvy nočního chování (v30: 17:00 → půlnoc → do východu FVE)
```mermaid
flowchart TD
A[LP: globální optimum v horizontu] --> B{slot >= 17h a profitable export?}
B -->|sell pod nocnim max - 0.05| C[ge_bat = 0: baterie ne pred spickou]
B -->|profitable + peak band noc| D[push: sell desc az do Wh rozpoctu]
D --> F[ge_bat >= plny vykon na cap v kazdem push slotu]
C --> G[Vysledek: energie zustane na nejdrazsi vecer]
F --> G
```
1. **SQL masky (R__063, vrstva 2)** — které večerní sloty *smí* export z baterie vůbec (`allow_discharge_export`): mimo jiné sloty v pásmu „denní večerní max degrad“ (SQL), plus globální Wh rozpočet (vrstva 1).
2. **v41 — zákaz večerního vývozu mimo špičku** (`evening_early_export_penalty_ts` → tvrdé `ge_bat[t] = 0`):
- v **celém nočním okně** pro **všechny** sloty s `allow_discharge_export` **mimo** `evening_push_ts` (výjimky: pre-neg / neg-evening větve);
- **nezakazuje** přebytek FVE do sítě (`ge_pv`).
3. **v41 — plný výkon jen ve slotech s max sell v nočním úseku** (`evening_push_ts`):
- kandidáti: profitable ∩ noční okno ∩ **`sell = max` v nočním úseku** (ne široké pásmo peakdegrad);
- push = nejdražší kandidáti **`sell` desc**, dokud `kumulované_Wh ≤ push_budget`; `per_slot` ≈ min(BMS, export cap) × účinnost × 0,25 h;
- **`peak_export_shortfall`** se v nočním okně neaplikuje (jinak LP rozplizňoval export do levnějších sousedních slotů);
- **v28 push fyzika:** cap `ge_bat ≈ min(export_cap, max_discharge load)`;
- **výsledek:** plný export (~13,5 kW u home-01) v max-sell slotu; **sousední levnější sloty neprodávají** (`export_mode=NONE`).
**Není to** „prodávat v každém lehce ziskovém večerním slotu“ — je to „prodávat **plným výkonem** jen ve **max-sell** nočních slotech, kolik unese baterie“.
#### Co v26 opravilo oproti starému chování
| Dříve (problém) | Po v26 |
|-----------------|--------|
| Push kandidáti = široké pásmo `max degrad` (~15 haléřů) → vývoz i v 17:30 za 3,5 Kč | Push jen u `max 0,05` Kč/kWh |
| Měkká `peak_export_shortfall` → často ~50 % výkonu v mnoha slotech | Na `evening_push` slotech tvrdý push na cap; shortfall na push vypnutý |
| `grid_setpoint = gi ge` → Deye vidí ~0 W při velkém `ge_bat` | `_dispatch_grid_setpoint_w` z reálného exportu |
**Funkce:** `_evening_push_peak_candidates`, `_evening_battery_export_push_indices`, `_evening_early_export_penalty_indices`, `_rolling_evening_push_override`, `_evening_push_discharge_budget_wh`, `_evening_push_battery_export_w`, `_dispatch_grid_setpoint_w` v `planning_engine.py`. Tag: **`2026-05-29-evening-peak-only-export-v41`** (dříve v38).
### Arbitráž baterie — účtování mezi sloty (povinné čtení)
**Detail:** [`planning-arbitrage-accounting.md`](planning-arbitrage-accounting.md).
- **Nesmysl:** řídit arbitráž tak, že v **jednom 15min slotu** porovnáváme `buy[t]` a `sell[t]` jako nákup a prodej **téže** kWh z baterie. Ve výprodejním okně (např. sell 4,6 Kč, buy 7 Kč) je LP marginalně proti exportu, i když energie byla nabitá v poledne za ~0,7 Kč.
- **`min(buy)` horizontu není nákupní cena zásoby** — je to **jeden** čtvrthodinový slot; u home-01 lze nabíjet **hodiny** (64 kWh, až 17 kW ze site ≈ 4,25 kWh/slot). Acquisition cost musí vycházet z **nabíjecího okna** (průměr / vážený průměr / N nejlevnějších slotů podle potřebných Wh), ne z jednoho minima.
- Dnešní `ref_buy = min(buy)` ve maskách je jen **hrubá brána** pro výběr slotů, ne model zisku z cyklu.
- **Arbitráž baterie:** `charge_acquisition_buy_czk_kwh` z `fn_load_planning_slots_full` (vážený grid+FVE před `charge_acquisition_cutoff_at`); LP přičítá `ge_bat × acquisition` v `allow_discharge_export`. Detail: [`planning-arbitrage-accounting.md`](planning-arbitrage-accounting.md).
### 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;
```
- PV-surplus: `allow_charge=true` pro nejvyšší `store_score`, dokud se nepokryje `grid_target`.
- Non-PV: levný `buy`, lookahead 4 sloty, cap 6/segment; OTE před predikovanými.
- Pokud `current_soc_wh` odpovídá plné baterii (`soc_max_wh`), jsou povoleny všechny sloty.
---
## 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
# battery_discharge = bd (W z baterie na AC sběrnici z bilance pv+gi+bd = load+bc+ge).
# ge_bat je součást ge — v SoC znovu neodečítat (v39).
soc[t] == soc[t-1]
+ (bc_pv[t] + bc_gi[t]) * charge_efficiency * interval_h
- bd[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)
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[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
PLANNING_ENGINE_VERSION=v1 # v1 = původní planner, v2 = nová policy větev
PLANNING_ENGINE_COMPARE_ENABLED=false # true = spočítat i druhou verzi a uložit comparison do solver_params
```
> **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ů)
---
## Planner v2
Tahle sekce popisuje návrh druhé verze planneru. Cíl je mít samostatný solver, který bude vycházet ze stejného vstupu a bude zapisovat do stejného `planning_interval`, ale provozní pravidla budou čitelné a striktně dané zadáním.
### Význam hranic SoC
- `reserve_soc_percent` = ranní cílová hranice, na kterou se má baterie dobít, pokud to denní forecast a ceny umožňují
- `min_soc_percent` = fyzická / TOU podlaha, pod kterou baterie nesmí klesnout
- `reserve_soc_percent` je tedy provozní kotva pro den, zatímco `min_soc_percent` je tvrdé minimum
- `reserve_soc_percent` není predikce noční spotřeby; jen znamená „než začne export z FVE do sítě, drž baterii aspoň sem“
### Základní pravidla v2
#### Ráno
- pokud denní forecast dává dostatek výroby nebo levných hodin, planner dobije baterii minimálně na `reserve_soc_percent`
- tato rezerva slouží jako ochrana proti neplánované spotřebě během dne
- `min_soc_percent` se v ranní fázi nepoužívá jako cíl, ale jen jako spodní limit
#### Záporná nákupní cena
- při `buy_price < 0` má prioritu nabíjení ze sítě
- cílem je uložit levnou energii pro pozdější dražší prodej
- to ale neznamená, že se má baterie dobít hned v první záporné hodině; pokud jsou v horizontu ještě zápornější ceny, může být lepší nabíjet později
- nabíjení ze sítě je omezené jen fyzickými limity baterie a připojení
#### Záporná prodejní cena
- při `sell_price < 0` je export do sítě zakázán
- řiditelná FVE A se může škrtit
- neřiditelná FVE B se neškrtí, pouze se povinně zohlední v bilanci
- baterie se nejdřív nabíjí z přebytku FVE, potom se využije flexibilní spotřeba
- pokud je potřeba uvolnit místo pro pozdější extrémně záporné ceny, může planner baterii předem záměrně mírně vybít až na bezpečnou ekonomickou podlahu
#### Nezáporná prodejní cena
- věta „prodám vše“ v tomto návrhu neznamená povinné okamžité vybití baterie
- znamená pouze to, že pokud je baterie už plná z levných nebo záporných hodin, přebytek FVE A jde do sítě
- pokud ještě dává větší smysl uložit energii pro pozdější dražší prodej, má přednost uložení do baterie
- dynamické zátěže jako TUV a wallbox zůstávají plně součástí bilance; jejich spotřeba může být využita jako další „úložiště“ levné energie
#### Prodej z baterie
- při cenové špičce má baterie prodávat do sítě
- v2 má využít baterii jako arbitrážní zásobník mezi levnými a drahými okny
- vybíjení nesmí klesnout pod `min_soc_percent`
#### PV A a PV B
- PV A je řiditelná a může být curtailovaná
- PV B je neřiditelná a nikdy se neplánuje jako curtailovaná výroba
- PV B je vždy pevný vstup do bilance
#### BA81 / GEN cutoff
- v lokalitě BA81 může být zapnutý `deye_gen_microinverter_cutoff_enabled`
- pokud by při záporné prodejní ceně nebo no-export politice vznikal nežádoucí export z GEN portu, planner v2 musí umět aktivovat cutoff mikroinvertoru
- cutoff má být součást rozhodnutí planneru, ne dodatečná heuristika v exporteru
### Co má být v plánu zapsané
Planner v2 má do `planning_interval` zapisovat stejné základní položky jako dosavadní verze:
- `battery_setpoint_w`
- `battery_soc_target_pct`
- `grid_setpoint_w`
- `export_limit_w`
- `export_mode`
- `deye_physical_mode`
- `deye_gen_cutoff_enabled`
- `pv_a_curtailed_w`
- `expected_cost_czk`
- `effective_buy_price`
- `effective_sell_price`
### Implementační oddělení od v1
- v1 zůstává beze změny
- v2 bude samostatný modul planneru
- přepnutí mezi v1 a v2 bude na úrovni orchestrace nebo konfigurace lokality
- exportér i control pipeline mají dál číst standardní výstup z `planning_interval`
- pokud je zapnuté `PLANNING_ENGINE_COMPARE_ENABLED`, backend spočítá obě verze nad stejným vstupem, aktivní verzi zapíše do plánu a druhou uloží i jako samostatný read-only `planning_run` se stavem `comparison`
- compare čtení jde přes `GET /api/v1/sites/{site_id}/plan/compare` → jedno volání `ems.fn_plan_compare_bundle` (aktivní plán + `fn_planning_run_debug` comparison runu)
- **Výkon `/plan/current` a `/plan/compare` (V079+):** read-model `ems.fn_plan_current_bundle` dříve při každém HTTP requestu přepočítával `fn_pv_forecast_delta_profile` nad celou historií `forecast_accuracy` (~stovky tisíc řádků na site) a kanonický PV forecast na 96 h. Od **V079** se delta profil cacheuje v `site_pv_forecast_calibration.delta_profile_cache` (refresh po `fn_fill_forecast_accuracy` a po `PATCH …/pv-forecast-calibration` přes `fn_refresh_site_pv_delta_profile_cache`; čtení přes `fn_pv_forecast_delta_profile_cached`, TTL 30 min). Kanonický PV pro graf se počítá jen za horizontem uloženého plánu (`horizon_end``horizon_start + 96 h`), ne pro sloty už v `planning_interval`. Ověření: `curl -w '%{time_total}\n' http://…/plan/current` před/po migraci; první request po deployi může být pomalý dokud cache nezaplní job (15 min) nebo ručně `select ems.fn_refresh_site_pv_delta_profile_cache(<site_id>);`
- FE stránka `frontend/src/pages/Planning.tsx` ukazuje souhrn aktivní verze, compare verze, slotové rozdíly a compare křivku baterie v grafu. Od 2026-05 navíc: **acquisition** a počty masek z `planning_run.solver_params` (blok „Solver — masky a arbitráž“), sloupce **Export** (`export_mode`) a **Masky** (⚡ `allow_charge` / ↓ `allow_discharge_export`), pásy v grafu (zelená/oranžová okna), detail slotu po kliknutí na řádek. Dashboard `StatePanel` v tooltipu Deye uvádí `export_mode` z plánu.
- fyzicky se na střídač aplikuje jen aktivní plán; compare běh slouží jen pro audit a vizualizaci
### Shrnutí v jedné větě
Planner v2 má dělat přesně toto:
- ráno držet baterii na `reserve_soc_percent`
- při záporných nákupních cenách nabíjet ze sítě
- při záporných prodejních cenách zakázat export
- při cenových špičkách prodávat z baterie
- PV A škrtit jen když je to nutné
- PV B nikdy neškrtit
- BA81 řešit přes GEN cutoff