Files
ems/docs/04-modules/planning-charge-slot-budget.md
Dusan Vojacek 1429d402e5
Some checks failed
CI and deploy / migration-check (push) Failing after 13s
CI and deploy / deploy (push) Has been skipped
zdokumentovani noveho pohleud na planovani nabijeni
2026-06-01 19:53:04 +02:00

341 lines
17 KiB
Markdown
Raw Permalink 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í: rozpočet nabíjecích slotů (Wh × ceny × forecast)
**Stav:** návrh k implementaci (2026-06) — **zatím neimplementováno** v produkčním kódu.
**Účel:** nahradit tvrdé prahy typu `sell > min_sell + 0,20 → bc_pv = 0` (v58) a binární pre-neg „cushion“ (v33) jednotným **energetickým rozpočtem** ve `fn_load_planning_slots_full`, který pokryje fixní tarify (BA81, KV1), spot (home-01) i zkracující se okna `sell < 0` (zima).
**Související:**
| Dokument | Vztah |
|----------|--------|
| [`planning.md`](planning.md) | LP, masky, současné v58v62 |
| [`planning-arbitrage-accounting.md`](planning-arbitrage-accounting.md) | proč ne `sell < buy` v jednom slotu |
| [`planning-neg-sell-strategy.md`](planning-neg-sell-strategy.md) | rampa, tail, pole B — cílové SoC v okně |
| [`R__063_fn_load_planning_slots_full.sql`](../../db/routines/R__063_fn_load_planning_slots_full.sql) | místo implementace masek |
| [`planning_engine.py`](../../backend/services/planning_engine.py) | LP jen respektuje masky + objective |
**Changelog (plánované):** [`docs/planning-changelog.md`](../planning-changelog.md) — sekce *Plánováno: charge-slot-budget*.
---
## 1. Problém, který řešíme
### 1.1 Fixní tarif (BA81, KV1) — slunečný den ~60 % SoC
**Symptom:** přes den nabíjení ze slunce jen na ~6066 %, zbytek FVE jde do sítě při výkupu 23 Kč/kWh; nabíjení jen v 24 slotech u minima výkupu (~1,45 Kč).
**Příčina (současný kód):**
- `R__063` už počítá `v_energy_to_fill` a vrstvu A (PV) s kumulací Wh, ale u fixního tarifu řadí PV vrstvu podle **`store_score`** (future sell sell).
- **`planning_engine` v58:** tvrdě `bc_pv = 0` (a často i `bc_gi = 0`) když `sell > min(sell≥0) + 0,20` — LP **nesmí** nabíjet, i když SQL nastaví `allow_charge = true`.
→ Rozpor mezi maskou a LP; ekonomicky „správný“ export v 10:00 blokuje doplnění baterie, i když večerní arbitráž to nevyžaduje.
### 1.2 home-01 — velká baterie, kratší / slabší okno `sell < 0`
**Symptom:** ráno export FVE před `sell < 0` i při výkupu ~23 Kč, zatímco v záporném okně by energie mohla být potřeba víc (krátké okno, slabší zimní FVE).
**Příčina (současný kód):**
- **v33:** `_pre_neg_pv_export_forecast_cushion_ok`**binární** rozhodnutí: pokud forecast PV v celém denním okně `sell < 0` pokryje deficit do `soc_target[first_neg]` × 1,15 → **všechny** pre-neg sloty s přebytkem → export (`bc_pv = 0`, push `ge_pv`).
- Neptá se: *kolik Wh musím nabít **před** oknem*, když *uvnitř* okna forecast nestačí.
- **v44:** na neg den **žádný grid** před 1. `sell < 0` — při nedostatku FVE v okně chybí páka levného NT před oknem.
### 1.3 Společná chyba modelu
| Špatně | Správně |
|--------|---------|
| Prah `sell > min + 0,20` | **Kolik Wh** chybí do cíle a **které sloty** je nejlevněji doplní |
| Binární cushion OK / fail | **pre_window_wh** = max(0, deficit in_window_wh) |
| `store_score` u fixního buy | U fixního tarifu řadit **`sell ASC`** (výkup = příležitostní cena uložení) |
| LP přepisuje SQL masky (v58) | LP **jen** `bc_*` kde `allow_charge`; export kde maska nepřidělila charge budget |
---
## 2. Principy návrhu
1. **SQL-first:** výběr nabíjecích slotů = **`ems.fn_load_planning_slots_full`** (rozšíření `R__063`), ne nové tvrdé větve v `solve_dispatch` kromě stávajících bezpečnostních guardů (load-first, večerní push, neg fáze).
2. **Energetický rozpočet (Wh), ne Kč prah:** ceny řadí **prioritu slotů**; zastavení kumulace je **`cum_wh ≥ target_wh`** (± `charge_slot_buffer`).
3. **Forecast v každém slotu:** `pv_surplus_w` = max(0, pv_a + pv_b load_baseline …) už ve work tabulce; nabíjecí příspěvek slotu = `min(pv_surplus_w, max_charge_w) × charge_eff × 0,25` (+ volitelně grid `per_slot_charge_wh`).
4. **Oddělení nákupního a výprodejního okna** — viz [`planning-arbitrage-accounting.md`](planning-arbitrage-accounting.md); `charge_acquisition` z vybraných slotů, ne `min(buy)` celého horizontu.
5. **Jeden algoritmus, více režimů:** stejná kostra pro spot i fixed; liší se **řazení** (buy vs sell), **cíl SoC** a **výjimky** (neg den, block_export).
---
## 3. Slovník
| Symbol / pole | Význam |
|---------------|--------|
| `energy_to_fill_wh` | `soc_max_wh current_soc_wh` (základní deficit do plné baterie) |
| `charge_target_wh` | Cíl pro výběr slotů — může být `< soc_max` (rezerva neg okna, safety, večerní export) |
| `in_window_wh` | Odhad energie do baterie **uvnitř** speciálního okna (např. všechny sloty `sell < 0` téhož pražského dne), z forecastu A+B (+ volitelně grid v okně) |
| `pre_window_wh` | `max(0, charge_target_wh in_window_wh × reliability_factor)` — kolik Wh je třeba doplnit **před** oknem |
| `slot_charge_wh[t]` | Wh, které lze v `t` reálně natočit (PV surplus cap + výkon baterie) |
| `allow_charge` | SQL maska: LP smí `bc_pv` / `bc_gi` |
| `allow_grid_charge` | podmnožina: smí `bc_gi` |
| `charge_slot_reason` | debug: `grid_layer_b`, `pv_layer_a`, `pre_neg_fill`, `neg_window`, `buy_negative`, … |
---
## 4. Jádro algoritmu (společné)
Pro každý běh plánovače (site, horizont, `current_soc_wh`):
### Krok 1 — Cíl energie
```text
charge_target_wh := min(
soc_max_wh current_soc_wh,
optional_cap_from_neg_strategy, -- viz §6
optional_safety_soc_target_wh
)
```
- Výchozí: doplnit do **`soc_max_wh`** (s `charge_slot_buffer` z `asset_battery` jako dnes).
- U **home-01** s neg dnem: cíl na vstupu do okna = **`soc_need[first_neg_sell]`** z rampy (v35/v36), ne fixních 80 %.
- Rezerva pro okno `sell < 0`: stávající logika `v_pv_layer_cap_wh -= neg_window_pv_surplus_wh` v `R__063` zůstane jako **snížení** `charge_target_wh` před oknem, ne jako samostatný binární export.
### Krok 2 — Dodávka uvnitř speciálního okna (volitelná větev)
Pro každý pražský den s alespoň jedním `sell < 0`:
```text
in_window_wh := sum over slots t in neg_window(day):
min(pv_surplus_w[t], max_charge_w) * charge_eff * 0.25
+ (optional) grid_wh if allow_grid in neg window (v45)
```
`reliability_factor` ∈ (0, 1] — např. **0,85** zimní / krátké okno, **1,0** letní dlouhé okno; nebo odvozené z počtu slotů `sell < 0` (≤ 4 sloty → nižší faktor).
### Krok 3 — Deficit před oknem
```text
pre_window_wh := max(0, charge_target_at_neg_entry in_window_wh * reliability_factor)
```
Kde `charge_target_at_neg_entry` = SoC potřeba na `first_neg_sell` (z rampy / `soc_need`), minus `current_soc` (pozorované), přepočteno na Wh.
### Krok 4 — Fronta slotů (řazení + kumulace)
Kandidáti = sloty s `slot_charge_wh > 0` **nebo** (pro grid vrstvu) sloty bez PV, kde smí síť.
**Řazení (priorita):**
| Režim | Primární klíč | Sekundární |
|-------|----------------|------------|
| Spot (`purchase_pricing_mode ≠ fixed`) | `buy_price ASC` | den plánu, před exportním oknem, `slot_ord` |
| Fixní tarif | `sell_price ASC` | stejné geografické priority jako dnes v R__063 |
**Kumulace:**
```text
cum := 0
for slot in candidates ordered:
if cum >= budget_wh: break
allow_charge[slot] := true
if grid_layer: allow_grid_charge[slot] := true
cum += slot_charge_wh[slot] -- u grid vrstvy min(cum increment, per_slot_charge_wh)
```
**Budgety (vrstvy, po sobě):**
1. **Pre-neg / před oknem**`budget = pre_window_wh` (jen sloty `t < first_neg_sell` téhož dne).
2. **Grid B**`budget = grid_target_wh` (AM/PM 50/50 jako dnes); spot: nejlevnější `buy`; fixed: nejlevnější `sell` u slotů splňujících marži.
3. **PV A — zbytek**`budget = max(0, charge_target_wh grid_filled_wh pre_neg_filled_wh)`.
Po výběru: sloty **mimo** vybranou frontu nemusí mít `allow_charge` — LP pak může exportovat FVE, pokud objective dává smysl (bez v58 zákazu).
### Krok 5 — Export před oknem (home-01)
**Nahradit** v33 binární cushion:
```text
export_allowed_pre_neg[t] :=
t in pre_neg_calendar_window
AND pv_surplus_w[t] > threshold
AND NOT allow_charge[t] -- přebytek po naplnění pre_window_wh
AND (optional) sell[t] >= sell_export_floor[t] -- ne dump pod ranním pásmem (R__063 morning zone)
```
V Pythonu: `pre_neg_pv_export_ts` = sloty s exportem **jen pokud** nejsou v charge frontě; případně měkká penalizace místo `bc_pv = 0` na celé pásmo.
---
## 5. Fixní tarif (BA81, KV1)
### 5.1 Změny oproti dnešku
| Dnes (v58v59) | Plán |
|----------------|------|
| `bc_pv = 0` if `sell > min + 0,20` | **Zrušit** v `planning_engine.py` |
| PV vrstva A: `store_score DESC` | Fixed: **`sell_price ASC`** + kumulace `pv_surplus` Wh |
| Grid: min sell sloty (v59) | Zachovat, sladit s jednotnou frontou (grid = vrstva B) |
| Večer push: `bc_* = 0` | Zachovat (exportní okno) |
### 5.2 Ekonomická interpretace
- **Buy** je konstantní → rozhoduje **výkup v slotu** (opportunity cost uložení do baterie).
- Nabíjet v pořadí **nejnižší sell**, dokud `cum < charge_target_wh`.
- Export v slotu s vyšším sell je OK **až poté**, co je rozpočet Wh vyčerpán v levnějších slotech (včetně pozdějších levných sellů v pořadí času — viz pořadí: nejdřív globálně nejlevnější sell v rámci dne / AM-PM, viz níže).
### 5.3 AM/PM a pořadí v čase
Zachovat stávající **AM/PM rozpočet** `grid_target_wh` (50/50, přeliv AM→PM). Uvnitř segmentu:
- fixed grid: `sell ASC` + filtr `sell ≤ min(sell≥0) + degrad + ε` (jako v59) **nebo** čistě kumulace Wh bez ε, pokud Wh budget stačí — **rozhodnutí při implementaci:** preferovat **Wh kumulaci**; ε jen jako failsafe proti grid nabíjení za 6 Kč v noci (KV1).
### 5.4 Večerní špička a profitable export
Beze změny principu: `allow_discharge_export` + `evening_push_ts`; v push slotech **`allow_charge = false`**.
---
## 6. Spot — home-01 a negativní výkup
### 6.1 Běžný spot (bez neg dne v horizontu)
Stejná kostra jako §4:
- Grid vrstva: **`buy ASC`** (stávající spot loop + self-konzistentní filtr `pv_charge_wh_ahead`).
- PV vrstva: **`store_score DESC`** nebo hybrid — **po** grid; budget = zbytek `charge_target_wh`.
### 6.2 Den s `sell < 0` (neg strategie)
Integrace s [`planning-neg-sell-strategy.md`](planning-neg-sell-strategy.md):
| Komponenta | Role v charge-slot budget |
|------------|---------------------------|
| `soc_need[t]` rampa (v35/v36) | `charge_target_at_neg_entry` |
| `in_window_wh` z A+B v neg sloty | sníží `pre_window_wh` |
| `pre_window_wh > 0` | **nabíjecí fronta před `first_neg_sell`** (PV + případně grid) |
| Tail / T / curtail | **beze změny** v LP (fáze neg okna) |
| v44 `neg_day_no_grid_before_neg_sell` | **Změkčit:** povolit grid v N nejlevnějších `buy` slotech před oknem, pokud `pre_window_wh > in_window_wh × factor` a `buy < 0` nebo `buy ≤ ref_buy_am + degrad` |
### 6.3 Nahrazení v33 cushion
| v33 (dnes) | charge-slot budget |
|------------|-------------------|
| `cushion_ok` → export vše pre-neg | `pre_window_wh` malé → více `allow_charge` pre-neg |
| `cushion_fail` → nabíjet | `pre_window_wh` velké → fronta nabíjení |
| `bc_pv = 0` v celém `pre_neg_pv_export_ts` | Jen sloty, kde **není** `allow_charge` a export je ekonomicky výhodný |
**Zima / krátké okno:** málo slotů `sell < 0` → malé `in_window_wh` → velké `pre_window_wh`**více nabíjení před oknem**, i při sell 23 Kč, pokud jsou to nejlevnější dostupné sloty (ne plošný export).
### 6.4 Velká baterie (64 kWh)
- `charge_target_wh` může být **desítky kWh** — fronta musí počítat **skutečné** `slot_charge_wh`, ne jen cap 6 slotů / segment, pokud budget vyžaduje více (rozšířit `grid_charge_cap_*` odvozeně od `ceil(budget / per_slot_charge_wh)` — částečně už je).
- `charge_acquisition` = vážený buy ve vybraných `allow_grid_charge` slotech (stávající two-pass v Pythonu).
---
## 7. Role LP po změně masek
` solve_dispatch` **nesmí** přepisovat energetický výběr prahy typu v58.
| Oblast | LP chování |
|--------|------------|
| Nabíjení | `bc_pv`, `bc_gi` jen kde `allow_charge` / `allow_grid_charge` |
| Export FVE | objective `ge_pv×sell`; **bez** `bc_pv=0` jen kvůli sell |
| Export bat | `allow_discharge_export`, `charge_acquisition`, večerní push |
| Guard | `ge_pv=0` if `sell < charge_acquisition degrad` (spot) — **měkká** hranice hodnoty uložené energie |
| Spot grid | v61: `bc_gi=0` if `buy > charge_acquisition + degrad` |
| Neg fáze | rampa, tail, T — beze změny |
---
## 8. Návrh rozšíření `fn_load_planning_slots_full`
Nové / rozšířené sloupce ve výstupu (nebo JSON v meta — preferováno sloupce pro debug):
| Sloupec | Typ | Popis |
|---------|-----|--------|
| `charge_budget_wh` | numeric | celkový cíl pro tento běh |
| `charge_slot_wh` | numeric | odhad Wh v daném slotu |
| `charge_cum_wh` | numeric | kumulativa po řazení (audit) |
| `charge_layer` | text | `pre_neg` / `grid_am` / `grid_pm` / `pv_a` / … |
| `pre_window_wh` | numeric | jen informativní per běh (nebo per den v `solver_params`) |
V `planning_run.solver_params` (commit meta):
```json
{
"charge_slot_budget": {
"charge_target_wh": 42000,
"pre_window_wh_by_day": {"2026-06-02": 18000},
"in_window_wh_by_day": {"2026-06-02": 12000},
"reliability_factor": 0.85,
"planner_build_tag": "…-charge-slot-budget-v1"
}
}
```
---
## 9. Co zrušit / neimplementovat znovu
| Položka | Akce |
|---------|------|
| v58 `fixed_high_sell_no_pv_charge` | **odstranit** po nasazení budget |
| v58 `FIXED_PV_CHARGE_ONLY_NEAR_MIN_SELL_CZK_KWH` | **odstranit** (konstanta) |
| v59 `fixed_grid_charge_unprofitable` část `sell > min + 0,20` | nahradit: grid jen ve vybrané frontě; případně ponechat `sell < buy` guard pro grid |
| v60 `sell < buy``bc_gi=0` (spot) | **neobnovovat** (záměrně zrušeno v61) |
| v33 binární cushion jako hlavní páka | nahradit §4 krok 23; cushion ponechat jako **audit** / `solver_params.inputs.pre_neg_cushion_legacy_ok` |
---
## 10. Fáze implementace (doporučené pořadí)
1. **Dokumentace + testy scénářů** (tento soubor, pytest fixtures s umělými sloty).
2. **`R__063`:** fixed PV vrstva `sell ASC` + Wh kumulace; sloupce debug; bez změny Pythonu → ověřit, že v58 stále blokuje (regrese).
3. **Python:** odstranit v58/v59 `bc_pv`/`bc_gi` fixed větve; spoléhat na masky.
4. **`R__063` + Python:** pre-neg `pre_window_wh` pro spot; zúžit `pre_neg_pv_export_ts`.
5. **v44 změkčení:** grid před neg jen když `pre_window_wh` > práh.
6. **Dokumentace + changelog** tag `charge-slot-budget-v1`; MCP ověření home-01 / KV1 / BA81.
---
## 11. Ověření
### 11.1 Automatické testy (pytest)
| Scénář | Očekávání |
|--------|-----------|
| Fixed, slunečný den, nízký ranní SoC | `allow_charge` v mnoha PV slotech; max SoC v plánu → blízko `soc_max` |
| Fixed, min sell odpoledne | dřívější sloty s vyšším sell **bez** `allow_charge`, pokud budget vyčerpán v levnějších |
| Spot, krátké neg okno (24 sloty), slabá FVE | `pre_window_wh > 0`; nabíjení před `first_neg_sell`; **ne** plošný pre-neg export |
| Spot, dlouhé neg okno, silná FVE | po naplnění `pre_window_wh` může export pre-neg v drahých sell |
| home-01 velká baterie | kumulace ≥ desítky kWh přes více slotů |
### 11.2 SQL / MCP
```sql
select interval_start, allow_charge, allow_grid_charge,
charge_layer, charge_slot_wh, charge_cum_wh,
sell_price, pv_surplus_w
from ems.fn_load_planning_slots_full(<site_id>, <from>, <to>, <soc_wh>)
where allow_charge
order by interval_start;
```
Po deployi: aktivní `planning_run.solver_params->'charge_slot_budget'`; u home-01 `pre_neg_pv_export_slots` ⊆ sloty bez `allow_charge`.
### 11.3 Regrese
- Večerní push (v57), spot v61, KV1 noc v62 — **nesmí** rozpadnout.
- Neg rampa v35/v36 — SoC v okně `sell < 0` stejné cíle, mění se jen **příprava před oknem**.
---
## 12. Otevřené body (rozhodnutí před kódem)
1. **`reliability_factor`:** fixní 0,85 vs funkce počtu neg slotů?
2. **Fixed řazení:** globální `sell ASC` přes den vs AM/PM segmenty (doporučení: AM/PM budget + `sell ASC` v segmentu).
3. **Večer před neg dnem:** zda `charge_target_wh` zahrnuje večerní výboj D1 (`neg_evening_before_neg`) — ano, přes `soc_need`, ne duplicitní budget.
4. **Multi-day horizont:** každý pražský den vlastní `pre_window_wh` (jako v36 bundle) — **ano**.
5. **UI:** badge „charge budget“ ve frontendu — volitelné, až budou sloupce v API.
---
## 13. Shrnutí pro produkt
Jednou větou: **místo prahu „sell nad minimum + 20 haléřů“ spočítáme, kolik Wh chybí do cíle, kolik jich dodá záporné výkupní okno z forecastu, a zbytek nabijeme v nejlevnějších slotech (buy nebo sell podle tarifu) s ohledem na PV přebytek a spotřebu — u home-01 tím nahradíme plošný ranní export před `sell < 0`, u KV1/BA81 doplníme baterii ve slunečný den nad ~60 %.**