Initial commit
Made-with: Cursor
This commit is contained in:
423
docs/04-modules/planning.md
Normal file
423
docs/04-modules/planning.md
Normal file
@@ -0,0 +1,423 @@
|
||||
# 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**
|
||||
- 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Č)
|
||||
- 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 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ů)
|
||||
|
||||
```python
|
||||
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
|
||||
```python
|
||||
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
|
||||
```python
|
||||
# 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
|
||||
```python
|
||||
# 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
|
||||
```python
|
||||
# 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
|
||||
```python
|
||||
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
|
||||
```python
|
||||
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
|
||||
```python
|
||||
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
|
||||
```python
|
||||
# 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
|
||||
```python
|
||||
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í
|
||||
```python
|
||||
# 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é
|
||||
```python
|
||||
# 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
|
||||
```python
|
||||
# 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)
|
||||
|
||||
```python
|
||||
# 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:
|
||||
|
||||
```sql
|
||||
-- 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é)
|
||||
|
||||
```env
|
||||
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ů)
|
||||
Reference in New Issue
Block a user