fix zapisovani casu
This commit is contained in:
@@ -83,7 +83,7 @@ Multi-site Energy Management System: optimalizuje FVE, baterii a flexibilní zá
|
|||||||
|
|
||||||
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`.
|
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žimy střídače jsou tři:** **PASSIVE**, **SELL**, **CHARGE** (mapování z plánu / politik EMS v `control_exporter.get_deye_mode`). V **PASSIVE** a **SELL** jsou reg **108** / **109** obvykle na **maximum z DB** (**výjimka PRESERVE:** `lock_battery=True` → **0 / 0**). Omezování pod maximum jinak brání Deye reagovat na nepředvídatelnou spotřebu a přebytky FVE. **Řízení:** time points – blok **1** = začátek **aktuálního** 15min slotu + plán pro tento slot, blok **2** = začátek **následujícího** slotu + plán pro něj (`current_slot_hhmm` / `next_slot_hhmm`); bloky **3–6** neaktivní **2355** (ne 23:59 kvůli firmware), zápis **nejednou častěji než 1× denně** (Europe/Prague) + při změně podpisu (`deye_tou_inactive_signature`: `HHMM|min_soc|reserve_soc|tp_discharge_w`, V028 meta + V029 komentář); **reg 166+** u TP: **SELL** = `reserve_soc_percent`, **PASSIVE** / řádky **3–6** = `min_soc_percent`. **108** / **109** / **141** (0) / **142** (0 = selling first jen ve **SELL**, jinak 1) / **178** (pevně **32** ve **SELL**, **48** v **PASSIVE** a **CHARGE** – bez read-modify-write) / **143** (export limit W z DB) z **aktuálního** setpointu. **Reg 191** EMS **nezapisuje**. **Čas 62–64:** před zařazením do fronty **čtení** 62–64; zápis jen při driftu **> 60 s**, nebo **NULL** `deye_last_system_time_sync_at`, nebo uplynulých **24 h** od posledního **ověřeného** syncu (`deye_last_system_time_sync_at` / `deye_last_system_time_sync_minute` se doplňují až po **úspěšné toleranční verifikaci** v `_verify_deye_clock_command_run`, ne po samotném zápisu); při chybě čtení se čas zapisuje; reg **64** se zapisuje s **sekundami 0**; verifikace journalu pro souvislý blok 62–64 je **toleranční** (odchylka dekódovaného času až **120 s**); po 3 neúspěšných ověřeních **bez** přepnutí do SELF_SUSTAIN (jen Discord). **SELL:** `grid_setpoint_w` < −200. **CHARGE:** `battery_w` > 500 a `grid_setpoint_w` > 200. **PASSIVE:** ostatní (včetně `battery_w=None` u SELF_SUSTAIN → plné limity 108/109). Detail: `docs/04-modules/modbus-registers.md`, režimy: `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žimy střídače jsou tři:** **PASSIVE**, **SELL**, **CHARGE** (mapování z plánu / politik EMS v `control_exporter.get_deye_mode`). V **PASSIVE** a **SELL** jsou reg **108** / **109** obvykle na **maximum z DB** (**výjimka PRESERVE:** `lock_battery=True` → **0 / 0**). Omezování pod maximum jinak brání Deye reagovat na nepředvídatelnou spotřebu a přebytky FVE. **Řízení:** time points – blok **1** = začátek **aktuálního** 15min slotu + plán pro tento slot, blok **2** = začátek **následujícího** slotu + plán pro něj (`current_slot_hhmm` / `next_slot_hhmm`); bloky **3–6** neaktivní **2355** (ne 23:59 kvůli firmware), zápis **nejednou častěji než 1× denně** (Europe/Prague) + při změně podpisu (`deye_tou_inactive_signature`: `HHMM|min_soc|reserve_soc|tp_discharge_w`, V028 meta + V029 komentář); **reg 166+** u TP: **SELL** = `reserve_soc_percent`, **PASSIVE** / řádky **3–6** = `min_soc_percent`. **108** / **109** / **141** (0) / **142** (0 = selling first jen ve **SELL**, jinak 1) / **178** (pevně **32** ve **SELL**, **48** v **PASSIVE** a **CHARGE** – bez read-modify-write) / **143** (export limit W z DB) z **aktuálního** setpointu. **Reg 191** EMS **nezapisuje**. **Čas 62–64:** před zařazením do fronty **čtení** 62–64; zápis jen při driftu **> 60 s**, nebo **NULL** `deye_last_system_time_sync_at`, nebo uplynulých **24 h** od posledního syncu; `deye_last_system_time_sync_at` / `deye_last_system_time_sync_minute` po **úspěšném zápisu** 62–64 a znovu po **úspěšné toleranční verifikaci**; při chybě čtení se čas zapisuje; reg **64** se zapisuje s **sekundami 0**; verify **vždy** čte 62–64 najednou — **reg 64 nesmí** do striktní větve; toleranční odchylka až **120 s**; po 3 neúspěších u hodin **bez** SELF_SUSTAIN (jen Discord). **SELL:** `grid_setpoint_w` < −200. **CHARGE:** `battery_w` > 500 a `grid_setpoint_w` > 200. **PASSIVE:** ostatní (včetně `battery_w=None` u SELF_SUSTAIN → plné limity 108/109). Detail: `docs/04-modules/modbus-registers.md`, režimy: `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. **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).
|
||||||
|
|
||||||
@@ -179,6 +179,7 @@ Specifikace z `docs/02-architecture.md`, modulových docs a komentářů v `plan
|
|||||||
| Self-hosted deploy (Gitea, Caddy, `/opt/ems-deploy`) | `docs/deployment-self-hosted.md`, `deploy/deploy.sh` |
|
| Self-hosted deploy (Gitea, Caddy, `/opt/ems-deploy`) | `docs/deployment-self-hosted.md`, `deploy/deploy.sh` |
|
||||||
| Reset DB / restore z dumpu (Docker volume, Timescale) | `docs/database-reset-and-restore.md`, `scripts/import_ems_db.sh` |
|
| Reset DB / restore z dumpu (Docker volume, Timescale) | `docs/database-reset-and-restore.md`, `scripts/import_ems_db.sh` |
|
||||||
| Nespecifikované chování | `docs/06-open-questions.md` (přidat otázku, neimpl. naslepo) |
|
| Nespecifikované chování | `docs/06-open-questions.md` (přidat otázku, neimpl. naslepo) |
|
||||||
|
| **MCP read-only SQL na EMS DB** | Cursor MCP server **`postgres-ems`**, nástroj **`query`**. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ _DEYE_INACTIVE_TOU_REGISTERS: frozenset[int] = frozenset(
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Systémový čas Deye — vždy toleranční verify jako celek 62–64 (reg 64 sám nesmí do striktní větve).
|
||||||
|
DEYE_CLOCK_REGS: frozenset[int] = frozenset({62, 63, 64})
|
||||||
|
|
||||||
DEYE_REGISTER_NAMES: dict[int, str] = {
|
DEYE_REGISTER_NAMES: dict[int, str] = {
|
||||||
108: "max_charge_a (max nabíjecí proud baterie)",
|
108: "max_charge_a (max nabíjecí proud baterie)",
|
||||||
109: "max_discharge_a (max vybíjecí proud baterie)",
|
109: "max_discharge_a (max vybíjecí proud baterie)",
|
||||||
@@ -178,9 +181,9 @@ def _deye_should_skip_time_sync_after_read(
|
|||||||
r64: int,
|
r64: int,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
True = nezařazovat zápis 62–64: drift je malý a od posledního úspěšného ověření času
|
True = nezařazovat zápis 62–64: drift je malý a od posledního úspěšného zápisu (FC 0x10 ACK)
|
||||||
(status verified v journalu 62–64) neuplynul 24h — sloupec deye_last_system_time_sync_at
|
nebo tolerančního ověření neuplynulo 24h — sloupec deye_last_system_time_sync_at doplňuje
|
||||||
se doplňuje jen po tolerančním ověření v _verify_deye_clock_command_run.
|
write_inverter_setpoints po úspěšném zápisu batche obsahujícího 62–64 a znovu po úspěšném verify.
|
||||||
"""
|
"""
|
||||||
dev = _deye_registers_to_prague_datetime(r62, r63, r64)
|
dev = _deye_registers_to_prague_datetime(r62, r63, r64)
|
||||||
if dev is None:
|
if dev is None:
|
||||||
@@ -202,11 +205,35 @@ def _deye_should_skip_time_sync_after_read(
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _is_deye_contiguous_clock_run(run: list[asyncpg.Record]) -> bool:
|
async def _fetch_written_deye_clock_commands(
|
||||||
if len(run) != 3:
|
site_id: int,
|
||||||
return False
|
asset_id: int,
|
||||||
regs = sorted(int(c["register"]) for c in run)
|
host: str,
|
||||||
return regs == [62, 63, 64]
|
port: int,
|
||||||
|
unit_id: int,
|
||||||
|
db: asyncpg.Connection,
|
||||||
|
) -> list[asyncpg.Record]:
|
||||||
|
"""Všechny řádky journalu 62–64 ve stavu written pro daný invertor/endpoint."""
|
||||||
|
rows = await db.fetch(
|
||||||
|
"""
|
||||||
|
SELECT * FROM ems.modbus_command
|
||||||
|
WHERE site_id = $1
|
||||||
|
AND asset_type = 'inverter'
|
||||||
|
AND asset_id = $2
|
||||||
|
AND device_host = $3
|
||||||
|
AND device_port = $4
|
||||||
|
AND device_unit_id = $5
|
||||||
|
AND register IN (62, 63, 64)
|
||||||
|
AND status = 'written'
|
||||||
|
ORDER BY register
|
||||||
|
""",
|
||||||
|
site_id,
|
||||||
|
asset_id,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
unit_id,
|
||||||
|
)
|
||||||
|
return list(rows)
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_last_verified_inverter_registers(
|
async def _fetch_last_verified_inverter_registers(
|
||||||
@@ -453,30 +480,72 @@ async def _switch_to_self_sustain(site_id: int, db: asyncpg.Connection, reason:
|
|||||||
logger.critical("Site %s switched to SELF_SUSTAIN: %s", site_id, reason)
|
logger.critical("Site %s switched to SELF_SUSTAIN: %s", site_id, reason)
|
||||||
|
|
||||||
|
|
||||||
async def _verify_deye_clock_command_run(
|
def _modbus_cmd_register(cmd: Any) -> int:
|
||||||
run: list[asyncpg.Record],
|
"""asyncpg.Record má __getitem__; objekty s atributem .register též (testy)."""
|
||||||
values: list[int],
|
try:
|
||||||
db: asyncpg.Connection,
|
return int(cmd["register"])
|
||||||
|
except (KeyError, TypeError):
|
||||||
|
return int(cmd.register)
|
||||||
|
|
||||||
|
|
||||||
|
def _deye_expected_clock_triplet_for_verify(
|
||||||
|
bundle: list[asyncpg.Record],
|
||||||
|
last_verified: dict[int, int],
|
||||||
|
a62: int,
|
||||||
|
a63: int,
|
||||||
|
a64: int,
|
||||||
|
) -> tuple[int, int, int]:
|
||||||
|
"""
|
||||||
|
Sestaví očekávané (w62,w63,w64) pro toleranční verify.
|
||||||
|
Chybějící registry doplní poslední verified nebo aktuálním přečtením ze zařízení
|
||||||
|
(aby osiřelý zápis např. jen 64 nešel do striktního porovnání reg64).
|
||||||
|
"""
|
||||||
|
by_reg = {_modbus_cmd_register(c): c for c in bundle}
|
||||||
|
def _vtw(c: Any) -> int:
|
||||||
|
try:
|
||||||
|
return int(c["value_to_write"])
|
||||||
|
except (KeyError, TypeError):
|
||||||
|
return int(c.value_to_write)
|
||||||
|
|
||||||
|
w62 = _vtw(by_reg[62]) if 62 in by_reg else last_verified.get(62, a62)
|
||||||
|
w63 = _vtw(by_reg[63]) if 63 in by_reg else last_verified.get(63, a63)
|
||||||
|
w64 = _vtw(by_reg[64]) if 64 in by_reg else last_verified.get(64, a64)
|
||||||
|
return (int(w62), int(w63), int(w64))
|
||||||
|
|
||||||
|
|
||||||
|
async def _verify_deye_clock_written_bundle(
|
||||||
site_id: int,
|
site_id: int,
|
||||||
|
bundle: list[asyncpg.Record],
|
||||||
|
a62: int,
|
||||||
|
a63: int,
|
||||||
|
a64: int,
|
||||||
|
db: asyncpg.Connection,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Ověření souvislého bloku 62–64: porovnání času z trojice registrů s tolerancí (sekundy na Deye běží).
|
Toleranční ověření pro jeden až tři řádky journalu 62–64 ve stavu written.
|
||||||
Při mismatch retry všech tří řádků journalu společně.
|
Při mismatch retry společně; bez přepnutí do SELF_SUSTAIN po 3 pokusech.
|
||||||
"""
|
"""
|
||||||
from services.notification_service import (
|
from services.notification_service import (
|
||||||
notify_modbus_clock_verify_exhausted,
|
notify_modbus_clock_verify_exhausted,
|
||||||
notify_modbus_mismatch,
|
notify_modbus_mismatch,
|
||||||
)
|
)
|
||||||
|
|
||||||
run_s = sorted(run, key=lambda c: int(c["register"]))
|
cmds_s = sorted(bundle, key=_modbus_cmd_register)
|
||||||
w62 = int(run_s[0]["value_to_write"])
|
try:
|
||||||
w63 = int(run_s[1]["value_to_write"])
|
asset_id = int(cmds_s[0]["asset_id"])
|
||||||
w64 = int(run_s[2]["value_to_write"])
|
except (KeyError, TypeError):
|
||||||
a62, a63, a64 = (int(values[0]), int(values[1]), int(values[2]))
|
asset_id = int(cmds_s[0].asset_id)
|
||||||
|
last_v = await _fetch_last_verified_inverter_registers(site_id, asset_id, db)
|
||||||
|
w62, w63, w64 = _deye_expected_clock_triplet_for_verify(bundle, last_v, a62, a63, a64)
|
||||||
clock_ok = _deye_clock_registers_verify_match(w62, w63, w64, a62, a63, a64)
|
clock_ok = _deye_clock_registers_verify_match(w62, w63, w64, a62, a63, a64)
|
||||||
|
actual_by_reg = {62: a62, 63: a63, 64: a64}
|
||||||
|
|
||||||
for cmd, actual in zip(run_s, values):
|
for cmd in cmds_s:
|
||||||
cid = int(cmd["id"])
|
try:
|
||||||
|
cid = int(cmd["id"])
|
||||||
|
except (KeyError, TypeError):
|
||||||
|
cid = int(cmd.id)
|
||||||
|
r = _modbus_cmd_register(cmd)
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE ems.modbus_command
|
UPDATE ems.modbus_command
|
||||||
@@ -484,13 +553,12 @@ async def _verify_deye_clock_command_run(
|
|||||||
status=CASE WHEN $2::boolean THEN 'verified' ELSE 'mismatch' END
|
status=CASE WHEN $2::boolean THEN 'verified' ELSE 'mismatch' END
|
||||||
WHERE id=$3::int
|
WHERE id=$3::int
|
||||||
""",
|
""",
|
||||||
int(actual),
|
actual_by_reg[r],
|
||||||
clock_ok,
|
clock_ok,
|
||||||
cid,
|
cid,
|
||||||
)
|
)
|
||||||
|
|
||||||
if clock_ok:
|
if clock_ok:
|
||||||
inv_asset_id = int(run_s[0]["asset_id"])
|
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE ems.asset_inverter
|
UPDATE ems.asset_inverter
|
||||||
@@ -499,22 +567,35 @@ async def _verify_deye_clock_command_run(
|
|||||||
WHERE id = $2
|
WHERE id = $2
|
||||||
""",
|
""",
|
||||||
_prague_minute_start_utc(),
|
_prague_minute_start_utc(),
|
||||||
inv_asset_id,
|
asset_id,
|
||||||
)
|
)
|
||||||
for cmd, actual in zip(run_s, values):
|
for cmd in cmds_s:
|
||||||
|
try:
|
||||||
|
cid_l = int(cmd["id"])
|
||||||
|
except (KeyError, TypeError):
|
||||||
|
cid_l = int(cmd.id)
|
||||||
|
try:
|
||||||
|
code_l = str(cmd["asset_code"])
|
||||||
|
except (KeyError, TypeError):
|
||||||
|
code_l = str(cmd.asset_code)
|
||||||
|
rr = _modbus_cmd_register(cmd)
|
||||||
logger.info(
|
logger.info(
|
||||||
"[cmd %s] verified OK (clock tolerant): %s 0x%04X=%s",
|
"[cmd %s] verified OK (clock tolerant): %s 0x%04X=%s",
|
||||||
int(cmd["id"]),
|
cid_l,
|
||||||
cmd["asset_code"],
|
code_l,
|
||||||
int(cmd["register"]),
|
rr,
|
||||||
int(actual),
|
actual_by_reg[rr],
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
cmd0 = run_s[0]
|
cmd0 = cmds_s[0]
|
||||||
|
try:
|
||||||
|
ac0 = str(cmd0["asset_code"])
|
||||||
|
except (KeyError, TypeError):
|
||||||
|
ac0 = str(cmd0.asset_code)
|
||||||
logger.error(
|
logger.error(
|
||||||
"[cmd clock] MISMATCH %s 62–64: written=(%s,%s,%s) actual=(%s,%s,%s)",
|
"[cmd clock] MISMATCH %s 62–64: written=(%s,%s,%s) actual=(%s,%s,%s)",
|
||||||
cmd0["asset_code"],
|
ac0,
|
||||||
w62,
|
w62,
|
||||||
w63,
|
w63,
|
||||||
w64,
|
w64,
|
||||||
@@ -524,15 +605,19 @@ async def _verify_deye_clock_command_run(
|
|||||||
)
|
)
|
||||||
|
|
||||||
attempts = 0
|
attempts = 0
|
||||||
for cmd in run_s:
|
for cmd in cmds_s:
|
||||||
|
try:
|
||||||
|
cid_q = int(cmd["id"])
|
||||||
|
except (KeyError, TypeError):
|
||||||
|
cid_q = int(cmd.id)
|
||||||
row_ac = await db.fetchrow(
|
row_ac = await db.fetchrow(
|
||||||
"SELECT attempt_count FROM ems.modbus_command WHERE id=$1", int(cmd["id"])
|
"SELECT attempt_count FROM ems.modbus_command WHERE id=$1", cid_q
|
||||||
)
|
)
|
||||||
ac = int(row_ac["attempt_count"] or 0) if row_ac else 0
|
ac = int(row_ac["attempt_count"] or 0) if row_ac else 0
|
||||||
attempts = max(attempts, ac)
|
attempts = max(attempts, ac)
|
||||||
|
|
||||||
await notify_modbus_mismatch(
|
await notify_modbus_mismatch(
|
||||||
str(cmd0["asset_code"]),
|
ac0,
|
||||||
62,
|
62,
|
||||||
"system_time_62_64",
|
"system_time_62_64",
|
||||||
w62,
|
w62,
|
||||||
@@ -540,7 +625,12 @@ async def _verify_deye_clock_command_run(
|
|||||||
attempts,
|
attempts,
|
||||||
)
|
)
|
||||||
|
|
||||||
ids_ordered = [int(c["id"]) for c in run_s]
|
ids_ordered = []
|
||||||
|
for c in cmds_s:
|
||||||
|
try:
|
||||||
|
ids_ordered.append(int(c["id"]))
|
||||||
|
except (KeyError, TypeError):
|
||||||
|
ids_ordered.append(int(c.id))
|
||||||
if attempts < 3:
|
if attempts < 3:
|
||||||
for cid in ids_ordered:
|
for cid in ids_ordered:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
@@ -556,7 +646,7 @@ async def _verify_deye_clock_command_run(
|
|||||||
site = await db.fetchrow("SELECT code FROM ems.site WHERE id=$1", site_id)
|
site = await db.fetchrow("SELECT code FROM ems.site WHERE id=$1", site_id)
|
||||||
await notify_modbus_clock_verify_exhausted(
|
await notify_modbus_clock_verify_exhausted(
|
||||||
site["code"] if site else str(site_id),
|
site["code"] if site else str(site_id),
|
||||||
str(cmd0["asset_code"]),
|
ac0,
|
||||||
(w62, w63, w64),
|
(w62, w63, w64),
|
||||||
(a62, a63, a64),
|
(a62, a63, a64),
|
||||||
)
|
)
|
||||||
@@ -663,7 +753,40 @@ async def verify_modbus_commands(
|
|||||||
all_ok = True
|
all_ok = True
|
||||||
for (host, port, unit), group in by_gw.items():
|
for (host, port, unit), group in by_gw.items():
|
||||||
client = await get_modbus_client(host, port)
|
client = await get_modbus_client(host, port)
|
||||||
for run in _modbus_command_contiguous_runs(group):
|
clock_cmds = [c for c in group if int(c["register"]) in DEYE_CLOCK_REGS]
|
||||||
|
rest = [c for c in group if int(c["register"]) not in DEYE_CLOCK_REGS]
|
||||||
|
|
||||||
|
if clock_cmds:
|
||||||
|
asset_id = int(clock_cmds[0]["asset_id"])
|
||||||
|
bundle = await _fetch_written_deye_clock_commands(
|
||||||
|
site_id, asset_id, host, port, unit, db
|
||||||
|
)
|
||||||
|
if not bundle:
|
||||||
|
bundle = clock_cmds
|
||||||
|
try:
|
||||||
|
cvals = await client.read_holding_registers(62, 3, unit)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("verify clock read 62–64 failed: %s", e)
|
||||||
|
all_ok = False
|
||||||
|
else:
|
||||||
|
if len(cvals) != 3:
|
||||||
|
logger.error(
|
||||||
|
"verify clock read: expected 3 regs, got %s", len(cvals)
|
||||||
|
)
|
||||||
|
all_ok = False
|
||||||
|
else:
|
||||||
|
matched = await _verify_deye_clock_written_bundle(
|
||||||
|
site_id,
|
||||||
|
bundle,
|
||||||
|
int(cvals[0]),
|
||||||
|
int(cvals[1]),
|
||||||
|
int(cvals[2]),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
if not matched:
|
||||||
|
all_ok = False
|
||||||
|
|
||||||
|
for run in _modbus_command_contiguous_runs(rest):
|
||||||
start_reg = int(run[0]["register"])
|
start_reg = int(run[0]["register"])
|
||||||
n = len(run)
|
n = len(run)
|
||||||
try:
|
try:
|
||||||
@@ -683,11 +806,6 @@ async def verify_modbus_commands(
|
|||||||
)
|
)
|
||||||
all_ok = False
|
all_ok = False
|
||||||
continue
|
continue
|
||||||
if _is_deye_contiguous_clock_run(run):
|
|
||||||
matched = await _verify_deye_clock_command_run(run, values, db, site_id)
|
|
||||||
if not matched:
|
|
||||||
all_ok = False
|
|
||||||
continue
|
|
||||||
for cmd, actual in zip(run, values):
|
for cmd, actual in zip(run, values):
|
||||||
matched = await _apply_verify_result(cmd, int(actual))
|
matched = await _apply_verify_result(cmd, int(actual))
|
||||||
if not matched:
|
if not matched:
|
||||||
@@ -1172,7 +1290,7 @@ async def write_inverter_setpoints(
|
|||||||
|
|
||||||
if skip_time:
|
if skip_time:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Deye clock 62–64 skipped (drift ≤ %ss, last write < %sh ago): %s CET",
|
"Deye clock 62–64 skipped (drift ≤ %ss, last sync < %sh ago): %s CET",
|
||||||
DEYE_CLOCK_DRIFT_OK_SEC,
|
DEYE_CLOCK_DRIFT_OK_SEC,
|
||||||
DEYE_CLOCK_RESYNC_INTERVAL_HOURS,
|
DEYE_CLOCK_RESYNC_INTERVAL_HOURS,
|
||||||
now_cet.strftime("%Y-%m-%d %H:%M:%S"),
|
now_cet.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
@@ -1290,6 +1408,19 @@ async def write_inverter_setpoints(
|
|||||||
return f"FAIL inverter: {inv.code}: Modbus write failed (see modbus_command)"
|
return f"FAIL inverter: {inv.code}: Modbus write failed (see modbus_command)"
|
||||||
logger.info("[control] Inverter %s journal write OK", inv.code)
|
logger.info("[control] Inverter %s journal write OK", inv.code)
|
||||||
|
|
||||||
|
will_write_time = any(int(r) in DEYE_CLOCK_REGS for r, _, _ in registers)
|
||||||
|
if will_write_time:
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
UPDATE ems.asset_inverter
|
||||||
|
SET deye_last_system_time_sync_minute = $1,
|
||||||
|
deye_last_system_time_sync_at = now()
|
||||||
|
WHERE id = $2
|
||||||
|
""",
|
||||||
|
_prague_minute_start_utc(),
|
||||||
|
inv.id,
|
||||||
|
)
|
||||||
|
|
||||||
if need_inactive_tou or will_write_inactive:
|
if need_inactive_tou or will_write_inactive:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from services.control_exporter import (
|
from services.control_exporter import (
|
||||||
DEYE_CLOCK_DRIFT_OK_SEC,
|
DEYE_CLOCK_DRIFT_OK_SEC,
|
||||||
@@ -12,6 +13,7 @@ from services.control_exporter import (
|
|||||||
InverterConfig,
|
InverterConfig,
|
||||||
PRAGUE_TZ,
|
PRAGUE_TZ,
|
||||||
_deye_clock_registers_verify_match,
|
_deye_clock_registers_verify_match,
|
||||||
|
_deye_expected_clock_triplet_for_verify,
|
||||||
_deye_registers_to_prague_datetime,
|
_deye_registers_to_prague_datetime,
|
||||||
_deye_should_skip_time_sync_after_read,
|
_deye_should_skip_time_sync_after_read,
|
||||||
_deye_system_time_register_rows,
|
_deye_system_time_register_rows,
|
||||||
@@ -121,5 +123,23 @@ class DeyeSkipTimeSyncPolicyTests(unittest.TestCase):
|
|||||||
self.assertGreater(DEYE_CLOCK_DRIFT_OK_SEC, 5)
|
self.assertGreater(DEYE_CLOCK_DRIFT_OK_SEC, 5)
|
||||||
|
|
||||||
|
|
||||||
|
class DeyeClockTripletForVerifyTests(unittest.TestCase):
|
||||||
|
def test_orphan_reg64_fills_w62_w63_from_device_read(self) -> None:
|
||||||
|
a62 = (2026 - 2000) << 8 | 4
|
||||||
|
a63 = 10 << 8 | 12
|
||||||
|
a64 = 45 << 8 | 30
|
||||||
|
bundle = [SimpleNamespace(register=64, value_to_write=(45 << 8) | 0)]
|
||||||
|
w62, w63, w64 = _deye_expected_clock_triplet_for_verify(bundle, {}, a62, a63, a64)
|
||||||
|
self.assertEqual(w62, a62)
|
||||||
|
self.assertEqual(w63, a63)
|
||||||
|
self.assertEqual(w64, 45 << 8)
|
||||||
|
|
||||||
|
def test_last_verified_used_when_not_in_bundle(self) -> None:
|
||||||
|
bundle: list[SimpleNamespace] = []
|
||||||
|
last = {62: 1, 63: 2, 64: 3}
|
||||||
|
w62, w63, w64 = _deye_expected_clock_triplet_for_verify(bundle, last, 9, 8, 7)
|
||||||
|
self.assertEqual((w62, w63, w64), (1, 2, 3))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Účel
|
## Účel
|
||||||
|
|
||||||
Každý zápis na Modbus TCP (Deye a později další aktiva) se ukládá do tabulky `ems.modbus_command` jako samostatný řádek: cílový registr, hodnota, endpoint, vazba na `site_id` a volitelně na `planning_run_id`. Po zápisu má řádek stav `written`; samostatný **verifikační job** (každé 2 minuty) nebo ruční **GET** `/api/v1/sites/{site_id}/control/verify` přečte registr zpět a nastaví `value_verified` a stav `verified` nebo `mismatch`. **Výjimka:** souvislý blok Deye **62–64** (systémový čas) se ověřuje **tolerančně** podle dekódovaného data/času (kvůli tikajícím sekundám na invertoru); viz `modbus-registers.md`.
|
Každý zápis na Modbus TCP (Deye a později další aktiva) se ukládá do tabulky `ems.modbus_command` jako samostatný řádek: cílový registr, hodnota, endpoint, vazba na `site_id` a volitelně na `planning_run_id`. Po zápisu má řádek stav `written`; samostatný **verifikační job** (každé 2 minuty) nebo ruční **GET** `/api/v1/sites/{site_id}/control/verify` přečte registr zpět a nastaví `value_verified` a stav `verified` nebo `mismatch`. **Výjimka:** Deye **62–64** (systémový čas) se vždy ověřují **jako celek** jedním čtením 62–64 a **tolerančně** podle dekódovaného data/času — řádky 62–64 se **neprohánějí** striktní větví po jednom registru (jinak by zejména **64** způsoboval falešné `mismatch` a SELF_SUSTAIN). Podmnožina `written` řádků (např. jen 64) se sloučí s dotazem na všechny `written` 62–64 pro daný invertor; viz `modbus-registers.md`.
|
||||||
|
|
||||||
## Schéma `ems.modbus_command`
|
## Schéma `ems.modbus_command`
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ Indexy: podle `(site_id, status, created_at)` a částečný index pro `pending`
|
|||||||
|
|
||||||
**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.
|
**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_command_run`, `_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`; `services/notification_service.py` — `run_fn_set_mode_with_discord`, `notify_operating_mode_changed`.
|
||||||
|
|
||||||
## Střídač (Deye)
|
## Střídač (Deye)
|
||||||
|
|
||||||
|
|||||||
@@ -100,11 +100,11 @@ Registry **62–64** nastavují invertoru čas v **Europe/Prague**.
|
|||||||
- reg **63:** `den << 8 | hodina`
|
- reg **63:** `den << 8 | hodina`
|
||||||
- reg **64:** `minuta << 8 | sekunda` — při zápisu z EMS jsou **sekundy vždy 0** (stabilnější hodnota; na zařízení pak sekundy dál běží).
|
- reg **64:** `minuta << 8 | sekunda` — při zápisu z EMS jsou **sekundy vždy 0** (stabilnější hodnota; na zařízení pak sekundy dál běží).
|
||||||
|
|
||||||
**Řidší zápis:** před každým exportem setpointů EMS **přečte** 62–64 (FC 0x03). Do journalu **62–64 nezařadí**, pokud je dekódovaný čas invertoru vůči aktuální **Europe/Prague** v odchylce **≤ 60 s** *a zároveň* od posledního **úspěšného ověření** trojice 62–64 v journalu (stav `verified` po toleranční kontrole níže) neuplynulo **24 h**. Tomu odpovídají sloupce `asset_inverter.deye_last_system_time_sync_at` a `deye_last_system_time_sync_minute`: doplňují se **až po úspěšné toleranční verifikaci** v `_verify_deye_clock_command_run`, nikoli po samotném FC 0x10 zápisu (aby řídicí logika ne„myslela“, že je čas na invertoru sjednocený, dokud to verify nepotvrdí). Je-li `deye_last_system_time_sync_at` **NULL** (první provoz / nikdy neověřený sync), zápis času se **nevynechá**. Při **selhání čtení** 62–64 před rozhodnutím se čas **zařadí do journalu** (bezpečný fallback). Při scénáři „žádný řádek journalu, všechny hodnoty jako poslední `verified`“ se **čas v DB neaktualizuje** (žádný fiktivní sync).
|
**Řidší zápis:** před každým exportem setpointů EMS **přečte** 62–64 (FC 0x03). Do journalu **62–64 nezařadí**, pokud je dekódovaný čas invertoru vůči aktuální **Europe/Prague** v odchylce **≤ 60 s** *a zároveň* od posledního **úspěšného syncu** neuplynulo **24 h**. Sloupce `asset_inverter.deye_last_system_time_sync_at` a `deye_last_system_time_sync_minute` se doplňují po **úspěšném FC 0x10 zápisu** batche obsahujícího 62–64 (`write_inverter_setpoints`) a znovu po **úspěšné toleranční verifikaci** (`_verify_deye_clock_written_bundle`) — obojí drží řidší zápis i když verify občas selže. Je-li `deye_last_system_time_sync_at` **NULL** (první provoz), zápis času se **nevynechá**. Při **selhání čtení** 62–64 před rozhodnutím se čas **zařadí do journalu** (bezpečný fallback). Při scénáři „žádný řádek journalu, všechny hodnoty jako poslední `verified`“ se **čas v DB neaktualizuje** (žádný fiktivní sync).
|
||||||
|
|
||||||
Zápis prochází journal jako každý jiný registr; na sběrnici jde souvislý blok **FC 0x10**.
|
Zápis prochází journal jako každý jiný registr; na sběrnici jde souvislý blok **FC 0x10**.
|
||||||
|
|
||||||
**Verifikace (journal):** u souvislého bloku **62–64** není porovnání bajt po bajtu — invertor mezi zápisem a čtením posune sekundy. EMS považuje zápis za úspěšný, pokud se dekódované časy (z `value_to_write` trojice vs. přečtené hodnoty) liší nejvýše o **120 s** (`control_exporter._verify_deye_clock_command_run`). Po **třech neúspěšných ověřeních** bloku 62–64 EMS **nepřepíná** provozní režim na SELF_SUSTAIN (na rozdíl od ostatních registrů); pošle **kritický Discord** (`notify_modbus_clock_verify_exhausted`) a příkazy zůstanou ve stavu vhodném pro diagnostiku — viz `modbus-command-journal.md`.
|
**Verifikace (journal):** registry **62–64** se **nikdy** neověřují striktním porovnáním po jednotlivých registrech (reg **64** by kvůli běžícím sekundám padal do `mismatch` a spouštěl SELF_SUSTAIN). Verifikační job vždy přečte **FC 0x03** souvisle **62–64** a použije toleranci **120 s** na dekódovaný čas (`_deye_clock_registers_verify_match`). Je-li ve stavu `written` jen podmnožina řádků (např. jen **64**), očekávané hodnoty pro chybějící registry se doplní z posledního `verified` nebo z aktuálního přečtení na sběrnici (`_deye_expected_clock_triplet_for_verify`). Po **třech neúspěšných ověřeních** bloku 62–64 EMS **nepřepíná** provozní režim na SELF_SUSTAIN; pošle **kritický Discord** (`notify_modbus_clock_verify_exhausted`) — viz `modbus-command-journal.md`.
|
||||||
|
|
||||||
**Před vytvořením journalu:** pokud je navrhovaná hodnota **shodná s posledním `verified`** záznamem daného registru v `modbus_command`, EMS **řádek nevytvoří** a na Modbus neposílá (žádný „X → X“ zápis jen kvůli periodickému exportu). Výjimky řeší stávající logika (řidší 62–64 výše, denní TOU 3–6 + meta sloupce na `asset_inverter`).
|
**Před vytvořením journalu:** pokud je navrhovaná hodnota **shodná s posledním `verified`** záznamem daného registru v `modbus_command`, EMS **řádek nevytvoří** a na Modbus neposílá (žádný „X → X“ zápis jen kvůli periodickému exportu). Výjimky řeší stávající logika (řidší 62–64 výše, denní TOU 3–6 + meta sloupce na `asset_inverter`).
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user