second version

This commit is contained in:
Dusan Vojacek
2026-04-03 14:23:16 +02:00
parent 897b95f728
commit 9f4126946d
105 changed files with 9738 additions and 1470 deletions

View File

@@ -2,17 +2,38 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from datetime import date, datetime, timedelta, timezone
from typing import Annotated, Any, Literal
from zoneinfo import ZoneInfo
import asyncpg
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from app.db_json import record_to_dict
from app.deps import get_pg_pool
from app.notifications_logic import (
EvSessionRow,
NegWindowRow,
PriceSlot,
build_smart_notifications,
)
router = APIRouter(prefix="/sites/{site_id}", tags=["sites"])
class SiteNotificationItem(BaseModel):
id: str
level: Literal["success", "info", "warning", "error"]
title: str
body: str
eta_minutes: int | None = None
action: Literal["connect_ev", "replan", "import_prices", "switch_auto"] | None = None
class SiteNotificationsResponse(BaseModel):
notifications: list[SiteNotificationItem] = Field(default_factory=list)
INV_STALE_SEC = 300
HEARTBEAT_STALE_SEC = 300
EXPECTED_TOMORROW_PRICE_SLOTS = 90
@@ -235,7 +256,10 @@ async def get_site_status_full(
if not has_plan:
add_alert("warn", "Není aktivní plán EMS neoptimalizuje")
if tomorrow_slots < EXPECTED_TOMORROW_PRICE_SLOTS:
# OTE D+1 typicky až po ~14:30 Europe/Prague před tím nevarovat
now_prague = datetime.now(ZoneInfo("Europe/Prague"))
prices_expected = (now_prague.hour, now_prague.minute) >= (14, 30)
if tomorrow_slots < EXPECTED_TOMORROW_PRICE_SLOTS and prices_expected:
add_alert("warn", "Chybí spotové ceny pro zítřek")
if mode_code.upper() == "MANUAL":
@@ -266,3 +290,326 @@ async def get_site_status_full(
"planning": planning,
"alerts": alerts,
}
_NOTIF_LEVEL_PRIORITY = {"error": 0, "success": 1, "warning": 2, "info": 3}
def _infrastructure_notification_items(
*,
has_plan: bool,
tomorrow_slots: int,
mode_code: str,
reserve_soc: float | None,
soc: float | None,
inv_age: int | None,
hb_age: int | None,
) -> list[SiteNotificationItem]:
"""Kritické / provozní notifikace (telemetrie, plán, ceny, režim, heartbeat)."""
items: list[SiteNotificationItem] = []
def push(
nid: str,
level: Literal["success", "info", "warning", "error"],
title: str,
body: str,
*,
eta_minutes: int | None = None,
action: Literal["connect_ev", "replan", "import_prices", "switch_auto"] | None = None,
) -> None:
items.append(
SiteNotificationItem(
id=nid,
level=level,
title=title,
body=body,
eta_minutes=eta_minutes,
action=action,
)
)
if inv_age is None or inv_age > INV_STALE_SEC:
push("telemetry_inverter", "error", "Telemetrie střídače", "Data ze střídače nejsou aktuální.")
if not has_plan:
push(
"no_active_plan",
"warning",
"Chybí aktivní plán",
"EMS zatím neoptimalizuje provoz spusťte plánování.",
action="replan",
)
now_prague = datetime.now(ZoneInfo("Europe/Prague"))
prices_expected = (now_prague.hour, now_prague.minute) >= (14, 30)
if tomorrow_slots < EXPECTED_TOMORROW_PRICE_SLOTS and prices_expected:
push(
"prices_tomorrow",
"warning",
"Ceny na zítřek",
"Nejsou kompletní spotové ceny OTE pro následující den.",
action="import_prices",
)
if mode_code.upper() == "MANUAL":
push("mode_manual", "info", "Manuální režim", "Automatická optimalizace je vypnutá.")
if reserve_soc is not None and soc is not None and soc < reserve_soc:
push("soc_reserve", "error", "SoC pod rezervou", "Nabití baterie je pod nastavenou bezpečnostní rezervou.")
if hb_age is None or hb_age > HEARTBEAT_STALE_SEC:
push("heartbeat", "error", "EMS heartbeat", "Služba EMS nehlásí pravidelný heartbeat.")
return items
def _float_or_none(v: Any) -> float | None:
if v is None:
return None
return float(v)
@router.get("/notifications", response_model=SiteNotificationsResponse)
async def get_site_notifications(
site_id: int,
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
) -> SiteNotificationsResponse:
async with pool.acquire() as conn:
site = await conn.fetchrow(
"SELECT id, 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
FROM ems.site_operating_mode m
WHERE m.site_id = $1
""",
site_id,
)
run_row = await conn.fetchrow(
"""
SELECT id FROM ems.planning_run
WHERE site_id = $1 AND status = 'active'
ORDER BY created_at DESC
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,
)
inv_row = await conn.fetchrow(
"""
SELECT battery_soc_percent, measured_at
FROM ems.vw_latest_inverter
WHERE site_id = $1
ORDER BY measured_at DESC NULLS LAST
LIMIT 1
""",
site_id,
)
hb_row = await conn.fetchrow(
"SELECT last_seen FROM ems.site_heartbeat WHERE site_id = $1",
site_id,
)
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,
)
price_rows = await conn.fetch(
"""
SELECT interval_start,
effective_buy_price_czk_kwh,
effective_sell_price_czk_kwh
FROM ems.vw_site_effective_price
WHERE site_id = $1
AND interval_start >= now()
AND interval_start < now() + INTERVAL '48 hours'
ORDER BY interval_start
""",
site_id,
)
avg_row = await conn.fetchrow(
"""
SELECT AVG(effective_buy_price_czk_kwh)::float AS avg_buy
FROM ems.vw_site_effective_price
WHERE site_id = $1
AND interval_start::date IN (CURRENT_DATE, CURRENT_DATE + INTERVAL '1 day')
""",
site_id,
)
bat_row = await conn.fetchrow(
"""
SELECT COALESCE(SUM(ab.usable_capacity_wh), 0)::float AS usable_wh
FROM ems.asset_battery ab
JOIN ems.asset_inverter ai ON ai.id = ab.inverter_id
WHERE ai.site_id = $1
""",
site_id,
)
ev_rows = await conn.fetch(
"""
SELECT DISTINCT ON (es.id)
es.id,
es.charger_id,
es.energy_delivered_wh,
es.target_soc_pct,
es.session_start,
es.soc_at_connect_pct,
COALESCE(av_id.battery_capacity_kwh, av_def.battery_capacity_kwh) AS battery_capacity_kwh,
COALESCE(av_id.make, av_def.make) AS make,
COALESCE(av_id.model, av_def.model) AS model,
COALESCE(av_id.default_target_soc_pct, av_def.default_target_soc_pct) AS default_target_soc_pct,
ac.code AS charger_code
FROM ems.ev_session es
JOIN ems.asset_ev_charger ac ON ac.id = es.charger_id
LEFT JOIN ems.asset_vehicle av_id ON av_id.id = es.vehicle_id
LEFT JOIN ems.asset_vehicle av_def
ON av_def.default_charger_id = ac.id AND es.vehicle_id IS NULL
WHERE es.site_id = $1 AND es.session_end IS NULL
ORDER BY es.id, av_def.id NULLS LAST
""",
site_id,
)
neg_rows = await conn.fetch(
"""
SELECT predicted_date, window_start_hour, window_end_hour, probability_pct
FROM ems.predicted_negative_price_window
WHERE site_id = $1
AND predicted_date BETWEEN CURRENT_DATE AND CURRENT_DATE + 2
AND probability_pct >= 50
ORDER BY predicted_date, window_start_hour
""",
site_id,
)
has_plan = run_row is not None
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
)
inv_age = _age_seconds(inv_row["measured_at"] if inv_row else None)
hb_age = _age_seconds(hb_row["last_seen"] if hb_row else None)
infra = _infrastructure_notification_items(
has_plan=has_plan,
tomorrow_slots=int(tomorrow_slots or 0),
mode_code=mode_code,
reserve_soc=reserve_soc,
soc=soc,
inv_age=inv_age,
hb_age=hb_age,
)
prices: list[PriceSlot] = []
for r in price_rows:
buy = _float_or_none(r["effective_buy_price_czk_kwh"])
if buy is None:
continue
sell_v = _float_or_none(r["effective_sell_price_czk_kwh"])
istart = r["interval_start"]
prices.append(
PriceSlot(
interval_start=istart,
buy=buy,
sell=sell_v if sell_v is not None else buy,
)
)
avg_buy = _float_or_none(avg_row["avg_buy"]) if avg_row else None
usable_wh = _float_or_none(bat_row["usable_wh"]) if bat_row else None
battery_kwh = (usable_wh / 1000.0) if usable_wh is not None else None
ev_sessions: list[EvSessionRow] = []
for er in ev_rows:
ev_sessions.append(
EvSessionRow(
id=int(er["id"]),
charger_id=int(er["charger_id"]),
energy_delivered_wh=float(er["energy_delivered_wh"] or 0),
target_soc_pct=_float_or_none(er["target_soc_pct"]),
session_start=er["session_start"],
battery_capacity_kwh=_float_or_none(er["battery_capacity_kwh"]),
make=er["make"],
model=er["model"],
default_target_soc_pct=_float_or_none(er["default_target_soc_pct"]),
charger_code=str(er["charger_code"] or ""),
soc_at_connect_pct=_float_or_none(er["soc_at_connect_pct"]),
)
)
neg_windows: list[NegWindowRow] = []
for nr in neg_rows:
dr = nr["predicted_date"]
if isinstance(dr, datetime):
d_conv = dr.date()
elif isinstance(dr, date):
d_conv = dr
else:
d_conv = date.today()
neg_windows.append(
NegWindowRow(
predicted_date=d_conv,
window_start_hour=int(nr["window_start_hour"]),
window_end_hour=int(nr["window_end_hour"]),
probability_pct=int(nr["probability_pct"]),
)
)
sell_now = prices[0].sell if prices else None
smart_raw = build_smart_notifications(
prices=prices,
avg_buy=avg_buy,
soc_pct=soc,
battery_kwh=battery_kwh,
ev_sessions=ev_sessions,
neg_windows=neg_windows,
mode=mode_code,
sell_price_now=sell_now,
)
smart_items = [
SiteNotificationItem(
id=d["id"],
level=d["level"],
title=d["title"],
body=d["body"],
eta_minutes=d.get("eta_minutes"),
action=d.get("action"),
)
for d in smart_raw
]
merged = infra + smart_items
merged.sort(key=lambda x: _NOTIF_LEVEL_PRIORITY.get(x.level, 9))
return SiteNotificationsResponse(notifications=merged[:5])