49 KiB
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, …); vizCLAUDE.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_otevrací konec posledního OTE intervalu. Rolling replan přiNULLpř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[T−1](Kč), kde faktor jeems.asset_battery.planner_terminal_soc_value_factorpřesems.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řibd[t]ige_bat[t](vybíjení do domu i do sítě). Bezge_batv 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): generujeems.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, buy−sell); jen sloty ssell ≥ buy − degradation. Kumulativní PV pokrývágrid_target(deficit SoC, nadreserve_socbez násobenícharge_slot_buffer). Zbytek →allow_charge=false(PV jen do sítě /bc ≤ pv_surplusv LP). - Grid ze sítě (vrstva B, před FVE): výchozí AM/PM 50/50 z
grid_target × charge_slot_buffer(dosoc_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 sbuy < 0→allow_grid_charge. Po výběru AM/PM běží iterativní self-konzistentní filtr (vyloučí drahé grid sloty, pokudpv_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í podleslot_ord(buy konstantní), jen pokud v horizontu existujesell > buy + degradation; jinak jen PV vrstva A. Cap slotů:ceil(budget/per_slot_wh) × charge_slot_buffer.charge_acquisition: váženýbuyuallow_grid_chargepřed 1. exportem; two-pass vplanning_engine.py. - PV vrstva A: při
sell ≥ 0jen pokudsell ≥ future_sell_opportunity − degradation(držet FVE na večerní peak). Přisell < 0vrstva 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×acquisitionv exportních slotech; bez cross-slot vynuceníge_pv ≥ surplus. Guard FVE:ge_pv=0jen pokudsell < charge_acquisition − degrad(nesell < buyve slotu). Vizplanning-arbitrage-accounting.md. - Load-first (Deye, AUTO): proměnné
pv_ld(PV → load+EV+TČ),pv_sp(přebytek),bc_pv/bc_gi. Plná bilancepv_a + pv_b + gi + bd = load + ev + hp + bc + ge;bc_pv + ge_pv ≤ pv_sp;gi ≤ load + bc_gi; mimoallow_discharge_export:bd ≤ load − pv_ldapv_ld ≥ load − gi − bd. Snapshot:load_first_enabled=true. TestLoadFirstDispatchTests. - 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ébdage_bataž 2× max discharge — vizSitePowerCapTests. - Hodnota FVE (PV store value):
ge_pv = 0, pokudsell < future_sell_opportunity − degradation(necharge_acquisition— u fixního KV1 by jinak blokoval export při sell 2 Kč). Před prvnímsell < 0v horizontu: přisell ≥ 0smíge_pvaž dopv_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. TestyHome01PvStoreValueTests,PreNegativeSellExportTests. - Drahý nákup → vlastní spotřeba z baterie: mimo
allow_chargeplatíbd + pv_ld ≥ load_baseline + hp[t]agi ≤ EV + hp[t](nehp_rated). Spot: drahý slot =buy > min(buy≥0) + degradace. Fixní nákup (DBpurchase_pricing_mode=fixednebo heuristika rozptylu buy < 0,25): navícbuy > 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 srelaxed_expensive_import(síť smí krmit baseload v drahých slotech; vsolver_params.inputs.relaxed_expensive_import=true). TestyAutoPassiveSelfConsumptionTests,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) nebopurchase_pricing_mode=fixed(BA81). Spot (home-01):ge_pv=0dokud není plná baterie; při plné jen ventil pole B (ge_pv ≤ pv_b,w_pv_b_vent_neg); výboj baterie přisell<0jen 12 slotů předbuy ≤ planner_extreme_buy_threshold(default −2), pokud spread do budoucna dává smysl — tag2026-05-26-neg-sell-bat-dump-extreme-buy-v11. Večerní discharge maska u spotu: denní peak ≥17:00 (nesell > ref_buyv slotu). - Pole B při sell<0 (home-01): pokud
block_export_on_negative_sell = false, LP nesmí vynutitge_pv = 0(přebytek neriťitelného PV B). KV1 sblock_export = truejen 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). Vizplanning-arbitrage-accounting.md.- Pokud
energy_to_fill <= 0nebocharge_slot_buffer = 0: všechny sloty povoleny.
- PV-surplus (vrstva A): ranking dle
- LP ekonomické guardy (
solve_dispatch, AUTO):ge_pv=0pokudsell < charge_acquisition − degradation(výjimka: plná baterie, přebytek pv_b). Pokudbuy > min(buy)+degradationmimo charge masku →gijen na load+EV+TČ. Vizplanning_engine.pypo 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:00–06:00 Europe/Prague), buffer % zasset_battery.planner_night_baseload_buffer_percent, lookaheadfuture_*_czk_kwh, volitelnýsafety_soc_target_wh(6–19) a flagis_daytime_pv_surplus_slot.\n+\n+ V solveru (planning_engine.solve_dispatch()):\n+ -safety_soc_target_whse používá primárně jako ochrana exportu z baterie: v běžných slotech (mimo high‑sell š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 vis_daytime_pv_surplus_slot(a ne v high‑sell špičce), aby solver neměl motivaci dělat obecné „nabij co nejdřív“ chování.\n+ Tvrdéallow_chargese kvůli tomu nemění. - Rolling charge commitment: při
run_rolling_replanse z aktivního plánu načtou sloty, kde dříve platilobattery_setpoint_w > 500,pv_a+pv_b > load_baseline,grid_setpoint_w ≤ 0a 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_kwhnaasset_battery. Implementace:_load_previous_plan_charge_commitment_prev_w, volitelný argumentcharge_commitment_prev_wusolve_dispatch(). - Debug snapshot: každý běh ukládá JSON do
ems.planning_run.solver_params(sekceversion,inputs,masks,soc_bounds,objective_terms,chosen_slots) přesfn_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=truese na exportní vrstvě vynutí PASSIVE/no-export chování (u nových plánů byis_predicted_pricev horizontu nemělo nastat).
- při
- 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 11–12 %; migrace V029 + komentář v DB, uhome-01cílený UPDATE z 10 %),reserve_soc_percent= ekonomická („arbitrážní“) podlaha – pod ní MILP sw_arbomezuje vybíjení podle začátku slotu a FVE lookahead (arb_floor_series; typicky 20 %),- Export ze site: binárka
z_export[t]– pokudgrid_export ≥ 1W, musí být koncovésoc[t] ≥ export_soc_floor_wh, kde:\n+ - při hluboké relaxaci (soc_panel_minpodmin_soc) jeexport_soc_floor_wh = soc_panel_min[t],\n+ - jinak jeexport_soc_floor_wh = arb_base_wh, a v běžných slotech se safety targetem navícmax(arb_base_wh, safety_soc_target_wh)(mimo high‑sell špičky).arb_floor_seriesse proz_exportnepouží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.
- objective používá
- SoC buffer:
- měkký cíl na konci 24h přes
_soc_security_profile+ tvrdé dvouúrovňové pravidlo výše.
- měkký cíl na konci 24h přes
- 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) searb_floor_wh[t]posouvá mezimin_soc_wha rezervou z DB – silné očekávané slunce ji sníží (ráno / po obloze); vynutit konstantní chování lzebattery.disable_dynamic_arb_floor=Truejen pro testy / ladění.- Výběr exportních slotů (
allow_discharge_export):ems.fn_load_planning_slots_full(R__063). Tři vrstvy:- Globální rozpočet Wh (
discharge_slot_buffer × exportovatelná kapacita): sloty podlesell_price desc. Před prvnímsell < 0se z rozpočtu vynechají sloty, kde později tentýž den existujesellvyšší o více neždegradation(OTE, ne pevné hodiny 00–04). - Večerní špičky per den:
sell ≥ max(sell) − degradationjen pro hodiny ≥ 17 (Prague), ne globální max horizontu (jinak by vyhrála půlnoc 3,7 Kč místo večera). - Ranní pásmo před prvním
sell < 0: hodiny 5–11 téhož kalendářního dne — všechny sloty ssell ≥ lokální_max_ráno − degradation; ostatní sloty mezi ranním pásmem a prvnímsell < 0s nižším sell mají export zakázán (žádný dump v 07:30 za 2 Kč).charge_acquisition: váženýbuypřed prvním exportem téhož dne jako záporné výkupní okno. Planner tag v21: po revertu v20 znovu drží SoC před prvnímbuy<0— bezneg_sell_soc_underfill/pv_charge_shortfalla bezbc_pvz FVE v rannímsell<0okně předbuy<0(MCP run 16692: v20 nabíjelo od 05:30 na 98 %, pak export v sell<0). Stále v17 + v18; noční výboj / import vbuy<0→R__063. Viz changelog v21. Vsolve_dispatch(AUTO):charge_slots=allow_chargez DB +buy < 0+ všechny slotysell < 0s PV přebytkem > 500 W (i bezblock_export_on_negative_sell, BA81).pv_charge_shortfall/NEG_SELL_CURTAIL_PENALTYplatí v těchto slotech. Přisell < 0: safety deficit cílísoc_max_wh(plný planner strop). Po poslednímsell < 0tentýž den:post_neg_pv_topupdobije z FVE nasoc_maxpřed exportem (kladný sell, ne high-sell peak). U fixního tarifu s polem B:ge_pv ≤ pv_b(ne pv_storege_pv = 0). Přideye_gen_microinverter_cutoff_enabled:ge == 0jen pokudblock_export_on_negative_sell(KV1), ne kvůli samotnémuz_gen_cutoff(BA81 musí moci exportovat B při plné baterii). Vstupnísoc_whz telemetrie se před MILP omezí přes_planner_soc_for_solver(rezerva ~650 Wh podsoc_max, jinak Infeasible při 100 % SoC a dlouhém záporném výkupu).planner_build_tagvsolver_params. Changelog:docs/planning-changelog.md.
- Globální rozpočet Wh (
- Záporná nákupní cena:
- horní mez
grid_importzahrnujeload_baseline_w+ nabíjení/EV/TČ (bez nekonečného importu).
- horní mez
- Záporná prodejní cena → tvrdý zákaz vývozu (
ge = 0) (planning_engine.solve_dispatch): platí ve slotu kdesell_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_wjako tvrdý site/inverter limit aplanning_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_wpro UI a audit. - Více FVE polí s různou orientací:
planning_engine._load_slotssčítá predikovaný výkon za 15min přes všechnaasset_pv_arraydané lokality —pv_a_forecast_w= součet řádků scontrollable = true,pv_b_forecast_w= součet scontrollable = 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_calibrationdrží persite_idmimo jinédelta_learn_min_ts(dolní mez řádků zforecast_accuracypro učení delty), volitelněpv_curtailment_policy_effective_froma přepsání parametrů (top_n_days,half_life_days, …; V076 navícreference_day_weight_multpro „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 vesite.timezonejako u časování slotů) při agregaci δ zforecast_accuracy(fn_pv_forecast_delta_profile); hromadný zápisems.fn_pv_forecast_sync_reference_days, detaildocs/04-modules/forecast.md.ems.fn_fill_forecast_accuracynastavujelearning_eligible/learning_exclude_reason(sloty před cutoffem, nebo se škrcením / gen cut-off / záznamem vems.cutoff_switch_logpo účinnosti policy se z učení vyřadí; u škrcení zůstáváactual_power_wNULL). Telemetrie:ems.telemetry_inverter.is_export_limitednebopv_derating_flags <> 0v okně 15min → stejné vyloučení (telemetry_derating).\n+\n+ Single source of truth pro solver i UI jeems.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_fullbere PV A/B z této kanonické funkce; UI je čte z/plan/current(bundle obsahujepv_*_forecast_solver_wipv_forecast_total_wjako 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]asell[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_kwhzfn_load_planning_slots_full(vážený grid+FVE předcharge_acquisition_cutoff_at); LP přičítáge_bat × acquisitionvallow_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=truepro nejvyššístore_score, dokud se nepokryjegrid_target. - Non-PV: levný
buy, lookahead 4 sloty, cap 6/segment; OTE před predikovanými. - Pokud
current_soc_whodpoví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
- Má 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 = 0při záporném výkupu (viz výšeblock_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). Sblock_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 (17–18 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 + bcpřesáhne breaker. - Pokud bychom
gi[t] ≤ max_import_power_wnechali jako tvrdé LP omezení, LP by v slotech s vyššíload_baseline_wzbytečně osekávalbcdolů (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ýgihorní strop umožní plánovat plné využití BMS v cenových oknech; reálný HW nikdy nepřetáhne jistič. - Trade-off:
expected_costv 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 zbattery_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 0–rated_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 11–12 %)
# 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 < 0tedy 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 bits0–1 („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_w≈pv_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_exportpolitika, pokud je v kontextu dostupná Mimo tyto případy jez_gen_cutoff[t]vynucené na0.
- 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.
- Efektivní výkon z GEN do bilance:
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[T−1] (viz solve_dispatch v backend/services/planning_engine.py).
Kde se bere faktor (jediný kanonický zdroj):
- Sloupec
ems.asset_battery.planner_terminal_soc_value_factor(NOT NULL, default 0.9 — migrace V062, idempotentní upevnění V069). - Hodnota se do solveru dostává výhradně přes
ems.fn_planning_site_context(site_id)→ polebattery.planner_terminal_soc_value_factorv JSONu. - Backend v
_load_site_context()mapuje JSON naSimpleNamespaceasolve_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_contextsloupec z tabulky nepropisoval dobatteryJSONu 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 repeatableR__039_fn_planning_site_context.sqli 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_factor0.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ř. 0–0.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á vfn_fill_audit_intervalpřesems.fn_green_bonus_revenue()a ukládá se doaudit_interval.green_bonus_czk; v přehledech (např.vw_audit_daily) je samostatná položka příjmů vedle nákladů ze sítě. Vizdocs/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 highspypřímý binding místoHiGHS_CMDshell 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í klesnoutreserve_soc_percentje tedy provozní kotva pro den, zatímcomin_soc_percentje tvrdé minimumreserve_soc_percentnení 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_percentse v ranní fázi nepoužívá jako cíl, ale jen jako spodní limit
Záporná nákupní cena
- při
buy_price < 0má 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 < 0je 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_wbattery_soc_target_pctgrid_setpoint_wexport_limit_wexport_modedeye_physical_modedeye_gen_cutoff_enabledpv_a_curtailed_wexpected_cost_czkeffective_buy_priceeffective_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-onlyplanning_runse stavemcomparison - 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_debugcomparison runu) - Výkon
/plan/currenta/plan/compare(V079+): read-modelems.fn_plan_current_bundledříve při každém HTTP requestu přepočítávalfn_pv_forecast_delta_profilenad celou historiíforecast_accuracy(~stovky tisíc řádků na site) a kanonický PV forecast na 96 h. Od V079 se delta profil cacheuje vsite_pv_forecast_calibration.delta_profile_cache(refresh pofn_fill_forecast_accuracya poPATCH …/pv-forecast-calibrationpřesfn_refresh_site_pv_delta_profile_cache; čtení přesfn_pv_forecast_delta_profile_cached, TTL 30 min). Kanonický PV pro graf se počítá jen za horizontem uloženého plánu (horizon_end→horizon_start + 96 h), ne pro sloty už vplanning_interval. Ověření:curl -w '%{time_total}\n' http://…/plan/currentpř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.tsxukazuje 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 zplanning_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. DashboardStatePanelv tooltipu Deye uvádíexport_modez 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