baterie pri sell neklesne pod 20% ale pri normalnim provozu muze jit az k 10%, mame tak rezervu a neohrozime si nahly propad procent battery packu
This commit is contained in:
@@ -5,9 +5,10 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from datetime import datetime, timezone
|
||||
from datetime import date, datetime, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import asyncpg
|
||||
@@ -18,6 +19,8 @@ from services.modbus_client import get_modbus_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PRAGUE_TZ = ZoneInfo("Europe/Prague")
|
||||
|
||||
# Deye LV baterie: převod výkon → proud pro registry 108/109 (viz docs/04-modules/modbus-registers.md)
|
||||
BATT_VOLTAGE_V = 51.2
|
||||
|
||||
@@ -29,6 +32,16 @@ REG178_PASSIVE = 0b00110000 # 48, grid peak shaving enable (PASSIVE i CHARGE)
|
||||
# verify pak hlásí mismatch. 23:55 je na zařízeních stabilní (viz HHMM jako desítkové číslo).
|
||||
DEYE_TOU_INACTIVE_HHMM = 2355
|
||||
|
||||
# Registry TOU řádků 3–6 (slot index 2…5): 150–153, 156–159, … — pro detekci skutečného zápisu po filtru „unchanged“.
|
||||
_DEYE_INACTIVE_TOU_REGISTERS: frozenset[int] = frozenset(
|
||||
[
|
||||
150, 151, 152, 153,
|
||||
156, 157, 158, 159,
|
||||
168, 169, 170, 171,
|
||||
174, 175, 176, 177,
|
||||
]
|
||||
)
|
||||
|
||||
DEYE_REGISTER_NAMES: dict[int, str] = {
|
||||
108: "max_charge_a (max nabíjecí proud baterie)",
|
||||
109: "max_discharge_a (max vybíjecí proud baterie)",
|
||||
@@ -100,11 +113,73 @@ class InverterConfig:
|
||||
no_export: bool
|
||||
max_battery_charge_w: int | None
|
||||
max_battery_discharge_w: int | None
|
||||
min_soc_percent: int | None
|
||||
reserve_soc_percent: int | None
|
||||
max_soc_percent: int | None
|
||||
usable_capacity_wh: int | None
|
||||
max_charge_a: int
|
||||
max_discharge_a: int
|
||||
deye_last_system_time_sync_minute: datetime | None = None
|
||||
deye_last_tou_inactive_write_prague_date: date | None = None
|
||||
deye_tou_inactive_signature: str | None = None
|
||||
|
||||
|
||||
def _prague_minute_start_utc() -> datetime:
|
||||
"""UTC okamžik odpovídající začátku aktuální kalendářní minuty v Europe/Prague."""
|
||||
p = datetime.now(PRAGUE_TZ).replace(second=0, microsecond=0)
|
||||
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:
|
||||
return False
|
||||
if last.tzinfo is None:
|
||||
last = last.replace(tzinfo=timezone.utc)
|
||||
return last.astimezone(timezone.utc) == _prague_minute_start_utc()
|
||||
|
||||
|
||||
async def _fetch_last_verified_inverter_registers(
|
||||
site_id: int, inverter_asset_id: int, db: asyncpg.Connection
|
||||
) -> dict[int, int]:
|
||||
"""
|
||||
Poslední hodnota na zařízení podle journalu (jen status verified).
|
||||
Slouží k přeskočení duplicitního zápisu stejné hodnoty.
|
||||
"""
|
||||
rows = await db.fetch(
|
||||
"""
|
||||
SELECT DISTINCT ON (register)
|
||||
register,
|
||||
value_verified
|
||||
FROM ems.modbus_command
|
||||
WHERE site_id = $1
|
||||
AND asset_type = 'inverter'
|
||||
AND asset_id = $2
|
||||
AND status = 'verified'
|
||||
AND value_verified IS NOT NULL
|
||||
ORDER BY register, verified_at DESC NULLS LAST, id DESC
|
||||
""",
|
||||
site_id,
|
||||
inverter_asset_id,
|
||||
)
|
||||
return {int(r["register"]): int(r["value_verified"]) for r in rows}
|
||||
|
||||
|
||||
def _drop_registers_matching_last_verified(
|
||||
registers: list[tuple[int, str, int]],
|
||||
last_verified: dict[int, int],
|
||||
) -> tuple[list[tuple[int, str, int]], list[int]]:
|
||||
"""Vynechá položky s hodnotou shodnou s posledním ověřeným stavem; vrátí (nový seznam, vynechané reg)."""
|
||||
out: list[tuple[int, str, int]] = []
|
||||
skipped: list[int] = []
|
||||
for reg, meta, val in registers:
|
||||
lv = last_verified.get(int(reg))
|
||||
if lv is not None and lv == int(val):
|
||||
skipped.append(int(reg))
|
||||
continue
|
||||
out.append((reg, meta, val))
|
||||
return out, skipped
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -181,81 +256,115 @@ async def create_modbus_commands(
|
||||
return ids
|
||||
|
||||
|
||||
def _modbus_command_contiguous_runs(cmds: list[asyncpg.Record]) -> list[list[asyncpg.Record]]:
|
||||
"""Seřadí podle adresy registru a rozdělí na souvislé úseky pro FC 0x10 / FC 3."""
|
||||
if not cmds:
|
||||
return []
|
||||
sorted_cmds = sorted(cmds, key=lambda c: int(c["register"]))
|
||||
runs: list[list[asyncpg.Record]] = []
|
||||
cur: list[asyncpg.Record] = [sorted_cmds[0]]
|
||||
for c in sorted_cmds[1:]:
|
||||
if int(c["register"]) == int(cur[-1]["register"]) + 1:
|
||||
cur.append(c)
|
||||
else:
|
||||
runs.append(cur)
|
||||
cur = [c]
|
||||
runs.append(cur)
|
||||
return runs
|
||||
|
||||
|
||||
async def execute_modbus_commands(
|
||||
command_ids: list[int],
|
||||
db: asyncpg.Connection,
|
||||
) -> bool:
|
||||
"""
|
||||
Zapíše příkazy z modbus_command do zařízení.
|
||||
Zapíše příkazy z modbus_command do zařízení (FC 0x10 po souvislých blocích).
|
||||
Aktualizuje status na 'written' nebo 'failed'.
|
||||
Vrátí True pokud všechny příkazy uspěly.
|
||||
"""
|
||||
MAX_RETRIES = 3
|
||||
RETRY_DELAY = 0.5
|
||||
|
||||
all_ok = True
|
||||
rows: list[asyncpg.Record] = []
|
||||
for cmd_id in command_ids:
|
||||
cmd = await db.fetchrow(
|
||||
"SELECT * FROM ems.modbus_command WHERE id=$1", cmd_id
|
||||
)
|
||||
if cmd is None:
|
||||
continue
|
||||
unit = int(cmd["device_unit_id"])
|
||||
client = await get_modbus_client(cmd["device_host"], int(cmd["device_port"]))
|
||||
for attempt in range(MAX_RETRIES):
|
||||
try:
|
||||
await client.write_registers(
|
||||
int(cmd["register"]),
|
||||
[int(cmd["value_to_write"])],
|
||||
unit,
|
||||
)
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE ems.modbus_command
|
||||
SET status='written', value_written=$1, written_at=now(),
|
||||
attempt_count=attempt_count+1, error_msg=NULL
|
||||
WHERE id=$2
|
||||
""",
|
||||
int(cmd["value_to_write"]),
|
||||
cmd_id,
|
||||
)
|
||||
logger.info(
|
||||
"[cmd %s] %s 0x%04X=%s OK (attempt %s)",
|
||||
cmd_id,
|
||||
cmd["asset_code"],
|
||||
int(cmd["register"]),
|
||||
int(cmd["value_to_write"]),
|
||||
attempt + 1,
|
||||
)
|
||||
break
|
||||
except Exception as e:
|
||||
if attempt < MAX_RETRIES - 1:
|
||||
logger.warning(
|
||||
"[cmd %s] attempt %s failed: %s, retrying...",
|
||||
cmd_id,
|
||||
attempt + 1,
|
||||
e,
|
||||
)
|
||||
await asyncio.sleep(RETRY_DELAY)
|
||||
await client.force_disconnect()
|
||||
else:
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE ems.modbus_command
|
||||
SET status='failed', error_msg=$1,
|
||||
attempt_count=attempt_count+1
|
||||
WHERE id=$2
|
||||
""",
|
||||
str(e),
|
||||
cmd_id,
|
||||
)
|
||||
logger.error(
|
||||
"[cmd %s] all %s attempts failed: %s",
|
||||
cmd_id,
|
||||
MAX_RETRIES,
|
||||
e,
|
||||
)
|
||||
all_ok = False
|
||||
if cmd is not None:
|
||||
rows.append(cmd)
|
||||
|
||||
if not rows:
|
||||
return True
|
||||
|
||||
by_gw: dict[tuple[str, int, int], list[asyncpg.Record]] = defaultdict(list)
|
||||
for cmd in rows:
|
||||
by_gw[
|
||||
(cmd["device_host"], int(cmd["device_port"]), int(cmd["device_unit_id"]))
|
||||
].append(cmd)
|
||||
|
||||
all_ok = True
|
||||
for (host, port, unit), group in by_gw.items():
|
||||
client = await get_modbus_client(host, port)
|
||||
for run in _modbus_command_contiguous_runs(group):
|
||||
start_reg = int(run[0]["register"])
|
||||
values = [int(c["value_to_write"]) for c in run]
|
||||
ids_run = [int(c["id"]) for c in run]
|
||||
for attempt in range(MAX_RETRIES):
|
||||
try:
|
||||
await client.write_registers(start_reg, values, unit)
|
||||
for cmd, val in zip(run, values):
|
||||
cid = int(cmd["id"])
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE ems.modbus_command
|
||||
SET status='written', value_written=$1, written_at=now(),
|
||||
attempt_count=attempt_count+1, error_msg=NULL
|
||||
WHERE id=$2
|
||||
""",
|
||||
val,
|
||||
cid,
|
||||
)
|
||||
logger.info(
|
||||
"[cmd %s] %s 0x%04X=%s OK batch@%s (attempt %s)",
|
||||
cid,
|
||||
cmd["asset_code"],
|
||||
int(cmd["register"]),
|
||||
val,
|
||||
start_reg,
|
||||
attempt + 1,
|
||||
)
|
||||
break
|
||||
except Exception as e:
|
||||
if attempt < MAX_RETRIES - 1:
|
||||
logger.warning(
|
||||
"Modbus batch write 0x%04X count=%s attempt %s failed: %s, retrying...",
|
||||
start_reg,
|
||||
len(values),
|
||||
attempt + 1,
|
||||
e,
|
||||
)
|
||||
await asyncio.sleep(RETRY_DELAY)
|
||||
await client.force_disconnect()
|
||||
else:
|
||||
for cmd in run:
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE ems.modbus_command
|
||||
SET status='failed', error_msg=$1,
|
||||
attempt_count=attempt_count+1
|
||||
WHERE id=$2
|
||||
""",
|
||||
str(e),
|
||||
int(cmd["id"]),
|
||||
)
|
||||
logger.error(
|
||||
"Modbus batch 0x%04X count=%s all %s attempts failed: %s",
|
||||
start_reg,
|
||||
len(values),
|
||||
MAX_RETRIES,
|
||||
e,
|
||||
)
|
||||
all_ok = False
|
||||
|
||||
return all_ok
|
||||
|
||||
@@ -279,7 +388,7 @@ async def verify_modbus_commands(
|
||||
site_id: int,
|
||||
) -> bool:
|
||||
"""
|
||||
Přečte registry zpět a porovná s value_to_write.
|
||||
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.
|
||||
"""
|
||||
from services.notification_service import (
|
||||
@@ -287,98 +396,131 @@ async def verify_modbus_commands(
|
||||
notify_self_sustain_activated,
|
||||
)
|
||||
|
||||
all_ok = True
|
||||
async def _apply_verify_result(cmd: asyncpg.Record, actual_i: int) -> bool:
|
||||
"""Vrátí True při shodě, False při mismatch (a obslouží retry / SELF_SUSTAIN)."""
|
||||
cmd_id = int(cmd["id"])
|
||||
expected_i = int(cmd["value_to_write"])
|
||||
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
|
||||
WHERE id=$3::int
|
||||
""",
|
||||
actual_i,
|
||||
expected_i,
|
||||
cmd_id,
|
||||
)
|
||||
|
||||
if actual_i != expected_i:
|
||||
logger.error(
|
||||
"[cmd %s] MISMATCH %s 0x%04X: expected=%s actual=%s",
|
||||
cmd_id,
|
||||
cmd["asset_code"],
|
||||
int(cmd["register"]),
|
||||
expected_i,
|
||||
actual_i,
|
||||
)
|
||||
row_ac = await db.fetchrow(
|
||||
"SELECT attempt_count FROM ems.modbus_command WHERE id=$1", cmd_id
|
||||
)
|
||||
attempts = int(row_ac["attempt_count"] or 0) if row_ac else 0
|
||||
await notify_modbus_mismatch(
|
||||
cmd["asset_code"],
|
||||
int(cmd["register"]),
|
||||
cmd["register_name"] or "",
|
||||
expected_i,
|
||||
actual_i,
|
||||
attempts,
|
||||
)
|
||||
|
||||
if attempts < 3:
|
||||
await db.execute(
|
||||
"UPDATE ems.modbus_command SET status='retrying' WHERE id=$1",
|
||||
cmd_id,
|
||||
)
|
||||
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,
|
||||
)
|
||||
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: {cmd['asset_code']} "
|
||||
f"reg 0x{cmd['register']:04X}"
|
||||
),
|
||||
)
|
||||
if site:
|
||||
await notify_self_sustain_activated(
|
||||
site["code"],
|
||||
(
|
||||
f"Modbus mismatch: {cmd['asset_code']} "
|
||||
f"0x{cmd['register']:04X} expected={expected_i} "
|
||||
f"actual={actual_i}"
|
||||
),
|
||||
)
|
||||
return False
|
||||
|
||||
logger.info(
|
||||
"[cmd %s] verified OK: %s 0x%04X=%s",
|
||||
cmd_id,
|
||||
cmd["asset_code"],
|
||||
int(cmd["register"]),
|
||||
actual_i,
|
||||
)
|
||||
return True
|
||||
|
||||
cmds: list[asyncpg.Record] = []
|
||||
for cmd_id in command_ids:
|
||||
cmd = await db.fetchrow(
|
||||
"SELECT * FROM ems.modbus_command WHERE id=$1", cmd_id
|
||||
)
|
||||
if cmd is None or cmd["status"] != "written":
|
||||
continue
|
||||
if cmd is not None and cmd["status"] == "written":
|
||||
cmds.append(cmd)
|
||||
|
||||
try:
|
||||
unit = int(cmd["device_unit_id"])
|
||||
client = await get_modbus_client(cmd["device_host"], int(cmd["device_port"]))
|
||||
actual = await client.read_register(int(cmd["register"]), unit)
|
||||
actual_i = int(actual)
|
||||
expected_i = int(cmd["value_to_write"])
|
||||
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
|
||||
WHERE id=$3::int
|
||||
""",
|
||||
actual_i,
|
||||
expected_i,
|
||||
cmd_id,
|
||||
)
|
||||
if not cmds:
|
||||
return True
|
||||
|
||||
if actual_i != expected_i:
|
||||
by_gw: dict[tuple[str, int, int], list[asyncpg.Record]] = defaultdict(list)
|
||||
for cmd in cmds:
|
||||
by_gw[
|
||||
(cmd["device_host"], int(cmd["device_port"]), int(cmd["device_unit_id"]))
|
||||
].append(cmd)
|
||||
|
||||
all_ok = True
|
||||
for (host, port, unit), group in by_gw.items():
|
||||
client = await get_modbus_client(host, port)
|
||||
for run in _modbus_command_contiguous_runs(group):
|
||||
start_reg = int(run[0]["register"])
|
||||
n = len(run)
|
||||
try:
|
||||
values = await client.read_holding_registers(start_reg, n, unit)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"[cmd %s] MISMATCH %s 0x%04X: expected=%s actual=%s",
|
||||
cmd_id,
|
||||
cmd["asset_code"],
|
||||
int(cmd["register"]),
|
||||
expected_i,
|
||||
actual_i,
|
||||
"verify batch read 0x%04X count=%s failed: %s", start_reg, n, e
|
||||
)
|
||||
row_ac = await db.fetchrow(
|
||||
"SELECT attempt_count FROM ems.modbus_command WHERE id=$1", cmd_id
|
||||
)
|
||||
attempts = int(row_ac["attempt_count"] or 0) if row_ac else 0
|
||||
await notify_modbus_mismatch(
|
||||
cmd["asset_code"],
|
||||
int(cmd["register"]),
|
||||
cmd["register_name"] or "",
|
||||
expected_i,
|
||||
actual_i,
|
||||
attempts,
|
||||
)
|
||||
|
||||
if attempts < 3:
|
||||
await db.execute(
|
||||
"UPDATE ems.modbus_command SET status='retrying' WHERE id=$1",
|
||||
cmd_id,
|
||||
)
|
||||
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,
|
||||
)
|
||||
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: {cmd['asset_code']} "
|
||||
f"reg 0x{cmd['register']:04X}"
|
||||
),
|
||||
)
|
||||
if site:
|
||||
await notify_self_sustain_activated(
|
||||
site["code"],
|
||||
(
|
||||
f"Modbus mismatch: {cmd['asset_code']} "
|
||||
f"0x{cmd['register']:04X} expected={expected_i} "
|
||||
f"actual={actual_i}"
|
||||
),
|
||||
)
|
||||
all_ok = False
|
||||
else:
|
||||
logger.info(
|
||||
"[cmd %s] verified OK: %s 0x%04X=%s",
|
||||
cmd_id,
|
||||
cmd["asset_code"],
|
||||
int(cmd["register"]),
|
||||
actual_i,
|
||||
continue
|
||||
if len(values) != n:
|
||||
logger.error(
|
||||
"verify read 0x%04X: expected %s regs, got %s",
|
||||
start_reg,
|
||||
n,
|
||||
len(values),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("[cmd %s] verify read failed: %s", cmd_id, e)
|
||||
all_ok = False
|
||||
all_ok = False
|
||||
continue
|
||||
for cmd, actual in zip(run, values):
|
||||
matched = await _apply_verify_result(cmd, int(actual))
|
||||
if not matched:
|
||||
all_ok = False
|
||||
|
||||
return all_ok
|
||||
|
||||
@@ -442,9 +584,13 @@ async def _load_inverter_config(
|
||||
sgc.no_export,
|
||||
ai.max_battery_charge_w,
|
||||
ai.max_battery_discharge_w,
|
||||
ab.min_soc_percent,
|
||||
ab.reserve_soc_percent,
|
||||
ab.max_soc_percent,
|
||||
ab.usable_capacity_wh,
|
||||
ai.deye_last_system_time_sync_minute,
|
||||
ai.deye_last_tou_inactive_write_prague_date,
|
||||
ai.deye_tou_inactive_signature,
|
||||
LEAST(
|
||||
COALESCE(ab.bms_max_charge_w, ai.max_battery_charge_w),
|
||||
ai.max_battery_charge_w
|
||||
@@ -494,6 +640,9 @@ async def _load_inverter_config(
|
||||
max_battery_discharge_w=int(row["max_battery_discharge_w"])
|
||||
if row["max_battery_discharge_w"] is not None
|
||||
else None,
|
||||
min_soc_percent=int(round(float(row["min_soc_percent"])))
|
||||
if row["min_soc_percent"] is not None
|
||||
else None,
|
||||
reserve_soc_percent=int(row["reserve_soc_percent"])
|
||||
if row["reserve_soc_percent"] is not None
|
||||
else None,
|
||||
@@ -505,6 +654,11 @@ async def _load_inverter_config(
|
||||
else None,
|
||||
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_tou_inactive_write_prague_date=row[
|
||||
"deye_last_tou_inactive_write_prague_date"
|
||||
],
|
||||
deye_tou_inactive_signature=row["deye_tou_inactive_signature"],
|
||||
)
|
||||
|
||||
|
||||
@@ -705,6 +859,22 @@ def _deye_reg143_export_w(no_export: bool, max_export_power_w: int | None) -> in
|
||||
return max(0, int(max_export_power_w or 0))
|
||||
|
||||
|
||||
def _clamp_deye_tou_soc_pct(pct: int) -> int:
|
||||
return max(5, min(95, pct))
|
||||
|
||||
|
||||
def _deye_tou_min_soc_pct(inv: InverterConfig) -> int:
|
||||
if inv.min_soc_percent is not None:
|
||||
return _clamp_deye_tou_soc_pct(int(inv.min_soc_percent))
|
||||
return 10
|
||||
|
||||
|
||||
def _deye_tou_reserve_soc_pct(inv: InverterConfig) -> int:
|
||||
if inv.reserve_soc_percent is not None:
|
||||
return _clamp_deye_tou_soc_pct(int(inv.reserve_soc_percent))
|
||||
return 20
|
||||
|
||||
|
||||
def get_deye_mode(setpoints: ControlSetpoints) -> str:
|
||||
"""
|
||||
Fyzický režim Deye: SELL | CHARGE | PASSIVE.
|
||||
@@ -731,11 +901,12 @@ def _deye_tou_params(
|
||||
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.
|
||||
"""
|
||||
reserve_soc = inv.reserve_soc_percent or 20
|
||||
max_batt_w_discharge = int(inv.max_discharge_a * BATT_VOLTAGE_V)
|
||||
tp_discharge_w = 0 if setpoints.lock_battery else max_batt_w_discharge
|
||||
tou_min = _deye_tou_min_soc_pct(inv)
|
||||
tou_reserve = _deye_tou_reserve_soc_pct(inv)
|
||||
if setpoints.lock_battery:
|
||||
return tp_discharge_w, reserve_soc, False
|
||||
return tp_discharge_w, tou_min, False
|
||||
deye_mode = get_deye_mode(setpoints)
|
||||
if deye_mode == "CHARGE":
|
||||
raw_bat = setpoints.battery_w
|
||||
@@ -744,7 +915,9 @@ def _deye_tou_params(
|
||||
target_soc = max(10, min(95, cap))
|
||||
tp_charge_w = battery_watts_to_amps(battery_w, inv.max_charge_a) * int(BATT_VOLTAGE_V)
|
||||
return tp_charge_w, target_soc, True
|
||||
return tp_discharge_w, reserve_soc, False
|
||||
if deye_mode == "SELL":
|
||||
return tp_discharge_w, tou_reserve, False
|
||||
return tp_discharge_w, tou_min, False
|
||||
|
||||
|
||||
async def write_inverter_setpoints(
|
||||
@@ -762,9 +935,10 @@ 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)
|
||||
reserve_soc = inv.reserve_soc_percent or 20
|
||||
max_batt_w_discharge = int(inv.max_discharge_a * 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)
|
||||
|
||||
try:
|
||||
soc_telemetry = await _get_current_soc(site_id, db)
|
||||
@@ -795,9 +969,17 @@ async def write_inverter_setpoints(
|
||||
)
|
||||
|
||||
now_cet, time_rows = _deye_system_time_register_rows()
|
||||
logger.info("Deye time synced: %s CET", now_cet.strftime("%Y-%m-%d %H:%M:%S"))
|
||||
skip_time = _deye_skip_time_registers(inv)
|
||||
if skip_time:
|
||||
logger.info(
|
||||
"Deye clock 62–64 skipped (same Prague minute as last sync): %s CET",
|
||||
now_cet.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
)
|
||||
else:
|
||||
logger.info("Deye time will sync: %s CET", now_cet.strftime("%Y-%m-%d %H:%M:%S"))
|
||||
|
||||
registers: list[tuple[int, str, int]] = list(time_rows)
|
||||
registers: list[tuple[int, str, int]] = [] if skip_time else list(time_rows)
|
||||
time_rows_were_scheduled = not skip_time
|
||||
|
||||
sp_tp2 = setpoints_next if setpoints_next is not None else setpoints_now
|
||||
hh_cur = current_slot_hhmm()
|
||||
@@ -807,11 +989,24 @@ async def write_inverter_setpoints(
|
||||
registers.extend(_deye_time_point_rows(0, hh_cur, p1, s1, g1))
|
||||
registers.extend(_deye_time_point_rows(1, hh_nxt, p2, s2, g2))
|
||||
|
||||
for idx in range(2, 6):
|
||||
registers.extend(
|
||||
_deye_time_point_rows(
|
||||
idx, DEYE_TOU_INACTIVE_HHMM, tp_discharge_w, reserve_soc, False
|
||||
prague_date = datetime.now(PRAGUE_TZ).date()
|
||||
inactive_sig = (
|
||||
f"{DEYE_TOU_INACTIVE_HHMM}|{tou_min_pct}|{tou_reserve_pct}|{tp_discharge_w}"
|
||||
)
|
||||
need_inactive_tou = (
|
||||
inv.deye_last_tou_inactive_write_prague_date != prague_date
|
||||
or inv.deye_tou_inactive_signature != inactive_sig
|
||||
)
|
||||
if need_inactive_tou:
|
||||
for idx in range(2, 6):
|
||||
registers.extend(
|
||||
_deye_time_point_rows(
|
||||
idx, DEYE_TOU_INACTIVE_HHMM, tp_discharge_w, tou_min_pct, False
|
||||
)
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"Deye TOU rows 3–6 skipped (already written today, signature unchanged)"
|
||||
)
|
||||
|
||||
registers.extend(
|
||||
@@ -841,6 +1036,53 @@ async def write_inverter_setpoints(
|
||||
grid_w,
|
||||
)
|
||||
|
||||
last_verified = await _fetch_last_verified_inverter_registers(site_id, inv.id, db)
|
||||
registers, skipped_unchanged = _drop_registers_matching_last_verified(
|
||||
registers, last_verified
|
||||
)
|
||||
if skipped_unchanged:
|
||||
logger.info(
|
||||
"[control] %s: skip %s registers (value equals last verified): %s",
|
||||
inv.code,
|
||||
len(skipped_unchanged),
|
||||
skipped_unchanged[:24],
|
||||
)
|
||||
if not registers:
|
||||
logger.info(
|
||||
"[control] %s: all Deye holding regs match last verified, no Modbus write",
|
||||
inv.code,
|
||||
)
|
||||
if need_inactive_tou:
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE ems.asset_inverter
|
||||
SET deye_last_tou_inactive_write_prague_date = $1,
|
||||
deye_tou_inactive_signature = $2
|
||||
WHERE id = $3
|
||||
""",
|
||||
prague_date,
|
||||
inactive_sig,
|
||||
inv.id,
|
||||
)
|
||||
if time_rows_were_scheduled:
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE ems.asset_inverter
|
||||
SET deye_last_system_time_sync_minute = $1
|
||||
WHERE id = $2
|
||||
""",
|
||||
_prague_minute_start_utc(),
|
||||
inv.id,
|
||||
)
|
||||
return (
|
||||
f"OK inverter: batt_w={raw_bat!r} (no changes vs last verified Modbus snapshot)"
|
||||
)
|
||||
|
||||
will_write_time = any(int(r) in (62, 63, 64) for r, _, _ in registers)
|
||||
will_write_inactive = any(
|
||||
int(r) in _DEYE_INACTIVE_TOU_REGISTERS for r, _, _ in registers
|
||||
)
|
||||
|
||||
cmd_ids = await create_modbus_commands(
|
||||
site_id,
|
||||
planning_run_id,
|
||||
@@ -857,6 +1099,30 @@ async def write_inverter_setpoints(
|
||||
if not await execute_modbus_commands(cmd_ids, db):
|
||||
return f"FAIL inverter: {inv.code}: Modbus write failed (see modbus_command)"
|
||||
logger.info("[control] Inverter %s journal write OK", inv.code)
|
||||
|
||||
minute_utc = _prague_minute_start_utc()
|
||||
if will_write_time:
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE ems.asset_inverter
|
||||
SET deye_last_system_time_sync_minute = $1
|
||||
WHERE id = $2
|
||||
""",
|
||||
minute_utc,
|
||||
inv.id,
|
||||
)
|
||||
if need_inactive_tou or will_write_inactive:
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE ems.asset_inverter
|
||||
SET deye_last_tou_inactive_write_prague_date = $1,
|
||||
deye_tou_inactive_signature = $2
|
||||
WHERE id = $3
|
||||
""",
|
||||
prague_date,
|
||||
inactive_sig,
|
||||
inv.id,
|
||||
)
|
||||
except Exception as e:
|
||||
return f"FAIL inverter: {inv.code}: {e}"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user