"""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, }