Files
ems/docs/04-modules/planning.md
Dusan Vojacek f1a4dbd7e7
Some checks failed
CI and deploy / migration-check (push) Failing after 20s
CI and deploy / deploy (push) Has been skipped
zruseni fixnich konstant
2026-05-25 09:41:06 +02:00

49 KiB
Raw Blame History

Modul: Planning (LP Optimalizace)

Přístup

PuLP + HiGHS solver lineární programování (LP) s uvolněním binárních proměnných.

Implementované provozní změny (2026-03, aktualizace 2026-04)

  • SQL-first: horizont a sloty z DB funkcí (fn_planning_horizon_end, fn_load_planning_slots_full, …); viz CLAUDE.md → sekce SQL-first a read-model.
  • Dynamický horizont (jen OTE): konec plánu z ems.fn_planning_horizon_end(site_id, horizon_start) (výchozí strop 36 h, minimum pro rolling 1 h obojí jako defaultní argumenty v SQL, úprava přes repeatable migraci). Pomocná ems.fn_last_effective_ote vrací konec posledního OTE intervalu. Rolling replan při NULL přeskočí; denní plán použije krátký (1 h) fallback v Pythonu. Sloty v solveru jsou bez predikovaných cen v rámci tohoto horizontu.
  • Terminal SoC shadow price: v objective je člen (avg_buy_prvních_24h × planner_terminal_soc_value_factor / 1000) × soc[T1] (Kč), kde faktor je ems.asset_battery.planner_terminal_soc_value_factor přes ems.fn_planning_site_context (default v DB 0.9); viz sekci Tuning pro malé baterie níže. Účel: konec horizontu nemusí končit zbytečně vyprázdněnou baterií (receding horizon).
  • SoC kontinuita a export z baterie: soc[t] klesá při bd[t] i ge_bat[t] (vybíjení do domu i do sítě). Bez ge_bat v bilanci SoC LP „exportovalo“ bez vybití — arbitrážní dump v pozdních slotech místo ranního peaku.
  • Masky allow_charge / allow_discharge_export (tenký anti-mikrocyklus): generuje ems.fn_load_planning_slots_full (R__063). Ekonomiku primárně řídí LP podle efektivních cen; masky jen omezují počet slotů pro grid nabíjení / export baterie.
    • PV-surplus (vrstva A): ranking dle store_score DESC = future_sell_opportunity sell max(0, buysell); jen sloty s sell ≥ buy degradation. Kumulativní PV pokrývá grid_target (deficit SoC, nad reserve_soc bez násobení charge_slot_buffer). Zbytek → allow_charge=false (PV jen do sítě / bc ≤ pv_surplus v LP).
    • Grid ze sítě (vrstva B, před FVE): výchozí AM/PM 50/50 z grid_target × charge_slot_buffer (do soc_max); nevyčerpaný AM Wh přejde do PM (R__063). Spot: výběr nejlevnější buy (den plánu → před exportním oknem → buy ASC); navíc všechny sloty s buy < 0allow_grid_charge. Po výběru AM/PM běží iterativní self-konzistentní filtr (vyloučí drahé grid sloty, pokud pv_charge_wh_ahead + neg_buy_wh_ahead >= 60 % deficitu SoC; failsafe unlock). Debug: grid_charge_suppressed_reason. Fixní tarif (BA81): stejný AM/PM rozpočet, ale pořadí podle slot_ord (buy konstantní), jen pokud v horizontu existuje sell > buy + degradation; jinak jen PV vrstva A. Cap slotů: ceil(budget/per_slot_wh) × charge_slot_buffer. charge_acquisition: vážený buy u allow_grid_charge před 1. exportem; two-pass v planning_engine.py.
    • PV vrstva A: při sell ≥ 0 jen pokud sell ≥ future_sell_opportunity degradation (držet FVE na večerní peak). Při sell < 0 vrstva A bez tohoto filtru (nabít z FVE v záporném výkupním okně). Historie: docs/planning-changelog.md.
    • LP (AUTO): objective explicitně ge_pv×sell ge_bat×sell + ge_bat×acquisition v exportních slotech; bez cross-slot vynucení ge_pv ≥ surplus. Guard FVE: ge_pv=0 jen pokud sell < charge_acquisition degrad (ne sell < buy ve slotu). Viz planning-arbitrage-accounting.md.
    • Load-first (Deye, AUTO): proměnné pv_ld (PV → load+EV+TČ), pv_sp (přebytek), bc_pv / bc_gi. Plná bilance pv_a + pv_b + gi + bd = load + ev + hp + bc + ge; bc_pv + ge_pv ≤ pv_sp; gi ≤ load + bc_gi; mimo allow_discharge_export: bd ≤ load pv_ld a pv_ld ≥ load gi bd. Snapshot: load_first_enabled=true. Test LoadFirstDispatchTests.
    • Tvrdé výkonové limity site/baterie: gi ≤ site_grid_connection.max_import_power_w (breaker); bc_pv + bc_gi ≤ asset_battery.max_charge_power_w; ge ≤ max_export_power_w (proměnná ge, platí ge = ge_pv + ge_bat); bd + ge_bat ≤ asset_battery.max_discharge_power_w (vybíjení do domu + export z baterie nesmí současně překročit BMS). Dříve LP dovoloval import+nabíjení a dvojnásobné nabíjení; u prodeje hrozilo současné bd a ge_bat až 2× max discharge — viz SitePowerCapTests.
    • Hodnota FVE (PV store value): ge_pv = 0, pokud sell < future_sell_opportunity degradation (ne charge_acquisition — u fixního KV1 by jinak blokoval export při sell 2 Kč). Před prvním sell < 0 v horizontu: při sell ≥ 0 smí ge_pv až do pv_sp (strategie BA81: vyvézt přes poledne, pak nabít z FVE v záporném okně). Výjimka nucený vent jen plná baterie. Testy Home01PvStoreValueTests, PreNegativeSellExportTests.
    • Drahý nákup → vlastní spotřeba z baterie: mimo allow_charge platí bd + pv_ld ≥ load_baseline + hp[t] a gi ≤ EV + hp[t] (ne hp_rated). Spot: drahý slot = buy > min(buy≥0) + degradace. Fixní nákup (DB purchase_pricing_mode=fixed nebo heuristika rozptylu buy < 0,25): navíc buy > charge_acquisition + degradace. Na spotu nesmí charge_acquisition (~0,9 Kč) označit všechny sloty jako drahé → Infeasible (home-01). Při Infeasible solver jednou opakuje s relaxed_expensive_import (síť smí krmit baseload v drahých slotech; v solver_params.inputs.relaxed_expensive_import=true). Testy AutoPassiveSelfConsumptionTests, test_spot_low_acquisition_does_not_mark_all_slots_expensive, test_negative_buy_in_horizon_does_not_block_all_grid_import.
    • Záporný výkup (sell < 0) bez exportu: block_export_on_negative_sell (KV1) nebo purchase_pricing_mode=fixed (BA81). Spot (home-01): ge_pv=0 dokud není plná baterie; při plné jen ventil pole B (ge_pv ≤ pv_b, w_pv_b_vent_neg); výboj baterie při sell<0 jen 12 slotů před buy ≤ planner_extreme_buy_threshold (default 2), pokud spread do budoucna dává smysl — tag 2026-05-26-neg-sell-bat-dump-extreme-buy-v11. Večerní discharge maska u spotu: denní peak ≥17:00 (ne sell > ref_buy v slotu).
    • Pole B při sell<0 (home-01): pokud block_export_on_negative_sell = false, LP nesmí vynutit ge_pv = 0 (přebytek neriťitelného PV B). KV1 s block_export = true jen curtail A / nabíjení.
    • ref_buy_min (brána exportu): min(buy_price) horizontu — jen „existuje levný nákup?“, ne průměrná cena nabití přes hodiny. Export sloty: sell > ref_buy_min + degradation (spot). Viz planning-arbitrage-accounting.md.
    • Pokud energy_to_fill <= 0 nebo charge_slot_buffer = 0: všechny sloty povoleny.
  • LP ekonomické guardy (solve_dispatch, AUTO): ge_pv=0 pokud sell < charge_acquisition degradation (výjimka: plná baterie, přebytek pv_b). Pokud buy > min(buy)+degradation mimo charge masku → gi jen na load+EV+TČ. Viz planning_engine.py po slot pre-selection.
  • Denní safety charge (měkké LP, ne maska): fn_load_planning_slots_full (V077+) vrací navíc odhad nočního baseload Wh (20:0006:00 Europe/Prague), buffer % z asset_battery.planner_night_baseload_buffer_percent, lookahead future_*_czk_kwh, volitelný safety_soc_target_wh (619) a flag is_daytime_pv_surplus_slot.\n+\n+ V solveru (planning_engine.solve_dispatch()):\n+ - safety_soc_target_wh se používá primárně jako ochrana exportu z baterie: v běžných slotech (mimo highsell špičky) se při aktivním exportu vynutí soc[t] ≥ max(arb_base_wh, safety_soc_target_wh).\n+ - safety deficit penalizace v objective běží jen v is_daytime_pv_surplus_slot (a ne v highsell špičce), aby solver neměl motivaci dělat obecné „nabij co nejdřív“ chování.\n+ Tvrdé allow_charge se kvůli tomu nemění.
  • Rolling charge commitment: při run_rolling_replan se z aktivního plánu načtou sloty, kde dříve platilo battery_setpoint_w > 500, pv_a+pv_b > load_baseline, grid_setpoint_w ≤ 0 a současně není výrazný export (grid_setpoint_w ≥ 500). To je záměr: commitment má kotvit „nabíjení z PV přebytku“, ne „charge while exporting“. Měkká penalizace proti snížení bc[t] oproti předchozímu plánu je řízená planner_charge_commitment_penalty_czk_kwh na asset_battery. Implementace: _load_previous_plan_charge_commitment_prev_w, volitelný argument charge_commitment_prev_w u solve_dispatch().
  • Debug snapshot: každý běh ukládá JSON do ems.planning_run.solver_params (sekce version, inputs, masks, soc_bounds, objective_terms, chosen_slots) přes fn_planning_run_commit (p_run_meta->'solver_params'). Read-model: select ems.fn_planning_run_debug(<run_id>); (R__087_fn_planning_run_debug.sql).
  • Runtime guard v exportu setpointů (legacy):
    • při AUTO + is_predicted_price=true se na exportní vrstvě vynutí PASSIVE/no-export chování (u nových plánů by is_predicted_price v horizontu nemělo nastat).
  • Ekonomika baterie:
    • min_soc_percent = nejnižší SoC v LP a runtime clamp telemetrie; u více paralelních stringů držet nad holým BMS minimem (typicky 1112 %; migrace V029 + komentář v DB, u home-01 cílený UPDATE z 10 %),
    • reserve_soc_percent = ekonomická („arbitrážní“) podlaha pod ní MILP s w_arb omezuje vybíjení podle začátku slotu a FVE lookahead (arb_floor_series; typicky 20 %),
    • Export ze site: binárka z_export[t] pokud grid_export ≥ 1 W, musí být koncové soc[t] ≥ export_soc_floor_wh, kde:\n+ - při hluboké relaxaci (soc_panel_min pod min_soc) je export_soc_floor_wh = soc_panel_min[t],\n+ - jinak je export_soc_floor_wh = arb_base_wh, a v běžných slotech se safety targetem navíc max(arb_base_wh, safety_soc_target_wh) (mimo highsell špičky). arb_floor_series se pro z_export nepoužívá.
    • degradation_cost_czk_kwh (např. 0.15) / penalizace cyklu v objective symetrická (0.5*(charge+discharge)).
  • PV-aware nejistota:
    • objective používá pv_scarcity_factor (0.65..1.0), odvozený z forecastu slunce,
    • při slabém slunci je plán ochotnější držet energii v baterii.
  • SoC buffer:
    • měkký cíl na konci 24h přes _soc_security_profile + tvrdé dvouúrovňové pravidlo výše.
  • Dynamická ekonomická podlaha (fáze 2):
    • _dynamic_arb_floor_wh_series: podle součtu FVE výkonu v dalších ~8 h (ARB_LOOKAHEAD_SLOTS) se arb_floor_wh[t] posouvá mezi min_soc_wh a rezervou z DB silné očekávané slunce ji sníží (ráno / po obloze); vynutit konstantní chování lze battery.disable_dynamic_arb_floor=True jen pro testy / ladění.
    • Výběr exportních slotů (allow_discharge_export): ems.fn_load_planning_slots_full (R__063). Tři vrstvy:
      1. Globální rozpočet Wh (discharge_slot_buffer × exportovatelná kapacita): sloty podle sell_price desc. Před prvním sell < 0 se z rozpočtu vynechají sloty, kde později tentýž den existuje sell vyšší o více než degradation (OTE, ne pevné hodiny 0004).
      2. Večerní špičky per den: sell ≥ max(sell) degradation jen pro hodiny ≥ 17 (Prague), ne globální max horizontu (jinak by vyhrála půlnoc 3,7 Kč místo večera).
      3. Ranní pásmo před prvním sell < 0: hodiny 511 téhož kalendářního dne — všechny sloty s sell ≥ lokální_max_ráno degradation; ostatní sloty mezi ranním pásmem a prvním sell < 0 s nižším sell mají export zakázán (žádný dump v 07:30 za 2 Kč). charge_acquisition: vážený buy před prvním exportem téhož dne jako záporné výkupní okno. Planner tag v24: v23 + večerní tvrdý push podle rozpočtu Wh (discharge_slot_buffer, SoC nad min_soc, per_slot_discharge) — bez pevného top-3 / len≥2. Viz changelog v24. Planner tag v23: v22b + výboj baterie do sítě před buy<0 (_pre_neg_buy_discharge_indices, sell≥1 Kč/kWh, push ge_bat z DB limitů). Viz changelog v23. V solve_dispatch (AUTO): charge_slots = allow_charge z DB + buy < 0 + všechny sloty sell < 0 s PV přebytkem > 500 W (i bez block_export_on_negative_sell, BA81). pv_charge_shortfall / NEG_SELL_CURTAIL_PENALTY platí v těchto slotech. Při sell < 0: safety deficit cílí soc_max_wh (plný planner strop). Po posledním sell < 0 tentýž den: post_neg_pv_topup dobije z FVE na soc_max před exportem (kladný sell, ne high-sell peak). U fixního tarifu s polem B: ge_pv ≤ pv_b (ne pv_store ge_pv = 0). Při deye_gen_microinverter_cutoff_enabled: ge == 0 jen pokud block_export_on_negative_sell (KV1), ne kvůli samotnému z_gen_cutoff (BA81 musí moci exportovat B při plné baterii). Vstupní soc_wh z telemetrie se před MILP omezí přes _planner_soc_for_solver (rezerva ~650 Wh pod soc_max, jinak Infeasible při 100 % SoC a dlouhém záporném výkupu). planner_build_tag v solver_params. Changelog: docs/planning-changelog.md.
  • Záporná nákupní cena:
    • horní mez grid_import zahrnuje load_baseline_w + nabíjení/EV/TČ (bez nekonečného importu).
  • Záporná prodejní cena → tvrdý zákaz vývozu (ge = 0) (planning_engine.solve_dispatch): platí ve slotu kde sell_price < 0, pokud lokality zapne některou z opcí —
    • asset_inverter.deye_gen_microinverter_cutoff_enabled (deye-main) — spojeno s MILP binárkami GEN cut-off (BA81),
    • nebo ems.site_grid_connection.block_export_on_negative_sell (migrace V074, default false) — bez GEN registrů na Deye; vhodné např. pro KV1 (fixní nákup, bez nutnosti vést výkon neriťitelného pole B do sítě). home-01 nech false, jinak může být horizont při přebytku z pole B a plné/nedostupné baterii infeasible (solver export potřebuje jako fyzikální ventil tam, kde FVE B nelze štípnout ani odpojit modelem bez GEN řádku).
  • Export bez forecastového capu: solver ukládá explicitní planning_interval.export_limit_w jako tvrdý site/inverter limit a planning_interval.export_mode (NONE / PV_SURPLUS / BATTERY_SELL). Exportér z plánu neodvozuje žádný forecastový strop exportu.
  • Uložené vstupy plánu (planning_interval): load_baseline_w, pv_*_forecast_raw_w, pv_*_forecast_solver_w pro UI a audit.
  • Více FVE polí s různou orientací: planning_engine._load_slots sčítá predikovaný výkon za 15min přes všechna asset_pv_array dané lokality — pv_a_forecast_w = součet řádků s controllable = true, pv_b_forecast_w = součet s controllable = false. Pro každé pole a slot se bere nejnovější forecast_pv_run (ORDER BY created_at DESC, DISTINCT ON (pv_array_id)). Curtailment v LP zůstává jedno agregované pv_a (součet řiditelných polí); per-string curtailment by vyžadovalo rozšíření modelu.
  • Kanonický PV forecast (delta + rolling): tabulka ems.site_pv_forecast_calibration drží per site_id mimo jiné delta_learn_min_ts (dolní mez řádků z forecast_accuracy pro učení delty), volitelně pv_curtailment_policy_effective_from a přepsání parametrů (top_n_days, half_life_days, …; V076 navíc reference_day_weight_mult pro „připnuté“ dny níže). ems.site_pv_forecast_reference_day (V076) umožňuje zvýšit váhu konkrétních kalendářních dnů (datum ve site.timezone jako u časování slotů) při agregaci δ z forecast_accuracy (fn_pv_forecast_delta_profile); hromadný zápis ems.fn_pv_forecast_sync_reference_days, detail docs/04-modules/forecast.md. ems.fn_fill_forecast_accuracy nastavuje learning_eligible / learning_exclude_reason (sloty před cutoffem, nebo se škrcením / gen cut-off / záznamem v ems.cutoff_switch_log po účinnosti policy se z učení vyřadí; u škrcení zůstává actual_power_w NULL). Telemetrie: ems.telemetry_inverter.is_export_limited nebo pv_derating_flags <> 0 v okně 15min → stejné vyloučení (telemetry_derating).\n+\n+ Single source of truth pro solver i UI je ems.fn_forecast_pv_slots_range_canonical_ab, která v jednom místě kombinuje:\n+ - delta profil (aditivní odečet per-array)\n+ - rolling multiplikativní faktor vs telemetrie (fn_pv_forecast_correction_factor) s decay.\n+ ems.fn_load_planning_slots_full bere PV A/B z této kanonické funkce; UI je čte z /plan/current (bundle obsahuje pv_*_forecast_solver_w i pv_forecast_total_w jako součet).

