"""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()