Files
ems/backend/services/control_exporter.py
Dusan Vojacek 897b95f728 x
2026-03-20 14:30:03 +01:00

426 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Export plánovaných setpointů na Modbus (Deye, EV, TČ) a HTTP do Loxone."""
from __future__ import annotations
import asyncio
import logging
import os
from dataclasses import dataclass
from datetime import datetime, timezone
import asyncpg
import httpx
from app.config import get_settings
from services.telemetry_collector import ModbusDevice
logger = logging.getLogger(__name__)
def watts_to_amps(power_w: int | None, phases: int = 3, voltage: int = 230) -> int:
if not power_w or power_w <= 0:
return 0
return min(32, max(0, int(power_w / (phases * voltage))))
@dataclass
class ControlSetpoints:
battery_w: int | None
grid_export_limit: int
ev1_current_a: int
ev2_current_a: int
heat_pump_enable: bool
grid_setpoint_w: int
ev1_power_w: int
ev2_power_w: int
@dataclass
class OperatingModeInfo:
mode_code: str
battery_mode: str
grid_mode: str
ev_enabled: bool
heat_pump_enabled_def: bool
loxone_mode_value: int
def _clamp_u16(value: int) -> int:
return max(0, min(65535, int(value)))
async def _fetch_operating_mode(site_id: int, db: asyncpg.Connection) -> OperatingModeInfo | None:
sql = """
SELECT som.mode_code, omd.battery_mode, omd.grid_mode,
omd.ev_enabled, omd.heat_pump_enabled, omd.loxone_mode_value,
som.valid_until
FROM ems.site_operating_mode som
JOIN ems.operating_mode_def omd ON omd.code = som.mode_code
WHERE som.site_id = $1
"""
row = await db.fetchrow(sql, site_id)
if row is None:
return None
vu = row["valid_until"]
if vu is not None:
now_utc = datetime.now(timezone.utc)
if vu.tzinfo is None:
vu = vu.replace(tzinfo=timezone.utc)
if vu <= now_utc:
await db.execute("SELECT ems.fn_expire_modes()")
row = await db.fetchrow(sql, site_id)
if row is None:
return None
return OperatingModeInfo(
mode_code=row["mode_code"],
battery_mode=row["battery_mode"],
grid_mode=row["grid_mode"],
ev_enabled=bool(row["ev_enabled"]),
heat_pump_enabled_def=bool(row["heat_pump_enabled"]),
loxone_mode_value=int(row["loxone_mode_value"]),
)
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:3014:45)."""
return await db.fetchrow(
"""
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'
)
LIMIT 1
""",
site_id,
)
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
LIMIT 1
""",
site_id,
)
if v is None:
return 0
return int(v)
def _build_setpoints(mode: OperatingModeInfo, pi: asyncpg.Record | None) -> ControlSetpoints | None:
code = mode.mode_code
if code == "MANUAL":
return None
if code == "AUTO":
if pi is None:
return None
grid_sp = int(pi["grid_setpoint_w"] or 0)
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"])
return ControlSetpoints(
battery_w=int(pi["battery_setpoint_w"] or 0),
grid_export_limit=abs(min(grid_sp, 0)),
ev1_current_a=watts_to_amps(ev1_w, phases=3),
ev2_current_a=watts_to_amps(ev2_w, phases=1),
heat_pump_enable=hp_en,
grid_setpoint_w=grid_sp,
ev1_power_w=ev1_w,
ev2_power_w=ev2_w,
)
if code == "SELF_SUSTAIN":
return ControlSetpoints(
battery_w=None,
grid_export_limit=0,
ev1_current_a=0,
ev2_current_a=0,
heat_pump_enable=False,
grid_setpoint_w=0,
ev1_power_w=0,
ev2_power_w=0,
)
if code == "CHARGE_CHEAP":
# max_charge doplníme v export_setpoints z DB
return ControlSetpoints(
battery_w=0,
grid_export_limit=0,
ev1_current_a=0,
ev2_current_a=0,
heat_pump_enable=False,
grid_setpoint_w=0,
ev1_power_w=0,
ev2_power_w=0,
)
if code == "PRESERVE":
return ControlSetpoints(
battery_w=0,
grid_export_limit=0,
ev1_current_a=0,
ev2_current_a=0,
heat_pump_enable=False,
grid_setpoint_w=0,
ev1_power_w=0,
ev2_power_w=0,
)
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'
""",
site_id,
)
if not rows:
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
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()
if errors:
return "FAIL inverter: " + "; ".join(errors)
return f"OK inverter: batt_w={bw} export_limit_w={gex}"
def _current_limit_for_charger(charger_code: str, sp: ControlSetpoints) -> int:
c = (charger_code or "").strip().lower()
if c == "ev-charger-1":
a = sp.ev1_current_a
elif c == "ev-charger-2":
a = sp.ev2_current_a
elif c.endswith("-1") or c == "ev1":
a = sp.ev1_current_a
elif c.endswith("-2") or c == "ev2":
a = sp.ev2_current_a
else:
a = 0
if a < 6:
a = 0
return a
async def write_ev_setpoints(site_id: int, setpoints: ControlSetpoints, db: asyncpg.Connection) -> str:
rows = await db.fetch(
"""
SELECT ec.code, se.host, se.port, se.unit_id
FROM ems.asset_ev_charger ec
JOIN ems.site_endpoint se ON se.id = ec.endpoint_id
WHERE ec.site_id = $1
AND ec.schedulable = true
AND se.enabled = true
AND se.endpoint_type = 'modbus_tcp'
ORDER BY ec.code
""",
site_id,
)
if not rows:
return "OK EV: no schedulable chargers"
for row in rows:
code = row["code"]
current_a = _current_limit_for_charger(code, setpoints)
logger.info(
"EV setpoint [%s]: %sA (TODO: Modbus registers)",
code,
current_a,
)
return f"OK EV: logged {len(rows)} charger(s) (Modbus TODO)"
async def write_heat_pump_setpoint(site_id: int, setpoints: ControlSetpoints, db: asyncpg.Connection) -> str:
rows = await db.fetch(
"""
SELECT hp.code, se.host, se.port, se.unit_id
FROM ems.asset_heat_pump hp
JOIN ems.site_endpoint se ON se.id = hp.endpoint_id
WHERE hp.site_id = $1
AND hp.schedulable = true
AND se.enabled = true
AND se.endpoint_type = 'modbus_tcp'
""",
site_id,
)
if not rows:
return "OK heat pump: no schedulable unit"
for row in rows:
logger.info(
"HP setpoint [%s]: enable=%s (TODO: Modbus registers)",
row["code"],
setpoints.heat_pump_enable,
)
return "OK heat pump: logged (Modbus TODO)"
async def send_loxone_setpoints(
site_id: int,
setpoints: ControlSetpoints,
mode: OperatingModeInfo,
db: asyncpg.Connection,
) -> str:
endpoint = await db.fetchrow(
"""
SELECT host, port, protocol
FROM ems.site_endpoint
WHERE site_id = $1 AND endpoint_type = 'loxone_http' AND enabled = true
ORDER BY id
LIMIT 1
""",
site_id,
)
if not endpoint:
return "OK Loxone: no endpoint, skipped"
proto = (endpoint["protocol"] or "http").lower()
if proto not in ("http", "https"):
proto = "http"
host = endpoint["host"]
port = int(endpoint["port"] or (443 if proto == "https" else 80))
base = f"{proto}://{host}:{port}/dev/sps/io"
settings = get_settings()
user = settings.loxone_user or os.getenv("LOXONE_USER") or ""
password = settings.loxone_password or os.getenv("LOXONE_PASSWORD") or ""
auth = (user, password) if user else None
batt_display = 0 if setpoints.battery_w is None else int(setpoints.battery_w)
paths: list[tuple[str, int]] = [
(f"{base}/EMS_Mode/{mode.loxone_mode_value}", mode.loxone_mode_value),
(f"{base}/EMS_Battery_Setpoint_W/{batt_display}", batt_display),
(f"{base}/EMS_Grid_Setpoint_W/{setpoints.grid_setpoint_w}", setpoints.grid_setpoint_w),
(f"{base}/EMS_EV1_Power_W/{setpoints.ev1_power_w}", setpoints.ev1_power_w),
(f"{base}/EMS_EV2_Power_W/{setpoints.ev2_power_w}", setpoints.ev2_power_w),
(f"{base}/EMS_HeatPump_Enable/{1 if setpoints.heat_pump_enable else 0}", 1 if setpoints.heat_pump_enable else 0),
]
errs: list[str] = []
try:
async with httpx.AsyncClient(timeout=5.0) as client:
for url, _ in paths:
try:
r = await client.get(url, auth=auth)
r.raise_for_status()
except Exception as e:
errs.append(f"{url!s}: {e}")
except Exception as e:
return f"FAIL Loxone: client {e}"
if errs:
return "FAIL Loxone: " + "; ".join(errs[:3])
return "OK Loxone: all virtual inputs updated"
async def export_setpoints(site_id: int, db: asyncpg.Connection) -> None:
mode = await _fetch_operating_mode(site_id, db)
if mode is None:
logger.warning("control export site=%s: no operating mode row", site_id)
return
if mode.mode_code == "MANUAL":
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)
if mode.mode_code == "AUTO" and sp is None:
if pi is None:
logger.warning(
"control export site=%s: AUTO but no planning_interval for current slot, skip",
site_id,
)
return
if sp is None:
logger.warning(
"control export site=%s: no setpoints for mode %s, skip",
site_id,
mode.mode_code,
)
return
if mode.mode_code == "CHARGE_CHEAP":
max_ch = await _fetch_max_charge_power_w(site_id, db)
sp = 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,
ev1_power_w=0,
ev2_power_w=0,
)
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,
),
)
)
for name, res in results:
if isinstance(res, Exception):
logger.error("control export site=%s %s: FAIL %s", site_id, name, res)
elif isinstance(res, str) and res.startswith("FAIL"):
logger.error("control export site=%s %s: %s", site_id, name, res)
else:
logger.info("control export site=%s %s: %s", site_id, name, res)