Solver optimalizuje celý horizont (typicky do konce známých OTE dat, strop z fn_planning_horizon_end) najednou, čímž přirozeně zvládá:

  • pohled dopředu (ráno ví že přes poledne bude záporná cena → prodává z baterie)
  • kompromisy mezi prodejem, nabíjením, TČ a EV v globálním optimu

Arbitráž baterie — účtování mezi sloty (povinné čtení)

Detail: planning-arbitrage-accounting.md.

  • Nesmysl: řídit arbitráž tak, že v jednom 15min slotu porovnáváme buy[t] a sell[t] jako nákup a prodej téže kWh z baterie. Ve výprodejním okně (např. sell 4,6 Kč, buy 7 Kč) je LP marginalně proti exportu, i když energie byla nabitá v poledne za ~0,7 Kč.
  • min(buy) horizontu není nákupní cena zásoby — je to jeden čtvrthodinový slot; u home-01 lze nabíjet hodiny (64 kWh, až 17 kW ze site ≈ 4,25 kWh/slot). Acquisition cost musí vycházet z nabíjecího okna (průměr / vážený průměr / N nejlevnějších slotů podle potřebných Wh), ne z jednoho minima.
  • Dnešní ref_buy = min(buy) ve maskách je jen hrubá brána pro výběr slotů, ne model zisku z cyklu.
  • Arbitráž baterie: charge_acquisition_buy_czk_kwh z fn_load_planning_slots_full (vážený grid+FVE před charge_acquisition_cutoff_at); LP přičítá ge_bat × acquisition v allow_discharge_export. Detail: planning-arbitrage-accounting.md.

