Files
next-tjwater-server/app/services/scada_raw.py
T
jiang 26fa5d1909
Generic Container CI/CD / test-build-publish (push) Successful in 3m39s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 3m39s
feat(scada): add project monitoring replay
2026-11-04 14:27:21 +08:00

67 lines
2.9 KiB
Python

"""Read-only chronological SCADA paging shared by REST and replay processing."""
from __future__ import annotations
import base64
import hashlib
import json
import math
from datetime import datetime
from app.domain.schemas.scada import ScadaRawPage, ScadaRawReading
from app.infra.db.timescaledb.repositories.scada import ScadaRepository
def validate_window(start: datetime, end: datetime) -> None:
if start.tzinfo is None or end.tzinfo is None or start >= end:
raise ValueError("需要带时区、结束晚于开始的 SCADA 时间区间")
def raw_reading(row: dict) -> ScadaRawReading:
value = row["monitored_value"]
invalid = value is not None and not math.isfinite(float(value))
return ScadaRawReading(
time=row["time"], device_id=row["device_id"],
monitored_value=None if invalid else value,
quality="invalid" if invalid else "missing" if value is None else "unvalidated",
)
async def read_raw_page(
conn, *, project_id: str, device_ids: list[str], start: datetime,
end: datetime, limit: int = 500, cursor: str | None = None,
through: datetime | None = None,
) -> ScadaRawPage:
validate_window(start, end)
ids = sorted(set(device_ids))
if not ids or len(ids) > 500 or not 1 <= limit <= 500:
raise ValueError("请选择 1 至 500 个设备,每页最多 500 条")
cap = min(through or end, end)
if cap.tzinfo is None or cap < start:
raise ValueError("模拟时间不在可读取区间内")
signature = hashlib.sha256(json.dumps(
[project_id, ids, start.isoformat(), end.isoformat()], separators=(",", ":")
).encode()).hexdigest()
after = None
if cursor:
try:
data = json.loads(base64.urlsafe_b64decode(cursor.encode()))
frozen = datetime.fromisoformat(data["through"])
last = datetime.fromisoformat(data["time"])
if (data["query"] != signature or frozen.tzinfo is None or last.tzinfo is None
or frozen > cap or not start <= last <= frozen or data["device"] not in ids):
raise ValueError()
cap, after = frozen, (last, data["device"])
except (ValueError, KeyError, TypeError, UnicodeError) as exc:
raise ValueError("SCADA 分页游标无效或查询范围已改变") from exc
rows = await ScadaRepository.get_raw_page(conn, ids, start, end, cap, limit + 1, after)
selected = rows[:limit]
next_cursor = None
if len(rows) > limit:
last = selected[-1]
next_cursor = base64.urlsafe_b64encode(json.dumps({
"query": signature, "through": cap.isoformat(),
"time": last["time"].isoformat(), "device": last["device_id"],
}, separators=(",", ":")).encode()).decode()
return ScadaRawPage(items=[raw_reading(row) for row in selected],
next_cursor=next_cursor, scanned_through=cap)