752 lines
55 KiB
Markdown
752 lines
55 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).
|
||
- **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, buy−sell)`; 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). **v43 `evening_arbitrage_unlock`:** grid **11–16h** jen na dnech **bez sell<0**, když večer `buy + degrad < evening_peak_sell`. **v44 `neg_day_no_grid_before_neg_sell`:** na neg den **žádný grid před 1. sell<0**. 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 < 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<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: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`.\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 high‑sell š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 high‑sell š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 **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] ≥ 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 high‑sell š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 00–04).
|
||
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 **5–11** 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 (v32–v40 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 D−1. **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<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<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 (v24–v30) — 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 → 0–5: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 **5–11** 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 + **0–5: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. **v43 — večerní push + nocí vlastní spotřeba + odpolední arbitráž** (`evening_push_ts`):
|
||
- push jen **≥17h Prague** + `allow_discharge_export`; rozpočet Wh **per kalendářní večer** (druhý den v horizontu ne prázdný);
|
||
- mimo push: **`night_self_consume_discourage`** — baterie krmí dům, ne import ~5 Kč/kWh;
|
||
- **R__063 `evening_arbitrage_unlock`:** grid nabíjení **11–16h** jen na dnech **bez sell<0**, když večerní peak sell > buy + degrad;
|
||
- **bez predawn push** (02–06h); **`peak_export_shortfall`** v noci vypnutý.
|
||
|
||
4. **v44 — neg den: místo pro FVE před sell<0 oknem:**
|
||
- **`neg_day_no_grid_before_neg_sell`:** na kalendářní den s sell<0 **žádné grid nabíjení před 1. sell<0** (ne 3 Kč ráno místo 0,5 Kč v okně);
|
||
- **`_neg_sell_pv_forecast_charge_wh`:** zpětná soc_need z **A+B** FVE, ne jen pole B;
|
||
- LP **`bc_gi=0`** před 1. sell<0 na neg den.
|
||
|
||
5. **v45 — neg okno + noc z baterie:**
|
||
- **`neg_window_grid_charge`:** v sell<0 okně neg dne grid nabíjení i bez `pv_surplus` (07:45+);
|
||
- **`night_self_consume_discourage`** na **celé** noční okno mimo push;
|
||
- při `relaxed_neg_prep_window` bez prep shortfall penalizace.
|
||
|
||
**Funkce:** … Tag: **`2026-05-29-neg-window-charge-night-v45`**.
|
||
|
||
### 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**
|
||
- 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
|
||
# 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 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
|
||
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
|