8 Commits

Author SHA1 Message Date
Dusan Vojacek
8a3a49806b dalsi pokus ladeni
Some checks failed
CI and deploy / migration-check (pull_request) Failing after 17s
CI and deploy / deploy (pull_request) Has been skipped
2026-05-04 20:11:50 +02:00
Dusan Vojacek
b35f292295 fix chargedischarge A
Some checks failed
CI and deploy / migration-check (pull_request) Failing after 25s
CI and deploy / deploy (pull_request) Has been skipped
2026-05-04 19:37:42 +02:00
Dusan Vojacek
6471467bc5 fix
Some checks failed
CI and deploy / migration-check (pull_request) Failing after 13s
CI and deploy / deploy (pull_request) Has been skipped
2026-05-04 19:14:52 +02:00
Dusan Vojacek
ba53fe5bfc fix 2026-05-04 19:10:15 +02:00
Dusan Vojacek
335c413232 planner battery tuning
Some checks failed
CI and deploy / migration-check (pull_request) Failing after 15s
CI and deploy / deploy (pull_request) Has been skipped
2026-05-04 19:06:04 +02:00
Dusan Vojacek
bcb05d4896 tuning palnneru 2026-05-04 19:04:48 +02:00
Dusan Vojacek
405e832f8d doplneni dokumentace provozcnih rezimu 2026-05-03 22:46:16 +02:00
Dusan Vojacek
e8eb867a2a refactor export limit semantics
Some checks failed
CI and deploy / migration-check (pull_request) Failing after 15s
CI and deploy / deploy (pull_request) Has been skipped
2026-05-03 22:24:35 +02:00
28 changed files with 1460 additions and 108 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

@@ -108,7 +108,7 @@ Projekt je **SQL-first**: doménová logika, agregace, joiny mezi tabulkami a st
17. **Modbus zápis = journal.** Každý zápis do zařízení přes control exporter se loguje do `ems.modbus_command`. **Verifikační job** běží každé **2 minuty** a ověřuje nedávno zápis (`written` → čtení registru). Při **mismatch** po max. **3** pokusech o zápis → u běžných registrů přepnutí na **SELF_SUSTAIN** (`run_fn_set_mode_with_discord``fn_set_mode`, `activated_by` = `system:mismatch`) + **Discord** při skutečné změně režimu. **Výjimka:** souvislý blok Deye **6264** (čas) → po 3 neúspěšných ověřeních **bez** změny režimu, kritický **Discord** (`notify_modbus_clock_verify_exhausted`). **Obecně:** při jakékoli změně `mode_code` z Pythonu (`POST /api/v1/sites/{id}/mode`, mismatch → SELF_SUSTAIN, `fn_expire_modes`) lze Discord zapnout přes `DISCORD_WEBHOOK_URL`. Detail: `docs/04-modules/modbus-command-journal.md`.
18. **Deye zápis registrů 60499:** pouze **FC 0x10** (`write_registers`), **nikdy** FC 0x06 pro tento rozsah; **`execute_modbus_commands`** slučuje souvislé adresy do jednoho FC 0x10. **Fyzický režim Deye** (`PASSIVE` / `CHARGE` / `SELL`): **výhradně** `get_deye_mode` v `exporter_monolith.py` (bez wattových prahů: **SELL** při `battery_w` < 0 a `grid_setpoint_w` < 0; **CHARGE** při obou > 0; jinak **PASSIVE**). **PASSIVE (ZERO, AUTO):** **108/109** dle `_deye_zero_export_amps_for_passive`; **TOU SOC** (reg 166+): **PASSIVE** = **`min_soc_percent`**, **CHARGE** = **`max_soc_percent`** (clamp 10100 z DB), **SELL** = **`reserve_soc_percent`** (`_deye_passive_tou_battery_soc_pct`, `_deye_tou_params`). **SELL:** 108=0, 109=max, **178**=32 (peak shaving off), **143** omezeno podle `|grid_setpoint_w|`; **142/145/TOU** jako v `write_inverter_setpoints`. **Reg 340** (*max solar power*, W): jen pokud `ems.fn_site_has_active_green_bonus_pv(site_id)` (lokalita se zeleným bonusem na PV poli) **a** `ems.fn_inverter_pv_a_max_w(inverter_id) > 0`; hodnota z `pv_a_forecast_solver_w` / `pv_a_curtailed_w` (AUTO). **Není** v `DEYE_CRITICAL_REGS_SELF_SUSTAIN` — verify mismatch nečeká přepnutí do SELF_SUSTAIN. **PRESERVE:** `lock_battery` → 108/109=0. **Čas 6264**, bloky TOU **12** vs **36**, verify, Discord: beze změny oproti dřívějšímu chování — plný popis **`docs/04-modules/modbus-registers.md`** a **`docs/04-modules/operating-modes.md`**.
18. **Deye zápis registrů 60499:** pouze **FC 0x10** (`write_registers`), **nikdy** FC 0x06 pro tento rozsah; **`execute_modbus_commands`** slučuje souvislé adresy do jednoho FC 0x10. **Fyzický režim Deye** (`PASSIVE` / `CHARGE` / `SELL`): **výhradně** `get_deye_mode` v `exporter_monolith.py` (bez wattových prahů: **SELL** při `battery_w` < 0 a `grid_setpoint_w` < 0; **CHARGE** při obou > 0; jinak **PASSIVE**). **PASSIVE (ZERO, AUTO):** **108/109** dle `deye_battery_charge_discharge_amps` a `_deye_zero_export_amps_for_passive` (jen asymetrie **import bez vybíjení****109 = 0**; export **108** nenuluje); **TOU SOC** (reg 166+): **PASSIVE** = **`min_soc_percent`**, **CHARGE** = **`max_soc_percent`** (clamp 10100 z DB), **SELL** = **`reserve_soc_percent`** (`_deye_passive_tou_battery_soc_pct`, `_deye_tou_params`). **SELL:** 108=0, 109=max, **178**=32 (peak shaving off), **143** omezeno podle `|grid_setpoint_w|`; **142/145/TOU** jako v `write_inverter_setpoints`. **Reg 340** (*max solar power*, W): jen pokud `ems.fn_site_has_active_green_bonus_pv(site_id)` (lokalita se zeleným bonusem na PV poli) **a** `ems.fn_inverter_pv_a_max_w(inverter_id) > 0`; hodnota z `pv_a_forecast_solver_w` / `pv_a_curtailed_w` (AUTO). **Není** v `DEYE_CRITICAL_REGS_SELF_SUSTAIN` — verify mismatch nečeká přepnutí do SELF_SUSTAIN. **PRESERVE:** `lock_battery` → 108/109=0. **Čas 6264**, bloky TOU **12** vs **36**, verify, Discord: beze změny oproti dřívějšímu chování — plný popis **`docs/04-modules/modbus-registers.md`** a **`docs/04-modules/operating-modes.md`**.
19. **Baterie export v LP:** V `solve_dispatch` binárka `z_export[t]`: pokud `grid_export` v daném slotu **≥ 1** W, platí koncové `soc[t] ≥ arb_base_wh` (ekonomická rezerva z DB, ne časová řada `arb_floor_series`). Bez exportu může plán jít k `min_soc_percent` (provozní podlaha; u paralelních packů často 1112 %, migrace V029 + komentář sloupce).

