Spaces:
Sleeping
Sleeping
| """Headless SWMM model pipeline: INP parsing and result-summary builders. | |
| Extracted verbatim from the SWMM6 GIS Tool (Rev 23.2) Streamlit app so the | |
| same deterministic logic serves the MCP/REST server without a Streamlit | |
| dependency. Includes the Rev 23.2 fix resolving IRREGULAR-section full depth | |
| from [TRANSECTS] GR data. | |
| """ | |
| from __future__ import annotations | |
| import pandas as pd | |
| import numpy as np | |
| def parse_inp_sections(inp_path): | |
| sections = {} | |
| current = None | |
| with open(inp_path, encoding="utf-8", errors="ignore") as f: | |
| for line in f: | |
| line = line.strip() | |
| if not line or line.startswith(";"): | |
| continue | |
| if line.startswith("["): | |
| try: | |
| current = line[1:line.index("]")] | |
| sections[current] = [] | |
| except ValueError: | |
| pass | |
| elif current is not None: | |
| sections[current].append(line.split()) | |
| return sections | |
| def parse_node_types(sections): | |
| types = {} | |
| for sname, ntype in [("JUNCTIONS", "junction"), ("OUTFALLS", "outfall"), | |
| ("STORAGE", "storage"), ("DIVIDERS", "divider")]: | |
| for row in sections.get(sname, []): | |
| if row: | |
| types[row[0]] = ntype | |
| return types | |
| def parse_link_topology(sections): | |
| """Return {link_id: (from_node, to_node, type)} dict.""" | |
| topo = {} | |
| for sname, ltype in [("CONDUITS", "conduit"), ("PUMPS", "pump"), | |
| ("ORIFICES", "orifice"), ("WEIRS", "weir"), ("OUTLETS", "outlet")]: | |
| for row in sections.get(sname, []): | |
| if len(row) >= 3: | |
| topo[row[0]] = (row[1], row[2], ltype) | |
| return topo | |
| def parse_conduit_geometry(sections): | |
| """Return {conduit_id: {length, roughness, xsect_params}} dict.""" | |
| geom = {} | |
| for row in sections.get("CONDUITS", []): | |
| if len(row) >= 6: | |
| try: | |
| geom[row[0]] = {"length": float(row[3]), "roughness": float(row[4])} | |
| except ValueError: | |
| pass | |
| # Full depth of IRREGULAR sections comes from the referenced transect's | |
| # GR rows (max station elevation - min station elevation), matching the | |
| # engine's Cross Section Summary "Full Depth". Previously float() failed | |
| # on the transect NAME in geom1 and the depth silently defaulted to | |
| # 1.0 m downstream, distorting depth ratios for street/overland links. | |
| transect_full_depth = {} | |
| current_transect = None | |
| for row in sections.get("TRANSECTS", []): | |
| tag = str(row[0]).upper() | |
| if tag == "X1" and len(row) >= 2: | |
| current_transect = row[1] | |
| transect_full_depth.setdefault(current_transect, []) | |
| elif tag == "GR" and current_transect is not None: | |
| # GR rows are (elev, station) pairs. | |
| for i in range(1, len(row) - 1, 2): | |
| try: | |
| transect_full_depth[current_transect].append(float(row[i])) | |
| except ValueError: | |
| pass | |
| transect_full_depth = { | |
| name: (max(elevs) - min(elevs)) for name, elevs in transect_full_depth.items() if elevs | |
| } | |
| for row in sections.get("XSECTIONS", []): | |
| if len(row) >= 3 and row[0] in geom: | |
| geom[row[0]]["shape"] = row[1] | |
| if str(row[1]).upper() == "IRREGULAR": | |
| geom[row[0]]["transect"] = row[2] | |
| full = transect_full_depth.get(row[2]) | |
| if full and full > 0: | |
| geom[row[0]]["diameter"] = full | |
| else: | |
| try: | |
| geom[row[0]]["diameter"] = float(row[2]) | |
| except (ValueError, IndexError): | |
| pass | |
| return geom | |
| def parse_subcatchment_attrs(sections): | |
| attrs = {} | |
| for row in sections.get("SUBCATCHMENTS", []): | |
| if len(row) >= 6: | |
| try: | |
| attrs[row[0]] = { | |
| "gage": row[1], | |
| "outlet": row[2], | |
| "area": float(row[3]), | |
| "pct_imp": float(row[4]), | |
| "width": float(row[5]) if len(row) > 5 else 0.0, | |
| "slope": float(row[6]) if len(row) > 6 else 0.0, | |
| } | |
| except (ValueError, IndexError): | |
| pass | |
| return attrs | |
| def parse_gis(sections): | |
| """Extract node coords, link vertices, sub polygons from INP.""" | |
| dims = get_map_dimensions(sections) | |
| # Node coordinates | |
| raw_coords = {} | |
| for row in sections.get("COORDINATES", []): | |
| if len(row) >= 3: | |
| try: | |
| raw_coords[row[0]] = (float(row[1]), float(row[2])) | |
| except ValueError: | |
| pass | |
| node_coords = normalize_coords(raw_coords, dims) | |
| # Link vertices | |
| raw_verts = {} | |
| for row in sections.get("VERTICES", []): | |
| if len(row) >= 3: | |
| try: | |
| raw_verts.setdefault(row[0], []).append((float(row[1]), float(row[2]))) | |
| except ValueError: | |
| pass | |
| # Normalize vertices using same scale | |
| if raw_coords and dims: | |
| xmin, ymin, xmax, ymax = dims | |
| cx = (xmin + xmax) / 2 | |
| cy = (ymin + ymax) / 2 | |
| rx = max(xmax - xmin, 1e-9) | |
| ry = max(ymax - ymin, 1e-9) | |
| scale = 0.005 / max(rx, ry) | |
| elif raw_coords: | |
| xs = [v[0] for v in raw_coords.values()] | |
| ys = [v[1] for v in raw_coords.values()] | |
| cx = (min(xs) + max(xs)) / 2 | |
| cy = (min(ys) + max(ys)) / 2 | |
| scale = 0.005 / max(max(xs) - min(xs), max(ys) - min(ys), 1e-9) | |
| else: | |
| cx, cy, scale = 0, 0, 1 | |
| link_vertices = {} | |
| for lid, verts in raw_verts.items(): | |
| link_vertices[lid] = [((x - cx) * scale, (y - cy) * scale) for x, y in verts] | |
| # Subcatchment polygons | |
| raw_polys = {} | |
| for row in sections.get("Polygons", []): | |
| if len(row) >= 3: | |
| try: | |
| raw_polys.setdefault(row[0], []).append((float(row[1]), float(row[2]))) | |
| except ValueError: | |
| pass | |
| sub_polygons = {} | |
| for sid, pts in raw_polys.items(): | |
| sub_polygons[sid] = [((x - cx) * scale, (y - cy) * scale) for x, y in pts] | |
| return node_coords, link_vertices, sub_polygons | |
| def build_node_summary(node_ts, node_types, flood_thresh, depth_ratio_thresh): | |
| rows = [] | |
| for nid, d in node_ts.items(): | |
| depths = d.get("depth", [0]) | |
| floods = d.get("flooding", [0]) | |
| inflows = d.get("inflow", [0]) | |
| invert = d.get("invert_elevation", 0) | |
| full_d = d.get("full_depth", 1) or 1 | |
| pk_depth = max(depths) if depths else 0 | |
| pk_flood = max(floods) if floods else 0 | |
| pk_inflow = max(inflows) if inflows else 0 | |
| depth_ratio = pk_depth / full_d | |
| if pk_flood > flood_thresh: | |
| status = "🚨 Flooded" | |
| elif depth_ratio > depth_ratio_thresh: | |
| status = "⚠️ Near Capacity" | |
| else: | |
| status = "✅ OK" | |
| rows.append({ | |
| "Node ID": nid, | |
| "Type": node_types.get(nid, "junction"), | |
| "Invert (m)": round(invert, 3), | |
| "Full Depth (m)": round(full_d, 3), | |
| "Peak Depth (m)": round(pk_depth, 4), | |
| "Depth Ratio": round(depth_ratio, 3), | |
| "Peak Flooding (m³/s)": round(pk_flood, 6), | |
| "Peak Inflow (m³/s)": round(pk_inflow, 6), | |
| "Status": status, | |
| }) | |
| return pd.DataFrame(rows) | |
| def build_link_summary(link_ts, link_topo, conduit_geom, depth_ratio_thresh, vel_thresh): | |
| rows = [] | |
| for lid, d in link_ts.items(): | |
| flows = d.get("flow", [0]) | |
| depths = d.get("depth", [0]) | |
| velocities = d.get("velocity", [0]) | |
| topo = link_topo.get(lid, ("?", "?", "conduit")) | |
| geom = conduit_geom.get(lid, {}) | |
| pk_flow = max(flows) if flows else 0 | |
| pk_depth = max(depths) if depths else 0 | |
| pk_velocity = max((abs(v) for v in velocities), default=0) | |
| diam = geom.get("diameter", 1) or 1 | |
| length = geom.get("length", 0) | |
| depth_ratio = pk_depth / diam | |
| if depth_ratio >= 1.0: | |
| status = "Pressurised" | |
| elif depth_ratio > depth_ratio_thresh: | |
| status = "Surcharging" | |
| elif pk_velocity > vel_thresh: | |
| status = "High Velocity" | |
| elif depth_ratio > 0.5: | |
| status = "Filling" | |
| else: | |
| status = "Free-flow" | |
| rows.append({ | |
| "Link ID": lid, | |
| "Type": topo[2], | |
| "From Node": topo[0], | |
| "To Node": topo[1], | |
| "Length (m)": round(length, 1), | |
| "Diameter (m)": round(diam, 3), | |
| "Peak Flow (m³/s)": round(pk_flow, 6), | |
| "Peak Depth (m)": round(pk_depth, 4), | |
| "Depth Ratio": round(depth_ratio, 3), | |
| "Peak Velocity (m/s)": round(pk_velocity, 3), | |
| "Status": status, | |
| }) | |
| return pd.DataFrame(rows) | |
| def build_sub_summary(sub_ts, sub_attrs, times=None, flow_units="CMS"): | |
| """Build subcatchment summary with time-integrated runoff volume. | |
| Values remain in the SWMM model unit system. The legacy internal column name | |
| ``Total Runoff (m³)`` is retained for database compatibility, but its value is | |
| flow integrated over time in the native flow-volume basis (e.g., ft³ for CFS, | |
| m³ for CMS, litres for LPS). The report engine assigns the correct label. | |
| """ | |
| rows = [] | |
| flow_units = str(flow_units or "CMS").upper() | |
| def _integrate(values, timestamps): | |
| if not values: | |
| return 0.0 | |
| if timestamps and len(timestamps) == len(values) and len(values) > 1: | |
| total = 0.0 | |
| for i in range(1, len(values)): | |
| try: | |
| dt = (timestamps[i] - timestamps[i - 1]).total_seconds() | |
| except Exception: | |
| dt = 0.0 | |
| if dt > 0: | |
| total += 0.5 * (float(values[i - 1]) + float(values[i])) * dt | |
| return total | |
| return float(sum(values)) | |
| def _rain_depth(values, timestamps): | |
| # Rainfall is an intensity (in/hr for US, mm/hr for SI). | |
| return _integrate(values, timestamps) / 3600.0 | |
| for sid, d in sub_ts.items(): | |
| runoffs = d.get("runoff", [0]) | |
| rainfalls = d.get("rainfall", [0]) | |
| attrs = sub_attrs.get(sid, {}) | |
| area = d.get("area", attrs.get("area", 0)) | |
| pct_imp = d.get("pct_imp", attrs.get("pct_imp", 0)) | |
| pk_runoff = max(runoffs) if runoffs else 0 | |
| pk_rainfall = max(rainfalls) if rainfalls else 0 | |
| integrated_flow_seconds = _integrate(runoffs, times) | |
| total_rain_depth = _rain_depth(rainfalls, times) | |
| # Convert integrated native flow to the native report volume basis. | |
| if flow_units == "CFS": | |
| total_runoff_vol = integrated_flow_seconds # ft³ | |
| runoff_depth = (total_runoff_vol / (area * 43560.0) * 12.0) if area > 0 else 0.0 | |
| elif flow_units == "CMS": | |
| total_runoff_vol = integrated_flow_seconds # m³ | |
| runoff_depth = (total_runoff_vol / (area * 10000.0) * 1000.0) if area > 0 else 0.0 | |
| elif flow_units == "LPS": | |
| total_runoff_vol = integrated_flow_seconds # litres | |
| runoff_depth = ((total_runoff_vol / 1000.0) / (area * 10000.0) * 1000.0) if area > 0 else 0.0 | |
| else: | |
| total_runoff_vol = integrated_flow_seconds | |
| runoff_depth = 0.0 | |
| rc = (runoff_depth / total_rain_depth) if total_rain_depth > 0 else 0.0 | |
| rows.append({ | |
| "Sub ID": sid, | |
| "Area (ha)": round(area, 3), | |
| "% Impervious": round(pct_imp, 1), | |
| "Connected To": attrs.get("outlet", "?"), | |
| "Peak Runoff (m³/s)": round(pk_runoff, 6), | |
| "Total Runoff (m³)": round(total_runoff_vol, 3), | |
| "Peak Rainfall (mm/h)": round(pk_rainfall, 4), | |
| "Total Rainfall Depth": round(total_rain_depth, 4), | |
| "Runoff Depth": round(runoff_depth, 4), | |
| "Runoff Coefficient": round(rc, 3), | |
| }) | |
| return pd.DataFrame(rows) | |