Files
ems/docs/04-modules/planning.md
Dusan Vojacek 8b4af663d8 Initial commit
Made-with: Cursor
2026-03-20 13:27:44 +01:00

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

Solver optimalizuje celý horizont (typicky 36h) 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

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Č)
  • Solver nikdy neexportuje výrobu pole B pokud je prodejní cena záporná

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

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 Nákup ze sítě v W
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

# Rezerva pro výpadek sítě  nikdy nesahat
soc_reserve_wh = battery.reserve_soc_percent / 100 * battery.usable_capacity_wh
soc[t] >= soc_reserve_wh  # za normálních podmínek

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

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é ---
    grid_import    = [pulp.LpVariable(f"gi_{t}", 0, grid.max_import_power_w)  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.reserve_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.';

Konfigurace (env proměnné)

PLANNING_HORIZON_HOURS=36
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
PV_B_GREEN_BONUS_CZK_KWH=1.20       # zelený bonus Kč/kWh (informativní, do účelové funkce přidat pokud chceš)

Zelený bonus v účelové funkci: Pokud chceš bonus explicitně zahrnout, přidat do objective function: - pv_b[t] * GREEN_BONUS_CZK_KWH * H / 1000 jako konstantní příjem (pole B vždy vyrábí). Protože je to konstanta, neovlivní optimalizaci ale správně zobrazí ekonomiku v auditu.


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 zahrnout do auditního výpočtu nákladů (ne jen do objective)
  • 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ů)