"""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()}") def check_snapshot_start(run, start): replay_start = datetime.fromisoformat(run["start_time"]) if start < replay_start: baseline = run.get("baseline") if not baseline: raise HTTPException(422, "本轮回放未冻结历史对照,请新建回放后查询历史基线") if start < datetime.fromisoformat(baseline["start_time"]): raise HTTPException(422, "查询起点早于本轮已冻结的历史基线") 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, "设备或回放不属于当前调查") check_snapshot_start(run, start) 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: check_snapshot_start(run, min(args.scada_burst_start, args.scada_normal_start)) 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 {}), }