Verifikace (DB)

Pro kontrolu masek nabíjení:

select *
from ems.fn_load_planning_slots_full(<site_id>, <from_utc>, <to_utc>, <current_soc_wh>)
where allow_charge is true
order by interval_start;
  • PV-surplus: allow_charge=true pro nejvyšší store_score, dokud se nepokryje grid_target.
  • Non-PV: levný buy, lookahead 4 sloty, cap 6/segment; OTE před predikovanými.
  • Pokud current_soc_wh odpovídá plné baterii (soc_max_wh), jsou povoleny všechny sloty.

Klíčové předpoklady a specifika home-01

FVE pole A (10 kWp, řízené Deye)

  • Curtailment povolen přes Modbus (Output Power Limit)
  • Solver může omezit výrobu pokud export nevychází a není kam ukládat
  • Curtailment má nulový přímý náklad, ale ztrátu příležitosti

FVE pole B (10 kWp, ongridový na GEN portu)

  • Nelze omezit ani řídit
  • zelený bonus (dotace za každé vyrobené kWh bez ohledu na cenu)
  • Výroba pole B musí být vždy plně spotřebována nebo uložena
  • Při záporné prodejní ceně má nejvyšší prioritu ukládání (baterie → EV → TČ)
  • Bez tvrdého zákazu ge = 0 při záporném výkupu (viz výše block_export_on_negative_sell / GEN cut-off) může MILP vývoz zvolit i ekonomicky proti „bonusové“ náplni baterie; u home-01 jde o záměrný trade-off (zelený bonus pole B, prostor baterie na záporný nákup). S block_export_on_negative_sell = true (typicky KV1) musí přebytek jít do baterie / curtail A, ne do sítě.

