diff --git a/CLAUDE.md b/CLAUDE.md index 8d60412..5bdd94f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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** z plánu. **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_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`**. 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). diff --git a/backend/services/control/exporter_monolith.py b/backend/services/control/exporter_monolith.py index db4462b..0c8b159 100644 --- a/backend/services/control/exporter_monolith.py +++ b/backend/services/control/exporter_monolith.py @@ -40,9 +40,6 @@ REG143_SELL_CAP_MIN_W = 200 # Ostatní bity zachovat → read-modify-write. REG178_SELL = 0b00100000 # 32, grid peak shaving disable REG178_PASSIVE = 0b00110000 # 48, grid peak shaving enable (PASSIVE i CHARGE) -# TOU reg 166+ ve PASSIVE při prioritě baterie: signál střídači „využij celý dostupný rozsah“, -# ne provozní strop z DB (ten je pro LP / Wh – viz asset_battery.max_soc_percent). -DEYE_TOU_SOC_PASSIVE_BATTERY_PRIORITY_PCT = 100 # Verify: jen bity 4–5 (horní byte layout v dokumentaci); ostatní bity mohou mít firmware / Loxone REG178_VERIFY_MASK = 0x0030 # Reg 178 bits 0–1: MI export cutoff (AC coupling / GEN). @@ -1411,11 +1408,6 @@ def _clamp_deye_tou_soc_pct(pct: int) -> int: return max(5, min(95, pct)) -def _clamp_deye_tou_soc_pct_hi(pct: int, hi: int) -> int: - """Stejné dolní omezení 5 % jako u TOU; horní mez z parametru (např. 100 u priority baterie).""" - return max(5, min(int(hi), int(pct))) - - def _deye_tou_min_soc_pct(inv: InverterConfig) -> int: if inv.min_soc_percent is not None: return _clamp_deye_tou_soc_pct(int(inv.min_soc_percent)) @@ -1429,39 +1421,23 @@ def _deye_tou_reserve_soc_pct(inv: InverterConfig) -> int: def _deye_passive_tou_battery_soc_pct( - inv: InverterConfig, - setpoints: ControlSetpoints, + inv: InverterConfig, _setpoints: ControlSetpoints ) -> int: """ Hodnota SOC u Deye TOU řádku (reg 166+) ve fyzickém PASSIVE. - Na home-01 Deye interpretuje TOU % jako „kam má směřovat využití baterie“: - je-li zapsané procento **nižší než skutečný SoC**, přebytek FVE míří spíš do sítě. + Vždy provozní minimum z DB (**``min_soc_percent``**, clamp 5–95 jako u všech TOU SOC) + — signál „spodní pásmo“ pro firmware, aby baterii šlo použít pro překrytí zátěže bez + snahy o vysoký cílový SoC jen přes TOU. - Při **záporné vykupní** nebo **plánovaném nabíjení** (kladný ``battery_w``) EMS - zapíše **100 %** do TOU (signál střídači „ber přebytek do baterie v celém rozsahu“). - **``max_soc_percent`` v DB** je odděleně: horní limit pro **plánovač / Wh bilance** - (denní provoz, viz komentář sloupce), **nikoli** časové „do kdy“. + Riziko spojené v minulosti s nízkým TOU („přebytek FVE tíhne do sítě“ při nízkém % + oproti skutečnému SoC) řeší **LP**, **145** (**``export_ban``** při záporné vykupní), + řez GEN (**178**) a další páky — ne zvyšování TOU nad **min_soc**. Přímé dobíjení ze + sítě a cílové horní pásmo: větev **CHARGE** v ``_deye_tou_params`` (**``max_soc_percent``**). - Jinak zůstane provozní podlaha ``min_soc_percent`` (typicky nízká % → přetok do sítě - možný dle chování Deye). - - Režim **SELF_SUSTAIN** (``self_sustain_local_use``): vždy ``min_soc_percent`` — nízké - TOU drží prioritu „baterie jako buffer“ při plném reg. 108/109 a reg. 142 zero-export; - neaplikuje se sem logika 100 % podle ceny (LP se v SELF_SUSTAIN nepoužívá). + Argument ``_setpoints`` zůstává kvůli volajícím API; hodnoty z něj PASSIVE SOC nebere. """ - mn = _deye_tou_min_soc_pct(inv) - if setpoints.self_sustain_local_use: - return mn - - bat_w = 0 if setpoints.battery_w is None else int(setpoints.battery_w) - sell = setpoints.effective_sell_price_czk_kwh - want_battery_priority = bat_w > 0 or (sell is not None and float(sell) < 0) - - if not want_battery_priority: - return mn - - return _clamp_deye_tou_soc_pct_hi(DEYE_TOU_SOC_PASSIVE_BATTERY_PRIORITY_PCT, hi=100) + return _deye_tou_min_soc_pct(inv) def _deye_zero_export_amps_for_passive( @@ -1521,7 +1497,8 @@ def _deye_tou_params( ) -> tuple[int, int, bool]: """ Parametry jednoho Deye time pointu: výkon W, SOC % (TOU reg 166+), grid_charge. - Ve PASSIVE viz _deye_passive_tou_battery_soc_pct (min vs. plný max z DB). + Ve PASSIVE: TOU SOC = ``min_soc_percent`` z DB; v CHARGE: horní hraniční SoC = + ``asset_battery.max_soc_percent`` (clamp 10–100). """ max_batt_w_discharge = int(inv.max_discharge_a * BATT_VOLTAGE_V) tp_discharge_w = 0 if setpoints.lock_battery else max_batt_w_discharge @@ -1534,7 +1511,7 @@ def _deye_tou_params( raw_bat = setpoints.battery_w battery_w = int(raw_bat) if raw_bat is not None else 0 cap = int(inv.max_soc_percent) if inv.max_soc_percent is not None else 95 - target_soc = max(10, min(95, cap)) + target_soc = max(10, min(100, cap)) tp_charge_w = ( battery_watts_to_amps(battery_w, int(inv.max_charge_a)) * int(BATT_VOLTAGE_V) ) diff --git a/backend/tests/test_control_exporter_tou.py b/backend/tests/test_control_exporter_tou.py index e8ddefe..e8eb208 100644 --- a/backend/tests/test_control_exporter_tou.py +++ b/backend/tests/test_control_exporter_tou.py @@ -158,8 +158,8 @@ class DeyeTouParamsTests(unittest.TestCase): self.assertFalse(g) self.assertEqual(s, 12) - def test_passive_negative_sell_steers_tou_above_current_soc(self) -> None: - """Záporná vykupní → TOU SOC = 100 % (priorita akumulace vs. přetok).""" + def test_passive_negative_sell_tou_stays_min_soc(self) -> None: + """PASSIVE: záporná vykupní nenastavuje TOU na 100 — zůstává min_soc (145/export_ban řeší síť).""" sp = ControlSetpoints( battery_w=-400, grid_export_limit=0, @@ -175,9 +175,10 @@ class DeyeTouParamsTests(unittest.TestCase): self.assertEqual(get_deye_mode(sp), "PASSIVE") _p, s, g = _deye_tou_params(sp, _inv(min_soc=12, reserve_soc=20)) self.assertFalse(g) - self.assertEqual(s, 100) + self.assertEqual(s, 12) - def test_passive_planned_charge_steers_tou(self) -> None: + def test_passive_planned_pv_charge_tou_stays_min_soc(self) -> None: + """PASSIVE s kladným battery_w bez grid importu: CHARGE to není — TOU je stále min_soc.""" sp = ControlSetpoints( battery_w=800, grid_export_limit=0, @@ -193,7 +194,7 @@ class DeyeTouParamsTests(unittest.TestCase): self.assertEqual(get_deye_mode(sp), "PASSIVE") _p, s, g = _deye_tou_params(sp, _inv(min_soc=12, reserve_soc=20)) self.assertFalse(g) - self.assertEqual(s, 100) + self.assertEqual(s, 12) def test_charge_unchanged_grid_charge(self) -> None: sp = ControlSetpoints( @@ -212,6 +213,24 @@ class DeyeTouParamsTests(unittest.TestCase): self.assertTrue(g) self.assertEqual(s, 95) + def test_charge_target_soc_respects_max_soc_100(self) -> None: + sp = ControlSetpoints( + battery_w=5000, + grid_export_limit=0, + ev1_current_a=0, + ev2_current_a=0, + heat_pump_enable=False, + grid_setpoint_w=5000, + ev1_power_w=0, + ev2_power_w=0, + target_soc_pct=80, + ) + self.assertEqual(get_deye_mode(sp), "CHARGE") + inv = replace(_inv(), max_soc_percent=100) + _p, s, g = _deye_tou_params(sp, inv) + self.assertTrue(g) + self.assertEqual(s, 100) + def test_charge_any_positive_pair_without_w_threshold(self) -> None: sp = ControlSetpoints( battery_w=50, diff --git a/docs/04-modules/control.md b/docs/04-modules/control.md index 11bd597..81f8c49 100644 --- a/docs/04-modules/control.md +++ b/docs/04-modules/control.md @@ -168,11 +168,11 @@ bits 0–1). Detail registrů: [`modbus-registers.md`](modbus-registers.md) (reg | **145** (solar sell) | 1 | 1 | 1 | 1 | | **178** (peak shaving) | 48 | 48 | **32** | 48 | -U **AUTO PASSIVE** závisí **108/109** na znaménkách plánu (viz `operating-modes.md`). **SELF_SUSTAIN** drží oba **max z DB**; liší se **TOU SOC** a `battery_w`. +U **AUTO PASSIVE** závisí **108/109** na znaménkách plánu (viz `operating-modes.md`). **SELF_SUSTAIN** drží oba **max z DB**; **TOU SOC** ve všech PASSIVE větvích je **`min_soc_percent`** (viz `_deye_passive_tou_battery_soc_pct`). Liší se především **`battery_w`** a mapování **108/109**. Hodnota `deye_zero_export_mode` (1 = zero export to load, 2 = zero export to CT) pochází z `asset_inverter.deye_zero_export_mode` a závisí na fyzické instalaci (přítomnost CT). Detail v [`modbus-registers.md`](modbus-registers.md). -**TOU (time points, reg. 166+):** SOC závisí na fyzickém režimu z `get_deye_mode` — **SELL** zapisuje ekonomickou rezervu (`reserve_soc_percent`), **PASSIVE** a neaktivní řádky **3–6** provozní minimum (`min_soc_percent`). Viz [`modbus-registers.md`](modbus-registers.md). +**TOU (time points, reg. 166+):** SOC podle `get_deye_mode` — **CHARGE**: `max_soc_percent` z DB (clamp 10–100), **SELL**: `reserve_soc_percent`, **PASSIVE** + neaktivní řádky **3–6**: **`min_soc_percent`**. Viz [`modbus-registers.md`](modbus-registers.md). ### Verifikace zápisů (journal) a SELF_SUSTAIN diff --git a/docs/04-modules/modbus-registers.md b/docs/04-modules/modbus-registers.md index 4219201..80594f6 100644 --- a/docs/04-modules/modbus-registers.md +++ b/docs/04-modules/modbus-registers.md @@ -85,7 +85,7 @@ Z hlediska `get_deye_mode` je **SELF_SUSTAIN** stále **PASSIVE** (`battery_w` z - **108 / 109** = **max** z invertoru (DB) — plný rozsah nabíjení i vybíjení, aby přebytek FVE mohl do baterie. - **142** = `asset_inverter.deye_zero_export_mode` (**1** = zero export to load, **2** = zero export to CT), stejně jako u ostatního PASSIVE mimo SELL. -- **TOU SOC** (reg 166+) = vždy **`min_soc_percent`** (typicky 12 %) — `_deye_passive_tou_battery_soc_pct` při tomto příznaku **ne** přepíná na 100 % podle vykupní ceny, protože LP se v SELF_SUSTAIN nepoužívá. +- **TOU SOC** (reg 166+) = vždy **`min_soc_percent`** (typicky 12 %) — stejně jako u běžného **AUTO PASSIVE**: akumulace vs. síť řeší plán a **145** / **178**, ne výška TOU %. ### Čtyři typy slotů a mapování na registry @@ -102,9 +102,11 @@ Solver předvybírá sloty pro nabíjení a export-vybíjení (`_select_charge_s | **178** peak shaving | 48 (PASSIVE) | 48 (PASSIVE) | **32** (SELL) | 48 (PASSIVE) | | **143** export limit | max export W z DB | max export W z DB | max export W z DB | max export W z DB | | **141** energy mode | 0 | 0 | 0 | 0 | -| **TOU SOC** (reg 166+) | viz níže | min_soc_pct | reserve_soc_pct | min_soc_pct | +| **TOU SOC** (reg 166+) | **`max_soc_percent`** (clamp 10–100), grid charge ON | **`min_soc_percent`** z DB | reserve_soc_pct | min_soc_pct | -**PASSIVE – TOU SOC % (home-01 / Deye):** EMS ukládá do řádku time pointu procento, které na zařízení řídí **prioritu baterie vs. přetok FVE do sítě** (viz firmware / instalace). Je-li zapsané procento **níž než skutečný SoC**, přebytek tíhne do sítě; při **záporné efektivní vykupní** (`effective_sell_price` ze slotu) nebo při **kladném `battery_setpoint_w`** (plánované nabíjení) EMS nastaví **100 %** (signál „využij baterii naplno“) — **ne** v režimu **SELF_SUSTAIN** (`self_sustain_local_use`), tam je vždy **`min_soc_percent`**. **`asset_battery.max_soc_percent`** (typicky 95) je **jiný účel**: horní limit pro **plánovač / denní provoz v % SoC** (komfort, degradace, rezerva výrobce), **ne** časové „do kdy“ ani hodnota zapisovaná do tohoto TOU při této priorité. Jinak zůstane **`min_soc_percent`**. +**PASSIVE – TOU SOC % (Deye):** EMS zapisuje vždy **`min_soc_percent`** z ``asset_battery`` (clamp jako u všech TOU SOC 5–95). Slouží jako spodní pásmový signál pro firmware; výšku nepoužíváme k řízení „honit akumulaci na 100 %“ ve PASSIVE — to u levného importu řeší **108/109** (viz ``operating-modes.md``), u záporné vykupní **BLOCK_EXPORT** přes **`export_ban`** → **145**, případně **178** na GEN. + +**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:** @@ -147,9 +149,9 @@ Příklad v 14:18: blok 1 má čas **1415**, blok 2 čas **1430** – mezi 14:15 | Režim | Výkon (W) | SOC min (reg 166+) | Grid charge | |-------|-----------|---------------------|-------------| -| **PASSIVE** | `max_discharge_a × 51,2` | `_deye_passive_tou_battery_soc_pct`: při neg. vykupní / plánovaném nabíjení = **100 %**, jinak **`min_soc_percent`** | NE | +| **PASSIVE** | `max_discharge_a × 51,2` | **`min_soc_percent`** z DB (**`_deye_passive_tou_battery_soc_pct`**) | NE | | **SELL** | `max_discharge_a × 51,2` | **`reserve_soc_percent`** z DB | NE | -| **CHARGE** | `battery_watts_to_amps(battery_w, max_charge_a) × 51,2` | min(95, cíl SoC z plánu nebo 80) | ANO | +| **CHARGE** | `battery_watts_to_amps(battery_w, max_charge_a) × 51,2` | **clamp**(10 … **100**, **`asset_battery.max_soc_percent`**) | ANO | Bloky 3–6 používají čas **2355** a stejnou **SOC** hodnotu jako PASSIVE (`min_soc_percent`, grid charge = NE).