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
+226
View File
@@ -0,0 +1,226 @@
"""File-isolated hydraulic calibration; database connections are read-only."""
from __future__ import annotations
import ctypes
import hashlib
import json
import math
import os
import re
import subprocess
from datetime import datetime, timedelta
from pathlib import Path
from uuid import NAMESPACE_URL, uuid5
from zoneinfo import ZoneInfo
from scripts.benchmark.simulation_design import PipeCalibration, SelectionRule
def digest(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def save_json(path: Path, value: dict) -> None:
temporary = path.with_suffix(".pending")
with temporary.open("x", encoding="utf-8") as output:
os.chmod(temporary, 0o600)
json.dump(value, output, ensure_ascii=False, indent=2, allow_nan=False, default=str)
output.write("\n")
temporary.replace(path)
def select_calibrated_area(
pipe: PipeCalibration, passed_levels: list[str], case_results: list[dict], retain_weak: bool,
) -> dict:
selected = passed_levels[-1] if passed_levels else None
soft_blockers = {"insufficient_consecutive_pressure_and_corroboration", "event_rule_did_not_trigger"}
physically_valid = bool(case_results) and all(
not set(case["blockers"]) - soft_blockers for case in case_results
)
status = "calibrated_candidate" if selected else (
"weak_signal_candidate" if retain_weak and physically_valid else "unsuitable"
)
return {"status": status, "severity": selected,
"area_cm2": pipe.candidate_orifice_areas_cm2[selected] if selected else None}
def configure_inp(
source: str,
start: datetime,
duration: int,
*,
emitter: tuple[str, float] | None = None,
pda_options: dict[str, str] | None = None,
) -> str:
"""Apply the burst service's options to a file, including the zero-leak control."""
if start.tzinfo is None:
raise ValueError("simulation start must include a timezone")
start = start.astimezone(ZoneInfo("Asia/Shanghai"))
updates = {
"TIMES": {"DURATION": f"{duration // 3600}:{duration % 3600 // 60:02d}:00",
"PATTERN START": start.strftime("%H:%M:%S"),
"REPORT START": "0:00:00", "STATISTIC": "NONE"},
"OPTIONS": {**(pda_options or {}), "DEMAND MODEL": "PDA", "REQUIRED PRESSURE": "10.0000"},
"REPORT": {"NODES": "NONE", "LINKS": "NONE"},
}
section = None
output = []
pending = {}
for line in source.splitlines():
match = re.fullmatch(r"\s*\[([^]]+)\]\s*", line)
if match:
output.extend(f"{key} {value}" for key, value in pending.items())
section = match[1].upper()
pending = dict(updates.get(section, {}))
output.append(line)
if section == "EMITTERS" and emitter:
node, area = emitter
coefficient = 0.65 * area / 10000 * math.sqrt(19.6) * 1000
output.append(f"{node} {coefficient:.12g}")
continue
tokens = line.split(";", 1)[0].upper().split()
if any(tokens[:len(key.split())] == key.split() for key in updates.get(section, {})):
continue
output.append(line)
output.extend(f"{key} {value}" for key, value in pending.items())
if "[EMITTERS]" not in source.upper() and emitter:
raise ValueError("exported model has no emitter section")
return "\n".join(output) + "\n"
def run_file(inp: Path, node_ids: list[str], link_ids: list[str], *, timeout: int) -> dict:
from app.infra.epanet.epanet import Output
executable = Path(__file__).resolve().parents[2] / "app/infra/epanet/linux/runepanet"
env = os.environ.copy()
env["LD_LIBRARY_PATH"] = f"{executable.parent}:{env.get('LD_LIBRARY_PATH', '')}"
report_path, binary = inp.with_suffix(".rpt"), inp.with_suffix(".bin")
result = subprocess.run(
[str(executable), str(inp), str(report_path), str(binary)],
env=env, capture_output=True, text=True, timeout=timeout, check=False,
)
report = report_path.read_text(encoding="utf-8", errors="replace") if report_path.exists() else ""
warnings = [line.strip() for line in report.splitlines()
if re.search(r"^\s*(?:warning\b|error\s+\d+\b)|system\s+(?:is\s+)?unbalanced|cannot solve hydraulic",
line, re.IGNORECASE)]
summary = {"exit_code": result.returncode, "warnings": warnings,
"inp_sha256": digest(inp), "report_sha256": digest(report_path) if report_path.exists() else None}
if result.returncode or not binary.exists():
return {**summary, "status": "failed"}
reader = Output(str(binary))
names = reader.element_name()
summary.update(status="completed", units=reader.units(), times=reader.times(), nodes={}, links={})
for kind, selected, attributes, function in (
("nodes", node_ids, ["demand", "head", "pressure", "quality"], reader._lib.ENR_getNodeResult),
("links", link_ids, ["flow", "velocity", "headloss", "quality", "status", "setting", "reaction", "friction"],
reader._lib.ENR_getLinkResult),
):
indices = {name: index + 1 for index, name in enumerate(names[kind])}
for name in selected:
index = indices.get(name)
if index is None:
raise ValueError("requested hydraulic output is missing")
series = []
for period in range(summary["times"]["num_periods"]):
values = ctypes.POINTER(ctypes.c_float)()
length = ctypes.c_int()
reader._check(function(reader._handle, period, index, ctypes.byref(values), ctypes.byref(length)))
try:
if length.value != len(attributes):
raise ValueError("unexpected hydraulic result attributes")
row = {key: float(values[i]) for i, key in enumerate(attributes)}
if not all(math.isfinite(value) for value in row.values()):
raise ValueError("non-finite hydraulic result")
series.append(row)
finally:
reader._lib.ENR_free(ctypes.byref(values))
summary[kind][name] = series
return summary
def pressure_preview(
pipe: PipeCalibration, start: datetime, duration: int, step: int,
history: dict, current: dict, rule: SelectionRule,
) -> dict:
"""Exercise the actual replay cleaner/detector without database mutations."""
from app.domain.schemas.scada_replay import ScadaReplayCreate
from app.services.scada_replay_processing import detect_frame, prepare_cleaning
ids = [binding.device_id for binding in pipe.pressure_bindings]
config = ScadaReplayCreate(
request_id=uuid5(NAMESPACE_URL, f"calibration:{pipe.pipe_id}:{start.isoformat()}"),
start_time=start, end_time=start + timedelta(seconds=duration + step), device_ids=ids,
rules=[{"rule_id": "calibration-pressure", "device_ids": ids,
"kind": "relative_change", "direction": "decrease", "unit": "m",
"threshold": rule.primary_pressure_drop_percent,
"window_samples": 4, "minimum_samples": 4,
"consecutive_samples": rule.consecutive_samples, "minimum_devices": 1}],
)
document = {"replay_id": str(config.request_id), "devices": [
{"device_id": key, "transmission_frequency": "0:15:00"} for key in ids],
"histories": {binding.device_id: [row["pressure"] for row in history[binding.node_id]]
for binding in pipe.pressure_bindings}}
raised = []
for index in range(duration // step + 1):
at = start + timedelta(seconds=index * step)
rows = [{"device_id": binding.device_id, "time": at,
"monitored_value": current[binding.node_id][index]["pressure"],
"source_hash": hashlib.sha256(f"{binding.device_id}:{at}".encode()).hexdigest()}
for binding in pipe.pressure_bindings]
document, changes = detect_frame(document, prepare_cleaning(rows, config), config, at)
raised.extend(change for change in changes
if change["change"] == "raised" and change.get("category") != "data_quality")
return {"raised": bool(raised), "first_signal_at": raised[0]["occurred_at"] if raised else None,
"scope": "pure replay rule preview; not monitoring worker or Agent acceptance"}
def assess(
pipe: PipeCalibration, normal: dict, burst: dict, rule: SelectionRule,
*, duration: int, step: int,
) -> dict:
expected_times = {"report_start": 0, "report_step": step,
"sim_duration": duration, "num_periods": duration // step + 1}
blockers = []
for label, result in (("normal", normal), ("burst", burst)):
if result.get("status") != "completed" or result.get("times") != expected_times:
blockers.append(f"{label}_incomplete_or_failed")
if result.get("warnings"):
blockers.append(f"{label}_solver_warning")
if result.get("units") != {"flow": "LPS", "pressure": "MTR", "quality": "NONE"}:
blockers.append(f"{label}_unexpected_units")
if blockers:
return {"passed": False, "blockers": blockers}
pressures, flows = {}, {}
for binding in pipe.pressure_bindings:
before = [row["pressure"] for row in normal["nodes"][binding.node_id]]
after = [row["pressure"] for row in burst["nodes"][binding.node_id]]
if min(before) <= rule.relative_change_epsilon:
blockers.append(f"nonpositive_normal_pressure:{binding.device_id}")
if min(after) < 0:
blockers.append(f"negative_monitor_pressure:{binding.device_id}")
pressures[binding.device_id] = [100 * (a - b) / max(abs(a), rule.relative_change_epsilon)
for a, b in zip(before, after)]
for binding in pipe.flow_bindings:
flows[binding.device_id] = [100 * abs(a["flow"] - b["flow"]) /
max(abs(a["flow"]), rule.relative_change_epsilon)
for a, b in zip(normal["links"][binding.link_id], burst["links"][binding.link_id])]
witnesses = []
count = duration // step + 1
for primary, drops in pressures.items():
for start in range(count - rule.consecutive_samples + 1):
window = range(start, start + rule.consecutive_samples)
if not all(drops[i] >= rule.primary_pressure_drop_percent for i in window):
continue
second = [key for key, values in pressures.items() if key != primary and
all(values[i] >= rule.corroborating_pressure_drop_percent for i in window)]
flow = [key for key, values in flows.items() if
all(values[i] >= rule.flow_change_percent for i in window) and
all(abs(normal["links"][next(b.link_id for b in pipe.flow_bindings if b.device_id == key)][i]["flow"])
> rule.relative_change_epsilon for i in window)]
if second or flow:
witnesses.append({"primary_device": primary, "second_pressure_devices": second,
"flow_devices": flow, "start_offset_seconds": start * step})
if not witnesses:
blockers.append("insufficient_consecutive_pressure_and_corroboration")
return {"passed": not blockers, "blockers": blockers, "witnesses": witnesses,
"pressure_drop_percent": pressures, "flow_change_percent": flows}