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
@@ -0,0 +1,97 @@
from datetime import datetime, timezone
from types import SimpleNamespace
import pytest
from scripts.benchmark.calibration import assess, configure_inp, pressure_preview, select_calibrated_area
START = datetime(2026, 10, 8, 12, tzinfo=timezone.utc)
PIPE = SimpleNamespace(
pipe_id="test-pipe",
pressure_bindings=[SimpleNamespace(device_id="P1", node_id="N1"),
SimpleNamespace(device_id="P2", node_id="N2")],
flow_bindings=[SimpleNamespace(device_id="F1", link_id="L1")],
)
RULE = SimpleNamespace(
primary_pressure_drop_percent=30, corroborating_pressure_drop_percent=10,
flow_change_percent=20, consecutive_samples=2, relative_change_epsilon=1e-6,
)
def result(p1=(30, 30, 30, 30, 30), p2=(30, 30, 30, 30, 30), flow=(10, 10, 10, 10, 10)):
return {"status": "completed", "warnings": [],
"units": {"flow": "LPS", "pressure": "MTR", "quality": "NONE"},
"times": {"report_start": 0, "report_step": 900, "sim_duration": 3600, "num_periods": 5},
"nodes": {"N1": [{"pressure": v} for v in p1], "N2": [{"pressure": v} for v in p2]},
"links": {"L1": [{"flow": v} for v in flow]}}
def check(burst, normal=None):
return assess(PIPE, normal or result(), burst, RULE, duration=3600, step=900)
def test_file_configuration_preserves_source_and_matches_burst_service():
source = "[OPTIONS]\nDEMAND MODEL DDA\nREQUIRED PRESSURE 20\n[TIMES]\nDURATION 0\nPATTERN START 0:00\n[REPORT]\nNODES ALL\nLINKS ALL\n[EMITTERS]\n[END]\n"
configured = configure_inp(source, START, 18000, emitter=("N1", 100), pda_options={"HEADERROR": "0.0001"})
assert "DEMAND MODEL DDA" in source
assert "DEMAND MODEL DDA" not in configured
assert "DEMAND MODEL PDA" in configured
assert "REQUIRED PRESSURE 10.0000" in configured
assert "HEADERROR 0.0001" in configured
assert "DURATION 5:00:00" in configured
assert "PATTERN START 20:00:00" in configured
coefficient = float(configured.split("[EMITTERS]\nN1 ")[1].splitlines()[0])
assert coefficient == pytest.approx(28.7767267075)
assert "NODES NONE" in configured and "LINKS NONE" in configured
def test_requires_two_consecutive_primary_samples():
assert not check(result(p1=(20, 30, 20, 30, 20), p2=(25,) * 5))["passed"]
assert check(result(p1=(20, 20, 30, 30, 30), p2=(25,) * 5))["passed"]
def test_corroboration_must_be_concurrent_and_a_different_pressure_device():
assert not check(result(p1=(20, 20, 30, 30, 30)))["passed"]
assert not check(result(p1=(20, 20, 30, 30, 30), p2=(30, 30, 25, 25, 30)))["passed"]
assert check(result(p1=(20,) * 5, flow=(12,) * 5))["passed"]
def test_pressure_rise_and_zero_flow_baseline_do_not_supply_false_evidence():
assert not check(result(p1=(40,) * 5, p2=(25,) * 5, flow=(100,) * 5))["passed"]
assert not check(result(p1=(20,) * 5, flow=(10,) * 5), normal=result(flow=(0,) * 5))["passed"]
def test_negative_pressure_or_bad_normal_pressure_invalidates_candidate():
assert "negative_monitor_pressure:P2" in check(result(p1=(20,) * 5, p2=(-1,) * 5))["blockers"]
assert "nonpositive_normal_pressure:P1" in check(result(p1=(0,) * 5), normal=result(p1=(0,) * 5))["blockers"]
@pytest.mark.parametrize("fault", ["warning", "missing_period", "unit"])
def test_solver_warning_incomplete_time_grid_and_wrong_unit_fail(fault):
burst = result(p1=(20,) * 5, p2=(25,) * 5)
if fault == "warning":
burst["warnings"] = ["WARNING: System unbalanced"]
elif fault == "missing_period":
burst["times"]["num_periods"] = 4
else:
burst["units"]["flow"] = "CMH"
assert not check(burst)["passed"]
def test_actual_replay_rule_uses_warmup_and_reports_first_signal_time():
history = {"N1": [{"pressure": 30}] * 4, "N2": [{"pressure": 30}] * 4}
normal = pressure_preview(PIPE, START, 3600, 900, history, result()["nodes"], RULE)
event = pressure_preview(PIPE, START, 3600, 900, history, result(p1=(20,) * 5)["nodes"], RULE)
assert not normal["raised"]
assert event["raised"]
assert event["first_signal_at"] == "2026-10-08T12:15:00+00:00"
def test_valid_burst_with_a_weak_signal_is_retained_separately_from_invalid_physics():
pipe = SimpleNamespace(candidate_orifice_areas_cm2={"light": 1, "medium": 2, "severe": 4})
weak = [{"blockers": ["event_rule_did_not_trigger", "insufficient_consecutive_pressure_and_corroboration"]}]
assert select_calibrated_area(pipe, [], weak, True)["status"] == "weak_signal_candidate"
assert select_calibrated_area(pipe, [], weak, False)["status"] == "unsuitable"
assert select_calibrated_area(pipe, [], [{"blockers": ["burst_solver_warning"]}], True)["status"] == "unsuitable"
assert select_calibrated_area(pipe, ["severe", "medium"], weak, True)["area_cm2"] == 2