Files
next-tjwater-server/app/services/scada_replay_queries.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

96 lines
5.3 KiB
Python

"""Server-enforced replay knowledge bounds and immutable corrected snapshots."""
from datetime import datetime
from fastapi import HTTPException
from app.infra.db.postgresql.scada_replays import ScadaReplayRepository
def check_replay_window(context, start, end):
cap = datetime.fromisoformat(context["knowledge_cutoff"])
if start is None or end is None or start.tzinfo is None or end.tzinfo is None or start > end or end > cap:
raise HTTPException(422, f"回放查询需要明确时间范围,结束不得晚于 {cap.isoformat()}")
async def replay_snapshot_rows(connection, project_id, context, device_ids, start, end, limit, offset=0):
check_replay_window(context, start, end)
repository = ScadaReplayRepository(connection, project_id)
run = await repository.get(context["replay_id"])
if not set(device_ids) <= set(run["config"]["device_ids"]):
raise HTTPException(403, "设备或回放不属于当前调查")
if start < datetime.fromisoformat(run["start_time"]):
raise HTTPException(422, "当前调查证据接口仅返回本轮已处理观测,回放前基线已用于后台初始化")
rows = await repository.detected_outputs(
context["replay_id"], device_ids, start, end, context["batch_sequence"],
limit=limit, offset=offset,
)
return [{key: row[key] for key in (
"time", "device_id", "monitored_value", "cleaned_value", "quality", "reason"
)} for row in rows]
async def replay_burst_location_inputs(connection, project_id, context, args):
"""Build location series from immutable replay outputs, never live TSDB rows."""
if args.data_source != "monitoring":
raise HTTPException(422, "回放定位只支持 monitoring 数据源")
if args.scada_normal_start is None or args.scada_normal_end is None:
raise HTTPException(422, "回放定位必须显式提供已处理范围内的正常对照窗口")
check_replay_window(context, args.scada_burst_start, args.scada_burst_end)
check_replay_window(context, args.scada_normal_start, args.scada_normal_end)
repository = ScadaReplayRepository(connection, project_id)
run = await repository.get(context["replay_id"])
if run:
replay_start = datetime.fromisoformat(run["start_time"])
if args.scada_burst_start < replay_start or args.scada_normal_start < replay_start:
raise HTTPException(422, "定位窗口必须位于本轮已处理回放范围内")
devices = {item["device_id"]: item for item in run.get("devices", [])}
monitoring = run.get("monitoring_config") or {}
pressure_devices = monitoring.get("pressure_device_ids", [])
pressure_map = {str(devices[item]["node_id"]): item for item in pressure_devices
if item in devices and devices[item].get("node_id")}
requested_pressure = args.pressure_scada_ids or list(pressure_map)
if not requested_pressure or not set(requested_pressure) <= set(pressure_map):
raise HTTPException(422, "压力节点必须来自本轮已配置的回放监测角色")
flow_devices = monitoring.get("outlet_flow_device_ids", [])
flow_map = {str(devices[item]["link_id"]): item for item in flow_devices
if item in devices and devices[item].get("link_id")}
requested_flow = args.flow_scada_ids or []
if not set(requested_flow) <= set(flow_map):
raise HTTPException(422, "流量要素必须来自本轮已配置的出厂边界流量角色")
selected = [pressure_map[item] for item in requested_pressure] + [flow_map[item] for item in requested_flow]
rows = await repository.detected_outputs(
context["replay_id"], selected,
min(args.scada_burst_start, args.scada_normal_start),
max(args.scada_burst_end, args.scada_normal_end),
context["batch_sequence"],
)
def averages(mapping, start, end):
result = {}
for element_id, device_id in mapping.items():
values = [float(row["cleaned_value"]) for row in rows
if row.get("device_id") == device_id
and row.get("quality") in {"valid", "corrected"}
and row.get("cleaned_value") is not None
and start <= datetime.fromisoformat(row["time"]) <= end]
if not values:
raise HTTPException(422, f"回放窗口缺少 {device_id} 的有效清洗观测")
result[element_id] = sum(values) / len(values)
return result
pressure_mapping = {item: pressure_map[item] for item in requested_pressure}
flow_mapping = {item: flow_map[item] for item in requested_flow}
return {
"data_source": "monitoring",
"burst_leakage": args.burst_leakage,
"pressure_scada_ids": requested_pressure,
"burst_pressure": averages(pressure_mapping, args.scada_burst_start, args.scada_burst_end),
"normal_pressure": averages(pressure_mapping, args.scada_normal_start, args.scada_normal_end),
**({
"flow_scada_ids": requested_flow,
"burst_flow": averages(flow_mapping, args.scada_burst_start, args.scada_burst_end),
"normal_flow": averages(flow_mapping, args.scada_normal_start, args.scada_normal_end),
"use_scada_flow": True,
} if requested_flow else {}),
}