# Plánování: arbitráž a účtování energie (mezi sloty vs. v jednom slotu) **Účel:** Trvalá poznámka pro implementaci i pro agenty — aby se neopakovala chyba „řešit arbitráž přes `buy` a `sell` ve stejném 15min slotu“ nebo přes **`min(buy)` celého horizontu** jako nákupní cenu uložené energie. **Související:** [`planning.md`](planning.md), [`planning_engine.py`](../../backend/services/planning_engine.py) (`solve_dispatch`), [`R__063_fn_load_planning_slots_full.sql`](../../db/routines/R__063_fn_load_planning_slots_full.sql). --- ## 1. Co uživatel / provoz očekává (správný model) Arbitráž baterie je **časový posun**: 1. V **levných hodinách** (může jich být **více za sebou**) nabít z site — např. home-01: baterie **64 kWh**, import z site typicky až **17 kW** → za 15 min až ~**4,25 kWh** ze sítě na slot, ale **klidně 8–16 slotů** (2–4 h) dokud cena sedí. 2. V **drahých / výkupních hodinách** (jiný čas) stejnou energii prodat do sítě nebo ušetřit drahý import domu. Ekonomický přínos je přibližně: ```text zisk ≈ (efektivní sell ve výprodejním okně) − (efektivní buy v nabíjecím okně) − degradace cyklu / účinnost ``` **Není to** rozhodnutí „v tomto jednom 15min okně koupím za 7 Kč a prodám za 4,6 Kč“ — ve výprodejním slotu se **nekupuje** energie určená k exportu z baterie; ta byla nabitá dříve za jinou cenu. --- ## 2. Co dělá dnešní LP (a proč to arbitráž láme) ### 2.1 Účelová funkce je po slotech V `solve_dispatch` je v každém slotu `t` zhruba: ```text náklad += gi[t] × buy[t] výnos -= ge[t] × sell[t] ``` Energetická bilance je také **per slot** (15 min). Když solver v evening slotu zvýší `ge_bat` (export baterie), bilance často vyžaduje současně `gi` (síť krmí dům) a `bd`/`ge_bat`. Marginalně pak vypadá každá vyvezená kWh jako: - „koupeno“ za **`buy[t]`** večer (např. 7 Kč/kWh), - „prodáno“ za **`sell[t]`** večer (např. 4,6 Kč/kWh), → v jednom okně **ztráta**, i když energie v baterii pochází z poledních **0,7 Kč/kWh**. **Závěr:** Samotné opravy typu „přidat `ge_bat × (sell − ref_buy)`“ **nestačí**, pokud `ref_buy` je jedna čísla z jednoho slotu — pořád myslíme příliš v rámci jednoho okna. Cíl je **oddělit nákupní okno od výprodejního okna** v ekonomice modelu. ### 2.2 Guardy `sell < buy` ve stejném slotu Tvrdé zákazy typu `ge_pv = 0` když `sell[t] < buy[t]` brání **ztrátovému** exportu FVE v tomže slotu (prodat za 3 Kč při VT nákupu 5 Kč). **Výjimka (AUTO, od 2026-05):** pokud je v budoucnu `allow_charge` (levný nákup), solver **povolí** FVE export i při `sell[t] < buy[t]`, ale **jen když** `(sell[t] − min_buy_charge) ≥ (future_sell_opportunity − charge_acquisition) + degrad` — tj. prodat teď a později levně dobít překoná uložení PV na večerní špičku. Při odpoledním sell ~1,4 Kč a večer ~5,5 Kč **export se nevnucuje** (energie do baterie). Implementace: `solve_dispatch()` v `planning_engine.py`. Pro **baterii** stejný test v **exportním** slotu **nesmí** být jediná logika arbitráže — večer téměř vždy `sell[t] < buy[t]` (VT/NT vs výkupní marže), přesto má smysl **vybíjet do sítě** energii nabitou v levném okně. --- ## 3. Proč `min(buy)` přes celý horizont **není** nákupní cena zásoby `min(buy_price)` v horizontu je **jeden** 15min slot (nejlevnější čtvrthodina). | home-01 (typicky) | Hodnota | |-------------------|--------| | Kapacita baterie | 64 kWh | | Max import ze site | 17 kW | | Max energie ze sítě / slot (15 min) | 17 kW × 0,25 h ≈ **4,25 kWh** | | Počet slotů na „plné“ grid nabíjení | až **~15** slotů (≈ 64/4,25), tedy **hodiny** | **Min buy** tedy popisuje **špičku** v jednom čtvrthodině, ne průměrnou cenu energie, kterou skutečně natankujeme přes **dlouhé nabíjecí okno**. Použití `min(buy)` jako „acquisition cost“ pro večerní export: - **podhodnotí** skutečný náklad, pokud nabíjíme i v druhém/třetím levném slotu s vyšší cenou; - **neříká nic** o tom, kolik energie v levném pásmu vůbec nabít — to řeší masky `allow_charge` a rozpočet Wh, ne jedna čísla. **Kde je `min(buy)` dnes OK:** hrubá **brána** („existuje v horizontu levný nákup?“), výběr slotů pro vrstvu B (`buy ≤ min + ε`), **ne** jako jediná proměnná pro výpočet zisku z baterie. --- ## 4. Co používat místo toho (směr návrhu) | Pojem | Význam | Poznámka | |--------|--------|----------| | **`buy_charge_window`** | Nákupní cena energie do baterie | Odvozená z **množiny nabíjecích slotů** (`allow_charge` / skutečný `bc`+`gi`), ne z jednoho minima | | **`sell_discharge_window`** | Výkup při vybíjení do sítě | Např. průměr / percentil `sell` v `allow_discharge_export` slotech | | **Spread** | `sell_discharge − buy_charge − degradace` | Rozhoduje, zda má smysl večer `ge_bat` | Příklady výpočtu **`buy_charge`** (zvolit jednu politiku v implementaci): 1. **Průměr přes `allow_charge` sloty** (vážený 0/1 — všechny povolené sloty stejně). 2. **Průměr přes N nejlevnějších slotů**, kde N = počet slotů potřebných na dobití: `ceil(energy_to_fill_wh / (max_charge_w × η × 0,25 h))`. 3. **Vážený průměr** `sum(buy[t] × charge_wh[t]) / sum(charge_wh[t])` z výsledku LP (až po solve — iterace nebo aproximace před solve z masky). Pro **home-01** při nabíjení 11:00–14:00 za ~0,7–0,9 Kč a výprodeji 19:00–22:00 za ~3,5–5,5 Kč je spread řádově **2–4 Kč/kWh** — to LP dnes nevidí, pokud účtuje večerní `buy[t]`. --- ## 5. Co **nedělat** v dalších iteracích - Navrhovat „opravu arbitráže“ jen jako **`sell[t] − min(buy horizontu)`** v objective — **min buy je jeden slot**, nabíjení je **více hodin**. - Zaměňovat **stejnoslotové** `buy`/`sell` s **mezi-slotovou** arbitráží — uživatel to explicitně považuje za nesmysl. - Očekávat, že zvýšení `allow_discharge_export` samo spustí večerní **SELL**, když objective pořád trestá export při `buy[t] > sell[t]` ve stejném slotu. --- ## 6. Implementace (LP-first přestavba, 2026-05) ### Hotovo 1. **`ems.fn_load_planning_slots_full`** (`R__063`): grid **B** = nejlevnější sloty v AM/PM do Wh rozpočtu; **nevyčerpaný AM rozpočet přejde do PM** (odpolední NT za ~0,5 Kč může nabíjet i po ranním dobití). `grid_target × charge_slot_buffer`, cap slotů též × buffer. **A** = PV jen pokud `sell ≥ future_sell_lookahead − degrad`. 2. **`solve_dispatch` (AUTO):** objective `gi×buy − ge_pv×sell − ge_bat×sell + ge_bat×acquisition` (export bat. jen v `allow_discharge_export`). Odstraněn cross-slot guard `ge_pv ≥ surplus` / `bc=0` dle `export_refill_net`. 3. **Guard FVE:** `ge_pv=0` při `sell < future_sell_opportunity − degrad` **jen pokud `sell < 0`** (spot) nebo fixní tarif — u **`sell ≥ 0`** spot neblokuje export FVE kvůli budoucímu peak sell (solver export vs. nabíjení; baterii šetří `ge_bat`). Při `sell < 0` home-01: `ge_pv=0` / ventil pole B. Tag `2026-05-28-pv-positive-sell-solver-v29`. 4. **`solve_dispatch_two_pass`:** pass 1 → vážený `buy` z `bc`+`gi` v `allow_charge` → pass 2; volá `run_daily_plan` / `run_rolling_replan` v AUTO. Snapshot: `acquisition_pass1_czk_kwh`, `acquisition_pass2_czk_kwh`, `two_pass_enabled`. 5. **Regrese:** `Home01RegressionTests` v `backend/tests/test_planning_dispatch_milp.py`; masky v `test_planning_charge_slot_selection.py`. 6. **Load-first (Deye, AUTO, tvrdý v34):** `pv_ld` / `pv_sp`, `bc_pv` / `bc_gi`; `bc_pv + ge_pv ≤ pv_sp`; **`gi ≤ bc_gi + max(0, max_load − pv_forecast)`**; při `pv ≥ load + 500 W` **`pv_ld ≥ load`**. Plná bilance `pv_a + pv_b + gi + bd = load + ev + hp + bc + ge`. Test `LoadFirstDispatchTests`. 7. **Self-konzistentní vrstva B (`R__063`, 2026-05):** iterativní filtr v plpgsql — vyloučí drahé grid sloty, pokud `pv_charge_wh_ahead + neg_buy_wh_ahead >= 60 % deficitu SoC` (levnější alternativa dál v horizontu). Failsafe unlock pokud výsledek nepokryje safety target. Důsledek: `acquisition_pass1 ~ acquisition_pass2` v drtivé většině případů. Nové debug sloupce: `min_buy_before_cutoff_czk_kwh`, `pv_charge_wh_ahead`, `neg_buy_wh_ahead`, `grid_charge_suppressed_reason` (`cheaper_pv_ahead` / `cheaper_neg_buy_ahead` / `safety_failsafe_unlock`). 8. **Ekonomická transparentnost plánu (`V081`, 2026-05):** `planning_interval` — `cashflow_czk`, `battery_arbitrage_czk`, `penalty_czk`, `green_bonus_czk`; `fn_plan_explain_bundle` → `economics_summary`; post-processing v `solve_dispatch()`. ### Co dál neřešit ad-hoc - Další Python `if sell < buy` guardy — ekonomiku drží LP + acquisition + masky rozpočtu slotů. - Multi-period inventory model (větší projekt) — mimo tuto vlnu. --- ## 7. Ověření po změně (home-01) ```sql -- levné okno: víc allow_charge, rozumný buy_charge (~0.7–1.0) select interval_start at time zone 'Europe/Prague' as t, buy_price, allow_charge from ems.fn_load_planning_slots_full(2, , , ) where buy_price < 1.2 order by 1; -- večer: BATTERY_SELL, záporný grid_setpoint select interval_start at time zone 'Europe/Prague' as t, effective_buy_price, effective_sell_price, battery_setpoint_w, grid_setpoint_w, export_mode from ems.planning_interval where run_id = and extract(hour from interval_start at time zone 'Europe/Prague') between 19 and 22; ``` Očekávání: SoC před večerem **70–90 %** po levném pásmu; večer **export do sítě** v špičce sell, ne jen ~2 kW do domu. --- *Poslední aktualizace: 2026-05-27 — self-konzistentní grid maska B (v12), ekonomické sloupce v `planning_interval`, `economics_summary` v explain bundle. Po deployi: `PLANNER_BUILD_TAG=2026-05-27-self-consistent-grid-mask-v12`, `solver_params.objective_terms[].grid_charge_suppressed_reason`.*