feat(scada): add calibrated monitoring and incident recovery

This commit is contained in:
2026-11-07 19:44:58 +08:00
parent 26fa5d1909
commit ff8aba4964
109 changed files with 9165 additions and 629 deletions
+169
View File
@@ -0,0 +1,169 @@
#!/usr/bin/env python3
"""Build a private multi-region, multi-area and multi-time design from screening evidence."""
from __future__ import annotations
import argparse
import asyncio
import json
import math
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
from uuid import uuid4
def require_isolatable_pipe(topology: list[str], pipe_id: str, sources: list[str], closed: list[str]) -> dict:
from app.algorithms.valve_isolation.topology_search import valve_isolation_analysis
result = valve_isolation_analysis(topology, pipe_id, accident_element_type="link",
water_source_nodes=sources, closed_links=closed)
if not result["isolatable"] or not result["source_check_performed"] or not result["must_close_valves"]:
raise ValueError("selected region cannot be isolated from water sources")
return result
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--screening", type=Path, required=True)
parser.add_argument("--additional-screening", type=Path, action="append", default=[])
parser.add_argument("--pipe", action="append", required=True, help="New pipe ID; the existing primary is retained")
parser.add_argument("--allow-weak", action="store_true", help="Include weak-signal physical bursts without treating them as no-burst controls")
parser.add_argument("--case-start", type=int, default=9)
parser.add_argument("--output-root", type=Path, default=Path("data/benchmark-regional"))
args = parser.parse_args()
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
os.umask(0o077)
from scripts.benchmark.calibration import save_json, digest
from scripts.benchmark.simulation_design import BurstSimulationDesign, load_simulation_design
from scripts.check_burst_simulation_design import readonly, resolve_project, validate_database, validate_tool_inputs
original = load_simulation_design(Path("resources/benchmarks/tjwater2-v2-burst-simulation.json"))
screening = json.loads(args.screening.read_text())
candidates = {row["pipe_id"]: row for row in screening["results"]}
for path in args.additional_screening:
extra = json.loads(path.read_text())
if extra["model_sha256"] != screening["model_sha256"]:
raise ValueError("screening model fingerprints do not match")
candidates.update({row["pipe_id"]: row for row in extra["results"]})
requested = list(dict.fromkeys(args.pipe))
if not 1 <= len(requested) <= 3 or any(pipe not in candidates for pipe in requested):
raise ValueError("select one to three screened pipes")
for pipe in requested:
if not candidates[pipe].get("possible_candidate") and not args.allow_weak:
raise ValueError("selected pipe has not passed single-frame screening")
if candidates[pipe].get("status") != "completed":
raise ValueError("selected pipe screening simulation did not complete")
folder = (args.output_root / (datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + "-" + uuid4().hex[:8])).resolve()
folder.mkdir(parents=True, mode=0o700)
biz, _ = asyncio.run(resolve_project(original))
with readonly(biz) as connection:
links = connection.execute(
"SELECT l.id,l.start_node_id,l.end_node_id,l.link_type,"
"COALESCE(s.status::text,p.status::text,'OPEN') status FROM network.links l "
"LEFT JOIN network.pipes p ON p.link_id=l.id "
"LEFT JOIN network.link_initial_settings s ON s.link_id=l.id"
).fetchall()
sources = [row["id"] for row in connection.execute(
"SELECT id FROM network.nodes WHERE node_type IN ('reservoir','tank')"
)]
devices = connection.execute(
"SELECT device_id,device_type,node_id,link_id,measurement_unit FROM asset.scada_devices "
"WHERE device_type IN ('pressure','pipe_flow') AND device_id<>'547063'"
).fetchall()
positions = {row["id"]: row for row in connection.execute(
"SELECT l.id,l.start_node_id,l.end_node_id,p.diameter,p.length,"
"ST_X(ST_Transform(g.geom,4326)) longitude,ST_Y(ST_Transform(g.geom,4326)) latitude "
"FROM network.links l JOIN network.pipes p ON p.link_id=l.id "
"JOIN gis.node_geometries g ON g.node_id=l.end_node_id WHERE l.id=ANY(%s)",
([original.pipes[0].pipe_id, *requested],),
)}
pipe_geometries = {row["id"]: json.loads(row["geometry"]) for row in connection.execute(
"SELECT id,ST_AsGeoJSON(ST_Transform(geom,4326)) geometry FROM gis.pipes WHERE id=ANY(%s)",
([original.pipes[0].pipe_id, *requested],),
)}
topology = [f"{row['id']}:{row['link_type']}:{row['start_node_id']}:{row['end_node_id']}" for row in links]
closed = [row["id"] for row in links if str(row["status"]).upper() == "CLOSED"]
by_device = {row["device_id"]: row for row in devices}
pipes = [original.pipes[0].model_dump(mode="json")]
for pipe_id in requested:
candidate = candidates[pipe_id]
position = positions[pipe_id]
pressure_ids = [key for key in candidate["pressure_drop_percent"]
if key in by_device and by_device[key]["device_type"] == "pressure"][:5]
# Keep the plant outlets plus the two most responsive other flow meters.
flow_ids = ["542017", "486033", "542121"]
for key, _ in sorted(candidate["flow_change_percent"].items(), key=lambda pair: pair[1], reverse=True):
if key in by_device and key not in flow_ids:
flow_ids.append(key)
if len(flow_ids) >= 5:
break
pressure = [{"device_id": key, "node_id": by_device[key]["node_id"]} for key in pressure_ids]
flow = [{"device_id": key, "link_id": by_device[key]["link_id"]} for key in flow_ids]
area = math.pi * (position["diameter"] / 10) ** 2 / 4
pipes.append({
"role": "secondary", "pipe_id": pipe_id, "diameter_mm": position["diameter"],
"length_m": position["length"], "start_node_id": position["start_node_id"],
"emitter_node_id": position["end_node_id"],
"longitude": position["longitude"], "latitude": position["latitude"],
"candidate_orifice_areas_cm2": {"light": round(area / 32, 6), "medium": round(area / 16, 6), "severe": round(area / 8, 6)},
"pressure_bindings": pressure, "flow_bindings": flow,
"boundary_valve_ids": [],
"output_node_ids": sorted({position["start_node_id"], position["end_node_id"], *[item["node_id"] for item in pressure]}),
"output_link_ids": [],
})
isolations = {}
for pipe in pipes:
pipe_id = pipe["pipe_id"]
result = require_isolatable_pipe(topology, pipe_id, sources, closed)
pipe["boundary_valve_ids"] = result["must_close_valves"]
pipe["output_link_ids"] = sorted({pipe_id, *result["must_close_valves"],
*[item["link_id"] for item in pipe["flow_bindings"]]})
pipe.update(longitude=positions[pipe_id]["longitude"], latitude=positions[pipe_id]["latitude"])
isolations[pipe_id] = result
payload = original.model_dump(mode="json")
times = [f"2026-10-08T{hour:02d}:00:00+08:00" for hour in (2, 8, 12, 20)]
payload.update(pipes=pipes, duration_seconds=18000, event_times=times)
payload["selection_rule"]["expected_sample_count"] = 21
if not 1 <= args.case_start <= 84:
raise ValueError("case start must leave room for sixteen distinct Bxx cases")
payload["cases"] = [{"case_id": f"B{args.case_start + p * len(times) + t:02d}", "pipe_id": pipe["pipe_id"], "occurred_at": at}
for p, pipe in enumerate(pipes) for t, at in enumerate(times)]
design = BurstSimulationDesign.model_validate(payload)
validate_tool_inputs(design)
with readonly(biz) as connection:
validate_database(connection, design, template=False)
path = folder / "design.json"
save_json(path, design.model_dump(mode="json"))
save_json(folder / "selection.json", {"source_effect": "read-only", "screening_sha256": digest(args.screening),
"additional_screening_sha256": [digest(path) for path in args.additional_screening],
"weak_signal_cases_included": args.allow_weak,
"assessment_note": "a missed pressure rule does not change physical burst ground truth",
"model_sha256": screening["model_sha256"], "design_sha256": digest(path),
"isolations": isolations,
"coordinates": "WGS84 burst endpoint, not pipe midpoint"})
valve_ids = sorted({valve for pipe in pipes for valve in pipe["boundary_valve_ids"]})
with readonly(biz) as connection:
valve_geometries = {row["id"]: json.loads(row["geometry"]) for row in connection.execute(
"SELECT l.id,ST_AsGeoJSON(ST_Transform(ST_LineInterpolatePoint(ST_MakeLine(a.geom,b.geom),0.5),4326)) geometry "
"FROM network.links l JOIN gis.node_geometries a ON a.node_id=l.start_node_id "
"JOIN gis.node_geometries b ON b.node_id=l.end_node_id WHERE l.id=ANY(%s)",
(valve_ids,),
)}
features = []
for index, pipe in enumerate(pipes, 1):
properties = {"region": f"R{index}", "pipe_id": pipe["pipe_id"], "diameter_mm": pipe["diameter_mm"]}
features.append({"type": "Feature", "properties": {**properties, "kind": "burst"},
"geometry": {"type": "Point", "coordinates": [pipe["longitude"], pipe["latitude"]]}})
features.append({"type": "Feature", "properties": {**properties, "kind": "pipe"},
"geometry": pipe_geometries[pipe["pipe_id"]]})
for valve in pipe["boundary_valve_ids"]:
features.append({"type": "Feature", "properties": {**properties, "kind": "valve", "valve_id": valve},
"geometry": valve_geometries[valve]})
save_json(folder / "locations.geojson", {"type": "FeatureCollection", "features": features})
print(f"regional design ready: {len(pipes)} regions x 3 areas x {len(times)} times = {len(pipes) * 3 * len(times)} physical cases; {path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())