96 lines
5.3 KiB
Python
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 {}),
|
|
}
|