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

17 KiB
Raw Permalink Blame History

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 LP, masky, současné v58v62
planning-arbitrage-accounting.md proč ne sell < buy v jednom slotu
planning-neg-sell-strategy.md rampa, tail, pole B — cílové SoC v okně
R__063_fn_load_planning_slots_full.sql místo implementace masek
planning_engine.py LP jen respektuje masky + objective

Changelog (plánované): docs/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_okbiná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_whcharge_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; 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

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:

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

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:

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 oknembudget = pre_window_wh (jen sloty t < first_neg_sell téhož dne).
  2. Grid Bbudget = 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 — zbytekbudget = 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:

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:

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_whví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):

{
  "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 < buybc_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

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 %.