Files
ems/docs/04-modules/planning-arbitrage-accounting.md
Dusan Vojacek f726188ec9
All checks were successful
CI and deploy / migration-check (push) Successful in 31s
CI and deploy / deploy (push) Has been skipped
docs: arbitráž — sell strana marginální cena vs strop exportu (proč baterie nedrží na špičku)
Zrcadlo sekce 3 (buy: min(buy) != cena zásoby) na prodejní straně: baterie se
nevyprodá do jednoho slotu (strop 13.5 kW = 3.4 kWh/15min), rozloží se přes
více slotů s klesající cenou. Rozhodnutí drzet vs vybit = proti MARGINÁLNÍ
ceně (nejnizsi pouzity slot), ne spicce. Konkrétní příklad večer 2026-06-14
+ caveat terminal value za horizontem (jeden skalár, ne marginální).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 11:46:42 +02:00

210 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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ě 816 slotů** (24 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.
---
## 3b. Výprodejní strana: marginální cena, ne špička (proč baterie nedrží jen na nejdražší slot)
Zrcadlo sekce 3 na **prodejní** straně. Častá intuice: „baterie drží, protože večer
je jeden drahý slot (sell 2,76 Kč) — prodá se za něj“. **Ne.** Stejně jako se 64 kWh
**nenabije** v jedné čtvrthodině, tak se ani **nevyprodá** do jednoho slotu — strop
exportu (home-01 **13,5 kW**) pustí jen ~**3,4 kWh / 15 min**. Vybití celé baterie do
sítě tedy zabere **hodiny** a rozloží se přes **více** prodejních slotů s **klesající**
cenou.
**Klíčový princip — MILP je marginální:** účelová funkce `Σ (ge[t] × sell[t])` přes
**všechny** sloty s **per-slot stropem** `ge[t] ≤ max_export` znamená, že solver
prodává **nejdřív nejdražší sloty** a každou další kWh až za **nižší** dosažitelnou
cenu. Rozhodnutí **„držet baterii vs. vybít teď“** se tedy poměřuje proti
**MARGINÁLNÍ ceně** = nejnižší z **reálně využitých** prodejních slotů, **ne** proti
špičce. Baterie drží jen tolik, dokud i ta **marginální** vyvezená kWh (prodaná v tom
nejlevnějším z použitých oken) bije alternativu (krytí loadu / další držení); nad to
prodá / spotřebuje dřív.
**Konkrétní příklad (home-01, večer 2026-06-14, plán):** baterie nabitá za odpolední
**záporné** ceny (SoC ~100 %) se vyváží na **stropu 13,5 kW napříč 20:3023:00**, ne jen
do špičky 22:00:
| slot | sell Kč/kWh | export | pozn. |
|------|-------------|--------|-------|
| 20:30 | 2,21 | 13,5 kW | |
| 21:00 | 2,20 | 13,5 kW | marginální (nejnižší použitý) |
| 21:30 | 2,25 | 13,5 kW | |
| **22:00** | **2,76** | 13,5 kW | **špička — jen jeden slot** |
| 22:30 | 2,47 | 13,5 kW | |
| 23:00 | 2,32 | 13,5 kW | |
SoC klesne 92 → 32 % (~38 kWh) za **6** slotů. Do špičky 2,76 jde jen **jeden** slot;
marginální kWh se prodá za **~2,2**. Kdyby solver oceňoval **celou** baterii špičkou
2,76, **držel by víc**, než je optimální. Že vyváží napříč oknem = důkaz, že počítá
**marginálně se stropem**, ne naivně na špičku.
**Caveat — kde to marginální NENÍ:** platí jen pro sloty **uvnitř horizontu** (viditelná
prodejní okna). Energie zbylá **za horizontem** (`soc[T1]`) se ocení **jedním skalárem**
(`terminal_soc_value` = průměr buy prvních 24 h × faktor, pravidlo §16), **ne** pořádnou
marginální budoucí prodejní křivkou. Pro „dnešní večer v horizontu“ je to správně
marginální; zpřesnění terminal value (počasím/cenou vážené, „fáze 3e“) je
v `docs/06-open-questions.md` a `docs/04-modules/planning-extended-horizon.md`.
---
## 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:0014:00 za ~0,70,9 Kč a výprodeji 19:0022:00 za ~3,55,5 Kč je spread řádově **24 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.71.0)
select interval_start at time zone 'Europe/Prague' as t,
buy_price, allow_charge
from ems.fn_load_planning_slots_full(2, <from>, <to>, <soc_wh>)
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 = <active_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 **7090 %** po levném pásmu; večer **export do sítě** v špičce sell, ne jen ~2 kW do domu.
---
## 6. Plánováno: výběr nabíjecích slotů podle Wh (charge-slot-budget)
**Stav:** neimplementováno — plná specifikace v [`planning-charge-slot-budget.md`](planning-charge-slot-budget.md).
Shrnutí vztahu k arbitráži:
- **`charge_acquisition`** má vycházet z **vybrané fronty** slotů (`allow_charge` / `allow_grid_charge`), ne z jednoho `min(buy)` ani prahu `sell > min + ε`.
- **Počet slotů** nabíjení má odpovídat **potřebným Wh** (`soc_max current`, případně `soc_need[first_neg] observed` před neg oknem), s `min(pv_surplus, P_max) × 0,25` per slot.
- **Export FVE** v drahém slotu je správný **až po** vyčerpání levnější fronty — ne tvrdý `bc_pv = 0` (v58) nezávisle na rozpočtu.
Tím se sjednotí fixní tarif (řazení `sell ASC`) a spot (řazení `buy ASC` + pre-neg `pre_window_wh`).
---
*Poslední aktualizace: 2026-06-01 — přidán odkaz na plánovaný charge-slot-budget; dříve 2026-05-27 self-konzistentní grid maska B (v12).*