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
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""Compare replay-rule thresholds against frozen hydraulic outputs without rerunning hydraulics."""
from __future__ import annotations
import argparse
import json
import os
import sys
from datetime import datetime
from pathlib import Path
from types import SimpleNamespace
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--calibration-dir", required=True, type=Path)
parser.add_argument("--thresholds", nargs="+", type=float, default=[3, 5, 10, 20, 30])
args = parser.parse_args()
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
os.umask(0o077)
from scripts.benchmark.calibration import digest, pressure_preview, save_json
from scripts.benchmark.pressure_thresholds import learn_pressure_thresholds
from scripts.benchmark.simulation_design import BurstSimulationDesign
folder = args.calibration_dir.resolve()
source = folder / "calibration.json"
report = json.loads(source.read_text())
if report.get("status") != "completed" or any(not 0 < value <= 100 for value in args.thresholds):
raise ValueError("a completed calibration and thresholds in (0,100] are required")
design = BurstSimulationDesign.model_validate(report["design"])
pipes = {pipe.pipe_id: pipe for pipe in design.pipes}
cases = {case.case_id: case for case in design.cases}
index_by_time = {at: index for index, at in enumerate(design.event_times)}
output = {"version": "burst-threshold-sensitivity@1", "calibration_sha256": digest(source),
"scope": "pure cleaner/replay-rule sensitivity, not Agent or worker acceptance",
"thresholds_percent": args.thresholds, "rows": [], "summary": [],
"adaptive_fit": learn_pressure_thresholds([], ["calibration-history"], design.event_times[0]),
"adaptive_note": "this package has four warmup samples; fitting needs 1440 pre-event normal samples per device",
"absolute_drop_gate_applied": False}
cache = {}
controls = {}
for entry in report["cases"]:
case = cases[entry["case_id"]]
pipe = pipes[case.pipe_id]
index = index_by_time[case.occurred_at]
if index not in cache:
cache[index] = (json.loads((folder / f"normal-{index}.json").read_text()),
json.loads((folder / f"warmup-{index}.json").read_text()))
normal, warmup = cache[index]
burst = json.loads((folder / f"{case.case_id}-{entry['severity']}.json").read_text())
for threshold in args.thresholds:
rule = SimpleNamespace(primary_pressure_drop_percent=threshold,
consecutive_samples=design.selection_rule.consecutive_samples)
key = (pipe.pipe_id, index, threshold)
if key not in controls:
controls[key] = pressure_preview(pipe, case.occurred_at, report["duration_seconds"], 900,
warmup["nodes"], normal["nodes"], rule)
event = pressure_preview(pipe, case.occurred_at, report["duration_seconds"], 900,
warmup["nodes"], burst["nodes"], rule)
at = event["first_signal_at"]
output["rows"].append({"case_id": case.case_id, "pipe_id": pipe.pipe_id,
"severity": entry["severity"], "threshold_percent": threshold,
"event_signal": event["raised"], "first_signal_at": at,
"delay_minutes": (datetime.fromisoformat(at) - case.occurred_at).total_seconds() / 60 if at else None,
"normal_false_positive": controls[key]["raised"]})
for threshold in args.thresholds:
rows = [row for row in output["rows"] if row["threshold_percent"] == threshold]
normal = [value for key, value in controls.items() if key[2] == threshold]
output["summary"].append({"threshold_percent": threshold, "event_cases": len(rows),
"event_signals": sum(row["event_signal"] for row in rows),
"unique_normal_controls": len(normal),
"normal_false_positives": sum(row["raised"] for row in normal)})
path = folder / "threshold-sensitivity.json"
save_json(path, output)
for summary in output["summary"]:
print(summary)
print(f"threshold sensitivity complete: {path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+1
View File
@@ -0,0 +1 @@
"""Offline benchmark preparation; never imported by the application runtime."""
+226
View File
@@ -0,0 +1,226 @@
"""File-isolated hydraulic calibration; database connections are read-only."""
from __future__ import annotations
import ctypes
import hashlib
import json
import math
import os
import re
import subprocess
from datetime import datetime, timedelta
from pathlib import Path
from uuid import NAMESPACE_URL, uuid5
from zoneinfo import ZoneInfo
from scripts.benchmark.simulation_design import PipeCalibration, SelectionRule
def digest(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def save_json(path: Path, value: dict) -> None:
temporary = path.with_suffix(".pending")
with temporary.open("x", encoding="utf-8") as output:
os.chmod(temporary, 0o600)
json.dump(value, output, ensure_ascii=False, indent=2, allow_nan=False, default=str)
output.write("\n")
temporary.replace(path)
def select_calibrated_area(
pipe: PipeCalibration, passed_levels: list[str], case_results: list[dict], retain_weak: bool,
) -> dict:
selected = passed_levels[-1] if passed_levels else None
soft_blockers = {"insufficient_consecutive_pressure_and_corroboration", "event_rule_did_not_trigger"}
physically_valid = bool(case_results) and all(
not set(case["blockers"]) - soft_blockers for case in case_results
)
status = "calibrated_candidate" if selected else (
"weak_signal_candidate" if retain_weak and physically_valid else "unsuitable"
)
return {"status": status, "severity": selected,
"area_cm2": pipe.candidate_orifice_areas_cm2[selected] if selected else None}
def configure_inp(
source: str,
start: datetime,
duration: int,
*,
emitter: tuple[str, float] | None = None,
pda_options: dict[str, str] | None = None,
) -> str:
"""Apply the burst service's options to a file, including the zero-leak control."""
if start.tzinfo is None:
raise ValueError("simulation start must include a timezone")
start = start.astimezone(ZoneInfo("Asia/Shanghai"))
updates = {
"TIMES": {"DURATION": f"{duration // 3600}:{duration % 3600 // 60:02d}:00",
"PATTERN START": start.strftime("%H:%M:%S"),
"REPORT START": "0:00:00", "STATISTIC": "NONE"},
"OPTIONS": {**(pda_options or {}), "DEMAND MODEL": "PDA", "REQUIRED PRESSURE": "10.0000"},
"REPORT": {"NODES": "NONE", "LINKS": "NONE"},
}
section = None
output = []
pending = {}
for line in source.splitlines():
match = re.fullmatch(r"\s*\[([^]]+)\]\s*", line)
if match:
output.extend(f"{key} {value}" for key, value in pending.items())
section = match[1].upper()
pending = dict(updates.get(section, {}))
output.append(line)
if section == "EMITTERS" and emitter:
node, area = emitter
coefficient = 0.65 * area / 10000 * math.sqrt(19.6) * 1000
output.append(f"{node} {coefficient:.12g}")
continue
tokens = line.split(";", 1)[0].upper().split()
if any(tokens[:len(key.split())] == key.split() for key in updates.get(section, {})):
continue
output.append(line)
output.extend(f"{key} {value}" for key, value in pending.items())
if "[EMITTERS]" not in source.upper() and emitter:
raise ValueError("exported model has no emitter section")
return "\n".join(output) + "\n"
def run_file(inp: Path, node_ids: list[str], link_ids: list[str], *, timeout: int) -> dict:
from app.infra.epanet.epanet import Output
executable = Path(__file__).resolve().parents[2] / "app/infra/epanet/linux/runepanet"
env = os.environ.copy()
env["LD_LIBRARY_PATH"] = f"{executable.parent}:{env.get('LD_LIBRARY_PATH', '')}"
report_path, binary = inp.with_suffix(".rpt"), inp.with_suffix(".bin")
result = subprocess.run(
[str(executable), str(inp), str(report_path), str(binary)],
env=env, capture_output=True, text=True, timeout=timeout, check=False,
)
report = report_path.read_text(encoding="utf-8", errors="replace") if report_path.exists() else ""
warnings = [line.strip() for line in report.splitlines()
if re.search(r"^\s*(?:warning\b|error\s+\d+\b)|system\s+(?:is\s+)?unbalanced|cannot solve hydraulic",
line, re.IGNORECASE)]
summary = {"exit_code": result.returncode, "warnings": warnings,
"inp_sha256": digest(inp), "report_sha256": digest(report_path) if report_path.exists() else None}
if result.returncode or not binary.exists():
return {**summary, "status": "failed"}
reader = Output(str(binary))
names = reader.element_name()
summary.update(status="completed", units=reader.units(), times=reader.times(), nodes={}, links={})
for kind, selected, attributes, function in (
("nodes", node_ids, ["demand", "head", "pressure", "quality"], reader._lib.ENR_getNodeResult),
("links", link_ids, ["flow", "velocity", "headloss", "quality", "status", "setting", "reaction", "friction"],
reader._lib.ENR_getLinkResult),
):
indices = {name: index + 1 for index, name in enumerate(names[kind])}
for name in selected:
index = indices.get(name)
if index is None:
raise ValueError("requested hydraulic output is missing")
series = []
for period in range(summary["times"]["num_periods"]):
values = ctypes.POINTER(ctypes.c_float)()
length = ctypes.c_int()
reader._check(function(reader._handle, period, index, ctypes.byref(values), ctypes.byref(length)))
try:
if length.value != len(attributes):
raise ValueError("unexpected hydraulic result attributes")
row = {key: float(values[i]) for i, key in enumerate(attributes)}
if not all(math.isfinite(value) for value in row.values()):
raise ValueError("non-finite hydraulic result")
series.append(row)
finally:
reader._lib.ENR_free(ctypes.byref(values))
summary[kind][name] = series
return summary
def pressure_preview(
pipe: PipeCalibration, start: datetime, duration: int, step: int,
history: dict, current: dict, rule: SelectionRule,
) -> dict:
"""Exercise the actual replay cleaner/detector without database mutations."""
from app.domain.schemas.scada_replay import ScadaReplayCreate
from app.services.scada_replay_processing import detect_frame, prepare_cleaning
ids = [binding.device_id for binding in pipe.pressure_bindings]
config = ScadaReplayCreate(
request_id=uuid5(NAMESPACE_URL, f"calibration:{pipe.pipe_id}:{start.isoformat()}"),
start_time=start, end_time=start + timedelta(seconds=duration + step), device_ids=ids,
rules=[{"rule_id": "calibration-pressure", "device_ids": ids,
"kind": "relative_change", "direction": "decrease", "unit": "m",
"threshold": rule.primary_pressure_drop_percent,
"window_samples": 4, "minimum_samples": 4,
"consecutive_samples": rule.consecutive_samples, "minimum_devices": 1}],
)
document = {"replay_id": str(config.request_id), "devices": [
{"device_id": key, "transmission_frequency": "0:15:00"} for key in ids],
"histories": {binding.device_id: [row["pressure"] for row in history[binding.node_id]]
for binding in pipe.pressure_bindings}}
raised = []
for index in range(duration // step + 1):
at = start + timedelta(seconds=index * step)
rows = [{"device_id": binding.device_id, "time": at,
"monitored_value": current[binding.node_id][index]["pressure"],
"source_hash": hashlib.sha256(f"{binding.device_id}:{at}".encode()).hexdigest()}
for binding in pipe.pressure_bindings]
document, changes = detect_frame(document, prepare_cleaning(rows, config), config, at)
raised.extend(change for change in changes
if change["change"] == "raised" and change.get("category") != "data_quality")
return {"raised": bool(raised), "first_signal_at": raised[0]["occurred_at"] if raised else None,
"scope": "pure replay rule preview; not monitoring worker or Agent acceptance"}
def assess(
pipe: PipeCalibration, normal: dict, burst: dict, rule: SelectionRule,
*, duration: int, step: int,
) -> dict:
expected_times = {"report_start": 0, "report_step": step,
"sim_duration": duration, "num_periods": duration // step + 1}
blockers = []
for label, result in (("normal", normal), ("burst", burst)):
if result.get("status") != "completed" or result.get("times") != expected_times:
blockers.append(f"{label}_incomplete_or_failed")
if result.get("warnings"):
blockers.append(f"{label}_solver_warning")
if result.get("units") != {"flow": "LPS", "pressure": "MTR", "quality": "NONE"}:
blockers.append(f"{label}_unexpected_units")
if blockers:
return {"passed": False, "blockers": blockers}
pressures, flows = {}, {}
for binding in pipe.pressure_bindings:
before = [row["pressure"] for row in normal["nodes"][binding.node_id]]
after = [row["pressure"] for row in burst["nodes"][binding.node_id]]
if min(before) <= rule.relative_change_epsilon:
blockers.append(f"nonpositive_normal_pressure:{binding.device_id}")
if min(after) < 0:
blockers.append(f"negative_monitor_pressure:{binding.device_id}")
pressures[binding.device_id] = [100 * (a - b) / max(abs(a), rule.relative_change_epsilon)
for a, b in zip(before, after)]
for binding in pipe.flow_bindings:
flows[binding.device_id] = [100 * abs(a["flow"] - b["flow"]) /
max(abs(a["flow"]), rule.relative_change_epsilon)
for a, b in zip(normal["links"][binding.link_id], burst["links"][binding.link_id])]
witnesses = []
count = duration // step + 1
for primary, drops in pressures.items():
for start in range(count - rule.consecutive_samples + 1):
window = range(start, start + rule.consecutive_samples)
if not all(drops[i] >= rule.primary_pressure_drop_percent for i in window):
continue
second = [key for key, values in pressures.items() if key != primary and
all(values[i] >= rule.corroborating_pressure_drop_percent for i in window)]
flow = [key for key, values in flows.items() if
all(values[i] >= rule.flow_change_percent for i in window) and
all(abs(normal["links"][next(b.link_id for b in pipe.flow_bindings if b.device_id == key)][i]["flow"])
> rule.relative_change_epsilon for i in window)]
if second or flow:
witnesses.append({"primary_device": primary, "second_pressure_devices": second,
"flow_devices": flow, "start_offset_seconds": start * step})
if not witnesses:
blockers.append("insufficient_consecutive_pressure_and_corroboration")
return {"passed": not blockers, "blockers": blockers, "witnesses": witnesses,
"pressure_drop_percent": pressures, "flow_change_percent": flows}
+145
View File
@@ -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",
}
+576
View File
@@ -0,0 +1,576 @@
"""Isolated database preparation and deterministic burst-dataset scheduling."""
from __future__ import annotations
import hashlib
import json
import math
from datetime import datetime, timedelta, timezone
from pathlib import Path
from uuid import UUID
from zoneinfo import ZoneInfo
from psycopg import connect, sql
from psycopg.conninfo import make_conninfo
from psycopg.rows import dict_row
def canonical_hash(value) -> str:
return hashlib.sha256(json.dumps(value, sort_keys=True, default=str, separators=(",", ":")).encode()).hexdigest()
def deterministic_standard_normal(*, seed: str, at: datetime, device_id: str, metric: str) -> float:
"""Return order-independent deterministic Gaussian noise for one observation."""
material = f"{seed}\0{at.isoformat()}\0{device_id}\0{metric}".encode()
digest = hashlib.sha256(material).digest()
first = (int.from_bytes(digest[:8], "big") + 1) / (2**64 + 1)
second = (int.from_bytes(digest[8:16], "big") + 1) / (2**64 + 1)
return math.sqrt(-2.0 * math.log(first)) * math.cos(2.0 * math.pi * second)
def noisy_scada_observation(
*,
raw_value: float,
device_type: str,
at: datetime,
device_id: str,
seed: str,
pressure_sigma_percent: float = 0.2,
pressure_sigma_floor_m: float = 0.02,
flow_sigma_percent: float = 0.5,
clip_sigma: float = 3.0,
) -> float:
"""Convert model units and add reproducible zero-mean telemetry noise."""
if not math.isfinite(raw_value):
raise ValueError("model observation must be finite")
if min(pressure_sigma_percent, pressure_sigma_floor_m, flow_sigma_percent, clip_sigma) < 0:
raise ValueError("noise parameters must be non-negative")
z = max(-clip_sigma, min(clip_sigma, deterministic_standard_normal(
seed=seed, at=at, device_id=device_id, metric=device_type,
)))
if device_type == "pressure":
converted = raw_value
sigma = max(abs(converted) * pressure_sigma_percent / 100.0, pressure_sigma_floor_m)
return max(0.0, converted + z * sigma)
if device_type == "pipe_flow":
converted = raw_value * 3.6 # L/s -> m3/h
sigma = abs(converted) * flow_sigma_percent / 100.0
return converted + z * sigma
raise ValueError(f"unsupported SCADA device type: {device_type}")
def write_realtime_scada_day(
*,
business_dsn: str,
timescale_dsn: str,
start: datetime,
interval_seconds: int = 900,
duration_seconds: int = 86400,
noise_seed: str,
pressure_sigma_percent: float = 0.2,
pressure_sigma_floor_m: float = 0.02,
flow_sigma_percent: float = 0.5,
clip_sigma: float = 3.0,
) -> dict:
"""Map one online-model day into noisy SCADA observations."""
if start.tzinfo is None or duration_seconds <= 0 or interval_seconds <= 0:
raise ValueError("an aware start and positive duration/interval are required")
if duration_seconds % interval_seconds:
raise ValueError("duration must be divisible by the sampling interval")
expected_times = [
start + timedelta(seconds=offset)
for offset in range(0, duration_seconds, interval_seconds)
]
end = start + timedelta(seconds=duration_seconds)
with connect(business_dsn, row_factory=dict_row) as business:
devices = business.execute(
"""SELECT device_id,device_type,node_id,link_id,measurement_unit
FROM asset.scada_devices
WHERE device_type IN ('pressure','pipe_flow')
ORDER BY device_id"""
).fetchall()
if not devices:
raise ValueError("no pressure or pipe-flow SCADA bindings are available")
records: list[tuple] = []
raw_fingerprint_rows: list[tuple] = []
with connect(timescale_dsn, row_factory=dict_row) as timescale:
for device in devices:
if device["device_type"] == "pressure":
if not device["node_id"] or device["measurement_unit"] != "m":
raise ValueError("pressure devices must bind nodes and use metres")
rows = timescale.execute(
"""SELECT time,pressure AS value FROM realtime.node_results
WHERE node_id=%s AND time>=%s AND time<%s ORDER BY time""",
(device["node_id"], start, end),
).fetchall()
else:
if not device["link_id"] or device["measurement_unit"] != "m3/h":
raise ValueError("flow devices must bind links and use m3/h")
rows = timescale.execute(
"""SELECT time,flow AS value FROM realtime.link_results
WHERE link_id=%s AND time>=%s AND time<%s ORDER BY time""",
(device["link_id"], start, end),
).fetchall()
actual_times = [row["time"] for row in rows]
if actual_times != expected_times:
raise ValueError(f"incomplete realtime samples for device {device['device_id']}")
for row in rows:
raw_value = float(row["value"])
value = noisy_scada_observation(
raw_value=raw_value,
device_type=device["device_type"],
at=row["time"],
device_id=device["device_id"],
seed=noise_seed,
pressure_sigma_percent=pressure_sigma_percent,
pressure_sigma_floor_m=pressure_sigma_floor_m,
flow_sigma_percent=flow_sigma_percent,
clip_sigma=clip_sigma,
)
raw_fingerprint_rows.append((row["time"].isoformat(), device["device_id"], raw_value))
records.append((row["time"], device["device_id"], value, None))
collision = timescale.execute(
"SELECT count(*) count FROM scada.measurements WHERE time>=%s AND time<%s",
(start, end),
).fetchone()["count"]
if collision:
raise ValueError("SCADA target day is not empty")
with timescale.cursor().copy(
"COPY scada.measurements (time,device_id,monitored_value,cleaned_value) FROM STDIN"
) as copy:
for record in records:
copy.write_row(record)
timescale.commit()
persisted = timescale.execute(
"SELECT count(*) count FROM scada.measurements WHERE time>=%s AND time<%s",
(start, end),
).fetchone()["count"]
if persisted != len(records):
raise ValueError("SCADA normal-day read-back count does not match generated records")
return {
"start": start.isoformat(),
"end_exclusive": end.isoformat(),
"devices": len(devices),
"samples_per_device": len(expected_times),
"rows": persisted,
"units": {"pressure": "m", "pipe_flow": "m3/h", "model_flow": "L/s"},
"noise": {
"seed": noise_seed,
"distribution": "deterministic_clipped_gaussian",
"pressure_sigma_percent": pressure_sigma_percent,
"pressure_sigma_floor_m": pressure_sigma_floor_m,
"flow_sigma_percent": flow_sigma_percent,
"clip_sigma": clip_sigma,
},
"raw_sha256": canonical_hash(raw_fingerprint_rows),
"value_sha256": canonical_hash(
[(time.isoformat(), device_id, value) for time, device_id, value, _ in records]
),
}
def write_demo_scada_window(
*,
business_dsn: str,
timescale_dsn: str,
normal_source_start: datetime,
target_start: datetime,
day_count: int,
event_start: datetime,
event_run_id: str,
noise_seed: str,
interval_seconds: int = 900,
) -> dict:
"""Repeat one normal online day and replace one five-hour event window."""
if any(value.tzinfo is None for value in (normal_source_start, target_start, event_start)):
raise ValueError("normal, target, and event times must be timezone-aware")
if day_count < 15 or interval_seconds <= 0 or 86400 % interval_seconds:
raise ValueError("demo windows require at least 15 complete aligned days")
target_end = target_start + timedelta(days=day_count)
if not target_start <= event_start < target_end:
raise ValueError("event must fall inside the demo window")
run_uuid = UUID(str(event_run_id))
with connect(business_dsn, row_factory=dict_row) as business:
devices = business.execute(
"""SELECT device_id,device_type,node_id,link_id,measurement_unit
FROM asset.scada_devices
WHERE device_type IN ('pressure','pipe_flow') ORDER BY device_id"""
).fetchall()
pressure_devices = [row for row in devices if row["device_type"] == "pressure"]
flow_devices = [row for row in devices if row["device_type"] == "pipe_flow"]
if any(not row["node_id"] or row["measurement_unit"] != "m" for row in pressure_devices):
raise ValueError("pressure devices must bind nodes and use metres")
if any(not row["link_id"] or row["measurement_unit"] != "m3/h" for row in flow_devices):
raise ValueError("flow devices must bind links and use m3/h")
normal_end = normal_source_start + timedelta(days=1)
expected_normal_times = [
normal_source_start + timedelta(seconds=offset)
for offset in range(0, 86400, interval_seconds)
]
with connect(timescale_dsn, row_factory=dict_row) as timescale:
normal_nodes = timescale.execute(
"""SELECT time,node_id,pressure AS value FROM realtime.node_results
WHERE time>=%s AND time<%s AND node_id=ANY(%s) ORDER BY time,node_id""",
(normal_source_start, normal_end, [row["node_id"] for row in pressure_devices]),
).fetchall()
normal_links = timescale.execute(
"""SELECT time,link_id,flow AS value FROM realtime.link_results
WHERE time>=%s AND time<%s AND link_id=ANY(%s) ORDER BY time,link_id""",
(normal_source_start, normal_end, [row["link_id"] for row in flow_devices]),
).fetchall()
event_nodes = timescale.execute(
"""SELECT time,node_id,pressure AS value FROM analysis.node_results
WHERE run_id=%s AND node_id=ANY(%s) ORDER BY time,node_id""",
(run_uuid, [row["node_id"] for row in pressure_devices]),
).fetchall()
event_links = timescale.execute(
"""SELECT time,link_id,flow AS value FROM analysis.link_results
WHERE run_id=%s AND link_id=ANY(%s) ORDER BY time,link_id""",
(run_uuid, [row["link_id"] for row in flow_devices]),
).fetchall()
normal_node_values = {(row["time"], row["node_id"]): float(row["value"]) for row in normal_nodes}
normal_link_values = {(row["time"], row["link_id"]): float(row["value"]) for row in normal_links}
for device in devices:
values = normal_node_values if device["device_type"] == "pressure" else normal_link_values
element = device["node_id"] if device["device_type"] == "pressure" else device["link_id"]
if any((at, element) not in values for at in expected_normal_times):
raise ValueError(f"normal source day is incomplete for device {device['device_id']}")
records: dict[tuple[datetime, str], tuple] = {}
for day_offset in range(day_count):
day_start = target_start + timedelta(days=day_offset)
for sample_offset, source_at in enumerate(expected_normal_times):
target_at = day_start + timedelta(seconds=sample_offset * interval_seconds)
for device in pressure_devices:
raw = normal_node_values[(source_at, device["node_id"])]
value = noisy_scada_observation(
raw_value=raw, device_type="pressure", at=target_at,
device_id=device["device_id"], seed=noise_seed,
)
records[(target_at, device["device_id"])] = (
target_at, device["device_id"], value, None,
)
for device in flow_devices:
raw = normal_link_values[(source_at, device["link_id"])]
value = noisy_scada_observation(
raw_value=raw, device_type="pipe_flow", at=target_at,
device_id=device["device_id"], seed=noise_seed,
)
records[(target_at, device["device_id"])] = (
target_at, device["device_id"], value, None,
)
if not event_nodes or not event_links:
raise ValueError("event analysis has no bound pressure or flow results")
source_event_start = min(row["time"] for row in event_nodes + event_links)
event_times = sorted({row["time"] for row in event_nodes + event_links})
if len(event_times) != 21 or any(
right - left != timedelta(seconds=interval_seconds)
for left, right in zip(event_times, event_times[1:])
):
raise ValueError("event analysis must contain 21 aligned fifteen-minute frames")
event_node_values = {(row["time"], row["node_id"]): float(row["value"]) for row in event_nodes}
event_link_values = {(row["time"], row["link_id"]): float(row["value"]) for row in event_links}
for source_at in event_times:
target_at = event_start + (source_at - source_event_start)
if target_at >= target_end:
raise ValueError("event results extend beyond the demo window")
for device in pressure_devices:
key = (source_at, device["node_id"])
if key not in event_node_values:
raise ValueError(f"event pressure is incomplete for device {device['device_id']}")
value = noisy_scada_observation(
raw_value=event_node_values[key], device_type="pressure", at=target_at,
device_id=device["device_id"], seed=noise_seed,
)
records[(target_at, device["device_id"])] = (
target_at, device["device_id"], value, None,
)
for device in flow_devices:
key = (source_at, device["link_id"])
if key not in event_link_values:
raise ValueError(f"event flow is incomplete for device {device['device_id']}")
value = noisy_scada_observation(
raw_value=event_link_values[key], device_type="pipe_flow", at=target_at,
device_id=device["device_id"], seed=noise_seed,
)
records[(target_at, device["device_id"])] = (
target_at, device["device_id"], value, None,
)
ordered = [records[key] for key in sorted(records)]
expected_count = day_count * len(expected_normal_times) * len(devices)
if len(ordered) != expected_count:
raise ValueError("demo SCADA window has an unexpected observation count")
timescale.execute(
"DELETE FROM scada.measurements WHERE time>=%s AND time<%s",
(target_start, target_end),
)
with timescale.cursor().copy(
"COPY scada.measurements (time,device_id,monitored_value,cleaned_value) FROM STDIN"
) as copy:
for record in ordered:
copy.write_row(record)
timescale.commit()
persisted = timescale.execute(
"""SELECT count(*) count,count(DISTINCT device_id) devices,
count(DISTINCT time) frames,count(cleaned_value) cleaned
FROM scada.measurements WHERE time>=%s AND time<%s""",
(target_start, target_end),
).fetchone()
if persisted != {
"count": expected_count,
"devices": len(devices),
"frames": day_count * len(expected_normal_times),
"cleaned": 0,
}:
raise ValueError("demo SCADA read-back verification failed")
return {
"start": target_start.isoformat(),
"end_exclusive": target_end.isoformat(),
"day_count": day_count,
"event_start": event_start.isoformat(),
"event_end": (event_start + timedelta(seconds=20 * interval_seconds)).isoformat(),
"event_run_id": str(run_uuid),
"devices": len(devices),
"frames": day_count * len(expected_normal_times),
"rows": expected_count,
"cleaned_rows": 0,
"noise_seed": noise_seed,
"value_sha256": canonical_hash(
[(time.isoformat(), device_id, value) for time, device_id, value, _ in ordered]
),
}
def create_empty_database(source_dsn: str, target: str, schema_template: str) -> None:
if not target.startswith("tjw_bench_") or target.endswith("_template"):
raise ValueError("benchmark targets must use the owned tjw_bench_ prefix")
with connect(make_conninfo(source_dsn, dbname="postgres"), autocommit=True, row_factory=dict_row) as connection:
if connection.execute("SELECT 1 FROM pg_database WHERE datname=%s", (target,)).fetchone():
raise ValueError("target database already exists; explicit state is required for resumption")
# Templates are intentionally connection-disabled; clone without altering that state.
connection.execute(sql.SQL("CREATE DATABASE {} TEMPLATE {}").format(sql.Identifier(target), sql.Identifier(schema_template)))
def copy_project_inputs(source_dsn: str, target_dsn: str) -> dict:
from scripts.check_burst_simulation_design import readonly
from app.native.wndb.core.model_replace import _copy_order, _copy_table, _model_tables, _table_columns
summaries = {}
with readonly(source_dsn) as source, connect(target_dsn, row_factory=dict_row) as target:
source.execute("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY")
tables = _model_tables(source)
ordered = _copy_order(source, tables)
for schema, table in reversed(ordered):
target.execute(sql.SQL("DELETE FROM {}").format(sql.Identifier(schema, table)))
for schema, table in ordered + [("asset", "scada_devices")]:
columns = _table_columns(source, schema, table)
if _table_columns(target, schema, table) != columns:
raise ValueError(f"schema differs for {schema}.{table}")
_copy_table(source, target, schema, table, columns)
statement = sql.SQL("SELECT count(*) count FROM {}").format(sql.Identifier(schema, table))
before, after = source.execute(statement).fetchone()["count"], target.execute(statement).fetchone()["count"]
if before != after:
raise ValueError("isolated model copy count mismatch")
summaries[f"{schema}.{table}"] = before
target.commit()
return summaries
def apply_project_runtime_migrations(target_dsn: str, repository_root: Path) -> list[str]:
"""Install the additive MCP and calculation queue tables in an owned target."""
migrations = (
"025_mcp_query_contexts.sql",
"026_calculation_jobs.sql",
"027_calculation_job_lifecycle.sql",
"030_remove_calculation_queue_expiry.sql",
)
applied = []
with connect(target_dsn) as connection:
for filename in migrations:
path = repository_root / "resources" / "sql" / filename
connection.execute(path.read_text(encoding="utf-8"))
applied.append(filename)
connection.commit()
return applied
async def register_benchmark_project(
*,
source_project_id: str,
target: str,
target_biz_dsn: str,
target_iot_dsn: str,
):
"""Register owned databases and mirror source membership without role expansion."""
from uuid import UUID
from app.infra.db.metadb.database import SessionLocal
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
source_id = UUID(str(source_project_id))
async with SessionLocal() as session:
repository = MetadataRepository(session)
source = await repository.get_project_by_id(source_id)
if source is None:
raise ValueError("source project metadata is unavailable")
project = await repository.get_project_by_code(target)
marker = {
"benchmark_source_project_id": str(source_id),
"benchmark_dataset": "burst-v1",
}
if project is None:
project = await repository.create_project(
name=f"{source.name} 爆管 Benchmark",
code=target,
description="隔离的爆管 benchmark 模拟与 SCADA 回放数据集",
gs_workspace=target,
map_extent=source.map_extent,
status="active",
map_config=source.map_config or {},
settings={**(source.settings or {}), **marker},
)
elif any((project.settings or {}).get(key) != value for key, value in marker.items()):
raise ValueError("existing target project is not owned by this benchmark dataset")
await repository.upsert_project_database_config(
project.id,
db_role="biz_data",
db_type="postgresql",
dsn=target_biz_dsn,
pool_min_size=1,
pool_max_size=4,
)
await repository.upsert_project_database_config(
project.id,
db_role="iot_data",
db_type="timescaledb",
dsn=target_iot_dsn,
pool_min_size=1,
pool_max_size=4,
)
existing = {member.user_id for member in await repository.list_project_members(project.id)}
for member in await repository.list_project_members(source_id):
if member.user_id not in existing:
await repository.add_project_member(
project.id, member.user_id, member.project_role
)
return project
def write_analysis_scada(
*,
business_dsn: str,
timescale_dsn: str,
run_id: str,
) -> dict:
"""Materialize one completed hydraulic run as bound SCADA observations."""
run_uuid = UUID(str(run_id))
with connect(business_dsn, row_factory=dict_row) as business:
devices = business.execute(
"""SELECT device_id,device_type,node_id,link_id,measurement_unit
FROM asset.scada_devices
WHERE device_type IN ('pressure','pipe_flow')
ORDER BY device_id"""
).fetchall()
pressure = [row for row in devices if row["device_type"] == "pressure"]
flows = [row for row in devices if row["device_type"] == "pipe_flow"]
if any(not row["node_id"] or row["measurement_unit"] != "m" for row in pressure):
raise ValueError("pressure devices must bind nodes and use metres")
if any(not row["link_id"] or row["measurement_unit"] != "m3/h" for row in flows):
raise ValueError("flow devices must bind links and use m3/h")
pressure_by_node: dict[str, list[str]] = {}
flow_by_link: dict[str, list[str]] = {}
for row in pressure:
pressure_by_node.setdefault(row["node_id"], []).append(row["device_id"])
for row in flows:
flow_by_link.setdefault(row["link_id"], []).append(row["device_id"])
records: list[tuple] = []
with connect(timescale_dsn, row_factory=dict_row) as timescale:
node_rows = timescale.execute(
"""SELECT time,node_id,pressure FROM analysis.node_results
WHERE run_id=%s AND node_id=ANY(%s) ORDER BY time,node_id""",
(run_uuid, list(pressure_by_node)),
).fetchall()
link_rows = timescale.execute(
"""SELECT time,link_id,flow FROM analysis.link_results
WHERE run_id=%s AND link_id=ANY(%s) ORDER BY time,link_id""",
(run_uuid, list(flow_by_link)),
).fetchall()
for row in node_rows:
records.extend(
(row["time"], device_id, float(row["pressure"]), None)
for device_id in pressure_by_node[row["node_id"]]
)
for row in link_rows:
records.extend(
(row["time"], device_id, float(row["flow"]) * 3.6, None)
for device_id in flow_by_link[row["link_id"]]
)
if not records:
raise ValueError("analysis run has no bound SCADA results")
start = min(row[0] for row in records)
end = max(row[0] for row in records)
collision = timescale.execute(
"SELECT count(*) count FROM scada.measurements WHERE time BETWEEN %s AND %s",
(start, end),
).fetchone()["count"]
if collision:
raise ValueError("SCADA target interval is not empty")
with timescale.cursor().copy(
"COPY scada.measurements (time,device_id,monitored_value,cleaned_value) FROM STDIN"
) as copy:
for record in sorted(records):
copy.write_row(record)
timescale.commit()
persisted = timescale.execute(
"SELECT count(*) count FROM scada.measurements WHERE time BETWEEN %s AND %s",
(start, end),
).fetchone()["count"]
if persisted != len(records):
raise ValueError("SCADA read-back count does not match generated records")
samples_per_device: dict[str, int] = {}
for _, device_id, _, _ in records:
samples_per_device[device_id] = samples_per_device.get(device_id, 0) + 1
if len(set(samples_per_device.values())) != 1:
raise ValueError("bound SCADA devices have inconsistent sample counts")
return {
"run_id": str(run_uuid),
"start": start.isoformat(),
"end": end.isoformat(),
"devices": len(samples_per_device),
"samples_per_device": next(iter(samples_per_device.values())),
"rows": persisted,
"value_sha256": canonical_hash(
[(time.isoformat(), device_id, value) for time, device_id, value, _ in sorted(records)]
),
}
def make_schedule(design) -> list[dict]:
"""Seventeen-day slots keep fifteen clean training days plus a recovery buffer."""
local = ZoneInfo("Asia/Shanghai")
first_date = datetime(2024, 1, 16, tzinfo=local)
rows = []
pipes = {pipe.pipe_id: pipe for pipe in design.pipes}
for case in design.cases:
for level in ("light", "medium", "severe"):
source = case.occurred_at.astimezone(local)
at = first_date + timedelta(days=17 * len(rows), hours=source.hour, minutes=source.minute)
rows.append({"case_key": f"{case.case_id}-{level}", "case_id": case.case_id,
"severity": level, "pipe_id": case.pipe_id,
"area_cm2": pipes[case.pipe_id].candidate_orifice_areas_cm2[level],
"event_at": at.isoformat(), "history_start": (at - timedelta(days=15)).isoformat(),
"event_end": (at + timedelta(seconds=design.duration_seconds)).isoformat(),
"reserved_end": (at + timedelta(seconds=design.duration_seconds, days=1)).isoformat()})
previous = None
for row in rows:
start = datetime.fromisoformat(row["history_start"])
if previous is not None and start <= previous:
raise ValueError("dataset date reservations overlap")
previous = datetime.fromisoformat(row["reserved_end"])
if previous is not None and previous >= datetime.now(timezone.utc):
raise ValueError("benchmark slots must remain historical")
return rows
+99
View File
@@ -0,0 +1,99 @@
"""Learn pressure thresholds from pre-event normal history, never burst truth."""
from __future__ import annotations
import math
from datetime import datetime, timedelta
from statistics import median
def learn_pressure_thresholds(
rows: list[dict], device_ids: list[str], cutoff: datetime, *,
history_days: int = 15, interval_seconds: int = 900,
relative_floor_percent: float = 3.0, noise_multiplier: float = 3.0,
absolute_floor_m: float = 0.5,
) -> dict:
"""Estimate noise after removing each daily slot's median operating pattern."""
if (cutoff.tzinfo is None or history_days < 1 or interval_seconds <= 0
or 86400 % interval_seconds or not device_ids or len(set(device_ids)) != len(device_ids)):
raise ValueError("an aware cutoff and whole-day sampling grid are required")
if not all(math.isfinite(value) and value > 0 for value in (relative_floor_percent, noise_multiplier, absolute_floor_m)):
raise ValueError("threshold floors and noise multiplier must be positive")
count = history_days * 86400 // interval_seconds
start = cutoff - timedelta(days=history_days)
expected = {start + timedelta(seconds=interval_seconds * index) for index in range(count)}
data = {device: {} for device in device_ids}
for row in rows:
device = row["device_id"]
if device not in data:
continue
at = row["time"]
if at.tzinfo is None or at >= cutoff:
raise ValueError("adaptive fitting must not consume event or future observations")
if at < start:
continue
value = row["monitored_value"]
if at not in expected or at in data[device] or value is None or not math.isfinite(float(value)) or float(value) <= 0:
raise ValueError("adaptive fitting requires distinct positive finite pressure samples on the grid")
data[device][at] = float(value)
insufficient = {device: len(values) for device, values in data.items() if set(values) != expected}
if insufficient:
return {"status": "history_not_ready", "required_samples_per_device": count,
"available_samples": insufficient, "knowledge_cutoff": cutoff.isoformat(), "devices": {}}
fitted = {}
for device, values in data.items():
slots = {}
for at, value in values.items():
slot = int((at - start).total_seconds()) // interval_seconds % (86400 // interval_seconds)
slots.setdefault(slot, []).append(value)
references = {slot: median(group) for slot, group in slots.items()}
residuals = []
for at, value in values.items():
slot = int((at - start).total_seconds()) // interval_seconds % (86400 // interval_seconds)
residuals.append(100 * (value / references[slot] - 1))
center = median(residuals)
robust_sigma = 1.4826 * median(abs(value - center) for value in residuals)
fitted[device] = {"relative_drop_percent": max(relative_floor_percent, noise_multiplier * robust_sigma),
"absolute_drop_m": absolute_floor_m, "robust_noise_percent": robust_sigma,
"reference_pressure_m_by_slot": {str(slot): value for slot, value in references.items()}}
return {"status": "ready", "required_samples_per_device": count,
"knowledge_cutoff": cutoff.isoformat(), "devices": fitted,
"reference_grid_start": start.isoformat(), "interval_seconds": interval_seconds,
"method": "daily-slot median removal; 1.4826*MAD; thresholds frozen before event"}
def evaluate_pressure_thresholds(rows: list[dict], fitted: dict, *, consecutive_samples: int = 2) -> dict:
"""Experimental adaptive evaluator; this is not the production replay worker."""
if fitted.get("status") != "ready" or consecutive_samples < 1:
raise ValueError("a fitted rule and positive consecutive sample count are required")
start = datetime.fromisoformat(fitted["reference_grid_start"])
cutoff = datetime.fromisoformat(fitted["knowledge_cutoff"])
step = fitted["interval_seconds"]
states = {}
signals = []
for row in sorted(rows, key=lambda item: (item["time"], item["device_id"])):
device = row["device_id"]
if device not in fitted["devices"]:
continue
at, value = row["time"], row["monitored_value"]
if at.tzinfo is None or at < cutoff or (at - start).total_seconds() % step:
raise ValueError("evaluation samples must follow the fit cutoff on the sampling grid")
previous, hits, signaled = states.get(device, (None, 0, False))
if previous is not None and at <= previous:
raise ValueError("evaluation samples must be distinct")
if previous is None or at - previous != timedelta(seconds=step):
hits = 0
rule = fitted["devices"][device]
slot = int((at - start).total_seconds()) // step % (86400 // step)
expected = rule["reference_pressure_m_by_slot"][str(slot)]
valid = value is not None and math.isfinite(float(value)) and float(value) > 0
drop = expected - float(value) if valid else 0
hit = valid and drop >= rule["absolute_drop_m"] and 100 * drop / expected >= rule["relative_drop_percent"]
hits = hits + 1 if hit else 0
if hits >= consecutive_samples and not signaled:
signals.append({"device_id": device, "first_signal_at": at.isoformat(),
"reference_pressure_m": expected, "absolute_drop_m": drop,
"relative_drop_percent": 100 * drop / expected})
signaled = True
states[device] = (at, hits, signaled)
return {"raised": bool(signals), "signals": signals,
"scope": "experimental adaptive pressure rule only; not a worker or Agent result"}
+263
View File
@@ -0,0 +1,263 @@
"""Read project data through metadata routing, in repeatable read-only transactions."""
from __future__ import annotations
import gzip
import hashlib
import itertools
import json
import math
from contextlib import contextmanager
from datetime import datetime, timedelta, timezone
from pathlib import Path
from uuid import NAMESPACE_URL, uuid5
import psycopg
from psycopg.conninfo import conninfo_to_dict, make_conninfo
from psycopg.rows import dict_row
from scripts.benchmark.contracts import (
BurstCase, BurstSuite, canonical_json, complete_devices, fingerprint, isolation_reference,
)
async def resolve_sources(suite: BurstSuite) -> tuple[dict, dict[str, str]]:
# Import lazily: validating a suite must not load environment credentials.
from sqlalchemy import text
from app.infra.db.metadb.database import SessionLocal, engine
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
try:
async with SessionLocal() as session:
await session.execute(text("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY"))
repo = MetadataRepository(session)
project = await repo.get_project_by_code(suite.project_code)
if project is None or project.id != suite.project_id or project.status != "active":
raise ValueError("benchmark project identity or active status does not match")
routes = {}
for role in ("biz_data", "iot_data"):
route = await repo.get_project_db_routing(project.id, role)
if route is None:
raise ValueError("benchmark project database routing is incomplete")
routes[role] = route.dsn
identity = {"project_id": str(project.id), "project_code": project.code, "name": project.name}
await session.rollback()
return identity, routes
finally:
await engine.dispose()
@contextmanager
def readonly_source(dsn: str):
# Replace inherited options: never rely on application credentials being read-only.
safe_dsn = make_conninfo(dsn, options=(
"-c default_transaction_read_only=on -c statement_timeout=20000 "
"-c lock_timeout=2000 -c idle_in_transaction_session_timeout=600000"
), application_name="tjwater-benchmark-source-readonly", connect_timeout="5")
with psycopg.connect(safe_dsn, row_factory=dict_row) as connection:
connection.execute("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY")
if connection.execute("SHOW transaction_read_only").fetchone()["transaction_read_only"] != "on":
raise ValueError("source transaction is not read-only")
try:
yield connection
finally:
connection.rollback()
def source_identity(connection, dsn: str) -> dict:
values = conninfo_to_dict(dsn)
row = connection.execute(
"SELECT current_database() AS database, current_setting('server_version_num') AS server_version, "
"transaction_timestamp() AS observed_at"
).fetchone()
if row["database"] != values.get("dbname"):
raise ValueError("connected source differs from the metadata route")
return {
**row,
"read_only": True,
"isolation": "repeatable read",
# Equal database names on different servers are not equal routes.
"route_sha256": fingerprint({key: values.get(key) for key in ("host", "port", "dbname")}),
}
def read_model(connection, cases: list[BurstCase]) -> dict:
links = connection.execute(
"SELECT l.id, l.link_type::text AS kind, l.start_node_id, l.end_node_id, "
"COALESCE(s.status::text,p.status::text) AS status "
"FROM network.links l LEFT JOIN network.pipes p ON p.link_id=l.id "
"LEFT JOIN network.link_initial_settings s ON s.link_id=l.id ORDER BY l.id"
).fetchall()
for link in links:
for key in ("id", "start_node_id", "end_node_id"):
link[key] = str(link[key])
sources = [str(row["id"]) for row in connection.execute(
"SELECT id FROM network.nodes WHERE node_type::text IN ('reservoir','tank') ORDER BY id"
).fetchall()]
devices = connection.execute(
"SELECT device_id,device_type,node_id,link_id,api_query_id,measurement_unit, "
"transmission_frequency::text AS transmission_frequency FROM asset.scada_devices "
"WHERE device_type::text IN ('pressure','pipe_flow','flow') ORDER BY device_id"
).fetchall()
for device in devices:
for key in ("device_id", "node_id", "link_id"):
if device[key] is not None:
device[key] = str(device[key])
analyses = connection.execute(
"SELECT run_id,name,run_type,status,parameters FROM analysis.runs WHERE name=ANY(%s) "
"ORDER BY name,created_at,run_id", ([case.reference_analysis for case in cases],),
).fetchall()
return {"links": links, "source_nodes": sources, "devices": devices, "reference_analyses": analyses}
def readings(connection, device_ids: list[str], start: datetime, end: datetime) -> list[dict]:
return connection.execute(
"SELECT time,device_id,monitored_value FROM scada.measurements "
"WHERE device_id=ANY(%s) AND time>=%s AND time<=%s ORDER BY time,device_id",
(device_ids, start, end),
).fetchall()
def long_history_capability(connection, suite: BurstSuite, case: BurstCase, devices: list[dict]) -> dict:
interval = suite.sampling_interval_minutes
expected = suite.detector_days * 1440 // interval
start = case.occurred_at - timedelta(days=suite.detector_days) + timedelta(minutes=interval)
mapped = {device["device_id"]: device["node_id"] for device in devices
if device["device_type"] == "pressure" and device["node_id"] and device["api_query_id"]}
rows = connection.execute(
"SELECT device_id,count(*) AS count,count(DISTINCT time) AS unique_count, "
"count(*) FILTER (WHERE monitored_value IS NOT NULL "
"AND monitored_value::text NOT IN ('NaN','Infinity','-Infinity') "
"AND mod(extract(epoch FROM (time-%s::timestamptz)),%s)=0) AS valid_count "
"FROM scada.measurements WHERE device_id=ANY(%s) AND time>=%s AND time<=%s "
"GROUP BY device_id ORDER BY device_id",
(case.occurred_at, interval * 60, list(mapped), start, case.occurred_at),
).fetchall()
eligible = sorted({mapped[row["device_id"]] for row in rows
if row["count"] == row["unique_count"] == row["valid_count"] == expected})
return {"available": len(eligible) >= 5, "expected_samples_per_device": expected,
"complete_pressure_nodes": eligible, "tool_node_limit": 20,
"required_for": "optional run_burst_detection, not the short-window SCADA trigger"}
def trigger_preview(suite: BurstSuite, case: BurstCase, rows: list[dict], devices: list[dict], *, normal=False) -> dict:
"""Exercise the current pure detector; do not tick replay, clean TSDB or create tasks."""
from app.domain.schemas.scada_replay import ScadaReplayCreate
from app.services.scada_replay_processing import detect_frame, prepare_cleaning
start = case.occurred_at - (timedelta(days=1) if normal else timedelta())
pressure = [device for device in devices
if device["device_type"] == "pressure" and device["measurement_unit"] == "m"]
ids = {device["device_id"] for device in pressure}
rule = suite.trigger_rule
if len(ids) < rule.minimum_devices:
return {"raised": False, "reason": "insufficient pressure devices with unit m"}
config = ScadaReplayCreate(
request_id=uuid5(NAMESPACE_URL, f"benchmark:{case.case_id}:{normal}"),
start_time=start, end_time=start + timedelta(minutes=suite.event_minutes + suite.sampling_interval_minutes),
device_ids=sorted(ids), rules=[{
"rule_id": "benchmark-pressure-drop", "device_ids": sorted(ids),
"kind": "relative_change", "direction": "decrease", "unit": "m",
"threshold": rule.pressure_drop_percent, "window_samples": rule.baseline_samples,
"minimum_samples": rule.baseline_samples, "consecutive_samples": rule.consecutive_samples,
"minimum_devices": rule.minimum_devices,
}],
)
history: dict[str, list[float]] = {device: [] for device in ids}
for row in rows:
value = row["monitored_value"]
if row["device_id"] in ids and row["time"] < start and value is not None and math.isfinite(float(value)):
history[row["device_id"]].append(float(value))
document = {"replay_id": str(config.request_id), "devices": pressure,
"histories": {key: values[-rule.baseline_samples:] for key, values in history.items()}}
changes = []
for at, group in itertools.groupby(rows, key=lambda row: row["time"]):
if at < start:
continue
frame = [{**row, "source_hash": fingerprint([row["time"], row["device_id"], row["monitored_value"]])}
for row in group if row["device_id"] in ids]
document, events = detect_frame(document, prepare_cleaning(frame, config), config, at)
changes.extend(event for event in events if event.get("category") != "data_quality")
raised = [event for event in changes if event["change"] == "raised"]
return {"raised": bool(raised), "first_signal_at": raised[0]["occurred_at"] if raised else None,
"event_count": len(raised), "warm_pressure_devices": sum(len(v) >= rule.baseline_samples for v in history.values()),
"scope": "pure short-window rule preview; not worker/Agent end-to-end acceptance"}
def inspect_case(iot_connection, suite: BurstSuite, case: BurstCase, model: dict) -> dict:
devices = model["devices"]
ids = [device["device_id"] for device in devices]
warmup = timedelta(minutes=suite.trigger_rule.baseline_samples * suite.sampling_interval_minutes)
end = case.occurred_at + timedelta(minutes=suite.event_minutes)
event_rows = readings(iot_connection, ids, case.occurred_at - warmup, end)
normal_rows = readings(iot_connection, ids, case.occurred_at - timedelta(days=1) - warmup,
end - timedelta(days=1))
complete = set(complete_devices(event_rows, case.occurred_at, end, suite.sampling_interval_minutes))
complete &= set(complete_devices(normal_rows, case.occurred_at - timedelta(days=1),
end - timedelta(days=1), suite.sampling_interval_minutes))
pressure_ids = {device["device_id"] for device in devices if device["device_type"] == "pressure"}
matching = [run for run in model["reference_analyses"] if run["name"] == case.reference_analysis
and run["run_type"] == "burst_analysis" and run["status"] == "completed"
and case.pipe_id in [str(value) for value in run["parameters"].get("burst_ID", [])]]
topology = isolation_reference(model["links"], model["source_nodes"], case.pipe_id)
blockers = []
if case.observation_provenance != "declared_historical_replay":
blockers.append("event_observations_require_isolated_generation")
if not matching:
blockers.append("matching_completed_reference_analysis_missing")
if len(complete & pressure_ids) < 5:
blockers.append("fewer_than_five_complete_event_and_control_pressure_devices")
if topology["status"] in {"target_missing", "sources_missing"}:
blockers.append(topology["status"])
event_signal = trigger_preview(suite, case, event_rows, devices)
normal_signal = trigger_preview(suite, case, normal_rows, devices, normal=True)
scada_blockers = list(blockers)
if not event_signal["raised"]:
scada_blockers.append("short_window_event_trigger_not_raised")
if normal_signal["raised"]:
scada_blockers.append("normal_control_false_positive")
return {
"case_id": case.case_id, "pipe_id": case.pipe_id, "occurred_at": case.occurred_at,
"observation_provenance": case.observation_provenance,
"data_candidate_available": not blockers,
"blockers": blockers,
"complete_event_and_control_pressure_devices": sorted(complete & pressure_ids),
"window_sha256": fingerprint({"event": event_rows, "normal": normal_rows}),
"optional_long_history_detection": long_history_capability(iot_connection, suite, case, devices),
"event_signal_preview": event_signal, "normal_signal_preview": normal_signal,
"topology_reference": topology,
"variants": [
{"case_id": case.case_id + "-WO", "data_candidate_available": not blockers,
"blockers": list(blockers)},
{"case_id": case.case_id + "-SCADA", "data_candidate_available": not scada_blockers,
"blockers": scada_blockers},
],
"acceptance_pending": ["numerical_reference_review", "approved_equivalent_candidates",
"public_leakage_input_or_declared_location_limitation",
"isolated_runtime_and_cutoff_verification"],
}
def export_readings(connection, path: Path, suite: BurstSuite, cases: list[BurstCase], devices: list[dict]) -> dict:
"""Export only burst-related devices and the selected history/event window."""
start = min(case.occurred_at for case in cases) - timedelta(days=suite.detector_days)
end = max(case.occurred_at for case in cases) + timedelta(minutes=suite.event_minutes)
ids = [device["device_id"] for device in devices]
count = 0
digest = hashlib.sha256()
path.touch(mode=0o600, exist_ok=False)
with path.open("wb") as raw, gzip.GzipFile(fileobj=raw, mode="wb", mtime=0) as output:
with connection.cursor(name="benchmark_raw_export") as cursor:
cursor.execute(
"SELECT time,device_id,monitored_value FROM scada.measurements "
"WHERE device_id=ANY(%s) AND time>=%s AND time<=%s ORDER BY time,device_id", (ids, start, end),
)
for row in cursor:
count += 1
if count > 1_000_000:
raise ValueError("selected observation export exceeds one million records")
line = (canonical_json(row) + "\n").encode()
digest.update(line)
output.write(line)
return {"file": str(path.name), "records": count, "content_sha256": digest.hexdigest(),
"start": start, "end": end, "origin": "existing_database_readonly_export",
"visibility": "private; contains future samples and must not be exposed directly to Agent"}
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env python3
"""Calibrate a read-only model export using file-isolated native EPANET runs."""
from __future__ import annotations
import argparse
import asyncio
import os
import sys
import traceback
from datetime import datetime, timedelta, timezone
from pathlib import Path
from uuid import uuid4
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--design", type=Path, default=Path("resources/benchmarks/tjwater2-v2-burst-simulation.json"))
parser.add_argument("--output-root", type=Path, default=Path("data/benchmark-calibration"))
parser.add_argument("--duration-seconds", type=int, help="Override calibration duration, e.g. 18000 for five hours")
parser.add_argument("--timeout-seconds", type=int, default=240)
parser.add_argument("--all-severities", action="store_true", help="Retain weak-signal sizes even if the severe level fails")
args = parser.parse_args()
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
os.umask(0o077)
report = None
report_path = None
try:
from psycopg.conninfo import make_conninfo
from app.infra.db.project_routing import ActiveProjectRouting, activate_project_routing
from app.native.wndb.core.connection import close_all_project_pools, project_transaction
from app.native.wndb.inp.exporter import dump_inp
from scripts.check_burst_simulation_design import readonly, resolve_project, validate_database, validate_tool_inputs
from scripts.benchmark.simulation_design import load_simulation_design
from scripts.benchmark.calibration import assess, configure_inp, digest, pressure_preview, run_file, save_json, select_calibrated_area
design = load_simulation_design(args.design)
validate_tool_inputs(design)
duration = args.duration_seconds if args.duration_seconds is not None else design.duration_seconds
step = design.model_expectations.report_timestep_seconds
if not 3600 <= duration <= 18000 or duration % step or not 1 <= args.timeout_seconds <= 1800:
raise ValueError("duration must be whole report steps between one and five hours; timeout 1..1800")
biz, _ = asyncio.run(resolve_project(design))
with readonly(biz) as connection:
validate_database(connection, design, template=False)
folder = (args.output_root / (datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + "-" + uuid4().hex[:8])).resolve()
folder.mkdir(parents=True, mode=0o700)
snapshot = folder / "source.inp"
readonly_dsn = make_conninfo(biz, options="-c default_transaction_read_only=on -c statement_timeout=20000")
routing = ActiveProjectRouting(project_code=design.project_code, business_dsn=readonly_dsn)
try:
with activate_project_routing(routing), project_transaction(design.project_code) as connection:
connection.execute("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY")
# Stateless models permit event-aligned runs and independent warmup history.
counts = connection.execute("SELECT (SELECT count(*) FROM network.tanks) tanks, "
"(SELECT count(*) FROM network.pumps) pumps, "
"(SELECT count(*) FROM network.controls) controls, "
"(SELECT count(*) FROM network.rules) rules").fetchone()
if any(counts.values()):
raise ValueError("stateful model requires an explicit warm-start implementation")
pda_options = {row["key"]: row["value"] for row in connection.execute(
"SELECT key,value FROM network.simulation_settings WHERE engine_version='legacy' "
"AND key=ANY(%s)",
(["HEADERROR", "FLOWCHANGE", "MINIMUM PRESSURE", "PRESSURE EXPONENT"],),
)}
dump_inp(design.project_code, str(snapshot), "2")
finally:
close_all_project_pools()
source = snapshot.read_text(encoding="utf-8")
node_ids = sorted({node for pipe in design.pipes for node in pipe.output_node_ids})
link_ids = sorted({link for pipe in design.pipes for link in pipe.output_link_ids})
report = {"version": "burst-calibration@1", "status": "running", "created_at": datetime.now(timezone.utc),
"source_effect": "read-only model export; local file simulations only",
"model_sha256": digest(snapshot), "design_sha256": digest(args.design),
"design": design.model_dump(mode="json"), "duration_seconds": duration,
"pda_options": pda_options,
"assessment_scope": "strong-signal calibration, not Agent accuracy or absence of a physical burst",
"all_severities": args.all_severities,
"engine_sha256": digest(Path("app/infra/epanet/linux/runepanet")),
"cases": [], "selection": {}, "controls": {},
"limitations": ["No analysis scenario or SCADA rows have been persisted",
"No worker, MCP, Agent, or 15-day detection has been executed",
"The four-sample pressure replay rule is a calibration preview"]}
report_path = folder / "calibration.json"
save_json(report_path, report)
def simulate(label, start, seconds, emitter=None):
path = folder / f"{label}.inp"
path.write_text(configure_inp(source, start, seconds, emitter=emitter, pda_options=pda_options), encoding="utf-8")
result = run_file(path, node_ids, link_ids, timeout=args.timeout_seconds)
save_json(folder / f"{label}.json", result)
print(f"{label}: {result['status']}, solver warnings={len(result['warnings'])}", flush=True)
return result
controls = {}
histories = {}
for index, at in enumerate(design.event_times):
controls[at] = simulate(f"normal-{index}", at, duration)
warmup = simulate(f"warmup-{index}", at - timedelta(seconds=4 * step), 3 * step)
if warmup.get("status") != "completed" or warmup.get("warnings") or warmup.get("times") != {
"report_start": 0, "report_step": step, "sim_duration": 3 * step, "num_periods": 4
}:
raise ValueError("normal warmup did not produce four converged samples")
histories[at] = warmup["nodes"]
report["controls"][at.isoformat()] = {"normal_file": f"normal-{index}.json", "warmup_file": f"warmup-{index}.json"}
save_json(report_path, report)
for pipe in design.pipes:
cases = [case for case in design.cases if case.pipe_id == pipe.pipe_id]
passed_levels = []
for severity in design.calibration_order:
level_passed = True
for case in cases:
area = pipe.candidate_orifice_areas_cm2[severity]
burst = simulate(f"{case.case_id}-{severity}", case.occurred_at, duration, (pipe.emitter_node_id, area))
normal = controls[case.occurred_at]
result = assess(pipe, normal, burst, design.selection_rule, duration=duration, step=step)
if normal.get("status") == burst.get("status") == "completed" and not any(
reason.endswith("incomplete_or_failed") for reason in result["blockers"]
):
for key, current in (("normal_trigger", normal), ("event_trigger", burst)):
result[key] = pressure_preview(pipe, case.occurred_at, duration, step,
histories[case.occurred_at], current["nodes"], design.selection_rule)
if result["normal_trigger"]["raised"]:
result["blockers"].append("normal_control_false_positive")
if not result["event_trigger"]["raised"]:
result["blockers"].append("event_rule_did_not_trigger")
coefficient = 0.65 * area / 10000 * (19.6 ** 0.5) * 1000
# PDA demand can change with pressure: demand delta alone is not emitter flow.
result["emitter_flow_m3_s"] = [coefficient * max(row["pressure"], 0) ** 0.5 / 1000
for row in burst["nodes"][pipe.emitter_node_id]]
result.update(case_id=case.case_id, severity=severity, orifice_area_cm2=area,
passed=not result["blockers"])
report["cases"].append(result)
level_passed &= result["passed"]
save_json(report_path, report)
print(f"{case.case_id}-{severity}: physical and rule checks {'PASS' if result['passed'] else 'FAIL'}", flush=True)
if level_passed:
passed_levels.append(severity)
if severity == "severe" and not level_passed and not args.all_severities:
break
case_ids = {case.case_id for case in cases}
report["selection"][pipe.pipe_id] = select_calibrated_area(
pipe, passed_levels, [case for case in report["cases"] if case["case_id"] in case_ids], args.all_severities,
)
save_json(report_path, report)
report["status"] = "completed"
save_json(report_path, report)
print(f"calibration complete; report: {report_path}", flush=True)
return 0
except Exception as exc:
frames = traceback.extract_tb(exc.__traceback__)
location = frames[-1] if frames else None
suffix = f" at {location.filename}:{location.lineno}" if location else ""
if report is not None and report_path is not None:
report.update(status="failed", failure_type=type(exc).__name__)
save_json(report_path, report)
print(f"calibration failed ({type(exc).__name__}){suffix}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env python3
"""Generate persisted isolated burst scenarios and mapped SCADA observations."""
from __future__ import annotations
import argparse
import asyncio
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--design", type=Path, default=Path("resources/benchmarks/tjwater2-v2-burst-balanced.json"))
parser.add_argument("--target", default="tjw_bench_tjwater2_20261105")
parser.add_argument("--state-dir", type=Path, default=Path("data/benchmark-generation/tjwater2-20261105"))
parser.add_argument("--apply", action="store_true")
parser.add_argument("--prepare-only", action="store_true")
args = parser.parse_args()
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
os.umask(0o077)
from psycopg import connect
from psycopg.conninfo import make_conninfo
from psycopg.rows import dict_row
from app.core.config import settings
from scripts.benchmark.calibration import save_json, digest
from scripts.benchmark.generation import (
apply_project_runtime_migrations,
create_empty_database,
copy_project_inputs,
make_schedule,
register_benchmark_project,
)
from scripts.benchmark.simulation_design import load_simulation_design
from scripts.check_burst_simulation_design import resolve_project, readonly, validate_database
design = load_simulation_design(args.design)
schedule = make_schedule(design)
if not args.apply:
print(f"dry run: {len(schedule)} cases, target={args.target}, dates={schedule[0]['history_start']}..{schedule[-1]['reserved_end']}")
return 0
folder = args.state_dir.resolve()
folder.mkdir(parents=True, exist_ok=True, mode=0o700)
state_path = folder / "state.json"
import json
state = json.loads(state_path.read_text()) if state_path.exists() else {
"version": "burst-dataset-generation@1", "created_at": datetime.now(timezone.utc).isoformat(),
"target": args.target, "design_sha256": digest(args.design), "schedule": schedule,
"stages": {}, "cases": {}, "source_effect": "read-only", "status": "preparing"}
if state["target"] != args.target or state["design_sha256"] != digest(args.design):
raise ValueError("state belongs to a different target or design")
save_json(state_path, state)
biz, iot = asyncio.run(resolve_project(design))
with readonly(biz) as connection:
validate_database(connection, design, template=False)
target_biz, target_iot = make_conninfo(biz, dbname=args.target), make_conninfo(iot, dbname=args.target)
for role, source, template in (("biz", biz, settings.WNDB_SCHEMA_TEMPLATE_DB_NAME),
("iot", iot, settings.TIMESCALEDB_SCHEMA_TEMPLATE_DB_NAME)):
if not state["stages"].get(f"{role}_created"):
create_empty_database(source, args.target, template)
state["stages"][f"{role}_created"] = True
save_json(state_path, state)
print(f"created isolated {role} database: {args.target}", flush=True)
if not state["stages"].get("model_copied"):
state["model_counts"] = copy_project_inputs(biz, target_biz)
state["stages"]["model_copied"] = True
save_json(state_path, state)
print("copied model and device bindings from a read-only source snapshot", flush=True)
required_migrations = {
"025_mcp_query_contexts.sql",
"026_calculation_jobs.sql",
"027_calculation_job_lifecycle.sql",
"030_remove_calculation_queue_expiry.sql",
}
if not required_migrations.issubset(state.get("runtime_migrations", [])):
state["runtime_migrations"] = apply_project_runtime_migrations(
target_biz, Path(__file__).resolve().parents[1]
)
state["stages"]["runtime_migrations_applied"] = True
save_json(state_path, state)
print("installed isolated MCP and calculation queue storage", flush=True)
if not state["stages"].get("model_template_created"):
from app.native.wndb.core.project_templates import (
create_project_model_template,
ensure_replication_worker_capacity,
)
from app.native.wndb.core.projects import have_project
template = f"{args.target}_template"
if not have_project(template):
try:
ensure_replication_worker_capacity()
except RuntimeError as exc:
if "no reserved logical-replication worker capacity" not in str(exc):
raise
state["model_template_mode"] = "snapshot_per_run"
else:
create_project_model_template(args.target)
if have_project(template):
state["model_template"] = template
state["model_template_mode"] = "logical_replication"
state["stages"]["model_template_created"] = True
save_json(state_path, state)
print(f"configured hydraulic model isolation: {state['model_template_mode']}", flush=True)
if not state["stages"].get("metadata_registered"):
project = asyncio.run(register_benchmark_project(
source_project_id=str(design.project_id),
target=args.target,
target_biz_dsn=target_biz,
target_iot_dsn=target_iot,
))
state["project_id"] = str(project.id)
state["stages"]["metadata_registered"] = True
save_json(state_path, state)
print(f"registered isolated benchmark project: {project.id}", flush=True)
# Inspect isolated schema readiness before invoking backend persistence.
with connect(target_biz, row_factory=dict_row) as connection:
print("isolated BizDB analysis schema", connection.execute(
"SELECT column_name FROM information_schema.columns WHERE table_schema='analysis' AND table_name='runs' ORDER BY ordinal_position").fetchall(), flush=True)
state["status"] = "prepared"
save_json(state_path, state)
print(f"isolated preparation complete: {state_path}", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""Materialize selected burst demo months from one normal day and event runs."""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import sys
from datetime import datetime
from pathlib import Path
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--manifest", type=Path,
default=Path("resources/benchmarks/tjwater2-v2-burst-demo.json"),
)
parser.add_argument(
"--generation-state", type=Path,
default=Path("data/benchmark-generation/tjwater2-20261105/state.json"),
)
parser.add_argument(
"--state", type=Path,
default=Path("data/benchmark-generation/tjwater2-20261105/demo-scada-state.json"),
)
args = parser.parse_args()
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
os.umask(0o077)
from psycopg.conninfo import make_conninfo
from scripts.benchmark.calibration import digest, save_json
from scripts.benchmark.generation import write_demo_scada_window
from scripts.benchmark.simulation_design import load_simulation_design
from scripts.check_burst_simulation_design import resolve_project
manifest = json.loads(args.manifest.read_text(encoding="utf-8"))
generation = json.loads(args.generation_state.read_text(encoding="utf-8"))
if manifest["project_code"] != generation["target"] or generation.get("status") != "prepared":
raise ValueError("manifest and isolated generation state do not match")
design = load_simulation_design(Path("resources/benchmarks/tjwater2-v2-burst-balanced.json"))
source_biz, source_iot = asyncio.run(resolve_project(design))
business_dsn = make_conninfo(source_biz, dbname=generation["target"])
timescale_dsn = make_conninfo(source_iot, dbname=generation["target"])
state_path = args.state.resolve()
state_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
manifest_sha256 = digest(args.manifest)
state = json.loads(state_path.read_text(encoding="utf-8")) if state_path.exists() else {
"version": "burst-demo-scada-state@1",
"target": generation["target"],
"manifest_sha256": manifest_sha256,
"cases": {},
"status": "materializing",
}
if state["target"] != generation["target"] or state["manifest_sha256"] != manifest_sha256:
raise ValueError("demo state belongs to different inputs")
save_json(state_path, state)
normal_source_start = datetime.fromisoformat(manifest["normal_source_start"])
for case in manifest["cases"]:
source_case = generation["cases"].get(case["case_key"])
if not source_case or source_case.get("status") != "scada_materialized":
raise ValueError(f"event source is unavailable: {case['case_key']}")
summary = write_demo_scada_window(
business_dsn=business_dsn,
timescale_dsn=timescale_dsn,
normal_source_start=normal_source_start,
target_start=datetime.fromisoformat(case["target_start"]),
day_count=int(manifest["day_count"]),
event_start=datetime.fromisoformat(case["event_start"]),
event_run_id=source_case["analysis_run_id"],
noise_seed=manifest["noise_seed"],
)
state["cases"][case["demo_id"]] = {
"case_key": case["case_key"],
"entry_mode": case["entry_mode"],
**summary,
}
save_json(state_path, state)
print({"demo_id": case["demo_id"], "rows": summary["rows"]}, flush=True)
state["status"] = "completed"
save_json(state_path, state)
print(json.dumps({"status": state["status"], "cases": state["cases"]}, ensure_ascii=False), flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Create a private, read-only preflight report for the tjwater_v2 burst suite."""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import sys
import traceback
from datetime import datetime, timezone
from pathlib import Path
from uuid import uuid4
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--suite",
type=Path,
default=Path("resources/benchmarks/tjwater-v2-burst.json"),
help="versioned burst suite manifest",
)
parser.add_argument("--case", action="append", default=[], help="case ID to inspect; repeatable")
parser.add_argument(
"--output-root",
type=Path,
default=Path("data/benchmark/preflight"),
help="private local artifact directory (default: ignored data/benchmark/preflight)",
)
args = parser.parse_args()
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from scripts.benchmark.contracts import fingerprint, load_suite
from scripts.benchmark.source import inspect_case, read_model, resolve_sources, source_identity, readonly_source
try:
suite = load_suite(args.suite)
cases = suite.select(args.case)
identity, routes = asyncio.run(resolve_sources(suite))
with readonly_source(routes["biz_data"]) as biz_connection:
biz_source = source_identity(biz_connection, routes["biz_data"])
model = read_model(biz_connection, cases)
with readonly_source(routes["iot_data"]) as iot_connection:
iot_source = source_identity(iot_connection, routes["iot_data"])
case_results = [inspect_case(iot_connection, suite, case, model) for case in cases]
content_fingerprint = fingerprint({
"suite": suite.model_dump(mode="json"),
"source_routes": {
"biz_data": biz_source["route_sha256"],
"iot_data": iot_source["route_sha256"],
},
"model": model,
"cases": [{
"case_id": result["case_id"],
"window_sha256": result["window_sha256"],
"topology_reference": result["topology_reference"],
"data_candidate_available": result["data_candidate_available"],
"blockers": result["blockers"],
} for result in case_results],
})
report = {
"report_version": "burst-preflight@1",
"status": "preflight_complete",
"readiness": "preflight_only; isolated runtime and Agent execution are not verified",
"created_at": datetime.now(timezone.utc),
"project": identity,
"sources": {"biz_data": biz_source, "iot_data": iot_source},
"suite_id": suite.suite_id,
"suite_version": suite.version,
"content_fingerprint_sha256": content_fingerprint,
"source_effect": "read-only transactions; no source database writes requested",
"cases": case_results,
"limitations": [
"preflight does not execute Agent, work-order, SCADA replay worker, or MCP tools",
"SCADA event data requiring generation remains blocked until produced in an isolated dataset",
"this report is not a frozen evaluation database snapshot",
"numeric reference outputs and approved equivalent candidate groups still require review",
],
}
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
output_dir = args.output_root / f"{suite.suite_id}-{stamp}-{uuid4().hex[:8]}"
output_dir.mkdir(parents=True, mode=0o700)
os.chmod(output_dir, 0o700)
output_file = output_dir / "preflight.json"
output_file.write_text(
json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True, default=str) + "\n",
encoding="utf-8",
)
os.chmod(output_file, 0o600)
except Exception as exc:
# Driver exceptions can include connection details; never echo them into CI logs.
frames = traceback.extract_tb(exc.__traceback__)
location = frames[-1] if frames else None
suffix = f" at {location.filename}:{location.lineno}" if location else ""
print(f"benchmark preflight failed ({type(exc).__name__}){suffix}", file=sys.stderr)
return 1
work_order_candidates = sum(bool(case["variants"][0]["data_candidate_available"])
for case in case_results)
scada_candidates = sum(bool(case["variants"][1]["data_candidate_available"])
for case in case_results)
print(
f"preflight complete: {work_order_candidates}/{len(case_results)} WO and "
f"{scada_candidates}/{len(case_results)} SCADA data candidates; report: {output_file}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+169
View File
@@ -0,0 +1,169 @@
#!/usr/bin/env python3
"""Build a private multi-region, multi-area and multi-time design from screening evidence."""
from __future__ import annotations
import argparse
import asyncio
import json
import math
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
from uuid import uuid4
def require_isolatable_pipe(topology: list[str], pipe_id: str, sources: list[str], closed: list[str]) -> dict:
from app.algorithms.valve_isolation.topology_search import valve_isolation_analysis
result = valve_isolation_analysis(topology, pipe_id, accident_element_type="link",
water_source_nodes=sources, closed_links=closed)
if not result["isolatable"] or not result["source_check_performed"] or not result["must_close_valves"]:
raise ValueError("selected region cannot be isolated from water sources")
return result
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--screening", type=Path, required=True)
parser.add_argument("--additional-screening", type=Path, action="append", default=[])
parser.add_argument("--pipe", action="append", required=True, help="New pipe ID; the existing primary is retained")
parser.add_argument("--allow-weak", action="store_true", help="Include weak-signal physical bursts without treating them as no-burst controls")
parser.add_argument("--case-start", type=int, default=9)
parser.add_argument("--output-root", type=Path, default=Path("data/benchmark-regional"))
args = parser.parse_args()
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
os.umask(0o077)
from scripts.benchmark.calibration import save_json, digest
from scripts.benchmark.simulation_design import BurstSimulationDesign, load_simulation_design
from scripts.check_burst_simulation_design import readonly, resolve_project, validate_database, validate_tool_inputs
original = load_simulation_design(Path("resources/benchmarks/tjwater2-v2-burst-simulation.json"))
screening = json.loads(args.screening.read_text())
candidates = {row["pipe_id"]: row for row in screening["results"]}
for path in args.additional_screening:
extra = json.loads(path.read_text())
if extra["model_sha256"] != screening["model_sha256"]:
raise ValueError("screening model fingerprints do not match")
candidates.update({row["pipe_id"]: row for row in extra["results"]})
requested = list(dict.fromkeys(args.pipe))
if not 1 <= len(requested) <= 3 or any(pipe not in candidates for pipe in requested):
raise ValueError("select one to three screened pipes")
for pipe in requested:
if not candidates[pipe].get("possible_candidate") and not args.allow_weak:
raise ValueError("selected pipe has not passed single-frame screening")
if candidates[pipe].get("status") != "completed":
raise ValueError("selected pipe screening simulation did not complete")
folder = (args.output_root / (datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + "-" + uuid4().hex[:8])).resolve()
folder.mkdir(parents=True, mode=0o700)
biz, _ = asyncio.run(resolve_project(original))
with readonly(biz) as connection:
links = connection.execute(
"SELECT l.id,l.start_node_id,l.end_node_id,l.link_type,"
"COALESCE(s.status::text,p.status::text,'OPEN') status FROM network.links l "
"LEFT JOIN network.pipes p ON p.link_id=l.id "
"LEFT JOIN network.link_initial_settings s ON s.link_id=l.id"
).fetchall()
sources = [row["id"] for row in connection.execute(
"SELECT id FROM network.nodes WHERE node_type IN ('reservoir','tank')"
)]
devices = connection.execute(
"SELECT device_id,device_type,node_id,link_id,measurement_unit FROM asset.scada_devices "
"WHERE device_type IN ('pressure','pipe_flow') AND device_id<>'547063'"
).fetchall()
positions = {row["id"]: row for row in connection.execute(
"SELECT l.id,l.start_node_id,l.end_node_id,p.diameter,p.length,"
"ST_X(ST_Transform(g.geom,4326)) longitude,ST_Y(ST_Transform(g.geom,4326)) latitude "
"FROM network.links l JOIN network.pipes p ON p.link_id=l.id "
"JOIN gis.node_geometries g ON g.node_id=l.end_node_id WHERE l.id=ANY(%s)",
([original.pipes[0].pipe_id, *requested],),
)}
pipe_geometries = {row["id"]: json.loads(row["geometry"]) for row in connection.execute(
"SELECT id,ST_AsGeoJSON(ST_Transform(geom,4326)) geometry FROM gis.pipes WHERE id=ANY(%s)",
([original.pipes[0].pipe_id, *requested],),
)}
topology = [f"{row['id']}:{row['link_type']}:{row['start_node_id']}:{row['end_node_id']}" for row in links]
closed = [row["id"] for row in links if str(row["status"]).upper() == "CLOSED"]
by_device = {row["device_id"]: row for row in devices}
pipes = [original.pipes[0].model_dump(mode="json")]
for pipe_id in requested:
candidate = candidates[pipe_id]
position = positions[pipe_id]
pressure_ids = [key for key in candidate["pressure_drop_percent"]
if key in by_device and by_device[key]["device_type"] == "pressure"][:5]
# Keep the plant outlets plus the two most responsive other flow meters.
flow_ids = ["542017", "486033", "542121"]
for key, _ in sorted(candidate["flow_change_percent"].items(), key=lambda pair: pair[1], reverse=True):
if key in by_device and key not in flow_ids:
flow_ids.append(key)
if len(flow_ids) >= 5:
break
pressure = [{"device_id": key, "node_id": by_device[key]["node_id"]} for key in pressure_ids]
flow = [{"device_id": key, "link_id": by_device[key]["link_id"]} for key in flow_ids]
area = math.pi * (position["diameter"] / 10) ** 2 / 4
pipes.append({
"role": "secondary", "pipe_id": pipe_id, "diameter_mm": position["diameter"],
"length_m": position["length"], "start_node_id": position["start_node_id"],
"emitter_node_id": position["end_node_id"],
"longitude": position["longitude"], "latitude": position["latitude"],
"candidate_orifice_areas_cm2": {"light": round(area / 32, 6), "medium": round(area / 16, 6), "severe": round(area / 8, 6)},
"pressure_bindings": pressure, "flow_bindings": flow,
"boundary_valve_ids": [],
"output_node_ids": sorted({position["start_node_id"], position["end_node_id"], *[item["node_id"] for item in pressure]}),
"output_link_ids": [],
})
isolations = {}
for pipe in pipes:
pipe_id = pipe["pipe_id"]
result = require_isolatable_pipe(topology, pipe_id, sources, closed)
pipe["boundary_valve_ids"] = result["must_close_valves"]
pipe["output_link_ids"] = sorted({pipe_id, *result["must_close_valves"],
*[item["link_id"] for item in pipe["flow_bindings"]]})
pipe.update(longitude=positions[pipe_id]["longitude"], latitude=positions[pipe_id]["latitude"])
isolations[pipe_id] = result
payload = original.model_dump(mode="json")
times = [f"2026-10-08T{hour:02d}:00:00+08:00" for hour in (2, 8, 12, 20)]
payload.update(pipes=pipes, duration_seconds=18000, event_times=times)
payload["selection_rule"]["expected_sample_count"] = 21
if not 1 <= args.case_start <= 84:
raise ValueError("case start must leave room for sixteen distinct Bxx cases")
payload["cases"] = [{"case_id": f"B{args.case_start + p * len(times) + t:02d}", "pipe_id": pipe["pipe_id"], "occurred_at": at}
for p, pipe in enumerate(pipes) for t, at in enumerate(times)]
design = BurstSimulationDesign.model_validate(payload)
validate_tool_inputs(design)
with readonly(biz) as connection:
validate_database(connection, design, template=False)
path = folder / "design.json"
save_json(path, design.model_dump(mode="json"))
save_json(folder / "selection.json", {"source_effect": "read-only", "screening_sha256": digest(args.screening),
"additional_screening_sha256": [digest(path) for path in args.additional_screening],
"weak_signal_cases_included": args.allow_weak,
"assessment_note": "a missed pressure rule does not change physical burst ground truth",
"model_sha256": screening["model_sha256"], "design_sha256": digest(path),
"isolations": isolations,
"coordinates": "WGS84 burst endpoint, not pipe midpoint"})
valve_ids = sorted({valve for pipe in pipes for valve in pipe["boundary_valve_ids"]})
with readonly(biz) as connection:
valve_geometries = {row["id"]: json.loads(row["geometry"]) for row in connection.execute(
"SELECT l.id,ST_AsGeoJSON(ST_Transform(ST_LineInterpolatePoint(ST_MakeLine(a.geom,b.geom),0.5),4326)) geometry "
"FROM network.links l JOIN gis.node_geometries a ON a.node_id=l.start_node_id "
"JOIN gis.node_geometries b ON b.node_id=l.end_node_id WHERE l.id=ANY(%s)",
(valve_ids,),
)}
features = []
for index, pipe in enumerate(pipes, 1):
properties = {"region": f"R{index}", "pipe_id": pipe["pipe_id"], "diameter_mm": pipe["diameter_mm"]}
features.append({"type": "Feature", "properties": {**properties, "kind": "burst"},
"geometry": {"type": "Point", "coordinates": [pipe["longitude"], pipe["latitude"]]}})
features.append({"type": "Feature", "properties": {**properties, "kind": "pipe"},
"geometry": pipe_geometries[pipe["pipe_id"]]})
for valve in pipe["boundary_valve_ids"]:
features.append({"type": "Feature", "properties": {**properties, "kind": "valve", "valve_id": valve},
"geometry": valve_geometries[valve]})
save_json(folder / "locations.geojson", {"type": "FeatureCollection", "features": features})
print(f"regional design ready: {len(pipes)} regions x 3 areas x {len(times)} times = {len(pipes) * 3 * len(times)} physical cases; {path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+260
View File
@@ -0,0 +1,260 @@
#!/usr/bin/env python3
"""Submit, execute, and materialize isolated burst benchmark cases through MCP."""
from __future__ import annotations
import argparse
import asyncio
import json
import os
from pathlib import Path
import subprocess
import sys
from uuid import uuid4
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--design", type=Path, default=Path("resources/benchmarks/tjwater2-v2-burst-balanced.json"))
parser.add_argument("--state-dir", type=Path, default=Path("data/benchmark-generation/tjwater2-20261105"))
parser.add_argument("--agent-env", type=Path, default=Path("../next-tjwater-agent/.local.env"))
parser.add_argument("--limit", type=int, default=1, help="Maximum new cases to complete in this invocation")
parser.add_argument("--batches", type=int, default=1, help="Run isolated child processes for this many batches")
parser.add_argument("--local-executor-concurrency", type=int, default=1, choices=(1, 2))
args = parser.parse_args()
if args.limit < 1 or args.limit > args.local_executor_concurrency:
parser.error("--limit must be between 1 and local executor concurrency")
if args.batches < 1:
parser.error("--batches must be positive")
if args.batches > 1:
command = [
sys.executable,
str(Path(__file__).resolve()),
"--design", str(args.design),
"--state-dir", str(args.state_dir),
"--agent-env", str(args.agent_env),
"--limit", str(args.limit),
"--local-executor-concurrency", str(args.local_executor_concurrency),
"--batches", "1",
]
for _ in range(args.batches):
subprocess.run(command, check=True)
return 0
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
os.umask(0o077)
from dotenv import load_dotenv
from psycopg.conninfo import make_conninfo
from scripts.benchmark.calibration import save_json
from scripts.benchmark.generation import write_analysis_scada
from scripts.benchmark.simulation_design import load_simulation_design
from scripts.check_burst_simulation_design import resolve_project
load_dotenv(Path(__file__).resolve().parents[1] / ".env", override=False)
state_path = args.state_dir.resolve() / "state.json"
state = json.loads(state_path.read_text(encoding="utf-8"))
project_id = state.get("project_id")
if not project_id or state.get("status") != "prepared":
raise ValueError("run isolated preparation before MCP generation")
design = load_simulation_design(args.design)
pipes = {pipe.pipe_id: pipe for pipe in design.pipes}
source_biz, source_iot = asyncio.run(resolve_project(design))
business_dsn = make_conninfo(source_biz, dbname=state["target"])
timescale_dsn = make_conninfo(source_iot, dbname=state["target"])
pending = [
row for row in state["schedule"]
if state["cases"].get(row["case_key"], {}).get("status") != "scada_materialized"
][:args.limit]
for offset in range(0, len(pending), args.local_executor_concurrency):
batch = pending[offset:offset + args.local_executor_concurrency]
job_ids = submit_batch(
batch=batch,
pipes=pipes,
project_id=project_id,
agent_env=args.agent_env,
state=state,
state_path=state_path,
)
asyncio.run(execute_batch(project_id, job_ids, args.local_executor_concurrency))
for row in batch:
case = state["cases"][row["case_key"]]
run = find_analysis_run(business_dsn, case["scenario_name"])
summary = write_analysis_scada(
business_dsn=business_dsn,
timescale_dsn=timescale_dsn,
run_id=str(run["run_id"]),
)
case.update({
"analysis_run_id": str(run["run_id"]),
"status": "scada_materialized",
"scada": summary,
})
save_json(state_path, state)
print({"case_key": row["case_key"], "job_id": case["job_id"], "scada_rows": summary["rows"]}, flush=True)
return 0
def exchange_token(agent_env: Path) -> str:
import httpx
from dotenv import dotenv_values
from app.mcp.config import McpSettings
from scripts.run_operational_worker import CredentialProvider, WorkerSettings
values = dotenv_values(agent_env)
mcp = McpSettings()
async def exchange() -> str:
issuer = (values.get("TJWATER_MCP_DEV_HTTP_ISSUER_URI") or values["TJWATER_MCP_ISSUER_URI"]).rstrip("/")
async with httpx.AsyncClient(timeout=15) as client:
product = await CredentialProvider(WorkerSettings.from_environment()).headers(client)
response = await client.post(issuer + "/protocol/openid-connect/token", data={
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"client_id": values["TJWATER_MCP_CLIENT_ID"],
"client_secret": values["TJWATER_MCP_CLIENT_SECRET"],
"subject_token": product["Authorization"].removeprefix("Bearer "),
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
"audience": mcp.audience,
"resource": mcp.resource_uri,
"scope": "mcp.catalog.read mcp.query mcp.context.bind",
})
response.raise_for_status()
return response.json()["access_token"]
return asyncio.run(exchange())
def mcp_call(client, token: str, project_id: str, name: str, arguments: dict) -> dict:
from mcp import types
method = "tools/call"
call_id = str(uuid4())
meta = {
types.PROTOCOL_VERSION_META_KEY: "2026-07-28",
types.CLIENT_CAPABILITIES_META_KEY: {},
types.CLIENT_INFO_META_KEY: {"name": "benchmark-generator", "version": "1"},
"io.tjwater/callId": call_id,
}
response = client.post("/mcp", headers={
"Authorization": "Bearer " + token,
"X-Project-Id": project_id,
"MCP-Protocol-Version": "2026-07-28",
"MCP-Method": method,
"MCP-Name": name,
"Accept": "application/json, text/event-stream",
}, json={
"jsonrpc": "2.0",
"id": call_id,
"method": method,
"params": {"_meta": meta, "name": name, "arguments": arguments},
})
response.raise_for_status()
payload = response.json()
result = payload.get("result") or {}
if payload.get("error") or result.get("isError"):
raise RuntimeError("MCP tool call failed")
return result["structuredContent"]["data"]
def submit_batch(*, batch, pipes, project_id, agent_env, state, state_path) -> list[str]:
from fastapi.testclient import TestClient
from app.main import app
from scripts.benchmark.calibration import save_json
token = exchange_token(agent_env)
session_id = "benchmark-tjwater2-burst-v1"
jobs = []
with TestClient(app, base_url="http://127.0.0.1:8000") as client:
common = {"Authorization": "Bearer " + token, "X-Project-Id": project_id}
for row in batch:
case = state["cases"].setdefault(row["case_key"], {})
request_id = case.setdefault("request_id", str(uuid4()))
scenario_name = case.setdefault(
"scenario_name",
f"bench-{row['case_key']}-{row['event_at'][0:16].replace('-', '').replace(':', '').replace('T', '')}",
)
binding = client.post(
"/api/v1/mcp-query-contexts",
headers=common,
json={"native_session_id": session_id, "purpose": "ordinary_chat"},
)
binding.raise_for_status()
context_handle = binding.json()["context_handle"]
pipe = pipes[row["pipe_id"]]
parameters = {
"start_time": row["event_at"],
"duration": 18000,
"output_node_ids": pipe.output_node_ids,
"output_link_ids": pipe.output_link_ids,
"burst_pipe_ids": [row["pipe_id"]],
"orifice_areas_cm2": [row["area_cm2"]],
}
preview = mcp_call(client, token, project_id, "save_simulation_scenario", {
"context_handle": context_handle,
"timeout_seconds": 240,
"request_id": request_id,
"dry_run": True,
"name": scenario_name,
"scenario": "burst",
"parameters": parameters,
})
case["expected_digest"] = preview["expected_digest"]
save_json(state_path, state)
queued = mcp_call(client, token, project_id, "save_simulation_scenario", {
"context_handle": context_handle,
"timeout_seconds": 240,
"request_id": request_id,
"dry_run": False,
"expected_digest": preview["expected_digest"],
"name": scenario_name,
"scenario": "burst",
"parameters": parameters,
})
case.update({"job_id": queued["job_id"], "status": queued["status"]})
jobs.append(queued["job_id"])
save_json(state_path, state)
return jobs
async def execute_batch(project_id: str, expected_job_ids: list[str], concurrency: int) -> None:
from app.services.calculation_worker import CalculationWorker, WorkerSettings, queue_repository
worker = CalculationWorker(WorkerSettings(
max_concurrency=concurrency,
poll_seconds=1,
lease_seconds=30,
heartbeat_seconds=5,
))
rows = []
for expected in expected_job_ids:
async with queue_repository(project_id) as repository:
await repository.recover()
row = await repository.claim(worker.owner, worker.settings.lease_seconds)
if row is None or str(row["job_id"]) != expected:
raise RuntimeError("isolated benchmark queue order changed unexpectedly")
rows.append(row)
await asyncio.gather(*(worker.run_job(row) for row in rows))
for row in rows:
async with queue_repository(project_id) as repository:
stored = await repository.get(row["job_id"])
if stored["status"] != "succeeded":
raise RuntimeError(f"benchmark calculation failed with {stored['status']}")
def find_analysis_run(business_dsn: str, scenario_name: str) -> dict:
from psycopg import connect
from psycopg.rows import dict_row
with connect(business_dsn, row_factory=dict_row) as connection:
rows = connection.execute(
"""SELECT run_id,name,status,started_at FROM analysis.runs
WHERE name=%s ORDER BY created_at DESC LIMIT 2""",
(scenario_name,),
).fetchall()
if len(rows) != 1 or rows[0]["status"] != "completed":
raise ValueError("persisted scenario run is missing, duplicated, or incomplete")
return dict(rows[0])
if __name__ == "__main__":
raise SystemExit(main())
+155
View File
@@ -0,0 +1,155 @@
#!/usr/bin/env python3
"""Run one isolated online-model day through MCP and materialize noisy SCADA."""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import sys
from datetime import datetime
from pathlib import Path
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--start", default="2026-05-01T00:00:00+08:00")
parser.add_argument(
"--generation-state",
type=Path,
default=Path("data/benchmark-generation/tjwater2-20261105/state.json"),
)
parser.add_argument(
"--state",
type=Path,
default=Path("data/benchmark-generation/tjwater2-20261105/normal-day-state.json"),
)
parser.add_argument(
"--design",
type=Path,
default=Path("resources/benchmarks/tjwater2-v2-burst-balanced.json"),
)
parser.add_argument("--agent-env", type=Path, default=Path("../next-tjwater-agent/.local.env"))
parser.add_argument("--noise-seed", default="tjwater2-normal-scada-v1")
args = parser.parse_args()
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
os.umask(0o077)
from dotenv import load_dotenv
from fastapi.testclient import TestClient
from psycopg import connect
from psycopg.conninfo import make_conninfo
from psycopg.rows import dict_row
from app.main import app
from scripts.benchmark.calibration import save_json
from scripts.benchmark.generation import write_realtime_scada_day
from scripts.benchmark.simulation_design import load_simulation_design
from scripts.check_burst_simulation_design import resolve_project
from scripts.run_burst_benchmark_mcp import exchange_token, execute_batch, mcp_call
load_dotenv(Path(__file__).resolve().parents[1] / ".env", override=False)
start = datetime.fromisoformat(args.start)
if start.tzinfo is None or start.minute % 15 or start.second or start.microsecond:
raise ValueError("start must be timezone-aware and aligned to 15 minutes")
generation = json.loads(args.generation_state.read_text(encoding="utf-8"))
if generation.get("status") != "prepared" or not generation.get("project_id"):
raise ValueError("isolated benchmark project is not prepared")
state_path = args.state.resolve()
state_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
state = json.loads(state_path.read_text(encoding="utf-8")) if state_path.exists() else {
"version": "normal-scada-day@1",
"target": generation["target"],
"project_id": generation["project_id"],
"start": start.isoformat(),
"duration_seconds": 86400,
"noise_seed": args.noise_seed,
"status": "preparing",
}
expected = {
"target": generation["target"],
"project_id": generation["project_id"],
"start": start.isoformat(),
"noise_seed": args.noise_seed,
}
if any(state.get(key) != value for key, value in expected.items()):
raise ValueError("normal-day state belongs to different inputs")
save_json(state_path, state)
design = load_simulation_design(args.design)
source_biz, source_iot = asyncio.run(resolve_project(design))
business_dsn = make_conninfo(source_biz, dbname=state["target"])
timescale_dsn = make_conninfo(source_iot, dbname=state["target"])
with connect(business_dsn, row_factory=dict_row) as connection:
node = connection.execute(
"""SELECT node_id FROM asset.scada_devices
WHERE device_type='pressure' AND node_id IS NOT NULL ORDER BY device_id LIMIT 1"""
).fetchone()["node_id"]
link = connection.execute(
"""SELECT link_id FROM asset.scada_devices
WHERE device_type='pipe_flow' AND link_id IS NOT NULL ORDER BY device_id LIMIT 1"""
).fetchone()["link_id"]
if not state.get("job_id"):
token = exchange_token(args.agent_env)
with TestClient(app, base_url="http://127.0.0.1:8000") as client:
headers = {
"Authorization": "Bearer " + token,
"X-Project-Id": state["project_id"],
}
binding = client.post(
"/api/v1/mcp-query-contexts",
headers=headers,
json={"native_session_id": "benchmark-normal-scada-v1", "purpose": "ordinary_chat"},
)
binding.raise_for_status()
queued = mcp_call(client, token, state["project_id"], "run_simulation", {
"context_handle": binding.json()["context_handle"],
"timeout_seconds": 240,
"start_time": start.isoformat(),
"duration": 86400,
"output_node_ids": [node],
"output_link_ids": [link],
})
state.update({"job_id": queued["job_id"], "status": queued["status"]})
save_json(state_path, state)
print({"job_id": state["job_id"], "status": state["status"]}, flush=True)
async def stored_job(include_result: bool = False):
from app.services.calculation_worker import queue_repository
async with queue_repository(state["project_id"]) as repository:
return await repository.get(state["job_id"], include_result=include_result)
stored = asyncio.run(stored_job())
if stored["status"] == "queued":
asyncio.run(execute_batch(state["project_id"], [state["job_id"]], 1))
stored = asyncio.run(stored_job())
if stored["status"] != "succeeded":
raise RuntimeError(f"normal online simulation ended with {stored['status']}")
result = asyncio.run(stored_job(include_result=True))["result"]
calculation_data = (result or {}).get("data") or {}
scenario_result = calculation_data.get("result") or calculation_data
units = scenario_result.get("units") or {}
if str(units.get("pressure", "")).upper() not in {"MTR", "M", "METERS"}:
raise ValueError(f"unexpected model pressure unit: {units.get('pressure')}")
if str(units.get("flow", "")).upper() != "LPS":
raise ValueError(f"unexpected model flow unit: {units.get('flow')}")
if not state.get("scada"):
state["status"] = "simulated"
save_json(state_path, state)
state["scada"] = write_realtime_scada_day(
business_dsn=business_dsn,
timescale_dsn=timescale_dsn,
start=start,
noise_seed=args.noise_seed,
)
state["model_units"] = units
state["status"] = "scada_materialized"
save_json(state_path, state)
print(json.dumps({"status": state["status"], "job_id": state["job_id"], "scada": state["scada"]}, ensure_ascii=False), flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+8
View File
@@ -146,6 +146,14 @@ class OperationalWorker:
json={"limit": 100, **scope},
)
recovery.raise_for_status()
try:
recovery_checks = await self.client.post(
f"{server}/api/v1/operational-tasks/recovery/reconcile", headers=headers,
json={"limit": 20, **scope},
)
recovery_checks.raise_for_status()
except httpx.HTTPError as exc:
logger.warning("SCADA recovery reconciliation unavailable: %s", type(exc).__name__)
active_run_ids: set[str] = set()
for status_value in ("claimed", "running"):
response = await self.client.get(
+44 -8
View File
@@ -1,22 +1,58 @@
import argparse
import asyncio
import os
import sys
from typing import Any
import uvicorn
# 将项目根目录添加到 python 路径
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
def build_server_options(argv: list[str] | None = None) -> dict[str, Any]:
parser = argparse.ArgumentParser(description="Run the TJWater API server")
parser.add_argument("--host", default="0.0.0.0")
parser.add_argument("--port", type=int, default=8000)
parser.add_argument("--reload", action="store_true")
parser.add_argument("--reload-dir", action="append", default=[])
parser.add_argument("--startup-timeout", type=int, default=60)
args = parser.parse_args(argv)
if args.startup_timeout <= 0:
parser.error("--startup-timeout must be positive")
return {
"app": "app.main:app",
"host": args.host,
"port": args.port,
"reload": args.reload,
"reload_dirs": args.reload_dir,
"startup_timeout": args.startup_timeout,
"loop": "asyncio",
}
def main() -> None:
from pathlib import Path
from scripts.server_handoff import HandoffSupervisor
options = build_server_options()
reload_enabled = options.pop("reload")
reload_dirs = options.pop("reload_dirs")
startup_timeout = options.pop("startup_timeout")
if reload_enabled:
HandoffSupervisor(
uvicorn.Config(**options),
startup_timeout=startup_timeout,
reload_dirs=[Path(directory) for directory in reload_dirs] or None,
readiness_check="app.api.health:metadata_database_ready",
).run()
else:
uvicorn.run(**options)
if __name__ == "__main__":
# Windows 设置事件循环策略
if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
# 用 uvicorn.run 支持 workers 参数
uvicorn.run(
"app.main:app",
host="0.0.0.0",
port=8000,
# workers=2, # 这里可以设置多进程
loop="asyncio",
)
main()
+256
View File
@@ -0,0 +1,256 @@
#!/usr/bin/env python3
"""Screen alternative burst pipes near existing pressure meters using a frozen INP."""
from __future__ import annotations
import argparse
import asyncio
import heapq
import json
import math
import sys
import os
from collections import defaultdict, deque
from pathlib import Path
from uuid import uuid4
def nearest_pressure_distances(
graph: dict[str, list[tuple[str, float]]], devices: list[dict], radius_m: float,
) -> dict[str, tuple[float, str]]:
"""Measure to the nearest pressure meter over the network, not just a selected meter."""
distances = {}
pending = []
for device in devices:
node, device_id = device["node_id"], device["device_id"]
value = (0.0, device_id)
if value < distances.get(node, (math.inf, "")):
distances[node] = value
heapq.heappush(pending, (0.0, device_id, node))
while pending:
distance, device_id, node = heapq.heappop(pending)
if distances[node] != (distance, device_id):
continue
for neighbor, length in graph.get(node, []):
cost = distance + length
value = (cost, device_id)
if cost <= radius_m and value < distances.get(neighbor, (math.inf, "")):
distances[neighbor] = value
heapq.heappush(pending, (cost, device_id, neighbor))
return distances
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--calibration-dir", required=True, type=Path)
parser.add_argument("--limit", type=int, default=24)
parser.add_argument("--offset", type=int, default=0)
parser.add_argument("--radius-m", type=float, default=400)
parser.add_argument("--min-network-distance-m", type=float, default=0)
parser.add_argument("--min-diameter", type=float, default=150)
parser.add_argument("--require-isolation", action="store_true")
parser.add_argument("--exclude-existing-km", type=float, default=0)
parser.add_argument("--skip-screening", type=Path)
args = parser.parse_args()
os.umask(0o077)
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from scripts.benchmark.calibration import configure_inp, digest, run_file, save_json
from scripts.benchmark.simulation_design import BurstSimulationDesign
from scripts.check_burst_simulation_design import readonly, resolve_project
if (not 1 <= args.limit <= 100 or args.offset < 0 or not 100 <= args.radius_m <= 5000
or not 0 <= args.min_network_distance_m < args.radius_m
or not 50 <= args.min_diameter <= 800 or not 0 <= args.exclude_existing_km <= 10):
parser.error("invalid screen limit, offset, radius, diameter or exclusion distance")
folder = args.calibration_dir.resolve()
calibration = json.loads((folder / "calibration.json").read_text())
design = BurstSimulationDesign.model_validate(calibration["design"])
snapshot = folder / "source.inp"
if digest(snapshot) != calibration["model_sha256"]:
raise ValueError("model snapshot fingerprint changed")
biz, _ = asyncio.run(resolve_project(design))
with readonly(biz) as connection:
devices = connection.execute(
"SELECT device_id,device_type,node_id,link_id FROM asset.scada_devices "
"WHERE device_type IN ('pressure','pipe_flow') ORDER BY device_id"
).fetchall()
pressure = [device for device in devices if device["device_type"] == "pressure"]
flow = [device for device in devices if device["device_type"] == "pipe_flow" and device["device_id"] != "547063"]
source = snapshot.read_text()
graph = defaultdict(list)
pipes = {}
valve_links = {}
source_nodes = set()
closed_links = set()
junctions = set()
existing_emitters = set()
section = ""
for raw in source.splitlines():
line = raw.split(";", 1)[0].strip()
if line.startswith("["):
section = line.strip("[]").upper()
continue
tokens = line.split()
if not tokens:
continue
if section == "JUNCTIONS":
junctions.add(tokens[0])
elif section in {"RESERVOIRS", "TANKS"}:
source_nodes.add(tokens[0])
elif section == "STATUS" and tokens[-1].upper() == "CLOSED":
closed_links.add(tokens[0])
elif section == "EMITTERS":
existing_emitters.add(tokens[0])
elif section in {"PIPES", "VALVES"}:
link, left, right = tokens[:3]
length = float(tokens[3]) if section == "PIPES" else 0.01
if section == "PIPES":
if tokens[-1].upper() == "CLOSED":
continue
pipes[link] = {"pipe_id": link, "start_node_id": left, "emitter_node_id": right,
"length_m": length, "diameter_mm": float(tokens[4])}
else:
valve_links[link] = (left, right)
graph[left].append((right, max(length, 0.01)))
graph[right].append((left, max(length, 0.01)))
# Initial link overrides also apply to pipe and valve connectivity.
graph.clear()
pipe_graph = defaultdict(list)
for link, pipe in pipes.items():
if link in closed_links:
continue
left, right = pipe["start_node_id"], pipe["emitter_node_id"]
length = max(pipe["length_m"], 0.01)
graph[left].append((right, length))
graph[right].append((left, length))
pipe_graph[left].append(right)
pipe_graph[right].append(left)
for link, (left, right) in valve_links.items():
if link not in closed_links:
graph[left].append((right, 0.01))
graph[right].append((left, 0.01))
component_by_node = {}
component_data = {}
def isolation(node):
if node not in component_by_node:
seen = {node}
pending = deque([node])
while pending:
for neighbor in pipe_graph[pending.popleft()]:
if neighbor not in seen:
seen.add(neighbor)
pending.append(neighbor)
component_id = min(seen)
for member in seen:
component_by_node[member] = component_id
boundaries = sorted(link for link, (left, right) in valve_links.items()
if link not in closed_links and (left in seen) != (right in seen))
component_data[component_id] = {"region_id": component_id, "node_count": len(seen),
"boundary_valve_ids": boundaries,
"source_inside": bool(seen & source_nodes)}
return component_data[component_by_node[node]]
# Only real pressure-meter model nodes present in this frozen snapshot are used.
pressure = [device for device in pressure if device["node_id"] in junctions]
flow = [device for device in flow if device["link_id"] in pipes]
distances = nearest_pressure_distances(graph, pressure, args.radius_m)
candidates = []
for pipe in pipes.values():
node = pipe["emitter_node_id"]
if (args.min_diameter <= pipe["diameter_mm"] <= 800 and pipe["length_m"] >= 50
and pipe["pipe_id"] not in closed_links
and node in junctions and node not in existing_emitters and node in distances):
distance, device = distances[node]
if distance < args.min_network_distance_m:
continue
region = isolation(node)
if args.require_isolation and (region["source_inside"] or not 1 <= len(region["boundary_valve_ids"]) <= 12):
continue
candidates.append({**pipe, "nearest_pressure_device": device, "network_distance_m": distance,
"screen_priority": pipe["length_m"] / pipe["diameter_mm"] ** 2,
"isolation": region})
skipped = set()
if args.skip_screening:
skipped = {entry["pipe_id"] for entry in json.loads(args.skip_screening.read_text())["results"]}
with readonly(biz) as connection:
coords = {row["node_id"]: row for row in connection.execute(
"SELECT node_id,ST_X(ST_Transform(geom,4326)) longitude,"
"ST_Y(ST_Transform(geom,4326)) latitude FROM gis.node_geometries WHERE node_id=ANY(%s)",
(list({pipe["emitter_node_id"] for pipe in candidates}),),
)}
existing_positions = connection.execute(
"SELECT ST_X(ST_Transform(geom,4326)) longitude,ST_Y(ST_Transform(geom,4326)) latitude "
"FROM gis.node_geometries WHERE node_id=ANY(%s)",
([pipe.emitter_node_id for pipe in design.pipes],),
).fetchall()
filtered = []
for candidate in candidates:
position = coords.get(candidate["emitter_node_id"])
if position is None or candidate["pipe_id"] in skipped:
continue
distances_km = [111.32 * math.hypot((position["longitude"] - old["longitude"]) *
math.cos(math.radians(position["latitude"])),
position["latitude"] - old["latitude"])
for old in existing_positions]
if distances_km and min(distances_km) < args.exclude_existing_km:
continue
candidate.update(longitude=position["longitude"], latitude=position["latitude"],
nearest_existing_km=min(distances_km) if distances_km else None)
filtered.append(candidate)
candidates = filtered
# Cover different meter neighborhoods before taking additional pipes at the same meter.
candidates.sort(key=lambda pipe: pipe["screen_priority"], reverse=True)
used = set()
unique, remaining = [], []
for candidate in candidates:
device = candidate["nearest_pressure_device"]
(remaining if device in used else unique).append(candidate)
used.add(device)
selected = (unique + remaining)[args.offset:args.offset + args.limit]
node_ids = sorted({device["node_id"] for device in pressure} | {pipe["emitter_node_id"] for pipe in selected})
link_ids = sorted({device["link_id"] for device in flow})
at = max(design.event_times)
output = folder / f"screening-{uuid4().hex[:8]}"
output.mkdir(mode=0o700)
report = {"version": "burst-pipe-screening@1", "model_sha256": digest(snapshot),
"at": at.isoformat(), "candidate_count": len(candidates), "results": [],
"screen_options": {key: str(value) if isinstance(value, Path) else value for key, value in vars(args).items()},
"device_bindings": devices,
"scope": "daytime single-frame screening only; requires full paired calibration and isolation review"}
def simulate(label, emitter=None):
path = output / f"{label}.inp"
path.write_text(configure_inp(source, at, 0, emitter=emitter, pda_options=calibration["pda_options"]))
data = run_file(path, node_ids, link_ids, timeout=240)
save_json(output / f"{label}.json", data)
return data
normal = simulate("normal")
if normal["status"] != "completed" or normal["warnings"]:
raise ValueError("normal screening baseline failed")
for index, pipe in enumerate(selected):
area = math.pi * (pipe["diameter_mm"] / 10) ** 2 / 4 / 8
burst = simulate(f"candidate-{index:02d}", (pipe["emitter_node_id"], area))
result = {**pipe, "orifice_area_cm2": area, "status": burst["status"], "output_file": f"candidate-{index:02d}.json"}
if burst["status"] == "completed":
drops = {device["device_id"]: 100 * (normal["nodes"][device["node_id"]][0]["pressure"] -
burst["nodes"][device["node_id"]][0]["pressure"]) /
max(abs(normal["nodes"][device["node_id"]][0]["pressure"]), 1e-6) for device in pressure}
changes = {device["device_id"]: 100 * abs(normal["links"][device["link_id"]][0]["flow"] -
burst["links"][device["link_id"]][0]["flow"]) /
abs(normal["links"][device["link_id"]][0]["flow"]) for device in flow
if abs(normal["links"][device["link_id"]][0]["flow"]) > 1e-6}
ranked = sorted(drops.items(), key=lambda pair: pair[1], reverse=True)
negative = any(row[0]["pressure"] < 0 for row in burst["nodes"].values())
result.update(pressure_drop_percent=dict(ranked), flow_change_percent=changes,
possible_candidate=not burst["warnings"] and not negative and ranked[0][1] >= 30
and (ranked[1][1] >= 10 or max(changes.values(), default=0) >= 20))
print(f"{index+1}/{len(selected)} {pipe['pipe_id']}: max pressure drop={ranked[0][1]:.2f}%, "
f"possible candidate={result['possible_candidate']}", flush=True)
report["results"].append(result)
save_json(output / "screening.json", report)
print(f"screening complete; report: {output / 'screening.json'}", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+267
View File
@@ -0,0 +1,267 @@
"""Keep the listening socket alive while replacement ASGI workers become ready."""
from __future__ import annotations
import asyncio
import logging
import multiprocessing
import signal
import threading
import time
from dataclasses import dataclass
from functools import partial
from multiprocessing.connection import Connection
from multiprocessing.process import BaseProcess
from pathlib import Path
import uvicorn
from uvicorn._subprocess import get_subprocess
from uvicorn.importer import import_from_string
logger = logging.getLogger("uvicorn.error")
class _ReadinessGate:
def __init__(self, app, check):
self.app, self.check = app, check
async def __call__(self, scope, receive, send):
if scope["type"] != "lifespan":
return await self.app(scope, receive, send)
async def checked_send(message):
if message["type"] == "lifespan.startup.complete" and not await self.check():
await send({"type": "lifespan.startup.failed", "message": "API readiness check failed"})
else:
await send(message)
await self.app(scope, receive, checked_send)
class _ReadyServer(uvicorn.Server):
def __init__(self, config: uvicorn.Config, control: Connection):
super().__init__(config)
self.control = control
async def startup(self, sockets=None) -> None:
await super().startup(sockets=sockets)
if self.started and not self.should_exit:
self.control.send("ready")
async def shutdown(self, sockets=None) -> None:
# Acknowledge that the old worker has actually stopped accepting. Its
# established streams may still run for minutes after this handshake.
for listener in self.servers:
listener.close()
for sock in sockets or []:
sock.close()
self.control.send("draining")
await super().shutdown(sockets=sockets)
def _serve_worker(*, config: uvicorn.Config, control: Connection, readiness_check: str | None, sockets) -> None:
config.reload = False
config.workers = 1
if readiness_check:
config.load()
config.loaded_app = _ReadinessGate(config.loaded_app, import_from_string(readiness_check))
server = _ReadyServer(config, control)
async def receive_commands() -> None:
while True:
if control.poll():
try:
command = control.recv()
except EOFError:
command = "drain"
if command == "drain":
server.should_exit = True
return
await asyncio.sleep(0.05)
async def serve() -> None:
receiver = asyncio.create_task(receive_commands())
try:
await server.serve(sockets=sockets)
finally:
receiver.cancel()
await asyncio.gather(receiver, return_exceptions=True)
control.close()
config.setup_event_loop()
asyncio.run(serve())
@dataclass
class Worker:
process: BaseProcess
control: Connection
def drain(self, *, wait_timeout: float = 0) -> bool:
if self.process.is_alive():
try:
self.control.send("drain")
if wait_timeout:
deadline = time.monotonic() + wait_timeout
while time.monotonic() < deadline:
if self.control.poll(0.05) and self.control.recv() == "draining":
return True
if not self.process.is_alive():
return True
return False
except (BrokenPipeError, EOFError, OSError):
return not self.process.is_alive()
return True
class HandoffSupervisor:
"""Start the replacement before draining the worker with existing connections."""
def __init__(
self,
config: uvicorn.Config,
*,
startup_timeout: float = 60,
reload_dirs: list[Path] | None = None,
poll_interval: float = 0.25,
readiness_check: str | None = None,
):
self.config = config
self.startup_timeout = startup_timeout
self.reload_dirs = reload_dirs or [Path("app"), Path("samples/peripheral")]
self.poll_interval = poll_interval
self.readiness_check = readiness_check
self.current: Worker | None = None
self.retiring: list[Worker] = []
self.socket = config.bind_socket()
self.socket.listen(config.backlog)
self.should_exit = threading.Event()
self._files: dict[Path, tuple[int, int]] = {}
def _candidate(self) -> Worker | None:
parent = child = process = None
ready = False
try:
parent, child = multiprocessing.get_context("spawn").Pipe()
process = get_subprocess(
config=self.config,
target=partial(_serve_worker, config=self.config, control=child, readiness_check=self.readiness_check),
sockets=[self.socket],
)
worker = Worker(process, parent)
process.start()
child.close()
deadline = time.monotonic() + self.startup_timeout
while not self.should_exit.is_set() and time.monotonic() < deadline:
if parent.poll(0.05):
try:
ready = parent.recv() == "ready" and process.is_alive()
except EOFError:
break
if ready:
return worker
if not process.is_alive():
break
logger.error("Replacement worker did not become ready; keeping the current worker")
return None
except Exception:
logger.exception("Replacement startup or readiness handshake failed; keeping the current worker")
return None
finally:
if child is not None:
child.close()
if not ready:
# Only an unready candidate is terminated. The serving worker
# and workers draining established connections are untouched.
if process is not None and process.pid is not None:
if process.is_alive():
process.terminate()
process.join(timeout=2)
if process.is_alive():
process.kill()
process.join()
if parent is not None:
parent.close()
def start(self) -> bool:
self._files = self._snapshot()
self.current = self._candidate()
return self.current is not None
def reload(self) -> bool:
self.reap()
candidate = self._candidate()
if candidate is None:
return False
old, self.current = self.current, candidate
logger.info("Worker %s is ready to accept new connections", candidate.process.pid)
if old is not None:
acknowledged = old.drain(wait_timeout=2)
self.retiring.append(old)
if not acknowledged:
logger.error("Worker %s has not yet acknowledged draining", old.process.pid)
return False
logger.info("Worker %s is draining existing connections", old.process.pid)
return True
def reap(self) -> None:
remaining = []
for worker in self.retiring:
if worker.process.is_alive():
remaining.append(worker)
else:
worker.process.join()
worker.control.close()
self.retiring = remaining
def _snapshot(self) -> dict[Path, tuple[int, int]]:
files = {}
for directory in self.reload_dirs:
for path in directory.rglob("*.py"):
try:
stat = path.stat()
files[path.resolve()] = (stat.st_mtime_ns, stat.st_size)
except OSError:
continue
return files
def run(self) -> None:
signals = (signal.SIGINT, signal.SIGTERM)
original = {sig: signal.signal(sig, lambda *_: self.should_exit.set()) for sig in signals}
try:
if not self.start():
raise RuntimeError("The initial API worker failed to start")
pending_reload = False
while not self.should_exit.wait(self.poll_interval):
self.reap()
files = self._snapshot()
if files != self._files:
self._files = files
pending_reload = True
active_failed = self.current is None or not self.current.process.is_alive()
if active_failed:
logger.error("The active worker exited; starting a replacement")
pending_reload = True
# Coalesce rapid edits while old SSE connections drain, rather
# than keeping an unbounded number of application generations.
if pending_reload and (active_failed or len(self.retiring) < 2):
pending_reload = False
self.reload()
finally:
try:
self.close()
finally:
for sig, handler in original.items():
signal.signal(sig, handler)
def close(self) -> None:
self.should_exit.set()
workers = [*self.retiring, *([self.current] if self.current else [])]
for worker in workers:
worker.drain()
self.socket.close()
for worker in workers:
worker.process.join()
worker.control.close()
self.current = None
self.retiring = []
+5 -2
View File
@@ -215,14 +215,17 @@ async def sync_once(client: httpx.AsyncClient, settings: WorkerSettings, periphe
evidence_summary = order["summary"]
if notes:
evidence_summary += "\n现场反馈:\n" + "\n".join(notes[-10:])
field_times = [activity["occurred_at"] for activity in order.get("activity", [])
if activity.get("kind") == "feedback" and activity.get("occurred_at")]
feedback_time = field_times[-1] if order["status"] == "completed" and field_times else order["updated_at"]
feedback = {
"request_id": str(uuid5(NAMESPACE_URL, event_id)), "source_event_id": external_id,
"source_revision": order["revision"], "status": STATUS[order["status"]],
"occurred_at": order["updated_at"],
"occurred_at": feedback_time,
"detail": f"{source_label} {order['work_order_id']}:{order['status']}",
"evidence": {"evidence_id": event_id, "kind": "external_feedback", "summary": evidence_summary,
"source_label": source_label, "source_ref": external_id,
"observed_at": order["updated_at"]},
"observed_at": feedback_time},
}
# A child order's creation is already represented by its dispatch
# receipt. Only a subsequent source revision is new field input.