View File

@@ -19,9 +19,6 @@ DEYE_CLOCK_RESYNC_INTERVAL_HOURS = 24
# Deye LV baterie: převod výkon -> proud pro registry 108/109.
BATT_VOLTAGE_V = 51.2
# Reg 143 ve SELL: min(|grid_setpoint_w|, ...) nesmí klesnout pod tuto podlahu (W).
REG143_SELL_CAP_MIN_W = 200
# Reg 178 - bitové pole: bity 4-5 (peak shaving switch) a bity 0-1 (MI export cutoff).
REG178_SELL = 0b00100000
REG178_PASSIVE = 0b00110000

View File

@@ -13,7 +13,6 @@ from services.control.deye_helpers import (
DEYE_TOU_INACTIVE_HHMM,
DEYE_TOU_POWER_REGS,
PRAGUE_TZ,
REG143_SELL_CAP_MIN_W,
REG178_MI_EXPORT_DISABLE,
REG178_MI_EXPORT_ENABLE,
REG178_MI_EXPORT_MASK,
@@ -70,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

@@ -15,7 +15,6 @@ from services.control.deye_helpers import (
DEYE_CLOCK_RESYNC_INTERVAL_HOURS,
DEYE_TOU_INACTIVE_HHMM,
PRAGUE_TZ,
REG143_SELL_CAP_MIN_W,
REG178_MI_EXPORT_DISABLE,
REG178_MI_EXPORT_ENABLE,
REG178_MI_EXPORT_MASK,
@@ -26,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,
)
@@ -45,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
@@ -79,32 +77,20 @@ 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)
selling_mode = 0 if deye_mode == "SELL" else zero_exp_mode
solar_sell = 0 if (setpoints_now.export_ban and deye_mode != "SELL") else 1
export_limit = export_lim
if deye_mode == "SELL" and grid_w < 0:
export_limit = min(export_lim, max(REG143_SELL_CAP_MIN_W, abs(grid_w)))
reg178_val = REG178_SELL if deye_mode == "SELL" else REG178_PASSIVE
logger.info(

View File

@@ -39,6 +39,7 @@ class InverterConfig:
@dataclass
class ControlSetpoints:
battery_w: int | None
#: Tvrdý limit exportu do sítě v daném slotu (W), ne forecastová cílová hodnota.
grid_export_limit: int
ev1_current_a: int
ev2_current_a: int

View File

@@ -81,6 +81,8 @@ def _build_setpoints(
if pi is None:
return None
grid_sp = int(pi["grid_setpoint_w"] or 0)
export_limit_raw = pi.get("export_limit_w")
export_limit = int(export_limit_raw) if export_limit_raw is not None else abs(min(grid_sp, 0))
ev1_w = int(pi["ev1_setpoint_w"] or 0) if "ev1_setpoint_w" in pi else 0
ev2_w = int(pi["ev2_setpoint_w"] or 0) if "ev2_setpoint_w" in pi else 0
hp_en = bool(pi["heat_pump_enabled"])
@@ -90,6 +92,10 @@ def _build_setpoints(
pm: str | None = str(pm_raw).strip().upper() if pm_raw is not None else None
sell_raw = pi.get("effective_sell_price")
sell_f: float | None = float(sell_raw) if sell_raw is not None else None
export_mode_raw = pi.get("export_mode")
export_mode = str(export_mode_raw).strip().upper() if export_mode_raw is not None else None
if export_mode == "NONE":
export_limit = 0
# Záporný výkup sám o sobě neblokuje export, pokud plán export explicitně žádá.
export_ban = sell_f is not None and float(sell_f) < 0 and grid_sp >= 0
gen_cutoff_raw = pi.get("deye_gen_cutoff_enabled")
@@ -112,7 +118,7 @@ def _build_setpoints(
pv_a_allowed = 0
return ControlSetpoints(
battery_w=int(pi["battery_setpoint_w"] or 0),
grid_export_limit=abs(min(grid_sp, 0)),
grid_export_limit=max(0, export_limit),
ev1_current_a=watts_to_amps(ev1_w, phases=3),
ev2_current_a=watts_to_amps(ev2_w, phases=1),
heat_pump_enable=hp_en,
@@ -238,15 +244,47 @@ def _deye_zero_export_amps_for_passive(
max_discharge_a: int,
) -> tuple[int, int]:
"""
PASSIVE (zero export k CT/zátěži): výchozí plné 108/109.
PASSIVE (zero export k CT/zátěži): asymetrie jen tam, kde dává smysl pro import.
Export v plánu bez vybíjení baterie vypne charge A; import bez nabíjení vypne discharge A.
Export do sítě (grid_w < 0) už směr toku řeší režim / 142 / 145 — **108** jako strop zbytečně
nenulovat na 0 (viz home-01). Jediná speciální větev: import bez nabíjení → vypnout vybíjení.
"""
if grid_w < 0 and bat_w >= 0:
return 0, max_discharge_a
if grid_w > 0 and bat_w <= 0:
return max_charge_a, 0
return max_charge_a, max_discharge_a
return int(max_charge_a), int(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 (typicky z FVE, i při exportu zbytku do sítě): **108 = max_charge_a**
z invertoru — reg. 108 je strop, ne příkaz k proudu; průměrný `battery_w` ze slotu nesmí špičku FVE
stíhat do baterie omezovat. **109 = max_discharge_a** (domácnost z baterie při výpadku PV).
**CHARGE** (import ze sítě + nabíjení): 108 dál z `battery_w` (řízený importní okamžik). **SELL** beze změny.
"""
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 int(max_charge_a), int(max_discharge_a)
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:

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).
@@ -319,6 +333,8 @@ class DispatchResult:
battery_setpoint_w: int # kladné = nabíjení, záporné = vybíjení
battery_soc_target: float # % SoC na konci intervalu
grid_setpoint_w: int # kladné = import, záporné = export
export_limit_w: int # tvrdý limit exportu do sítě; 0 = bez exportu
export_mode: str # NONE / PV_SURPLUS / BATTERY_SELL
#: Explicitní fyzický režim Deye pro control exporter (PASSIVE / SELL / CHARGE).
#: Cíl: odstranit heuristiky z exporteru a nést záměr přímo v plánu.
deye_physical_mode: str
@@ -436,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:
@@ -601,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(
@@ -642,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í ---
@@ -678,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]
@@ -760,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
@@ -851,6 +909,10 @@ def solve_dispatch(
batt_w = round(pulp.value(bc[t]) - pulp.value(bd[t]))
grid_w = round(pulp.value(gi[t]) - pulp.value(ge[t]))
soc_pct = round(pulp.value(soc[t]) / battery.usable_capacity_wh * 100, 1)
export_limit_w = int(grid.max_export_power_w) if grid_w < 0 else 0
export_mode = "NONE"
if grid_w < 0:
export_mode = "BATTERY_SELL" if batt_w < 0 else "PV_SURPLUS"
# Primární klasifikace fyzického režimu pro Deye: explicitně do plánu (Variant A).
# Default PASSIVE; SELL při export+vybíjení; CHARGE při import+nabíjení.
@@ -874,6 +936,8 @@ def solve_dispatch(
battery_setpoint_w = batt_w,
battery_soc_target = soc_pct,
grid_setpoint_w = grid_w,
export_limit_w = export_limit_w,
export_mode = export_mode,
deye_physical_mode = deye_mode,
deye_gen_cutoff_enabled = deye_gen_cutoff,
ev1_setpoint_w = round(pulp.value(ev_direct[0][t]) + pulp.value(ev_via_bat[0][t]))
@@ -891,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
# ============================================================
@@ -922,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",
@@ -942,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
@@ -1015,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)
@@ -1035,6 +1187,7 @@ async def run_rolling_replan(
correction=correction_factor,
db=db,
slot_inputs=slot_inputs,
solver_snapshot=solver_snapshot,
)
await db.execute(
@@ -1157,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"]
@@ -1219,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,
@@ -1232,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
)
@@ -1258,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:
@@ -1298,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,
@@ -1310,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 = {
@@ -1319,6 +1544,8 @@ async def _save_planning_run(
"battery_setpoint_w": r.battery_setpoint_w,
"battery_soc_target_pct": r.battery_soc_target,
"grid_setpoint_w": r.grid_setpoint_w,
"export_limit_w": r.export_limit_w,
"export_mode": r.export_mode,
"deye_physical_mode": r.deye_physical_mode,
"deye_gen_cutoff_enabled": r.deye_gen_cutoff_enabled,
"ev1_setpoint_w": r.ev1_setpoint_w,

View File

@@ -178,6 +178,10 @@ async def compute_export_ban_active(site_id: int, conn: asyncpg.Connection) -> b
if bool(pi.get("is_predicted_price")):
return True
export_mode = str(pi.get("export_mode") or "").upper()
if export_mode in ("PV_SURPLUS", "BATTERY_SELL"):
return False
sell_raw = pi.get("effective_sell_price")
grid_sp = int(pi.get("grid_setpoint_w") or 0)
if sell_raw is None:

View File

@@ -0,0 +1,67 @@
"""PASSIVE + plán chce nabíjet: 108 = plný strop z DB, 109 = max (PV špička + domácnost)."""
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.assertEqual(ch, 100)
self.assertEqual(dis, 100)
def test_charge_mode_still_scales_108_from_battery_w(self) -> None:
"""CHARGE (síť + baterie): 108 podle plánu, ne vždy plný strop."""
ch, dis = deye_battery_charge_discharge_amps(
lock_battery=False,
deye_mode="CHARGE",
self_sustain_local_use=False,
bat_w=2000,
grid_w=3000,
max_charge_a=100,
max_discharge_a=100,
)
self.assertLess(ch, 100)
self.assertGreater(ch, 0)
self.assertEqual(dis, 0)
def test_passive_export_without_battery_charge_uses_max_108(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, 100)
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

@@ -11,10 +11,11 @@ from services.control.exporter_monolith import (
_deye_reg178_verify_with_double_read,
_deye_tou_params,
_deye_tou_power_verify_match,
_deye_zero_export_amps_for_passive,
deye_reg_triggers_self_sustain_after_verify_exhaust,
get_deye_mode,
)
from services.control.models import OperatingModeInfo
from services.control.setpoints import _build_setpoints, _deye_zero_export_amps_for_passive
def _inv(*, min_soc: int | None = 12, reserve_soc: int | None = 20) -> InverterConfig:
@@ -110,6 +111,30 @@ class DeyeTouParamsTests(unittest.TestCase):
)
self.assertEqual(get_deye_mode(sp), "PASSIVE")
def test_build_setpoints_uses_explicit_export_limit(self) -> None:
mode = OperatingModeInfo(
mode_code="AUTO",
battery_mode="AUTO",
grid_mode="AUTO",
ev_enabled=False,
heat_pump_enabled_def=False,
loxone_mode_value=1,
)
pi = {
"battery_setpoint_w": 0,
"grid_setpoint_w": -3000,
"export_limit_w": 13_500,
"export_mode": "PV_SURPLUS",
"ev1_setpoint_w": 0,
"ev2_setpoint_w": 0,
"heat_pump_enabled": False,
"battery_soc_target_pct": 50,
"effective_sell_price": 1.0,
}
sp = _build_setpoints(mode, pi)
self.assertIsNotNone(sp)
self.assertEqual(sp.grid_export_limit, 13_500)
def test_pv_led_export_with_small_battery_is_sell(self) -> None:
"""Obě záporné → SELL (bez porovnání |bat| vs |grid|)."""
sp = ControlSetpoints(
@@ -247,7 +272,7 @@ class DeyeTouParamsTests(unittest.TestCase):
def test_zero_export_amps_fve_overflow(self) -> None:
c, d = _deye_zero_export_amps_for_passive(-1000, 0, 100, 90)
self.assertEqual(c, 0)
self.assertEqual(c, 100)
self.assertEqual(d, 90)
def test_zero_export_amps_import_hold_discharge(self) -> None:

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,
@@ -254,6 +254,47 @@ class PlanningDispatchMilpTests(unittest.TestCase):
self.assertEqual(int(results[0].pv_a_curtailed_w), 5000)
self.assertGreaterEqual(int(results[0].grid_setpoint_w), 0)
def test_pv_surplus_export_uses_hard_export_cap(self) -> None:
slots = [
_slot(load=0, buy=3.0, sell=2.5, pv_a=20_000, pv_b=0),
]
battery = _battery()
hp = SimpleNamespace(
rated_heating_power_w=0,
tuv_min_temp_c=45.0,
tuv_target_temp_c=55.0,
)
grid = SimpleNamespace(max_import_power_w=20_000, max_export_power_w=13_500)
vehicles = [
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
SimpleNamespace(
max_charge_power_w=0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
),
]
soc0 = battery.soc_max_wh
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
soc0,
50.0,
tuv_delta_stats=None,
operating_mode="AUTO",
)
self.assertEqual(len(results), 1)
self.assertEqual(results[0].export_mode, "PV_SURPLUS")
self.assertEqual(results[0].export_limit_w, 13_500)
self.assertGreater(results[0].pv_a_curtailed_w, 0)
def test_two_tier_soc_solves_optimal(self) -> None:
slots = [_slot()]
battery = _battery()
@@ -276,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,
@@ -316,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,
@@ -352,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,
@@ -392,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,
@@ -470,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,
@@ -552,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,
@@ -639,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,
@@ -714,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,
@@ -757,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,
@@ -812,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,
@@ -872,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,
@@ -942,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

@@ -1,4 +1,10 @@
-- sloty pro LP: ceny, forecast, baseline, EV připojení + masky allow_charge / allow_discharge_export
-- DROP: změna RETURNS TABLE (nové sloupce) — CREATE OR REPLACE na rozdílný row type v PG neprojde.
-- Musí být plná signatura (pg_proc ukládá int jako integer); DROP bez () funkci se směrem nemaže.
drop function if exists ems.fn_load_planning_slots_full(
integer, timestamp with time zone, timestamp with time zone, numeric
);
create or replace function ems.fn_load_planning_slots_full(
p_site_id int,
@@ -18,7 +24,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 +59,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 +295,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 +308,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 +416,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 +435,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
comment on function ems.fn_load_planning_slots_full 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. **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`) a režim zůstává **PASSIVE** (typicky FVE přebytek, často i **export** části výroby), **108 = max_charge_a z invertoru** — jde o **horní limit** proudu do baterie; průměrný `battery_w` ze 15min slotu nesmí špičku FVE do baterie uměle omezovat (dřívější odvod z W dával smysl jen u **CHARGE** ze sítě). **109 = max z DB**. Když plán nabíjení nechce (`battery_w ≤ 0`) a současně je export, **108** zůstává **max** (přebytek do sítě řeší **142/145**, ne vynucení **108 = 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** — viz [`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,10 +160,10 @@ 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 nabíjení) / **max** při PASSIVE + `battery_w>0` (FVE do baterie až po strop) | **0** | dle varianty |
| **109** (discharge A) | **0** | max / **0** (import, držet bat.) / **max** při PASSIVE + `battery_w>0` | **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 | `min(max_site, max(200, \|grid_setpoint_w\|))` | max z DB |
| **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` |
| **178** (peak shaving) | 48 | 48 | **32** | 48 |
@@ -207,7 +207,7 @@ async def write_inverter_setpoints(site_id: int, setpoints: Setpoints, db):
slave=inv.unit_id)
# Export limit
export_limit = max(0, -setpoints.grid_setpoint_w) if setpoints.grid_setpoint_w < 0 else 0
export_limit = setpoints.grid_export_limit
await client.write_register(0x00F6, export_limit, slave=inv.unit_id)
logger.info(f"Inverter {inv.code} setpoints written: batt={setpoints.battery_setpoint_w}W")

View File

@@ -12,15 +12,15 @@ EMS zapisuje řídící hodnoty přes journal (`modbus_command`) a **`write_regi
| Reg | Název | Rozsah | Jednotka | Použití v EMS |
|-----|-------|--------|----------|---------------|
| 108 | Max charge current | 0 … **max dle modelu** (manuál Deye) | 1 A | Strop **A** z DB (`COALESCE(deye_register_max_charge_a, odvod z kW)` + clamp **350 A**). Ve **PASSIVE** (AUTO) podle `_deye_zero_export_amps_for_passive`: výchozí **max**, u exportu v plánu bez vybíjení **0**. **CHARGE:** proud z `battery_w` přes `battery_watts_to_amps`. **SELL:** **0**. |
| 109 | Max discharge current | 0 … **max dle modelu** (manuál Deye) | 1 A | Ve **PASSIVE** (AUTO): výchozí **max**, u importu bez nabíjení **0**; **SELL** max vybíjení; **CHARGE** typicky **0**. |
| 108 | Max charge current | 0 … **max dle modelu** (manuál Deye) | 1 A | Strop **A** z DB (`COALESCE(deye_register_max_charge_a, odvod z kW)` + clamp **350 A**). **PASSIVE** + plán chce nabíjet (`battery_w>0`): **108 = max** (špička FVE nesmí být omezená průměrem slotu). **PASSIVE** + export bez nabíjení: **0**. **CHARGE:** z `battery_w` přes `battery_watts_to_amps`. **SELL:** **0**. |
| 109 | Max discharge current | 0 … **max dle modelu** (manuál Deye) | 1 A | Ve **PASSIVE** (AUTO): výchozí **max**, u importu bez nabíjení **0**; při **PASSIVE + `battery_w>0` + export** zůstává **max** (domácnost z baterie při výpadku PV). **SELL** max vybíjení; **CHARGE** typicky **0**. |
| 128 | Grid charge current | 0 … **max dle modelu** (manuál Deye) | 1 A | Nabíjení ze sítě. Firmware automaticky sníží reálný proud tak, aby `load + battery_charge` nepřekročil velikost jističe — proto LP v `planning_engine.py` plánuje `gi[t]`**do `max_import_power_w + BMS_max_charge`**, aby uměl využít cenově nejlepší 15min okna pro nabíjení na plný BMS strop (viz `planning.md` sekce „Plánovací strop gi[t] vs. fyzický jistič"). Fyzické dodržení jističe drží reg 128 + firmware. |
| 130 | Grid charge enable | 0/1 | — | 1 = povolit nabíjení ze sítě |
| 141 | Energy mgmt mode | bitmask | — | EMS vždy **0** (neměnit jinak) |
| 142 | Limit control (System work mode) | 0/1/2 | — | **0** = selling first, **1** = zero export to load, **2** = zero export to CT. Hodnota v non-SELL režimech pochází z `asset_inverter.deye_zero_export_mode` (závisí na instalaci viz tabulka níže). V režimu SELL vždy **0**. |
| 145 | Solar sell | 0/1 | — | **0** = disabled (přebytek FVE na **straně měniče** se nesmí vést do sítě — curtailment vůči síti), **1** = enabled. Platí jen pro **FVE pod kontrolou Deye** (`controllable = true`); druhá pole (např. **pv-b** u home-01) EMS tímto registerem neřídí. EMS dnes **vždy zapisuje 1**; při 108 = 0 a 145 = 1 přebytky z řiditelného stringu typicky tečou do sítě (viz pass-through níže). |
| 145 | Solar sell | 0/1 | — | **0** = disabled (přebytek FVE na **straně měniče** se nesmí vést do sítě — curtailment vůči síti), **1** = enabled. Platí jen pro **FVE pod kontrolou Deye** (`controllable = true`); druhá pole (např. **pv-b** u home-01) EMS tímto registerem neřídí. EMS dnes **vždy zapisuje 1**; směr přebytku (baterie vs. síť) řeší energie management měniče a **142**, ne umělé **108 = 0** (viz pass-through níže). |
| 340 | Max solar power | 0 … cap (W) | 1 W | Strop výkonu DC PV řízeného střídačem (pole A). EMS zapisuje jen pokud `ems.fn_site_has_active_green_bonus_pv(site_id)` (zelený bonus na PV poli) **a** `ems.fn_inverter_pv_a_max_w(inverter_id) > 0`. Hodnota z aktivního `planning_interval`: bez curtailmentu = cap; s `pv_a_curtailed_w > 0` = `clamp(0, cap, pv_a_forecast_solver_w pv_a_curtailed_w)`. **Není** v `DEYE_CRITICAL_REGS_SELF_SUSTAIN` — verify mismatch nečeká přepnutí do SELF_SUSTAIN. |
| 143 | Export limit W | závisí na typu (SUN-20K až ~13 500) | 1 W | Max export do sítě; hodnota z `site_grid_connection.max_export_power_w` |
| 143 | Export limit W | závisí na typu (SUN-20K až ~13 500) | 1 W | Max export do sítě; hodnota z `site_grid_connection.max_export_power_w`. EMS ji neodvozuje z forecastu ani z `grid_setpoint_w`; pro exportní sloty je to tvrdý site/inverter cap. |
| 178 | Control board special 1 | bitmask | — | Bitové pole pro více funkcí. **EMS používá:** (a) bits **45** pro peak shaving switch: **32** (`0b00100000`, bit45 = **10**) v režimu **SELL**; **48** (`0b00110000`, bit45 = **11**) v **PASSIVE/CHARGE**. (b) **BA81:** bits **01** pro „MI export to Grid cutoff“ (AC coupling / GEN): **2** = disable (cutoff OFF), **3** = enable (cutoff ON). EMS zapisuje jako **read-modify-write** (zachová ostatní bity). V některých manuálech/UI je to označené jako „register 179“ (1-based). |
| 190 | GEN peak shaving | 016000 | 1 W | Peak shaving na GEN portu |
| 191 | Grid peak shaving power | 016000 | 1 W | **EMS NEZAPISUJE** nastavit **manuálně v SolarmanApp**. Hodnota určuje výkon peak shavingu v **W**. |
@@ -66,7 +66,7 @@ Vychází z **`grid_setpoint_w`** a **`battery_w`** z `ControlSetpoints` (aktivn
Režim **CHARGE_CHEAP** nastaví oba setpointy na stejný kladný výkon (min. 1 W), aby byl výsledek **CHARGE**.
**PASSIVE (ZERO):** reg. **108/109** podle `_deye_zero_export_amps_for_passive` — při exportu v plánu bez vybíjení je **108 = 0** (přetok FVE); při importu bez nabíjení je **109 = 0** (držet baterii). Jinak oba max (AUTO). Detail: `operating-modes.md`.
**PASSIVE (ZERO):** reg. **108/109** podle `_deye_zero_export_amps_for_passive` / `deye_battery_charge_discharge_amps`**108** a **109** jsou typicky **max** z DB; výjimka jen **import bez nabíjení** (`109 = 0`). Export bez kladného `battery_w`**108 nenuluje** (přebytek do sítě řeší režim / 142 / 145, ne falešné „baterie plná“). Detail: `operating-modes.md`.
### BA81: GEN port cut-off (reg 178 bits01) z plánu
@@ -108,12 +108,12 @@ Solver předvybírá sloty pro nabíjení a export-vybíjení (`_select_charge_s
**CHARGE:** TOU řádek nese **`max_soc_percent`** z DB (**clamp 10100**) jako cíl při **grid charge** (spolu s příznakem grid charge v time pointu). **Energy pattern** („load first“ / „battery first“) v UI střídače zůstává v kompetenci instalace — EMS ho přes Modbus nenastavuje.
**Jak funguje pass-through fyzicky:**
**Jak funguje pass-through (logicky):**
1. Reg 108 = 0 → baterie se fyzicky nemůže nabíjet (Deye ji považuje za „plnou")
2. Reg 142 = 1/2 → zero export mode (Deye nebude aktivně prodávat z baterie)
3. Reg 145 = 1 → solar sell enabled: protože baterie je „plná" (108 = 0), PV přebytky tečou do sítě
4. Reg 109 = max → pokud spotřeba překročí FVE, baterie může vybíjet (ochrana self-consumption)
1. **108 / 109** typicky **max** z invertoru — horní limity, ne příkaz „nabíjej / vybíjej“.
2. Reg **142** = 1/2 → zero export to load / CT (instalace závislá).
3. Reg **145** = 1 → solar sell enabled; přebytek řiditelné FVE po zátěži a limitech směřuje do sítě podle firmware.
4. Plán (`battery_w`, `grid_setpoint_w`) a **CHARGE** / **SELL** větev v `deye_battery_charge_discharge_amps` dál určují asymetrie (např. **CHARGE**: 109 = 0).
### `deye_zero_export_mode` per inverter

View File

@@ -3,7 +3,8 @@
## Keep it simple
- **Žádné wattové prahy pro výběr SELL / CHARGE** — mapování z MILP setpointů je čistě ze **znamének** `battery_setpoint_w` a `grid_setpoint_w` (viz `get_deye_mode` v `exporter_monolith.py`).
- **ZERO (PASSIVE)** = zero export k CT/zátěži (**142** = `deye_zero_export_mode`), s **plnými proudy 108/109** jen ve výchozím stavu; pro přetok FVE do sítě nebo odběr ze sítě bez vybíjení baterie se jeden z proudů **vynuluje** (`_deye_zero_export_amps_for_passive`).
- **Přetok FVE do sítě** se neodvozuje z forecastového capu: plán nese explicitní `export_limit_w` jako tvrdý limit lokality / invertoru, ne jako tipované maximum z předpovědi.
- **ZERO (PASSIVE)** = zero export k CT/zátěži (**142** = `deye_zero_export_mode`); **108/109** jsou typicky **max** z DB. Vynulování jen u **importu bez vybíjení** (`109 = 0` přes `_deye_zero_export_amps_for_passive`). Přetok FVE do sítě při exportu **108 nenuluje** — směr přebytku řeší **142/145** a plán, ne falešné „baterie plná“.
- **SELL** = plánovaný export **i** plánované vybíjení (oba záporné) → **142** = selling first, **178** = vypnutý grid peak shaving (32); po návratu do ZERO/CHARGE zase **178** = 48.
- Novou logiku vždy ověřit proti **reálnému řádku plánu** (audit / `planning_interval`).
@@ -50,15 +51,16 @@ Všechny řádky předpokládají **142** = zero export (ne SELL).
| Situace | Podmínka (plán) | Reg. 108 (max charge A) | Reg. 109 (max discharge A) |
|---------|-----------------|-------------------------|----------------------------|
| Výchozí | ostatní případy PASSIVE | max | max |
| Přetok FVE do sítě | `grid_setpoint_w` < 0 **a** `battery_w` ≥ 0 | **0** | max |
| Výchozí | ostatní případy PASSIVE (včetně exportu / přetoku FVE) | max | max |
| Držet baterii, brát ze sítě | `grid_setpoint_w` > 0 **a** `battery_w` ≤ 0 | max | **0** |
V obou exportních případech platí, že `export_limit_w` je **tvrdý site/inverter cap**. Nejde o forecastový odhad exportu, takže se může pustit plný přetok v rámci distribučního limitu.
Nabíjení ze sítě s vysokým cílovým SoC v TOU řeší větev **CHARGE** (grid charge v time pointech), ne tato tabulka.
### ZERO a „zakázaný export FVE do sítě“ (jen řiditelná pole)
**Reg. 145 (solar sell)** na Deye je přepínač typu **enabled / disabled** pro **přebytek FVE na straně měniče** (počítá se vůči režimu **142** zero export a stavu **108** — viz `modbus-registers.md`, pass-through krok za krokem).
**Reg. 145 (solar sell)** na Deye je přepínač typu **enabled / disabled** pro **přebytek FVE na straně měniče** (vůči režimu **142** zero export a interní logice měniče — viz `modbus-registers.md`, pass-through).
- **Pouze to, co EMS umí přes Deye Modbus ovlivnit** — typicky **FVE pole řízené střídačem** (`asset_pv_array.controllable = true`, u referenční lokality **home-01** např. string za Deye).
- **Pole mimo tento kanál** (např. **pv-b** u **home-01**, `controllable = false`, často samostatný ongrid GEN) **reg. 145 neovládá**; jejich výkon jde do plánovače jako `pv_b_forecast_w`, ale curtailment / solar sell logika Deye se jich netýká.
@@ -72,7 +74,12 @@ Týká se jen výroby, kterou Deye umí ovlivnit; **pv-b / ongrid GEN** u home-0
#### PV1/PV2 vs. GEN port (důležité pro BLOCK_EXPORT)
- **PV1/PV2 (hlavní stringy na DC vstupu Deye)**: výkon je v režimu zero-export **řiditelný** (střídač umí výrobu stáhnout až k nule, pokud není odběr a baterie už nemůže nabíjet). Při BLOCK_EXPORT tedy dává smysl „zakázat export“ přes **reg 145 = 0** Deye zamezí přetokům z těchto stringů.\n+- **GEN port (AC coupling / mikroinvertory / ongrid GEN)**: výkon **nelze plynule omezovat**. Pole vyrábí „co dá slunce“ a pokud ho **nespotřebuje dům + EV/TČ + baterie**, přebytek fyzicky teče do sítě.\n+ - U instalací typu **BA81** je proto k dispozici jen **tvrdý cut-off** (reg 179 bits01).\n+ - U **malé baterie** (např. BA81 ~6 kW max charge a navíc při vysokém SoC ještě méně) může při plném osvitu často nastat přebytek i při BLOCK_EXPORT a bez cut-off by šel do sítě.\n+ - Naopak při **malém osvitu / velké spotřebě** jsou „každé watty z GEN“ užitečné (jít do domu/baterie) a cut-off by zbytečně zahodil výrobu.\n+
- **PV1/PV2 (hlavní stringy na DC vstupu Deye)**: výkon je v režimu zero-export **řiditelný** (střídač umí výrobu stáhnout až k nule, pokud není odběr a baterie už nemůže nabíjet). Při BLOCK_EXPORT tedy dává smysl „zakázat export“ přes **reg 145 = 0** Deye zamezí přetokům z těchto stringů.
- **GEN port (AC coupling / mikroinvertory / ongrid GEN)**: výkon **nelze plynule omezovat**. Pole vyrábí „co dá slunce“ a pokud ho **nespotřebuje dům + EV/TČ + baterie**, přebytek fyzicky teče do sítě.
- U instalací typu **BA81** je proto k dispozici jen **tvrdý cut-off** (reg 179 bits01).
- U **malé baterie** (např. BA81 ~6 kW max charge a navíc při vysokém SoC ještě méně) může při plném osvitu často nastat přebytek i při BLOCK_EXPORT a bez cut-off by šel do sítě.
- Naopak při **malém osvitu / velké spotřebě** jsou „každé watty z GEN“ užitečné (jít do domu/baterie) a cut-off by zbytečně zahodil výrobu.
Z toho plyne: **cut-off GEN portu je smysluplné řídit podle očekávaného přebytku**, ne jen podle „sell < 0“. Detail návrhu implementace je v `docs/04-modules/planning.md` (sekce o GEN portu a export banu).
**SELF_SUSTAIN:** `battery_w = None` ⇒ v `get_deye_mode` jako 0 ⇒ **PASSIVE**; v `write_inverter_setpoints` při `self_sustain_local_use=True`**108 i 109 = max** (bez variant ZERO výše), reg. **142** dle DB, TOU SOC = **`min_soc_percent`**. **PRESERVE:** `lock_battery`**108 = 0**, **109 = 0**.

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:**
@@ -30,6 +33,7 @@
- **Záporná prodejní cena → tvrdý zákaz vývozu (`ge = 0`)** (`planning_engine.solve_dispatch`): platí ve slotu kde `sell_price < 0`, pokud lokality zapne některou z opcí —
- **`asset_inverter.deye_gen_microinverter_cutoff_enabled`** (`deye-main`) — spojeno s MILP binárkami GEN cut-off (BA81),
- **nebo** **`ems.site_grid_connection.block_export_on_negative_sell`** (migrace **V074**, default **false**) — bez GEN registrů na Deye; vhodné např. pro **KV1** (fixní nákup, bez nutnosti vést výkon neriťitelného pole B do sítě). **home-01** nech **false**, jinak může být horizont při přebytku z pole B a plné/nedostupné baterii **infeasible** (solver export potřebuje jako fyzikální ventil tam, kde FVE B nelze štípnout ani odpojit modelem bez GEN řádku).
- **Export bez forecastového capu:** solver ukládá explicitní `planning_interval.export_limit_w` jako tvrdý site/inverter limit a `planning_interval.export_mode` (`NONE` / `PV_SURPLUS` / `BATTERY_SELL`). Exportér z plánu neodvozuje žádný forecastový strop exportu.
- **Uložené vstupy plánu** (`planning_interval`): `load_baseline_w`, `pv_*_forecast_raw_w`, `pv_*_forecast_solver_w` pro UI a audit.
- **Více FVE polí s různou orientací:** `planning_engine._load_slots` sčítá predikovaný výkon za 15min přes **všechna** `asset_pv_array` dané lokality — `pv_a_forecast_w` = součet řádků s `controllable = true`, `pv_b_forecast_w` = součet s `controllable = false`. Pro každé pole a slot se bere **nejnovější** `forecast_pv_run` (`ORDER BY created_at DESC`, `DISTINCT ON (pv_array_id)`). Curtailment v LP zůstává **jedno** agregované `pv_a` (součet řiditelných polí); per-string curtailment by vyžadovalo rozšíření modelu.
- **Kalibrace PV forecastu (delta profil):** tabulka `ems.site_pv_forecast_calibration` drží per `site_id` mimo jiné `delta_learn_min_ts` (dolní mez řádků z `forecast_accuracy` pro učení delty), volitelně `pv_curtailment_policy_effective_from` a přepsání parametrů (`top_n_days`, `half_life_days`, …; **V076** navíc `reference_day_weight_mult` pro „připnuté“ dny níže). **`ems.site_pv_forecast_reference_day`** (**V076**) umožňuje zvýšit váhu konkrétních kalendářních dnů (datum ve `site.timezone` jako u časování slotů) při agregaci δ z `forecast_accuracy` (`fn_pv_forecast_delta_profile`); hromadný zápis **`ems.fn_pv_forecast_sync_reference_days`**, detail **`docs/04-modules/forecast.md`**. `ems.fn_fill_forecast_accuracy` nastavuje `learning_eligible` / `learning_exclude_reason` (sloty před cutoffem, nebo se škrcením / gen cut-off / záznamem v `ems.cutoff_switch_log` po účinnosti policy se z učení vyřadí; u škrcení zůstává `actual_power_w` NULL). Telemetrie: `ems.telemetry_inverter.is_export_limited` nebo `pv_derating_flags <> 0` v okně 15min → stejné vyloučení (`telemetry_derating`). `ems.fn_pv_forecast_delta_profile` vrací `deltas_by_array` i součtové `deltas`; `ems.fn_load_planning_slots_full` aplikuje stejnou **per-pole** korekci jako UI (`fn_forecast_pv_slots_range_corrected`); pokud v JSON profilu chybí `deltas_by_array`, použije se souhrnné `deltas` rozpuštěné podle podílu výkonu pole na slotu (solver má tak stále použitou korekci i bez per-pole JSON).
@@ -421,6 +425,8 @@ def solve_dispatch(
battery_setpoint_w = round(pulp.value(batt_charge[t]) - pulp.value(batt_discharge[t])),
battery_soc_target = round(pulp.value(soc[t]) / battery.usable_capacity_wh * 100, 1),
grid_setpoint_w = round(pulp.value(grid_import[t]) - pulp.value(grid_export[t])),
export_limit_w = int(grid.max_export_power_w) if round(pulp.value(grid_import[t]) - pulp.value(grid_export[t])) < 0 else 0,
export_mode = "BATTERY_SELL" if round(pulp.value(batt_charge[t]) - pulp.value(batt_discharge[t])) < 0 and round(pulp.value(grid_import[t]) - pulp.value(grid_export[t])) < 0 else ("PV_SURPLUS" if round(pulp.value(grid_import[t]) - pulp.value(grid_export[t])) < 0 else "NONE"),
ev_charge_power_w = round(pulp.value(ev_charge[t])),
heat_pump_enabled = hp_enabled,
heat_pump_setpoint_w = hp_power,
@@ -495,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 (bez plánovaného nabíjení z baterie) používají typicky **`108` i `109` na max** z invertoru; přebytek do sítě řeší **142/145** a firmware, ne umělé **108 = 0** (to dřív matlo měnič jako „baterie plná“)
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` (jen ve fyzickém **SELL** — aktivní výdej baterie do sítě; u **PASSIVE** + přetoku FVE už **108** typicky **max**)
## 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.
---

View File

@@ -84,6 +84,12 @@ const LiveRegistersSection = memo(
valueText={live?.reg142_limit_control != null ? String(live.reg142_limit_control) : undefined}
/>
<Metric label="Energy mode" reg={141} valueText={live?.reg141_energy_mode != null ? String(live.reg141_energy_mode) : undefined} />
<Metric
label="Export cap"
reg={143}
sub="Hard limit lokality / invertoru; neforecastuje se"
valueText={fmtW(live?.reg143_export_limit_w)}
/>
<Metric
label="Peak shaving switch"
reg={178}

View File

@@ -221,6 +221,8 @@ function syntheticForecastOnlyInterval(
battery_setpoint_w: null,
battery_soc_target_pct: null,
grid_setpoint_w: null,
export_limit_w: null,
export_mode: null,
deye_physical_mode: null,
ev1_setpoint_w: null,
ev2_setpoint_w: null,
@@ -341,6 +343,7 @@ function axiosDetail(e: unknown): string {
function deyeSetpointLabel(i: PlanningIntervalDto): string {
const battery_w = i.battery_setpoint_w ?? 0
const grid_w = i.grid_setpoint_w ?? 0
const exportLimitW = i.export_limit_w ?? 0
const tgt = i.battery_soc_target_pct
const targetSoc = tgt != null ? Math.min(95, Math.round(Number(tgt))) : 80
@@ -353,14 +356,18 @@ function deyeSetpointLabel(i: PlanningIntervalDto): string {
const pm = (i.deye_physical_mode ?? '').toString().trim().toUpperCase()
if (pm === 'SELL') {
const tpPowerW = Math.abs(battery_w)
return `SELL | ⬇ ${fmtKw(tpPowerW)} | reg142=0 reg178=32`
const cap = exportLimitW > 0 ? ` | cap ${fmtKw(exportLimitW)}` : ''
return `SELL | ⬇ ${fmtKw(tpPowerW)}${cap} | reg142=0 reg178=32`
}
if (pm === 'CHARGE') {
return `CHARGE | ⬆ ${fmtKw(Math.max(0, battery_w))} | grid=yes | SOC→${targetSoc}%`
}
// PASSIVE (ZERO): doplň informaci o variantě 108/109 podle pravidel (bez wattových prahů).
if (grid_w < 0 && battery_w >= 0) return 'PASSIVE | FVE→síť (108=0)'
if (grid_w < 0 && battery_w >= 0) {
const cap = exportLimitW > 0 ? ` | cap ${fmtKw(exportLimitW)}` : ''
return `PASSIVE | FVE→síť${cap} (108=0)`
}
if (grid_w > 0 && battery_w <= 0) return 'PASSIVE | držet bat. (109=0)'
return 'PASSIVE | max/max'
}
@@ -369,12 +376,15 @@ function deyeModeBadge(i: PlanningIntervalDto): { label: string; klass: string;
const m = (i.deye_physical_mode ?? 'PASSIVE').toString().trim().toUpperCase()
const battery_w = i.battery_setpoint_w ?? 0
const grid_w = i.grid_setpoint_w ?? 0
const exportLimitW = i.export_limit_w ?? 0
const exportMode = (i.export_mode ?? 'NONE').toString().trim().toUpperCase()
const cap = exportLimitW > 0 ? `; hard cap ${formatPlanPowerW(exportLimitW)}` : ''
if (m === 'SELL') {
return {
label: 'SELL',
klass: 'bg-orange-500/15 text-orange-200 ring-1 ring-orange-500/35',
title: 'SELL (selling first): reg142=0, reg178=32 (grid peak shaving off)',
title: `SELL (selling first): reg142=0, reg178=32 (grid peak shaving off)${cap}`,
}
}
if (m === 'CHARGE') {
@@ -386,12 +396,14 @@ function deyeModeBadge(i: PlanningIntervalDto): { label: string; klass: string;
}
let variant = 'max/max'
if (grid_w < 0 && battery_w >= 0) variant = 'FVE→síť (108=0)'
if (grid_w < 0 && battery_w >= 0) {
variant = exportMode === 'PV_SURPLUS' ? 'FVE→síť' : 'export'
}
else if (grid_w > 0 && battery_w <= 0) variant = 'držet bat. (109=0)'
return {
label: 'PASSIVE',
klass: 'bg-slate-600/40 text-slate-200 ring-1 ring-slate-500/30',
title: `PASSIVE (ZERO): ${variant}; reg142=deye_zero_export_mode; reg178=48`,
title: `PASSIVE (ZERO): ${variant}${cap}; reg142=deye_zero_export_mode; reg178=48`,
}
}
@@ -539,6 +551,8 @@ function PlanTooltip({
const fveStr = formatPlanPowerW(p.pv_a_w)
const fveDisplay = fveStr === '—' ? '—' : fveStr.includes('kW') ? fveStr : `${fveStr} W`
const soc = p.battery_soc_target_pct
const exportLimit = i.export_limit_w
const exportMode = i.export_mode ?? 'NONE'
return (
<div className="rounded-lg border border-slate-600 bg-slate-950 px-3 py-2 text-xs text-slate-200 shadow-xl">
<div className="mb-1 font-medium text-slate-100">{formatLocal(i.interval_start)}</div>
@@ -556,6 +570,12 @@ function PlanTooltip({
{sell != null ? `${sell.toFixed(3)} Kč/kWh` : '—'}
</div>
<div>FVE (korig. předpověď / audit): {fveDisplay}</div>
{exportMode !== 'NONE' ? (
<div>
Export: {exportMode}
{exportLimit != null ? ` · limit ${formatPlanPowerW(exportLimit)}` : ''}
</div>
) : null}
<div>SoC cíl: {soc != null && !Number.isNaN(Number(soc)) ? `${Number(soc).toFixed(1)} %` : '—'}</div>
<div>Dům: {i.load_baseline_w ?? '—'} W</div>
<div>Baterie: {i.battery_setpoint_w ?? '—'} W</div>

View File

@@ -15,6 +15,10 @@ export type PlanningIntervalDto = {
battery_setpoint_w: number | null
battery_soc_target_pct: number | null
grid_setpoint_w: number | null
/** Tvrdý limit exportu do sítě v daném slotu (W); 0 = bez exportu. */
export_limit_w?: number | null
/** Záměr exportu: NONE / PV_SURPLUS / BATTERY_SELL. */
export_mode?: 'NONE' | 'PV_SURPLUS' | 'BATTERY_SELL' | null
/** Explicitní fyzický režim Deye pro slot (PASSIVE/SELL/CHARGE). */
deye_physical_mode?: 'PASSIVE' | 'SELL' | 'CHARGE' | null
/** True = solver plánuje odpojit GEN port (MI export cutoff) v tomto slotu (BA81). */