269 lines
8.9 KiB
Python
269 lines
8.9 KiB
Python
"""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,
|
||
}
|