Files
ems/docs/04-modules/planning.md
Dusan Vojacek 1e0300dd7e
Some checks failed
CI and deploy / migration-check (push) Failing after 11s
CI and deploy / deploy (push) Has been skipped
register 340 -omezovani vyrkonu pv pole (home-01)
2026-05-01 12:51:28 +02:00

30 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).
  • Masky allow_charge / allow_discharge_export (anti-mikrocyklování): generuje ems.fn_load_planning_slots_full. Důležité: pokud rolling replan startuje s baterií na 100 %, allow_charge se nesmí stát globálně false pro celý horizont jinak solver nemá motivaci baterii před PV špičkou „uvolnit“ (headroom), protože ji pak nesmí z PV znovu nabít. Aktuálně se v tomto případě allow_charge ponechá povolené alespoň pro sloty s pv_surplus_w > 0.
  • 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] ≥ arb_base_wh (fixní z DB, ne dynamicky snížená arb_floor_series),
    • 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 omezuje, ve kterých slotech smí solver vybíjet baterii „nad rámec spotřeby“ pro export do sítě (anti-mikrocyklování). Aktuálně se sloty pro exportní vybíjení vybírají globálně podle sell_price desc přes celé okno (ne 50/50 AM/PM), aby solver neodkládal vybíjení do levnějších ranních slotů, pokud jsou dražší sloty už večer.
  • 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).
  • 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.
  • Kalibrace PV forecastu (delta profil): 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, …). 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). ems.fn_pv_forecast_delta_profile vrací deltas_by_array i součtové deltas; ems.fn_load_planning_slots_full aplikuje stejnou per-pole korekci jako UI (fn_forecast_pv_slots_range_corrected); pokud v JSON profilu chybí deltas_by_array, použije se souhrnné deltas rozpuštěné podle podílu výkonu pole na slotu (solver má tak stále použitou korekci i bez per-pole JSON).

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

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;
  • Pokud current_soc_wh odpovídá plné baterii (soc_max_wh), měly by být allow_charge=true alespoň sloty s PV přebytkem (pv_surplus_w > 0).

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)
    + 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])),
            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 Deye (bez změny solveru): u hybridu s manufacturer = Deye a nenulovým součtem 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)

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

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ů)