Merge pull request 'refactor-control-monolith' (#4) from refactor-control-monolith into main
Some checks failed
CI and deploy / migration-check (push) Failing after 13s
CI and deploy / deploy (push) Has been skipped

Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
2026-05-04 19:07:17 +02:00
18 changed files with 1279 additions and 70 deletions

View File

@@ -0,0 +1,279 @@
---
name: planner-battery-tuning
overview: Opravíme nesoulad mezi plánem a zápisem do Deye při nabíjení z FVE přebytku, doplníme SQL-first vstupy pro denní safety charge, aplikujeme je v LP jako soft penalty a uložíme debug snapshot každého běhu planneru.
todos:
- id: fix-deye-passive-charge
content: Opravit Deye PASSIVE překlad tak, aby plánované nabíjení z FVE přebytku nezapsalo reg108=0.
status: completed
- id: add-planner-debug-snapshot
content: Ukládat ke každému planning_run kompaktní debug JSON do solver_params se sekcemi inputs, masks, soc_bounds, objective_terms a chosen_slots.
status: pending
- id: prevent-charge-deferral
content: Doplnit near-term commitment / soft target před drahým sell oknem, aby rolling replan neodkládal nabíjení bez ekonomické náhrady.
status: pending
- id: add-daytime-safety-charge
content: Spočítat safety-charge vstupy v SQL, předat je do LP a aplikovat jako měkkou penalizaci deficitu proti noční energii.
status: pending
- id: add-regression-test
content: Přidat regresní testy pro PV surplus charge + současný net export a pro neodkládání nabíjení při receding horizon.
status: completed
- id: tune-small-site-terminal-soc
content: Po debug ověření upravit parametry BA81/KV1 cíleně; nezačínat slepým přepsáním `planner_terminal_soc_value_factor` na 0.9.
status: cancelled
- id: update-docs
content: Aktualizovat dokumentaci control/planning a ověřovací MCP dotazy.
status: completed
- id: verify
content: Spustit testy/validaci a sepsat očekávané MCP ověření po deployi.
status: completed
isProject: false
---
# Stabilizace plánovače baterie
## Cíl
Opravit tři související problémy:
- Plán někdy chce nabíjet baterii z PV přebytku, ale Deye dostane `reg108 = 0`, takže fyzicky nenabíjí.
- Rolling replan umí posouvat plánované nabíjení dál a dál, až levné PV okno uteče.
- Malé baterie BA81/KV1 potřebují robustní denní nabití pro noc, ale zároveň nesmí ztratit schopnost ekonomicky cyklovat a prodávat v opravdu drahých sell oknech.
## Datové zjištění
- `BA81` = site `3`, `KV1` = site `4`, `home-01` = site `2`.
- KV1 run `8101` pro slot 17:15 plánoval `battery_setpoint_w = 4737` W, `grid_setpoint_w = -13` W, `deye_physical_mode = PASSIVE`; `modbus_command` následně zapsal a ověřil Deye `register = 108`, `value_to_write = 0`. To je konkrétní bug v control exportu.
- BA81 historie rolling runů ukazuje posun prvního charge slotu s časem. To je částečně normální receding-horizon efekt, ale nesmí prodat levný PV přebytek, který je potřeba pro pozdější sell peak nebo noční baseload.
- `planner_terminal_soc_value_factor` není jediné řešení. BA81/KV1 mají `0.2`, home-01 má `0.9`; nezvyšovat BA81/KV1 plošně na `0.9`, protože to může vrátit starou neochotu malé baterie cyklovat.
## Architektonické rozhodnutí
- SQL-first zůstává: výpočet vstupů pro planner patří do SQL funkcí / view.
- Safety charge nesmí být hard `allow_charge` maska. SQL má spočítat vstupní hodnoty, LP je použije jako soft penalty v objective.
- Debug snapshot ukládat do existujícího `ems.planning_run.solver_params`. Samostatnou tabulku nezavádět v první iteraci.
- Hodnota energie v baterii není jedna konstanta: `battery_value = max(future_avoided_buy, future_sell_opportunity) - degradation`, plus samostatný měkký noční buffer.
## Implementace
### 1. Oprava Deye exportéru
Soubory:
- [`backend/services/control/inverter.py`](backend/services/control/inverter.py)
- [`backend/services/control/setpoints.py`](backend/services/control/setpoints.py)
Požadované chování:
- Pokud `ControlSetpoints.battery_w > 0`, Deye musí dostat nenulový nabíjecí proud podle `battery_w`, i když `grid_setpoint_w < 0`.
- V tomto scénáři zůstává `deye_physical_mode = PASSIVE`, pokud plán explicitně neurčí `CHARGE`. Nejde o grid-charge režim; jde o nabíjení z PV přebytku a současný export zbytku.
- `discharge_a` v tomto scénáři nastavit na `0` nebo jinak omezit tak, aby Deye současně nevybíjel baterii.
- Existující SELL a PRESERVE chování neměnit.
Konkrétní místo:
- V `write_inverter_setpoints()` je problém v PASSIVE větvi, která přes `_deye_zero_export_amps_for_passive()` vrací `charge_a = 0`, když `grid_w < 0` a `bat_w >= 0`.
- Přidej před tuto větev explicitní případ `bat_w > 0`: `charge_a = battery_watts_to_amps(bat_w, inv.max_charge_a)`, `discharge_a = 0`.
### 2. SQL vstupy pro daytime safety charge
Soubory:
- [`db/routines/R__063_fn_load_planning_slots_full.sql`](db/routines/R__063_fn_load_planning_slots_full.sql)
- případně nová repeatable funkce v [`db/routines`](db/routines)
Neimplementovat jako hard masku. Nezakazovat / nepovolovat sloty natvrdo jen kvůli safety charge.
Doplnit SQL výstupy, které Python LP použije:
- `night_baseload_target_wh`: kolik Wh je potřeba od večera do dalšího ranního PV okna.
- `night_baseload_buffer_wh`: bezpečnostní přirážka, např. procento z cíle.
- `safety_soc_target_wh`: doporučený SoC cíl pro slot.
- `future_avoided_buy_czk_kwh`: odhad ceny, kterou baterie ušetří, pokud energii necháme pro vlastní spotřebu.
- `future_sell_opportunity_czk_kwh`: nejlepší relevantní budoucí sell příležitost v horizontu.
- `is_daytime_pv_surplus_slot`: pomocný boolean pro debug a vážení cíle.
Preferovaný způsob:
- Rozšířit `ems.fn_load_planning_slots_full(...)`, protože už je hlavní zdroj slotových vstupů pro `_load_slots()`.
- Pokud by rozšíření funkce bylo příliš velké, vytvořit samostatnou `ems.fn_planning_safety_charge_inputs(site_id, from, to, current_soc_wh)` a joinovat podle `interval_start` v SQL/Pythonu.
Výpočet nočního okna:
- Praktická první verze: noc = od lokálního západu / večerního konce PV surplus do dalšího rána, zjednodušeně `20:00-06:00 Europe/Prague`.
- Přesnější verze později: od posledního dnešního slotu s významným PV forecastem do prvního zítřejšího slotu s významným PV forecastem.
- Pro první implementaci stačí konzervativní a čitelná definice, hlavně ji uložit do debug snapshotu.
### 3. Rozšíření Python datových tříd a načítání slotů
Soubor:
- [`backend/services/planning_engine.py`](backend/services/planning_engine.py)
Upravit `PlanningSlot`:
- Přidat volitelná pole pro SQL safety vstupy:
- `safety_soc_target_wh: float | None`
- `night_baseload_target_wh: float | None`
- `night_baseload_buffer_wh: float | None`
- `future_avoided_buy_czk_kwh: float | None`
- `future_sell_opportunity_czk_kwh: float | None`
- `is_daytime_pv_surplus_slot: bool = False`
Upravit `_load_slots()`:
- Načíst nové sloupce ze SQL.
- Pokud SQL sloupce dočasně nejsou k dispozici, použít bezpečný fallback `None` / `False`, aby testy starších DB funkcí nespadly.
- Nepočítat noční baseload ad-hoc v Pythonu, pokud už SQL funkce hodnotu vrací.
### 4. LP objective: soft safety target
Soubor:
- [`backend/services/planning_engine.py`](backend/services/planning_engine.py)
Přidat do `solve_dispatch()`:
- Pro každý slot `t` s `safety_soc_target_wh is not None` vytvořit spojitou proměnnou `safety_deficit_wh[t] >= 0`.
- Přidat omezení:
- `safety_deficit_wh[t] >= safety_soc_target_wh[t] - soc[t]`
- Přidat do objective penalizaci:
- `safety_deficit_wh[t] * safety_penalty_czk_per_wh[t]`
Výpočet penalty:
- `battery_value_czk_kwh = max(future_avoided_buy_czk_kwh, future_sell_opportunity_czk_kwh) - degradation_cost_effective`
- `safety_penalty_czk_per_wh = max(0, battery_value_czk_kwh) / 1000`
- Přidat rozumný clamp, aby penalty nebyla extrémní kvůli vadné ceně.
Chování:
- Pokud je vysoký sell peak ekonomicky lepší než držet energii pro noc, LP smí target porušit a prodat.
- Pokud je budoucí nákup drahý, typicky KV1, deficit bude drahý a LP bude energii spíš držet pro vlastní spotřebu.
- Toto není hard constraint.
### 5. Near-term commitment proti deferralu
Soubory:
- [`backend/services/planning_engine.py`](backend/services/planning_engine.py)
- DB čtení z `ems.planning_run` / `ems.planning_interval` přes SQL funkci nebo jednoduchý read model
Cíl:
- Rolling replan nesmí bez náhrady odsunout nejbližší plánované nabíjení z PV přebytku, pokud předchozí aktivní plán pro stejný nebo nejbližší slot chtěl nabíjet.
První jednoduchá implementace:
- Při rolling replanu načíst předchozí aktivní plán pro stejné `site_id`.
- Najít nejbližší 1-2 sloty od `replan_from`, kde předchozí plán měl:
- `battery_setpoint_w > 500`
- `pv_a_forecast_solver_w + pv_b_forecast_solver_w > load_baseline_w`
- ideálně `grid_setpoint_w <= 0`
- V novém LP pro odpovídající slot přidat soft proměnnou `charge_commitment_shortfall_w[t] >= previous_battery_charge_w - bc[t]`.
- Penalizace má být malá, ale nenulová: má zabránit bezdůvodnému odsunu, ne přebít skutečně lepší ekonomiku.
- Uložit do debug snapshotu, kdy commitment vznikl a kolik stál.
Neimplementovat jako hard constraint.
### 6. Debug snapshot do solver_params
Soubory:
- [`backend/services/planning_engine.py`](backend/services/planning_engine.py)
- [`db/routines/R__037_fn_planning_run_commit.sql`](db/routines/R__037_fn_planning_run_commit.sql)
Upravit `_save_planning_run()`:
- Rozšířit `run_meta` o `solver_params`.
- `solver_params` bude JSON serializovatelný dict.
Upravit `ems.fn_planning_run_commit(...)`:
- Při insertu do `ems.planning_run` uložit `solver_params = p_run_meta->'solver_params'`.
Minimální struktura JSON:
```json
{
"version": 1,
"inputs": {
"current_soc_wh": 0,
"operating_mode": "AUTO",
"battery": {
"usable_capacity_wh": 0,
"min_soc_wh": 0,
"reserve_soc_wh": 0,
"degradation_cost_czk_kwh": 0,
"planner_terminal_soc_value_factor": 0.2
}
},
"masks": [
{
"slot": "2026-05-04T15:45:00+00:00",
"allow_charge": true,
"allow_discharge_export": false
}
],
"soc_bounds": [
{
"slot": "2026-05-04T15:45:00+00:00",
"soc_min_wh": 0,
"arb_floor_wh": 0,
"soc_panel_min_wh": 0,
"safety_soc_target_wh": 0
}
],
"objective_terms": [
{
"slot": "2026-05-04T15:45:00+00:00",
"buy_price": 0,
"sell_price": 0,
"future_avoided_buy_czk_kwh": 0,
"future_sell_opportunity_czk_kwh": 0,
"battery_value_czk_kwh": 0,
"safety_deficit_penalty_czk_per_wh": 0,
"commitment_penalty_czk_per_w": 0
}
],
"chosen_slots": {
"charge_commitment": [],
"high_sell_windows": [],
"night_window": {
"start": "2026-05-04T18:00:00+00:00",
"end": "2026-05-05T04:00:00+00:00",
"target_wh": 0
}
}
}
```
### 7. Debug read model
Soubor:
- nová repeatable funkce v [`db/routines`](db/routines), např. `R__086_fn_planning_run_debug.sql`
Vytvořit `ems.fn_planning_run_debug(p_run_id int)`:
- Vrátí jeden `jsonb`.
- Obsahuje:
- metadata z `planning_run`,
- `solver_params`,
- intervaly z `planning_interval` pro daný run,
- krátký souhrn: první charge slot, první battery export slot, nejdražší sell sloty, největší safety deficit.
Použití přes MCP:
```sql
select ems.fn_planning_run_debug(8107);
```
### 8. Parametry
Nepřepisovat plošně BA81/KV1 na `planner_terminal_soc_value_factor = 0.9`.
Nové parametry preferovaně v `ems.asset_battery` přes novou migraci:
- `planner_daytime_charge_target_enabled boolean default true`
- `planner_night_baseload_buffer_percent numeric default 20`
- `planner_daytime_charge_price_quantile numeric default 0.70`
- `planner_charge_commitment_penalty_czk_kwh numeric default 0.20`
Pokud je rozsah příliš velký, první iterace může mít konzervativní konstanty v Pythonu, ale plánovaná cílová podoba je DB parametrizace.
### 9. Testy
Najít existující testovací styl v repu a přidat testy co nejblíže dotčeným modulům.
Povinné scénáře:
- Control exporter: `battery_w > 0`, `grid_setpoint_w < 0`, `deye_physical_mode = PASSIVE` vede na `reg108 > 0`, `reg109 = 0`.
- Control exporter: SELL režim se nezmění.
- Planner safety: malá baterie, PV surplus přes den, noční baseload, pozdější drahý sell slot. LP má nabíjet v rozumně levném PV slotu a neodsunout charge donekonečna.
- Planner economics: pokud `sell_now` převyšuje budoucí avoided buy plus degradaci, LP smí porušit safety target a prodat.
- Planner economics KV1-like: pokud budoucí buy je drahý a sell není dost vysoký, LP má držet energii pro vlastní spotřebu.
### 10. Dokumentace
Aktualizovat:
- [`docs/04-modules/control.md`](docs/04-modules/control.md)
- [`docs/04-modules/planning.md`](docs/04-modules/planning.md)
Dokumentace musí popsat:
- rozdíl mezi plánem, Deye fyzickým režimem a registry `108/109`,
- PV-surplus charging při současném exportu,
- `solver_params` debug snapshot a `fn_planning_run_debug`,
- rozdíl mezi hard maskami (`allow_charge`, `allow_discharge_export`) a soft LP penalizacemi,
- že `planner_terminal_soc_value_factor` není jediný mechanismus ochrany malé baterie.
## Ověření
- Spustit backend testy pro control a planner.
- Spustit Flyway validate lokálně.
- Přes MCP ověřit po nasazení:
- pro BA81/KV1 sloty s `battery_setpoint_w > 0` a `grid_setpoint_w < 0` má následný `modbus_command.register = 108` hodnotu > 0,
- `planning_run.solver_params` není `NULL` a obsahuje `inputs`, `masks`, `soc_bounds`, `objective_terms`, `chosen_slots`,
- `select ems.fn_planning_run_debug(<run_id>)` vrací vysvětlitelný JSON,
- rolling replan neodkládá nabíjení z levného PV přebytku bez viditelného ekonomického důvodu v debug snapshotu.

View File

@@ -69,7 +69,6 @@ from services.control.setpoints import (
_deye_tou_min_soc_pct,
_deye_tou_params,
_deye_tou_reserve_soc_pct,
_deye_zero_export_amps_for_passive,
get_deye_mode,
)
from services.control.verify import (

View File

@@ -25,7 +25,6 @@ from services.control.deye_helpers import (
_DEYE_INACTIVE_TOU_REGISTERS,
_deye_should_skip_time_sync_after_read,
_prague_minute_start_utc,
battery_watts_to_amps,
current_slot_hhmm,
next_slot_hhmm,
)
@@ -44,7 +43,7 @@ from services.control.setpoints import (
_deye_tou_min_soc_pct,
_deye_tou_params,
_deye_tou_reserve_soc_pct,
_deye_zero_export_amps_for_passive,
deye_battery_charge_discharge_amps,
get_deye_mode,
)
from services.modbus_client import get_modbus_client
@@ -78,24 +77,14 @@ async def write_inverter_setpoints(
deye_mode = get_deye_mode(setpoints_now)
bat_w = int(raw_bat) if raw_bat is not None else 0
if setpoints_now.lock_battery:
charge_a = 0
discharge_a = 0
elif deye_mode == "CHARGE":
charge_a = battery_watts_to_amps(bat_w, inv.max_charge_a)
discharge_a = 0
elif deye_mode == "SELL":
charge_a = 0
discharge_a = int(inv.max_discharge_a)
elif setpoints_now.self_sustain_local_use:
charge_a = int(inv.max_charge_a)
discharge_a = int(inv.max_discharge_a)
else:
charge_a, discharge_a = _deye_zero_export_amps_for_passive(
grid_w,
bat_w,
int(inv.max_charge_a),
int(inv.max_discharge_a),
charge_a, discharge_a = deye_battery_charge_discharge_amps(
lock_battery=setpoints_now.lock_battery,
deye_mode=deye_mode,
self_sustain_local_use=setpoints_now.self_sustain_local_use,
bat_w=bat_w,
grid_w=grid_w,
max_charge_a=int(inv.max_charge_a),
max_discharge_a=int(inv.max_discharge_a),
)
zero_exp_mode = int(inv.deye_zero_export_mode or 1)

View File

@@ -255,6 +255,36 @@ def _deye_zero_export_amps_for_passive(
return max_charge_a, max_discharge_a
def deye_battery_charge_discharge_amps(
*,
lock_battery: bool,
deye_mode: str,
self_sustain_local_use: bool,
bat_w: int,
grid_w: int,
max_charge_a: int,
max_discharge_a: int,
) -> tuple[int, int]:
"""
Proud nabíjení / vybíjení (reg 108 / 109) pro zápis Deye.
PASSIVE + plán chce nabíjet z PV přebytku i při exportu do sítě: nenulový charge, discharge 0.
"""
if lock_battery:
return 0, 0
if deye_mode == "CHARGE":
return battery_watts_to_amps(bat_w, max_charge_a), 0
if deye_mode == "SELL":
return 0, int(max_discharge_a)
if self_sustain_local_use:
return int(max_charge_a), int(max_discharge_a)
if bat_w > 0:
return battery_watts_to_amps(bat_w, max_charge_a), 0
return _deye_zero_export_amps_for_passive(
grid_w, bat_w, int(max_charge_a), int(max_discharge_a)
)
def get_deye_mode(setpoints: ControlSetpoints) -> str:
"""Fyzický režim Deye: SELL | CHARGE | PASSIVE."""
pm = (setpoints.deye_physical_mode or "").strip().upper()

View File

@@ -14,7 +14,7 @@ import time
from dataclasses import dataclass, replace
from datetime import datetime, timezone, timedelta
from types import SimpleNamespace
from typing import Optional
from typing import Any, Optional
from zoneinfo import ZoneInfo
import pulp
@@ -159,6 +159,13 @@ def _soc_security_profile(slots: list["PlanningSlot"], battery) -> tuple[float,
return target_wh, penalty_czk_kwh
def _slot_float_nullable(d: dict[str, Any], key: str) -> float | None:
v = d.get(key)
if v is None:
return None
return float(v)
def _prague_dow_hour(interval_start: datetime) -> tuple[int, int]:
"""DOW v konvenci PostgreSQL EXTRACT(DOW, Europe/Prague): 0=Ne … 6=So."""
dt = interval_start
@@ -185,6 +192,13 @@ class PlanningSlot:
is_predicted_price: bool = False
allow_charge: bool = True
allow_discharge_export: bool = True
#: Měkké LP vstupy z `ems.fn_load_planning_slots_full` (mimo masky allow_*).
night_baseload_target_wh: float | None = None
night_baseload_buffer_wh: float | None = None
safety_soc_target_wh: float | None = None
future_avoided_buy_czk_kwh: float | None = None
future_sell_opportunity_czk_kwh: float | None = None
is_daytime_pv_surplus_slot: bool = False
# Lookahead pro relax spodní meze SoC: až 36 h od indexu slotu (pevné OTE ceny v horizontu).
@@ -438,10 +452,11 @@ def solve_dispatch(
*,
tuv_delta_stats: Optional[dict[tuple[int, int], float]] = None,
operating_mode: str = "AUTO",
) -> tuple[list[DispatchResult], int]:
charge_commitment_prev_w: Optional[list[Optional[float]]] = None,
) -> tuple[list[DispatchResult], int, dict[str, Any]]:
"""
LP solver pro dispatch optimalizaci.
Vrátí (výsledky, solver_duration_ms).
Vrátí (výsledky, solver_duration_ms, solver_debug_snapshot).
"""
T = len(slots)
if T < 1:
@@ -603,6 +618,33 @@ def solve_dispatch(
t_anchor = first_neg_sell_idx - 1
soc_anchor_slack = pulp.LpVariable("soc_anchor_slack_wh", 0, float(battery.usable_capacity_wh))
daytime_en = bool(getattr(battery, "planner_daytime_charge_target_enabled", True))
safety_pen_czk_per_wh: list[float] = []
safety_vars: list[Optional[pulp.LpVariable]] = []
for t in range(T):
sft = slots[t].safety_soc_target_wh if daytime_en else None
fb = float(slots[t].future_avoided_buy_czk_kwh or slots[t].buy_price)
fs = float(slots[t].future_sell_opportunity_czk_kwh or slots[t].sell_price)
bv = max(fb, fs) - float(degradation_cost_effective)
bv = max(0.0, min(5.0, bv))
safety_pen_czk_per_wh.append(bv / 1000.0 if sft is not None else 0.0)
if sft is not None:
safety_vars.append(
pulp.LpVariable(f"safety_def_{t}", 0, float(battery.usable_capacity_wh))
)
else:
safety_vars.append(None)
commit_pen = float(getattr(battery, "planner_charge_commitment_penalty_czk_kwh", 0.2))
commit_lp: list[tuple[int, pulp.LpVariable, float]] = []
if charge_commitment_prev_w is not None and len(charge_commitment_prev_w) == T:
for t in range(T):
prev = charge_commitment_prev_w[t]
if prev is not None and prev > 500:
cap_prev = float(prev)
cv = pulp.LpVariable(f"ccommit_{t}", 0, cap_prev)
commit_lp.append((t, cv, cap_prev))
# --- Účelová funkce (jen OTE sloty; terminal SoC shadow price na konci horizontu) ---
prob += (
pulp.lpSum(
@@ -644,6 +686,12 @@ def solve_dispatch(
if soc_anchor_slack is not None
else 0
)
+ pulp.lpSum(
safety_vars[t] * safety_pen_czk_per_wh[t]
for t in range(T)
if safety_vars[t] is not None
)
+ pulp.lpSum(cv * INTERVAL_H / 1000.0 * commit_pen for _t, cv, _p in commit_lp)
)
# --- Omezení ---
@@ -680,6 +728,11 @@ def solve_dispatch(
- bd[t] / battery.discharge_efficiency * INTERVAL_H
)
sv = safety_vars[t]
tgt_s = slots[t].safety_soc_target_wh if daytime_en else None
if sv is not None and tgt_s is not None:
prob += sv >= float(tgt_s) - soc[t]
# ev_via_bat kryto z discharge
prob += pulp.lpSum(ev_via_bat[e][t] for e in range(EV)) <= bd[t]
@@ -762,6 +815,9 @@ def solve_dispatch(
else:
prob += ev_direct[e][t] + ev_via_bat[e][t] <= vehicles[e].max_charge_power_w
for tt, cv, prev in commit_lp:
prob += cv >= prev - bc[tt]
if om == "SELF_SUSTAIN":
for t in range(T):
prob += gi[t] <= slots[t].load_baseline_w
@@ -899,7 +955,91 @@ def solve_dispatch(
is_predicted_price = bool(slots[t].is_predicted_price),
))
return results, duration_ms
sell_rank = sorted(range(T), key=lambda i: float(slots[i].sell_price), reverse=True)[: min(3, T)]
charge_commit_snapshot = [
{
"slot": slots[tt].interval_start.isoformat(),
"previous_charge_w": prev,
"shortfall_w": float(pulp.value(cv) or 0.0),
}
for tt, cv, prev in commit_lp
]
masks_snap: list[dict[str, Any]] = []
soc_bounds_snap: list[dict[str, Any]] = []
objective_terms_snap: list[dict[str, Any]] = []
for t in range(T):
st = slots[t]
masks_snap.append(
{
"slot": st.interval_start.isoformat(),
"allow_charge": bool(st.allow_charge),
"allow_discharge_export": bool(st.allow_discharge_export),
}
)
tgt_s = st.safety_soc_target_wh if daytime_en else None
soc_bounds_snap.append(
{
"slot": st.interval_start.isoformat(),
"soc_min_wh": float(soc_panel_min[t]),
"arb_floor_wh": float(arb_floor_series[t]),
"soc_panel_min_wh": float(soc_panel_min[t]),
"safety_soc_target_wh": float(tgt_s) if tgt_s is not None else None,
}
)
fb = float(st.future_avoided_buy_czk_kwh or st.buy_price)
fs = float(st.future_sell_opportunity_czk_kwh or st.sell_price)
bv = max(fb, fs) - float(degradation_cost_effective)
bv = max(0.0, min(5.0, bv))
pen_wh = bv / 1000.0 if tgt_s is not None else 0.0
sv = safety_vars[t]
sdv = float(pulp.value(sv) or 0.0) if sv is not None else None
cshort = next((float(pulp.value(cv) or 0.0) for tt, cv, _p in commit_lp if tt == t), None)
objective_terms_snap.append(
{
"slot": st.interval_start.isoformat(),
"buy_price": float(st.buy_price),
"sell_price": float(st.sell_price),
"future_avoided_buy_czk_kwh": float(st.future_avoided_buy_czk_kwh or st.buy_price),
"future_sell_opportunity_czk_kwh": float(
st.future_sell_opportunity_czk_kwh or st.sell_price
),
"battery_value_czk_kwh": float(bv),
"safety_deficit_penalty_czk_per_wh": float(pen_wh),
"safety_deficit_wh": sdv,
"commitment_shortfall_w": cshort,
"commitment_penalty_czk_kwh": float(commit_pen) if cshort is not None else None,
}
)
night0 = slots[0]
solver_snapshot: dict[str, Any] = {
"version": 1,
"inputs": {
"current_soc_wh": float(current_soc_wh),
"operating_mode": operating_mode,
"battery": {
"usable_capacity_wh": float(battery.usable_capacity_wh),
"min_soc_wh": float(battery.min_soc_wh),
"reserve_soc_wh": float(getattr(battery, "reserve_soc_wh", 0.0)),
"degradation_cost_czk_kwh": float(battery.degradation_cost_czk_kwh),
"planner_terminal_soc_value_factor": float(battery.planner_terminal_soc_value_factor),
"planner_daytime_charge_target_enabled": daytime_en,
"planner_charge_commitment_penalty_czk_kwh": float(commit_pen),
},
},
"masks": masks_snap,
"soc_bounds": soc_bounds_snap,
"objective_terms": objective_terms_snap,
"chosen_slots": {
"charge_commitment": charge_commit_snapshot,
"high_sell_windows": [slots[i].interval_start.isoformat() for i in sell_rank],
"night_window": {
"definition": "Europe/Prague 20:0006:00 projected baseload Wh (fn_load_planning_slots_full)",
"target_wh": night0.night_baseload_target_wh,
"buffer_wh": night0.night_baseload_buffer_wh,
},
},
}
return results, duration_ms, solver_snapshot
# ============================================================
@@ -930,7 +1070,7 @@ async def run_daily_plan(site_id: int, db, triggered_by: str = "scheduler:daily"
)
slots = await _load_slots(site_id, horizon_from, horizon_to, db, soc_wh=soc_wh)
results, duration_ms = solve_dispatch(
results, duration_ms, solver_snapshot = solve_dispatch(
slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp,
tuv_delta_stats=tuv_stats,
operating_mode=operating_mode or "AUTO",
@@ -950,6 +1090,7 @@ async def run_daily_plan(site_id: int, db, triggered_by: str = "scheduler:daily"
correction=1.0,
db=db,
slot_inputs=slot_inputs,
solver_snapshot=solver_snapshot,
)
logger.info(f"[site={site_id}] Daily plan done in {duration_ms} ms")
return run_id, duration_ms
@@ -1023,10 +1164,13 @@ async def run_rolling_replan(
slots = apply_forecast_correction(slots, now, correction_factor)
results, duration_ms = solve_dispatch(
commitment_prev = await _load_previous_plan_charge_commitment_prev_w(site_id, slots, db)
results, duration_ms, solver_snapshot = solve_dispatch(
slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp,
tuv_delta_stats=tuv_stats,
operating_mode=operating_mode or "AUTO",
charge_commitment_prev_w=commitment_prev,
)
slot_inputs = _build_slot_inputs(slots_before_pv_correction, slots)
@@ -1043,6 +1187,7 @@ async def run_rolling_replan(
correction=correction_factor,
db=db,
slot_inputs=slot_inputs,
solver_snapshot=solver_snapshot,
)
await db.execute(
@@ -1165,6 +1310,18 @@ async def _load_site_context(site_id: int, db):
if relax_prewin is not None
else DEFAULT_PLANNER_DISCHARGE_RELAX_PREWINDOW_SLOTS,
planner_terminal_soc_value_factor=float(b["planner_terminal_soc_value_factor"]),
planner_daytime_charge_target_enabled=bool(
b.get("planner_daytime_charge_target_enabled", True)
),
planner_night_baseload_buffer_percent=float(
b.get("planner_night_baseload_buffer_percent") or 20.0
),
planner_daytime_charge_price_quantile=float(
b.get("planner_daytime_charge_price_quantile") or 0.70
),
planner_charge_commitment_penalty_czk_kwh=float(
b.get("planner_charge_commitment_penalty_czk_kwh") or 0.20
),
)
hpj = ctx["heat_pump"]
@@ -1227,6 +1384,51 @@ async def _load_site_context(site_id: int, db):
)
async def _load_previous_plan_charge_commitment_prev_w(
site_id: int,
slots: list[PlanningSlot],
db,
) -> list[Optional[float]]:
"""
Pro rolling replan: z aktivního plánu načte battery_setpoint_w pro shodné sloty.
Kotva měkkého commitmentu jen když předchozí plán chtěl nabíjet z PV přebytku (viz podmínky).
"""
if not slots:
return []
rows = await db.fetch(
"""
select pi.interval_start,
pi.battery_setpoint_w,
pi.grid_setpoint_w,
coalesce(pi.pv_a_forecast_solver_w, 0) as pva,
coalesce(pi.pv_b_forecast_solver_w, 0) as pvb,
coalesce(pi.load_baseline_w, 0) as lb
from ems.planning_interval pi
inner join ems.planning_run pr on pr.id = pi.run_id
where pr.site_id = $1::int
and pr.status = 'active'
""",
site_id,
)
by_start = {r["interval_start"]: r for r in rows}
out: list[Optional[float]] = []
for s in slots:
r = by_start.get(s.interval_start)
if r is None:
out.append(None)
continue
bw = int(r["battery_setpoint_w"] or 0)
gw = int(r["grid_setpoint_w"] or 0)
pva = int(r["pva"] or 0)
pvb = int(r["pvb"] or 0)
lb = int(r["lb"] or 0)
if bw > 500 and (pva + pvb) > lb and gw <= 0:
out.append(float(bw))
else:
out.append(None)
return out
async def _load_slots(
site_id: int,
from_dt: datetime,
@@ -1240,7 +1442,10 @@ async def _load_slots(
"""
select slot_ord, interval_start, buy_price, sell_price, is_predicted_price,
pv_a_forecast_w, pv_b_forecast_w, load_baseline_w,
ev1_connected, ev2_connected, allow_charge, allow_discharge_export
ev1_connected, ev2_connected, allow_charge, allow_discharge_export,
night_baseload_target_wh, night_baseload_buffer_wh, safety_soc_target_wh,
future_avoided_buy_czk_kwh, future_sell_opportunity_czk_kwh,
is_daytime_pv_surplus_slot
from ems.fn_load_planning_slots_full(
$1::int, $2::timestamptz, $3::timestamptz, $4::numeric
)
@@ -1266,6 +1471,14 @@ async def _load_slots(
is_predicted_price=bool(d.get("is_predicted_price")),
allow_charge=bool(d.get("allow_charge", True)),
allow_discharge_export=bool(d.get("allow_discharge_export", True)),
night_baseload_target_wh=_slot_float_nullable(d, "night_baseload_target_wh"),
night_baseload_buffer_wh=_slot_float_nullable(d, "night_baseload_buffer_wh"),
safety_soc_target_wh=_slot_float_nullable(d, "safety_soc_target_wh"),
future_avoided_buy_czk_kwh=_slot_float_nullable(d, "future_avoided_buy_czk_kwh"),
future_sell_opportunity_czk_kwh=_slot_float_nullable(
d, "future_sell_opportunity_czk_kwh"
),
is_daytime_pv_surplus_slot=bool(d.get("is_daytime_pv_surplus_slot", False)),
)
)
if not out:
@@ -1306,11 +1519,13 @@ async def _save_planning_run(
run_type, triggered_by, replan_from,
soc_wh, duration_ms, correction, db,
slot_inputs: Optional[list[tuple[int, int, int, int, int]]] = None,
*,
solver_snapshot: Optional[dict[str, Any]] = None,
) -> int:
"""Uloží výsledky solveru přes ems.fn_planning_run_commit."""
if slot_inputs is not None and len(slot_inputs) != len(results):
raise ValueError("slot_inputs and results length mismatch")
run_meta = {
run_meta: dict[str, Any] = {
"run_type": run_type,
"triggered_by": triggered_by,
"replan_from": replan_from.isoformat() if replan_from else None,
@@ -1318,6 +1533,8 @@ async def _save_planning_run(
"solver_duration_ms": duration_ms,
"forecast_correction_factor": correction,
}
if solver_snapshot is not None:
run_meta["solver_params"] = solver_snapshot
intervals: list[dict] = []
for i, r in enumerate(results):
row: dict = {

View File

@@ -0,0 +1,52 @@
"""PASSIVE + nabíjení z PV přebytku při současném exportu → nenulový nabíjecí proud."""
from __future__ import annotations
import unittest
from services.control.setpoints import deye_battery_charge_discharge_amps
class PassivePvSurplusChargeAmpsTests(unittest.TestCase):
def test_passive_charge_while_exporting_grid_negative(self) -> None:
ch, dis = deye_battery_charge_discharge_amps(
lock_battery=False,
deye_mode="PASSIVE",
self_sustain_local_use=False,
bat_w=5000,
grid_w=-2000,
max_charge_a=100,
max_discharge_a=100,
)
self.assertGreater(ch, 0)
self.assertEqual(dis, 0)
def test_passive_zero_export_still_zero_charge_when_no_battery_charge(self) -> None:
ch, dis = deye_battery_charge_discharge_amps(
lock_battery=False,
deye_mode="PASSIVE",
self_sustain_local_use=False,
bat_w=0,
grid_w=-2000,
max_charge_a=100,
max_discharge_a=100,
)
self.assertEqual(ch, 0)
self.assertEqual(dis, 100)
def test_sell_unchanged(self) -> None:
ch, dis = deye_battery_charge_discharge_amps(
lock_battery=False,
deye_mode="SELL",
self_sustain_local_use=False,
bat_w=-3000,
grid_w=-2000,
max_charge_a=100,
max_discharge_a=80,
)
self.assertEqual(ch, 0)
self.assertEqual(dis, 80)
if __name__ == "__main__":
unittest.main()

View File

@@ -237,7 +237,7 @@ class PlanningDispatchMilpTests(unittest.TestCase):
),
]
soc0 = 0.50 * battery.usable_capacity_wh
results, _ms = solve_dispatch(
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
@@ -278,7 +278,7 @@ class PlanningDispatchMilpTests(unittest.TestCase):
),
]
soc0 = battery.soc_max_wh
results, _ms = solve_dispatch(
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
@@ -317,7 +317,7 @@ class PlanningDispatchMilpTests(unittest.TestCase):
),
]
soc0 = 0.15 * battery.usable_capacity_wh
results, ms = solve_dispatch(
results, ms, _ = solve_dispatch(
slots,
battery,
hp,
@@ -357,7 +357,7 @@ class PlanningDispatchMilpTests(unittest.TestCase):
),
]
soc0 = 0.12 * battery.usable_capacity_wh
results, _ms = solve_dispatch(
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
@@ -393,7 +393,7 @@ class PlanningDispatchMilpTests(unittest.TestCase):
),
]
soc0 = 0.5 * battery.usable_capacity_wh
results, _ms = solve_dispatch(
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
@@ -433,7 +433,7 @@ class PlanningDispatchMilpTests(unittest.TestCase):
),
]
soc0 = 0.22 * battery.usable_capacity_wh
results, _ms = solve_dispatch(
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
@@ -511,7 +511,7 @@ class PlanningDispatchMilpTests(unittest.TestCase):
),
]
soc0 = 0.88 * battery.usable_capacity_wh
results, _ms = solve_dispatch(
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
@@ -593,7 +593,7 @@ class PlanningDispatchMilpTests(unittest.TestCase):
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
soc0 = 0.9 * battery.usable_capacity_wh
results, _ms = solve_dispatch(
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
@@ -680,7 +680,7 @@ class PlanningDispatchMilpTests(unittest.TestCase):
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
soc0 = 0.9 * battery.usable_capacity_wh
results, _ms = solve_dispatch(
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
@@ -755,7 +755,7 @@ class PlanningDispatchMilpTests(unittest.TestCase):
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
soc0 = 0.9 * battery.usable_capacity_wh
results, _ms = solve_dispatch(
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
@@ -798,7 +798,7 @@ class PlanningDispatchMilpTests(unittest.TestCase):
),
]
soc0 = 0.55 * battery.usable_capacity_wh
results, _ms = solve_dispatch(
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
@@ -853,7 +853,7 @@ class PlanningDispatchMilpTests(unittest.TestCase):
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
soc0 = 0.55 * battery.usable_capacity_wh
results, _ms = solve_dispatch(
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
@@ -913,7 +913,7 @@ class PlanningDispatchMilpTests(unittest.TestCase):
),
]
soc0 = 0.34 * battery.usable_capacity_wh
results, _ms = solve_dispatch(
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
@@ -983,7 +983,7 @@ class TerminalSocShadowTests(unittest.TestCase):
),
]
soc0 = 0.5 * battery.usable_capacity_wh
results, _ms = solve_dispatch(
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,

View File

@@ -0,0 +1,140 @@
"""Měkké safety SoC a rolling charge commitment v solve_dispatch."""
from __future__ import annotations
import unittest
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from services.planning_engine import PlanningSlot, solve_dispatch
def _bat(**kwargs: object) -> SimpleNamespace:
base = dict(
usable_capacity_wh=20_000.0,
min_soc_wh=2000.0,
arb_floor_wh=4000.0,
reserve_soc_wh=4000.0,
soc_max_wh=19_000.0,
charge_efficiency=0.95,
discharge_efficiency=0.95,
degradation_cost_czk_kwh=0.1,
max_charge_power_w=5000,
max_discharge_power_w=5000,
planner_terminal_soc_value_factor=0.2,
planner_extreme_buy_threshold_czk_kwh=-5.0,
planner_discharge_floor_percent=None,
planner_discharge_relax_prewindow_slots=8,
planner_daytime_charge_target_enabled=True,
planner_charge_commitment_penalty_czk_kwh=0.5,
)
base.update(kwargs)
return SimpleNamespace(**base)
def _grid() -> SimpleNamespace:
return SimpleNamespace(
max_import_power_w=11_000,
max_export_power_w=11_000,
block_export_on_negative_sell=False,
deye_gen_microinverter_cutoff_enabled=False,
)
def _hp() -> SimpleNamespace:
return SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
def _slot(
t0: datetime,
idx: int,
*,
buy: float = 3.0,
sell: float = 2.5,
pv_a: int = 0,
load: int = 1500,
safety: float | None = None,
fut_buy: float | None = None,
fut_sell: float | None = None,
) -> PlanningSlot:
return PlanningSlot(
interval_start=t0 + timedelta(minutes=15 * idx),
buy_price=buy,
sell_price=sell,
pv_a_forecast_w=pv_a,
pv_b_forecast_w=0,
load_baseline_w=load,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=True,
safety_soc_target_wh=safety,
future_avoided_buy_czk_kwh=fut_buy,
future_sell_opportunity_czk_kwh=fut_sell,
)
class PlanningSafetyCommitmentTests(unittest.TestCase):
def test_solver_snapshot_has_version_and_masks(self) -> None:
t0 = datetime(2026, 5, 4, 8, 0, tzinfo=timezone.utc)
slots = [_slot(t0, i, buy=2.0, sell=2.0, pv_a=6000, load=1500) for i in range(8)]
hp, grid = _hp(), _grid()
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0)
] * 2
res, _ms, snap = solve_dispatch(
slots,
_bat(),
hp,
grid,
[None, None],
vehicles,
current_soc_wh=5000.0,
current_tuv_temp_c=50.0,
operating_mode="AUTO",
)
self.assertEqual(len(res), 8)
self.assertEqual(snap.get("version"), 1)
self.assertIn("masks", snap)
self.assertEqual(len(snap["masks"]), 8)
def test_charge_commitment_snapshot_populated(self) -> None:
"""Rolling kotva: při předchozím nabíjení z PV se do snapshotu zapíše commitment."""
t0 = datetime(2026, 5, 4, 10, 0, tzinfo=timezone.utc)
slots = [_slot(t0, i, buy=1.5, sell=1.2, pv_a=8000, load=1000) for i in range(12)]
hp, grid = _hp(), _grid()
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0)
] * 2
prev = [None] * 12
prev[0] = 4000.0
_res1, _, snap1 = solve_dispatch(
slots,
_bat(),
hp,
grid,
[None, None],
vehicles,
current_soc_wh=4000.0,
current_tuv_temp_c=50.0,
operating_mode="AUTO",
charge_commitment_prev_w=prev,
)
self.assertTrue(snap1["chosen_slots"]["charge_commitment"])
_res2, _, snap2 = solve_dispatch(
slots,
_bat(),
hp,
grid,
[None, None],
vehicles,
current_soc_wh=4000.0,
current_tuv_temp_c=50.0,
operating_mode="AUTO",
charge_commitment_prev_w=None,
)
self.assertEqual(snap2["chosen_slots"]["charge_commitment"], [])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,25 @@
-- Parametry pro denní „safety charge“ (měkké LP penalizace) a kotvu rolling replanu.
alter table ems.asset_battery
add column if not exists planner_daytime_charge_target_enabled boolean not null default true;
alter table ems.asset_battery
add column if not exists planner_night_baseload_buffer_percent numeric not null default 20;
alter table ems.asset_battery
add column if not exists planner_daytime_charge_price_quantile numeric not null default 0.70;
alter table ems.asset_battery
add column if not exists planner_charge_commitment_penalty_czk_kwh numeric not null default 0.20;
comment on column ems.asset_battery.planner_daytime_charge_target_enabled is
'Zapíná SQL/LP měkké denní cíle SoC (safety) z fn_load_planning_slots_full; ne tvrdé allow_charge masky.';
comment on column ems.asset_battery.planner_night_baseload_buffer_percent is
'Procentní přirážka k odhadu nočního baseload Wh (20 = +20 % k night_baseload_target_wh).';
comment on column ems.asset_battery.planner_daytime_charge_price_quantile is
'Rezervováno pro budoucí výběr „drahých“ oken z cenové distribuce; v1 se v LP nepoužívá.';
comment on column ems.asset_battery.planner_charge_commitment_penalty_czk_kwh is
'Koeficient měkké penalizace (Kč/kWh krátkého nedodržení) proti předchozímu plánu při rolling replanu.';

View File

@@ -23,7 +23,8 @@ begin
insert into ems.planning_run (
site_id, horizon_start, horizon_end, status,
run_type, triggered_by, replan_from,
soc_at_replan_wh, solver_duration_ms, forecast_correction_factor
soc_at_replan_wh, solver_duration_ms, forecast_correction_factor,
solver_params
) values (
p_site_id,
p_horizon_start,
@@ -39,7 +40,12 @@ begin
end,
(p_run_meta->>'soc_at_replan_wh')::numeric,
(p_run_meta->>'solver_duration_ms')::int,
(p_run_meta->>'forecast_correction_factor')::numeric
(p_run_meta->>'forecast_correction_factor')::numeric,
case
when p_run_meta ? 'solver_params' and jsonb_typeof(p_run_meta->'solver_params') = 'object'
then p_run_meta->'solver_params'
else null::jsonb
end
)
returning id into v_run_id;

View File

@@ -67,7 +67,11 @@ begin
)::int,
'charge_slot_buffer', ab.charge_slot_buffer,
'discharge_slot_buffer', ab.discharge_slot_buffer,
'planner_terminal_soc_value_factor', ab.planner_terminal_soc_value_factor
'planner_terminal_soc_value_factor', ab.planner_terminal_soc_value_factor,
'planner_daytime_charge_target_enabled', coalesce(ab.planner_daytime_charge_target_enabled, true),
'planner_night_baseload_buffer_percent', coalesce(ab.planner_night_baseload_buffer_percent, 20::numeric),
'planner_daytime_charge_price_quantile', coalesce(ab.planner_daytime_charge_price_quantile, 0.70::numeric),
'planner_charge_commitment_penalty_czk_kwh', coalesce(ab.planner_charge_commitment_penalty_czk_kwh, 0.20::numeric)
)
into v_b
from ems.asset_battery ab

View File

@@ -18,7 +18,13 @@ returns table (
ev1_connected boolean,
ev2_connected boolean,
allow_charge boolean,
allow_discharge_export boolean
allow_discharge_export boolean,
night_baseload_target_wh numeric,
night_baseload_buffer_wh numeric,
safety_soc_target_wh numeric,
future_avoided_buy_czk_kwh numeric,
future_sell_opportunity_czk_kwh numeric,
is_daytime_pv_surplus_slot boolean
)
language plpgsql
volatile
@@ -47,6 +53,9 @@ declare
v_chg_pm_wh numeric;
v_dis_am_wh numeric;
v_dis_pm_wh numeric;
v_reserve_wh numeric;
v_daytime_en boolean;
v_night_buf_pct numeric;
begin
drop table if exists _ems_plan_slot_wk;
create temp table _ems_plan_slot_wk on commit drop as
@@ -280,7 +289,10 @@ begin
coalesce(ai.max_battery_discharge_w, ai.max_discharge_power_w)
)
)::numeric,
greatest(coalesce(ab.discharge_efficiency, 1::numeric), 0.0001::numeric)
greatest(coalesce(ab.discharge_efficiency, 1::numeric), 0.0001::numeric),
(ab.reserve_soc_percent / 100.0 * ab.usable_capacity_wh)::numeric,
coalesce(ab.planner_daytime_charge_target_enabled, true),
coalesce(ab.planner_night_baseload_buffer_percent, 20::numeric)
into
v_charge_buf,
v_discharge_buf,
@@ -290,7 +302,10 @@ begin
v_charge_eff,
v_max_charge_w,
v_max_discharge_w,
v_discharge_eff
v_discharge_eff,
v_reserve_wh,
v_daytime_en,
v_night_buf_pct
from ems.asset_battery ab
join ems.asset_inverter ai on ai.id = ab.inverter_id and ai.site_id = ab.site_id
where ab.site_id = p_site_id
@@ -395,6 +410,13 @@ begin
end if;
return query
with night_tot as (
select coalesce(sum(w2.load_baseline_w), 0) * 0.25 as night_wh
from _ems_plan_slot_wk w2
where extract(hour from w2.interval_start at time zone 'Europe/Prague') >= 20
or extract(hour from w2.interval_start at time zone 'Europe/Prague') < 6
),
enriched as (
select
w.slot_ord,
w.interval_start,
@@ -407,13 +429,78 @@ begin
w.ev1_connected,
w.ev2_connected,
w.allow_charge,
w.allow_discharge_export
w.allow_discharge_export,
nt.night_wh as night_baseload_target_wh,
nt.night_wh * (v_night_buf_pct / 100.0) as night_baseload_buffer_wh,
case
when not v_daytime_en then null::numeric
when extract(hour from w.interval_start at time zone 'Europe/Prague') between 6 and 19 then
least(
v_soc_max_wh,
v_reserve_wh + (nt.night_wh + nt.night_wh * (v_night_buf_pct / 100.0))
* greatest(
0::numeric,
least(
1::numeric,
(
extract(hour from w.interval_start at time zone 'Europe/Prague')::numeric
+ (
extract(minute from w.interval_start at time zone 'Europe/Prague')::numeric
/ 60.0
)
- 6.0
) / 14.0
)
)
)
else null::numeric
end as safety_soc_target_wh,
coalesce(
max(w.buy_price) over (
order by w.slot_ord rows between 1 following and unbounded following
),
w.buy_price
) as future_avoided_buy_czk_kwh,
coalesce(
max(w.sell_price) over (
order by w.slot_ord rows between 1 following and unbounded following
),
w.sell_price
) as future_sell_opportunity_czk_kwh,
(
extract(hour from w.interval_start at time zone 'Europe/Prague') between 6 and 18
and w.pv_surplus_w > 0
) as is_daytime_pv_surplus_slot
from _ems_plan_slot_wk w
order by w.slot_ord;
cross join night_tot nt
)
select
e.slot_ord,
e.interval_start,
e.buy_price,
e.sell_price,
e.is_predicted_price,
e.pv_a_forecast_w,
e.pv_b_forecast_w,
e.load_baseline_w,
e.ev1_connected,
e.ev2_connected,
e.allow_charge,
e.allow_discharge_export,
e.night_baseload_target_wh,
e.night_baseload_buffer_wh,
e.safety_soc_target_wh,
e.future_avoided_buy_czk_kwh,
e.future_sell_opportunity_czk_kwh,
e.is_daytime_pv_surplus_slot
from enriched e
order by e.slot_ord;
end;
$fn$;
comment on function ems.fn_load_planning_slots_full(int, timestamptz, timestamptz, numeric) is
'15min sloty s cenami, forecastem, baseline a maskami proti mikro-cyklu (charge/discharge-export). '
'Masky charge/discharge-export se berou zvlášť pro 0012 a 1224 Europe/Prague (polovina budgetu na segment). '
'Strop SoC pro výpočet energie k dobití: coalesce(planner_max_soc_percent, max_soc_percent).';
'Strop SoC pro výpočet energie k dobití: coalesce(planner_max_soc_percent, max_soc_percent). '
'Denní safety vstupy: night_baseload_* (20:0006:00 Europe/Prague), safety_soc_target_wh (619), '
'lookahead max buy/sell pro měkké LP penalizace.';

View File

@@ -0,0 +1,76 @@
-- Kompaktní JSON pro diagnostiku jednoho planning_run (MCP / UI).
create or replace function ems.fn_planning_run_debug(p_run_id int)
returns jsonb
language plpgsql
stable
as $fn$
declare
r_run ems.planning_run%rowtype;
v_intervals jsonb;
v_first_charge timestamptz;
v_first_bat_export timestamptz;
v_top_sell jsonb;
begin
select * into r_run from ems.planning_run where id = p_run_id;
if not found then
return null::jsonb;
end if;
select coalesce(jsonb_agg(to_jsonb(pi.*) order by pi.interval_start), '[]'::jsonb)
into v_intervals
from ems.planning_interval pi
where pi.run_id = p_run_id;
select pi.interval_start
into v_first_charge
from ems.planning_interval pi
where pi.run_id = p_run_id
and coalesce(pi.battery_setpoint_w, 0) > 500
order by pi.interval_start
limit 1;
select pi.interval_start
into v_first_bat_export
from ems.planning_interval pi
where pi.run_id = p_run_id
and coalesce(pi.battery_setpoint_w, 0) < -500
and coalesce(pi.grid_setpoint_w, 0) < 0
order by pi.interval_start
limit 1;
select coalesce(
jsonb_agg(
jsonb_build_object(
'interval_start', x.interval_start,
'effective_sell_price', x.effective_sell_price
)
order by x.effective_sell_price desc nulls last
),
'[]'::jsonb
)
into v_top_sell
from (
select pi.interval_start, pi.effective_sell_price
from ems.planning_interval pi
where pi.run_id = p_run_id
order by pi.effective_sell_price desc nulls last
limit 3
) x;
return jsonb_build_object(
'planning_run', to_jsonb(r_run),
'solver_params', r_run.solver_params,
'intervals', v_intervals,
'summary', jsonb_build_object(
'first_charge_slot', to_jsonb(v_first_charge),
'first_battery_export_slot', to_jsonb(v_first_bat_export),
'top_sell_slots', v_top_sell,
'solver_params_version', r_run.solver_params->'version'
)
);
end;
$fn$;
comment on function ems.fn_planning_run_debug(int) is
'Jeden jsonb: metadata planning_run, solver_params, všechny planning_interval řádky a krátký summary.';

View File

@@ -127,6 +127,8 @@ CREATE TABLE asset_battery (
-- planner_max_soc_percent, planner_discharge_floor_percent,
-- planner_extreme_buy_threshold_czk_kwh,
-- planner_terminal_soc_value_factor
-- V077: planner_daytime_charge_target_enabled, planner_night_baseload_buffer_percent,
-- planner_daytime_charge_price_quantile, planner_charge_commitment_penalty_czk_kwh
);
```
@@ -359,7 +361,7 @@ CREATE TABLE planning_run (
horizon_end TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT now(),
status TEXT DEFAULT 'draft', -- 'draft', 'approved', 'active', 'superseded'
solver_params JSONB,
solver_params JSONB, -- po V077: JSON z planning_engine (masks, soc_bounds, objective_terms, …)
notes TEXT
);
```

View File

@@ -150,9 +150,9 @@ bits 01). Detail registrů: [`modbus-registers.md`](modbus-registers.md) (reg
|---|---|
| **SELL** | `grid_setpoint_w` < 0 **a** `battery_w` < 0 |
| **CHARGE** | `battery_w` > 0 **a** `grid_setpoint_w` > 0 |
| **PASSIVE (ZERO)** | vše ostatní — reg. **108/109** dle `_deye_zero_export_amps_for_passive` (viz `operating-modes.md`) |
| **PASSIVE (ZERO)** | vše ostatní — reg. **108/109** z `deye_battery_charge_discharge_amps()` v `setpoints.py` (volá `write_inverter_setpoints`) |
**PASSIVE** (AUTO, ZERO): výchozí **108** i **109** = maximum z DB; u exportu bez vybíjení **108 = 0**, u importu bez nabíjení **109 = 0** (`_deye_zero_export_amps_for_passive`). **TOU** z plánu. Reg. **142** = `deye_zero_export_mode`. Reg. **143** je tvrdý limit exportu z lokality/invertoru (ne forecast). Reg. **145** (solar sell): **0** při `export_ban` mimo SELL, jinak **1** — význam přepínače a rozdíl vůči neřízeným FVE polím je v [`operating-modes.md`](operating-modes.md) (sekce *ZERO a zakázaný export*).
**PASSIVE** (AUTO, ZERO): proudy **108/109** počítá **`deye_battery_charge_discharge_amps`**: pokud plán žádá **nabíjení** (`battery_w > 0`) včetně scénáře **PV přebytek + export do sítě** (`grid_setpoint_w < 0`), nastaví se **kladný nabíjecí proud (108)** a **109 = 0** — nesmí se použít čistě „zero export“ větev, která by při exportu vynutila **108 = 0** a rozbila soulad plán ↔ Deye. Jinak platí `_deye_zero_export_amps_for_passive` (export bez nabíjení → 108 = 0, import bez vybíjení → 109 = 0). **TOU** z plánu. Reg. **142** = `deye_zero_export_mode`. Reg. **143** je tvrdý limit exportu z lokality/invertoru (ne forecast). Reg. **145** (solar sell): **0** při `export_ban` mimo SELL, jinak **1** — význam přepínače a rozdíl vůči neřízeným FVE polím je v [`operating-modes.md`](operating-modes.md) (sekce *ZERO a zakázaný export*).
**SELF_SUSTAIN** zůstává **PASSIVE** v `get_deye_mode`; **108/109** jsou vždy **max z DB** (bez variant ZERO). Rozdíl je **`self_sustain_local_use=True`**: **TOU SOC** = **`min_soc_percent`**, `battery_w=None`.
@@ -160,8 +160,8 @@ bits 01). Detail registrů: [`modbus-registers.md`](modbus-registers.md) (reg
| Registr | Charge | PASSIVE (ZERO) | SELL | Self-consumption |
|---|---|---|---|---|
| **108** (charge A) | škálo dle `battery_w` | max / **0** (FVE přetok) | **0** | dle varianty |
| **109** (discharge A) | **0** | max / **0** (import, držet bat.) | **max z DB** | dle varianty |
| **108** (charge A) | škálo dle `battery_w` | max / **0** (export bez `battery_w>0`) / **>0** při `battery_w>0` i při exportu | **0** | dle varianty |
| **109** (discharge A) | **0** | max / **0** (import, držet bat.) / **0** při `battery_w>0` + export z PV | **max z DB** | dle varianty |
| **142** (limit control) | `deye_zero_export_mode` | `deye_zero_export_mode` | **0** (selling first) | `deye_zero_export_mode` |
| **143** (export cap) | max z DB | max z DB | max z DB (tvrdý limit, bez forecast heuristiky) | max z DB |
| **145** (solar sell) | 1 / 0 při `export_ban` | 1 / 0 při `export_ban` | 1 | 1 / 0 při `export_ban` |

View File

@@ -10,6 +10,9 @@
- **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`.
- **Denní safety charge (měkké LP, ne maska):** `fn_load_planning_slots_full` (**V077+**) vrací navíc odhad nočního baseload Wh (20:0006:00 Europe/Prague), buffer % z `asset_battery.planner_night_baseload_buffer_percent`, lookahead `future_*_czk_kwh`, volitelný `safety_soc_target_wh` (619) a flag `is_daytime_pv_surplus_slot`. `planning_engine.solve_dispatch()` přidá proměnné deficit vůči cíli a penalizaci `max(future_buy, future_sell) degradace` (clamp), aby šlo prodat ve velmi drahém sell okně i přes deficit. Tvrdé `allow_charge` se kvůli tomu nemění.
- **Rolling charge commitment:** při `run_rolling_replan` se z aktivního plánu načtou sloty, kde dříve platilo `battery_setpoint_w > 500`, `pv_a+pv_b > load_baseline`, `grid_setpoint_w ≤ 0`; měkká penalizace proti snížení `bc[t]` oproti předchozímu plánu (`planner_charge_commitment_penalty_czk_kwh` na `asset_battery`). Implementace: `_load_previous_plan_charge_commitment_prev_w`, volitelný argument `charge_commitment_prev_w` u `solve_dispatch()`.
- **Debug snapshot:** každý běh ukládá JSON do `ems.planning_run.solver_params` (sekce `version`, `inputs`, `masks`, `soc_bounds`, `objective_terms`, `chosen_slots`) přes `fn_planning_run_commit` (`p_run_meta->'solver_params'`). Read-model: **`select ems.fn_planning_run_debug(<run_id>);`** (`R__087_fn_planning_run_debug.sql`).
- **Runtime guard v exportu setpointů (legacy):**
- při `AUTO` + `is_predicted_price=true` se na exportní vrstvě vynutí PASSIVE/no-export chování (u nových plánů by `is_predicted_price` v horizontu nemělo nastat).
- **Ekonomika baterie:**
@@ -498,6 +501,8 @@ COMMENT ON COLUMN ems.planning_interval.pv_a_curtailed_w IS
## 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).

View File

@@ -0,0 +1,293 @@
# Provozní režimy EMS - praktický přehled
Tenhle dokument je zkrácený provozní cheat sheet. Cíl je jednoduchý:
- rychle poznat, co EMS v daném slotu dělá
- umět to porovnat s `planning_interval`
- ověřit to na live registrech Deye a ve FE
## 1. Co je zdroj pravdy
- EMS provozní režim lokality: `ems.site_operating_mode.mode_code`
- aktivní plán: `ems.planning_interval`
- fyzická konfigurace invertoru: `asset_inverter` + `site_grid_connection`
- live stav Deye: registry 108, 109, 141, 142, 143, 145, 178, 340
Když něco nesedí, porovnávej vždy v tomto pořadí:
1. `planning_interval`
2. `control/registers` na FE
3. `modbus_command` journal
## 2. EMS režimy lokality
### AUTO
Normální provoz. EMS bere sloty z `planning_interval` a podle nich řídí Deye, EV, TČ a signály.
V AUTO se pak mohou objevit sloty s různým exportním záměrem:
- `PV_SURPLUS`
- `BATTERY_SELL`
- `NONE`
### SELF_SUSTAIN
Bezpečný provoz bez obchodní logiky.
- Deye fyzicky běží v PASSIVE
- baterie se nechává pro vlastní spotřebu
- export je jen nouzový ventil, pokud je potřeba kvůli feasibility
### CHARGE_CHEAP
- nabíjení ze sítě
- export se nepoužívá
- fyzicky CHARGE
### PRESERVE
- baterie je uzamčená
- žádné nabíjení ani vybíjení
- fyzicky PASSIVE
### MANUAL
- EMS setpointy nezapisuje
- vše je ruční řízení
## 3. Tvoje 5 provozních archetypů
### 1. Standardní režim s přetokem
Co tím myslíme:
- baterie se normálně nabíjí i vybíjí podle plánu
- přetok do sítě je povolený
- exportní limit je jen tvrdý site / inverter cap
- když je baterie plná, přebytek FVE jde do sítě
Jak to je v implementaci:
- `export_mode = PV_SURPLUS`
- `export_limit_w = hard cap`
- `solar_sell = 1`
- `deye_physical_mode = PASSIVE`
- v PASSIVE se pro exportní slot typicky použije `108 = 0`, `109 = max`
Poznámka:
- exportní limit se už netipuje z forecastu
- neomezuješ tedy výkon do sítě podle předpovědi, jen podle hard capu
### 2. Standardní režim s vypnutým přetokem
Co tím myslíme:
- `solar_sell = false`
- přebytek FVE se nesmí posílat do sítě
- jakmile je baterie plná, FVE se utlumí
Jak to je v implementaci:
- tohle není samostatný fyzický Deye režim
- většinou jde o kombinaci:
- `reg 143 = 0` nebo site `no_export`
- případně `export_ban = true` a `reg 145 = 0`
- fyzicky to pořád bývá PASSIVE
Poznámka:
- tohle je důležité ověřovat na `reg 143` a `reg 145`, ne jen na `grid_setpoint_w`
### 3. Prodej přebytku do sítě bez nabíjení baterie
Co tím myslíme:
- baterie není cílem
- nechci ji nabíjet
- chci prodávat celou výrobu do sítě
Jak to je v implementaci:
- `export_mode = PV_SURPLUS`
- `solar_sell = 1`
- `export_limit_w = hard cap`
Poznámka k implementaci:
- tohle je v kódu garantované až ve chvíli, kdy planner dá `battery_setpoint_w = 0`
- pokud je `battery_setpoint_w > 0`, tak současná implementace už dovoluje i nabíjení baterie, i když exportní záměr zůstává `PV_SURPLUS`
- jinými slovy: čisté „prodávám výrobu, ale baterii nechci nabíjet“ ještě není samostatný fyzický Deye režim, je to kombinace plánovacího setpointu a exportního záměru
Použití:
- vhodné, když je výkupní cena vysoká
- baterii chceš šetřit na jiný slot
### 4. Šetření baterie
Co tím myslíme:
- když je kupní cena nízká
- nechci brát energii z baterie
- raději budu kupovat ze sítě
Jak to je v implementaci:
- `battery discharge A = 0`
- fyzicky PASSIVE
- baterie se nevybíjí, ale podle slotu se může pořád nabíjet nebo držet
Poznámka:
- tohle je jiné než SELL
- tady jen chráníš baterii, neprodáváš ji
### 5. Aktivní prodej do sítě z baterie
Co tím myslíme:
- `selling first`
- baterie prodává do sítě plným výkonem, co dovolí střídač / baterie / síť
Jak to je v implementaci:
- `export_mode = BATTERY_SELL`
- `deye_physical_mode = SELL`
- `reg 142 = 0`
- `reg 178 = 32`
- `reg 109` na max, `reg 108 = 0`
## 4. Další režimy, které v praxi existují
### CHARGE_CHEAP
- nabíjení ze sítě
- export vypnutý
- fyzicky CHARGE
### SELF_SUSTAIN
- vlastní spotřeba
- fyzicky PASSIVE
- export jen jako nouzový ventil
### PRESERVE
- baterie uzamčená
- žádné řízení baterie
### MANUAL
- EMS nezasahuje
## 5. Registry, které má smysl kontrolovat
### 108
Max charge current.
### 109
Max discharge current.
### 142
Limit control:
- `0` = selling first
- `1` = zero export to load
- `2` = zero export to CT
### 143
Export cap.
- tvrdý site / inverter limit
- neforecastuje se
### 145
Solar sell:
- `1` = povoleno
- `0` = zakázáno
### 178
Bitové pole:
- bits 4-5 = peak shaving switch
- bits 0-1 = GEN export cut-off u BA81
### 340
Max solar power pro řízenou FVE A.
- není to exportní cap
- je to strop výroby pole A
## 6. Co kontrolovat na FE
### Planning page
Zkontroluj:
- `deye_physical_mode`
- `grid_setpoint_w`
- `export_limit_w`
- `export_mode`
- `deye_gen_cutoff_enabled`
- `effective_buy_price`
- `effective_sell_price`
### Control panel
Na živém panelu porovnej:
- reg 142
- reg 143
- reg 145
- reg 178
Reg 143 musí být vidět jako hard cap.
## 7. Rychlá kontrola nesrovnalostí
1. Najdi slot v `Planning`
2. Podívej se na:
- `battery_setpoint_w`
- `grid_setpoint_w`
- `export_limit_w`
- `export_mode`
- `deye_physical_mode`
3. Otevři `ControlPanel`
4. Porovnej live registry:
- 142
- 143
- 145
- 178
5. Podívej se do `modbus_command`
## 8. Co je v implementaci důležité vědět
Tady jsou dva praktické detaily:
- `export_limit_w` se bere jako hard cap z lokality / invertoru
- export se už netipuje z forecastu
To znamená:
- při `PV_SURPLUS` se má pustit maximum, které dovoluje distribuce a HW
- při `BATTERY_SELL` se použije SELL a prodej z baterie
- při běžném importu / šetření baterie se exportní logika nemá „uhádnout“ z ceny nebo forecastu
## 9. Kde hledat v kódu
- plánování: `backend/services/planning_engine.py`
- mapování plánu na setpointy: `backend/services/control/setpoints.py`
- zápis Deye: `backend/services/control/inverter.py`
- live registry: `backend/app/routers/sites.py`
- FE plánování: `frontend/src/pages/Planning.tsx`
- FE live registry: `frontend/src/components/ControlPanel.tsx`

View File

@@ -61,6 +61,11 @@ limit 10;
select ems.fn_plan_explain_bundle(2, 6);
```
```sql
-- Diagnostika posledního běhu plánovače (run_id z planning_run)
select ems.fn_planning_run_debug(8107);
```
Měnící funkce (**`ems.fn_delete_forecast_pv_prague_calendar_day`**, **`ems.fn_rebuild_consumption_baseline_stats`**, …) MCP přes **`query` neprovede**, pokud má server jen read-only práva na DB — použij psql aplikačním účtem.
---