151 lines
9.8 KiB
Markdown
151 lines
9.8 KiB
Markdown
# 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 < max(future_sell_opportunity, charge_acquisition) − degrad` (PV store value); výjimka jen plná baterie v kotvícím slotu. Při `sell < 0` také `ge_pv=0` (home-01 bez `block_export_on_negative_sell`). Bez blanket výjimky „pole B má přebytek“.
|
||
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):** proměnné `pv_ld` / `pv_sp`, `bc_pv` / `bc_gi`; přebytek FVE jen `bc_pv + ge_pv ≤ pv_sp`; `gi ≤ load + bc_gi` (žádný fiktivní import při PV exportu). 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, <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 **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`.*
|