diff --git a/backend/services/control_exporter.py b/backend/services/control_exporter.py index a716ad4..e70bcb4 100644 --- a/backend/services/control_exporter.py +++ b/backend/services/control_exporter.py @@ -34,6 +34,12 @@ BATT_VOLTAGE_V = 51.2 # Reg 178 – pevné hodnoty (bit4–5); bez read-modify-write (kolize s Loxone / transaction ID) REG178_SELL = 0b00100000 # 32, grid peak shaving disable REG178_PASSIVE = 0b00110000 # 48, grid peak shaving enable (PASSIVE i CHARGE) +# Verify: jen bity 4–5 (horní byte layout v dokumentaci); ostatní bity mohou mít firmware / Loxone +REG178_VERIFY_MASK = 0x0030 + + +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) # 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). @@ -90,6 +96,17 @@ def battery_watts_to_amps(power_w: int, max_amps: int) -> int: return min(max(0, max_amps), max(0, round(abs(power_w) / BATT_VOLTAGE_V))) +def _effective_battery_current_caps(inv: InverterConfig) -> tuple[int, int]: + """Efektivní stropy pro reg 108/109 po zohlednění volitelných Deye limitů v DB.""" + ca = int(inv.max_charge_a) + da = int(inv.max_discharge_a) + if inv.deye_register_max_charge_a is not None: + ca = min(ca, int(inv.deye_register_max_charge_a)) + if inv.deye_register_max_discharge_a is not None: + da = min(da, int(inv.deye_register_max_discharge_a)) + return ca, da + + def current_slot_hhmm() -> int: """Začátek probíhajícího 15min slotu v Europe/Prague, formát HHMM (např. 1415).""" now = datetime.now(ZoneInfo("Europe/Prague")) @@ -129,6 +146,8 @@ class InverterConfig: usable_capacity_wh: int | None max_charge_a: int max_discharge_a: int + deye_register_max_charge_a: int | None = None + deye_register_max_discharge_a: int | None = None deye_last_system_time_sync_minute: datetime | None = None deye_last_system_time_sync_at: datetime | None = None deye_last_tou_inactive_write_prague_date: date | None = None @@ -664,30 +683,78 @@ async def verify_modbus_commands( """ from services.notification_service import notify_modbus_mismatch - async def _apply_verify_result(cmd: asyncpg.Record, actual_i: int) -> bool: + async def _apply_verify_result( + cmd: asyncpg.Record, + actual_i: int, + *, + client: Any, + unit: int, + ) -> bool: """Vrátí True při shodě, False při mismatch (a obslouží retry / SELF_SUSTAIN).""" + reg = int(cmd["register"]) cmd_id = int(cmd["id"]) + + if reg in DEYE_CLOCK_REGS: + asset_id = int(cmd["asset_id"]) + host = str(cmd["device_host"]) + port_i = int(cmd["device_port"]) + uid = int(cmd["device_unit_id"]) + bundle = await _fetch_written_deye_clock_commands( + site_id, asset_id, host, port_i, uid, db + ) + if not bundle: + bundle = [cmd] + try: + cvals = await client.read_holding_registers(62, 3, uid) + except Exception as e: + logger.error( + "verify clock guard read 62–64 failed (reg 0x%04X): %s", reg, e + ) + return False + if len(cvals) != 3: + logger.error( + "verify clock guard: expected 3 regs, got %s", len(cvals) + ) + return False + logger.warning( + "Clock register 0x%04X reached strict verify path; using tolerant 62–64 bundle", + reg, + ) + return await _verify_deye_clock_written_bundle( + site_id, + bundle, + int(cvals[0]), + int(cvals[1]), + int(cvals[2]), + db, + ) + expected_i = int(cmd["value_to_write"]) + matches = actual_i == expected_i + if reg == 178: + matches = _deye_reg178_verify_match(expected_i, actual_i) + await db.execute( """ UPDATE ems.modbus_command SET value_verified=$1::int, verified_at=now(), - status=CASE WHEN $1::int = $2::int THEN 'verified' ELSE 'mismatch' END + status=CASE WHEN $2::boolean THEN 'verified' ELSE 'mismatch' END WHERE id=$3::int """, actual_i, - expected_i, + matches, cmd_id, ) - if actual_i != expected_i: + if not matches: logger.error( - "[cmd %s] MISMATCH %s 0x%04X: expected=%s actual=%s", + "[cmd %s] MISMATCH %s 0x%04X: expected=%s actual=%s%s", cmd_id, cmd["asset_code"], - int(cmd["register"]), + reg, expected_i, actual_i, + " (reg178 mask 0x%04X)" % REG178_VERIFY_MASK if reg == 178 else "", ) row_ac = await db.fetchrow( "SELECT attempt_count FROM ems.modbus_command WHERE id=$1", cmd_id @@ -695,7 +762,7 @@ async def verify_modbus_commands( attempts = int(row_ac["attempt_count"] or 0) if row_ac else 0 await notify_modbus_mismatch( cmd["asset_code"], - int(cmd["register"]), + reg, cmd["register_name"] or "", expected_i, actual_i, @@ -719,18 +786,28 @@ async def verify_modbus_commands( db, reason=( f"Modbus mismatch po 3 pokusech: {cmd['asset_code']} " - f"reg 0x{cmd['register']:04X}" + f"reg 0x{reg:04X}" ), ) return False - logger.info( - "[cmd %s] verified OK: %s 0x%04X=%s", - cmd_id, - cmd["asset_code"], - int(cmd["register"]), - actual_i, - ) + if reg == 178 and actual_i != expected_i: + logger.info( + "[cmd %s] verified OK (reg178 masked): %s 0x%04X value_to_write=%s actual=%s", + cmd_id, + cmd["asset_code"], + reg, + expected_i, + actual_i, + ) + else: + logger.info( + "[cmd %s] verified OK: %s 0x%04X=%s", + cmd_id, + cmd["asset_code"], + reg, + actual_i, + ) return True cmds: list[asyncpg.Record] = [] @@ -807,7 +884,9 @@ async def verify_modbus_commands( all_ok = False continue for cmd, actual in zip(run, values): - matched = await _apply_verify_result(cmd, int(actual)) + matched = await _apply_verify_result( + cmd, int(actual), client=client, unit=unit + ) if not matched: all_ok = False @@ -891,6 +970,8 @@ async def _load_inverter_config( ai.deye_last_system_time_sync_at, ai.deye_last_tou_inactive_write_prague_date, ai.deye_tou_inactive_signature, + ai.deye_register_max_charge_a, + ai.deye_register_max_discharge_a, LEAST( COALESCE(ab.bms_max_charge_w, ai.max_battery_charge_w), ai.max_battery_charge_w @@ -954,6 +1035,12 @@ async def _load_inverter_config( else None, max_charge_a=max_charge_a, max_discharge_a=max_discharge_a, + deye_register_max_charge_a=int(row["deye_register_max_charge_a"]) + if row["deye_register_max_charge_a"] is not None + else None, + deye_register_max_discharge_a=int(row["deye_register_max_discharge_a"]) + if row["deye_register_max_discharge_a"] is not None + else None, deye_last_system_time_sync_minute=row["deye_last_system_time_sync_minute"], deye_last_system_time_sync_at=row["deye_last_system_time_sync_at"], deye_last_tou_inactive_write_prague_date=row[ @@ -1197,12 +1284,17 @@ def get_deye_mode(setpoints: ControlSetpoints) -> str: def _deye_tou_params( setpoints: ControlSetpoints, inv: InverterConfig, + *, + max_charge_a_cap: int | None = None, + max_discharge_a_cap: int | None = None, ) -> tuple[int, int, bool]: """ Parametry jednoho Deye time pointu: výkon W, SOC min %, grid_charge. Musí odpovídat logice get_deye_mode / lock_battery v write_inverter_setpoints. """ - max_batt_w_discharge = int(inv.max_discharge_a * BATT_VOLTAGE_V) + md = int(max_discharge_a_cap) if max_discharge_a_cap is not None else int(inv.max_discharge_a) + mc = int(max_charge_a_cap) if max_charge_a_cap is not None else int(inv.max_charge_a) + max_batt_w_discharge = int(md * BATT_VOLTAGE_V) tp_discharge_w = 0 if setpoints.lock_battery else max_batt_w_discharge tou_min = _deye_tou_min_soc_pct(inv) tou_reserve = _deye_tou_reserve_soc_pct(inv) @@ -1214,7 +1306,7 @@ def _deye_tou_params( 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)) - tp_charge_w = battery_watts_to_amps(battery_w, inv.max_charge_a) * int(BATT_VOLTAGE_V) + tp_charge_w = battery_watts_to_amps(battery_w, mc) * int(BATT_VOLTAGE_V) return tp_charge_w, target_soc, True if deye_mode == "SELL": return tp_discharge_w, tou_reserve, False @@ -1236,7 +1328,8 @@ async def write_inverter_setpoints( grid_w = int(setpoints_now.grid_setpoint_w or 0) no_export = inv.no_export export_lim = _deye_reg143_export_w(no_export, inv.max_export_power_w) - max_batt_w_discharge = int(inv.max_discharge_a * BATT_VOLTAGE_V) + eff_ca, eff_da = _effective_battery_current_caps(inv) + max_batt_w_discharge = int(eff_da * BATT_VOLTAGE_V) tp_discharge_w = 0 if setpoints_now.lock_battery else max_batt_w_discharge tou_min_pct = _deye_tou_min_soc_pct(inv) tou_reserve_pct = _deye_tou_reserve_soc_pct(inv) @@ -1251,11 +1344,11 @@ async def write_inverter_setpoints( discharge_a = 0 elif deye_mode == "CHARGE": battery_w = int(raw_bat) if raw_bat is not None else 0 - charge_a = battery_watts_to_amps(battery_w, inv.max_charge_a) + charge_a = battery_watts_to_amps(battery_w, eff_ca) discharge_a = 0 else: - charge_a = int(inv.max_charge_a) - discharge_a = int(inv.max_discharge_a) + charge_a = int(eff_ca) + discharge_a = int(eff_da) selling_mode = 0 if deye_mode == "SELL" else 1 export_limit = export_lim @@ -1303,8 +1396,12 @@ async def write_inverter_setpoints( sp_tp2 = setpoints_next if setpoints_next is not None else setpoints_now hh_cur = current_slot_hhmm() hh_nxt = next_slot_hhmm() - p1, s1, g1 = _deye_tou_params(setpoints_now, inv) - p2, s2, g2 = _deye_tou_params(sp_tp2, inv) + p1, s1, g1 = _deye_tou_params( + setpoints_now, inv, max_charge_a_cap=eff_ca, max_discharge_a_cap=eff_da + ) + p2, s2, g2 = _deye_tou_params( + sp_tp2, inv, max_charge_a_cap=eff_ca, max_discharge_a_cap=eff_da + ) registers.extend(_deye_time_point_rows(0, hh_cur, p1, s1, g1)) registers.extend(_deye_time_point_rows(1, hh_nxt, p2, s2, g2)) diff --git a/db/migration/V044__deye_register_max_current_a.sql b/db/migration/V044__deye_register_max_current_a.sql new file mode 100644 index 0000000..dfa14f6 --- /dev/null +++ b/db/migration/V044__deye_register_max_current_a.sql @@ -0,0 +1,9 @@ +-- Volitelný tvrdý strop proudu pro Modbus reg 108/109 (Deye může firmwarem oříznout pod W-odvozeným max, např. 351→350 A). +ALTER TABLE ems.asset_inverter + ADD COLUMN IF NOT EXISTS deye_register_max_charge_a INT NULL, + ADD COLUMN IF NOT EXISTS deye_register_max_discharge_a INT NULL; + +COMMENT ON COLUMN ems.asset_inverter.deye_register_max_charge_a IS + 'Optional cap for holding reg 108 (A); NULL = use only LEAST(W)/51.2 derived max.'; +COMMENT ON COLUMN ems.asset_inverter.deye_register_max_discharge_a IS + 'Optional cap for holding reg 109 (A); NULL = use only derived max.'; diff --git a/docs/04-modules/modbus-command-journal.md b/docs/04-modules/modbus-command-journal.md index d56f830..c24b853 100644 --- a/docs/04-modules/modbus-command-journal.md +++ b/docs/04-modules/modbus-command-journal.md @@ -24,10 +24,16 @@ 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. Po třech neúspěšných cyklech ověření: +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). - **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). +**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`. @@ -42,7 +48,7 @@ Implementace: `services/control_exporter.py` — `verify_modbus_commands`, `_ver | Job | Frekvence | Popis | |-----|-----------|--------| -| `verify_modbus` | každé **2 min** | Pro každou aktivní site vybere `written` příkazy s `written_at` v posledních **10 min** a zavolá `verify_modbus_commands`. | +| `verify_modbus` | každé **2 min** | Pro každou aktivní site vybere `written` příkazy s `written_at` v posledních **20 min** a zavolá `verify_modbus_commands`. | ## Ruční API @@ -60,6 +66,6 @@ Tabulka pro budoucí logování **cut-off** přepínačů (mikroinvertory / GEN ## Související soubory -- Migrace: `db/migration/V023__modbus_command_journal.sql`, `V025__deye_physical_mode.sql`, `V030__deye_clock_sync_at.sql`; repeatables `db/routines/R__fn_set_mode.sql` (`fn_expire_modes` vrací detail přepnutí pro notifikace) +- Migrace: `db/migration/V023__modbus_command_journal.sql`, `V025__deye_physical_mode.sql`, `V030__deye_clock_sync_at.sql`, `V044__deye_register_max_current_a.sql`; repeatables `db/routines/R__fn_set_mode.sql` (`fn_expire_modes` vrací detail přepnutí pro notifikace) - Backend: `backend/services/control_exporter.py`, `backend/services/modbus_client.py`, `backend/services/notification_service.py`, `backend/app/main.py` - Registry Deye: `docs/04-modules/modbus-registers.md` diff --git a/docs/04-modules/modbus-registers.md b/docs/04-modules/modbus-registers.md index 3a14b8a..9620cf8 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 | Limit nabíjení baterie; horní mez není napříč modely stejná (nižší výkonové řady mívají jiný strop než např. SUN-20K) | -| 109 | Max discharge current | 0 … **max dle modelu** (manuál Deye) | 1 A | Limit vybíjení baterie; viz výše | +| 108 | Max charge current | 0 … **max dle modelu** (manuál Deye) | 1 A | Limit nabíjení baterie; horní mez není napříč modely stejná (nižší výkonové řady mívají jiný strop než např. SUN-20K). Volitelně **`asset_inverter.deye_register_max_charge_a`**: tvrdý strop v **A** pro zápis do registru (firmware může oříznout pod hodnotou odvozenou z W/51,2, např. 351→350). | +| 109 | Max discharge current | 0 … **max dle modelu** (manuál Deye) | 1 A | Limit vybíjení baterie; viz výše. Obdobně **`deye_register_max_discharge_a`**. | | 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) | @@ -37,6 +37,8 @@ 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`. + ## Klíčové registry podle fyzického režimu Deye Provozní režimy EMS (AUTO, SELF_SUSTAIN, SELL, …) se mapují na **tři fyzické režimy** střídače: **PASSIVE**, **SELL**, **CHARGE**. Ostatní je politika solveru / EMS, ne samostatný „režim“ invertoru. @@ -64,7 +66,7 @@ Vychází z **`grid_setpoint_w`** a **`battery_w`** z `ControlSetpoints` (aktivn Režim **CHARGE_CHEAP** v EMS nastaví `grid_setpoint_w` tak, aby platila podmínka importu (> 200 W), jinak by fyzicky zůstal PASSIVE. -Všechny limity (`max_charge_a`, `max_discharge_a`, `max_export_power_w` / reg 143) pocházejí **výhradně z DB** (`_load_inverter_config`). +Limity `max_charge_a` / `max_discharge_a` (odvozené z W a BMS) a volitelné stropy **`deye_register_max_charge_a` / `deye_register_max_discharge_a`** pocházejí z DB (`_load_inverter_config`, migrace **V044**). `max_export_power_w` / reg 143 také z DB. ## Time Points – řízení podle fyzického režimu