diff --git a/CLAUDE.md b/CLAUDE.md index d958331..41cb6de 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,7 +110,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):** **`export_mode=PV_SURPLUS`** → **108=0**, **109=max**, **142**=`deye_zero_export_mode` (ne selling first); jinak **108/109** dle `deye_battery_charge_discharge_amps` / `_deye_zero_export_amps_for_passive` (import bez vybíjení → **109=0**); **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:** reg **108** EMS **nezapisuje** (selling first = **142**), **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)` **a** `ems.fn_inverter_pv_a_max_w(inverter_id) > 0` (strop z `deye_reg340_max_solar_w`, typ. 32k home-01 / 65k jinde, ne součet Wp; min `deye_reg340_min_solar_w`, home-01 400); hodnota z plánu / curtailu (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):** **`export_mode=PV_SURPLUS`** → reg **108 sleduje charge intent plánu** (fix 2026-06-16): `bat_w>0` → **108=max** (baterka nabere kolik fyzicky zvládne, přebytek **nad nabíjecí rychlost** do sítě — případ „výroba > rychlost baterky", BA81); SoC u maxima (`>= max_soc − BATTERY_CALIB_TOPOFF_MARGIN_PCT`) + přebytek → **108=max** (BMS kalibrace na 100 %); jen `bat_w<=0` daleko od maxima → **108=0** (prodej PV, drž baterku). **109=max**, **142**=`deye_zero_export_mode` (ne selling first); jinak **108/109** dle `deye_battery_charge_discharge_amps` / `_deye_zero_export_amps_for_passive` (import bez vybíjení → **109=0**); **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:** reg **108** EMS **nezapisuje** (selling first = **142**), **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)` **a** `ems.fn_inverter_pv_a_max_w(inverter_id) > 0` (strop z `deye_reg340_max_solar_w`, typ. 32k home-01 / 65k jinde, ne součet Wp; min `deye_reg340_min_solar_w`, home-01 400); hodnota z plánu / curtailu (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. **HARD LIMIT exportu na fakturačním elektroměru — NIKDY nepřekročit.** Překročení rezervovaného exportního výkonu (home-01: 13.5 kW) byť o desetiny kW = smluvní pokuta v řádu desítek tisíc Kč za kW. Jediný bezpečný invariant: **reg 143 (limit na svorkách střídače) <= max_export_power_w (limit ulice) VŽDY** — v nejhorším případě (spotřeba mezi střídačem a CT odpadne) je ulice rovna svorkám. **ZAKÁZÁNO** jakékoli feed-forward navyšování terminálového limitu o měřenou spotřebu (výpadek spotřeby = přestřelení ulice). Vyšší vytěžení smí přinést jedině interní regulace střídače proti CT (firmware smyčka), nikdy náš software s 1min telemetrií a 15min ticky. diff --git a/backend/services/control/inverter.py b/backend/services/control/inverter.py index 7f62dae..4d8fa9b 100644 --- a/backend/services/control/inverter.py +++ b/backend/services/control/inverter.py @@ -91,6 +91,8 @@ async def write_inverter_setpoints( max_discharge_a=int(inv.max_discharge_a), export_mode=setpoints_now.export_mode, export_ban=bool(setpoints_now.export_ban), + current_soc_pct=soc_telemetry, + max_soc_pct=inv.max_soc_percent, ) zero_exp_mode = int(inv.deye_zero_export_mode or 1) diff --git a/backend/services/control/setpoints.py b/backend/services/control/setpoints.py index 7851300..e6468dd 100644 --- a/backend/services/control/setpoints.py +++ b/backend/services/control/setpoints.py @@ -17,6 +17,10 @@ from services.control.models import ControlSetpoints, InverterConfig, OperatingM logger = logging.getLogger(__name__) +#: Tolerance pod max SoC, v rámci níž se v PV přebytku nechá baterka dojet na max +#: (reg 108 = max) kvůli BMS rekalibraci SoC (LiFePO4 potřebuje občas na 100 %). +BATTERY_CALIB_TOPOFF_MARGIN_PCT = 3.0 + def _deye_system_time_register_rows() -> tuple[datetime, list[tuple[int, str, int]]]: """Hodnoty pro reg 62-64 (Europe/Prague); sekundy v reg 64 = 0 (stabilnější zápis).""" @@ -437,12 +441,20 @@ def deye_battery_charge_discharge_amps( max_discharge_a: int, export_mode: str | None = None, export_ban: bool = False, + current_soc_pct: float | None = None, + max_soc_pct: int | None = None, ) -> tuple[int | None, int]: """ Proud nabíjení / vybíjení (reg 108 / 109) pro zápis Deye. - **PV_SURPLUS** (PASSIVE, export FVE): **108 = 0**, **109 = max** — baterie se přes limit - nabíjení neplní, přebytek jde do sítě (142 = zero-export dle instalace, 145 = 1). + **PV_SURPLUS** (PASSIVE, export FVE) — reg 108 SLEDUJE charge intent plánu (fix 2026-06-16): + - `bat_w > 0` (plán chce nabíjet z přebytku) → **108 = max**: baterie nabere kolik fyzicky + zvládne (nabíjecí rychlost), přebytek NAD ni jde do sítě (BA81: výroba 12 kW > rychlost + 6 kW → 6 do baterky, 6 ven). Dřív tvrdě 108=0 i při bat_w>0 → baterka nenabíjela ani + levné ranní PV (control bug). + - kalibrace: SoC u maxima (`>= max_soc − margin`) + přebytek → **108 = max**, ať dojede na + 100 % (BMS rekalibrace SoC). Strop drží Deye max_soc. + - jen „prodej PV a drž baterku" daleko od maxima (`bat_w <= 0`) → **108 = 0**, přebytek ven. PASSIVE + nabíjení bez exportního záměru (`battery_w > 0`, export_mode NONE): **108 = max**. **CHARGE** ze sítě: 108 z `battery_w`. @@ -464,6 +476,16 @@ def deye_battery_charge_discharge_amps( export_ban=export_ban, grid_w=grid_w, ): + # reg 108 sleduje charge intent: nabíjet z přebytku (bat_w>0) nebo dojet na max + # kvůli BMS kalibraci (SoC u maxima + přebytek) → 108 = max; jinak 108 = 0 (přebytek + # ven). Strop SoC drží Deye max_soc, takže 108=max nepřebije nad povolené. + near_full_calib = ( + current_soc_pct is not None + and max_soc_pct is not None + and float(current_soc_pct) >= float(max_soc_pct) - BATTERY_CALIB_TOPOFF_MARGIN_PCT + ) + if bat_w > 0 or near_full_calib: + return int(max_charge_a), int(max_discharge_a) return 0, int(max_discharge_a) if bat_w > 0: return int(max_charge_a), int(max_discharge_a) diff --git a/backend/tests/test_control_deye_passive_pv_charge.py b/backend/tests/test_control_deye_passive_pv_charge.py index bb77bf2..057cc0c 100644 --- a/backend/tests/test_control_deye_passive_pv_charge.py +++ b/backend/tests/test_control_deye_passive_pv_charge.py @@ -1,4 +1,9 @@ -"""PASSIVE + PV_SURPLUS: 108=0 (nepoužívat baterii), 109=max; 142 zůstává zero-export (1/2).""" +"""PASSIVE + PV_SURPLUS: reg 108 sleduje charge intent (fix 2026-06-16). + +bat_w>0 (plán chce nabíjet z přebytku) → 108=max (baterka nabere co zvládne, zbytek ven); +SoC u maxima + přebytek → 108=max (BMS kalibrace na 100 %); jen "prodej PV a drž baterku" +daleko od maxima (bat_w<=0) → 108=0. 109=max, 142 zůstává zero-export (1/2). +""" from __future__ import annotations @@ -23,8 +28,11 @@ class PassivePvSurplusChargeAmpsTests(unittest.TestCase): self.assertEqual(ch, 0) self.assertEqual(dis, 90) - def test_pv_surplus_even_if_lp_shows_positive_battery_w(self) -> None: - """Plán může mít kladný battery_w; exportní záměr je PV_SURPLUS → 108=0.""" + def test_pv_surplus_with_positive_battery_w_charges_at_max(self) -> None: + """Fix 2026-06-16: plán chce nabíjet z přebytku (bat_w>0) → 108=max (ne 0). + + Baterka nabere kolik zvládne, přebytek nad nabíjecí rychlost jde do sítě (BA81). + """ ch, dis = deye_battery_charge_discharge_amps( lock_battery=False, deye_mode="PASSIVE", @@ -36,6 +44,41 @@ class PassivePvSurplusChargeAmpsTests(unittest.TestCase): export_mode="PV_SURPLUS", export_ban=False, ) + self.assertEqual(ch, 100) + self.assertEqual(dis, 100) + + def test_pv_surplus_near_full_tops_off_for_calibration(self) -> None: + """SoC u maxima (97 >= 100-3) + přebytek → 108=max i při bat_w<=0 (BMS kalibrace).""" + 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, + export_mode="PV_SURPLUS", + export_ban=False, + current_soc_pct=97.0, + max_soc_pct=100, + ) + self.assertEqual(ch, 100) + + def test_pv_surplus_sell_hold_far_from_full_zeros_charge(self) -> None: + """Prodej PV a drž baterku daleko od maxima (bat_w<=0, SoC nízko) → 108=0.""" + 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, + export_mode="PV_SURPLUS", + export_ban=False, + current_soc_pct=60.0, + max_soc_pct=100, + ) self.assertEqual(ch, 0) self.assertEqual(dis, 100) diff --git a/docs/04-modules/modbus-registers.md b/docs/04-modules/modbus-registers.md index 1169e36..4e3fb49 100644 --- a/docs/04-modules/modbus-registers.md +++ b/docs/04-modules/modbus-registers.md @@ -68,7 +68,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):** u slotu **`export_mode = PV_SURPLUS`** exporter nastaví **108 = 0** (nabíjecí proud), **109 = max** — baterie nemá kam brát přebytek FVE, jde do sítě při **145 = 1**; **142** zůstává **`deye_zero_export_mode`** (u CT často **2** = zero export k měření zátěže, ne selling first z baterie). Detail: `operating-modes.md`. +**PASSIVE (ZERO):** u slotu **`export_mode = PV_SURPLUS`** reg **108** (nabíjecí proud) **sleduje charge intent plánu** (fix 2026-06-16): `bat_w > 0` → **108 = max** (baterka nabere kolik fyzicky zvládne, přebytek **nad nabíjecí rychlost** jde do sítě při **145 = 1** — řeší případ „výroba > rychlost baterky" na export-omezených i běžných lokalitách); SoC u maxima (`>= max_soc − 3 p.b.`, `BATTERY_CALIB_TOPOFF_MARGIN_PCT`) + přebytek → **108 = max** (BMS rekalibrace na 100 %); jen „prodej PV a drž baterku" daleko od maxima (`bat_w <= 0`) → **108 = 0**. **109 = max**; **142** zůstává **`deye_zero_export_mode`** (u CT často **2**). Dřív tvrdě **108 = 0** i při `bat_w > 0` → baterka nenabíjela ani levné ranní PV (control bug, BA81). Detail: `operating-modes.md`, changelog 2026-06-16. ### BA81: GEN port cut-off (reg 178 bits0–1) z plánu diff --git a/docs/04-modules/operating-modes.md b/docs/04-modules/operating-modes.md index 9c0f9bb..934580d 100644 --- a/docs/04-modules/operating-modes.md +++ b/docs/04-modules/operating-modes.md @@ -4,7 +4,7 @@ - **Žá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`). - **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)** = **142** = `deye_zero_export_mode` (1/2, ne selling first). **PV_SURPLUS:** **108 = 0**, **109 = max** — přebytek FVE do sítě (**145 = 1**), ne do baterie. Jinak **108/109** typicky max; výjimka import bez vybíjení → **109 = 0**. +- **ZERO (PASSIVE)** = **142** = `deye_zero_export_mode` (1/2, ne selling first). **PV_SURPLUS** (fix 2026-06-16): reg **108 sleduje charge intent plánu** — `bat_w > 0` → **108 = max** (baterka nabere kolik fyzicky zvládne, přebytek **nad nabíjecí rychlost** jde do sítě, **145 = 1**); SoC u maxima (`>= max_soc − 3 p.b.`) + přebytek → **108 = max** (BMS kalibrace na 100 %); jen „prodej PV a drž baterku" daleko od maxima (`bat_w <= 0`) → **108 = 0**. **109 = max**. Jinak **108/109** typicky max; výjimka import bez vybíjení → **109 = 0**. - **SELL** = plánovaný export **i** plánované vybíjení (oba záporné) → **142** = selling first, **178** = vypnutý grid peak shaving (32); reg **108** EMS **nemění** (export řídí 142, ne vynucené 0 A). 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`). diff --git a/docs/improvement-backlog-2026-06-14.md b/docs/improvement-backlog-2026-06-14.md index 9b02ce4..dd7f870 100644 --- a/docs/improvement-backlog-2026-06-14.md +++ b/docs/improvement-backlog-2026-06-14.md @@ -70,6 +70,7 @@ Systém řídí produkci přes v2 solver, ale backlog stojí na **jedné tiché | **FastAPI write auth → plný RBAC + PostgREST RLS/JWT** | API-key gate (Tier 1) je dočasná záplata. `ems_anon` read-only na views bez RLS → vidí všechny sites. | Bezpečnost před multi-user produkcí. | RLS policy per site + JWT; `GET /me/sites` filtr. | §2.2 — **autorizační logika/RLS NESMÍ do Pythonu**. **Rozhodnout:** kdy 2. tenant / externí přístup (jinak parkovat, ale Tier 1 API-key gate udělat hned). | | **pgbouncer connection pooling** | `max_connections` (deploy/docker-compose.yml:17 `${POSTGRES_MAX_CONNECTIONS:-100}`) na slabém nočním serveru: skok na 250 = +~1.5 GB RAM (250×~10 MB) → OOM/swap riziko místo občasného timeoutu. | Řeší „remaining connection slots" bez RAM nárůstu. | Zavést pgbouncer; `max_connections` může zůstat nízko. | **Nešvihat tvrdě na 250 na slabém serveru.** Pooling je správné řešení; mezitím ops-checklist: zvednout na 150-180 + sledovat `pg_stat_activity` a RAM. | | **Termo-flex blok (TČ + spirála + bazén)** | TČ reg 74 + spirála + bazén = jeden produktový balík „flexibilní zátěže". | Konzistentní řízení flex zátěží. | Pořadí: TČ zápis (Tier 2) → spirála → bazén. | Žádná sezónní okna (v2 filozofie); každá zátěž opt-in per site config. | +| **Export-constrained lokalita — curtailment-min use-case (TEST)** | Hypotetická lokalita: **malý export limit (~4.5 kW), velký instal (~10 kW)**, konečná baterka. Otestovat, že MILP **drží baterce rezervu na polední peak** (ráno export na limitu + nabíjení zbytkem, baterka se plní pomalu) místo naivního „plná baterka ráno → v poledne se peak curtailuje". Přes kladné ceny: export limit + zbytek do baterky; přes záporné/peak: export off, vše do baterky + curtail zbytku. | Ověření, že na export-omezených sitech **minimalizujeme curtailment vs naivní Deye** (méně „škoda na střeše"); připravenost na 2. typ lokality. | Syntetický golden fixture (bell-curve PV >> export limit, konečná kapacita+rychlost baterky) + assert: `Σ curtailment < naivní baseline` a `SoC nenajede na plno před peakem`. | **Stojí a padá na kvalitě forecastu peaku** (podstřel → málo rezervy → zbytečný curtail; viz PV forecast review — canonical rolling_factor/delta) → **až po něm**. Curtail nad `(export+load+battery_charge_RATE)` je fyzicky nevyhnutelný, ne bug. Reaktivní řez nechat na Deye (CT smyčka), EMS jen strategie (viz [[ems-not-realtime-inverter-battery-buffer]]). v2 filozofie (žádná sezónní okna). | --- diff --git a/docs/planning-changelog.md b/docs/planning-changelog.md index e1e8138..4769e76 100644 --- a/docs/planning-changelog.md +++ b/docs/planning-changelog.md @@ -5,6 +5,14 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen --- +## 2026-06-16 — control: reg 108 v PV_SURPLUS sleduje charge intent (BA81 nenabíjelo levné ráno) + +- **Problém (triáž BA81):** výroba 12 kW (= ~2× nabíjecí rychlost baterky 6 kW), levné ranní výkupní ceny, baterka stála celé ráno na 29 % a vše šlo do sítě; nabíjet začala až odpoledne (dražší). Plán PŘITOM chtěl nabíjet (soc_tgt rostl), ale realita ne → promeškaná levná ranní arbitráž (~0.7 Kč/kWh). NEbyl to forecast (canonical ≈ realita) ani planner — **exekuce.** +- **Příčina:** `deye_battery_charge_discharge_amps` (setpoints.py) v PASSIVE + `export_mode=PV_SURPLUS` vracela tvrdě **`108=0` i když `bat_w>0`** (záměrné, testem podchycené chování — ale chyba pro „výroba > nabíjecí rychlost"). Deye pak prodával vše, baterku nenabil. `get_deye_mode`: `bat_w>0 & grid<0` (export) → PASSIVE, ne CHARGE. +- **Mechanismus (fix):** reg 108 v PV_SURPLUS **sleduje charge intent plánu**: `bat_w>0` → **108=max** (baterka nabere kolik fyzicky zvládne, přebytek nad rychlost do sítě); SoC u maxima (`>= max_soc − 3 p.b.`) + přebytek → **108=max** (BMS rekalibrace na 100 %); jen `bat_w<=0` daleko od maxima → **108=0**. Sell/discharge beze změny (mód + 109, 108 neřešíme — díky DV za korekci). Strop SoC drží Deye max_soc. +- **Soubory:** `setpoints.py` (`deye_battery_charge_discharge_amps` + konstanta `BATTERY_CALIB_TOPOFF_MARGIN_PCT`), `inverter.py` (napojení živého SoC + max_soc), `test_control_deye_passive_pv_charge.py` (vědomě přepsán test starého chování + 2 nové), CLAUDE.md §18, operating-modes.md, modbus-registers.md. +- **Ověření:** plná sada **365 passed, 4 xfailed**. Mimo solver → golden gate beze změny. Platí pro všechny Deye lokality (BA81 i hypotetická malá s nízkým export limitem). + ## 2026-06-14 — EV anti-fragmentace + 3f power floor (Fix B, solver_v2) - **Problém:** EV nabíjení v solveru spojité po slotech bez start/stop penalty → rozsekané přes nesouvislé sloty + dílčí 1f trickle (sub-6A, který control stejně shazoval na 0 A) → cyklování nabíječky, Tesla notifikace.