Merge pull request 'refactor-control-monolith' (#4) from refactor-control-monolith into main
Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
279
.cursor/plans/planner-battery-tuning_ae42fae3.plan.md
Normal file
279
.cursor/plans/planner-battery-tuning_ae42fae3.plan.md
Normal 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.
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:00–06: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 = {
|
||||
|
||||
52
backend/tests/test_control_deye_passive_pv_charge.py
Normal file
52
backend/tests/test_control_deye_passive_pv_charge.py
Normal 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()
|
||||
@@ -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,
|
||||
|
||||
140
backend/tests/test_planning_safety_commitment.py
Normal file
140
backend/tests/test_planning_safety_commitment.py
Normal 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()
|
||||
25
db/migration/V077__planner_safety_charge_asset_battery.sql
Normal file
25
db/migration/V077__planner_safety_charge_asset_battery.sql
Normal 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.';
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 00–12 a 12–24 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:00–06:00 Europe/Prague), safety_soc_target_wh (6–19), '
|
||||
'lookahead max buy/sell pro měkké LP penalizace.';
|
||||
|
||||
76
db/routines/R__087_fn_planning_run_debug.sql
Normal file
76
db/routines/R__087_fn_planning_run_debug.sql
Normal 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.';
|
||||
@@ -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
|
||||
);
|
||||
```
|
||||
|
||||
@@ -150,9 +150,9 @@ bits 0–1). 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 0–1). 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` |
|
||||
|
||||
@@ -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[T−1]` (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:00–06:00 Europe/Prague), buffer % z `asset_battery.planner_night_baseload_buffer_percent`, lookahead `future_*_czk_kwh`, volitelný `safety_soc_target_wh` (6–19) 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).
|
||||
|
||||
293
docs/04-modules/provozni-rezimy-checklist.md
Normal file
293
docs/04-modules/provozni-rezimy-checklist.md
Normal 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`
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user