feat(scada): add calibrated monitoring and incident recovery
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
"""Versioned burst cases and deterministic preparation checks."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from collections import defaultdict, deque
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
class StrictModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
|
||||
|
||||
|
||||
class BurstCase(StrictModel):
|
||||
case_id: str = Field(pattern=r"^B[0-9]{2}$")
|
||||
pipe_id: str = Field(pattern=r"^[0-9]+$")
|
||||
occurred_at: datetime
|
||||
reference_analysis: str = Field(min_length=1, max_length=200)
|
||||
observation_provenance: Literal["declared_historical_replay", "requires_generation"]
|
||||
provenance_note: str = Field(min_length=1)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def aware_time(self):
|
||||
if self.occurred_at.tzinfo is None:
|
||||
raise ValueError("case time must include a timezone")
|
||||
return self
|
||||
|
||||
|
||||
class TriggerRule(StrictModel):
|
||||
pressure_drop_percent: float = Field(gt=0, le=100)
|
||||
baseline_samples: int = Field(ge=3, le=96)
|
||||
consecutive_samples: int = Field(ge=1, le=20)
|
||||
minimum_devices: int = Field(ge=1, le=500)
|
||||
|
||||
|
||||
class BurstSuite(StrictModel):
|
||||
version: Literal["burst-benchmark@1"]
|
||||
suite_id: Literal["tjwater-v2-burst"]
|
||||
project_code: Literal["tjwater_v2"]
|
||||
project_id: UUID
|
||||
sampling_interval_minutes: int = Field(ge=1, le=60)
|
||||
event_minutes: int = Field(ge=15, le=1440)
|
||||
detector_days: Literal[15]
|
||||
trigger_rule: TriggerRule
|
||||
score_weights: dict[str, int]
|
||||
cases: list[BurstCase] = Field(min_length=1, max_length=100)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def valid_suite(self):
|
||||
if 1440 % self.sampling_interval_minutes or self.event_minutes % self.sampling_interval_minutes:
|
||||
raise ValueError("sample interval must divide a day and the event window")
|
||||
if len({case.case_id for case in self.cases}) != len(self.cases):
|
||||
raise ValueError("case ids must be unique")
|
||||
if set(self.score_weights) != {"outcome", "tools", "plan", "efficiency"}:
|
||||
raise ValueError("all four score dimensions are required")
|
||||
if any(value <= 0 for value in self.score_weights.values()) or sum(self.score_weights.values()) != 100:
|
||||
raise ValueError("positive score weights must sum to 100")
|
||||
for case in self.cases:
|
||||
if case.occurred_at.timestamp() % (60 * self.sampling_interval_minutes):
|
||||
raise ValueError("event times must align with the sampling grid")
|
||||
return self
|
||||
|
||||
def select(self, case_ids: list[str]) -> list[BurstCase]:
|
||||
if not case_ids:
|
||||
return self.cases
|
||||
unknown = set(case_ids) - {case.case_id for case in self.cases}
|
||||
if unknown:
|
||||
raise ValueError("unknown case ids: " + ", ".join(sorted(unknown)))
|
||||
return [case for case in self.cases if case.case_id in case_ids]
|
||||
|
||||
|
||||
def load_suite(path: Path) -> BurstSuite:
|
||||
return BurstSuite.model_validate_json(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def canonical_json(value) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"),
|
||||
allow_nan=False, default=str)
|
||||
|
||||
|
||||
def fingerprint(value) -> str:
|
||||
return hashlib.sha256(canonical_json(value).encode()).hexdigest()
|
||||
|
||||
|
||||
def complete_devices(rows: list[dict], start: datetime, end: datetime, interval: int) -> list[str]:
|
||||
"""Counts alone are insufficient: require finite values on the exact grid."""
|
||||
step = timedelta(minutes=interval)
|
||||
expected = {start + step * index for index in range(int((end - start) / step) + 1)}
|
||||
values: dict[str, dict[datetime, float]] = defaultdict(dict)
|
||||
invalid: set[str] = set()
|
||||
for row in rows:
|
||||
device = row["device_id"]
|
||||
at = row["time"]
|
||||
if not start <= at <= end:
|
||||
continue
|
||||
value = row["monitored_value"]
|
||||
if at not in expected or at in values[device] or value is None or not math.isfinite(float(value)):
|
||||
invalid.add(device)
|
||||
else:
|
||||
values[device][at] = float(value)
|
||||
return sorted(device for device, series in values.items()
|
||||
if device not in invalid and set(series) == expected)
|
||||
|
||||
|
||||
def isolation_reference(links: list[dict], source_nodes: list[str], pipe_id: str) -> dict:
|
||||
"""Independent graph check, separate from the production isolation algorithm."""
|
||||
graph: dict[str, list[str]] = defaultdict(list)
|
||||
target = None
|
||||
valves = []
|
||||
for link in links:
|
||||
if link["id"] == pipe_id:
|
||||
target = (link["start_node_id"], link["end_node_id"])
|
||||
if link["kind"].lower() == "valve":
|
||||
valves.append(link["id"])
|
||||
continue
|
||||
if str(link["status"]).lower() == "closed":
|
||||
continue
|
||||
left, right = link["start_node_id"], link["end_node_id"]
|
||||
graph[left].append(right)
|
||||
graph[right].append(left)
|
||||
if target is None:
|
||||
return {"status": "target_missing"}
|
||||
if not source_nodes:
|
||||
return {"status": "sources_missing"}
|
||||
reached = set(source_nodes)
|
||||
pending = deque(source_nodes)
|
||||
while pending:
|
||||
for node in graph[pending.popleft()]:
|
||||
if node not in reached:
|
||||
reached.add(node)
|
||||
pending.append(node)
|
||||
connected = any(node in reached for node in target)
|
||||
return {
|
||||
"status": "not_isolatable" if connected else "requires_boundary_verification",
|
||||
"model_valve_count": len(valves),
|
||||
"source_count": len(source_nodes),
|
||||
"source_reachable_after_closing_all_model_valves": connected,
|
||||
"scope": "undirected model topology; not hydraulic or field acceptance",
|
||||
}
|
||||
Reference in New Issue
Block a user