x
This commit is contained in:
47
backend/services/audit_filler.py
Normal file
47
backend/services/audit_filler.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Plnění audit_interval pro dokončené 15min sloty (volá ems.fn_fill_audit_interval)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def fill_audit_for_completed_intervals(site_id: int, db) -> None:
|
||||
"""
|
||||
Naplní audit_interval pro všechny dokončené 15min intervaly
|
||||
za posledních 6 hodin které ještě nemají záznam.
|
||||
Volá PostgreSQL funkci ems.fn_fill_audit_interval().
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
last_complete = now.replace(
|
||||
minute=(now.minute // 15) * 15, second=0, microsecond=0
|
||||
)
|
||||
|
||||
rows = await db.fetch(
|
||||
"""
|
||||
SELECT gs.slot
|
||||
FROM generate_series(
|
||||
$1::timestamptz - interval '6 hours',
|
||||
$1::timestamptz - interval '15 minutes',
|
||||
interval '15 minutes'
|
||||
) AS gs(slot)
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM ems.audit_interval ai
|
||||
WHERE ai.site_id = $2 AND ai.interval_start = gs.slot
|
||||
)
|
||||
""",
|
||||
last_complete,
|
||||
site_id,
|
||||
)
|
||||
|
||||
for row in rows:
|
||||
await db.execute(
|
||||
"SELECT ems.fn_fill_audit_interval($1, $2)",
|
||||
site_id,
|
||||
row["slot"],
|
||||
)
|
||||
|
||||
if rows:
|
||||
logger.info("[site=%s] Filled %s missing audit intervals", site_id, len(rows))
|
||||
425
backend/services/control_exporter.py
Normal file
425
backend/services/control_exporter.py
Normal file
@@ -0,0 +1,425 @@
|
||||
"""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:30–14: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)
|
||||
247
backend/services/forecast_service.py
Normal file
247
backend/services/forecast_service.py
Normal file
@@ -0,0 +1,247 @@
|
||||
"""FVE production forecast from Open-Meteo + pvlib (15min intervals)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import timedelta, timezone
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import httpx
|
||||
import pandas as pd
|
||||
import pvlib
|
||||
from pvlib import irradiance
|
||||
from pvlib.pvsystem import pvwatts_dc
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _db_azimuth_to_pvlib(surface_azimuth_db_deg: float) -> float:
|
||||
"""DB: 0=jih, 90=západ, -90=východ → pvlib (N=0, E=90, S=180, W=270)."""
|
||||
return float((surface_azimuth_db_deg + 180) % 360)
|
||||
|
||||
|
||||
async def fetch_pv_forecast(site_id: int, db) -> tuple[int, int]:
|
||||
"""
|
||||
Stáhne počasí (Open-Meteo), pro každé FVE pole spočte výkon (pvlib) a uloží intervaly.
|
||||
|
||||
Open-Meteo nepodporuje název ``diffuse_horizontal_irradiance``; používá se
|
||||
``diffuse_radiation`` (DHI) a ``shortwave_radiation`` (GHI). Data jsou
|
||||
``minutely_15`` kvůli 15min slotům v ``ems.forecast_pv_interval``.
|
||||
|
||||
Returns:
|
||||
``(celkový_počet_řádků_forecast_pv_interval, počet_FVE_polí)``.
|
||||
Při chybě ``(-1, 0)``. Bez polí ``(0, 0)``.
|
||||
"""
|
||||
site = await db.fetchrow(
|
||||
"""
|
||||
SELECT latitude, longitude, timezone
|
||||
FROM ems.site
|
||||
WHERE id = $1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
if site is None:
|
||||
logger.error("fetch_pv_forecast: site id=%s nenalezen", site_id)
|
||||
return -1, 0
|
||||
|
||||
if site["latitude"] is None or site["longitude"] is None:
|
||||
logger.error("fetch_pv_forecast: site id=%s nemá latitude/longitude", site_id)
|
||||
return -1, 0
|
||||
|
||||
lat = float(site["latitude"])
|
||||
lon = float(site["longitude"])
|
||||
tz_name: str = site["timezone"] or "Europe/Prague"
|
||||
|
||||
try:
|
||||
ZoneInfo(tz_name)
|
||||
except Exception as e:
|
||||
logger.error("fetch_pv_forecast: neplatná timezone %r: %s", tz_name, e)
|
||||
return -1, 0
|
||||
|
||||
arrays = await db.fetch(
|
||||
"""
|
||||
SELECT *
|
||||
FROM ems.asset_pv_array
|
||||
WHERE site_id = $1
|
||||
ORDER BY id
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
if not arrays:
|
||||
logger.info("fetch_pv_forecast: žádná FVE pole pro site_id=%s", site_id)
|
||||
return 0, 0
|
||||
|
||||
n_arrays = len(arrays)
|
||||
|
||||
settings = get_settings()
|
||||
base = settings.open_meteo_api_url.rstrip("/")
|
||||
|
||||
params = {
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
"minutely_15": ",".join(
|
||||
[
|
||||
"direct_normal_irradiance",
|
||||
"diffuse_radiation",
|
||||
"shortwave_radiation",
|
||||
"temperature_2m",
|
||||
]
|
||||
),
|
||||
"forecast_days": 2,
|
||||
"timezone": "auto",
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=20.0) as client:
|
||||
resp = await client.get(base, params=params)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except httpx.TimeoutException:
|
||||
logger.warning("fetch_pv_forecast: timeout Open-Meteo")
|
||||
return -1, 0
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.warning(
|
||||
"fetch_pv_forecast: HTTP %s Open-Meteo: %s",
|
||||
e.response.status_code,
|
||||
e.response.text[:500],
|
||||
)
|
||||
return -1, 0
|
||||
except httpx.HTTPError as e:
|
||||
logger.warning("fetch_pv_forecast: HTTP chyba Open-Meteo: %s", e)
|
||||
return -1, 0
|
||||
|
||||
m15 = data.get("minutely_15") or {}
|
||||
times_raw = m15.get("time")
|
||||
if not times_raw or not isinstance(times_raw, list):
|
||||
snippet = json.dumps(data, ensure_ascii=False)[:500]
|
||||
logger.error("fetch_pv_forecast: chybí minutely_15.time, začátek: %s", snippet)
|
||||
return -1, 0
|
||||
|
||||
api_tz = data.get("timezone") or tz_name
|
||||
try:
|
||||
tzinfo = ZoneInfo(api_tz)
|
||||
except Exception:
|
||||
tzinfo = ZoneInfo(tz_name)
|
||||
|
||||
times = pd.DatetimeIndex(pd.to_datetime(times_raw))
|
||||
if times.tz is None:
|
||||
times = times.tz_localize(tzinfo)
|
||||
|
||||
def _series(key: str) -> pd.Series:
|
||||
raw = m15.get(key)
|
||||
if not isinstance(raw, list) or len(raw) != len(times):
|
||||
return pd.Series(0.0, index=times, dtype=float)
|
||||
return pd.Series(
|
||||
[0.0 if v is None else float(v) for v in raw],
|
||||
index=times,
|
||||
dtype=float,
|
||||
)
|
||||
|
||||
dni = _series("direct_normal_irradiance")
|
||||
ghi = _series("shortwave_radiation")
|
||||
dhi = _series("diffuse_radiation")
|
||||
temp_air = _series("temperature_2m")
|
||||
|
||||
loc = pvlib.location.Location(lat, lon, tz=api_tz)
|
||||
solar_pos = loc.get_solarposition(times)
|
||||
|
||||
total_rows = 0
|
||||
horizon_start = times[0].tz_convert(timezone.utc).to_pydatetime()
|
||||
horizon_end = (
|
||||
times[-1].tz_convert(timezone.utc).to_pydatetime() + timedelta(minutes=15)
|
||||
)
|
||||
|
||||
for arr in arrays:
|
||||
tilt = float(arr["tilt_deg"] or 0.0)
|
||||
az_db = float(arr["azimuth_deg"] or 0.0)
|
||||
az_pvlib = _db_azimuth_to_pvlib(az_db)
|
||||
pdc0 = float(arr["nominal_power_wp"])
|
||||
shading = float(arr["shading_factor"] or 1.0)
|
||||
|
||||
poa = irradiance.get_total_irradiance(
|
||||
surface_tilt=tilt,
|
||||
surface_azimuth=az_pvlib,
|
||||
solar_zenith=solar_pos["apparent_zenith"],
|
||||
solar_azimuth=solar_pos["azimuth"],
|
||||
dni=dni,
|
||||
ghi=ghi,
|
||||
dhi=dhi,
|
||||
model="haydavies",
|
||||
)["poa_global"].fillna(0).clip(lower=0)
|
||||
|
||||
temp_cell = temp_air + 0.04 * poa
|
||||
p_dc = pvwatts_dc(poa, temp_cell, pdc0, -0.004)
|
||||
p_dc = p_dc.fillna(0).clip(lower=0) * shading
|
||||
power_w = p_dc.round().astype(int)
|
||||
|
||||
model_params: dict[str, Any] = {
|
||||
"source": "open_meteo",
|
||||
"endpoint": base,
|
||||
"params": params,
|
||||
"pvlib_model": "haydavies",
|
||||
"pvwatts_gamma_pdc": -0.004,
|
||||
}
|
||||
|
||||
run_id = await db.fetchval(
|
||||
"""
|
||||
INSERT INTO ems.forecast_pv_run (
|
||||
site_id,
|
||||
pv_array_id,
|
||||
forecast_source,
|
||||
model_params,
|
||||
horizon_start,
|
||||
horizon_end,
|
||||
status
|
||||
)
|
||||
VALUES ($1, $2, $3, $4::jsonb, $5, $6, 'ok')
|
||||
RETURNING id
|
||||
""",
|
||||
site_id,
|
||||
arr["id"],
|
||||
"open_meteo",
|
||||
json.dumps(model_params),
|
||||
horizon_start,
|
||||
horizon_end,
|
||||
)
|
||||
|
||||
records = []
|
||||
for ts, p, g, t in zip(
|
||||
times,
|
||||
power_w,
|
||||
ghi,
|
||||
temp_air,
|
||||
strict=True,
|
||||
):
|
||||
interval_start = ts.tz_convert(timezone.utc).to_pydatetime()
|
||||
records.append(
|
||||
(
|
||||
run_id,
|
||||
arr["id"],
|
||||
interval_start,
|
||||
int(p),
|
||||
float(g),
|
||||
float(t),
|
||||
)
|
||||
)
|
||||
|
||||
await db.executemany(
|
||||
"""
|
||||
INSERT INTO ems.forecast_pv_interval (
|
||||
run_id,
|
||||
pv_array_id,
|
||||
interval_start,
|
||||
power_w,
|
||||
irradiance_wm2,
|
||||
temp_c
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
""",
|
||||
records,
|
||||
)
|
||||
total_rows += len(records)
|
||||
|
||||
return total_rows, n_arrays
|
||||
70
backend/services/heartbeat_service.py
Normal file
70
backend/services/heartbeat_service.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Heartbeat: DB záznam + volitelný HTTP pulz do Loxone."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
EMS_BACKEND_VERSION = "v1.0.0"
|
||||
|
||||
|
||||
async def send_heartbeat(
|
||||
site_id: int,
|
||||
db,
|
||||
loxone_host: str | None = None,
|
||||
loxone_port: int | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
1. Aktualizuje ems.site_heartbeat v DB
|
||||
2. Pokud je Loxone nakonfigurováno, pošle HTTP pulz
|
||||
"""
|
||||
try:
|
||||
endpoint = await db.fetchrow(
|
||||
"""
|
||||
SELECT host, port, protocol, auth_reference
|
||||
FROM ems.site_endpoint
|
||||
WHERE site_id = $1 AND endpoint_type = 'loxone_http' AND enabled = true
|
||||
ORDER BY id
|
||||
LIMIT 1
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
|
||||
loxone_ok = False
|
||||
if endpoint:
|
||||
proto = (endpoint["protocol"] or "http").lower()
|
||||
if proto not in ("http", "https"):
|
||||
proto = "http"
|
||||
host = loxone_host if loxone_host is not None else endpoint["host"]
|
||||
if loxone_port is not None:
|
||||
port = int(loxone_port)
|
||||
else:
|
||||
port = int(endpoint["port"] or (443 if proto == "https" else 80))
|
||||
|
||||
url = f"{proto}://{host}:{port}/dev/sps/io/EMS_Heartbeat/1"
|
||||
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
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
await client.get(url, auth=auth)
|
||||
loxone_ok = True
|
||||
except Exception as e:
|
||||
logger.warning("Heartbeat Loxone failed (site=%s): %s", site_id, e)
|
||||
|
||||
status = "ok" if (not endpoint or loxone_ok) else "degraded"
|
||||
await db.execute(
|
||||
"SELECT ems.fn_update_heartbeat($1, $2, $3)",
|
||||
site_id,
|
||||
status,
|
||||
EMS_BACKEND_VERSION,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Heartbeat service error (site=%s): %s", site_id, e)
|
||||
@@ -349,8 +349,6 @@ async def run_daily_plan(site_id: int, db, triggered_by: str = "scheduler:daily"
|
||||
logger.info(f"[site={site_id}] Daily plan: {horizon_from} → {horizon_to}")
|
||||
|
||||
slots = await _load_slots(site_id, horizon_from, horizon_to, db)
|
||||
if not slots:
|
||||
raise RuntimeError(f"No planning slots for site_id={site_id} (prices/forecast horizon?)")
|
||||
|
||||
battery, hp, grid, vehicles, ev_sessions, soc_wh, tuv_temp = await _load_site_context(
|
||||
site_id, db
|
||||
@@ -430,9 +428,6 @@ async def run_rolling_replan(
|
||||
correction_factor, correction_log = await compute_correction_factor(site_id, now, db)
|
||||
|
||||
slots = await _load_slots(site_id, replan_from, horizon_to, db)
|
||||
if not slots:
|
||||
logger.warning(f"[site={site_id}] Rolling replan: no slots, running daily plan")
|
||||
return await run_daily_plan(site_id, db, triggered_by=triggered_by)
|
||||
|
||||
slots = apply_forecast_correction(slots, now, correction_factor)
|
||||
|
||||
@@ -477,7 +472,13 @@ async def run_rolling_replan(
|
||||
return run_id, duration_ms
|
||||
|
||||
|
||||
async def run_plan_api(site_id: int, db, plan_type: str, triggered_by: str = "api") -> tuple[int, int]:
|
||||
async def run_plan_api(
|
||||
site_id: int,
|
||||
plan_type: str,
|
||||
db,
|
||||
*,
|
||||
triggered_by: str = "api",
|
||||
) -> tuple[int, int]:
|
||||
"""Ruční / UI spuštění plánu. Vždy vrátí (run_id, solver_duration_ms)."""
|
||||
pt = plan_type.lower().strip()
|
||||
if pt == "daily":
|
||||
@@ -671,10 +672,10 @@ async def _load_site_context(site_id: int, db):
|
||||
site_id,
|
||||
)
|
||||
if soc_pct is None:
|
||||
soc_wh = reserve_wh
|
||||
soc_wh = uc * 0.5
|
||||
else:
|
||||
soc_wh = float(soc_pct) / 100.0 * uc
|
||||
soc_wh = max(reserve_wh, min(soc_wh, soc_max_wh))
|
||||
soc_wh = max(reserve_wh, min(soc_wh, soc_max_wh))
|
||||
|
||||
tuv = await db.fetchval(
|
||||
"""
|
||||
@@ -701,9 +702,9 @@ async def _load_slots(site_id, from_dt, to_dt, db) -> list[PlanningSlot]:
|
||||
COALESCE(fpi_a.power_w, 0) AS pv_a_forecast_w,
|
||||
COALESCE(fpi_b.power_w, 0) AS pv_b_forecast_w,
|
||||
COALESCE(cbi.power_w, 500) AS load_baseline_w,
|
||||
-- EV připojení z aktuálního stavu nabíječek
|
||||
(ev1.status NOT IN ('available', 'unavailable')) AS ev1_connected,
|
||||
(ev2.status NOT IN ('available', 'unavailable')) AS ev2_connected
|
||||
-- EV připojení z poslední telemetrie nabíječek (bez řádku = nepřipojeno)
|
||||
(COALESCE(ev1.status, 'available') NOT IN ('available', 'unavailable')) AS ev1_connected,
|
||||
(COALESCE(ev2.status, 'available') NOT IN ('available', 'unavailable')) AS ev2_connected
|
||||
FROM ems.vw_site_effective_price ep
|
||||
-- FVE pole A forecast
|
||||
LEFT JOIN LATERAL (
|
||||
@@ -762,6 +763,10 @@ async def _load_slots(site_id, from_dt, to_dt, db) -> list[PlanningSlot]:
|
||||
ev2_connected=bool(d["ev2_connected"]),
|
||||
)
|
||||
)
|
||||
if not out:
|
||||
raise RuntimeError(
|
||||
"No planning slots available – check market prices and horizon settings"
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
|
||||
180
backend/services/price_importer.py
Normal file
180
backend/services/price_importer.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""OTE CZ DAM spot price import (15min slots, shared market table)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MARKET_SOURCE = "OTE_CZ"
|
||||
|
||||
|
||||
async def import_ote_prices(
|
||||
site_id: int,
|
||||
db,
|
||||
target_date: date | None = None,
|
||||
) -> tuple[int, str, float]:
|
||||
"""
|
||||
Stáhne DAM ceny OTE pro zvolený den (nebo „zítřek“ v TZ lokality), uloží 96 slotů (15 min).
|
||||
|
||||
Schéma DB: ``ems.market_interval_price`` má PK ``(market_source, interval_start)``;
|
||||
ceny v ``buy_raw_price_czk_kwh`` / ``sell_raw_price_czk_kwh`` (pro OTE stejné).
|
||||
|
||||
Returns:
|
||||
``(počet_slotů, datum_YMD, první_cena_kč_kwh)``. Počet 96 při úspěchu, -1 při chybě.
|
||||
První cena je cena prvního 15min slotu dne; při chybě 0.0.
|
||||
Datum je prázdný řetězec jen pokud site neexistuje nebo je neplatná timezone.
|
||||
"""
|
||||
row = await db.fetchrow(
|
||||
"SELECT timezone FROM ems.site WHERE id = $1",
|
||||
site_id,
|
||||
)
|
||||
if row is None:
|
||||
logger.error("import_ote_prices: site id=%s nenalezen", site_id)
|
||||
return -1, "", 0.0
|
||||
|
||||
tz_name: str = row["timezone"] or "Europe/Prague"
|
||||
try:
|
||||
site_tz = ZoneInfo(tz_name)
|
||||
except Exception as e:
|
||||
logger.error("import_ote_prices: neplatná timezone %r: %s", tz_name, e)
|
||||
return -1, "", 0.0
|
||||
|
||||
if target_date is not None:
|
||||
target_day = target_date
|
||||
else:
|
||||
now_local = datetime.now(site_tz)
|
||||
target_day = (now_local + timedelta(days=1)).date()
|
||||
date_str = target_day.isoformat()
|
||||
|
||||
cet = ZoneInfo("Europe/Prague")
|
||||
now_cet = datetime.now(cet)
|
||||
tomorrow_cet = (now_cet + timedelta(days=1)).date()
|
||||
if target_day == tomorrow_cet:
|
||||
cutoff = now_cet.replace(hour=13, minute=30, second=0, microsecond=0)
|
||||
if now_cet < cutoff:
|
||||
logger.warning(
|
||||
"OTE prices for tomorrow may not be available yet (before 13:30 CET)"
|
||||
)
|
||||
|
||||
settings = get_settings()
|
||||
base_url = settings.ote_api_url.rstrip("/")
|
||||
url = f"{base_url}?date={date_str}"
|
||||
eur_czk = float(settings.eur_czk_rate)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
resp = await client.get(url)
|
||||
resp.raise_for_status()
|
||||
body = resp.json()
|
||||
except httpx.TimeoutException:
|
||||
logger.warning("import_ote_prices: timeout při GET %s", url)
|
||||
return -1, date_str, 0.0
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.warning(
|
||||
"import_ote_prices: HTTP %s při GET %s: %s",
|
||||
e.response.status_code,
|
||||
url,
|
||||
e.response.text[:500],
|
||||
)
|
||||
return -1, date_str, 0.0
|
||||
except httpx.HTTPError as e:
|
||||
logger.warning("import_ote_prices: HTTP chyba při GET %s: %s", url, e)
|
||||
return -1, date_str, 0.0
|
||||
except Exception as e:
|
||||
logger.warning("import_ote_prices: neočekávaná chyba při stahování: %s", e)
|
||||
return -1, date_str, 0.0
|
||||
|
||||
hourly_eur_mwh: dict[int, float] | None = None
|
||||
try:
|
||||
points: list[dict[str, Any]] = body["data"]["dataLine"][0]["point"]
|
||||
hourly_eur_mwh = {}
|
||||
for p in points:
|
||||
x = int(p["x"])
|
||||
y = float(p["y"])
|
||||
hourly_eur_mwh[x] = y
|
||||
except (KeyError, TypeError, ValueError, IndexError):
|
||||
snippet = json.dumps(body, ensure_ascii=False)[:500]
|
||||
logger.error("import_ote_prices: neočekádaná struktura OTE, začátek: %s", snippet)
|
||||
return -1, date_str, 0.0
|
||||
|
||||
if len(hourly_eur_mwh) != 24 or set(hourly_eur_mwh.keys()) != set(range(1, 25)):
|
||||
logger.error(
|
||||
"import_ote_prices: očekáváno 24 bodů x=1..24, dostáno klíče %s",
|
||||
sorted(hourly_eur_mwh.keys()),
|
||||
)
|
||||
return -1, date_str, 0.0
|
||||
|
||||
slots: list[tuple[datetime, datetime, float]] = []
|
||||
for h in range(24):
|
||||
x = h + 1
|
||||
eur_mwh = hourly_eur_mwh[x]
|
||||
price_czk_kwh = eur_mwh * eur_czk / 1000.0
|
||||
for minute in (0, 15, 30, 45):
|
||||
interval_start_local = datetime(
|
||||
target_day.year,
|
||||
target_day.month,
|
||||
target_day.day,
|
||||
h,
|
||||
minute,
|
||||
tzinfo=site_tz,
|
||||
)
|
||||
interval_start_utc = interval_start_local.astimezone(timezone.utc)
|
||||
interval_end_utc = interval_start_utc + timedelta(minutes=15)
|
||||
slots.append((interval_start_utc, interval_end_utc, price_czk_kwh))
|
||||
|
||||
for interval_start_utc, interval_end_utc, price in slots:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO ems.market_interval_price (
|
||||
market_source,
|
||||
interval_start,
|
||||
interval_end,
|
||||
buy_raw_price_czk_kwh,
|
||||
sell_raw_price_czk_kwh,
|
||||
currency,
|
||||
imported_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, 'CZK', now())
|
||||
ON CONFLICT (market_source, interval_start)
|
||||
DO UPDATE SET
|
||||
interval_end = EXCLUDED.interval_end,
|
||||
buy_raw_price_czk_kwh = EXCLUDED.buy_raw_price_czk_kwh,
|
||||
sell_raw_price_czk_kwh = EXCLUDED.sell_raw_price_czk_kwh,
|
||||
imported_at = now()
|
||||
""",
|
||||
MARKET_SOURCE,
|
||||
interval_start_utc,
|
||||
interval_end_utc,
|
||||
price,
|
||||
price,
|
||||
)
|
||||
|
||||
first_price = float(slots[0][2]) if slots else 0.0
|
||||
return len(slots), date_str, first_price
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
import asyncpg
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
async def test():
|
||||
conn = await asyncpg.connect(os.getenv("DATABASE_URL"))
|
||||
n, d, fp = await import_ote_prices(1, conn)
|
||||
print(f"Uloženo {n} slotů pro {d}, první cena {fp}")
|
||||
await conn.close()
|
||||
|
||||
asyncio.run(test())
|
||||
321
backend/services/telemetry_collector.py
Normal file
321
backend/services/telemetry_collector.py
Normal file
@@ -0,0 +1,321 @@
|
||||
"""Sběr telemetrie z Modbus (Deye) a placeholder záznamy pro EV / TČ."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import asyncpg
|
||||
from pymodbus.client import AsyncModbusTcpClient
|
||||
from pymodbus.exceptions import ConnectionException, ModbusIOException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _to_signed_i16(value: int) -> int:
|
||||
v = value & 0xFFFF
|
||||
if v >= 0x8000:
|
||||
return v - 0x10000
|
||||
return v
|
||||
|
||||
|
||||
class ModbusDevice:
|
||||
def __init__(self, host: str, port: int, unit_id: int, device_name: str) -> None:
|
||||
self._host = host
|
||||
self._port = int(port) if port else 502
|
||||
self._unit_id = int(unit_id) if unit_id is not None else 1
|
||||
self._device_name = device_name
|
||||
self._client: AsyncModbusTcpClient | None = None
|
||||
self._error_count = 0
|
||||
|
||||
def _log_prefix(self) -> str:
|
||||
return f"[{self._device_name}]"
|
||||
|
||||
def _note_communication_failure(self, exc: BaseException | None) -> None:
|
||||
self._error_count += 1
|
||||
if isinstance(exc, ConnectionError):
|
||||
logger.warning("%s ConnectionError: %s", self._log_prefix(), exc)
|
||||
else:
|
||||
logger.warning(
|
||||
"%s komunikace selhala: %s",
|
||||
self._log_prefix(),
|
||||
exc if exc is not None else "neznámá chyba",
|
||||
)
|
||||
if self._error_count >= 3:
|
||||
logger.error("%s Opakované chyby komunikace", self._log_prefix())
|
||||
if self._error_count >= 10 and self._error_count % 10 == 0:
|
||||
logger.critical(
|
||||
"%s Opakované chyby komunikace, pokus o reconnect",
|
||||
self._log_prefix(),
|
||||
)
|
||||
|
||||
def _reset_error_count(self) -> None:
|
||||
self._error_count = 0
|
||||
|
||||
async def _ensure_connected(self) -> bool:
|
||||
if self._client is None:
|
||||
self._client = AsyncModbusTcpClient(self._host, port=self._port)
|
||||
if not self._client.connected:
|
||||
try:
|
||||
ok = await self._client.connect()
|
||||
except ConnectionError as e:
|
||||
self._note_communication_failure(e)
|
||||
return False
|
||||
except OSError as e:
|
||||
self._note_communication_failure(e)
|
||||
return False
|
||||
if not ok:
|
||||
self._note_communication_failure(ConnectionError("Modbus connect() returned False"))
|
||||
return False
|
||||
return True
|
||||
|
||||
async def _reconnect(self) -> None:
|
||||
if self._client is not None:
|
||||
self._client.close()
|
||||
self._client = None
|
||||
self._client = AsyncModbusTcpClient(self._host, port=self._port)
|
||||
try:
|
||||
await self._client.connect()
|
||||
except (ConnectionError, OSError) as e:
|
||||
logger.warning("%s reconnect selhal: %s", self._log_prefix(), e)
|
||||
|
||||
async def read_register(self, address: int) -> int:
|
||||
"""Čte jeden holding register. Vrátí 0 při chybě."""
|
||||
try:
|
||||
if not await self._ensure_connected():
|
||||
if self._error_count >= 10 and self._error_count % 10 == 0:
|
||||
await self._reconnect()
|
||||
return 0
|
||||
assert self._client is not None
|
||||
resp = await self._client.read_holding_registers(
|
||||
address, count=1, device_id=self._unit_id
|
||||
)
|
||||
if resp.isError() or not getattr(resp, "registers", None):
|
||||
self._note_communication_failure(
|
||||
ConnectionException(f"read_holding_registers@{address:#x}: {resp!r}")
|
||||
)
|
||||
if self._error_count >= 10 and self._error_count % 10 == 0:
|
||||
await self._reconnect()
|
||||
return 0
|
||||
self._reset_error_count()
|
||||
return int(resp.registers[0])
|
||||
except ConnectionError as e:
|
||||
self._note_communication_failure(e)
|
||||
if self._error_count >= 10 and self._error_count % 10 == 0:
|
||||
await self._reconnect()
|
||||
return 0
|
||||
except (OSError, ModbusIOException, ConnectionException) as e:
|
||||
self._note_communication_failure(e)
|
||||
if self._error_count >= 10 and self._error_count % 10 == 0:
|
||||
await self._reconnect()
|
||||
return 0
|
||||
|
||||
async def read_register_signed(self, address: int) -> int:
|
||||
"""Čte signed int16 (pro výkony které mohou být záporné)."""
|
||||
u = await self.read_register(address)
|
||||
return _to_signed_i16(u)
|
||||
|
||||
async def write_register(self, address: int, value: int) -> bool:
|
||||
"""Zapíše jeden holding register. Vrátí False při chybě."""
|
||||
try:
|
||||
if not await self._ensure_connected():
|
||||
if self._error_count >= 10 and self._error_count % 10 == 0:
|
||||
await self._reconnect()
|
||||
return False
|
||||
assert self._client is not None
|
||||
resp = await self._client.write_register(address, value, device_id=self._unit_id)
|
||||
if resp.isError():
|
||||
self._note_communication_failure(
|
||||
ConnectionException(f"write_register@{address:#x}: {resp!r}")
|
||||
)
|
||||
if self._error_count >= 10 and self._error_count % 10 == 0:
|
||||
await self._reconnect()
|
||||
return False
|
||||
self._reset_error_count()
|
||||
return True
|
||||
except ConnectionError as e:
|
||||
self._note_communication_failure(e)
|
||||
if self._error_count >= 10 and self._error_count % 10 == 0:
|
||||
await self._reconnect()
|
||||
return False
|
||||
except (OSError, ModbusIOException, ConnectionException) as e:
|
||||
self._note_communication_failure(e)
|
||||
if self._error_count >= 10 and self._error_count % 10 == 0:
|
||||
await self._reconnect()
|
||||
return False
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._client is not None:
|
||||
self._client.close()
|
||||
self._client = None
|
||||
|
||||
|
||||
async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None:
|
||||
rows = await db.fetch(
|
||||
"""
|
||||
SELECT ai.id, 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.active = true
|
||||
AND se.enabled = true
|
||||
AND se.endpoint_type = 'modbus_tcp'
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
measured_at = datetime.now(timezone.utc)
|
||||
for row in rows:
|
||||
inv_id = row["id"]
|
||||
code = row["code"]
|
||||
host = row["host"]
|
||||
port = row["port"] or 502
|
||||
unit_id = row["unit_id"] if row["unit_id"] is not None else 1
|
||||
dev = ModbusDevice(host, port, unit_id, f"inverter:{code}")
|
||||
try:
|
||||
pv_power_w = await dev.read_register(0x0215)
|
||||
battery_soc = await dev.read_register(0x0103)
|
||||
battery_power = await dev.read_register_signed(0x0105)
|
||||
battery_voltage = (await dev.read_register(0x0101)) / 10.0
|
||||
grid_power = await dev.read_register_signed(0x0169)
|
||||
grid_voltage = (await dev.read_register(0x016F)) / 10.0
|
||||
load_power = await dev.read_register(0x0213)
|
||||
inv_temp = (await dev.read_register(0x0220)) / 10.0
|
||||
op_mode = await dev.read_register(0x0168)
|
||||
fault_code = await dev.read_register(0x0180)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO ems.telemetry_inverter (
|
||||
site_id, inverter_id, measured_at,
|
||||
pv_power_w, battery_soc_percent, battery_power_w, battery_voltage_v,
|
||||
grid_power_w, grid_voltage_v, load_power_w,
|
||||
inverter_temp_c, operating_mode, fault_code
|
||||
)
|
||||
VALUES (
|
||||
$1, $2, $3,
|
||||
$4, $5, $6, $7,
|
||||
$8, $9, $10,
|
||||
$11, $12, $13
|
||||
)
|
||||
ON CONFLICT (inverter_id, measured_at) DO NOTHING
|
||||
""",
|
||||
site_id,
|
||||
inv_id,
|
||||
measured_at,
|
||||
pv_power_w,
|
||||
battery_soc,
|
||||
battery_power,
|
||||
battery_voltage,
|
||||
grid_power,
|
||||
grid_voltage,
|
||||
load_power,
|
||||
inv_temp,
|
||||
str(op_mode),
|
||||
fault_code,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("poll_inverter site=%s inverter=%s: %s", site_id, code, e)
|
||||
finally:
|
||||
await dev.close()
|
||||
|
||||
|
||||
async def poll_ev_chargers(site_id: int, db: asyncpg.Connection) -> None:
|
||||
rows = await db.fetch(
|
||||
"""
|
||||
SELECT ec.id, 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 se.enabled = true
|
||||
AND se.endpoint_type = 'modbus_tcp'
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
measured_at = datetime.now(timezone.utc)
|
||||
for row in rows:
|
||||
code = row["code"]
|
||||
logger.info("TODO: EV charger Modbus registry pending | %s", code)
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO ems.telemetry_ev_charger (
|
||||
site_id, charger_id, measured_at, connector_id,
|
||||
status, power_w, energy_kwh
|
||||
)
|
||||
VALUES ($1, $2, $3, 1, 'available', 0, 0)
|
||||
ON CONFLICT (charger_id, connector_id, measured_at) DO NOTHING
|
||||
""",
|
||||
site_id,
|
||||
row["id"],
|
||||
measured_at,
|
||||
)
|
||||
|
||||
|
||||
async def poll_heat_pump(site_id: int, db: asyncpg.Connection) -> None:
|
||||
rows = await db.fetch(
|
||||
"""
|
||||
SELECT hp.id, 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 se.enabled = true
|
||||
AND se.endpoint_type = 'modbus_tcp'
|
||||
""",
|
||||
site_id,
|
||||
)
|
||||
measured_at = datetime.now(timezone.utc)
|
||||
for row in rows:
|
||||
code = row["code"]
|
||||
logger.info("TODO: heat pump Modbus registry pending (heat_pump=%s)", code)
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO ems.telemetry_heat_pump (
|
||||
site_id, heat_pump_id, measured_at,
|
||||
power_w, outdoor_temp_c, water_outlet_temp_c, tuv_tank_temp_c,
|
||||
operating_mode
|
||||
)
|
||||
VALUES ($1, $2, $3, 0, 10.0, 45.0, 55.0, 'standby')
|
||||
ON CONFLICT (heat_pump_id, measured_at) DO NOTHING
|
||||
""",
|
||||
site_id,
|
||||
row["id"],
|
||||
measured_at,
|
||||
)
|
||||
|
||||
|
||||
async def run_telemetry_loop(conn: asyncpg.Connection) -> float:
|
||||
"""Jeden průchod smyčky; vrátí uplynulý čas v sekundách (pro sleep).
|
||||
|
||||
Poll probíhá sekvenčně — jedno asyncpg spojení nesmí obsluhovat paralelní dotazy.
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
start = loop.time()
|
||||
sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true")
|
||||
for site in sites:
|
||||
sid = site["id"]
|
||||
try:
|
||||
await poll_inverter(sid, conn)
|
||||
await poll_ev_chargers(sid, conn)
|
||||
await poll_heat_pump(sid, conn)
|
||||
except Exception as e:
|
||||
logger.error("Telemetry loop error site %s: %s", sid, e)
|
||||
return loop.time() - start
|
||||
|
||||
|
||||
async def run_telemetry_loop_wrapper(pool: asyncpg.Pool) -> None:
|
||||
"""Background task: každá iterace získá spojení z poolu; neblokuje pool během sleep."""
|
||||
while True:
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
elapsed = await run_telemetry_loop(conn)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("Telemetry wrapper DB error: %s", e)
|
||||
elapsed = 0.0
|
||||
await asyncio.sleep(5)
|
||||
continue
|
||||
|
||||
if elapsed > 50:
|
||||
logger.warning("Telemetry loop took %.1fs (>50s)", elapsed)
|
||||
await asyncio.sleep(max(0.0, 60.0 - elapsed))
|
||||
Reference in New Issue
Block a user