uprava vypoctu slotu
Some checks failed
CI and deploy / migration-check (push) Failing after 16s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-05-21 12:36:03 +02:00
parent 08f1b6741a
commit b78597fdda
8 changed files with 346 additions and 6 deletions

View File

@@ -0,0 +1,150 @@
# 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]` jsou správné pro **FVE export v tomže slotu** (fyzicky exportovat za haléř při drahém VT).
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 (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** jen pro sloty **před** prvním `allow_discharge_export` (`cutoff = min(interval_start)` exportních slotů):
- **grid:** `buy × per_slot_charge_wh` pro `allow_charge`;
- **FVE:** `future_sell_lookahead` (fallback `sell`) × odhad Wh z `pv_surplus` (`least(surplus, max_charge)×η×0,25 h`).
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.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 — charge_acquisition před 1. exportem + LP `ge_bat` náklad; iterace po solve v TODO.*