#!/usr/bin/env python3 """Read-only validation of the tjwater2_v2 burst-simulation design.""" from __future__ import annotations import argparse import asyncio import sys import traceback from datetime import timedelta from pathlib import Path from psycopg import connect from psycopg.conninfo import conninfo_to_dict, make_conninfo from psycopg.rows import dict_row def readonly(dsn: str): return connect( make_conninfo( dsn, options="-c default_transaction_read_only=on -c statement_timeout=20000", application_name="tjwater-burst-design-readonly", connect_timeout="5", ), row_factory=dict_row, ) async def resolve_project(design): 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(design.project_code) if ( project is None or project.id != design.project_id or project.status != "active" ): raise ValueError("project identity or active status does not match") biz = await repo.get_project_db_routing(project.id, "biz_data") iot = await repo.get_project_db_routing(project.id, "iot_data") if biz is None or iot is None: raise ValueError("project database routing is incomplete") await session.rollback() return biz.dsn, iot.dsn finally: await engine.dispose() def validate_database(connection, design, *, template: bool) -> None: connection.execute("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY") if connection.execute("SHOW transaction_read_only").fetchone()["transaction_read_only"] != "on": raise ValueError("database transaction is not read-only") expected = design.model_expectations counts = connection.execute( "SELECT (SELECT count(*) FROM network.nodes) AS nodes, " "(SELECT count(*) FROM network.pipes) AS pipes, " "(SELECT count(*) FROM network.links WHERE link_type='valve') AS valves" ).fetchone() if (counts["nodes"], counts["pipes"], counts["valves"]) != ( expected.node_count, expected.pipe_count, expected.valve_count, ): raise ValueError("model counts do not match the frozen design") settings = { row["key"]: row["value"] for row in connection.execute( "SELECT key,value FROM network.simulation_settings " "WHERE engine_version='legacy' AND key=ANY(%s)", (["UNITS", "PRESSURE", "EMITTER EXPONENT"],), ) } times = { row["key"]: row["value"] for row in connection.execute( "SELECT key,value FROM network.time_settings " "WHERE key=ANY(%s)", (["HYDRAULIC TIMESTEP", "REPORT TIMESTEP"],), ) } if settings != { "UNITS": expected.flow_unit, "PRESSURE": expected.pressure_unit, "EMITTER EXPONENT": f"{expected.emitter_exponent:.4f}", }: raise ValueError("simulation settings do not match the frozen design") if times != {"HYDRAULIC TIMESTEP": "0:15:00", "REPORT TIMESTEP": "0:15:00"}: raise ValueError("simulation timesteps do not match the frozen design") for pipe in design.pipes: row = connection.execute( "SELECT l.start_node_id,l.end_node_id,p.length,p.diameter,p.status " "FROM network.links l JOIN network.pipes p ON p.link_id=l.id WHERE l.id=%s", (pipe.pipe_id,), ).fetchone() if row is None: raise ValueError(f"pipe {pipe.pipe_id} is missing") if ( str(row["start_node_id"]) != pipe.start_node_id or str(row["end_node_id"]) != pipe.emitter_node_id or abs(float(row["length"]) - pipe.length_m) > 0.001 or abs(float(row["diameter"]) - pipe.diameter_mm) > 0.001 or str(row["status"]).upper() != "OPEN" ): raise ValueError(f"pipe {pipe.pipe_id} properties changed") node_rows = connection.execute( "SELECT id,node_type FROM network.nodes WHERE id=ANY(%s)", (pipe.output_node_ids,), ).fetchall() if {str(row["id"]) for row in node_rows} != set(pipe.output_node_ids): raise ValueError(f"pipe {pipe.pipe_id} output nodes are incomplete") node_types = {str(row["id"]): str(row["node_type"]) for row in node_rows} if node_types.get(pipe.emitter_node_id) != "junction": raise ValueError(f"pipe {pipe.pipe_id} emitter endpoint is not a junction") link_rows = connection.execute( "SELECT id,link_type FROM network.links WHERE id=ANY(%s)", (pipe.output_link_ids,), ).fetchall() if {str(row["id"]) for row in link_rows} != set(pipe.output_link_ids): raise ValueError(f"pipe {pipe.pipe_id} output links are incomplete") link_types = {str(row["id"]): str(row["link_type"]) for row in link_rows} if any(link_types.get(value) != "valve" for value in pipe.boundary_valve_ids): raise ValueError(f"pipe {pipe.pipe_id} boundary valve changed") emitter = connection.execute( "SELECT coefficient FROM network.emitters WHERE junction_id=%s", (pipe.emitter_node_id,), ).fetchone() if emitter is not None: raise ValueError(f"pipe {pipe.pipe_id} endpoint already has an emitter") if not template: pressure_bindings = { ( str(row["device_id"]), str(row["node_id"]), str(row["measurement_unit"]), str(row["transmission_frequency"]), ) for row in connection.execute( "SELECT device_id,node_id,measurement_unit,transmission_frequency " "FROM asset.scada_devices " "WHERE device_type='pressure' AND device_id=ANY(%s)", ([value.device_id for value in pipe.pressure_bindings],), ) } flow_bindings = { ( str(row["device_id"]), str(row["link_id"]), str(row["measurement_unit"]), str(row["transmission_frequency"]), ) for row in connection.execute( "SELECT device_id,link_id,measurement_unit,transmission_frequency " "FROM asset.scada_devices " "WHERE device_type IN ('pipe_flow','flow') AND device_id=ANY(%s)", ([value.device_id for value in pipe.flow_bindings],), ) } expected_pressure_bindings = { ( value.device_id, value.node_id, expected.pressure_measurement_unit, str(timedelta(seconds=expected.report_timestep_seconds)), ) for value in pipe.pressure_bindings } expected_flow_bindings = { ( value.device_id, value.link_id, expected.flow_measurement_unit, str(timedelta(seconds=expected.report_timestep_seconds)), ) for value in pipe.flow_bindings } if pressure_bindings != expected_pressure_bindings: raise ValueError(f"pipe {pipe.pipe_id} pressure bindings changed") if flow_bindings != expected_flow_bindings: raise ValueError(f"pipe {pipe.pipe_id} flow bindings changed") pressure_distances = { str(row["device_id"]): row["distance_m"] for row in connection.execute( "SELECT d.device_id,ST_Distance(d.geom,n.geom) AS distance_m " "FROM asset.scada_devices d " "JOIN gis.node_geometries n ON n.node_id=d.node_id " "WHERE d.device_id=ANY(%s)", ([value.device_id for value in pipe.pressure_bindings],), ) } flow_distances = { str(row["device_id"]): row["distance_m"] for row in connection.execute( "SELECT d.device_id," "ST_Distance(d.geom,ST_Transform(p.geom,900914)) AS distance_m " "FROM asset.scada_devices d " "JOIN gis.pipes p ON p.id=d.link_id " "WHERE d.device_id=ANY(%s)", ([value.device_id for value in pipe.flow_bindings],), ) } if len(pressure_distances) != len(pipe.pressure_bindings) or any( distance is None or float(distance) > design.binding_validation.max_pressure_distance_m for distance in pressure_distances.values() ): raise ValueError(f"pipe {pipe.pipe_id} pressure binding geometry changed") if len(flow_distances) != len(pipe.flow_bindings) or any( distance is None or float(distance) > design.binding_validation.max_flow_distance_m for distance in flow_distances.values() ): raise ValueError(f"pipe {pipe.pipe_id} flow binding geometry changed") connection.rollback() def validate_tool_inputs(design) -> None: from app.mcp.extended_models import BurstSimulationInput pipes = {pipe.pipe_id: pipe for pipe in design.pipes} for case in design.cases: pipe = pipes[case.pipe_id] for severity in design.calibration_order: BurstSimulationInput( context_handle="benchmark-design-context-handle-0001", timeout_seconds=240, start_time=case.occurred_at, duration=design.duration_seconds, output_node_ids=pipe.output_node_ids, output_link_ids=pipe.output_link_ids, burst_pipe_ids=[pipe.pipe_id], orifice_areas_cm2=[pipe.candidate_orifice_areas_cm2[severity]], ) def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--design", type=Path, default=Path("resources/benchmarks/tjwater2-v2-burst-simulation.json"), ) args = parser.parse_args() sys.path.insert(0, str(Path(__file__).resolve().parents[1])) try: from scripts.benchmark.simulation_design import load_simulation_design design = load_simulation_design(args.design) validate_tool_inputs(design) biz_dsn, _ = asyncio.run(resolve_project(design)) physical_name = conninfo_to_dict(biz_dsn).get("dbname") if not physical_name: raise ValueError("business route has no database name") template_dsn = make_conninfo(biz_dsn, dbname=physical_name + "_template") with readonly(biz_dsn) as connection: validate_database(connection, design, template=False) with readonly(template_dsn) as connection: validate_database(connection, design, template=True) 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 "" print(f"burst simulation design check failed ({type(exc).__name__}){suffix}", file=sys.stderr) return 1 maximum_runs = len(design.cases) * len(design.severity_levels) print( f"burst simulation design ready: {len(design.pipes)} pipes, " f"{len(design.cases)} cases, at most {maximum_runs} calibration runs; " "tool inputs, SCADA bindings, BizDB and model template were read-only verified" ) return 0 if __name__ == "__main__": raise SystemExit(main())