Files
ems/docs/04-modules/planning-arbitrage-accounting.md
Dusan Vojacek 94eb256598
Some checks failed
CI and deploy / migration-check (push) Failing after 21s
CI and deploy / deploy (push) Has been skipped
planovac reesi load first
2026-05-26 09:05:33 +02:00

151 lines
9.9 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.
---
## 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.
---
*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`.*