Poznámka: výše platí pro home-01 (pv-b jako ongrid GEN se zeleným bonusem), kde pole B nechceme curtailovat. U instalací typu BA81 je na GEN portu typicky AC coupling (mikroinvertory) bez bonusu výkon nelze plynule škrtit, ale lze ho tvrdě odpojit (cut-off) přes Deye reg 179 (viz modbus-registers.md). To je samostatná logika níže.

Export / import limity (home-01)

  • Max export do sítě: 13.5 kW (smlouva s distributorem)
  • Max import ze sítě: dle site_grid_connection.max_import_power_w
  • Konfigurovatelné per site v DB

Plánovací strop gi[t] vs. fyzický jistič

V LP má grid_import[t] (proměnná gi) horní mez max_import_power_w + battery.max_charge_power_w, ne jen max_import_power_w. Důvod:

  • Ceny se mění co 15 min a cílem je nabíjet baterii v cenově nejlepších oknech na BMS max (1718 kW), i když baseline zátěž doma navíc sežere část jističe.
  • O fyzické dodržení jističe se stará Deye reg 128 (grid charge current) + firmware — v reálném čase sníží bc, když load + bc přesáhne breaker.
  • Pokud bychom gi[t] ≤ max_import_power_w nechali jako tvrdé LP omezení, LP by v slotech s vyšší load_baseline_w zbytečně osekával bc dolů (viděno např. 2026-04-19 13:30: load 3.7 kW, breaker 17 kW → bc ≤ 17 3.7 + pv_b ≈ 14.7 kW, i když BMS zvládne 18 kW). Optimistický gi horní strop umožní plánovat plné využití BMS v cenových oknech; reálný HW nikdy nepřetáhne jistič.
  • Trade-off: expected_cost v plánu může být mírně optimistický (LP spočítá s ~20 kW importem, reálně občas míň kvůli skokům domácí zátěže). Rozdíl se automaticky dohání rolling replanem co 15 min.

Energetická bilance (pro každý 15min slot t)

pv_a_actual[t] + pv_b[t] + grid_import[t] + battery_discharge[t]
  = load_baseline[t]
    + Σ_e (ev_direct[e][t] + ev_via_bat[e][t])
    + heat_pump[t]
    + battery_charge[t] + grid_export[t] + pv_a_curtailed[t]

kde:

  • pv_a_actual[t] = pv_a_forecast[t] pv_a_curtailed[t]
  • pv_b[t] = predikce pole B (pevná, nekontrolovatelná)
  • grid_import[t], grid_export[t] ≥ 0 (oddělené proměnné, ne signed)
  • ev_direct[e][t] = přímé napájení EV e ze zdrojů (FVE, síť) bez průchodu baterií
  • ev_via_bat[e][t] = napájení EV e přes baterii (kryta z battery_discharge[t])

Round-trip efektivita: Přímé napájení EV je ~10 % levnější než přes baterii (η_charge × η_discharge ≈ 0.95 × 0.95 ≈ 0.90). Solver to vidí v účelové funkci.


Proměnné solveru

Proměnná Typ Rozsah Popis
grid_import[t] kontinuální 0 (max_import + bms_max_charge) Nákup ze sítě v W; breaker fyzicky drží Deye reg 128
grid_export[t] kontinuální 0 max_export (13500) Prodej do sítě v W
battery_charge[t] kontinuální 0 max_charge Nabíjení baterie v W
battery_discharge[t] kontinuální 0 max_discharge Vybíjení baterie v W
soc[t] kontinuální soc_min soc_max Stav nabití baterie v Wh
pv_a_curtailed[t] kontinuální 0 pv_a_forecast[t] Omezení výroby pole A v W
ev_direct[e][t] kontinuální 0 min(ev_max, pv_surplus) Přímé napájení EV e z FVE/sítě (bez průchodu baterií)
ev_via_bat[e][t] kontinuální 0 ev_max Napájení EV e přes baterii (s round-trip ztrátou)
heat_pump[t] kontinuální 0 hp_rated Výkon TČ v W (relaxováno z binární)

TČ relaxace: TČ je v realitě ON/OFF (binární). Pro LP ho relaxujeme na spojitou proměnnou 0rated_power. Post-processing pravidlo pak zaokrouhlí na ON/OFF a zkontroluje min_run_duration. V praxi výsledek LP vychází blízko binárnímu řešení.


Účelová funkce (minimalizace nákladů)

EV_ROUNDTRIP_FACTOR = 1.0 / (charge_efficiency * discharge_efficiency)  # ≈ 1.108

minimize:
  Σ_t [
    # Náklady na nákup ze sítě
    grid_import[t] * buy_price[t] * interval_h

    # Příjem z prodeje (záporný náklad)
    - grid_export[t] * sell_price[t] * interval_h

    # Náklad degradace baterie (nabíjení i vybíjení)
    + (battery_charge[t] + battery_discharge[t]) * degradation_cost * interval_h

    # EV přímé napájení  standardní cena energie
    + Σ_e ev_direct[e][t] * buy_price[t] * interval_h

    # EV přes baterii  navýšeno o round-trip ztrátu + degradaci
    # Solver tak přirozeně preferuje přímé nabíjení nad průchodem baterií
    + Σ_e ev_via_bat[e][t] * buy_price[t] * EV_ROUNDTRIP_FACTOR * interval_h

    # Malá penalizace curtailmentu pole A (preferujeme využití FVE).
    # Výjimka: pokud existuje PV B a v budoucnu v horizontu nastane buy < 0, pak v okně sell < 0
    # solver preferuje curtail PV A před placeným exportem (penalizace curtailmentu se v těchto slotech snižuje na 0).
    + pv_a_curtailed[t] * CURTAILMENT_PENALTY
  ]

