From f8e1eed127780a8561269e7814ff5528d2cb7cbd Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Sun, 19 Apr 2026 15:29:58 +0200 Subject: [PATCH] fix rs485 s eror self_sustain --- backend/services/control_exporter.py | 109 ++++++++++++++++++--- backend/services/notification_service.py | 36 +++++++ backend/tests/test_control_exporter_tou.py | 27 +++++ docs/04-modules/control.md | 9 ++ docs/04-modules/modbus-command-journal.md | 14 ++- docs/04-modules/modbus-registers.md | 6 +- 6 files changed, 179 insertions(+), 22 deletions(-) diff --git a/backend/services/control_exporter.py b/backend/services/control_exporter.py index 17155cb..e0a5fbb 100644 --- a/backend/services/control_exporter.py +++ b/backend/services/control_exporter.py @@ -40,10 +40,50 @@ 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 +# Po 3 neúspěšných verify pokusech → SELF_SUSTAIN jen u těchto registrech (bezpečnost / export). +# 62–64 řeší toleranční bundle (nemění režim). 178 a TOU power W jsou „soft“ — jen log + Discord. +DEYE_CRITICAL_REGS_SELF_SUSTAIN = frozenset({108, 109, 142, 143, 145}) +# Výkonové řádky TOU (154 + slot_index 0…5) — firmware často přepíše na max W z max_charge/max_discharge A. +DEYE_TOU_POWER_REGS = frozenset(range(154, 160)) +# Deye LV: firmware často odmítne 351 A a drží 350 — horní strop pro zápis z DB. +DEYE_LV_BATTERY_MAX_CHARGE_DISCHARGE_A = 350 + def _deye_reg178_verify_match(expected_i: int, actual_i: int) -> bool: return (int(expected_i) & REG178_VERIFY_MASK) == (int(actual_i) & REG178_VERIFY_MASK) + +def deye_reg_triggers_self_sustain_after_verify_exhaust(reg: int) -> bool: + """True = po 3× mismatch přepnout lokalitu do SELF_SUSTAIN (kritický registr).""" + return int(reg) in DEYE_CRITICAL_REGS_SELF_SUSTAIN + + +def _deye_tou_power_verify_match( + expected_i: int, actual_i: int, inv: InverterConfig +) -> bool: + """Firmware často clampne TOU power W na max z reg. 108/109 × 51.2 V — akceptovat jako OK.""" + if int(actual_i) == int(expected_i): + return True + # 51.2 V — nesmí int(BATT_VOLTAGE_V)==51 (off-by-one vs. firmware 17920 W @ 350 A) + max_w_charge = int(inv.max_charge_a * BATT_VOLTAGE_V) + max_w_discharge = int(inv.max_discharge_a * BATT_VOLTAGE_V) + a = int(actual_i) + return a == max_w_charge or a == max_w_discharge + + +def _deye_reg178_verify_with_double_read( + expected_i: int, actual_first: int, actual_second: int | None +) -> tuple[bool, int]: + """ + Vrátí (shoda, hodnota_pro_journal). + Druhé čtení použít jen když první neprojde maskou (RS485 / glitch). + """ + if _deye_reg178_verify_match(expected_i, actual_first): + return True, actual_first + if actual_second is not None and _deye_reg178_verify_match(expected_i, actual_second): + return True, int(actual_second) + return False, actual_first + # Neaktivní TOU bloky (3–6): „konec dne“ — Deye často 23:59 (2359) neuloží a vrátí např. 2355, # verify pak hlásí mismatch. 23:55 je na zařízeních stabilní (viz HHMM jako desítkové číslo). DEYE_TOU_INACTIVE_HHMM = 2355 @@ -679,10 +719,13 @@ async def verify_modbus_commands( ) -> bool: """ Přečte registry zpět (FC 3 po souvislých blocích) a porovná s value_to_write. - Při mismatch: retry → SELF_SUSTAIN + Discord. + Při mismatch: retry (až 3×). Po vyčerpání pokusů u kritických registrů (108, 109, 142, 143, 145) + → SELF_SUSTAIN + Discord; u „soft“ (178, TOU power W) jen log + Discord, režim se nemění. """ from services.notification_service import notify_modbus_mismatch + inv_cfg = await _load_inverter_config(site_id, db) + async def _apply_verify_result( cmd: asyncpg.Record, actual_i: int, @@ -732,7 +775,33 @@ async def verify_modbus_commands( expected_i = int(cmd["value_to_write"]) matches = actual_i == expected_i if reg == 178: - matches = _deye_reg178_verify_match(expected_i, actual_i) + first_178 = int(actual_i) + second_178: int | None = None + if not _deye_reg178_verify_match(expected_i, first_178): + try: + r178 = await client.read_holding_registers(178, 1, unit) + if r178 and len(r178) >= 1: + second_178 = int(r178[0]) + except Exception as e: + logger.warning( + "[cmd %s] reg178 double-read failed: %s", cmd_id, e + ) + matches, actual_i = _deye_reg178_verify_with_double_read( + expected_i, first_178, second_178 + ) + if ( + matches + and second_178 is not None + and not _deye_reg178_verify_match(expected_i, first_178) + ): + logger.info( + "[cmd %s] reg178 double-read recovered: first=%s second=%s", + cmd_id, + first_178, + second_178, + ) + if not matches and reg in DEYE_TOU_POWER_REGS and inv_cfg is not None: + matches = _deye_tou_power_verify_match(expected_i, actual_i, inv_cfg) await db.execute( """ @@ -777,18 +846,27 @@ async def verify_modbus_commands( await execute_modbus_commands([cmd_id], db) await verify_modbus_commands([cmd_id], db, site_id) else: - logger.critical( - "[cmd %s] 3 failed attempts, switching to SELF_SUSTAIN", - cmd_id, - ) - await _switch_to_self_sustain( - site_id, - db, - reason=( - f"Modbus mismatch po 3 pokusech: {cmd['asset_code']} " - f"reg 0x{reg:04X}" - ), - ) + if deye_reg_triggers_self_sustain_after_verify_exhaust(reg): + logger.critical( + "[cmd %s] 3 failed attempts, switching to SELF_SUSTAIN", + cmd_id, + ) + await _switch_to_self_sustain( + site_id, + db, + reason=( + f"Modbus mismatch po 3 pokusech: {cmd['asset_code']} " + f"reg 0x{reg:04X}" + ), + ) + else: + logger.warning( + "[cmd %s] 3 failed verify attempts on non-critical reg 0x%04X " + "(no mode change): %s", + cmd_id, + reg, + cmd["asset_code"], + ) return False if reg == 178 and actual_i != expected_i: @@ -1009,6 +1087,9 @@ async def _load_inverter_config( md = row["max_discharge_a"] max_charge_a = int(mc) if mc is not None else 0 max_discharge_a = int(md) if md is not None else 0 + # Firmware Deye často drží max 350 A — vyšší hodnota z DB → mismatch 351 vs 350. + max_charge_a = min(max_charge_a, DEYE_LV_BATTERY_MAX_CHARGE_DISCHARGE_A) + max_discharge_a = min(max_discharge_a, DEYE_LV_BATTERY_MAX_CHARGE_DISCHARGE_A) port = int(row["port"] or 502) uid = int(row["unit_id"] if row["unit_id"] is not None else 1) return InverterConfig( diff --git a/backend/services/notification_service.py b/backend/services/notification_service.py index 109d427..cd33395 100644 --- a/backend/services/notification_service.py +++ b/backend/services/notification_service.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import logging from datetime import datetime @@ -40,6 +41,32 @@ async def notify_operating_mode_changed( await send_discord(msg, level=lvl) +async def _auto_rolling_replan_after_self_sustain_exit(site_id: int) -> None: + """Po návratu z SELF_SUSTAIN do AUTO přepočítat rolling plán (nové DB spojení).""" + try: + from app.deps import get_pg_pool + from services.planning_engine import run_plan_api + + pool = await get_pg_pool() + except Exception as e: + logger.warning("Auto replan after SELF_SUSTAIN→AUTO: pool unavailable: %s", e) + return + try: + async with pool.acquire() as replan_conn: + await run_plan_api( + site_id, + "rolling", + replan_conn, + triggered_by="mode:self_sustain_exit", + ) + except Exception as e: + logger.warning( + "Auto rolling replan after SELF_SUSTAIN→AUTO failed: %s", + e, + exc_info=True, + ) + + async def run_fn_set_mode_with_discord( conn: asyncpg.Connection, site_id: int, @@ -84,6 +111,15 @@ async def run_fn_set_mode_with_discord( notes, level=notify_level, ) + prev_u = str(prev).upper() + new_u = str(new).upper() + if prev_u == "SELF_SUSTAIN" and new_u == "AUTO": + try: + asyncio.get_running_loop().create_task( + _auto_rolling_replan_after_self_sustain_exit(site_id) + ) + except RuntimeError: + logger.debug("No event loop; skip auto rolling replan") return str(new) diff --git a/backend/tests/test_control_exporter_tou.py b/backend/tests/test_control_exporter_tou.py index c95849f..4134f1c 100644 --- a/backend/tests/test_control_exporter_tou.py +++ b/backend/tests/test_control_exporter_tou.py @@ -3,11 +3,15 @@ from __future__ import annotations import unittest +from dataclasses import replace from services.control_exporter import ( ControlSetpoints, InverterConfig, + _deye_reg178_verify_with_double_read, _deye_tou_params, + _deye_tou_power_verify_match, + deye_reg_triggers_self_sustain_after_verify_exhaust, get_deye_mode, ) @@ -33,6 +37,29 @@ def _inv(*, min_soc: int | None = 12, reserve_soc: int | None = 20) -> InverterC ) +def _inv_350a() -> InverterConfig: + """350 A × 51.2 V = 17920 W — typický firmware clamp pro TOU power.""" + return replace(_inv(), max_charge_a=350, max_discharge_a=350) + + +class ModbusVerifyPolicyTests(unittest.TestCase): + def test_tou_power_accepts_firmware_max_w_clamp(self) -> None: + inv = _inv_350a() + self.assertTrue(_deye_tou_power_verify_match(7752, 17920, inv)) + self.assertTrue(_deye_tou_power_verify_match(16728, 17920, inv)) + + def test_reg178_double_read_recovers_from_glitch(self) -> None: + ok, v = _deye_reg178_verify_with_double_read(48, 12014, 48) + self.assertTrue(ok) + self.assertEqual(v, 48) + + def test_reg178_not_critical_for_self_sustain(self) -> None: + self.assertFalse(deye_reg_triggers_self_sustain_after_verify_exhaust(178)) + + def test_reg108_critical_for_self_sustain(self) -> None: + self.assertTrue(deye_reg_triggers_self_sustain_after_verify_exhaust(108)) + + class DeyeTouParamsTests(unittest.TestCase): def test_sell_uses_reserve_soc(self) -> None: sp = ControlSetpoints( diff --git a/docs/04-modules/control.md b/docs/04-modules/control.md index ee33ae8..a4efad8 100644 --- a/docs/04-modules/control.md +++ b/docs/04-modules/control.md @@ -139,6 +139,15 @@ Hodnota `deye_zero_export_mode` (1 = zero export to load, 2 = zero export to CT) **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). +### Verifikace zápisů (journal) a SELF_SUSTAIN + +Po zápisu na Modbus se hodnoty ověřují v `verify_modbus_commands` (`control_exporter.py`). Po **3 neúspěšných** cyklech zápis+verify: + +- **Kritické registry** (**108, 109, 142, 143, 145**) → přepnutí lokality do **SELF_SUSTAIN** (`system:mismatch`). +- **Ostatní** (včetně **178** a **TOU power W 154–159** po vyčerpání soft pravidel) → zůstane **AUTO** (nebo aktuální režim), řádek journalu **`mismatch`**, Discord upozornění. + +Při přechodu **SELF_SUSTAIN → AUTO** (`run_fn_set_mode_with_discord`) se na pozadí spustí **rolling replan**, aby aktivní plán odpovídal plné optimalizaci. Viz [`modbus-command-journal.md`](modbus-command-journal.md). + ```python async def write_inverter_setpoints(site_id: int, setpoints: Setpoints, db): inverters = await db.fetch( diff --git a/docs/04-modules/modbus-command-journal.md b/docs/04-modules/modbus-command-journal.md index c24b853..0bcbcac 100644 --- a/docs/04-modules/modbus-command-journal.md +++ b/docs/04-modules/modbus-command-journal.md @@ -24,19 +24,23 @@ Indexy: podle `(site_id, status, created_at)` a částečný index pro `pending` 1. Po `mismatch` se odešle **Discord** alert (`notify_modbus_mismatch`), pokud je nastaven `DISCORD_WEBHOOK_URL`. 2. **Retry** zápisu max. **3×** (počítáno přes `attempt_count` po zápisech). -3. **Reg 178** (grid peak shaving switch): journal ukládá **celé 16bit** `value_to_write` (32 nebo 48). Při ověření se za **shodu** považuje shoda **bitů 4–5** maskou **`0x0030`** — readback může mít jiné ostatní bity (firmware / paralelní čtení). `value_verified` = přečtená surová hodnota; stav **`verified`**, pokud maska sedí s očekáváním. -4. **Pojistka 62–64**: pokud by se řádek registru **62, 63 nebo 64** omylem dostal do striktní větve po jednom registru, verify to zachytí a zpracuje **jako toleranční celek 62–64** (stejně jako primární clock větev) — bez přepnutí do SELF_SUSTAIN jen kvůli tomu. -5. Po třech neúspěšných cyklech ověření: - - **Obyčejné registry** (mimo souvislý blok Deye **62–64**): přepnutí lokality na **SELF_SUSTAIN** přes `run_fn_set_mode_with_discord` → `ems.fn_set_mode` (`activated_by` = `system:mismatch`, poznámka = důvod). Při skutečné změně `mode_code` jde na Discord **kritická** zpráva (stejný formát jako u ostatních přepnutí režimu). +3. **Reg 178** (grid peak shaving switch): journal ukládá **celé 16bit** `value_to_write` (32 nebo 48). Při ověření se za **shodu** považuje shoda **bitů 4–5** maskou **`0x0030`** s očekáváním; `value_verified` = přečtená surová hodnota. Při nesouladu masky se **jednou** znovu přečte reg. 178 (druhé FC3) kvůli glitchům na RS485 — pokud druhé čtení maskou sedí, stav je **`verified`**. +4. **TOU výkon W (154–159):** firmware často vrátí **max. výkon z reg. 108/109 × 51.2 V** místo přesně zapsaného W; verify to akceptuje jako **shodu** (skutečný výkon je stejně omezen proudy 108/109). +5. **Pojistka 62–64**: pokud by se řádek registru **62, 63 nebo 64** omylem dostal do striktní větve po jednom registru, verify to zachytí a zpracuje **jako toleranční celek 62–64** (stejně jako primární clock větev) — bez přepnutí do SELF_SUSTAIN jen kvůli tomu. +6. Po třech neúspěšných cyklech ověření: + - **Kritické Deye registry** (**108, 109, 142, 143, 145**): přepnutí lokality na **SELF_SUSTAIN** přes `run_fn_set_mode_with_discord` → `ems.fn_set_mode` (`activated_by` = `system:mismatch`, poznámka = důvod). Při skutečné změně `mode_code` jde na Discord **kritická** zpráva (stejný formát jako u ostatních přepnutí režimu). + - **Nekritické / soft registry** (např. **178** po vyčerpání druhého čtení, **154–159** bez akceptovaného clampu, ostatní mimo výše uvedené kritické): po 3 pokusech zůstane řádek v **`mismatch`**, jde **Discord** (`notify_modbus_mismatch`), **režim se nemění**. - **Výjimka — systémový čas 62–64:** přepnutí režimu **se neprovádí**. Po 3 neúspěšných ověřeních jde **kritický** Discord (`notify_modbus_clock_verify_exhausted`); střídač a EMS režim zůstávají v aktuálním stavu (čas na sběrnici může vyžadovat ruční kontrolu / firmware). +**Po návratu SELF_SUSTAIN → AUTO** (přes `fn_set_mode`): `notification_service` naplánuje na pozadí **rolling replan** (`run_plan_api`, `triggered_by=mode:self_sustain_exit`), aby aktivní plán odpovídal znovu plné optimalizaci v AUTO. + **Baseline po deployi (operativa):** např. počet přepnutí na SELF_SUSTAIN z verify za poslední 2 dny: `SELECT count(*) FROM ems.site_operating_mode_log WHERE mode_code = 'SELF_SUSTAIN' AND activated_by = 'system:mismatch' AND activated_at >= now() - interval '2 days';` Pro diagnostiku času Deye po opravě clock logiky používej u `modbus_command` krátké okno (např. `verified_at >= now() - interval '2 days'`). **Discord při jakékoli změně režimu** (nejen Modbus): `notification_service.run_fn_set_mode_with_discord` volá `ems.fn_set_mode` a při změně `mode_code` oproti stavu před voláním pošle zprávu (`notify_operating_mode_changed`). Úroveň: `user:api` → info, obecné `system:*` → warning, `system:mismatch` → critical. Použití: HTTP `POST /api/v1/sites/{site_id}/mode`, `_switch_to_self_sustain` v `control_exporter`. Vypršení `valid_until`: `ems.fn_expire_modes()` vrací řádky `(site_id, site_code, old_mode, new_mode)` pro každé provedené přepnutí; scheduler v `main.py` (a lazy expire v `_fetch_operating_mode`) z nich pošle Discord. -Implementace: `services/control_exporter.py` — `verify_modbus_commands`, `_verify_deye_clock_written_bundle`, `_fetch_written_deye_clock_commands`, `_switch_to_self_sustain`; `services/notification_service.py` — `run_fn_set_mode_with_discord`, `notify_operating_mode_changed`. +Implementace: `services/control_exporter.py` — `verify_modbus_commands`, `_verify_deye_clock_written_bundle`, `_fetch_written_deye_clock_commands`, `_switch_to_self_sustain`, `DEYE_CRITICAL_REGS_SELF_SUSTAIN`, `_deye_tou_power_verify_match`; `services/notification_service.py` — `run_fn_set_mode_with_discord`, `_auto_rolling_replan_after_self_sustain_exit`, `notify_operating_mode_changed`. ## Střídač (Deye) diff --git a/docs/04-modules/modbus-registers.md b/docs/04-modules/modbus-registers.md index 1a5fc55..ee3acf2 100644 --- a/docs/04-modules/modbus-registers.md +++ b/docs/04-modules/modbus-registers.md @@ -12,8 +12,8 @@ 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 | EMS počítá proud v **SQL**: `COALESCE(deye_register_max_charge_a, FLOOR(LEAST(W)/51.2))` — sloupec stropu v **A** je volitelný (NULL = jen odvod z kW); při vyplnění např. 350 při W→351 A se použije 350. | -| 109 | Max discharge current | 0 … **max dle modelu** (manuál Deye) | 1 A | Stejně: `COALESCE(deye_register_max_discharge_a, FLOOR(LEAST(W)/51.2))`. | +| 108 | Max charge current | 0 … **max dle modelu** (manuál Deye) | 1 A | EMS počítá proud v **SQL**: `COALESCE(deye_register_max_charge_a, FLOOR(LEAST(W)/51.2))` — sloupec stropu v **A** je volitelný (NULL = jen odvod z kW); při vyplnění např. 350 při W→351 A se použije 350. V Pythonu se navíc **clampuje horní strop 350 A** (`DEYE_LV_BATTERY_MAX_CHARGE_DISCHARGE_A`), aby firmware nevracel 350 při zápisu 351. | +| 109 | Max discharge current | 0 … **max dle modelu** (manuál Deye) | 1 A | Stejně: `COALESCE(deye_register_max_discharge_a, FLOOR(LEAST(W)/51.2))` + **clamp 350 A** jako u 108. | | 128 | Grid charge current | 0 … **max dle modelu** (manuál Deye) | 1 A | Nabíjení ze sítě | | 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) | @@ -38,7 +38,7 @@ EMS zapisuje řídící hodnoty přes journal (`modbus_command`) a **`write_regi EMS **nezapisuje** read-modify-write (paralelní čtení jinými klienty může způsobit nesoulad). -**Ověření v journalu (`modbus_command`):** u zápisu **178** se při verify porovnávají jen **bity 4–5** maskou **`0x0030`** s očekávanou hodnotou (32/48); `value_verified` zůstává plný readback. Detail: `modbus-command-journal.md`. +**Ověření v journalu (`modbus_command`):** u zápisu **178** se při verify porovnávají jen **bity 4–5** maskou **`0x0030`** s očekávanou hodnotou (32/48); `value_verified` zůstává plný readback. Při nesouladu masky následuje **druhé FC3 čtení** reg. 178 (mitigace RS485 glitchů). U **TOU výkonu W (154–159)** verify akceptuje i readback **`max_charge_a × 51.2`** nebo **`max_discharge_a × 51.2`**, pokud firmware hodnotu přepíše na interní maximum (skutečný výkon je stejně omezen reg. 108/109). Detail: `modbus-command-journal.md`. ## Klíčové registry podle fyzického režimu Deye