# 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 slot s `allow_charge` (levné grid nabíjení) a `sell[t] ≥ charge_acquisition + degrad`, solver **vyžaduje export PV přebytku** (`ge_pv`, `bc=0`) — typicky ranní prodej FVE nad ~3 Kč/kWh a NT nabíjení odpoledne. 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 (2026-05) a backlog ### Hotovo (jednoduchá varianta před solve) 1. **`ems.fn_load_planning_slots_full`** (`R__063`): sloupce `charge_acquisition_buy_czk_kwh`, `charge_acquisition_cutoff_at`. 2. **Vážený průměr `buy`** v `allow_charge` slotech **před** prvním `allow_discharge_export` (`cutoff`): `Σ(buy × per_slot_charge_wh) / Σ(per_slot_charge_wh)` — bez `future_sell` z odpolední FVE (jinak acquisition nafukovala večerní export). 3. **`solve_dispatch`:** v exportních slotech (`allow_discharge_export`) přičíst k objective `+ ge_bat[t] × charge_acquisition × INTERVAL_H/1000` (náklad uložené energie), ponechat `−ge×sell`. Snapshot v `solver_params.inputs`. 4. **FVE opportunity:** varianta **B** — lookahead `future_sell_opportunity`, ne jen `sell[t]` v odhadu PV Wh. ### Zbývá / vylepšit 1. **Iterace po solve:** přepočítat acquisition z plánovaných `bc`+`gi` / `pv` místo odhadu z masek — viz [`docs/05-todo.md`](../05-todo.md). 2. **Objective:** explicitní `ge_bat × (sell − acquisition − degrad)` vs současné `−ge×sell` + `+ge_bat×acquisition` (ekvivalentní jen pokud `ge_pv` v exportním slotu ≈ 0). 3. **Masky:** více charge slotů ∝ `energy_to_fill / per_slot_wh` — viz [`planning.md`](planning.md). 4. **Bilance / guardy:** zpřesnit, aby večerní export nebyl vázaný na falešný `gi×buy` v tomže slotu. --- ## 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 — charge_acquisition před 1. exportem + LP `ge_bat` náklad; iterace po solve v TODO.*