kde interval_h = 0.25 (15 min = 0.25 h), ceny v Kč/kWh, výkony ve W.


Omezení solveru

Energetická bilance

pv_a_forecast[t] - pv_a_curtailed[t] + pv_b[t] + grid_import[t] + battery_discharge[t]
  == load_baseline[t]
     + Σ_e (ev_direct[e][t] + ev_via_bat[e][t])
     + heat_pump[t] + battery_charge[t] + grid_export[t]

Vazba ev_via_bat na battery_discharge

# ev_via_bat musí být kryto z vybíjení baterie
Σ_e ev_via_bat[e][t] <= battery_discharge[t]

Limit výkonu EV per vozidlo

# Celkový výkon do EV e nesmí překročit min(WB limit, vozidlo max)
ev_direct[e][t] + ev_via_bat[e][t] <= min(charger_max_w[e], vehicle_max_w[e])

# Pokud auto není připojeno → nula
if not ev_connected[e][t]:
    ev_direct[e][t] == 0
    ev_via_bat[e][t] == 0

Deadline charging hard constraint

# Pro každé EV e s nastaveným deadline a known SoC:
if ev_session[e].target_deadline and ev_session[e].soc_at_connect_pct is not None:
    energy_needed_wh = (
        (target_soc_pct - soc_at_connect_pct) / 100.0
        * vehicle_capacity_wh[e]
    )
    t_deadline = slot_index(ev_session[e].target_deadline)

    pulp.lpSum(
        (ev_direct[e][t] + ev_via_bat[e][t]) * interval_h
        for t in range(t_deadline + 1)
        if ev_connected[e][t]
    ) >= energy_needed_wh

# Pro Zoe (SoC neznámý)  deadline constraint na kumulativní dodanou energii:
# energy_needed = (default_target_soc - estimated_soc_from_session) * capacity

SoC kontinuita

soc[t] == soc[t-1]
          + battery_charge[t]   * charge_efficiency   * interval_h
          - battery_discharge[t] / discharge_efficiency * interval_h

soc[0] == current_soc_wh  # počáteční podmínka z telemetrie

SoC limity

soc_min_wh <= soc[t] <= soc_max_wh   # min_soc_percent z DB (provozní podlaha, často 1112 %)

# Ekonomická podlaha (reserve_soc_percent): w_arb[t] + arb_floor_series[t] 
# bd omezeno podle soc na začátku slotu (žádné „nadbytečné“ vybíjení z hlubokého pásma při exportu z AKU).

# Při grid_export[t] >= 1 W: soc[t] >= arb_base_wh (rezerva z DB, ne časová řada arb_floor).

# Měkký buffer na konci 24h dál přes soc_deficit_24h.

Limity výkonu

0 <= battery_charge[t]    <= battery.max_charge_power_w
0 <= battery_discharge[t] <= battery.max_discharge_power_w
0 <= grid_import[t]       <= grid.max_import_power_w + battery.max_charge_power_w  # LP soft; fyzicky drží Deye reg 128
0 <= grid_export[t]       <= grid.max_export_power_w   # = 13500 pro home-01
0 <= pv_a_curtailed[t]    <= pv_a_forecast[t]
0 <= ev_charge[t]         <= ev_max_total_w
0 <= heat_pump[t]         <= heat_pump.rated_heating_power_w

Nelze současně nabíjet a vybíjet baterii

# Přirozeně vyplyne z optimalizace díky degradation_cost.
# Pokud ne, přidat: battery_charge[t] * battery_discharge[t] == 0
# (to by ale byl QP, ne LP  raději nechat degradation_cost dělat práci)

Záporná prodejní cena zákaz exportu

if sell_price[t] < 0:
    grid_export[t] == 0  # přidat jako constraint pro daný slot

Záporná prodejní cena pole B má prioritu v ukládání

# Pokud sell_price[t] < 0, výroba pole B nesmí jít do exportu.
# Formulace: grid_export[t] <= grid_import[t] + battery_discharge[t] ...
# Jednodušeji: pokud sell_price < 0, přidat constraint grid_export[t] == 0
# (export stejně zakázán výše) a solver automaticky uloží přebytek.

BA81 / GEN port (mikroinvertory): kdy dává smysl „Grid export cut-off“

Kontext (instalace typu BA81):

  • PV1/PV2 (DC stringy na Deye) jsou řiditelné při zákazu exportu je Deye umí stáhnout až k nule.
  • GEN port (AC coupling / mikroinvertory) řiditelný výkonově není vyrábí „co dá slunce“. Při sell_price < 0 tedy nastává problém:
  • baterie má omezený nabíjecí výkon (např. BA81 cca 6 kW) a navíc při vysokém SoC má reálně menší „přijímací schopnost“,
  • pokud výroba na GEN portu převýší okamžitou spotřebu + možný charge do baterie, zbytek fyzicky teče do sítě (nechtěný export za zápornou cenu).

Řešení na hardware úrovni:

  • Deye reg 178 bits01 („MI export to Grid cutoff“, často uváděno jako “register 179” v 1-based značení) umožní GEN port tvrdě odpojit.

Správné rozhodovací pravidlo (záměr)

Cut-off nechceme spínat „vždy když sell<0“, protože při zataženu / malé výrobě jsou i malé watty z GEN užitečné.

Chceme spínat pouze tehdy, když je v daném slotu očekávaný přebytek z GEN, který není kam dát: [ pv_gen_w ;>; load_w ;+; batt_charge_cap_w ;+; flexible_load_w ]

kde:

  • pv_gen_wpv_b_forecast_solver_w (GEN/mikroinvertory)
  • batt_charge_cap_w = min(battery.max_charge_power_w, ((soc_{max}-soc)_{wh} / 0.25h)) tj. výkonově omezené a SoC-headroom omezené
  • flexible_load_w = plánované EV/TČ setpointy v daném slotu (pokud jsou připojené / povolené)

Implementace v EMS (aktuální chování)

  • Cut-off se řeší přímo v LP binární proměnnou z_gen_cutoff[t] (0/1), která modeluje, zda je GEN port odpojen.
    • Efektivní výkon z GEN do bilance: pv_b_effective[t] = pv_b_forecast_w * (1 - z_gen_cutoff[t])
    • Solver nechá GEN připojený vždy, když je výkon užitečný (sníží import / nabije baterii / pokryje zátěž).
    • z_gen_cutoff[t] je vůbec povolené jen v režimech/politikách, kde to dává smysl:
      • SELF_SUSTAIN
      • sloty s sell_price < 0 („BLOCK_EXPORT“ okna)
      • (případně) explicitní no_export politika, pokud je v kontextu dostupná Mimo tyto případy je z_gen_cutoff[t] vynucené na 0.
    • Cut-off je v účelové funkci penalizované (za „zahozenou“ GEN výrobu), aby se zapínalo jen jako poslední možnost.
    • Výstup se ukládá do planning_interval.deye_gen_cutoff_enabled (nullable) a exporter pak nastaví bity reg 178.

