feat(benchmark): add tjwater2 burst calibration design
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user