SWMM_MCP_Server_Claude / swmm_worker.py
razaali10's picture
Upload 20 files
e545bf5 verified
Raw
History Blame Contribute Delete
7.76 kB
"""One-shot OpenSWMM worker. Do not import this module from Streamlit."""
from __future__ import annotations
import argparse
import os
import pickle
import traceback
from pathlib import Path
from typing import Any
import numpy as np
def _as_float_list(values: Any) -> list[float]:
return np.asarray(values, dtype=float).copy().tolist()
def _enum_name(value: Any) -> str:
return getattr(value, "name", str(value))
def _safe_float(getter, default: float = 0.0) -> float:
try:
return float(getter())
except Exception:
return default
def simulate(inp_path: str, rpt_path: str, out_path: str) -> dict[str, Any]:
from openswmm.engine import Solver
node_ts: dict[str, dict[str, Any]] = {}
link_ts: dict[str, dict[str, Any]] = {}
sub_ts: dict[str, dict[str, Any]] = {}
times: list[Any] = []
warnings: list[dict[str, Any]] = []
with Solver(inp_path, rpt_path, out_path) as solver:
solver.set_warning_callback(
lambda code, message: warnings.append(
{"code": int(code), "message": str(message)}
)
)
nodes = solver.nodes
links = solver.links
subs = solver.subcatchments
node_ids = [str(nodes.get_id(i)) for i in range(len(nodes))]
link_ids = [str(links.get_id(i)) for i in range(len(links))]
sub_ids = [str(subs.get_id(i)) for i in range(len(subs))]
for node in nodes:
node_ts[str(node.id)] = {
"depth": [], "flooding": [], "inflow": [], "head": [],
"outflow": [], "volume": [],
"invert_elevation": float(node.invert_elev),
"full_depth": float(node.max_depth),
}
for link in links:
geom1 = 0.0
try:
geom1 = float(link.xsect.geom1)
except Exception:
try:
geom1 = float(link.xsect.geometry[0])
except Exception:
pass
link_ts[str(link.id)] = {
"flow": [], "depth": [], "velocity": [], "volume": [],
"capacity": [], "length": float(link.length),
"roughness": float(link.roughness), "diameter": geom1,
}
for sub in subs:
sub_ts[str(sub.id)] = {"runoff": [], "rainfall": [], "infil": []}
for _elapsed in solver.steps():
times.append(solver.current_datetime)
node_values = {
"depth": _as_float_list(nodes.depths),
"flooding": _as_float_list(nodes.overflows),
"inflow": _as_float_list(nodes.inflows),
"head": _as_float_list(nodes.heads),
"outflow": _as_float_list(nodes.outflows),
"volume": _as_float_list(nodes.volumes),
}
for i, node_id in enumerate(node_ids):
for key, values in node_values.items():
node_ts[node_id][key].append(values[i])
link_values = {
"flow": _as_float_list(links.flows),
"depth": _as_float_list(links.depths),
"velocity": _as_float_list(links.velocities),
"volume": _as_float_list(links.volumes),
"capacity": _as_float_list(links.capacities),
}
for i, link_id in enumerate(link_ids):
for key, values in link_values.items():
link_ts[link_id][key].append(values[i])
if sub_ids:
sub_values = {
"runoff": _as_float_list(subs.runoffs),
"rainfall": _as_float_list(subs.rainfalls),
"infil": _as_float_list(subs.infils),
}
for i, sub_id in enumerate(sub_ids):
for key, values in sub_values.items():
sub_ts[sub_id][key].append(values[i])
# Recompute link velocity as |flow| / (volume / length).
# Rationale: the bulk `links.velocities` API array was found to
# disagree with the engine's own .rpt Link Flow Summary (5-12% on
# circular pipes; understated up to ~6x on IRREGULAR transect
# channels), while |Q|*L/volume reproduces the .rpt values to ~1%
# for both pipes and channels (validated against a SWMM 5.0.022
# reference run of the same model). The raw API series is kept as
# "velocity_api" for auditability. Zero-length links (OUTLET/DUMMY)
# carry no meaningful velocity and are reported as zero.
for link_id in link_ids:
ts = link_ts[link_id]
length = float(ts.get("length", 0.0) or 0.0)
flows = ts["flow"]
volumes = ts["volume"]
ts["velocity_api"] = ts["velocity"]
if length > 0.0:
derived = []
for q, vol in zip(flows, volumes):
if vol > 1e-9:
v = abs(q) * length / vol
derived.append(v if np.isfinite(v) else 0.0)
else:
derived.append(0.0)
ts["velocity"] = derived
else:
ts["velocity"] = [0.0] * len(flows)
mb = solver.mass_balance
diag = mb.routing_diagnostics
metadata = {
"flow_units": _enum_name(solver.flow_units),
"system_units": str(solver.unit_system),
# OpenSWMM returns continuity errors as fractions; the .rpt and
# every downstream consumer (UI banner, report thresholds,
# calgary_rules continuity_*_pct) express them in PERCENT.
# Convert at the source so a -1.93% error reads as -1.93, not
# -0.0193 (which silently defeated the 0.5/1.0% thresholds).
"runoff_error": float(mb.runoff_continuity_error) * 100.0,
"flow_error": float(mb.routing_continuity_error) * 100.0,
"quality_error": _safe_float(lambda: mb.quality_continuity_error) * 100.0,
"start_time": solver.start_datetime,
"end_time": solver.end_datetime,
"routing_steps": int(diag.n_steps),
"not_converged_steps": int(diag.n_steps_not_converged),
"pct_not_converged": float(diag.pct_not_converged),
"avg_routing_step_s": float(diag.avg_time_step),
"warnings": warnings,
"report_path": rpt_path,
"output_path": out_path,
}
return {
"node_ts": node_ts,
"link_ts": link_ts,
"sub_ts": sub_ts,
"times": times,
"metadata": metadata,
}
def write_payload(path: Path, payload: dict[str, Any]) -> None:
temp = path.with_suffix(path.suffix + ".tmp")
with temp.open("wb") as f:
pickle.dump(payload, f, protocol=pickle.HIGHEST_PROTOCOL)
f.flush()
os.fsync(f.fileno())
os.replace(temp, path)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--inp", required=True)
parser.add_argument("--rpt", required=True)
parser.add_argument("--out", required=True)
parser.add_argument("--result", required=True)
args = parser.parse_args()
result_path = Path(args.result)
try:
results = simulate(args.inp, args.rpt, args.out)
write_payload(result_path, {"ok": True, "results": results})
# Bypass Python/native-extension finalisers. This is intentional.
os._exit(0)
except BaseException as exc:
write_payload(result_path, {
"ok": False,
"error": f"{type(exc).__name__}: {exc}\n{traceback.format_exc()}",
})
os._exit(1)
if __name__ == "__main__":
main()