Scope / bezpečnost: proměnná i flag existují jen na lokalitách, kde je zapnutý asset_inverter.deye_gen_microinverter_cutoff_enabled (tj. kde je GEN port s mikroinvertory reálně zapojen). Jinde se nic neřeší ani nezobrazuje.

Záporná nákupní cena nabíjet ze sítě je výhodné

# Pokud buy_price[t] < 0, grid_import[t] je příjem → solver automaticky maximalizuje import.
# Omezit maximálním výkonem baterie (aby to mělo smysl):
# grid_import[t] <= battery.max_charge_power_w + ev_max_total_w + heat_pump.rated_heating_power_w
# (nechceme kupovat víc než spotřebujeme / uložíme)

TUV minimální teplota nouzový ohřev vždy

# Pokud aktuální teplota zásobníku < tuv_min_temp_c:
#   heat_pump[t=0] >= heat_pump.rated_heating_power_w * 0.8  # minimálně 80% výkonu v prvním slotu
# Toto je tvrdé omezení nezávislé na ceně.

Implementace (Python / PuLP)

# backend/services/planning_engine.py

import pulp
from pulp import HiGHS_CMD

def solve_dispatch(
    site_id: int,
    slots: list[PlanningSlot],  # 15min sloty s cenami, forecasty
    battery: AssetBattery,
    heat_pump: AssetHeatPump,
    grid: SiteGridConnection,
    current_soc_wh: float,
    current_tuv_temp_c: float,
    ev_max_total_w: int,
) -> list[DispatchResult]:

    T = len(slots)
    H = 0.25  # interval v hodinách
    CURTAILMENT_PENALTY = 0.001  # Kč/Wh  malá penalizace aby solver preferoval využití

    prob = pulp.LpProblem("ems_dispatch", pulp.LpMinimize)

    # --- Proměnné ---
    # gi horní mez = breaker + BMS max_charge (LP optimistický strop, Deye reg 128 chrání fyzicky)
    gi_upper = grid.max_import_power_w + battery.max_charge_power_w
    grid_import    = [pulp.LpVariable(f"gi_{t}", 0, gi_upper)                  for t in range(T)]
    grid_export    = [pulp.LpVariable(f"ge_{t}", 0, grid.max_export_power_w)  for t in range(T)]
    batt_charge    = [pulp.LpVariable(f"bc_{t}", 0, battery.max_charge_power_w)    for t in range(T)]
    batt_discharge = [pulp.LpVariable(f"bd_{t}", 0, battery.max_discharge_power_w) for t in range(T)]
    soc            = [pulp.LpVariable(f"soc_{t}",
                                       battery.min_soc_wh,
                                       battery.soc_max_wh)                    for t in range(T)]
    curtail_a      = [pulp.LpVariable(f"ca_{t}", 0, slots[t].pv_a_forecast_w) for t in range(T)]
    ev_charge      = [pulp.LpVariable(f"ev_{t}", 0, ev_max_total_w)           for t in range(T)]
    heat_pump_p    = [pulp.LpVariable(f"hp_{t}", 0, heat_pump.rated_heating_power_w) for t in range(T)]

    # --- Účelová funkce ---
    prob += pulp.lpSum(
        grid_import[t]    * slots[t].buy_price  * H / 1000   # Kč (W→kW)
        - grid_export[t]  * slots[t].sell_price * H / 1000
        + (batt_charge[t] + batt_discharge[t]) * battery.degradation_cost_czk_kwh * H / 1000
        + curtail_a[t]    * CURTAILMENT_PENALTY
        for t in range(T)
    )

    # --- Omezení ---
    for t in range(T):
        s = slots[t]
        pv_a_net = s.pv_a_forecast_w - curtail_a[t]

        # Energetická bilance
        prob += (
            pv_a_net + s.pv_b_forecast_w + grid_import[t] + batt_discharge[t]
            == s.load_baseline_w + ev_charge[t] + heat_pump_p[t] + batt_charge[t] + grid_export[t]
        )

        # SoC kontinuita
        soc_prev = current_soc_wh if t == 0 else soc[t-1]
        prob += soc[t] == (
            soc_prev
            + batt_charge[t]    * battery.charge_efficiency    * H
            - batt_discharge[t] / battery.discharge_efficiency * H
        )

        # Záporná prodejní cena → zakázat export
        if s.sell_price < 0:
            prob += grid_export[t] == 0

        # Záporná nákupní cena → omezit import na to co reálně spotřebujeme/uložíme
        if s.buy_price < 0:
            prob += grid_import[t] <= (
                battery.max_charge_power_w
                + ev_max_total_w
                + heat_pump.rated_heating_power_w
            )

    # Nouzový ohřev TUV  pokud zásobník pod minimem
    if current_tuv_temp_c < heat_pump.tuv_min_temp_c:
        prob += heat_pump_p[0] >= heat_pump.rated_heating_power_w * 0.8

    # --- Řešení ---
    solver = HiGHS_CMD(msg=False, timeLimit=10)
    status = prob.solve(solver)

    if pulp.LpStatus[status] != 'Optimal':
        raise PlanningError(f"Solver nenašel optimální řešení: {pulp.LpStatus[status]}")

    # --- Post-processing TČ: relaxovaná → ON/OFF ---
    results = []
    for t in range(T):
        hp_raw = pulp.value(heat_pump_p[t])
        hp_enabled = hp_raw > heat_pump.rated_heating_power_w * 0.3  # threshold pro ON
        hp_power   = heat_pump.rated_heating_power_w if hp_enabled else 0

        results.append(DispatchResult(
            interval_start       = slots[t].interval_start,
            battery_setpoint_w   = round(pulp.value(batt_charge[t]) - pulp.value(batt_discharge[t])),
            battery_soc_target   = round(pulp.value(soc[t]) / battery.usable_capacity_wh * 100, 1),
            grid_setpoint_w      = round(pulp.value(grid_import[t]) - pulp.value(grid_export[t])),
            export_limit_w       = int(grid.max_export_power_w) if round(pulp.value(grid_import[t]) - pulp.value(grid_export[t])) < 0 else 0,
            export_mode          = "BATTERY_SELL" if round(pulp.value(batt_charge[t]) - pulp.value(batt_discharge[t])) < 0 and round(pulp.value(grid_import[t]) - pulp.value(grid_export[t])) < 0 else ("PV_SURPLUS" if round(pulp.value(grid_import[t]) - pulp.value(grid_export[t])) < 0 else "NONE"),
            ev_charge_power_w    = round(pulp.value(ev_charge[t])),
            heat_pump_enabled    = hp_enabled,
            heat_pump_setpoint_w = hp_power,
            pv_a_curtailed_w     = round(pulp.value(curtail_a[t])),
            expected_cost_czk    = round(
                pulp.value(grid_import[t])  * slots[t].buy_price  * H / 1000
                - pulp.value(grid_export[t]) * slots[t].sell_price * H / 1000,
                4
            ),
            effective_buy_price  = slots[t].buy_price,
            effective_sell_price = slots[t].sell_price,
        ))

    return results

