# 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 × effective_factor / 1000) × soc[T−1]` (Kč), kde `effective_factor = planner_terminal_soc_value_factor × (1 − terminal_neg_buy_weight)` a základní 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. Při **`buy<0`** v horizontu (36 h) roste **`terminal_neg_buy_weight`** s blízkostí a záporností ceny — LP nemá „šetřit“ baterii před levným importem. Úč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&lt;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&lt;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`.
  - **BA81 úsvit + MI (v51):** `fixed_pv_b_export_cap` (`ge_pv ≤ pv_b`) jen pokud **`pv_a_forecast ≥ 1500 W`** (`DAWN_LOW_PV_NO_CURTAIL_W`); při slabším A + přebytku → `fixed_mi_low_pv_surplus_export` (bez pv_store bloku). Exporter: při `forecast < 1500` a bez curtail A → **bez reg 340** (`setpoints.py`). Tag `2026-05-31-ba81-dawn-no-micro-curtail-v51`. Test `test_ba81_dawn_low_pv_no_full_curtail_for_mi_cap`.
  - **Fixní tarif — charge-slot budget (v1, 2026-06-06):** **`R__063`** vybírá nabíjecí sloty Wh kumulací; PV vrstva A u fixed = **`sell ASC`**. LP **nezakazuje** `bc_pv`/`bc_gi` prahy v58 (`sell > min+0,20`); respektuje jen `allow_charge` / `allow_grid_charge`. Večerní push: **`sell > buy + spread`** (BA81); KV1 navíc v52 morning-peak pravidlo. Debug: `charge_slot_budget` v `solver_params`, sloupce `charge_layer` / `charge_slot_reason` ve `fn_load_planning_slots_full`. Spec: **[`planning-charge-slot-budget.md`](planning-charge-slot-budget.md)**. Tag **`2026-06-06-charge-slot-budget-v1`**. v59 grid maska (min sell) a večerní push `bc=0` v push slotech zůstávají.
  - **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). **v50:** u **KV1** při `sell≥0` a PV přebytku &gt;500 W i **po** 1. `sell<0` → `ge_pv` (PV_SURPLUS), ne tvrdý `ge_bat` z večerního peak/push.
  - **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: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&lt;0 — export FVE s forecast pojistkou (v33, dočasné):** `_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`. **Plánovaná náhrada:** `pre_window_wh` + nabíjecí fronta — **[`planning-charge-slot-budget.md`](planning-charge-slot-budget.md)** §6. 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 / v49 — večerní push + nocí vlastní spotřeba + odpolední arbitráž** (`evening_push_ts`):
   - push jen **≥17h Prague** + `allow_discharge_export`; **v49:** rozpočet Wh z **aktuální SoC** jen pro **první noční epizodu** v horizontu (dnes večer → ráno), **ne** dělení se zítřejším večerem — zítřek přidá vlastní rolling replan po FVE/neg dni;
   - 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&lt;0**, když večerní peak sell &gt; buy + degrad;
   - **bez predawn push** (02–06h); **`peak_export_shortfall`** v noci vypnutý.

4. **v44 — neg den: místo pro FVE před sell&lt;0 oknem:**
   - **`neg_day_no_grid_before_neg_sell`:** na kalendářní den s sell&lt;0 **žádné grid nabíjení před 1. sell&lt;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&lt;0 na neg den.

