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

@@ -21,7 +21,7 @@ class Settings(BaseSettings):
database_url: str | None = Field(default=None)
postgrest_jwt_secret: str = Field(default="")
postgrest_anon_role: str = Field(default="ems_user")
postgrest_anon_role: str = Field(default="ems_anon")
ote_api_url: str = Field(
default="https://www.ote-cr.cz/pubapi/v1/market-data/dam",

35
backend/app/db_json.py Normal file
View File

@@ -0,0 +1,35 @@
"""asyncpg Record → JSON-serializovatelný dict."""
from __future__ import annotations
from datetime import date, datetime, timezone
from decimal import Decimal
from typing import Any
from uuid import UUID
import asyncpg
def record_to_dict(r: asyncpg.Record) -> dict[str, Any]:
out: dict[str, Any] = {}
for k in r.keys():
v = r[k]
if v is None:
out[k] = None
elif isinstance(v, datetime):
if v.tzinfo is None:
v = v.replace(tzinfo=timezone.utc)
out[k] = v.isoformat()
elif isinstance(v, date):
out[k] = v.isoformat()
elif isinstance(v, Decimal):
out[k] = float(v)
elif isinstance(v, UUID):
out[k] = str(v)
elif isinstance(v, (dict, list, str, int, float, bool)):
out[k] = v
elif isinstance(v, (bytes, memoryview)):
out[k] = bytes(v).decode("utf-8", errors="replace")
else:
out[k] = str(v)
return out

View File

@@ -0,0 +1,268 @@
"""GET /sites/{site_id}/status/full monitoring snapshot + alert pravidla."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Annotated, Any, Literal
import asyncpg
from fastapi import APIRouter, Depends, HTTPException
from app.db_json import record_to_dict
from app.deps import get_pg_pool
router = APIRouter(prefix="/sites/{site_id}", tags=["sites"])
INV_STALE_SEC = 300
HEARTBEAT_STALE_SEC = 300
EXPECTED_TOMORROW_PRICE_SLOTS = 90
def _iso_utc(dt: datetime | None) -> str | None:
if dt is None:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc).isoformat()
def _age_seconds(at: datetime | None) -> int | None:
if at is None:
return None
if at.tzinfo is None:
at = at.replace(tzinfo=timezone.utc)
return max(0, int((datetime.now(timezone.utc) - at).total_seconds()))
def _next_plan_interval(
intervals: list[dict[str, Any]], now_utc: datetime
) -> tuple[str | None, int | None]:
"""Nejbližší 15min slot od aktuálního času včetně probíhajícího."""
slot_ms = 15 * 60 * 1000
boundary_ms = (int(now_utc.timestamp() * 1000) // slot_ms) * slot_ms
boundary = datetime.fromtimestamp(boundary_ms / 1000, tz=timezone.utc)
for row in sorted(intervals, key=lambda r: r["interval_start"]):
istart = row["interval_start"]
if isinstance(istart, str):
istart = datetime.fromisoformat(istart.replace("Z", "+00:00"))
if istart.tzinfo is None:
istart = istart.replace(tzinfo=timezone.utc)
if istart >= boundary - timedelta(milliseconds=1):
bat = row.get("battery_setpoint_w")
bi = int(bat) if bat is not None else None
return _iso_utc(istart), bi
return None, None
@router.get("/status/full")
async def get_site_status_full(
site_id: int,
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
) -> dict[str, Any]:
async with pool.acquire() as conn:
site = await conn.fetchrow(
"""
SELECT id, code, name, timezone
FROM ems.site
WHERE id = $1
""",
site_id,
)
if site is None:
raise HTTPException(status_code=404, detail="Site not found")
tz = site["timezone"] or "Europe/Prague"
mode_row = await conn.fetchrow(
"""
SELECT m.mode_code, d.name AS mode_name, m.activated_at, m.activated_by
FROM ems.site_operating_mode m
JOIN ems.operating_mode_def d ON d.code = m.mode_code
WHERE m.site_id = $1
""",
site_id,
)
hb_row = await conn.fetchrow(
"""
SELECT last_seen, status
FROM ems.site_heartbeat
WHERE site_id = $1
""",
site_id,
)
inv_row = await conn.fetchrow(
"""
SELECT pv_power_w, battery_soc_percent, grid_power_w, measured_at
FROM ems.vw_latest_inverter
WHERE site_id = $1
ORDER BY measured_at DESC NULLS LAST
LIMIT 1
""",
site_id,
)
ev_rows = await conn.fetch(
"""
SELECT DISTINCT ON (charger_id)
charger_code AS code,
status,
power_w,
measured_at
FROM ems.vw_latest_ev_charger
WHERE site_id = $1
ORDER BY charger_id, measured_at DESC NULLS LAST
""",
site_id,
)
hp_row = await conn.fetchrow(
"""
SELECT power_w, tuv_tank_temp_c, measured_at
FROM ems.vw_latest_heat_pump
WHERE site_id = $1
ORDER BY measured_at DESC NULLS LAST
LIMIT 1
""",
site_id,
)
reserve_row = await conn.fetchrow(
"""
SELECT MIN(reserve_soc_percent)::float AS reserve_soc
FROM ems.asset_battery
WHERE site_id = $1
""",
site_id,
)
run_row = await conn.fetchrow(
"""
SELECT id, created_at
FROM ems.planning_run
WHERE site_id = $1 AND status = 'active'
ORDER BY created_at DESC
LIMIT 1
""",
site_id,
)
intervals: list[dict[str, Any]] = []
if run_row:
int_rows = await conn.fetch(
"""
SELECT interval_start, battery_setpoint_w
FROM ems.planning_interval
WHERE run_id = $1
ORDER BY interval_start
""",
run_row["id"],
)
intervals = [record_to_dict(r) for r in int_rows]
tomorrow_slots = await conn.fetchval(
"""
SELECT COUNT(*)::int
FROM ems.vw_site_effective_price v
WHERE v.site_id = $1
AND (v.interval_start AT TIME ZONE $2)::date =
((CURRENT_TIMESTAMP AT TIME ZONE $2)::date + INTERVAL '1 day')::date
""",
site_id,
tz,
)
tomorrow_slots = int(tomorrow_slots or 0)
now_utc = datetime.now(timezone.utc)
hb_last = hb_row["last_seen"] if hb_row else None
hb_age = _age_seconds(hb_last)
inv_measured = inv_row["measured_at"] if inv_row else None
inv_age = _age_seconds(inv_measured)
next_start, next_bat = _next_plan_interval(intervals, now_utc)
ev_list: list[dict[str, Any]] = []
for r in ev_rows:
ev_list.append(
{
"code": r["code"],
"status": r["status"],
"power_w": int(r["power_w"]) if r["power_w"] is not None else None,
}
)
telemetry: dict[str, Any] = {
"inverter": {
"pv_power_w": int(inv_row["pv_power_w"]) if inv_row and inv_row["pv_power_w"] is not None else None,
"battery_soc_pct": float(inv_row["battery_soc_percent"])
if inv_row and inv_row["battery_soc_percent"] is not None
else None,
"grid_power_w": int(inv_row["grid_power_w"]) if inv_row and inv_row["grid_power_w"] is not None else None,
"measured_at": _iso_utc(inv_measured),
"age_seconds": inv_age,
},
"ev_chargers": ev_list,
"heat_pump": {
"power_w": int(hp_row["power_w"]) if hp_row and hp_row["power_w"] is not None else None,
"tank_temp_c": float(hp_row["tuv_tank_temp_c"])
if hp_row and hp_row["tuv_tank_temp_c"] is not None
else None,
"measured_at": _iso_utc(hp_row["measured_at"]) if hp_row else None,
},
}
has_plan = run_row is not None
planning = {
"has_active_plan": has_plan,
"plan_created_at": _iso_utc(run_row["created_at"]) if run_row else None,
"next_interval_start": next_start,
"next_battery_setpoint_w": next_bat,
}
mode_code = (mode_row["mode_code"] if mode_row else None) or ""
reserve_soc = float(reserve_row["reserve_soc"]) if reserve_row and reserve_row["reserve_soc"] is not None else None
soc = float(inv_row["battery_soc_percent"]) if inv_row and inv_row["battery_soc_percent"] is not None else None
alerts: list[dict[str, str]] = []
def add_alert(level: Literal["warn", "error"], message: str) -> None:
alerts.append({"level": level, "message": message})
if inv_age is None or inv_age > INV_STALE_SEC:
add_alert("error", "Telemetrie střídače nedostupná")
if not has_plan:
add_alert("warn", "Není aktivní plán EMS neoptimalizuje")
if tomorrow_slots < EXPECTED_TOMORROW_PRICE_SLOTS:
add_alert("warn", "Chybí spotové ceny pro zítřek")
if mode_code.upper() == "MANUAL":
add_alert("warn", "Systém v manuálním režimu")
if reserve_soc is not None and soc is not None and soc < reserve_soc:
add_alert("error", "SoC baterie pod rezervou")
if hb_age is None or hb_age > HEARTBEAT_STALE_SEC:
add_alert("error", "EMS heartbeat výpadek")
alerts.sort(key=lambda a: (0 if a["level"] == "error" else 1, a["message"]))
return {
"site": {"id": site["id"], "code": site["code"], "name": site["name"]},
"operating_mode": {
"mode_code": mode_row["mode_code"] if mode_row else None,
"mode_name": mode_row["mode_name"] if mode_row else None,
"activated_at": _iso_utc(mode_row["activated_at"]) if mode_row else None,
"activated_by": mode_row["activated_by"] if mode_row else None,
},
"heartbeat": {
"last_seen": _iso_utc(hb_last),
"age_seconds": hb_age,
"status": hb_row["status"] if hb_row else None,
},
"telemetry": telemetry,
"planning": planning,
"alerts": alerts,
}

View File

@@ -1,72 +1,33 @@
"""REST API aktivní plán a ruční přepočet."""
from datetime import datetime
from datetime import datetime, timedelta, timezone
from typing import Annotated, Any, Literal
import asyncpg
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from pydantic import BaseModel
from app.db_json import record_to_dict
from app.deps import get_pg_pool
from services.planning_engine import run_plan_api
from services.planning_engine import _current_slot_start, run_plan_api
router = APIRouter(prefix="/sites/{site_id}/plan", tags=["plan"])
class PlanningRunOut(BaseModel):
id: int
created_at: datetime
run_type: str
horizon_start: datetime
horizon_end: datetime
forecast_correction_factor: float | None = None
solver_duration_ms: int | None = None
class PlanningIntervalOut(BaseModel):
interval_start: datetime
battery_setpoint_w: int | None = None
battery_soc_target_pct: float | None = None
grid_setpoint_w: int | None = None
ev1_setpoint_w: int | None = None
ev2_setpoint_w: int | None = None
heat_pump_enabled: bool | None = None
pv_a_curtailed_w: int | None = None
expected_cost_czk: float | None = None
effective_buy_price: float | None = None
effective_sell_price: float | None = None
pv_forecast_total_w: int | None = Field(
default=None,
description="Součet FVE forecast A+B pro graf (k aktuálnímu slotu z DB).",
)
load_baseline_w: int | None = Field(
default=None,
description="Bazální spotřeba forecast pro graf.",
)
class PlanningSummaryOut(BaseModel):
total_expected_cost_czk: float
total_pv_curtailed_kwh: float
charge_slots: int
discharge_slots: int
export_slots: int
class CurrentPlanResponse(BaseModel):
run: PlanningRunOut | None
intervals: list[PlanningIntervalOut]
summary: PlanningSummaryOut | None
PRICE_CHECK_HOURS = 24
_SLOTS_PER_HOUR = 4
_EXPECTED_PRICE_SLOTS = PRICE_CHECK_HOURS * _SLOTS_PER_HOUR
class RunPlanResponse(BaseModel):
run_id: int
solver_duration_ms: int
horizon_start: datetime
horizon_end: datetime
def _build_summary(intervals: list[dict[str, Any]]) -> PlanningSummaryOut:
def _build_summary(intervals: list[dict[str, Any]]) -> dict[str, Any]:
total_cost = 0.0
curtailed_wh = 0.0
total_curtailed_kwh = 0.0
charge_slots = 0
discharge_slots = 0
export_slots = 0
@@ -75,7 +36,7 @@ def _build_summary(intervals: list[dict[str, Any]]) -> PlanningSummaryOut:
if ec is not None:
total_cost += float(ec)
c = row.get("pv_a_curtailed_w") or 0
curtailed_wh += int(c) * 0.25
total_curtailed_kwh += int(c) * 0.25 / 1000.0
b = row.get("battery_setpoint_w")
if b is not None:
if int(b) > 0:
@@ -85,153 +46,110 @@ def _build_summary(intervals: list[dict[str, Any]]) -> PlanningSummaryOut:
g = row.get("grid_setpoint_w")
if g is not None and int(g) < 0:
export_slots += 1
return PlanningSummaryOut(
total_expected_cost_czk=round(total_cost, 4),
total_pv_curtailed_kwh=round(curtailed_wh / 1000.0, 6),
charge_slots=charge_slots,
discharge_slots=discharge_slots,
export_slots=export_slots,
)
return {
"total_expected_cost_czk": round(total_cost, 4),
"total_pv_curtailed_kwh": round(total_curtailed_kwh, 6),
"charge_slots": charge_slots,
"discharge_slots": discharge_slots,
"export_slots": export_slots,
}
@router.get("/current", response_model=CurrentPlanResponse)
@router.get("/current")
async def get_current_plan(
site_id: int,
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
) -> CurrentPlanResponse:
) -> dict[str, Any]:
async with pool.acquire() as conn:
exists = await conn.fetchval("SELECT 1 FROM ems.site WHERE id = $1", site_id)
if not exists:
site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id)
if not site_ok:
raise HTTPException(status_code=404, detail="Site not found")
run_row = await conn.fetchrow(
"""
SELECT id, created_at, run_type, horizon_start, horizon_end,
forecast_correction_factor, solver_duration_ms
FROM ems.planning_run
WHERE site_id = $1 AND status = 'active'
ORDER BY created_at DESC
SELECT pr.*
FROM ems.planning_run pr
WHERE pr.site_id = $1 AND pr.status = 'active'
ORDER BY pr.created_at DESC
LIMIT 1
""",
site_id,
)
if not run_row:
return CurrentPlanResponse(run=None, intervals=[], summary=None)
raise HTTPException(status_code=404, detail="No active plan")
run_id = run_row["id"]
int_rows = await conn.fetch(
"""
SELECT
pi.interval_start,
pi.battery_setpoint_w,
pi.battery_soc_target_pct,
pi.grid_setpoint_w,
pi.ev1_setpoint_w,
pi.ev2_setpoint_w,
pi.heat_pump_enabled,
pi.pv_a_curtailed_w,
pi.expected_cost_czk,
pi.effective_buy_price,
pi.effective_sell_price,
COALESCE(fa.power_w, 0) + COALESCE(fb.power_w, 0) AS pv_forecast_total_w,
COALESCE(cbi.power_w, 500) AS load_baseline_w
FROM ems.planning_interval pi
LEFT JOIN LATERAL (
SELECT fpi.power_w
FROM ems.forecast_pv_interval fpi
JOIN ems.forecast_pv_run fpr ON fpr.id = fpi.run_id
JOIN ems.asset_pv_array apa ON apa.id = fpi.pv_array_id AND apa.site_id = fpr.site_id
WHERE fpr.site_id = $2
AND apa.code = 'pv-a'
AND fpi.interval_start = pi.interval_start
AND fpr.status = 'ok'
ORDER BY fpr.created_at DESC
LIMIT 1
) fa ON true
LEFT JOIN LATERAL (
SELECT fpi.power_w
FROM ems.forecast_pv_interval fpi
JOIN ems.forecast_pv_run fpr ON fpr.id = fpi.run_id
JOIN ems.asset_pv_array apa ON apa.id = fpi.pv_array_id AND apa.site_id = fpr.site_id
WHERE fpr.site_id = $2
AND apa.code = 'pv-b'
AND fpi.interval_start = pi.interval_start
AND fpr.status = 'ok'
ORDER BY fpr.created_at DESC
LIMIT 1
) fb ON true
LEFT JOIN ems.consumption_baseline_interval cbi
ON cbi.site_id = $2
AND cbi.interval_start = pi.interval_start
AND cbi.data_type = 'forecast'
WHERE pi.run_id = $1
ORDER BY pi.interval_start
SELECT *
FROM ems.planning_interval
WHERE run_id = $1
ORDER BY interval_start
""",
run_id,
site_id,
)
intervals_dicts = [dict(r) for r in int_rows]
summary = _build_summary(intervals_dicts) if intervals_dicts else None
run_out = PlanningRunOut(
id=run_row["id"],
created_at=run_row["created_at"],
run_type=run_row["run_type"],
horizon_start=run_row["horizon_start"],
horizon_end=run_row["horizon_end"],
forecast_correction_factor=float(run_row["forecast_correction_factor"])
if run_row["forecast_correction_factor"] is not None
else None,
solver_duration_ms=run_row["solver_duration_ms"],
)
intervals_out = [
PlanningIntervalOut(
interval_start=r["interval_start"],
battery_setpoint_w=r["battery_setpoint_w"],
battery_soc_target_pct=float(r["battery_soc_target_pct"])
if r["battery_soc_target_pct"] is not None
else None,
grid_setpoint_w=r["grid_setpoint_w"],
ev1_setpoint_w=r["ev1_setpoint_w"],
ev2_setpoint_w=r["ev2_setpoint_w"],
heat_pump_enabled=r["heat_pump_enabled"],
pv_a_curtailed_w=r["pv_a_curtailed_w"],
expected_cost_czk=float(r["expected_cost_czk"])
if r["expected_cost_czk"] is not None
else None,
effective_buy_price=float(r["effective_buy_price"])
if r["effective_buy_price"] is not None
else None,
effective_sell_price=float(r["effective_sell_price"])
if r["effective_sell_price"] is not None
else None,
pv_forecast_total_w=int(r["pv_forecast_total_w"] or 0),
load_baseline_w=int(r["load_baseline_w"] or 0),
)
for r in intervals_dicts
]
return CurrentPlanResponse(run=run_out, intervals=intervals_out, summary=summary)
intervals = [record_to_dict(r) for r in int_rows]
summary = _build_summary(intervals)
return {"run": record_to_dict(run_row), "intervals": intervals, "summary": summary}
@router.post("/run", response_model=RunPlanResponse)
async def post_run_plan(
site_id: int,
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
plan_type: Literal["daily", "rolling"] = Query(..., alias="type"),
plan_type: Literal["daily", "rolling"] = Query("rolling", alias="type"),
) -> RunPlanResponse:
window_start = _current_slot_start(datetime.now(timezone.utc))
window_end = window_start + timedelta(hours=PRICE_CHECK_HOURS)
async with pool.acquire() as conn:
exists = await conn.fetchval("SELECT 1 FROM ems.site WHERE id = $1", site_id)
if not exists:
site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id)
if not site_ok:
raise HTTPException(status_code=404, detail="Site not found")
price_slots = await conn.fetchval(
"""
SELECT COUNT(DISTINCT interval_start)::int
FROM ems.vw_site_effective_price
WHERE site_id = $1
AND interval_start >= $2
AND interval_start < $3
""",
site_id,
window_start,
window_end,
)
if (price_slots or 0) < _EXPECTED_PRICE_SLOTS:
raise HTTPException(
status_code=422,
detail="Nejsou dostupné tržní ceny. Spusťte nejdřív import cen.",
)
try:
run_id, duration_ms = await run_plan_api(
site_id, conn, plan_type=plan_type, triggered_by="api"
run_id, solver_duration_ms = await run_plan_api(
site_id, plan_type, conn, triggered_by="api"
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
except RuntimeError as e:
raise HTTPException(status_code=500, detail=str(e)) from e
return RunPlanResponse(run_id=run_id, solver_duration_ms=duration_ms)
raise HTTPException(status_code=422, detail=str(e)) from e
row = await conn.fetchrow(
"""
SELECT horizon_start, horizon_end
FROM ems.planning_run
WHERE id = $1
""",
run_id,
)
if row is None:
raise HTTPException(status_code=500, detail="Planning run row missing after insert")
return RunPlanResponse(
run_id=run_id,
solver_duration_ms=solver_duration_ms,
horizon_start=row["horizon_start"],
horizon_end=row["horizon_end"],
)

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