second version
This commit is contained in:
@@ -6,15 +6,51 @@ import asyncio
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from datetime import datetime, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import asyncpg
|
||||
import httpx
|
||||
|
||||
from app.config import get_settings
|
||||
from services.telemetry_collector import ModbusDevice
|
||||
from services.modbus_client import get_modbus_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Deye LV baterie: převod výkon → proud pro registry 108/109 (viz docs/04-modules/modbus-registers.md)
|
||||
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)
|
||||
|
||||
DEYE_REGISTER_NAMES: dict[int, str] = {
|
||||
108: "max_charge_a (max nabíjecí proud baterie)",
|
||||
109: "max_discharge_a (max vybíjecí proud baterie)",
|
||||
141: "energy_mode (0, EMS nemění)",
|
||||
142: "limit_control (0=selling first, 1=zero export built-in CT)",
|
||||
143: "export_limit_w (max export do sítě)",
|
||||
178: "grid_peak_shaving_switch (SELL=32 bit4-5=10, PASSIVE/CHARGE=48 bit4-5=11)",
|
||||
148: "time_point_1_time",
|
||||
149: "time_point_2_time",
|
||||
154: "time_point_1_power_w",
|
||||
155: "time_point_2_power_w",
|
||||
166: "time_point_1_soc_min_pct",
|
||||
167: "time_point_2_soc_min_pct",
|
||||
172: "time_point_1_grid_charge",
|
||||
173: "time_point_2_grid_charge",
|
||||
62: "system_time_year_month",
|
||||
63: "system_time_day_hour",
|
||||
64: "system_time_min_sec",
|
||||
}
|
||||
for _tp_i in range(6):
|
||||
_n = _tp_i + 1
|
||||
DEYE_REGISTER_NAMES.setdefault(148 + _tp_i, f"time_point_{_n}_time")
|
||||
DEYE_REGISTER_NAMES.setdefault(154 + _tp_i, f"time_point_{_n}_power_w")
|
||||
DEYE_REGISTER_NAMES.setdefault(166 + _tp_i, f"time_point_{_n}_soc_min_pct")
|
||||
DEYE_REGISTER_NAMES.setdefault(172 + _tp_i, f"time_point_{_n}_grid_charge")
|
||||
|
||||
|
||||
def watts_to_amps(power_w: int | None, phases: int = 3, voltage: int = 230) -> int:
|
||||
if not power_w or power_w <= 0:
|
||||
@@ -22,6 +58,50 @@ def watts_to_amps(power_w: int | None, phases: int = 3, voltage: int = 230) -> i
|
||||
return min(32, max(0, int(power_w / (phases * voltage))))
|
||||
|
||||
|
||||
def battery_watts_to_amps(power_w: int, max_amps: int) -> int:
|
||||
"""Proud z |výkonu| baterie; max_amps výhradně z DB (_load_inverter_config)."""
|
||||
return min(max(0, max_amps), max(0, round(abs(power_w) / BATT_VOLTAGE_V)))
|
||||
|
||||
|
||||
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"))
|
||||
slot_min = (now.minute // 15) * 15
|
||||
return now.hour * 100 + slot_min
|
||||
|
||||
|
||||
def next_slot_hhmm() -> int:
|
||||
"""Začátek příštího 15min slotu v Europe/Prague, formát HHMM (např. 1430)."""
|
||||
now = datetime.now(ZoneInfo("Europe/Prague"))
|
||||
minutes = now.minute
|
||||
slot_minutes = ((minutes // 15) + 1) * 15
|
||||
if slot_minutes >= 60:
|
||||
next_hour = (now.hour + 1) % 24
|
||||
next_min = 0
|
||||
else:
|
||||
next_hour = now.hour
|
||||
next_min = slot_minutes
|
||||
return next_hour * 100 + next_min
|
||||
|
||||
|
||||
@dataclass
|
||||
class InverterConfig:
|
||||
id: int
|
||||
code: str
|
||||
host: str
|
||||
port: int
|
||||
unit_id: int
|
||||
max_export_power_w: int | None
|
||||
max_import_power_w: int | None
|
||||
no_export: bool
|
||||
max_battery_charge_w: int | None
|
||||
max_battery_discharge_w: int | None
|
||||
reserve_soc_percent: int | None
|
||||
usable_capacity_wh: int | None
|
||||
max_charge_a: int
|
||||
max_discharge_a: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class ControlSetpoints:
|
||||
battery_w: int | None
|
||||
@@ -32,6 +112,9 @@ class ControlSetpoints:
|
||||
grid_setpoint_w: int
|
||||
ev1_power_w: int
|
||||
ev2_power_w: int
|
||||
target_soc_pct: int | None = None
|
||||
#: True = reg 108/109 na 0 (PRESERVE – Deye baterii nepoužívá)
|
||||
lock_battery: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -44,8 +127,253 @@ class OperatingModeInfo:
|
||||
loxone_mode_value: int
|
||||
|
||||
|
||||
def _clamp_u16(value: int) -> int:
|
||||
return max(0, min(65535, int(value)))
|
||||
async def create_modbus_commands(
|
||||
site_id: int,
|
||||
planning_run_id: int | None,
|
||||
asset_type: str,
|
||||
asset_id: int,
|
||||
asset_code: str,
|
||||
host: str,
|
||||
port: int,
|
||||
unit_id: int,
|
||||
registers: list[tuple[int, str, int]],
|
||||
db: asyncpg.Connection,
|
||||
deye_physical_mode: str | None = None,
|
||||
) -> list[int]:
|
||||
"""
|
||||
Vytvoří záznamy v modbus_command pro sadu zápisů.
|
||||
Vrátí list command IDs.
|
||||
Pro Deye se jméno registru bere z DEYE_REGISTER_NAMES (prostřední položka tuplu se ignoruje).
|
||||
"""
|
||||
ids: list[int] = []
|
||||
for reg, _ignored_name, val in registers:
|
||||
register_name = DEYE_REGISTER_NAMES.get(reg, f"reg_{reg}")
|
||||
cmd_id = await db.fetchval(
|
||||
"""
|
||||
INSERT INTO ems.modbus_command
|
||||
(site_id, asset_type, asset_id, asset_code,
|
||||
device_host, device_port, device_unit_id,
|
||||
register, register_name, value_to_write,
|
||||
planning_run_id, status, deye_physical_mode)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'pending',$12)
|
||||
RETURNING id
|
||||
""",
|
||||
site_id,
|
||||
asset_type,
|
||||
asset_id,
|
||||
asset_code,
|
||||
host,
|
||||
port,
|
||||
unit_id,
|
||||
reg,
|
||||
register_name,
|
||||
val,
|
||||
planning_run_id,
|
||||
deye_physical_mode,
|
||||
)
|
||||
if cmd_id is not None:
|
||||
ids.append(int(cmd_id))
|
||||
return ids
|
||||
|
||||
|
||||
async def execute_modbus_commands(
|
||||
command_ids: list[int],
|
||||
db: asyncpg.Connection,
|
||||
) -> bool:
|
||||
"""
|
||||
Zapíše příkazy z modbus_command do zařízení.
|
||||
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
|
||||
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
|
||||
client = await get_modbus_client(
|
||||
cmd["device_host"], int(cmd["device_port"]), int(cmd["device_unit_id"])
|
||||
)
|
||||
for attempt in range(MAX_RETRIES):
|
||||
try:
|
||||
await client.write_registers(
|
||||
int(cmd["register"]), [int(cmd["value_to_write"])]
|
||||
)
|
||||
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)
|
||||
client._client = None # force reconnect
|
||||
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
|
||||
|
||||
return all_ok
|
||||
|
||||
|
||||
async def _switch_to_self_sustain(site_id: int, db: asyncpg.Connection, reason: str) -> None:
|
||||
"""Přepne lokalitu na SELF_SUSTAIN a zaloguje důvod."""
|
||||
await db.execute(
|
||||
"SELECT ems.fn_set_mode($1, $2, $3, $4, $5)",
|
||||
site_id,
|
||||
"SELF_SUSTAIN",
|
||||
"system:mismatch",
|
||||
None,
|
||||
reason,
|
||||
)
|
||||
logger.critical("Site %s switched to SELF_SUSTAIN: %s", site_id, reason)
|
||||
|
||||
|
||||
async def verify_modbus_commands(
|
||||
command_ids: list[int],
|
||||
db: asyncpg.Connection,
|
||||
site_id: int,
|
||||
) -> bool:
|
||||
"""
|
||||
Přečte registry zpět a porovná s value_to_write.
|
||||
Při mismatch: retry → SELF_SUSTAIN + Discord.
|
||||
"""
|
||||
from services.notification_service import (
|
||||
notify_modbus_mismatch,
|
||||
notify_self_sustain_activated,
|
||||
)
|
||||
|
||||
all_ok = True
|
||||
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
|
||||
|
||||
try:
|
||||
client = await get_modbus_client(
|
||||
cmd["device_host"], int(cmd["device_port"]), int(cmd["device_unit_id"])
|
||||
)
|
||||
actual = await client.read_register(int(cmd["register"]))
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE ems.modbus_command
|
||||
SET value_verified=$1, verified_at=now(),
|
||||
status=CASE WHEN $1=$2 THEN 'verified' ELSE 'mismatch' END
|
||||
WHERE id=$3
|
||||
""",
|
||||
actual,
|
||||
int(cmd["value_to_write"]),
|
||||
cmd_id,
|
||||
)
|
||||
|
||||
if actual != int(cmd["value_to_write"]):
|
||||
logger.error(
|
||||
"[cmd %s] MISMATCH %s 0x%04X: expected=%s actual=%s",
|
||||
cmd_id,
|
||||
cmd["asset_code"],
|
||||
int(cmd["register"]),
|
||||
cmd["value_to_write"],
|
||||
actual,
|
||||
)
|
||||
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 "",
|
||||
int(cmd["value_to_write"]),
|
||||
actual,
|
||||
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={cmd['value_to_write']} "
|
||||
f"actual={actual}"
|
||||
),
|
||||
)
|
||||
all_ok = False
|
||||
else:
|
||||
logger.info(
|
||||
"[cmd %s] verified OK: %s 0x%04X=%s",
|
||||
cmd_id,
|
||||
cmd["asset_code"],
|
||||
int(cmd["register"]),
|
||||
actual,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("[cmd %s] verify read failed: %s", cmd_id, e)
|
||||
all_ok = False
|
||||
|
||||
return all_ok
|
||||
|
||||
|
||||
async def _fetch_operating_mode(site_id: int, db: asyncpg.Connection) -> OperatingModeInfo | None:
|
||||
@@ -80,21 +408,155 @@ async def _fetch_operating_mode(site_id: int, db: asyncpg.Connection) -> Operati
|
||||
)
|
||||
|
||||
|
||||
async def _fetch_current_slot_plan_row(site_id: int, db: asyncpg.Connection) -> asyncpg.Record | None:
|
||||
"""Řádek plánu pro následující 15min slot (export ~1 min před hranicí, např. 14:29 → 14:30–14:45)."""
|
||||
return await db.fetchrow(
|
||||
async def _get_current_soc(site_id: int, db: asyncpg.Connection) -> int:
|
||||
soc = await db.fetchval(
|
||||
"""
|
||||
SELECT battery_soc_percent
|
||||
FROM ems.telemetry_inverter
|
||||
WHERE site_id = $1 AND battery_soc_percent IS NOT NULL
|
||||
ORDER BY measured_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
return int(soc) if soc is not None else 50
|
||||
|
||||
|
||||
async def _load_inverter_config(
|
||||
site_id: int, db: asyncpg.Connection
|
||||
) -> InverterConfig | None:
|
||||
row = await db.fetchrow(
|
||||
"""
|
||||
SELECT
|
||||
ai.id, ai.code,
|
||||
se.host, se.port, se.unit_id,
|
||||
sgc.max_export_power_w,
|
||||
sgc.max_import_power_w,
|
||||
sgc.no_export,
|
||||
ai.max_battery_charge_w,
|
||||
ai.max_battery_discharge_w,
|
||||
ab.reserve_soc_percent,
|
||||
ab.usable_capacity_wh,
|
||||
LEAST(
|
||||
COALESCE(ab.bms_max_charge_w, ai.max_battery_charge_w),
|
||||
ai.max_battery_charge_w
|
||||
) / 51.2 AS max_charge_a,
|
||||
LEAST(
|
||||
COALESCE(ab.bms_max_discharge_w, ai.max_battery_discharge_w),
|
||||
ai.max_battery_discharge_w
|
||||
) / 51.2 AS max_discharge_a
|
||||
FROM ems.asset_inverter ai
|
||||
JOIN ems.site_endpoint se ON se.id = ai.endpoint_id
|
||||
JOIN ems.asset_battery ab ON ab.inverter_id = ai.id
|
||||
LEFT JOIN ems.site_grid_connection sgc ON sgc.site_id = ai.site_id
|
||||
WHERE ai.site_id = $1
|
||||
AND ai.active = true
|
||||
AND ai.controllable = true
|
||||
AND se.enabled = true
|
||||
AND se.endpoint_type = 'modbus_tcp'
|
||||
ORDER BY ai.id
|
||||
LIMIT 1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
if row is None:
|
||||
return None
|
||||
mc = row["max_charge_a"]
|
||||
md = row["max_discharge_a"]
|
||||
max_charge_a = int(mc) if mc is not None else 0
|
||||
max_discharge_a = int(md) if md is not None else 0
|
||||
port = int(row["port"] or 502)
|
||||
uid = int(row["unit_id"] if row["unit_id"] is not None else 1)
|
||||
return InverterConfig(
|
||||
id=int(row["id"]),
|
||||
code=row["code"],
|
||||
host=row["host"],
|
||||
port=port,
|
||||
unit_id=uid,
|
||||
max_export_power_w=int(row["max_export_power_w"])
|
||||
if row["max_export_power_w"] is not None
|
||||
else None,
|
||||
max_import_power_w=int(row["max_import_power_w"])
|
||||
if row["max_import_power_w"] is not None
|
||||
else None,
|
||||
no_export=bool(row["no_export"] or False),
|
||||
max_battery_charge_w=int(row["max_battery_charge_w"])
|
||||
if row["max_battery_charge_w"] is not None
|
||||
else None,
|
||||
max_battery_discharge_w=int(row["max_battery_discharge_w"])
|
||||
if row["max_battery_discharge_w"] is not None
|
||||
else None,
|
||||
reserve_soc_percent=int(row["reserve_soc_percent"])
|
||||
if row["reserve_soc_percent"] is not None
|
||||
else None,
|
||||
usable_capacity_wh=int(row["usable_capacity_wh"])
|
||||
if row["usable_capacity_wh"] is not None
|
||||
else None,
|
||||
max_charge_a=max_charge_a,
|
||||
max_discharge_a=max_discharge_a,
|
||||
)
|
||||
|
||||
|
||||
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"))
|
||||
reg62 = ((now.year - 2000) << 8) | now.month
|
||||
reg63 = (now.day << 8) | now.hour
|
||||
reg64 = (now.minute << 8) | now.second
|
||||
rows = [
|
||||
(62, "", reg62),
|
||||
(63, "", reg63),
|
||||
(64, "", reg64),
|
||||
]
|
||||
return now, rows
|
||||
|
||||
|
||||
def _deye_time_point_rows(
|
||||
slot_index: int,
|
||||
time_hhmm: int,
|
||||
power_w: int,
|
||||
soc_pct: int,
|
||||
grid_charge: bool,
|
||||
) -> list[tuple[int, str, int]]:
|
||||
g = 1 if grid_charge else 0
|
||||
return [
|
||||
(148 + slot_index, "", time_hhmm),
|
||||
(154 + slot_index, "", power_w),
|
||||
(166 + slot_index, "", soc_pct),
|
||||
(172 + slot_index, "", g),
|
||||
]
|
||||
|
||||
|
||||
def _slot_start_prague_sql(slot_offset: int) -> str:
|
||||
"""Výraz TIMESTAMPTZ = začátek aktuálního (+offset) 15min slotu v Europe/Prague."""
|
||||
off = int(slot_offset)
|
||||
return f"""
|
||||
(
|
||||
WITH loc AS (SELECT now() AT TIME ZONE 'Europe/Prague' AS ts)
|
||||
SELECT (
|
||||
(date_trunc('day', ts)
|
||||
+ make_interval(
|
||||
hours => EXTRACT(HOUR FROM ts)::int,
|
||||
mins => (FLOOR(EXTRACT(MINUTE FROM ts) / 15) * 15)::int
|
||||
)
|
||||
)::timestamp AT TIME ZONE 'Europe/Prague'
|
||||
) + INTERVAL '{off * 15} minutes'
|
||||
FROM loc
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
async def _fetch_plan_row_for_slot_offset(
|
||||
site_id: int, db: asyncpg.Connection, slot_offset: int
|
||||
) -> asyncpg.Record | None:
|
||||
"""Řádek plánu pro slot: 0 = probíhající 15min, 1 = následující (hranice v Europe/Prague)."""
|
||||
t = _slot_start_prague_sql(slot_offset)
|
||||
return await db.fetchrow(
|
||||
f"""
|
||||
SELECT pi.* FROM ems.planning_interval pi
|
||||
JOIN ems.planning_run pr ON pr.id = pi.run_id
|
||||
WHERE pr.site_id = $1 AND pr.status = 'active'
|
||||
AND pi.interval_start = (
|
||||
SELECT MIN(pi2.interval_start) FROM ems.planning_interval pi2
|
||||
JOIN ems.planning_run pr2 ON pr2.id = pi2.run_id
|
||||
WHERE pr2.site_id = $1 AND pr2.status = 'active'
|
||||
AND pi2.interval_start >= date_trunc('hour', now())
|
||||
+ INTERVAL '15 min' * FLOOR(EXTRACT(MINUTE FROM now()) / 15)
|
||||
+ INTERVAL '15 minutes'
|
||||
)
|
||||
AND pi.interval_start = {t}
|
||||
LIMIT 1
|
||||
""",
|
||||
site_id,
|
||||
@@ -104,10 +566,20 @@ async def _fetch_current_slot_plan_row(site_id: int, db: asyncpg.Connection) ->
|
||||
async def _fetch_max_charge_power_w(site_id: int, db: asyncpg.Connection) -> int:
|
||||
v = await db.fetchval(
|
||||
"""
|
||||
SELECT ai.max_charge_power_w
|
||||
FROM ems.asset_inverter ai
|
||||
WHERE ai.site_id = $1 AND ai.controllable = true AND ai.active = true
|
||||
ORDER BY ai.id
|
||||
SELECT LEAST(
|
||||
COALESCE(ai.max_battery_charge_w, ai.max_charge_power_w),
|
||||
COALESCE(
|
||||
ab.bms_max_charge_w,
|
||||
CASE WHEN ab.max_charge_c_rate IS NOT NULL
|
||||
THEN (ab.max_charge_c_rate * ab.usable_capacity_wh)::bigint
|
||||
END,
|
||||
COALESCE(ai.max_battery_charge_w, ai.max_charge_power_w)
|
||||
)
|
||||
) AS effective_charge_w
|
||||
FROM ems.asset_battery ab
|
||||
JOIN ems.asset_inverter ai ON ai.id = ab.inverter_id AND ai.site_id = ab.site_id
|
||||
WHERE ab.site_id = $1 AND ai.controllable = true AND ai.active = true
|
||||
ORDER BY ab.id
|
||||
LIMIT 1
|
||||
""",
|
||||
site_id,
|
||||
@@ -129,6 +601,8 @@ def _build_setpoints(mode: OperatingModeInfo, pi: asyncpg.Record | None) -> Cont
|
||||
ev1_w = int(pi["ev1_setpoint_w"] or 0) if "ev1_setpoint_w" in pi else 0
|
||||
ev2_w = int(pi["ev2_setpoint_w"] or 0) if "ev2_setpoint_w" in pi else 0
|
||||
hp_en = bool(pi["heat_pump_enabled"])
|
||||
tgt = pi["battery_soc_target_pct"]
|
||||
target_soc = int(round(float(tgt))) if tgt is not None else None
|
||||
return ControlSetpoints(
|
||||
battery_w=int(pi["battery_setpoint_w"] or 0),
|
||||
grid_export_limit=abs(min(grid_sp, 0)),
|
||||
@@ -138,6 +612,7 @@ def _build_setpoints(mode: OperatingModeInfo, pi: asyncpg.Record | None) -> Cont
|
||||
grid_setpoint_w=grid_sp,
|
||||
ev1_power_w=ev1_w,
|
||||
ev2_power_w=ev2_w,
|
||||
target_soc_pct=target_soc,
|
||||
)
|
||||
|
||||
if code == "SELF_SUSTAIN":
|
||||
@@ -150,6 +625,7 @@ def _build_setpoints(mode: OperatingModeInfo, pi: asyncpg.Record | None) -> Cont
|
||||
grid_setpoint_w=0,
|
||||
ev1_power_w=0,
|
||||
ev2_power_w=0,
|
||||
target_soc_pct=None,
|
||||
)
|
||||
|
||||
if code == "CHARGE_CHEAP":
|
||||
@@ -163,6 +639,7 @@ def _build_setpoints(mode: OperatingModeInfo, pi: asyncpg.Record | None) -> Cont
|
||||
grid_setpoint_w=0,
|
||||
ev1_power_w=0,
|
||||
ev2_power_w=0,
|
||||
target_soc_pct=None,
|
||||
)
|
||||
|
||||
if code == "PRESERVE":
|
||||
@@ -175,62 +652,240 @@ def _build_setpoints(mode: OperatingModeInfo, pi: asyncpg.Record | None) -> Cont
|
||||
grid_setpoint_w=0,
|
||||
ev1_power_w=0,
|
||||
ev2_power_w=0,
|
||||
target_soc_pct=None,
|
||||
lock_battery=True,
|
||||
)
|
||||
|
||||
logger.warning("Unknown mode_code %s for site export, skipping", code)
|
||||
return None
|
||||
|
||||
|
||||
async def write_inverter_setpoints(site_id: int, setpoints: ControlSetpoints, db: asyncpg.Connection) -> str:
|
||||
if setpoints.battery_w is None:
|
||||
return "OK inverter: skipped (battery_w=None, Deye unchanged)"
|
||||
|
||||
rows = await db.fetch(
|
||||
"""
|
||||
SELECT ai.code, se.host, se.port, se.unit_id
|
||||
FROM ems.asset_inverter ai
|
||||
JOIN ems.site_endpoint se ON se.id = ai.endpoint_id
|
||||
WHERE ai.site_id = $1
|
||||
AND ai.controllable = true
|
||||
AND ai.active = true
|
||||
AND se.enabled = true
|
||||
AND se.endpoint_type = 'modbus_tcp'
|
||||
""",
|
||||
def _apply_price_failsafe_guard(
|
||||
site_id: int,
|
||||
mode: OperatingModeInfo,
|
||||
pi: asyncpg.Record | None,
|
||||
sp: ControlSetpoints,
|
||||
) -> ControlSetpoints:
|
||||
if mode.mode_code != "AUTO" or pi is None:
|
||||
return sp
|
||||
if "is_predicted_price" not in pi or not bool(pi["is_predicted_price"]):
|
||||
return sp
|
||||
logger.warning(
|
||||
"control export site=%s: AUTO slot uses predicted price -> forcing PASSIVE no-export guard",
|
||||
site_id,
|
||||
)
|
||||
if not rows:
|
||||
return ControlSetpoints(
|
||||
battery_w=0,
|
||||
grid_export_limit=0,
|
||||
ev1_current_a=sp.ev1_current_a,
|
||||
ev2_current_a=sp.ev2_current_a,
|
||||
heat_pump_enable=sp.heat_pump_enable,
|
||||
grid_setpoint_w=max(0, int(sp.grid_setpoint_w or 0)),
|
||||
ev1_power_w=sp.ev1_power_w,
|
||||
ev2_power_w=sp.ev2_power_w,
|
||||
target_soc_pct=sp.target_soc_pct,
|
||||
)
|
||||
|
||||
|
||||
def _deye_reg143_export_w(no_export: bool, max_export_power_w: int | None) -> int:
|
||||
"""Reg 143 – max export W z DB (např. SUN-20K / home-01 = 13 500 W)."""
|
||||
if no_export:
|
||||
return 0
|
||||
return max(0, int(max_export_power_w or 0))
|
||||
|
||||
|
||||
def get_deye_mode(setpoints: ControlSetpoints) -> str:
|
||||
"""
|
||||
Fyzický režim Deye: SELL | CHARGE | PASSIVE.
|
||||
Solver: záporný grid_setpoint_w = export; kladný výrazný + nabíjení = CHARGE ze sítě.
|
||||
battery_w=None (SELF_SUSTAIN) → bat_w považuj za 0 → typicky PASSIVE při grid_setpoint_w=0.
|
||||
"""
|
||||
grid_w = int(setpoints.grid_setpoint_w or 0)
|
||||
if setpoints.battery_w is None:
|
||||
bat_w = 0
|
||||
else:
|
||||
bat_w = int(setpoints.battery_w)
|
||||
if grid_w < -200:
|
||||
return "SELL"
|
||||
if bat_w > 500 and grid_w > 200:
|
||||
return "CHARGE"
|
||||
return "PASSIVE"
|
||||
|
||||
|
||||
def _deye_tou_params(
|
||||
setpoints: ControlSetpoints,
|
||||
inv: InverterConfig,
|
||||
) -> 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.
|
||||
"""
|
||||
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
|
||||
if setpoints.lock_battery:
|
||||
return tp_discharge_w, reserve_soc, False
|
||||
deye_mode = get_deye_mode(setpoints)
|
||||
if deye_mode == "CHARGE":
|
||||
raw_bat = setpoints.battery_w
|
||||
battery_w = int(raw_bat) if raw_bat is not None else 0
|
||||
target_soc = min(95, setpoints.target_soc_pct or 80)
|
||||
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
|
||||
|
||||
|
||||
async def write_inverter_setpoints(
|
||||
site_id: int,
|
||||
setpoints_now: ControlSetpoints,
|
||||
setpoints_next: ControlSetpoints | None,
|
||||
db: asyncpg.Connection,
|
||||
planning_run_id: int | None = None,
|
||||
) -> str:
|
||||
inv = await _load_inverter_config(site_id, db)
|
||||
if inv is None:
|
||||
return "FAIL inverter: no controllable Modbus endpoint"
|
||||
|
||||
bw = setpoints.battery_w
|
||||
gex = _clamp_u16(setpoints.grid_export_limit)
|
||||
chg = _clamp_u16(bw) if bw >= 0 else 0
|
||||
dis = _clamp_u16(abs(bw)) if bw < 0 else 0
|
||||
raw_bat = setpoints_now.battery_w
|
||||
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
|
||||
|
||||
errors: list[str] = []
|
||||
for row in rows:
|
||||
code = row["code"]
|
||||
host = row["host"]
|
||||
port = int(row["port"] or 502)
|
||||
unit_id = int(row["unit_id"] if row["unit_id"] is not None else 1)
|
||||
dev = ModbusDevice(host, port, unit_id, f"inverter-write:{code}")
|
||||
try:
|
||||
if bw >= 0:
|
||||
ok1 = await dev.write_register(0x00F3, chg)
|
||||
ok2 = await dev.write_register(0x00F4, 0)
|
||||
else:
|
||||
ok1 = await dev.write_register(0x00F3, 0)
|
||||
ok2 = await dev.write_register(0x00F4, dis)
|
||||
ok3 = await dev.write_register(0x00F6, gex)
|
||||
if not (ok1 and ok2 and ok3):
|
||||
errors.append(f"{code}: Modbus write failed")
|
||||
except Exception as e:
|
||||
errors.append(f"{code}: {e}")
|
||||
finally:
|
||||
await dev.close()
|
||||
try:
|
||||
soc_telemetry = await _get_current_soc(site_id, db)
|
||||
|
||||
if errors:
|
||||
return "FAIL inverter: " + "; ".join(errors)
|
||||
return f"OK inverter: batt_w={bw} export_limit_w={gex}"
|
||||
deye_mode = get_deye_mode(setpoints_now)
|
||||
|
||||
if setpoints_now.lock_battery:
|
||||
charge_a = 0
|
||||
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)
|
||||
discharge_a = 0
|
||||
else:
|
||||
charge_a = int(inv.max_charge_a)
|
||||
discharge_a = int(inv.max_discharge_a)
|
||||
|
||||
selling_mode = 0 if deye_mode == "SELL" else 1
|
||||
export_limit = export_lim
|
||||
reg178_val = REG178_SELL if deye_mode == "SELL" else REG178_PASSIVE
|
||||
|
||||
logger.info(
|
||||
f"[control] site={site_id} fyzický režim Deye: {deye_mode} | "
|
||||
f"battery_w={raw_bat!r} grid_w={grid_w} | "
|
||||
f"charge_a={charge_a} discharge_a={discharge_a} | "
|
||||
f"reg142={'0=SELL' if deye_mode == 'SELL' else '1=ZERO_EXP'} "
|
||||
f"reg178={reg178_val}"
|
||||
)
|
||||
|
||||
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"))
|
||||
|
||||
registers: list[tuple[int, str, int]] = list(time_rows)
|
||||
|
||||
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)
|
||||
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, 2359, tp_discharge_w, reserve_soc, False
|
||||
)
|
||||
)
|
||||
|
||||
registers.extend(
|
||||
[
|
||||
(108, "", charge_a),
|
||||
(109, "", discharge_a),
|
||||
(141, "energy_mode (0)", 0),
|
||||
(142, "limit_control (0=selling, 1=zero_export)", selling_mode),
|
||||
(178, "grid_peak_shaving_switch", reg178_val),
|
||||
(143, "", export_limit),
|
||||
]
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"[control] %s: deye_mode=%s charge=%sA discharge=%sA limit_control=%s export=%sW "
|
||||
"time_point1=%s time_point2=%s soc_telemetry=%s%% (batt=%r grid=%sW)",
|
||||
inv.code,
|
||||
deye_mode,
|
||||
charge_a,
|
||||
discharge_a,
|
||||
selling_mode,
|
||||
export_limit,
|
||||
hh_cur,
|
||||
hh_nxt,
|
||||
soc_telemetry,
|
||||
raw_bat,
|
||||
grid_w,
|
||||
)
|
||||
|
||||
cmd_ids = await create_modbus_commands(
|
||||
site_id,
|
||||
planning_run_id,
|
||||
"inverter",
|
||||
inv.id,
|
||||
inv.code,
|
||||
inv.host,
|
||||
inv.port,
|
||||
inv.unit_id,
|
||||
registers,
|
||||
db,
|
||||
deye_physical_mode=deye_mode,
|
||||
)
|
||||
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)
|
||||
except Exception as e:
|
||||
return f"FAIL inverter: {inv.code}: {e}"
|
||||
|
||||
return (
|
||||
f"OK inverter: batt_w={raw_bat!r} "
|
||||
f"(time points + FC 0x10: 108/109/141/142/178/143)"
|
||||
)
|
||||
|
||||
|
||||
async def read_deye_registers_live(site_id: int, db: asyncpg.Connection) -> dict[str, Any]:
|
||||
"""
|
||||
Živé čtení holding registrů Deye 108, 109, 141, 142, 143, 178, 191 (stejné TCP spojení jako telemetrie/export).
|
||||
"""
|
||||
inv = await _load_inverter_config(site_id, db)
|
||||
if inv is None:
|
||||
raise ValueError("no controllable Modbus inverter for site")
|
||||
|
||||
client = await get_modbus_client(inv.host, inv.port, inv.unit_id)
|
||||
read_at = datetime.now(timezone.utc)
|
||||
try:
|
||||
r108 = await client.read_register(108)
|
||||
r109 = await client.read_register(109)
|
||||
r141 = await client.read_register(141)
|
||||
r142 = await client.read_register(142)
|
||||
r143 = await client.read_register(143)
|
||||
r178 = await client.read_register(178)
|
||||
r191 = await client.read_register(191)
|
||||
except Exception:
|
||||
logger.exception("read_deye_registers_live site=%s failed", site_id)
|
||||
raise
|
||||
|
||||
return {
|
||||
"reg108_charge_a": int(r108),
|
||||
"reg109_discharge_a": int(r109),
|
||||
"reg141_energy_mode": int(r141),
|
||||
"reg142_limit_control": int(r142),
|
||||
"reg143_export_limit_w": int(r143),
|
||||
"reg178_peak_shaving_switch": int(r178),
|
||||
"reg191_peak_shaving_w": int(r191),
|
||||
"read_at": read_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def _current_limit_for_charger(charger_code: str, sp: ControlSetpoints) -> int:
|
||||
@@ -371,18 +1026,20 @@ async def export_setpoints(site_id: int, db: asyncpg.Connection) -> None:
|
||||
logger.info("control export site=%s: MANUAL, skip writes", site_id)
|
||||
return
|
||||
|
||||
pi = await _fetch_current_slot_plan_row(site_id, db)
|
||||
sp = _build_setpoints(mode, pi)
|
||||
pi_now = await _fetch_plan_row_for_slot_offset(site_id, db, 0)
|
||||
pi_next = await _fetch_plan_row_for_slot_offset(site_id, db, 1)
|
||||
sp_now = _build_setpoints(mode, pi_now)
|
||||
sp_next = _build_setpoints(mode, pi_next)
|
||||
|
||||
if mode.mode_code == "AUTO" and sp is None:
|
||||
if pi is None:
|
||||
if mode.mode_code == "AUTO" and sp_now is None:
|
||||
if pi_now is None:
|
||||
logger.warning(
|
||||
"control export site=%s: AUTO but no planning_interval for current slot, skip",
|
||||
site_id,
|
||||
)
|
||||
return
|
||||
|
||||
if sp is None:
|
||||
if sp_now is None:
|
||||
logger.warning(
|
||||
"control export site=%s: no setpoints for mode %s, skip",
|
||||
site_id,
|
||||
@@ -392,27 +1049,67 @@ async def export_setpoints(site_id: int, db: asyncpg.Connection) -> None:
|
||||
|
||||
if mode.mode_code == "CHARGE_CHEAP":
|
||||
max_ch = await _fetch_max_charge_power_w(site_id, db)
|
||||
sp = ControlSetpoints(
|
||||
# Kladný grid_setpoint_w > 200 → fyzický CHARGE (nabíjení ze sítě), viz get_deye_mode
|
||||
grid_for_charge = max(300, max_ch)
|
||||
sp_now = ControlSetpoints(
|
||||
battery_w=max_ch,
|
||||
grid_export_limit=0,
|
||||
ev1_current_a=0,
|
||||
ev2_current_a=0,
|
||||
heat_pump_enable=False,
|
||||
grid_setpoint_w=0,
|
||||
grid_setpoint_w=grid_for_charge,
|
||||
ev1_power_w=0,
|
||||
ev2_power_w=0,
|
||||
target_soc_pct=None,
|
||||
)
|
||||
sp_next = sp_now
|
||||
else:
|
||||
sp_now = _apply_price_failsafe_guard(site_id, mode, pi_now, sp_now)
|
||||
if sp_next is not None:
|
||||
sp_next = _apply_price_failsafe_guard(site_id, mode, pi_next, sp_next)
|
||||
|
||||
planning_run_id = await db.fetchval(
|
||||
"""
|
||||
SELECT id FROM ems.planning_run
|
||||
WHERE site_id = $1 AND status = 'active'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
if planning_run_id is not None:
|
||||
planning_run_id = int(planning_run_id)
|
||||
|
||||
try:
|
||||
inv_res = await write_inverter_setpoints(
|
||||
site_id, sp_now, sp_next, db, planning_run_id=planning_run_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("inverter write failed: %s", e)
|
||||
inv_res = f"FAIL inverter: {e}"
|
||||
|
||||
try:
|
||||
ev_res = await write_ev_setpoints(site_id, sp_now, db)
|
||||
except Exception as e:
|
||||
logger.error("ev write failed: %s", e)
|
||||
ev_res = f"FAIL ev: {e}"
|
||||
|
||||
try:
|
||||
hp_res = await write_heat_pump_setpoint(site_id, sp_now, db)
|
||||
except Exception as e:
|
||||
logger.error("hp write failed: %s", e)
|
||||
hp_res = f"FAIL heat pump: {e}"
|
||||
|
||||
try:
|
||||
lox_res = await send_loxone_setpoints(site_id, sp_now, mode, db)
|
||||
except Exception as e:
|
||||
logger.error("loxone write failed: %s", e)
|
||||
lox_res = f"FAIL Loxone: {e}"
|
||||
|
||||
results = list(
|
||||
zip(
|
||||
("inverter", "ev", "heat_pump", "loxone"),
|
||||
await asyncio.gather(
|
||||
write_inverter_setpoints(site_id, sp, db),
|
||||
write_ev_setpoints(site_id, sp, db),
|
||||
write_heat_pump_setpoint(site_id, sp, db),
|
||||
send_loxone_setpoints(site_id, sp, mode, db),
|
||||
return_exceptions=True,
|
||||
),
|
||||
(inv_res, ev_res, hp_res, lox_res),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user