146 lines
5.6 KiB
Python
146 lines
5.6 KiB
Python
"""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",
|
|
}
|