"""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", }