This commit is contained in:
Dusan Vojacek
2026-03-20 14:30:03 +01:00
parent 2cc5ccfda7
commit 897b95f728
48 changed files with 4034 additions and 842 deletions

View 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))

View 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: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)

View 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

View 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)

View File

@@ -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

View 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())

View 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))