"""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)