feat(scada): add calibrated monitoring and incident recovery
This commit is contained in:
@@ -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}
|
||||
Reference in New Issue
Block a user