Files
next-tjwater-server/scripts/benchmark/contracts.py
T

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