implmentace plan guardu
Some checks failed
CI and deploy / migration-check (push) Failing after 17s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-04-19 23:10:25 +02:00
parent d8221e3169
commit e3776226a4
9 changed files with 369 additions and 7 deletions

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
import asyncio
import json
import logging
from datetime import datetime
from datetime import datetime, timezone
import asyncpg
import httpx
@@ -119,6 +119,27 @@ async def run_fn_set_mode_with_discord(
return str(new)
async def notify_plan_vs_actual_fatal(
site_code: str,
slot_label: str,
interval_start_utc: datetime,
plan_grid_w: int,
actual_grid_w: int,
deviation_grid_w: int,
reason_code: str,
detail: str,
) -> None:
"""Discord po fatální odchylce plán vs. audit (síť) pro uzavřený 15min slot."""
utc_label = interval_start_utc.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
msg = (
f"**Fatální odchylka plán vs. realita (síť)** `{site_code}`\n"
f"Slot: **{slot_label}** (`{utc_label}`)\n"
f"**{reason_code}**: {detail}\n"
f"Plán grid: **{plan_grid_w}** W | Skutečnost: **{actual_grid_w}** W | Δ (actplan): **{deviation_grid_w}** W"
)
await send_discord(msg, level="critical")
async def send_discord(message: str, level: str = "info") -> bool:
"""
Pošle notifikaci na Discord webhook.

View File

@@ -0,0 +1,116 @@
"""
Kontrola plán vs. skutečnost po uzavření 15min slotu.
Pravidla a dedup INSERT drží ems.fn_plan_actual_slot_guard_site / fn_plan_actual_slot_guard_all_active
(repeatable R__076). Python jen zavolá funkci a pošle Discord podle vrácených alertů.
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from typing import Any
import asyncpg
from zoneinfo import ZoneInfo
from app.db_json import fetch_json
from services.notification_service import notify_plan_vs_actual_fatal
logger = logging.getLogger(__name__)
_PRAGUE = ZoneInfo("Europe/Prague")
def _interval_start_utc(value: Any) -> datetime:
if isinstance(value, datetime):
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
if isinstance(value, str):
s = value.replace("Z", "+00:00")
dt = datetime.fromisoformat(s)
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
raise TypeError(f"expected datetime or str for interval_start, got {type(value)!r}")
def _slot_label_prague(interval_start: datetime) -> str:
loc = interval_start.astimezone(_PRAGUE)
return loc.strftime("%Y-%m-%d %H:%M") + " Europe/Prague"
async def _dispatch_site_result(site_payload: dict[str, Any]) -> None:
if site_payload.get("error") == "unknown_site":
logger.warning("plan_actual_slot_guard: unknown site_id=%s", site_payload.get("site_id"))
return
site_code = str(site_payload.get("site_code") or site_payload.get("site_id") or "")
alerts = site_payload.get("alerts")
if not isinstance(alerts, list):
return
for alert in alerts:
if not isinstance(alert, dict):
continue
if not alert.get("notify"):
continue
interval_start = _interval_start_utc(alert["interval_start"])
reason_code = str(alert.get("reason_code") or "")
detail = str(alert.get("detail") or "")
plan_grid_w = int(alert.get("plan_grid_w") or 0)
actual_grid_w = int(alert.get("actual_grid_w") or 0)
deviation_grid_w = int(alert.get("deviation_grid_w") or 0)
slot_label = _slot_label_prague(interval_start)
await notify_plan_vs_actual_fatal(
site_code=site_code,
slot_label=slot_label,
interval_start_utc=interval_start,
plan_grid_w=plan_grid_w,
actual_grid_w=actual_grid_w,
deviation_grid_w=deviation_grid_w,
reason_code=reason_code,
detail=detail,
)
logger.warning(
"[site=%s] plan_actual fatal %s slot=%s: %s",
site_payload.get("site_id"),
reason_code,
interval_start.isoformat(),
detail,
)
async def run_plan_actual_slot_guard_for_all_active_sites(
pool: asyncpg.Pool,
*,
now: datetime | None = None,
) -> None:
"""Scheduler: jeden dotaz přes aktivní lokality (SQL dedup + klasifikace)."""
async with pool.acquire() as conn:
try:
if now is not None:
raw = await fetch_json(
conn,
"SELECT ems.fn_plan_actual_slot_guard_all_active($1::timestamptz)",
now,
)
else:
raw = await fetch_json(conn, "SELECT ems.fn_plan_actual_slot_guard_all_active()")
except Exception:
logger.exception("plan_actual_slot_guard fn_plan_actual_slot_guard_all_active failed")
return
if raw is None:
return
if not isinstance(raw, list):
logger.warning("plan_actual_slot_guard: unexpected payload type %s", type(raw))
return
for site_payload in raw:
if not isinstance(site_payload, dict):
continue
try:
await _dispatch_site_result(site_payload)
except Exception:
logger.exception(
"plan_actual_slot_guard site=%s failed",
site_payload.get("site_id"),
)