Compare commits
8 Commits
7711640a4b
...
refactor-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a3a49806b | ||
|
|
b35f292295 | ||
|
|
6471467bc5 | ||
|
|
ba53fe5bfc | ||
|
|
335c413232 | ||
|
|
bcb05d4896 | ||
|
|
405e832f8d | ||
|
|
e8eb867a2a |
279
.cursor/plans/planner-battery-tuning_ae42fae3.plan.md
Normal file
279
.cursor/plans/planner-battery-tuning_ae42fae3.plan.md
Normal file
@@ -0,0 +1,279 @@
|
||||
---
|
||||
name: planner-battery-tuning
|
||||
overview: Opravíme nesoulad mezi plánem a zápisem do Deye při nabíjení z FVE přebytku, doplníme SQL-first vstupy pro denní safety charge, aplikujeme je v LP jako soft penalty a uložíme debug snapshot každého běhu planneru.
|
||||
todos:
|
||||
- id: fix-deye-passive-charge
|
||||
content: Opravit Deye PASSIVE překlad tak, aby plánované nabíjení z FVE přebytku nezapsalo reg108=0.
|
||||
status: completed
|
||||
- id: add-planner-debug-snapshot
|
||||
content: Ukládat ke každému planning_run kompaktní debug JSON do solver_params se sekcemi inputs, masks, soc_bounds, objective_terms a chosen_slots.
|
||||
status: pending
|
||||
- id: prevent-charge-deferral
|
||||
content: Doplnit near-term commitment / soft target před drahým sell oknem, aby rolling replan neodkládal nabíjení bez ekonomické náhrady.
|
||||
status: pending
|
||||
- id: add-daytime-safety-charge
|
||||
content: Spočítat safety-charge vstupy v SQL, předat je do LP a aplikovat jako měkkou penalizaci deficitu proti noční energii.
|
||||
status: pending
|
||||
- id: add-regression-test
|
||||
content: Přidat regresní testy pro PV surplus charge + současný net export a pro neodkládání nabíjení při receding horizon.
|
||||
status: completed
|
||||
- id: tune-small-site-terminal-soc
|
||||
content: Po debug ověření upravit parametry BA81/KV1 cíleně; nezačínat slepým přepsáním `planner_terminal_soc_value_factor` na 0.9.
|
||||
status: cancelled
|
||||
- id: update-docs
|
||||
content: Aktualizovat dokumentaci control/planning a ověřovací MCP dotazy.
|
||||
status: completed
|
||||
- id: verify
|
||||
content: Spustit testy/validaci a sepsat očekávané MCP ověření po deployi.
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# Stabilizace plánovače baterie
|
||||
|
||||
## Cíl
|
||||
Opravit tři související problémy:
|
||||
|
||||
- Plán někdy chce nabíjet baterii z PV přebytku, ale Deye dostane `reg108 = 0`, takže fyzicky nenabíjí.
|
||||
- Rolling replan umí posouvat plánované nabíjení dál a dál, až levné PV okno uteče.
|
||||
- Malé baterie BA81/KV1 potřebují robustní denní nabití pro noc, ale zároveň nesmí ztratit schopnost ekonomicky cyklovat a prodávat v opravdu drahých sell oknech.
|
||||
|
||||
## Datové zjištění
|
||||
- `BA81` = site `3`, `KV1` = site `4`, `home-01` = site `2`.
|
||||
- KV1 run `8101` pro slot 17:15 plánoval `battery_setpoint_w = 4737` W, `grid_setpoint_w = -13` W, `deye_physical_mode = PASSIVE`; `modbus_command` následně zapsal a ověřil Deye `register = 108`, `value_to_write = 0`. To je konkrétní bug v control exportu.
|
||||
- BA81 historie rolling runů ukazuje posun prvního charge slotu s časem. To je částečně normální receding-horizon efekt, ale nesmí prodat levný PV přebytek, který je potřeba pro pozdější sell peak nebo noční baseload.
|
||||
- `planner_terminal_soc_value_factor` není jediné řešení. BA81/KV1 mají `0.2`, home-01 má `0.9`; nezvyšovat BA81/KV1 plošně na `0.9`, protože to může vrátit starou neochotu malé baterie cyklovat.
|
||||
|
||||
## Architektonické rozhodnutí
|
||||
- SQL-first zůstává: výpočet vstupů pro planner patří do SQL funkcí / view.
|
||||
- Safety charge nesmí být hard `allow_charge` maska. SQL má spočítat vstupní hodnoty, LP je použije jako soft penalty v objective.
|
||||
- Debug snapshot ukládat do existujícího `ems.planning_run.solver_params`. Samostatnou tabulku nezavádět v první iteraci.
|
||||
- Hodnota energie v baterii není jedna konstanta: `battery_value = max(future_avoided_buy, future_sell_opportunity) - degradation`, plus samostatný měkký noční buffer.
|
||||
|
||||
## Implementace
|
||||
|
||||
### 1. Oprava Deye exportéru
|
||||
Soubory:
|
||||
- [`backend/services/control/inverter.py`](backend/services/control/inverter.py)
|
||||
- [`backend/services/control/setpoints.py`](backend/services/control/setpoints.py)
|
||||
|
||||
Požadované chování:
|
||||
- Pokud `ControlSetpoints.battery_w > 0`, Deye musí dostat nenulový nabíjecí proud podle `battery_w`, i když `grid_setpoint_w < 0`.
|
||||
- V tomto scénáři zůstává `deye_physical_mode = PASSIVE`, pokud plán explicitně neurčí `CHARGE`. Nejde o grid-charge režim; jde o nabíjení z PV přebytku a současný export zbytku.
|
||||
- `discharge_a` v tomto scénáři nastavit na `0` nebo jinak omezit tak, aby Deye současně nevybíjel baterii.
|
||||
- Existující SELL a PRESERVE chování neměnit.
|
||||
|
||||
Konkrétní místo:
|
||||
- V `write_inverter_setpoints()` je problém v PASSIVE větvi, která přes `_deye_zero_export_amps_for_passive()` vrací `charge_a = 0`, když `grid_w < 0` a `bat_w >= 0`.
|
||||
- Přidej před tuto větev explicitní případ `bat_w > 0`: `charge_a = battery_watts_to_amps(bat_w, inv.max_charge_a)`, `discharge_a = 0`.
|
||||
|
||||
### 2. SQL vstupy pro daytime safety charge
|
||||
Soubory:
|
||||
- [`db/routines/R__063_fn_load_planning_slots_full.sql`](db/routines/R__063_fn_load_planning_slots_full.sql)
|
||||
- případně nová repeatable funkce v [`db/routines`](db/routines)
|
||||
|
||||
Neimplementovat jako hard masku. Nezakazovat / nepovolovat sloty natvrdo jen kvůli safety charge.
|
||||
|
||||
Doplnit SQL výstupy, které Python LP použije:
|
||||
- `night_baseload_target_wh`: kolik Wh je potřeba od večera do dalšího ranního PV okna.
|
||||
- `night_baseload_buffer_wh`: bezpečnostní přirážka, např. procento z cíle.
|
||||
- `safety_soc_target_wh`: doporučený SoC cíl pro slot.
|
||||
- `future_avoided_buy_czk_kwh`: odhad ceny, kterou baterie ušetří, pokud energii necháme pro vlastní spotřebu.
|
||||
- `future_sell_opportunity_czk_kwh`: nejlepší relevantní budoucí sell příležitost v horizontu.
|
||||
- `is_daytime_pv_surplus_slot`: pomocný boolean pro debug a vážení cíle.
|
||||
|
||||
Preferovaný způsob:
|
||||
- Rozšířit `ems.fn_load_planning_slots_full(...)`, protože už je hlavní zdroj slotových vstupů pro `_load_slots()`.
|
||||
- Pokud by rozšíření funkce bylo příliš velké, vytvořit samostatnou `ems.fn_planning_safety_charge_inputs(site_id, from, to, current_soc_wh)` a joinovat podle `interval_start` v SQL/Pythonu.
|
||||
|
||||
Výpočet nočního okna:
|
||||
- Praktická první verze: noc = od lokálního západu / večerního konce PV surplus do dalšího rána, zjednodušeně `20:00-06:00 Europe/Prague`.
|
||||
- Přesnější verze později: od posledního dnešního slotu s významným PV forecastem do prvního zítřejšího slotu s významným PV forecastem.
|
||||
- Pro první implementaci stačí konzervativní a čitelná definice, hlavně ji uložit do debug snapshotu.
|
||||
|
||||
### 3. Rozšíření Python datových tříd a načítání slotů
|
||||
Soubor:
|
||||
- [`backend/services/planning_engine.py`](backend/services/planning_engine.py)
|
||||
|
||||
Upravit `PlanningSlot`:
|
||||
- Přidat volitelná pole pro SQL safety vstupy:
|
||||
- `safety_soc_target_wh: float | None`
|
||||
- `night_baseload_target_wh: float | None`
|
||||
- `night_baseload_buffer_wh: float | None`
|
||||
- `future_avoided_buy_czk_kwh: float | None`
|
||||
- `future_sell_opportunity_czk_kwh: float | None`
|
||||
- `is_daytime_pv_surplus_slot: bool = False`
|
||||
|
||||
Upravit `_load_slots()`:
|
||||
- Načíst nové sloupce ze SQL.
|
||||
- Pokud SQL sloupce dočasně nejsou k dispozici, použít bezpečný fallback `None` / `False`, aby testy starších DB funkcí nespadly.
|
||||
- Nepočítat noční baseload ad-hoc v Pythonu, pokud už SQL funkce hodnotu vrací.
|
||||
|
||||
### 4. LP objective: soft safety target
|
||||
Soubor:
|
||||
- [`backend/services/planning_engine.py`](backend/services/planning_engine.py)
|
||||
|
||||
Přidat do `solve_dispatch()`:
|
||||
- Pro každý slot `t` s `safety_soc_target_wh is not None` vytvořit spojitou proměnnou `safety_deficit_wh[t] >= 0`.
|
||||
- Přidat omezení:
|
||||
- `safety_deficit_wh[t] >= safety_soc_target_wh[t] - soc[t]`
|
||||
- Přidat do objective penalizaci:
|
||||
- `safety_deficit_wh[t] * safety_penalty_czk_per_wh[t]`
|
||||
|
||||
Výpočet penalty:
|
||||
- `battery_value_czk_kwh = max(future_avoided_buy_czk_kwh, future_sell_opportunity_czk_kwh) - degradation_cost_effective`
|
||||
- `safety_penalty_czk_per_wh = max(0, battery_value_czk_kwh) / 1000`
|
||||
- Přidat rozumný clamp, aby penalty nebyla extrémní kvůli vadné ceně.
|
||||
|
||||
Chování:
|
||||
- Pokud je vysoký sell peak ekonomicky lepší než držet energii pro noc, LP smí target porušit a prodat.
|
||||
- Pokud je budoucí nákup drahý, typicky KV1, deficit bude drahý a LP bude energii spíš držet pro vlastní spotřebu.
|
||||
- Toto není hard constraint.
|
||||
|
||||
### 5. Near-term commitment proti deferralu
|
||||
Soubory:
|
||||
- [`backend/services/planning_engine.py`](backend/services/planning_engine.py)
|
||||
- DB čtení z `ems.planning_run` / `ems.planning_interval` přes SQL funkci nebo jednoduchý read model
|
||||
|
||||
Cíl:
|
||||
- Rolling replan nesmí bez náhrady odsunout nejbližší plánované nabíjení z PV přebytku, pokud předchozí aktivní plán pro stejný nebo nejbližší slot chtěl nabíjet.
|
||||
|
||||
První jednoduchá implementace:
|
||||
- Při rolling replanu načíst předchozí aktivní plán pro stejné `site_id`.
|
||||
- Najít nejbližší 1-2 sloty od `replan_from`, kde předchozí plán měl:
|
||||
- `battery_setpoint_w > 500`
|
||||
- `pv_a_forecast_solver_w + pv_b_forecast_solver_w > load_baseline_w`
|
||||
- ideálně `grid_setpoint_w <= 0`
|
||||
- V novém LP pro odpovídající slot přidat soft proměnnou `charge_commitment_shortfall_w[t] >= previous_battery_charge_w - bc[t]`.
|
||||
- Penalizace má být malá, ale nenulová: má zabránit bezdůvodnému odsunu, ne přebít skutečně lepší ekonomiku.
|
||||
- Uložit do debug snapshotu, kdy commitment vznikl a kolik stál.
|
||||
|
||||
Neimplementovat jako hard constraint.
|
||||
|
||||
### 6. Debug snapshot do solver_params
|
||||
Soubory:
|
||||
- [`backend/services/planning_engine.py`](backend/services/planning_engine.py)
|
||||
- [`db/routines/R__037_fn_planning_run_commit.sql`](db/routines/R__037_fn_planning_run_commit.sql)
|
||||
|
||||
Upravit `_save_planning_run()`:
|
||||
- Rozšířit `run_meta` o `solver_params`.
|
||||
- `solver_params` bude JSON serializovatelný dict.
|
||||
|
||||
Upravit `ems.fn_planning_run_commit(...)`:
|
||||
- Při insertu do `ems.planning_run` uložit `solver_params = p_run_meta->'solver_params'`.
|
||||
|
||||
Minimální struktura JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"inputs": {
|
||||
"current_soc_wh": 0,
|
||||
"operating_mode": "AUTO",
|
||||
"battery": {
|
||||
"usable_capacity_wh": 0,
|
||||
"min_soc_wh": 0,
|
||||
"reserve_soc_wh": 0,
|
||||
"degradation_cost_czk_kwh": 0,
|
||||
"planner_terminal_soc_value_factor": 0.2
|
||||
}
|
||||
},
|
||||
"masks": [
|
||||
{
|
||||
"slot": "2026-05-04T15:45:00+00:00",
|
||||
"allow_charge": true,
|
||||
"allow_discharge_export": false
|
||||
}
|
||||
],
|
||||
"soc_bounds": [
|
||||
{
|
||||
"slot": "2026-05-04T15:45:00+00:00",
|
||||
"soc_min_wh": 0,
|
||||
"arb_floor_wh": 0,
|
||||
"soc_panel_min_wh": 0,
|
||||
"safety_soc_target_wh": 0
|
||||
}
|
||||
],
|
||||
"objective_terms": [
|
||||
{
|
||||
"slot": "2026-05-04T15:45:00+00:00",
|
||||
"buy_price": 0,
|
||||
"sell_price": 0,
|
||||
"future_avoided_buy_czk_kwh": 0,
|
||||
"future_sell_opportunity_czk_kwh": 0,
|
||||
"battery_value_czk_kwh": 0,
|
||||
"safety_deficit_penalty_czk_per_wh": 0,
|
||||
"commitment_penalty_czk_per_w": 0
|
||||
}
|
||||
],
|
||||
"chosen_slots": {
|
||||
"charge_commitment": [],
|
||||
"high_sell_windows": [],
|
||||
"night_window": {
|
||||
"start": "2026-05-04T18:00:00+00:00",
|
||||
"end": "2026-05-05T04:00:00+00:00",
|
||||
"target_wh": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Debug read model
|
||||
Soubor:
|
||||
- nová repeatable funkce v [`db/routines`](db/routines), např. `R__086_fn_planning_run_debug.sql`
|
||||
|
||||
Vytvořit `ems.fn_planning_run_debug(p_run_id int)`:
|
||||
- Vrátí jeden `jsonb`.
|
||||
- Obsahuje:
|
||||
- metadata z `planning_run`,
|
||||
- `solver_params`,
|
||||
- intervaly z `planning_interval` pro daný run,
|
||||
- krátký souhrn: první charge slot, první battery export slot, nejdražší sell sloty, největší safety deficit.
|
||||
|
||||
Použití přes MCP:
|
||||
|
||||
```sql
|
||||
select ems.fn_planning_run_debug(8107);
|
||||
```
|
||||
|
||||
### 8. Parametry
|
||||
Nepřepisovat plošně BA81/KV1 na `planner_terminal_soc_value_factor = 0.9`.
|
||||
|
||||
Nové parametry preferovaně v `ems.asset_battery` přes novou migraci:
|
||||
- `planner_daytime_charge_target_enabled boolean default true`
|
||||
- `planner_night_baseload_buffer_percent numeric default 20`
|
||||
- `planner_daytime_charge_price_quantile numeric default 0.70`
|
||||
- `planner_charge_commitment_penalty_czk_kwh numeric default 0.20`
|
||||
|
||||
Pokud je rozsah příliš velký, první iterace může mít konzervativní konstanty v Pythonu, ale plánovaná cílová podoba je DB parametrizace.
|
||||
|
||||
### 9. Testy
|
||||
Najít existující testovací styl v repu a přidat testy co nejblíže dotčeným modulům.
|
||||
|
||||
Povinné scénáře:
|
||||
- Control exporter: `battery_w > 0`, `grid_setpoint_w < 0`, `deye_physical_mode = PASSIVE` vede na `reg108 > 0`, `reg109 = 0`.
|
||||
- Control exporter: SELL režim se nezmění.
|
||||
- Planner safety: malá baterie, PV surplus přes den, noční baseload, pozdější drahý sell slot. LP má nabíjet v rozumně levném PV slotu a neodsunout charge donekonečna.
|
||||
- Planner economics: pokud `sell_now` převyšuje budoucí avoided buy plus degradaci, LP smí porušit safety target a prodat.
|
||||
- Planner economics KV1-like: pokud budoucí buy je drahý a sell není dost vysoký, LP má držet energii pro vlastní spotřebu.
|
||||
|
||||
### 10. Dokumentace
|
||||
Aktualizovat:
|
||||
- [`docs/04-modules/control.md`](docs/04-modules/control.md)
|
||||
- [`docs/04-modules/planning.md`](docs/04-modules/planning.md)
|
||||
|
||||
Dokumentace musí popsat:
|
||||
- rozdíl mezi plánem, Deye fyzickým režimem a registry `108/109`,
|
||||
- PV-surplus charging při současném exportu,
|
||||
- `solver_params` debug snapshot a `fn_planning_run_debug`,
|
||||
- rozdíl mezi hard maskami (`allow_charge`, `allow_discharge_export`) a soft LP penalizacemi,
|
||||
- že `planner_terminal_soc_value_factor` není jediný mechanismus ochrany malé baterie.
|
||||
|
||||
## Ověření
|
||||
- Spustit backend testy pro control a planner.
|
||||
- Spustit Flyway validate lokálně.
|
||||
- Přes MCP ověřit po nasazení:
|
||||
- pro BA81/KV1 sloty s `battery_setpoint_w > 0` a `grid_setpoint_w < 0` má následný `modbus_command.register = 108` hodnotu > 0,
|
||||
- `planning_run.solver_params` není `NULL` a obsahuje `inputs`, `masks`, `soc_bounds`, `objective_terms`, `chosen_slots`,
|
||||
- `select ems.fn_planning_run_debug(<run_id>)` vrací vysvětlitelný JSON,
|
||||
- rolling replan neodkládá nabíjení z levného PV přebytku bez viditelného ekonomického důvodu v debug snapshotu.
|
||||
@@ -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 **62–64** (č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ů 60–499:** 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 10–100 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 62–64**, bloky TOU **1–2** vs **3–6**, 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ů 60–499:** 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 10–100 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 62–64**, bloky TOU **1–2** vs **3–6**, 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 11–12 %, migrace V029 + komentář sloupce).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:00–06:00 projected baseload Wh (fn_load_planning_slots_full)",
|
||||
"target_wh": night0.night_baseload_target_wh,
|
||||
"buffer_wh": night0.night_baseload_buffer_wh,
|
||||
},
|
||||
},
|
||||
}
|
||||
return results, duration_ms, solver_snapshot
|
||||
|
||||
|
||||
# ============================================================
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
67
backend/tests/test_control_deye_passive_pv_charge.py
Normal file
67
backend/tests/test_control_deye_passive_pv_charge.py
Normal 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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
140
backend/tests/test_planning_safety_commitment.py
Normal file
140
backend/tests/test_planning_safety_commitment.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""Měkké safety SoC a rolling charge commitment v solve_dispatch."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
from services.planning_engine import PlanningSlot, solve_dispatch
|
||||
|
||||
|
||||
def _bat(**kwargs: object) -> SimpleNamespace:
|
||||
base = dict(
|
||||
usable_capacity_wh=20_000.0,
|
||||
min_soc_wh=2000.0,
|
||||
arb_floor_wh=4000.0,
|
||||
reserve_soc_wh=4000.0,
|
||||
soc_max_wh=19_000.0,
|
||||
charge_efficiency=0.95,
|
||||
discharge_efficiency=0.95,
|
||||
degradation_cost_czk_kwh=0.1,
|
||||
max_charge_power_w=5000,
|
||||
max_discharge_power_w=5000,
|
||||
planner_terminal_soc_value_factor=0.2,
|
||||
planner_extreme_buy_threshold_czk_kwh=-5.0,
|
||||
planner_discharge_floor_percent=None,
|
||||
planner_discharge_relax_prewindow_slots=8,
|
||||
planner_daytime_charge_target_enabled=True,
|
||||
planner_charge_commitment_penalty_czk_kwh=0.5,
|
||||
)
|
||||
base.update(kwargs)
|
||||
return SimpleNamespace(**base)
|
||||
|
||||
|
||||
def _grid() -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
max_import_power_w=11_000,
|
||||
max_export_power_w=11_000,
|
||||
block_export_on_negative_sell=False,
|
||||
deye_gen_microinverter_cutoff_enabled=False,
|
||||
)
|
||||
|
||||
|
||||
def _hp() -> SimpleNamespace:
|
||||
return SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
|
||||
|
||||
|
||||
def _slot(
|
||||
t0: datetime,
|
||||
idx: int,
|
||||
*,
|
||||
buy: float = 3.0,
|
||||
sell: float = 2.5,
|
||||
pv_a: int = 0,
|
||||
load: int = 1500,
|
||||
safety: float | None = None,
|
||||
fut_buy: float | None = None,
|
||||
fut_sell: float | None = None,
|
||||
) -> PlanningSlot:
|
||||
return PlanningSlot(
|
||||
interval_start=t0 + timedelta(minutes=15 * idx),
|
||||
buy_price=buy,
|
||||
sell_price=sell,
|
||||
pv_a_forecast_w=pv_a,
|
||||
pv_b_forecast_w=0,
|
||||
load_baseline_w=load,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
allow_charge=True,
|
||||
allow_discharge_export=True,
|
||||
safety_soc_target_wh=safety,
|
||||
future_avoided_buy_czk_kwh=fut_buy,
|
||||
future_sell_opportunity_czk_kwh=fut_sell,
|
||||
)
|
||||
|
||||
|
||||
class PlanningSafetyCommitmentTests(unittest.TestCase):
|
||||
def test_solver_snapshot_has_version_and_masks(self) -> None:
|
||||
t0 = datetime(2026, 5, 4, 8, 0, tzinfo=timezone.utc)
|
||||
slots = [_slot(t0, i, buy=2.0, sell=2.0, pv_a=6000, load=1500) for i in range(8)]
|
||||
hp, grid = _hp(), _grid()
|
||||
vehicles = [
|
||||
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0)
|
||||
] * 2
|
||||
res, _ms, snap = solve_dispatch(
|
||||
slots,
|
||||
_bat(),
|
||||
hp,
|
||||
grid,
|
||||
[None, None],
|
||||
vehicles,
|
||||
current_soc_wh=5000.0,
|
||||
current_tuv_temp_c=50.0,
|
||||
operating_mode="AUTO",
|
||||
)
|
||||
self.assertEqual(len(res), 8)
|
||||
self.assertEqual(snap.get("version"), 1)
|
||||
self.assertIn("masks", snap)
|
||||
self.assertEqual(len(snap["masks"]), 8)
|
||||
|
||||
def test_charge_commitment_snapshot_populated(self) -> None:
|
||||
"""Rolling kotva: při předchozím nabíjení z PV se do snapshotu zapíše commitment."""
|
||||
t0 = datetime(2026, 5, 4, 10, 0, tzinfo=timezone.utc)
|
||||
slots = [_slot(t0, i, buy=1.5, sell=1.2, pv_a=8000, load=1000) for i in range(12)]
|
||||
hp, grid = _hp(), _grid()
|
||||
vehicles = [
|
||||
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0)
|
||||
] * 2
|
||||
prev = [None] * 12
|
||||
prev[0] = 4000.0
|
||||
_res1, _, snap1 = solve_dispatch(
|
||||
slots,
|
||||
_bat(),
|
||||
hp,
|
||||
grid,
|
||||
[None, None],
|
||||
vehicles,
|
||||
current_soc_wh=4000.0,
|
||||
current_tuv_temp_c=50.0,
|
||||
operating_mode="AUTO",
|
||||
charge_commitment_prev_w=prev,
|
||||
)
|
||||
self.assertTrue(snap1["chosen_slots"]["charge_commitment"])
|
||||
_res2, _, snap2 = solve_dispatch(
|
||||
slots,
|
||||
_bat(),
|
||||
hp,
|
||||
grid,
|
||||
[None, None],
|
||||
vehicles,
|
||||
current_soc_wh=4000.0,
|
||||
current_tuv_temp_c=50.0,
|
||||
operating_mode="AUTO",
|
||||
charge_commitment_prev_w=None,
|
||||
)
|
||||
self.assertEqual(snap2["chosen_slots"]["charge_commitment"], [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
25
db/migration/V077__planner_safety_charge_asset_battery.sql
Normal file
25
db/migration/V077__planner_safety_charge_asset_battery.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
-- Parametry pro denní „safety charge“ (měkké LP penalizace) a kotvu rolling replanu.
|
||||
|
||||
alter table ems.asset_battery
|
||||
add column if not exists planner_daytime_charge_target_enabled boolean not null default true;
|
||||
|
||||
alter table ems.asset_battery
|
||||
add column if not exists planner_night_baseload_buffer_percent numeric not null default 20;
|
||||
|
||||
alter table ems.asset_battery
|
||||
add column if not exists planner_daytime_charge_price_quantile numeric not null default 0.70;
|
||||
|
||||
alter table ems.asset_battery
|
||||
add column if not exists planner_charge_commitment_penalty_czk_kwh numeric not null default 0.20;
|
||||
|
||||
comment on column ems.asset_battery.planner_daytime_charge_target_enabled is
|
||||
'Zapíná SQL/LP měkké denní cíle SoC (safety) z fn_load_planning_slots_full; ne tvrdé allow_charge masky.';
|
||||
|
||||
comment on column ems.asset_battery.planner_night_baseload_buffer_percent is
|
||||
'Procentní přirážka k odhadu nočního baseload Wh (20 = +20 % k night_baseload_target_wh).';
|
||||
|
||||
comment on column ems.asset_battery.planner_daytime_charge_price_quantile is
|
||||
'Rezervováno pro budoucí výběr „drahých“ oken z cenové distribuce; v1 se v LP nepoužívá.';
|
||||
|
||||
comment on column ems.asset_battery.planner_charge_commitment_penalty_czk_kwh is
|
||||
'Koeficient měkké penalizace (Kč/kWh krátkého nedodržení) proti předchozímu plánu při rolling replanu.';
|
||||
@@ -23,7 +23,8 @@ begin
|
||||
insert into ems.planning_run (
|
||||
site_id, horizon_start, horizon_end, status,
|
||||
run_type, triggered_by, replan_from,
|
||||
soc_at_replan_wh, solver_duration_ms, forecast_correction_factor
|
||||
soc_at_replan_wh, solver_duration_ms, forecast_correction_factor,
|
||||
solver_params
|
||||
) values (
|
||||
p_site_id,
|
||||
p_horizon_start,
|
||||
@@ -39,7 +40,12 @@ begin
|
||||
end,
|
||||
(p_run_meta->>'soc_at_replan_wh')::numeric,
|
||||
(p_run_meta->>'solver_duration_ms')::int,
|
||||
(p_run_meta->>'forecast_correction_factor')::numeric
|
||||
(p_run_meta->>'forecast_correction_factor')::numeric,
|
||||
case
|
||||
when p_run_meta ? 'solver_params' and jsonb_typeof(p_run_meta->'solver_params') = 'object'
|
||||
then p_run_meta->'solver_params'
|
||||
else null::jsonb
|
||||
end
|
||||
)
|
||||
returning id into v_run_id;
|
||||
|
||||
|
||||
@@ -67,7 +67,11 @@ begin
|
||||
)::int,
|
||||
'charge_slot_buffer', ab.charge_slot_buffer,
|
||||
'discharge_slot_buffer', ab.discharge_slot_buffer,
|
||||
'planner_terminal_soc_value_factor', ab.planner_terminal_soc_value_factor
|
||||
'planner_terminal_soc_value_factor', ab.planner_terminal_soc_value_factor,
|
||||
'planner_daytime_charge_target_enabled', coalesce(ab.planner_daytime_charge_target_enabled, true),
|
||||
'planner_night_baseload_buffer_percent', coalesce(ab.planner_night_baseload_buffer_percent, 20::numeric),
|
||||
'planner_daytime_charge_price_quantile', coalesce(ab.planner_daytime_charge_price_quantile, 0.70::numeric),
|
||||
'planner_charge_commitment_penalty_czk_kwh', coalesce(ab.planner_charge_commitment_penalty_czk_kwh, 0.20::numeric)
|
||||
)
|
||||
into v_b
|
||||
from ems.asset_battery ab
|
||||
|
||||
@@ -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,25 +416,97 @@ 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,
|
||||
w.buy_price,
|
||||
w.sell_price,
|
||||
w.is_predicted_price,
|
||||
w.pv_a_forecast_w,
|
||||
w.pv_b_forecast_w,
|
||||
w.load_baseline_w,
|
||||
w.ev1_connected,
|
||||
w.ev2_connected,
|
||||
w.allow_charge,
|
||||
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
|
||||
cross join night_tot nt
|
||||
)
|
||||
select
|
||||
w.slot_ord,
|
||||
w.interval_start,
|
||||
w.buy_price,
|
||||
w.sell_price,
|
||||
w.is_predicted_price,
|
||||
w.pv_a_forecast_w,
|
||||
w.pv_b_forecast_w,
|
||||
w.load_baseline_w,
|
||||
w.ev1_connected,
|
||||
w.ev2_connected,
|
||||
w.allow_charge,
|
||||
w.allow_discharge_export
|
||||
from _ems_plan_slot_wk w
|
||||
order by w.slot_ord;
|
||||
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 00–12 a 12–24 Europe/Prague (polovina budgetu na segment). '
|
||||
'Strop SoC pro výpočet energie k dobití: coalesce(planner_max_soc_percent, max_soc_percent).';
|
||||
'Strop SoC pro výpočet energie k dobití: coalesce(planner_max_soc_percent, max_soc_percent). '
|
||||
'Denní safety vstupy: night_baseload_* (20:00–06:00 Europe/Prague), safety_soc_target_wh (6–19), '
|
||||
'lookahead max buy/sell pro měkké LP penalizace.';
|
||||
|
||||
76
db/routines/R__087_fn_planning_run_debug.sql
Normal file
76
db/routines/R__087_fn_planning_run_debug.sql
Normal file
@@ -0,0 +1,76 @@
|
||||
-- Kompaktní JSON pro diagnostiku jednoho planning_run (MCP / UI).
|
||||
|
||||
create or replace function ems.fn_planning_run_debug(p_run_id int)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
stable
|
||||
as $fn$
|
||||
declare
|
||||
r_run ems.planning_run%rowtype;
|
||||
v_intervals jsonb;
|
||||
v_first_charge timestamptz;
|
||||
v_first_bat_export timestamptz;
|
||||
v_top_sell jsonb;
|
||||
begin
|
||||
select * into r_run from ems.planning_run where id = p_run_id;
|
||||
if not found then
|
||||
return null::jsonb;
|
||||
end if;
|
||||
|
||||
select coalesce(jsonb_agg(to_jsonb(pi.*) order by pi.interval_start), '[]'::jsonb)
|
||||
into v_intervals
|
||||
from ems.planning_interval pi
|
||||
where pi.run_id = p_run_id;
|
||||
|
||||
select pi.interval_start
|
||||
into v_first_charge
|
||||
from ems.planning_interval pi
|
||||
where pi.run_id = p_run_id
|
||||
and coalesce(pi.battery_setpoint_w, 0) > 500
|
||||
order by pi.interval_start
|
||||
limit 1;
|
||||
|
||||
select pi.interval_start
|
||||
into v_first_bat_export
|
||||
from ems.planning_interval pi
|
||||
where pi.run_id = p_run_id
|
||||
and coalesce(pi.battery_setpoint_w, 0) < -500
|
||||
and coalesce(pi.grid_setpoint_w, 0) < 0
|
||||
order by pi.interval_start
|
||||
limit 1;
|
||||
|
||||
select coalesce(
|
||||
jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'interval_start', x.interval_start,
|
||||
'effective_sell_price', x.effective_sell_price
|
||||
)
|
||||
order by x.effective_sell_price desc nulls last
|
||||
),
|
||||
'[]'::jsonb
|
||||
)
|
||||
into v_top_sell
|
||||
from (
|
||||
select pi.interval_start, pi.effective_sell_price
|
||||
from ems.planning_interval pi
|
||||
where pi.run_id = p_run_id
|
||||
order by pi.effective_sell_price desc nulls last
|
||||
limit 3
|
||||
) x;
|
||||
|
||||
return jsonb_build_object(
|
||||
'planning_run', to_jsonb(r_run),
|
||||
'solver_params', r_run.solver_params,
|
||||
'intervals', v_intervals,
|
||||
'summary', jsonb_build_object(
|
||||
'first_charge_slot', to_jsonb(v_first_charge),
|
||||
'first_battery_export_slot', to_jsonb(v_first_bat_export),
|
||||
'top_sell_slots', v_top_sell,
|
||||
'solver_params_version', r_run.solver_params->'version'
|
||||
)
|
||||
);
|
||||
end;
|
||||
$fn$;
|
||||
|
||||
comment on function ems.fn_planning_run_debug(int) is
|
||||
'Jeden jsonb: metadata planning_run, solver_params, všechny planning_interval řádky a krátký summary.';
|
||||
@@ -127,6 +127,8 @@ CREATE TABLE asset_battery (
|
||||
-- planner_max_soc_percent, planner_discharge_floor_percent,
|
||||
-- planner_extreme_buy_threshold_czk_kwh,
|
||||
-- planner_terminal_soc_value_factor
|
||||
-- V077: planner_daytime_charge_target_enabled, planner_night_baseload_buffer_percent,
|
||||
-- planner_daytime_charge_price_quantile, planner_charge_commitment_penalty_czk_kwh
|
||||
);
|
||||
```
|
||||
|
||||
@@ -359,7 +361,7 @@ CREATE TABLE planning_run (
|
||||
horizon_end TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
status TEXT DEFAULT 'draft', -- 'draft', 'approved', 'active', 'superseded'
|
||||
solver_params JSONB,
|
||||
solver_params JSONB, -- po V077: JSON z planning_engine (masks, soc_bounds, objective_terms, …)
|
||||
notes TEXT
|
||||
);
|
||||
```
|
||||
|
||||
@@ -150,9 +150,9 @@ bits 0–1). Detail registrů: [`modbus-registers.md`](modbus-registers.md) (reg
|
||||
|---|---|
|
||||
| **SELL** | `grid_setpoint_w` < 0 **a** `battery_w` < 0 |
|
||||
| **CHARGE** | `battery_w` > 0 **a** `grid_setpoint_w` > 0 |
|
||||
| **PASSIVE (ZERO)** | vše ostatní — reg. **108/109** dle `_deye_zero_export_amps_for_passive` (viz `operating-modes.md`) |
|
||||
| **PASSIVE (ZERO)** | vše ostatní — reg. **108/109** z `deye_battery_charge_discharge_amps()` v `setpoints.py` (volá `write_inverter_setpoints`) |
|
||||
|
||||
**PASSIVE** (AUTO, ZERO): výchozí **108** i **109** = maximum z DB; u exportu bez vybíjení **108 = 0**, u importu bez nabíjení **109 = 0** (`_deye_zero_export_amps_for_passive`). **TOU** z plánu. Reg. **142** = `deye_zero_export_mode`. Reg. **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 0–1). Detail registrů: [`modbus-registers.md`](modbus-registers.md) (reg
|
||||
|
||||
| Registr | Charge | PASSIVE (ZERO) | SELL | Self-consumption |
|
||||
|---|---|---|---|---|
|
||||
| **108** (charge A) | škálo dle `battery_w` | max / **0** (FVE přetok) | **0** | dle varianty |
|
||||
| **109** (discharge A) | **0** | max / **0** (import, držet bat.) | **max z DB** | dle varianty |
|
||||
| **108** (charge A) | škálo dle `battery_w` | max / **0** (export bez 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")
|
||||
|
||||
@@ -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]` až **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 **4–5** pro peak shaving switch: **32** (`0b00100000`, bit4–5 = **10**) v režimu **SELL**; **48** (`0b00110000`, bit4–5 = **11**) v **PASSIVE/CHARGE**. (b) **BA81:** bits **0–1** 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 | 0–16000 | 1 W | Peak shaving na GEN portu |
|
||||
| 191 | Grid peak shaving power | 0–16000 | 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` už **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 bits0–1) 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 10–100**) 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
|
||||
|
||||
|
||||
@@ -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 bits0–1).\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 bits0–1).
|
||||
- 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**.
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
- **Dynamický horizont (jen OTE):** konec plánu z **`ems.fn_planning_horizon_end(site_id, horizon_start)`** (výchozí strop **36 h**, minimum pro rolling **1 h** – obojí jako defaultní argumenty v SQL, úprava přes repeatable migraci). Pomocná `ems.fn_last_effective_ote` vrací konec posledního OTE intervalu. Rolling replan při `NULL` přeskočí; denní plán použije krátký (1 h) fallback v Pythonu. Sloty v solveru jsou bez predikovaných cen v rámci tohoto horizontu.
|
||||
- **Terminal SoC shadow price:** v objective je člen `−(avg_buy_prvních_24h × planner_terminal_soc_value_factor / 1000) × soc[T−1]` (Kč), kde faktor je **`ems.asset_battery.planner_terminal_soc_value_factor`** přes **`ems.fn_planning_site_context`** (default v DB **0.9**); viz sekci *Tuning pro malé baterie* níže. Účel: konec horizontu nemusí končit zbytečně vyprázdněnou baterií (receding horizon).
|
||||
- **Masky `allow_charge` / `allow_discharge_export` (anti-mikrocyklování):** generuje `ems.fn_load_planning_slots_full`. Důležité: pokud rolling replan startuje s baterií na 100 %, `allow_charge` se nesmí stát globálně `false` pro celý horizont – jinak solver nemá motivaci baterii před PV špičkou „uvolnit“ (headroom), protože ji pak nesmí z PV znovu nabít. Aktuálně se v tomto případě `allow_charge` ponechá povolené alespoň pro sloty s `pv_surplus_w > 0`.
|
||||
- **Denní safety charge (měkké LP, ne maska):** `fn_load_planning_slots_full` (**V077+**) vrací navíc odhad nočního baseload Wh (20:00–06:00 Europe/Prague), buffer % z `asset_battery.planner_night_baseload_buffer_percent`, lookahead `future_*_czk_kwh`, volitelný `safety_soc_target_wh` (6–19) a flag `is_daytime_pv_surplus_slot`. `planning_engine.solve_dispatch()` přidá proměnné deficit vůči cíli a penalizaci `max(future_buy, future_sell) − degradace` (clamp), aby šlo prodat ve velmi drahém sell okně i přes deficit. Tvrdé `allow_charge` se kvůli tomu nemění.
|
||||
- **Rolling charge commitment:** při `run_rolling_replan` se z aktivního plánu načtou sloty, kde dříve platilo `battery_setpoint_w > 500`, `pv_a+pv_b > load_baseline`, `grid_setpoint_w ≤ 0`; měkká penalizace proti snížení `bc[t]` oproti předchozímu plánu (`planner_charge_commitment_penalty_czk_kwh` na `asset_battery`). Implementace: `_load_previous_plan_charge_commitment_prev_w`, volitelný argument `charge_commitment_prev_w` u `solve_dispatch()`.
|
||||
- **Debug snapshot:** každý běh ukládá JSON do `ems.planning_run.solver_params` (sekce `version`, `inputs`, `masks`, `soc_bounds`, `objective_terms`, `chosen_slots`) přes `fn_planning_run_commit` (`p_run_meta->'solver_params'`). Read-model: **`select ems.fn_planning_run_debug(<run_id>);`** (`R__087_fn_planning_run_debug.sql`).
|
||||
- **Runtime guard v exportu setpointů (legacy):**
|
||||
- při `AUTO` + `is_predicted_price=true` se na exportní vrstvě vynutí PASSIVE/no-export chování (u nových plánů by `is_predicted_price` v horizontu nemělo nastat).
|
||||
- **Ekonomika baterie:**
|
||||
@@ -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).
|
||||
|
||||
293
docs/04-modules/provozni-rezimy-checklist.md
Normal file
293
docs/04-modules/provozni-rezimy-checklist.md
Normal file
@@ -0,0 +1,293 @@
|
||||
# Provozní režimy EMS - praktický přehled
|
||||
|
||||
Tenhle dokument je zkrácený provozní cheat sheet. Cíl je jednoduchý:
|
||||
|
||||
- rychle poznat, co EMS v daném slotu dělá
|
||||
- umět to porovnat s `planning_interval`
|
||||
- ověřit to na live registrech Deye a ve FE
|
||||
|
||||
## 1. Co je zdroj pravdy
|
||||
|
||||
- EMS provozní režim lokality: `ems.site_operating_mode.mode_code`
|
||||
- aktivní plán: `ems.planning_interval`
|
||||
- fyzická konfigurace invertoru: `asset_inverter` + `site_grid_connection`
|
||||
- live stav Deye: registry 108, 109, 141, 142, 143, 145, 178, 340
|
||||
|
||||
Když něco nesedí, porovnávej vždy v tomto pořadí:
|
||||
|
||||
1. `planning_interval`
|
||||
2. `control/registers` na FE
|
||||
3. `modbus_command` journal
|
||||
|
||||
## 2. EMS režimy lokality
|
||||
|
||||
### AUTO
|
||||
|
||||
Normální provoz. EMS bere sloty z `planning_interval` a podle nich řídí Deye, EV, TČ a signály.
|
||||
|
||||
V AUTO se pak mohou objevit sloty s různým exportním záměrem:
|
||||
|
||||
- `PV_SURPLUS`
|
||||
- `BATTERY_SELL`
|
||||
- `NONE`
|
||||
|
||||
### SELF_SUSTAIN
|
||||
|
||||
Bezpečný provoz bez obchodní logiky.
|
||||
|
||||
- Deye fyzicky běží v PASSIVE
|
||||
- baterie se nechává pro vlastní spotřebu
|
||||
- export je jen nouzový ventil, pokud je potřeba kvůli feasibility
|
||||
|
||||
### CHARGE_CHEAP
|
||||
|
||||
- nabíjení ze sítě
|
||||
- export se nepoužívá
|
||||
- fyzicky CHARGE
|
||||
|
||||
### PRESERVE
|
||||
|
||||
- baterie je uzamčená
|
||||
- žádné nabíjení ani vybíjení
|
||||
- fyzicky PASSIVE
|
||||
|
||||
### MANUAL
|
||||
|
||||
- EMS setpointy nezapisuje
|
||||
- vše je ruční řízení
|
||||
|
||||
## 3. Tvoje 5 provozních archetypů
|
||||
|
||||
### 1. Standardní režim s přetokem
|
||||
|
||||
Co tím myslíme:
|
||||
|
||||
- baterie se normálně nabíjí i vybíjí podle plánu
|
||||
- přetok do sítě je povolený
|
||||
- exportní limit je jen tvrdý site / inverter cap
|
||||
- když je baterie plná, přebytek FVE jde do sítě
|
||||
|
||||
Jak to je v implementaci:
|
||||
|
||||
- `export_mode = PV_SURPLUS`
|
||||
- `export_limit_w = hard cap`
|
||||
- `solar_sell = 1`
|
||||
- `deye_physical_mode = PASSIVE`
|
||||
- v PASSIVE se pro exportní slot (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`
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user