5. **v45 — neg okno + noc z baterie:**
   - **`neg_window_grid_charge`:** v sell&lt;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_hold_only` nebo `relaxed_neg_prep_window` bez prep shortfall penalizace.

6. **v47 — po večerním pushu noc z baterie:**
   - večerní push zůstává **sell > acq+spread** (sell&lt;buy je záměr před neg dnem);
   - **`post_evening_push_night_ts`:** po pushu **bd ≥ load**, ne import ~5 Kč i při relaxed solve.

7. **v52 — KV1 večerní push (fixed + block_export):**
   - push profitabilita: **`sell ≥ max(sell 5–11 před 1. sell<0) − degrad`**, ne `sell > fixní buy + spread`;
   - **`evening_early`** beze změny — export jen v `evening_push_ts` (ne rozprostřeně po celé noci).
   - Snap: `kv1_evening_push_morning_peak_rule`. Tag **`2026-05-31-kv1-evening-push-morning-peak-v52`**.

8. **v53 — rolling hysteréze push:** při Infeasible retry se **`evening_push_ts_override` zahodí**; filtr override slotů (export maska, bez defer PV). Snap: `evening_push_override_dropped_on_retry`. Tag **`2026-05-31-evening-push-override-retry-v53`**.

9. **v54 — relaxed prep + two-pass:** při **`relaxed_neg_prep_window`** i vypočtený **`evening_push_ts = ∅`**; pass2 two-pass **nepoužívá override** a dědí relax vlajky z pass1. Tag **`2026-05-31-evening-push-relaxed-clear-v54`**.

10. **v55 — jakýkoli relaxed retry:** tvrdý push off už od **`relaxed_expensive_import`**; commitment ignorovat od **`relaxed_neg_buy_charge`**; comparison v2 **non-fatal**. Tag **`2026-05-31-evening-push-any-relaxed-v55`**.

11. **v56 — ranní tvrdý export:** `morning_pre_neg_export` / pre-neg discharge **jen strict**; pass2 Infeasible → **pass1**. Tag **`2026-05-31-morning-export-relaxed-v56`**.

12. **v57 / v64 — večerní push po rei / prep relax:** `relaxed_expensive_import` **nesmí** vymazat `evening_push_ts`; tvrdý `ge_bat` push vypnut jen při **`neg_sell_phases_fallback`** (v64: ne při `relaxed_neg_prep_window`). Tag **`2026-06-01-evening-push-keep-on-relaxed-import-v57`**, **`2026-06-06-future-neg-buy-evening-export-v64`**.

13. **v58 — fixní tarif PV vs. nabíjení (BA81/KV1):** `fixed_horizon_min_sell`; při **`sell > min + 0,20`** + PV → **`bc_pv = 0`**, export FVE; profitable noc **`sell > buy`** mimo `evening_early`. Tag **`2026-06-01-fixed-pv-export-min-sell-charge-v58`**.

14. **v59 — fixní grid jen u min sell:** `bc_gi = 0` při **`sell < buy`** nebo **`sell > min + 0,20`**; push bez charge; **`R__063`** `sell ASC`. Tag **`2026-06-01-fixed-grid-charge-min-sell-v59`**.

15. **v61 — spot: grid→bat jen při buy ≤ acq:** `sell < buy` ve slotu **není** kritérium (marže); zákaz nabíjení při **`buy > charge_acquisition + degrad`**. Zrušeno v60. Tag **`2026-06-01-spot-grid-charge-at-acq-buy-v61`**.

16. **v63 — Infeasible journal + granulární prep relax (Branch 1):**
   - Retry řetězec: strict → `relaxed_expensive_import` → `relaxed_neg_buy_charge` → **`relaxed_neg_prep_hold_only`** (jen prep hold / prep_soc shortfall) → **`relaxed_neg_prep_window`** (vypne strict pre-neg PV export bundle) → `neg_sell_phases_fallback`.
   - Snap: `relax_chain`, `relaxed_neg_prep_hold_only`.
   - Selhání po celém řetězci → `planning_run.status = failed`, sloupec `error_text`, `ems.fn_planning_run_fail` (aktivní plán se nemění).
   - Diagnostika: `scripts/diagnose_home01_infeasible.py --print-export-sql --run-id <id>`. Tag **`2026-06-06-infeasible-journal-granular-prep-relax-v63`**.

17. **v64 — future neg-buy večerní export (Branch 2, home-01):**
   - **`future_neg_buy_discharge`**: před **`buy<0`** dnem s dostatečnou FVE v **`sell<0`** zůstává neg-evening push + kotvy **`reserve_soc`** i při **`relaxed_neg_prep_window`**.
   - **`pos_sell_pre_neg_buy_ge_exempt_slots`**: večerní peak před **`buy<0`** — výjimka z `ge=0` při ekonomicky výhodném vývozu.
   - **`terminal_soc_factor_effective`**: v64 binární × **0,1** při **`future_neg_buy_discharge`** (nahrazeno v65). Tag **`2026-06-06-future-neg-buy-evening-export-v64`**.

18. **v65 — dynamický terminal SoC při future neg buy (Branch 5):**
   - **`terminal_neg_buy_weight`** (`w_neg`): `effective_factor = planner_terminal_soc_value_factor × (1 − w_neg)`; blížší a zápornější **`buy<0`** v horizontu (36 h) → vyšší `w_neg` (cap 0,95).
   - Snap: `terminal_neg_buy_weight`, `terminal_soc_factor_effective`. Tag **`2026-06-06-terminal-soc-future-neg-buy-v65`**.

**Funkce:** … home-01 **v61**; BA81/KV1 fixed **v59** (+ `R__063`).

### Rozpočet nabíjecích slotů (charge-slot-budget v1, 2026-06-06)

**Branch 3 (BA81/KV1):** `R__063` vrací `charge_target_wh`, `pre_window_wh`, `in_window_wh` a debug sloupce; fixed PV vrstva **`sell ASC`**. LP bez v58 — jen masky SQL. Večerní push fixed: **`sell > buy + spread`**. Tag **`2026-06-06-charge-slot-budget-v1`**. **Zbývá pro home-01:** pre-neg fronta místo v33 cushion, v44 změkčení — [`planning-charge-slot-budget.md`](planning-charge-slot-budget.md) §6.

### 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.
  - **Tvrdé vynucení `z_gen_cutoff[t]=1`** (tag **`2026-06-06-ba81-gen-cutoff-exec-v1`**) když LP zakazuje vývoz při `sell<0`: fixní tarif (`purchase_fixed_pre`), `block_export_on_negative_sell`, nebo `block_pv_export_neg_sell`; stejně při souběhu `buy<0` a `sell<0`. Bez toho plán ukazoval cut-off OFF, ale MI na GEN portu exportovaly (audit BA81 6. 6. 2026 ~08:00).
  - Výstup se ukládá do `planning_interval.deye_gen_cutoff_enabled` (nullable) a exporter pak nastaví bity reg 178 (viz [`control.md`](control.md) — cut-off i při `export_ban` bez solver flagu).

**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ě):**  
`effective_factor = planner_terminal_soc_value_factor × (1 − terminal_neg_buy_weight)`  
`terminal_soc_kcz_per_wh = (průměr buy v prvních 24 h slotů) × effective_factor / 1000`  
a v objective se přičítá `- terminal_soc_kcz_per_wh × soc[T−1]` (viz `_terminal_neg_buy_weight` a `solve_dispatch` v `backend/services/planning_engine.py`).

**`terminal_neg_buy_weight`:** pokud v horizontu existuje **`buy<0`**, váha roste s blízkostí prvního záporného slotu (horizont 36 h) a magnitudou ceny (ref 1 Kč/kWh, cap **0,95**). Bez záporného buy zůstává **0** — chování jako čistý DB faktor.

**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

---

## Verze enginu: v1 (heuristický) vs v2 (čisté jádro) — od 2026-06-11

Plánovač má dvě implementace, přepínané env proměnnými (`backend/app/config.py`):

| Env | Default | Význam |
|-----|---------|--------|
| `PLANNING_ENGINE_VERSION` | **`v2`** (od 2026-06-12; v deploy compose) | Aktivní engine pro daily i rolling plán |
| `PLANNING_ENGINE_COMPARE_ENABLED` | `false` | Shadow režim: druhá verze se počítá paralelně, diff se ukládá do `planning_run.solver_params.comparison` (status `comparison`) |

- **v1** = `solve_dispatch_two_pass` (heuristické fáze/okna/kotvy + penalty; popsáno výše v tomto dokumentu).
- **v2** = `services/planning/solver_v2.py`: objective = jen reálné peníze (cash + degradace − terminal SoC value z `asset_battery.planner_terminal_soc_value_factor`); tvrdá pravidla (CLAUDE.md 5/6/7/19), EV deadline (placený slack), TUV look-ahead, provozní režimy. SQL masky `allow_charge`/`allow_discharge_export` **ignoruje**.
- Router: `_solve_dispatch_for_version` v `planning_engine.py`; chyby v2 jdou do standardní failure pipeline (`fn_planning_run_fail`).
- **Robustnost v2 — „nejistota jako cena"** (parametry v DB, žádná okna):
  noční SoC polštář (`night_baseload_buffer_wh`, slack za buy slotu), PV-risk
  front-load v sell<0 (`planner_pv_risk_frontload_czk_kwh`, V090), denní SoC
  rampa (`safety_soc_target_wh` × `planner_safety_soc_risk_factor`, V091).
  Detail: hlavička `solver_v2.py` + changelog 2026-06-12.
- **EV ve v2 — účtování, headroom, min. výkon (2026-06-12):**
  - **Deadline boundary:** tvrdý cíl sčítá energii jen ve slotech `t < t_deadline`
    (první slot s `interval_start >= deadline` už energii dodává po odjezdu —
    dřívější off-by-one `range(t_dl + 1)` opraven).
  - **Měkký cíl (oportunismus):** dekompozice `Σ(EV energie) == needed − unmet + opp`;
    `opp ∈ [0, headroom_wh]`. **Headroom** = `(100 − max(target_soc_pct,
    soc_at_connect_pct)) % kapacity` (R__039) — „nenabíjet" (nízký target) už
    paradoxně NEzvětšuje oportunistickou vrstvu; auto fyzicky bere jen nad svým
    SoC. Hodnota kWh = `coalesce(ev_session.opportunistic_value_czk_kwh,
    asset_vehicle.opportunistic_value_czk_kwh)` (V099: session override; NULL =
    zdědit, 0 = vypnout pro session → `headroom_wh = 0`); patch klíčem
    `opportunistic_value_czk_kwh` v `fn_ev_session_apply_patch` (validace ≥ 0).
    **Oportunistická vrstva NENÍ omezená deadline** (rozhodnutí 2026-06-12:
    auto bývá doma dál, odjezd řeší rolling replan). **Bez session je EV == 0**
    i při `buy < 0` (stop-session; dřív neomezené „pumpování").
  - **Min. výkon wallboxu:** `asset_ev_charger.min_power_w` (6 A ≈ 1380 W) jde
    přes `fn_planning_site_context` (vehicles JSON) do binárky `ev_on[e][t]` —
    setpoint ∈ {0} ∪ [min, max]; konec nevykonatelných 400–900 W setpointů.
  - **Fyzikální split:** `Σ_e ev_direct[e][t] ≤ gi[t] + pv_a_net + pv_b_eff`
    (direct jen ze sítě + PV; via_bat kryje `bd`). Ekonomiku nemění, ale split
    direct/via_bat už není arbitrární.
  - **Reporting via_bat:** kWh do EV z baterie neplatí slotový buy — solver_v2
    plní `battery_arbitrage_czk` oportunitní cenou (min `sell` exportního slotu
    téhož pražského dne, jinak terminal value; clamp ≥ 0) × via_bat energie
    (dřív konstantní 0). `fn_plan_current_bundle` nese `ev1/ev2_via_bat_w`
    v `intervals`, aby UI necenilo EV kWh z baterie slotovým buy.
- Regresní brána a měření: `scripts/harness/README.md` (golden replay, economics report, penalty audit, `solver_v2_eval.py`); plán refaktoru: `docs/refactor-clean-planner.md`.
