Files
ems/docs/04-modules/planning-arbitrage-accounting.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

11 KiB
Raw Blame History

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_engine.py (solve_dispatch), 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ě:

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:

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í ~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_intervalcashflow_czk, battery_arbitrage_czk, penalty_czk, green_bonus_czk; fn_plan_explain_bundleeconomics_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)

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


6. Plánováno: výběr nabíjecích slotů podle Wh (charge-slot-budget)

Stav: neimplementováno — plná specifikace v planning-charge-slot-budget.md.

Shrnutí vztahu k arbitráži:

  • charge_acquisition má vycházet z vybrané fronty slotů (allow_charge / allow_grid_charge), ne z jednoho min(buy) ani prahu sell > min + ε.
  • Počet slotů nabíjení má odpovídat potřebným Wh (soc_max current, případně soc_need[first_neg] observed před neg oknem), s min(pv_surplus, P_max) × 0,25 per slot.
  • Export FVE v drahém slotu je správný až po vyčerpání levnější fronty — ne tvrdý bc_pv = 0 (v58) nezávisle na rozpočtu.

Tím se sjednotí fixní tarif (řazení sell ASC) a spot (řazení buy ASC + pre-neg pre_window_wh).


Poslední aktualizace: 2026-06-01 — přidán odkaz na plánovaný charge-slot-budget; dříve 2026-05-27 self-konzistentní grid maska B (v12).