Scénáře které solver řeší správně

Ráno vysoká FVE předpověď, přes poledne záporná cena

Solver ráno (vysoká cena):
  → vybíjí baterii do sítě (prodej při high price)
  → exportuje FVE přebytek

Přes poledne (záporná nebo nízká cena):
  → zakáže export (grid_export == 0)
  → nabíjí baterii z FVE + ze sítě (dostane zaplaceno)
  → spouští TČ a EV (spotřebovává levnou/zápornou energii)
  → případně curtailuje pole A pokud je baterie plná a není kam ukládat

Pole B + záporná cena

Pole B vyrábí 10 kWp, sell_price < 0:
  → grid_export == 0 (constraint)
  → solver musí interně spotřebovat vše z pole B
  → prioritně: nabíjení baterie, pak EV, pak TČ
  → pokud nic nestačí → baterie je plná, EV nepřipojeno, TČ na max:
     solver ukáže že zbývající výroba pole B nejde spotřebovat
     → tuto situaci logovat (přebytek nevyužit, bonus přesto inkasován)

Záporná nákupní cena (platíme za odběr)

  → solver maximalizuje grid_import (je to příjem)
  → omezen na max_charge + ev_max + hp_rated (nechceme kupovat zbytečně)
  → nabíjí baterii na maximum
  → spouští EV a TČ naplno

DB rozšíření planning_interval

Přidat sloupec pv_a_curtailed_w do tabulky:

-- V005__planning_curtailment.sql
ALTER TABLE ems.planning_interval
    ADD COLUMN pv_a_curtailed_w INT NOT NULL DEFAULT 0;

COMMENT ON COLUMN ems.planning_interval.pv_a_curtailed_w IS
'Plánované omezení výroby FVE pole A v W (curtailment). 0 = žádné omezení. '
'Hodnota > 0 znamená že solver rozhodl omezit výrobu pole A přes Modbus.';

Fyzická realizace na hybridu (bez změny solveru): při ems.fn_site_has_active_green_bonus_pv(site_id) a nenulovém součtu nominal_power_wp controllable polí na invertoru exportér mapuje pv_a_forecast_solver_w / pv_a_curtailed_w na zápis holding registru 340 (max solar power, W) — viz control.md sekce PV A curtailment a modbus-registers.md reg 340.


Tuning pro malé baterie (např. BA81)

Kromě planner_terminal_soc_value_factor existují od V077 měkké mechanismy denní safety charge a rolling charge commitment (viz výše) — malé instalace nelze spolehlivě stabilizovat jen slepým zvyšováním terminal faktoru na 0.9.

Terminal SoC shadow price (kritický parametr)

V účelové funkci LP je člen „terminal SoC shadow price“: energie ponechaná v baterii na konci horizontu je oceněná jako záporný příspěvek k nákladům (solver má motivaci držet část SoC, pokud se to ekonomicky vyplatí oproti okamžitému vývozu / nákupu).

Výpočet (zjednodušeně):
terminal_soc_kcz_per_wh = (průměr buy v prvních 24 h slotů) × planner_terminal_soc_value_factor / 1000
a v objective se přičítá - terminal_soc_kcz_per_wh × soc[T1] (viz solve_dispatch v backend/services/planning_engine.py).

Kde se bere faktor (jediný kanonický zdroj):

  1. Sloupec ems.asset_battery.planner_terminal_soc_value_factor (NOT NULL, default 0.9 — migrace V062, idempotentní upevnění V069).
  2. Hodnota se do solveru dostává výhradně přes ems.fn_planning_site_context(site_id) → pole battery.planner_terminal_soc_value_factor v JSONu.
  3. Backend v _load_site_context() mapuje JSON na SimpleNamespace a solve_dispatch() už nemá žádný skrytý fallback z kódu — chybí-li klíč v JSONu, je to chyba konfigurace / nasazení.

Historická chyba (opraveno): dříve fn_planning_site_context sloupec z tabulky nepropisoval do battery JSONu a Python atribut vůbec nenačítal, takže se v praxi používala pevná 0.9 z kódu bez ohledu na DB. To umělo zcela převrátit chování (např. BA81 s 0.2 v tabulce se chovalo jako 0.9). Po opravě musí projít repeatable R__039_fn_planning_site_context.sql i backend.

Doporučené hodnoty

Pokud solver „šetří baterku“ a raději importuje ze sítě (kvůli terminal SoC shadow price), lze per baterii upravit váhu této kotvy:

  • ems.asset_battery.planner_terminal_soc_value_factor
    • 0.0 = žádná motivace držet SoC na konci horizontu (agresivnější arbitráž / vybití)
    • 0.9 = výchozí default v DB (konzervativnější držení energie)

Pro BA81 typicky dává smysl menší hodnota (např. 00.3), aby solver klidně „vylil“ baterii do sítě při kladné sell_price a nechal si kapacitu na nabití v oknech záporných cen.

Konfigurace (env proměnné)

PLANNING_SOLVER_TIME_LIMIT_SEC=10    # HiGHS timeout
PLANNING_CURTAILMENT_PENALTY=0.001   # Kč/Wh penalizace za omezení FVE
PLANNING_HP_RELAXATION_THRESHOLD=0.3 # pod 30% rated = OFF při post-processingu
PLANNING_ENGINE_VERSION=v1           # v1 = původní planner, v2 = nová policy větev
PLANNING_ENGINE_COMPARE_ENABLED=false # true = spočítat i druhou verzi a uložit comparison do solver_params

Zelený bonus: Sazba a platnost jsou v ems.asset_pv_array (green_bonus_*). Bonus není v objective function LP solveru jako aditivní konstanta k nákladům by optimalizaci stejně neměnil. Příjem z bonusu se počítá v fn_fill_audit_interval přes ems.fn_green_bonus_revenue() a ukládá se do audit_interval.green_bonus_czk; v přehledech (např. vw_audit_daily) je samostatná položka příjmů vedle nákladů ze sítě. Viz docs/04-modules/market-prices.md → sekce Zelený bonus.


Závislosti (requirements.txt)

pulp>=2.8.0
highspy>=1.7.0    # HiGHS Python binding (rychlejší než HiGHS_CMD)

Preferovat import highspy přímý binding místo HiGHS_CMD shell volání výrazně rychlejší.


