Uprava aktualizace casu ve stridaci - mene casto, akceptujeme az 120s drift, zapisujeme presto 1x denne
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 → přepnutí na **SELF_SUSTAIN** (`fn_set_mode`, `system:mismatch`) + **Discord** alert, pokud je `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:** jen když se oproti poslednímu zápisu posunula **minuta** (Europe/Prague). **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 zápisu času (`deye_last_system_time_sync_at` se mění jen při 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**). **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).
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import os
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from datetime import date, datetime, timezone
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import asyncpg
|
||||
@@ -21,6 +21,13 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
PRAGUE_TZ = ZoneInfo("Europe/Prague")
|
||||
|
||||
# Hodiny Deye 62–64: po zápisu sekundy na zařízení dál běží → verify musí být toleranční.
|
||||
DEYE_CLOCK_VERIFY_MAX_DELTA_SEC = 120
|
||||
# Řidší zápis: bez zápisu, pokud čas na invertoru neodbočí od Prahy víc než o tolik sekund…
|
||||
DEYE_CLOCK_DRIFT_OK_SEC = 60
|
||||
# …a zároveň neuplynul tento interval od posledního syncu / potvrzení driftu.
|
||||
DEYE_CLOCK_RESYNC_INTERVAL_HOURS = 24
|
||||
|
||||
# Deye LV baterie: převod výkon → proud pro registry 108/109 (viz docs/04-modules/modbus-registers.md)
|
||||
BATT_VOLTAGE_V = 51.2
|
||||
|
||||
@@ -120,6 +127,7 @@ class InverterConfig:
|
||||
max_charge_a: int
|
||||
max_discharge_a: int
|
||||
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
|
||||
deye_tou_inactive_signature: str | None = None
|
||||
|
||||
@@ -130,14 +138,74 @@ def _prague_minute_start_utc() -> datetime:
|
||||
return p.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _deye_skip_time_registers(inv: InverterConfig) -> bool:
|
||||
"""True = neposílat 62–64 (stejná pražská minuta jako u posledního úspěšného zápisu)."""
|
||||
last = inv.deye_last_system_time_sync_minute
|
||||
if last is None:
|
||||
def _deye_registers_to_prague_datetime(r62: int, r63: int, r64: int) -> datetime | None:
|
||||
"""Dekódování reg 62–64 (Deye system time v Europe/Prague)."""
|
||||
try:
|
||||
year = (int(r62) >> 8) + 2000
|
||||
month = int(r62) & 0xFF
|
||||
day = int(r63) >> 8
|
||||
hour = int(r63) & 0xFF
|
||||
minute = int(r64) >> 8
|
||||
second = int(r64) & 0xFF
|
||||
if not (1 <= month <= 12 and 1 <= day <= 31 and 0 <= hour <= 23):
|
||||
return None
|
||||
if not (0 <= minute <= 59 and 0 <= second <= 59):
|
||||
return None
|
||||
return datetime(year, month, day, hour, minute, second, tzinfo=PRAGUE_TZ)
|
||||
except (ValueError, OverflowError):
|
||||
return None
|
||||
|
||||
|
||||
def _deye_clock_registers_verify_match(
|
||||
w62: int,
|
||||
w63: int,
|
||||
w64: int,
|
||||
a62: int,
|
||||
a63: int,
|
||||
a64: int,
|
||||
) -> bool:
|
||||
w_dt = _deye_registers_to_prague_datetime(w62, w63, w64)
|
||||
a_dt = _deye_registers_to_prague_datetime(a62, a63, a64)
|
||||
if w_dt is None or a_dt is None:
|
||||
return False
|
||||
if last.tzinfo is None:
|
||||
last = last.replace(tzinfo=timezone.utc)
|
||||
return last.astimezone(timezone.utc) == _prague_minute_start_utc()
|
||||
return abs((a_dt - w_dt).total_seconds()) <= DEYE_CLOCK_VERIFY_MAX_DELTA_SEC
|
||||
|
||||
|
||||
def _deye_should_skip_time_sync_after_read(
|
||||
inv: InverterConfig,
|
||||
r62: int,
|
||||
r63: int,
|
||||
r64: int,
|
||||
) -> bool:
|
||||
"""
|
||||
True = nezařazovat zápis 62–64: drift je malý a od posledního úspěšného zápisu času
|
||||
neuplynul 24h (deye_last_system_time_sync_at se mění jen při zápisu, ne při přeskočení).
|
||||
"""
|
||||
dev = _deye_registers_to_prague_datetime(r62, r63, r64)
|
||||
if dev is None:
|
||||
return False
|
||||
wall = datetime.now(PRAGUE_TZ)
|
||||
drift = abs((wall - dev).total_seconds())
|
||||
if drift > DEYE_CLOCK_DRIFT_OK_SEC:
|
||||
return False
|
||||
last_write = inv.deye_last_system_time_sync_at
|
||||
if last_write is None:
|
||||
return False
|
||||
if last_write.tzinfo is None:
|
||||
last_write = last_write.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
last_write = last_write.astimezone(timezone.utc)
|
||||
age = datetime.now(timezone.utc) - last_write
|
||||
if age >= timedelta(hours=DEYE_CLOCK_RESYNC_INTERVAL_HOURS):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _is_deye_contiguous_clock_run(run: list[asyncpg.Record]) -> bool:
|
||||
if len(run) != 3:
|
||||
return False
|
||||
regs = sorted(int(c["register"]) for c in run)
|
||||
return regs == [62, 63, 64]
|
||||
|
||||
|
||||
async def _fetch_last_verified_inverter_registers(
|
||||
@@ -382,6 +450,116 @@ 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)
|
||||
|
||||
|
||||
async def _verify_deye_clock_command_run(
|
||||
run: list[asyncpg.Record],
|
||||
values: list[int],
|
||||
db: asyncpg.Connection,
|
||||
site_id: int,
|
||||
) -> bool:
|
||||
"""
|
||||
Ověření souvislého bloku 62–64: porovnání času z trojice registrů s tolerancí (sekundy na Deye běží).
|
||||
Při mismatch retry všech tří řádků journalu společně.
|
||||
"""
|
||||
from services.notification_service import (
|
||||
notify_modbus_mismatch,
|
||||
notify_self_sustain_activated,
|
||||
)
|
||||
|
||||
run_s = sorted(run, key=lambda c: int(c["register"]))
|
||||
w62 = int(run_s[0]["value_to_write"])
|
||||
w63 = int(run_s[1]["value_to_write"])
|
||||
w64 = int(run_s[2]["value_to_write"])
|
||||
a62, a63, a64 = (int(values[0]), int(values[1]), int(values[2]))
|
||||
clock_ok = _deye_clock_registers_verify_match(w62, w63, w64, a62, a63, a64)
|
||||
|
||||
for cmd, actual in zip(run_s, values):
|
||||
cid = int(cmd["id"])
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE ems.modbus_command
|
||||
SET value_verified=$1::int, verified_at=now(),
|
||||
status=CASE WHEN $2::boolean THEN 'verified' ELSE 'mismatch' END
|
||||
WHERE id=$3::int
|
||||
""",
|
||||
int(actual),
|
||||
clock_ok,
|
||||
cid,
|
||||
)
|
||||
|
||||
if clock_ok:
|
||||
for cmd, actual in zip(run_s, values):
|
||||
logger.info(
|
||||
"[cmd %s] verified OK (clock tolerant): %s 0x%04X=%s",
|
||||
int(cmd["id"]),
|
||||
cmd["asset_code"],
|
||||
int(cmd["register"]),
|
||||
int(actual),
|
||||
)
|
||||
return True
|
||||
|
||||
cmd0 = run_s[0]
|
||||
logger.error(
|
||||
"[cmd clock] MISMATCH %s 62–64: written=(%s,%s,%s) actual=(%s,%s,%s)",
|
||||
cmd0["asset_code"],
|
||||
w62,
|
||||
w63,
|
||||
w64,
|
||||
a62,
|
||||
a63,
|
||||
a64,
|
||||
)
|
||||
|
||||
attempts = 0
|
||||
for cmd in run_s:
|
||||
row_ac = await db.fetchrow(
|
||||
"SELECT attempt_count FROM ems.modbus_command WHERE id=$1", int(cmd["id"])
|
||||
)
|
||||
ac = int(row_ac["attempt_count"] or 0) if row_ac else 0
|
||||
attempts = max(attempts, ac)
|
||||
|
||||
await notify_modbus_mismatch(
|
||||
str(cmd0["asset_code"]),
|
||||
62,
|
||||
"system_time_62_64",
|
||||
w62,
|
||||
a62,
|
||||
attempts,
|
||||
)
|
||||
|
||||
ids_ordered = [int(c["id"]) for c in run_s]
|
||||
if attempts < 3:
|
||||
for cid in ids_ordered:
|
||||
await db.execute(
|
||||
"UPDATE ems.modbus_command SET status='retrying' WHERE id=$1",
|
||||
cid,
|
||||
)
|
||||
await execute_modbus_commands(ids_ordered, db)
|
||||
await verify_modbus_commands(ids_ordered, db, site_id)
|
||||
else:
|
||||
logger.critical(
|
||||
"[cmd clock] 3 failed attempts (62–64 batch), switching to SELF_SUSTAIN"
|
||||
)
|
||||
site = await db.fetchrow("SELECT code FROM ems.site WHERE id=$1", site_id)
|
||||
await _switch_to_self_sustain(
|
||||
site_id,
|
||||
db,
|
||||
reason=(
|
||||
f"Modbus mismatch po 3 pokusech: {cmd0['asset_code']} "
|
||||
"regs 62–64 (system time)"
|
||||
),
|
||||
)
|
||||
if site:
|
||||
await notify_self_sustain_activated(
|
||||
site["code"],
|
||||
(
|
||||
f"Modbus mismatch: {cmd0['asset_code']} "
|
||||
f"regs 62–64 (system time) written=({w62},{w63},{w64}) "
|
||||
f"actual=({a62},{a63},{a64})"
|
||||
),
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def verify_modbus_commands(
|
||||
command_ids: list[int],
|
||||
db: asyncpg.Connection,
|
||||
@@ -517,6 +695,11 @@ async def verify_modbus_commands(
|
||||
)
|
||||
all_ok = False
|
||||
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):
|
||||
matched = await _apply_verify_result(cmd, int(actual))
|
||||
if not matched:
|
||||
@@ -589,6 +772,7 @@ async def _load_inverter_config(
|
||||
ab.max_soc_percent,
|
||||
ab.usable_capacity_wh,
|
||||
ai.deye_last_system_time_sync_minute,
|
||||
ai.deye_last_system_time_sync_at,
|
||||
ai.deye_last_tou_inactive_write_prague_date,
|
||||
ai.deye_tou_inactive_signature,
|
||||
LEAST(
|
||||
@@ -655,6 +839,7 @@ async def _load_inverter_config(
|
||||
max_charge_a=max_charge_a,
|
||||
max_discharge_a=max_discharge_a,
|
||||
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[
|
||||
"deye_last_tou_inactive_write_prague_date"
|
||||
],
|
||||
@@ -663,11 +848,11 @@ async def _load_inverter_config(
|
||||
|
||||
|
||||
def _deye_system_time_register_rows() -> tuple[datetime, list[tuple[int, str, int]]]:
|
||||
"""Hodnoty pro reg 62–64 (Europe/Prague)."""
|
||||
now = datetime.now(ZoneInfo("Europe/Prague"))
|
||||
"""Hodnoty pro reg 62–64 (Europe/Prague); sekundy v reg 64 = 0 (stabilnější zápis)."""
|
||||
now = datetime.now(PRAGUE_TZ).replace(second=0, microsecond=0)
|
||||
reg62 = ((now.year - 2000) << 8) | now.month
|
||||
reg63 = (now.day << 8) | now.hour
|
||||
reg64 = (now.minute << 8) | now.second
|
||||
reg64 = (now.minute << 8) | 0
|
||||
rows = [
|
||||
(62, "", reg62),
|
||||
(63, "", reg63),
|
||||
@@ -969,10 +1154,29 @@ async def write_inverter_setpoints(
|
||||
)
|
||||
|
||||
now_cet, time_rows = _deye_system_time_register_rows()
|
||||
skip_time = _deye_skip_time_registers(inv)
|
||||
skip_time = False
|
||||
try:
|
||||
mb_clock = await get_modbus_client(inv.host, inv.port)
|
||||
tvals = await mb_clock.read_holding_registers(
|
||||
62, 3, int(inv.unit_id if inv.unit_id is not None else 1)
|
||||
)
|
||||
if len(tvals) == 3:
|
||||
skip_time = _deye_should_skip_time_sync_after_read(
|
||||
inv, int(tvals[0]), int(tvals[1]), int(tvals[2])
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Deye clock read: expected 3 registers, got %s; will sync 62–64",
|
||||
len(tvals),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Deye clock read failed (will sync 62–64): %s", e)
|
||||
|
||||
if skip_time:
|
||||
logger.info(
|
||||
"Deye clock 62–64 skipped (same Prague minute as last sync): %s CET",
|
||||
"Deye clock 62–64 skipped (drift ≤ %ss, last write < %sh ago): %s CET",
|
||||
DEYE_CLOCK_DRIFT_OK_SEC,
|
||||
DEYE_CLOCK_RESYNC_INTERVAL_HOURS,
|
||||
now_cet.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
)
|
||||
else:
|
||||
@@ -1068,7 +1272,8 @@ async def write_inverter_setpoints(
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE ems.asset_inverter
|
||||
SET deye_last_system_time_sync_minute = $1
|
||||
SET deye_last_system_time_sync_minute = $1,
|
||||
deye_last_system_time_sync_at = now()
|
||||
WHERE id = $2
|
||||
""",
|
||||
_prague_minute_start_utc(),
|
||||
@@ -1105,7 +1310,8 @@ async def write_inverter_setpoints(
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE ems.asset_inverter
|
||||
SET deye_last_system_time_sync_minute = $1
|
||||
SET deye_last_system_time_sync_minute = $1,
|
||||
deye_last_system_time_sync_at = now()
|
||||
WHERE id = $2
|
||||
""",
|
||||
minute_utc,
|
||||
|
||||
125
backend/tests/test_deye_clock.py
Normal file
125
backend/tests/test_deye_clock.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""Deye systémový čas 62–64: dekódování, toleranční verify, politika driftu."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from services.control_exporter import (
|
||||
DEYE_CLOCK_DRIFT_OK_SEC,
|
||||
DEYE_CLOCK_RESYNC_INTERVAL_HOURS,
|
||||
DEYE_CLOCK_VERIFY_MAX_DELTA_SEC,
|
||||
InverterConfig,
|
||||
PRAGUE_TZ,
|
||||
_deye_clock_registers_verify_match,
|
||||
_deye_registers_to_prague_datetime,
|
||||
_deye_should_skip_time_sync_after_read,
|
||||
_deye_system_time_register_rows,
|
||||
)
|
||||
|
||||
|
||||
def _inv(
|
||||
*,
|
||||
sync_at: datetime | None = None,
|
||||
) -> InverterConfig:
|
||||
return InverterConfig(
|
||||
id=1,
|
||||
code="deye-main",
|
||||
host="127.0.0.1",
|
||||
port=502,
|
||||
unit_id=1,
|
||||
max_export_power_w=13_500,
|
||||
max_import_power_w=13_500,
|
||||
no_export=False,
|
||||
max_battery_charge_w=10_000,
|
||||
max_battery_discharge_w=10_000,
|
||||
min_soc_percent=12,
|
||||
reserve_soc_percent=20,
|
||||
max_soc_percent=95,
|
||||
usable_capacity_wh=64_000,
|
||||
max_charge_a=100,
|
||||
max_discharge_a=100,
|
||||
deye_last_system_time_sync_at=sync_at,
|
||||
)
|
||||
|
||||
|
||||
class DeyeClockDecodeTests(unittest.TestCase):
|
||||
def test_roundtrip_encode_decode(self) -> None:
|
||||
now_cet, rows = _deye_system_time_register_rows()
|
||||
r62 = next(v for a, _, v in rows if a == 62)
|
||||
r63 = next(v for a, _, v in rows if a == 63)
|
||||
r64 = next(v for a, _, v in rows if a == 64)
|
||||
dt = _deye_registers_to_prague_datetime(r62, r63, r64)
|
||||
self.assertIsNotNone(dt)
|
||||
assert dt is not None
|
||||
self.assertEqual(dt.tzinfo, PRAGUE_TZ)
|
||||
self.assertEqual(dt.year, now_cet.year)
|
||||
self.assertEqual(dt.month, now_cet.month)
|
||||
self.assertEqual(dt.day, now_cet.day)
|
||||
self.assertEqual(dt.hour, now_cet.hour)
|
||||
self.assertEqual(dt.minute, now_cet.minute)
|
||||
self.assertEqual(dt.second, 0)
|
||||
|
||||
def test_verify_same_triplet(self) -> None:
|
||||
# 2006-10-03 15:45:00 Praha (platné měsíc/den/hod/min/sec)
|
||||
r62, r63, r64 = 0x060A, 0x030F, 0x2D00
|
||||
self.assertTrue(_deye_clock_registers_verify_match(r62, r63, r64, r62, r63, r64))
|
||||
|
||||
def test_verify_seconds_within_tolerance(self) -> None:
|
||||
w62 = (2026 - 2000) << 8 | 4
|
||||
w63 = 3 << 8 | 14
|
||||
w64 = 30 << 8 | 10
|
||||
a64 = 30 << 8 | 50
|
||||
self.assertTrue(_deye_clock_registers_verify_match(w62, w63, w64, w62, w63, a64))
|
||||
self.assertLessEqual(40, DEYE_CLOCK_VERIFY_MAX_DELTA_SEC)
|
||||
|
||||
def test_verify_fails_when_minute_differs_beyond_tolerance(self) -> None:
|
||||
w62 = (2026 - 2000) << 8 | 4
|
||||
w63 = 3 << 8 | 14
|
||||
w64 = 30 << 8 | 0
|
||||
a64 = 33 << 8 | 0
|
||||
self.assertFalse(_deye_clock_registers_verify_match(w62, w63, w64, w62, w63, a64))
|
||||
|
||||
|
||||
class DeyeSkipTimeSyncPolicyTests(unittest.TestCase):
|
||||
def test_no_skip_when_never_written_even_if_drift_ok(self) -> None:
|
||||
wall = datetime.now(PRAGUE_TZ)
|
||||
r62 = ((wall.year - 2000) << 8) | wall.month
|
||||
r63 = (wall.day << 8) | wall.hour
|
||||
r64 = (wall.minute << 8) | wall.second
|
||||
self.assertFalse(
|
||||
_deye_should_skip_time_sync_after_read(_inv(sync_at=None), r62, r63, r64)
|
||||
)
|
||||
|
||||
def test_no_skip_when_drift_large(self) -> None:
|
||||
wall = datetime.now(PRAGUE_TZ)
|
||||
wrong_min = (wall.minute - 3) % 60
|
||||
r62 = ((wall.year - 2000) << 8) | wall.month
|
||||
r63 = (wall.day << 8) | wall.hour
|
||||
r64 = (wrong_min << 8) | wall.second
|
||||
self.assertFalse(_deye_should_skip_time_sync_after_read(_inv(sync_at=None), r62, r63, r64))
|
||||
|
||||
def test_no_skip_after_24h_even_if_drift_small(self) -> None:
|
||||
wall = datetime.now(PRAGUE_TZ)
|
||||
r62 = ((wall.year - 2000) << 8) | wall.month
|
||||
r63 = (wall.day << 8) | wall.hour
|
||||
r64 = (wall.minute << 8) | wall.second
|
||||
old = datetime.now(timezone.utc) - timedelta(hours=DEYE_CLOCK_RESYNC_INTERVAL_HOURS) - timedelta(minutes=1)
|
||||
self.assertFalse(
|
||||
_deye_should_skip_time_sync_after_read(_inv(sync_at=old), r62, r63, r64)
|
||||
)
|
||||
|
||||
def test_skip_within_24h_and_small_drift(self) -> None:
|
||||
wall = datetime.now(PRAGUE_TZ)
|
||||
r62 = ((wall.year - 2000) << 8) | wall.month
|
||||
r63 = (wall.day << 8) | wall.hour
|
||||
r64 = (wall.minute << 8) | min(59, wall.second + 5)
|
||||
recent = datetime.now(timezone.utc) - timedelta(hours=1)
|
||||
self.assertTrue(
|
||||
_deye_should_skip_time_sync_after_read(_inv(sync_at=recent), r62, r63, r64)
|
||||
)
|
||||
self.assertGreater(DEYE_CLOCK_DRIFT_OK_SEC, 5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
10
db/migration/V030__deye_clock_sync_at.sql
Normal file
10
db/migration/V030__deye_clock_sync_at.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
-- Čas posledního zápisu nebo „drift OK“ kontroly hodin Deye (62–64); periodický re-sync i při malém driftu
|
||||
|
||||
ALTER TABLE ems.asset_inverter
|
||||
ADD COLUMN IF NOT EXISTS deye_last_system_time_sync_at TIMESTAMPTZ;
|
||||
|
||||
COMMENT ON COLUMN ems.asset_inverter.deye_last_system_time_sync_at IS
|
||||
'Okamžik posledního úspěšného zápisu 62–64 (FC 0x10). Slouží k periodickému re-syncu každých 24h i při malém driftu; při přeskočení zápisu (malý drift) se sloupec nemění.';
|
||||
|
||||
COMMENT ON COLUMN ems.asset_inverter.deye_last_system_time_sync_minute IS
|
||||
'UTC začátek pražské minuty posledního úspěšného zápisu 62–64. Doplňuje se při zápisu času; rozhodování o řidším syncu je v aplikaci (drift + deye_last_system_time_sync_at).';
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Úč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`.
|
||||
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`.
|
||||
|
||||
## Schéma `ems.modbus_command`
|
||||
|
||||
@@ -30,7 +30,7 @@ Implementace: `services/control_exporter.py` — `verify_modbus_commands`, `_swi
|
||||
|
||||
## Střídač (Deye)
|
||||
|
||||
`write_inverter_setpoints` přidá do journalu podle potřeby **62–64** (čas — ne každý export) a **time pointy 148–177** (bloky 3–6 typicky jednou denně; viz `modbus-registers.md`), dále **108**, **109**, **141**, **142**, **178**, **143**. Každý řádek daného exportního běhu má **`deye_physical_mode`** (**PASSIVE** / **SELL** / **CHARGE**). **Reg 191** EMS nezapisuje (SolarmanApp). Převod výkonu: `battery_watts_to_amps` v `modbus-registers.md`.
|
||||
`write_inverter_setpoints` přidá do journalu podle potřeby **62–64** (čas — po čtení z invertoru jen při driftu / 24h intervalu; viz `modbus-registers.md`) a **time pointy 148–177** (bloky 3–6 typicky jednou denně; viz `modbus-registers.md`), dále **108**, **109**, **141**, **142**, **178**, **143**. Každý řádek daného exportního běhu má **`deye_physical_mode`** (**PASSIVE** / **SELL** / **CHARGE**). **Reg 191** EMS nezapisuje (SolarmanApp). Převod výkonu: `battery_watts_to_amps` v `modbus-registers.md`.
|
||||
|
||||
**Dávky:** `execute_modbus_commands` slučuje souvislé adresy do jednoho **`write_registers`** (FC **0x10**). `verify_modbus_commands` čte zpět po souvislých blocích (`read_holding_registers`, FC 0x03). Detail režimů: `modbus-registers.md`.
|
||||
|
||||
@@ -56,6 +56,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`
|
||||
- Migrace: `db/migration/V023__modbus_command_journal.sql`, `V025__deye_physical_mode.sql`, `V030__deye_clock_sync_at.sql`
|
||||
- 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`
|
||||
|
||||
@@ -94,15 +94,19 @@ Bloky 3–6 používají čas **2355** a stejnou **SOC** hodnotu jako PASSIVE (`
|
||||
|
||||
### Synchronizace času
|
||||
|
||||
Registry **62–64** nastavují invertoru čas v **Europe/Prague**. **EMS je do fronty nezařadí**, pokud ještě neuplynula **nová kalendářní minuta** oproti poslednímu úspěšnému zápisu (sloupec `asset_inverter.deye_last_system_time_sync_minute`). Jinak platí:
|
||||
Registry **62–64** nastavují invertoru čas v **Europe/Prague**.
|
||||
|
||||
- reg **62:** `(rok - 2000) << 8 | měsíc`
|
||||
- reg **63:** `den << 8 | hodina`
|
||||
- reg **64:** `minuta << 8 | sekunda`
|
||||
- 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 zápisu** 62–64 neuplynulo **24 h** (`asset_inverter.deye_last_system_time_sync_at`, mění se jen při zápisu). Je-li sloupec NULL (např. první provoz), zápis času se **nevynechá**. Při selhání čtení se čas **zapíše** (bezpečný fallback). Sloupec `deye_last_system_time_sync_minute` doplňuje začátek pražské minuty u **úspěšného zápisu** 62–64.
|
||||
|
||||
Zápis prochází journal jako každý jiný registr; na sběrnici jde souvislý blok **FC 0x10**.
|
||||
|
||||
**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 (nová minuta u 62–64, denní TOU 3–6 + meta sloupce na `asset_inverter`).
|
||||
**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`).
|
||||
|
||||
**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`).
|
||||
|
||||
### Mapování registrů (time point *i*, i = 0…5)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user