Otevřené body

  • Post-processing min_run_duration pro TČ po LP výsledku zkontrolovat a upravit krátké ON/OFF sekvence
  • Zelený bonus v auditu (fn_fill_audit_interval, green_bonus_czk) mimo solver
  • EV rozdělení výkonu mezi 2 nabíječky zatím řešeno jako agregát
  • Curtailment pole A ověřit Modbus registr pro Output Power Limit na Deye SUN-20K
  • Testovat solver na reálných datech ověřit čas výpočtu pro 36h horizont (144 slotů)

Planner v2

Tahle sekce popisuje návrh druhé verze planneru. Cíl je mít samostatný solver, který bude vycházet ze stejného vstupu a bude zapisovat do stejného planning_interval, ale provozní pravidla budou čitelné a striktně dané zadáním.

Význam hranic SoC

  • reserve_soc_percent = ranní cílová hranice, na kterou se má baterie dobít, pokud to denní forecast a ceny umožňují
  • min_soc_percent = fyzická / TOU podlaha, pod kterou baterie nesmí klesnout
  • reserve_soc_percent je tedy provozní kotva pro den, zatímco min_soc_percent je tvrdé minimum
  • reserve_soc_percent není predikce noční spotřeby; jen znamená „než začne export z FVE do sítě, drž baterii aspoň sem“

Základní pravidla v2

Ráno

  • pokud denní forecast dává dostatek výroby nebo levných hodin, planner dobije baterii minimálně na reserve_soc_percent
  • tato rezerva slouží jako ochrana proti neplánované spotřebě během dne
  • min_soc_percent se v ranní fázi nepoužívá jako cíl, ale jen jako spodní limit

Záporná nákupní cena

  • při buy_price < 0 má prioritu nabíjení ze sítě
  • cílem je uložit levnou energii pro pozdější dražší prodej
  • to ale neznamená, že se má baterie dobít hned v první záporné hodině; pokud jsou v horizontu ještě zápornější ceny, může být lepší nabíjet později
  • nabíjení ze sítě je omezené jen fyzickými limity baterie a připojení

Záporná prodejní cena

  • při sell_price < 0 je export do sítě zakázán
  • řiditelná FVE A se může škrtit
  • neřiditelná FVE B se neškrtí, pouze se povinně zohlední v bilanci
  • baterie se nejdřív nabíjí z přebytku FVE, potom se využije flexibilní spotřeba
  • pokud je potřeba uvolnit místo pro pozdější extrémně záporné ceny, může planner baterii předem záměrně mírně vybít až na bezpečnou ekonomickou podlahu

Nezáporná prodejní cena

  • věta „prodám vše“ v tomto návrhu neznamená povinné okamžité vybití baterie
  • znamená pouze to, že pokud je baterie už plná z levných nebo záporných hodin, přebytek FVE A jde do sítě
  • pokud ještě dává větší smysl uložit energii pro pozdější dražší prodej, má přednost uložení do baterie
  • dynamické zátěže jako TUV a wallbox zůstávají plně součástí bilance; jejich spotřeba může být využita jako další „úložiště“ levné energie

Prodej z baterie

  • při cenové špičce má baterie prodávat do sítě
  • v2 má využít baterii jako arbitrážní zásobník mezi levnými a drahými okny
  • vybíjení nesmí klesnout pod min_soc_percent

PV A a PV B

  • PV A je řiditelná a může být curtailovaná
  • PV B je neřiditelná a nikdy se neplánuje jako curtailovaná výroba
  • PV B je vždy pevný vstup do bilance

BA81 / GEN cutoff

  • v lokalitě BA81 může být zapnutý deye_gen_microinverter_cutoff_enabled
  • pokud by při záporné prodejní ceně nebo no-export politice vznikal nežádoucí export z GEN portu, planner v2 musí umět aktivovat cutoff mikroinvertoru
  • cutoff má být součást rozhodnutí planneru, ne dodatečná heuristika v exporteru

Co má být v plánu zapsané

Planner v2 má do planning_interval zapisovat stejné základní položky jako dosavadní verze:

  • battery_setpoint_w
  • battery_soc_target_pct
  • grid_setpoint_w
  • export_limit_w
  • export_mode
  • deye_physical_mode
  • deye_gen_cutoff_enabled
  • pv_a_curtailed_w
  • expected_cost_czk
  • effective_buy_price
  • effective_sell_price

Implementační oddělení od v1

  • v1 zůstává beze změny
  • v2 bude samostatný modul planneru
  • přepnutí mezi v1 a v2 bude na úrovni orchestrace nebo konfigurace lokality
  • exportér i control pipeline mají dál číst standardní výstup z planning_interval
  • pokud je zapnuté PLANNING_ENGINE_COMPARE_ENABLED, backend spočítá obě verze nad stejným vstupem, aktivní verzi zapíše do plánu a druhou uloží i jako samostatný read-only planning_run se stavem comparison
  • compare čtení jde přes GET /api/v1/sites/{site_id}/plan/compare → jedno volání ems.fn_plan_compare_bundle (aktivní plán + fn_planning_run_debug comparison runu)
  • Výkon /plan/current a /plan/compare (V079+): read-model ems.fn_plan_current_bundle dříve při každém HTTP requestu přepočítával fn_pv_forecast_delta_profile nad celou historií forecast_accuracy (~stovky tisíc řádků na site) a kanonický PV forecast na 96 h. Od V079 se delta profil cacheuje v site_pv_forecast_calibration.delta_profile_cache (refresh po fn_fill_forecast_accuracy a po PATCH …/pv-forecast-calibration přes fn_refresh_site_pv_delta_profile_cache; čtení přes fn_pv_forecast_delta_profile_cached, TTL 30 min). Kanonický PV pro graf se počítá jen za horizontem uloženého plánu (horizon_endhorizon_start + 96 h), ne pro sloty už v planning_interval. Ověření: curl -w '%{time_total}\n' http://…/plan/current před/po migraci; první request po deployi může být pomalý dokud cache nezaplní job (15 min) nebo ručně select ems.fn_refresh_site_pv_delta_profile_cache(<site_id>);
  • FE stránka frontend/src/pages/Planning.tsx ukazuje souhrn aktivní verze, compare verze, slotové rozdíly a compare křivku baterie v grafu. Od 2026-05 navíc: acquisition a počty masek z planning_run.solver_params (blok „Solver — masky a arbitráž“), sloupce Export (export_mode) a Masky ( allow_charge / ↓ allow_discharge_export), pásy v grafu (zelená/oranžová okna), detail slotu po kliknutí na řádek. Dashboard StatePanel v tooltipu Deye uvádí export_mode z plánu.
  • fyzicky se na střídač aplikuje jen aktivní plán; compare běh slouží jen pro audit a vizualizaci

Shrnutí v jedné větě

Planner v2 má dělat přesně toto:

  • ráno držet baterii na reserve_soc_percent
  • při záporných nákupních cenách nabíjet ze sítě
  • při záporných prodejních cenách zakázat export
  • při cenových špičkách prodávat z baterie
  • PV A škrtit jen když je to nutné
  • PV B nikdy neškrtit
  • BA81 řešit přes GEN cutoff