swmm-builder
Assemble a runnable SWMM INP deterministically from subcatchment geometry/attributes, merged parameter JSON, network JSON, and climate references. Use when creating auditable INP + manifest artifacts for downstream swmm-runner/calibration.
Install
npx skills add https://github.com/Zhonghao1995/agentic-swmm-workflow/tree/main/skills/swmm-builder
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install zhonghao1995-agentic-swmm-workflow@llmmart
git clone https://github.com/Zhonghao1995/agentic-swmm-workflow.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole zhonghao1995/agentic-swmm-workflow collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
SWMM Builder (INP assembly layer)
Part of Agentic SWMM — install the project first for the executable toolchain (aiswmm CLI, SWMM solver, MCP servers).
Contract
Build a runnable SWMM .inp using explicit file inputs:
subcatchments.csv(shape/area/outlet/routing basics)- merged params JSON from
swmm-params - network JSON from
swmm-network - rainfall/time-series references from
swmm-climate - optional options config JSON
The builder writes:
- final SWMM INP text (
--out-inp) - manifest JSON (
--out-manifest) with source paths + SHA256 + key metadata - strict validation diagnostics for critical sections (
[OPTIONS],[RAINGAGES],[TIMESERIES],[SUBCATCHMENTS],[SUBAREAS],[INFILTRATION], and current network sections)
Inputs
Subcatchments CSV schema (required)
Required columns:
subcatchment_idoutletarea_hawidth_mslope_pct
Optional columns:
rain_gage(falls back to default gage from climate/config)curb_length_m(default0)snow_pack(default blank)
Params JSON (required)
Expected to match skills/swmm-params/scripts/merge_swmm_params.py output:
sections.subcatchments(id,pct_imperv)sections.subareas(id, runoff/subarea fields)sections.infiltration(id, Green-Ampt fields)- Required fields are now validated strictly with type/range checks (for example
%Impervand routing percentages must be0..100).
Network JSON (required)
Expected to match skills/swmm-network schema (junctions, outfalls, conduits, etc.).
Builder now validates required network fields used to emit [JUNCTIONS], [OUTFALLS], [CONDUITS], [XSECTIONS], [COORDINATES], and [VERTICES].
Climate references (required in MVP)
Provide either:
--timeseries-textdirectly, or--rainfall-jsonproduced byswmm-climate/format_rainfall.py(must includeoutputs.timeseries_text)
For [RAINGAGES], provide either:
--raingage-jsonfromswmm-climate/build_raingage_section.py, or- rely on default deterministic gage generation from rainfall
series_name.
--raingage-json supports both the original single-gage form and a multi-gage form:
{
"gages": [
{
"id": "RG1",
"rain_format": "VOLUME",
"interval_min": 5,
"scf": 1.0,
"source": {"kind": "TIMESERIES", "series_name": "TS_RG1"}
},
{
"id": "RG2",
"rain_format": "VOLUME",
"interval_min": 5,
"scf": 1.0,
"source": {"kind": "TIMESERIES", "series_name": "TS_RG2"}
}
]
}
The timeseries text must include rows for every referenced series_name, and each subcatchment rain_gage must reference one of the emitted gage IDs.
Validation behavior:
- Missing critical fields fail fast with explicit section-scoped errors.
[TIMESERIES]rows are validated for series-name consistency and basic token/time/value correctness.- Manifest includes
validationplusvalidation_diagnosticsmetadata.
Scripts
scripts/build_swmm_inp.py- single entrypoint that reads all inputs and writes INP + manifest.
MCP
MCP wrapper location:
mcp/swmm-builder/server.js(was previouslyskills/swmm-builder/scripts/mcp/; moved during the mcp/ root restructure)
Exposed tools:
build_inp— assemble a runnable SWMM INP + manifest from subcatchments CSV, area-weighted params JSON, network JSON, rainfall JSON + timeseries text, and an options-config JSON. Required args:subcatchmentsCsvPath,paramsJsonPath,networkJsonPath,outInpPath,outManifestPath. Optional:rainfallJsonPath,raingageJsonPath,timeseriesTextPath,configJsonPath,defaultGageId,waterQualityJsonPath(path to a WQ config JSON — enables [POLLUTANTS]/[LANDUSES]/[BUILDUP]/[WASHOFF]/[COVERAGES]/ [LOADINGS] sections for pollutant buildup/washoff simulation). The subcatchments CSV must carryoutletvalues that point to real upstream junctions (useswmm-network-mcp.assign_subcatchment_outletsfirst if it currently points to the literal outfall).
Smoke example
The builder requires upstream outputs from swmm-params and swmm-climate.
Generate them first, then call build_swmm_inp.py:
# 1. Generate merged params from the bundled examples
mkdir -p /tmp/builder-smoke
python3 skills/swmm-params/scripts/landuse_to_swmm_params.py \
--input skills/swmm-params/examples/landuse_input.csv \
--output /tmp/builder-smoke/landuse.json
python3 skills/swmm-params/scripts/soil_to_greenampt.py \
--input skills/swmm-params/examples/soil_input.csv \
--output /tmp/builder-smoke/soil.json
python3 skills/swmm-params/scripts/merge_swmm_params.py \
--landuse-json /tmp/builder-smoke/landuse.json \
--soil-json /tmp/builder-smoke/soil.json \
--output /tmp/builder-smoke/merged_params.json
# 2. Format rainfall from the bundled climate example
python3 skills/swmm-climate/scripts/format_rainfall.py \
--input skills/swmm-climate/examples/rainfall_event.csv \
--out-json /tmp/builder-smoke/rainfall.json \
--out-timeseries /tmp/builder-smoke/rainfall_timeseries.txt
# 3. Build the INP
python3 skills/swmm-builder/scripts/build_swmm_inp.py \
--subcatchments-csv skills/swmm-builder/examples/subcatchments_input.csv \
--params-json /tmp/builder-smoke/merged_params.json \
--network-json skills/swmm-network/examples/basic-network.json \
--rainfall-json /tmp/builder-smoke/rainfall.json \
--config-json skills/swmm-builder/examples/options_config.json \
--out-inp /tmp/builder-smoke/example_model.inp \
--out-manifest /tmp/builder-smoke/example_manifest.json
Water quality sections
Pass --water-quality-json <path> to enable pollutant buildup/washoff
simulation. The JSON must satisfy the schema in
skills/swmm-water-quality/SKILL.md. Omit the flag for pure hydrology runs
(Water Quality: NO in the INP).
# Build an INP with water quality (TSS, 1 landuse, executed example):
python3 skills/swmm-builder/scripts/build_swmm_inp.py \
--subcatchments-csv skills/swmm-builder/examples/subcatchments_input.csv \
--params-json /tmp/builder-smoke/merged_params.json \
--network-json skills/swmm-network/examples/basic-network.json \
--water-quality-json /tmp/wq_example.json \
--out-inp /tmp/wq_model.inp \
--out-manifest /tmp/wq_manifest.json
The builder validates all cross-references (pollutant/landuse/subcatchment consistency) before writing the INP.
Known limitations
- No automatic polygon export, LID controls, snowpack, or RTC rules.
- Assumes one raingage source for all subcatchments unless
rain_gageis explicitly set per row.
Files (agentic-swmm-workflow)
-
examples
-
options_config.json 605 B
{ "title": "swmm-builder minimal assembled model", "options": { "FLOW_UNITS": "CMS", "INFILTRATION": "GREEN_AMPT", "FLOW_ROUTING": "DYNWAVE", "START_DATE": "06/01/2025", "START_TIME": "00:00:00", "REPORT_START_DATE": "06/01/2025", "REPORT_START_TIME": "00:00:00", "END_DATE": "06/01/2025", "END_TIME": "01:00:00", "REPORT_STEP": "00:05:00", "WET_STEP": "00:01:00", "DRY_STEP": "01:00:00", "ROUTING_STEP": "00:00:30" }, "report": { "INPUT": "NO", "CONTROLS": "NO", "SUBCATCHMENTS": "ALL", "NODES": "ALL", "LINKS": "ALL" } } -
subcatchments_input.csv 143 B · in bundle
-
-
scripts
-
build_swmm_inp.py 68.6 KB
#!/usr/bin/env python3 from __future__ import annotations import argparse import csv from datetime import datetime import hashlib import json from pathlib import Path from typing import Any # --------------------------------------------------------------------------- # Water quality validation helpers (inline — no external deps) # --------------------------------------------------------------------------- _WQ_UNITS = {"MG/L", "UG/L", "#/L"} _BUILDUP_FUNCS = {"POW", "EXP", "SAT"} # EXT excluded in v1 (see validate_wq_config) _WASHOFF_FUNCS = {"EXP", "RC", "EMC"} _NORMALIZERS = {"AREA", "CURBLENGTH"} def validate_wq_config(wq: dict[str, Any], *, known_subcatchment_ids: set[str] | None = None) -> None: """Validate cross-references and enum/range constraints in the WQ JSON. Raises ValueError with a descriptive message on the first constraint violation found. Designed so that ``build_swmm_inp.py`` and the standalone ``validate_wq_config.py`` script share identical logic (the script imports/duplicates this function). Parameters ---------- wq: Parsed water-quality config object (the JSON root dict). known_subcatchment_ids: Optional set of subcatchment IDs for referential checks on [COVERAGES] and [LOADINGS]. Pass ``None`` to skip those checks. """ subs = known_subcatchment_ids or set() # ---- collect defined names ---- pollutant_names: set[str] = set() for i, p in enumerate(wq.get("pollutants", []), start=1): name = require_non_blank_string(p.get("name"), field="name", context=f"[POLLUTANTS] entry {i}") if " " in name: raise ValueError(f"[POLLUTANTS] entry {i} 'name' must not contain spaces, got '{name}'") if name in pollutant_names: raise ValueError(f"[POLLUTANTS] duplicate pollutant name '{name}'") pollutant_names.add(name) units_raw = require_non_blank_string(p.get("units"), field="units", context=f"[POLLUTANTS] entry {i}") if units_raw.upper() not in _WQ_UNITS: raise ValueError(f"[POLLUTANTS] entry {i} 'units' must be one of {sorted(_WQ_UNITS)}, got '{units_raw}'") landuse_names: set[str] = set() for i, lu in enumerate(wq.get("landuses", []), start=1): name = require_non_blank_string(lu.get("name"), field="name", context=f"[LANDUSES] entry {i}") if name in landuse_names: raise ValueError(f"[LANDUSES] duplicate land-use name '{name}'") landuse_names.add(name) # ---- coverages ---- coverage_sums: dict[str, float] = {} for i, cov in enumerate(wq.get("coverages", []), start=1): ctx = f"[COVERAGES] entry {i}" sub = require_non_blank_string(cov.get("subcatchment"), field="subcatchment", context=ctx) lu = require_non_blank_string(cov.get("landuse"), field="landuse", context=ctx) pct = require_number(cov.get("percent"), field="percent", context=ctx, min_value=0.0, max_value=100.0) if subs and sub not in subs: raise ValueError(f"{ctx} 'subcatchment' '{sub}' not found in subcatchments") if lu not in landuse_names: raise ValueError(f"{ctx} 'landuse' '{lu}' not defined in [LANDUSES]") coverage_sums[sub] = coverage_sums.get(sub, 0.0) + pct for sub, total in coverage_sums.items(): if total > 100.0 + 1e-9: raise ValueError(f"[COVERAGES] subcatchment '{sub}' coverage percents sum to {total:.2f}, must be <= 100") # ---- buildup ---- for i, bu in enumerate(wq.get("buildup", []), start=1): ctx = f"[BUILDUP] entry {i}" lu = require_non_blank_string(bu.get("landuse"), field="landuse", context=ctx) pol = require_non_blank_string(bu.get("pollutant"), field="pollutant", context=ctx) ft = require_non_blank_string(bu.get("func_type"), field="func_type", context=ctx).upper() norm = require_non_blank_string(bu.get("normalizer", "AREA"), field="normalizer", context=ctx).upper() if lu not in landuse_names: raise ValueError(f"{ctx} 'landuse' '{lu}' not defined in [LANDUSES]") if pol not in pollutant_names: raise ValueError(f"{ctx} 'pollutant' '{pol}' not defined in [POLLUTANTS]") if ft == "EXT": raise ValueError( f"{ctx} FuncType 'EXT' (external time series) is not supported in v1. " "Use POW, EXP, or SAT." ) if ft not in _BUILDUP_FUNCS: raise ValueError(f"{ctx} 'func_type' must be one of {sorted(_BUILDUP_FUNCS)}, got '{ft}'") if norm not in _NORMALIZERS: raise ValueError(f"{ctx} 'normalizer' must be AREA or CURBLENGTH, got '{norm}'") # ---- washoff ---- for i, wo in enumerate(wq.get("washoff", []), start=1): ctx = f"[WASHOFF] entry {i}" lu = require_non_blank_string(wo.get("landuse"), field="landuse", context=ctx) pol = require_non_blank_string(wo.get("pollutant"), field="pollutant", context=ctx) ft = require_non_blank_string(wo.get("func_type"), field="func_type", context=ctx).upper() if lu not in landuse_names: raise ValueError(f"{ctx} 'landuse' '{lu}' not defined in [LANDUSES]") if pol not in pollutant_names: raise ValueError(f"{ctx} 'pollutant' '{pol}' not defined in [POLLUTANTS]") if ft not in _WASHOFF_FUNCS: raise ValueError(f"{ctx} 'func_type' must be one of {sorted(_WASHOFF_FUNCS)}, got '{ft}'") require_number(wo.get("sweep_removal", 0.0), field="sweep_removal", context=ctx, min_value=0.0, max_value=1.0) require_number(wo.get("bmp_removal", 0.0), field="bmp_removal", context=ctx, min_value=0.0, max_value=1.0) # ---- loadings ---- for i, lo in enumerate(wq.get("loadings", []), start=1): ctx = f"[LOADINGS] entry {i}" sub = require_non_blank_string(lo.get("subcatchment"), field="subcatchment", context=ctx) pol = require_non_blank_string(lo.get("pollutant"), field="pollutant", context=ctx) if subs and sub not in subs: raise ValueError(f"{ctx} 'subcatchment' '{sub}' not found in subcatchments") if pol not in pollutant_names: raise ValueError(f"{ctx} 'pollutant' '{pol}' not defined in [POLLUTANTS]") require_number(lo.get("init_buildup"), field="init_buildup", context=ctx, min_value=0.0) def load_json(path: Path) -> Any: return json.loads(path.read_text(encoding="utf-8")) def write_json(path: Path, obj: Any) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(obj, indent=2), encoding="utf-8") def write_text(path: Path, text: str) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(text, encoding="utf-8") def sha256_file(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as f: for chunk in iter(lambda: f.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def format_num(value: Any) -> str: if value is None: return "" if isinstance(value, bool): return "YES" if value else "NO" if isinstance(value, int): return str(value) if isinstance(value, float): return f"{value:.6f}".rstrip("0").rstrip(".") return str(value) def require_non_blank_string(value: Any, *, field: str, context: str) -> str: if value is None: raise ValueError(f"{context} missing required field '{field}'") text = str(value).strip() if not text: raise ValueError(f"{context} field '{field}' must be a non-blank string") return text def require_number( value: Any, *, field: str, context: str, min_value: float | None = None, max_value: float | None = None, min_inclusive: bool = True, max_inclusive: bool = True, ) -> float: if value is None: raise ValueError(f"{context} missing required numeric field '{field}'") parsed: float if isinstance(value, bool): raise ValueError(f"{context} field '{field}' must be numeric, got boolean {value}") if isinstance(value, (int, float)): parsed = float(value) elif isinstance(value, str): raw = value.strip() if not raw: raise ValueError(f"{context} field '{field}' must be numeric, got blank string") try: parsed = float(raw) except ValueError as exc: raise ValueError(f"{context} field '{field}' must be numeric, got: {value}") from exc else: raise ValueError(f"{context} field '{field}' must be numeric, got type: {type(value).__name__}") if min_value is not None: if min_inclusive and parsed < min_value: raise ValueError(f"{context} field '{field}' must be >= {min_value}, got {parsed}") if not min_inclusive and parsed <= min_value: raise ValueError(f"{context} field '{field}' must be > {min_value}, got {parsed}") if max_value is not None: if max_inclusive and parsed > max_value: raise ValueError(f"{context} field '{field}' must be <= {max_value}, got {parsed}") if not max_inclusive and parsed >= max_value: raise ValueError(f"{context} field '{field}' must be < {max_value}, got {parsed}") return parsed def require_int( value: Any, *, field: str, context: str, min_value: int | None = None, max_value: int | None = None, ) -> int: parsed_float = require_number(value, field=field, context=context) if not parsed_float.is_integer(): raise ValueError(f"{context} field '{field}' must be an integer, got {parsed_float}") parsed = int(parsed_float) if min_value is not None and parsed < min_value: raise ValueError(f"{context} field '{field}' must be >= {min_value}, got {parsed}") if max_value is not None and parsed > max_value: raise ValueError(f"{context} field '{field}' must be <= {max_value}, got {parsed}") return parsed def normalize_yes_no(value: Any, *, field: str, context: str) -> str: if isinstance(value, bool): return "YES" if value else "NO" token = require_non_blank_string(value, field=field, context=context).upper() if token in {"YES", "TRUE"}: return "YES" if token in {"NO", "FALSE"}: return "NO" raise ValueError(f"{context} field '{field}' must be YES/NO or boolean, got {value}") def parse_clock_time(value: str, *, field: str, context: str, max_hour: int | None = None) -> int: token = require_non_blank_string(value, field=field, context=context) parts = token.split(":") if len(parts) not in {2, 3}: raise ValueError(f"{context} field '{field}' must be HH:MM or HH:MM:SS, got {token}") if not all(part.isdigit() for part in parts): raise ValueError(f"{context} field '{field}' must be HH:MM or HH:MM:SS, got {token}") hh = int(parts[0]) mm = int(parts[1]) ss = int(parts[2]) if len(parts) == 3 else 0 if hh < 0 or mm < 0 or ss < 0: raise ValueError(f"{context} field '{field}' must not include negative values, got {token}") if mm > 59 or ss > 59: raise ValueError(f"{context} field '{field}' has invalid minute/second values, got {token}") if max_hour is not None and hh > max_hour: raise ValueError(f"{context} field '{field}' hour must be <= {max_hour}, got {token}") return hh * 3600 + mm * 60 + ss def validate_mmddyyyy(value: Any, *, field: str, context: str) -> str: token = require_non_blank_string(value, field=field, context=context) try: datetime.strptime(token, "%m/%d/%Y") except ValueError as exc: raise ValueError(f"{context} field '{field}' must be mm/dd/yyyy, got {token}") from exc return token def validate_mmdd(value: Any, *, field: str, context: str) -> str: token = require_non_blank_string(value, field=field, context=context) try: datetime.strptime(token, "%m/%d") except ValueError as exc: raise ValueError(f"{context} field '{field}' must be mm/dd, got {token}") from exc return token def validate_step_time(value: Any, *, field: str, context: str) -> str: token = require_non_blank_string(value, field=field, context=context) seconds = parse_clock_time(token, field=field, context=context) if seconds <= 0: raise ValueError(f"{context} field '{field}' must be greater than 00:00:00, got {token}") return token def require_object(value: Any, *, context: str) -> dict[str, Any]: if not isinstance(value, dict): raise ValueError(f"{context} must be a JSON object") return value def require_list(value: Any, *, context: str) -> list[Any]: if not isinstance(value, list): raise ValueError(f"{context} must be a JSON array") return value def read_subcatchments_csv(path: Path) -> dict[str, dict[str, Any]]: with path.open("r", encoding="utf-8", newline="") as f: rows = list(csv.DictReader(f)) if not rows: raise ValueError(f"[SUBCATCHMENTS] CSV has no rows: {path}") required = ["subcatchment_id", "outlet", "area_ha", "width_m", "slope_pct"] for col in required: if col not in rows[0]: raise ValueError(f"[SUBCATCHMENTS] missing required column '{col}' in {path}") out: dict[str, dict[str, Any]] = {} for row_num, row in enumerate(rows, start=2): context = f"[SUBCATCHMENTS] CSV row {row_num} ({path})" subcatchment_id = require_non_blank_string(row.get("subcatchment_id"), field="subcatchment_id", context=context) if subcatchment_id in out: raise ValueError(f"[SUBCATCHMENTS] duplicate 'subcatchment_id' {subcatchment_id} in {path}") outlet = require_non_blank_string(row.get("outlet"), field="outlet", context=context) area_ha = require_number(row.get("area_ha"), field="area_ha", context=context, min_value=0.0, min_inclusive=False) width_m = require_number(row.get("width_m"), field="width_m", context=context, min_value=0.0, min_inclusive=False) slope_pct = require_number(row.get("slope_pct"), field="slope_pct", context=context, min_value=0.0) rain_gage_raw = row.get("rain_gage") rain_gage = None if rain_gage_raw is not None and str(rain_gage_raw).strip(): rain_gage = require_non_blank_string(rain_gage_raw, field="rain_gage", context=context) curb_length = 0.0 curb_raw = row.get("curb_length_m") if curb_raw is not None and str(curb_raw).strip(): curb_length = require_number(curb_raw, field="curb_length_m", context=context, min_value=0.0) snow_pack_raw = row.get("snow_pack") snow_pack = str(snow_pack_raw).strip() if snow_pack_raw is not None else "" rec = { "id": subcatchment_id, "outlet": outlet, "area_ha": area_ha, "width_m": width_m, "slope_pct": slope_pct, "curb_length_m": curb_length, "snow_pack": snow_pack, "rain_gage": rain_gage, } out[subcatchment_id] = rec return out def index_by_id(entries: list[Any], *, section: str, source_path: Path) -> dict[str, dict[str, Any]]: out: dict[str, dict[str, Any]] = {} for idx, entry in enumerate(entries, start=1): if not isinstance(entry, dict): raise ValueError(f"[{section}] entry {idx} in {source_path} must be an object") raw_id = entry.get("id") if raw_id is None: raise ValueError(f"[{section}] entry {idx} in {source_path} is missing required field 'id'") key = str(raw_id).strip() if not key: raise ValueError(f"[{section}] entry {idx} in {source_path} has blank 'id'") if key in out: raise ValueError(f"[{section}] duplicate id '{key}' in {source_path}") out[key] = entry return out def load_params_sections(path: Path) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]: obj = require_object(load_json(path), context=f"Params JSON {path}") sections = require_object(obj.get("sections"), context=f"Params JSON {path} field 'sections'") subcatchments_entries = require_list(sections.get("subcatchments"), context=f"Params JSON {path} sections.subcatchments") subareas_entries = require_list(sections.get("subareas"), context=f"Params JSON {path} sections.subareas") infiltration_entries = require_list(sections.get("infiltration"), context=f"Params JSON {path} sections.infiltration") subcatchments = index_by_id(subcatchments_entries, section="SUBCATCHMENTS", source_path=path) subareas = index_by_id(subareas_entries, section="SUBAREAS", source_path=path) infiltration = index_by_id(infiltration_entries, section="INFILTRATION", source_path=path) return subcatchments, subareas, infiltration def validate_and_normalize_params( params_subcatchments: dict[str, dict[str, Any]], params_subareas: dict[str, dict[str, Any]], params_infiltration: dict[str, dict[str, Any]], ) -> tuple[dict[str, dict[str, Any]], dict[str, dict[str, Any]], dict[str, dict[str, Any]]]: out_subcatchments: dict[str, dict[str, Any]] = {} for subcatchment_id, p in params_subcatchments.items(): context = f"[SUBCATCHMENTS] params for id '{subcatchment_id}'" pct_imperv = require_number(p.get("pct_imperv"), field="pct_imperv", context=context, min_value=0.0, max_value=100.0) out_subcatchments[subcatchment_id] = {"id": subcatchment_id, "pct_imperv": pct_imperv} out_subareas: dict[str, dict[str, Any]] = {} for subcatchment_id, p in params_subareas.items(): context = f"[SUBAREAS] params for id '{subcatchment_id}'" n_imperv = require_number(p.get("n_imperv"), field="n_imperv", context=context, min_value=0.0) n_perv = require_number(p.get("n_perv"), field="n_perv", context=context, min_value=0.0) dstore_imperv_in = require_number(p.get("dstore_imperv_in"), field="dstore_imperv_in", context=context, min_value=0.0) dstore_perv_in = require_number(p.get("dstore_perv_in"), field="dstore_perv_in", context=context, min_value=0.0) zero_imperv_pct = require_number( p.get("zero_imperv_pct"), field="zero_imperv_pct", context=context, min_value=0.0, max_value=100.0, ) route_to = require_non_blank_string(p.get("route_to"), field="route_to", context=context).upper() if route_to not in {"OUTLET", "PERVIOUS", "IMPERVIOUS"}: raise ValueError( f"{context} field 'route_to' must be one of OUTLET/PERVIOUS/IMPERVIOUS, got {p.get('route_to')}" ) pct_routed = require_number(p.get("pct_routed"), field="pct_routed", context=context, min_value=0.0, max_value=100.0) out_subareas[subcatchment_id] = { "id": subcatchment_id, "n_imperv": n_imperv, "n_perv": n_perv, "dstore_imperv_in": dstore_imperv_in, "dstore_perv_in": dstore_perv_in, "zero_imperv_pct": zero_imperv_pct, "route_to": route_to, "pct_routed": pct_routed, } out_infiltration: dict[str, dict[str, Any]] = {} for subcatchment_id, p in params_infiltration.items(): context = f"[INFILTRATION] params for id '{subcatchment_id}'" suction_mm = require_number(p.get("suction_mm"), field="suction_mm", context=context, min_value=0.0, min_inclusive=False) ksat_mm_per_hr = require_number( p.get("ksat_mm_per_hr"), field="ksat_mm_per_hr", context=context, min_value=0.0, min_inclusive=False, ) imdmax = require_number(p.get("imdmax"), field="imdmax", context=context, min_value=0.0, max_value=1.0) out_infiltration[subcatchment_id] = { "id": subcatchment_id, "suction_mm": suction_mm, "ksat_mm_per_hr": ksat_mm_per_hr, "imdmax": imdmax, } return out_subcatchments, out_subareas, out_infiltration def parse_timeseries_body(text: str) -> list[str]: body: list[str] = [] for line in text.splitlines(): stripped = line.strip() if not stripped: continue if stripped.startswith("[TIMESERIES]"): continue body.append(line.rstrip()) if not body: raise ValueError("[TIMESERIES] text is empty") return body def parse_timeseries_date(value: str, *, line_number: int) -> datetime: for fmt in ("%m/%d/%Y", "%Y-%m-%d"): try: return datetime.strptime(value, fmt) except ValueError: continue raise ValueError( f"[TIMESERIES] line {line_number} has invalid date '{value}' (expected mm/dd/yyyy or yyyy-mm-dd)" ) def validate_timeseries_body(timeseries_body: list[str], *, expected_series_names: set[str]) -> dict[str, Any]: data_rows = 0 comment_rows = 0 previous_ts_by_series: dict[str, datetime] = {} datetime_rows = 0 series_counts: dict[str, int] = {} for idx, raw_line in enumerate(timeseries_body, start=1): stripped = raw_line.strip() if not stripped or stripped.startswith(";;"): if stripped.startswith(";;"): comment_rows += 1 continue parts = stripped.split() if len(parts) not in {3, 4}: raise ValueError( f"[TIMESERIES] line {idx} must have 3 or 4 tokens (name time value | name date time value), got: {stripped}" ) series_name = parts[0] if series_name not in expected_series_names: raise ValueError( f"[TIMESERIES] line {idx} series '{series_name}' is not referenced by any raingage source series " f"{sorted(expected_series_names)}" ) if len(parts) == 4: date_token = parts[1] time_token = parts[2] value_token = parts[3] dt_date = parse_timeseries_date(date_token, line_number=idx) seconds = parse_clock_time(time_token, field="time", context=f"[TIMESERIES] line {idx}", max_hour=23) current_ts = dt_date.replace(hour=seconds // 3600, minute=(seconds % 3600) // 60, second=seconds % 60) datetime_rows += 1 previous_ts = previous_ts_by_series.get(series_name) if previous_ts is not None and current_ts < previous_ts: raise ValueError( f"[TIMESERIES] line {idx} datetime {current_ts.isoformat()} for series '{series_name}' " f"is earlier than previous line datetime {previous_ts.isoformat()}" ) previous_ts_by_series[series_name] = current_ts else: time_token = parts[1] value_token = parts[2] parse_clock_time(time_token, field="time", context=f"[TIMESERIES] line {idx}", max_hour=23) require_number(value_token, field="value", context=f"[TIMESERIES] line {idx}") data_rows += 1 series_counts[series_name] = series_counts.get(series_name, 0) + 1 if data_rows == 0: raise ValueError("[TIMESERIES] must include at least one data row") missing_series = sorted(expected_series_names - set(series_counts)) if missing_series: raise ValueError(f"[TIMESERIES] missing data rows for raingage source series: {missing_series}") return { "rows_total": len(timeseries_body), "rows_data": data_rows, "rows_comments": comment_rows, "rows_with_datetime": datetime_rows, "series_counts": series_counts, } def normalize_raingage( gage: dict[str, Any], *, source_label: str, rainfall_series_name: str | None, ) -> dict[str, Any]: context = f"[RAINGAGES] ({source_label})" gage_id = require_non_blank_string(gage.get("id"), field="id", context=context) rain_format = require_non_blank_string(gage.get("rain_format"), field="rain_format", context=context).upper() if rain_format not in {"INTENSITY", "VOLUME", "CUMULATIVE"}: raise ValueError( f"{context} field 'rain_format' must be one of INTENSITY/VOLUME/CUMULATIVE, got {gage.get('rain_format')}" ) interval_min = require_int(gage.get("interval_min"), field="interval_min", context=context, min_value=1, max_value=1440) scf = require_number(gage.get("scf"), field="scf", context=context, min_value=0.0, min_inclusive=False) source = require_object(gage.get("source"), context=f"{context} field 'source'") source_kind = require_non_blank_string(source.get("kind"), field="kind", context=f"{context} source").upper() if source_kind != "TIMESERIES": raise ValueError(f"{context} source.kind must be 'TIMESERIES', got {source.get('kind')}") series_name = require_non_blank_string(source.get("series_name"), field="series_name", context=f"{context} source") if rainfall_series_name is not None and series_name != rainfall_series_name: raise ValueError( f"{context} source.series_name '{series_name}' does not match rainfall series_name '{rainfall_series_name}'" ) return { "id": gage_id, "rain_format": rain_format, "interval_min": interval_min, "scf": scf, "source": { "kind": "TIMESERIES", "series_name": series_name, }, } def load_climate( *, rainfall_json_path: Path | None, raingage_json_path: Path | None, explicit_timeseries_text: Path | None, default_gage_id: str, ) -> tuple[list[dict[str, Any]], list[str], Path, dict[str, Any]]: default_gage_id = require_non_blank_string(default_gage_id, field="default_gage_id", context="CLI") rainfall_obj: dict[str, Any] | None = None rainfall_series_name: str | None = None rainfall_interval_min: int | None = None if rainfall_json_path is not None: rainfall_obj = require_object(load_json(rainfall_json_path), context=f"Rainfall JSON {rainfall_json_path}") series_value = rainfall_obj.get("series_name") if series_value is not None: rainfall_series_name = require_non_blank_string( series_value, field="series_name", context=f"Rainfall JSON {rainfall_json_path}", ) rainfall_range = rainfall_obj.get("range") if isinstance(rainfall_range, dict) and rainfall_range.get("interval_minutes") is not None: rainfall_interval_min = require_int( rainfall_range.get("interval_minutes"), field="range.interval_minutes", context=f"Rainfall JSON {rainfall_json_path}", min_value=1, ) timeseries_path: Path | None = None if explicit_timeseries_text is not None: timeseries_path = explicit_timeseries_text elif rainfall_obj is not None: out_paths = rainfall_obj.get("outputs") if not isinstance(out_paths, dict): raise ValueError( f"Rainfall JSON {rainfall_json_path} is missing required object field 'outputs' to resolve timeseries_text" ) candidate = out_paths.get("timeseries_text") if candidate: timeseries_path = Path(str(candidate)) if timeseries_path is None: raise ValueError("Timeseries source is required. Use --timeseries-text or --rainfall-json with outputs.timeseries_text") if not timeseries_path.exists(): raise ValueError(f"Timeseries text not found: {timeseries_path}") default_series_name = rainfall_series_name or "TS_RAIN" gages: list[dict[str, Any]] if raingage_json_path is not None: gage_obj = require_object(load_json(raingage_json_path), context=f"Raingage JSON {raingage_json_path}") if "gages" in gage_obj: raw_gages = require_list(gage_obj.get("gages"), context=f"Raingage JSON {raingage_json_path} field 'gages'") else: raw_gages = [require_object(gage_obj.get("gage"), context=f"Raingage JSON {raingage_json_path} field 'gage'")] gages = [ normalize_raingage( require_object(raw_gage, context=f"Raingage JSON {raingage_json_path} gage entry {idx}"), source_label=f"{raingage_json_path} gages[{idx}]", rainfall_series_name=rainfall_series_name if len(raw_gages) == 1 else None, ) for idx, raw_gage in enumerate(raw_gages) ] else: generated_gage = { "id": default_gage_id, "rain_format": "INTENSITY", "interval_min": rainfall_interval_min if rainfall_interval_min is not None else 5, "scf": 1.0, "source": { "kind": "TIMESERIES", "series_name": default_series_name, }, } gages = [ normalize_raingage( generated_gage, source_label="generated-default", rainfall_series_name=rainfall_series_name, ) ] gage_ids: set[str] = set() series_names: set[str] = set() for gage in gages: if gage["id"] in gage_ids: raise ValueError(f"[RAINGAGES] duplicate gage id '{gage['id']}'") if gage["source"]["series_name"] in series_names: raise ValueError(f"[RAINGAGES] duplicate source series '{gage['source']['series_name']}'") gage_ids.add(gage["id"]) series_names.add(gage["source"]["series_name"]) timeseries_body = parse_timeseries_body(timeseries_path.read_text(encoding="utf-8")) timeseries_stats = validate_timeseries_body(timeseries_body, expected_series_names=series_names) return gages, timeseries_body, timeseries_path, timeseries_stats def default_options() -> dict[str, Any]: return { "FLOW_UNITS": "CMS", "INFILTRATION": "GREEN_AMPT", "FLOW_ROUTING": "DYNWAVE", "LINK_OFFSETS": "DEPTH", "MIN_SLOPE": 0, "ALLOW_PONDING": "NO", "SKIP_STEADY_STATE": "NO", "START_DATE": "06/01/2025", "START_TIME": "00:00:00", "REPORT_START_DATE": "06/01/2025", "REPORT_START_TIME": "00:00:00", "END_DATE": "06/01/2025", "END_TIME": "01:00:00", "SWEEP_START": "01/01", "SWEEP_END": "12/31", "DRY_DAYS": 0, "REPORT_STEP": "00:05:00", "WET_STEP": "00:01:00", "DRY_STEP": "01:00:00", "ROUTING_STEP": "00:00:30", } def default_report() -> dict[str, Any]: return { "INPUT": "NO", "CONTROLS": "NO", "SUBCATCHMENTS": "ALL", "NODES": "ALL", "LINKS": "ALL", } def load_builder_config(config_path: Path | None) -> dict[str, Any]: if config_path is None: return {} obj = load_json(config_path) if not isinstance(obj, dict): raise ValueError(f"Config must be a JSON object: {config_path}") if "title" in obj and not str(obj.get("title") or "").strip(): raise ValueError(f"Config field 'title' in {config_path} must be a non-blank string") if "options" in obj and not isinstance(obj.get("options"), dict): raise ValueError(f"Config field 'options' in {config_path} must be a JSON object") if "report" in obj and not isinstance(obj.get("report"), dict): raise ValueError(f"Config field 'report' in {config_path} must be a JSON object") return obj def title_from_config(config: dict[str, Any]) -> str: return str(config.get("title") or "SWMM model generated by swmm-builder") def validate_and_merge_options(config: dict[str, Any]) -> dict[str, Any]: context = "[OPTIONS]" merged = default_options() merged.update(config.get("options") or {}) flow_units = require_non_blank_string(merged.get("FLOW_UNITS"), field="FLOW_UNITS", context=context).upper() if flow_units not in {"CFS", "GPM", "MGD", "CMS", "LPS", "MLD"}: raise ValueError(f"{context} FLOW_UNITS must be one of CFS/GPM/MGD/CMS/LPS/MLD, got {merged.get('FLOW_UNITS')}") infiltration = require_non_blank_string(merged.get("INFILTRATION"), field="INFILTRATION", context=context).upper() if infiltration not in {"HORTON", "MODIFIED_HORTON", "GREEN_AMPT", "MODIFIED_GREEN_AMPT", "CURVE_NUMBER"}: raise ValueError( f"{context} INFILTRATION must be one of HORTON/MODIFIED_HORTON/GREEN_AMPT/MODIFIED_GREEN_AMPT/CURVE_NUMBER, got {merged.get('INFILTRATION')}" ) flow_routing = require_non_blank_string(merged.get("FLOW_ROUTING"), field="FLOW_ROUTING", context=context).upper() if flow_routing not in {"STEADY", "KINWAVE", "DYNWAVE"}: raise ValueError(f"{context} FLOW_ROUTING must be one of STEADY/KINWAVE/DYNWAVE, got {merged.get('FLOW_ROUTING')}") link_offsets = require_non_blank_string(merged.get("LINK_OFFSETS"), field="LINK_OFFSETS", context=context).upper() if link_offsets not in {"DEPTH", "ELEVATION"}: raise ValueError(f"{context} LINK_OFFSETS must be DEPTH or ELEVATION, got {merged.get('LINK_OFFSETS')}") min_slope = require_number(merged.get("MIN_SLOPE"), field="MIN_SLOPE", context=context, min_value=0.0) allow_ponding = normalize_yes_no(merged.get("ALLOW_PONDING"), field="ALLOW_PONDING", context=context) skip_steady = normalize_yes_no(merged.get("SKIP_STEADY_STATE"), field="SKIP_STEADY_STATE", context=context) start_date = validate_mmddyyyy(merged.get("START_DATE"), field="START_DATE", context=context) start_time = require_non_blank_string(merged.get("START_TIME"), field="START_TIME", context=context) parse_clock_time(start_time, field="START_TIME", context=context, max_hour=23) report_start_date = validate_mmddyyyy(merged.get("REPORT_START_DATE"), field="REPORT_START_DATE", context=context) report_start_time = require_non_blank_string(merged.get("REPORT_START_TIME"), field="REPORT_START_TIME", context=context) parse_clock_time(report_start_time, field="REPORT_START_TIME", context=context, max_hour=23) end_date = validate_mmddyyyy(merged.get("END_DATE"), field="END_DATE", context=context) end_time = require_non_blank_string(merged.get("END_TIME"), field="END_TIME", context=context) parse_clock_time(end_time, field="END_TIME", context=context, max_hour=23) sweep_start = validate_mmdd(merged.get("SWEEP_START"), field="SWEEP_START", context=context) sweep_end = validate_mmdd(merged.get("SWEEP_END"), field="SWEEP_END", context=context) dry_days = require_number(merged.get("DRY_DAYS"), field="DRY_DAYS", context=context, min_value=0.0) report_step = validate_step_time(merged.get("REPORT_STEP"), field="REPORT_STEP", context=context) wet_step = validate_step_time(merged.get("WET_STEP"), field="WET_STEP", context=context) dry_step = validate_step_time(merged.get("DRY_STEP"), field="DRY_STEP", context=context) routing_step = validate_step_time(merged.get("ROUTING_STEP"), field="ROUTING_STEP", context=context) start_dt = datetime.strptime(f"{start_date} {start_time}", "%m/%d/%Y %H:%M:%S" if len(start_time.split(":")) == 3 else "%m/%d/%Y %H:%M") report_start_dt = datetime.strptime( f"{report_start_date} {report_start_time}", "%m/%d/%Y %H:%M:%S" if len(report_start_time.split(":")) == 3 else "%m/%d/%Y %H:%M", ) end_dt = datetime.strptime(f"{end_date} {end_time}", "%m/%d/%Y %H:%M:%S" if len(end_time.split(":")) == 3 else "%m/%d/%Y %H:%M") if end_dt <= start_dt: raise ValueError(f"{context} END_DATE/END_TIME must be after START_DATE/START_TIME") if report_start_dt < start_dt or report_start_dt > end_dt: raise ValueError(f"{context} REPORT_START_DATE/REPORT_START_TIME must be within simulation start/end window") validated = dict(merged) validated.update( { "FLOW_UNITS": flow_units, "INFILTRATION": infiltration, "FLOW_ROUTING": flow_routing, "LINK_OFFSETS": link_offsets, "MIN_SLOPE": min_slope, "ALLOW_PONDING": allow_ponding, "SKIP_STEADY_STATE": skip_steady, "START_DATE": start_date, "START_TIME": start_time, "REPORT_START_DATE": report_start_date, "REPORT_START_TIME": report_start_time, "END_DATE": end_date, "END_TIME": end_time, "SWEEP_START": sweep_start, "SWEEP_END": sweep_end, "DRY_DAYS": dry_days, "REPORT_STEP": report_step, "WET_STEP": wet_step, "DRY_STEP": dry_step, "ROUTING_STEP": routing_step, } ) return validated def validate_and_merge_report(config: dict[str, Any]) -> dict[str, Any]: context = "[REPORT]" merged = default_report() merged.update(config.get("report") or {}) validated = dict(merged) validated["INPUT"] = normalize_yes_no(merged.get("INPUT"), field="INPUT", context=context) validated["CONTROLS"] = normalize_yes_no(merged.get("CONTROLS"), field="CONTROLS", context=context) validated["SUBCATCHMENTS"] = require_non_blank_string(merged.get("SUBCATCHMENTS"), field="SUBCATCHMENTS", context=context) validated["NODES"] = require_non_blank_string(merged.get("NODES"), field="NODES", context=context) validated["LINKS"] = require_non_blank_string(merged.get("LINKS"), field="LINKS", context=context) return validated def emit_title(title: str) -> list[str]: return ["[TITLE]", title] def emit_options(options: dict[str, Any]) -> list[str]: lines = ["[OPTIONS]", ";;Option Value"] for key, value in options.items(): lines.append(f"{key:<20} {format_num(value)}") return lines def emit_report(report: dict[str, Any]) -> list[str]: lines = ["[REPORT]", ";;Option Value"] for key, value in report.items(): lines.append(f"{key:<20} {format_num(value)}") return lines def format_interval_hhmm(interval_min: int) -> str: if interval_min <= 0: raise ValueError("[RAINGAGES] interval_min must be > 0") hh = interval_min // 60 mm = interval_min % 60 return f"{hh}:{mm:02d}" def emit_raingages(gages: list[dict[str, Any]]) -> list[str]: lines = [ "[RAINGAGES]", ";;Name Format Interval SCF Source", ] for gage in gages: lines.append( f"{str(gage['id']):<18} {str(gage['rain_format']):<10} " f"{format_interval_hhmm(int(gage['interval_min'])):<10} {format_num(float(gage['scf'])):<8} " f"TIMESERIES {str(gage['source']['series_name'])}" ) return lines def emit_subcatchments( subcatchments: dict[str, dict[str, Any]], params_subcatchments: dict[str, dict[str, Any]], *, default_gage_id: str, ) -> list[str]: lines = [ "[SUBCATCHMENTS]", ";;Name Rain Gage Outlet Area %Imperv Width %Slope CurbLen SnowPack", ] for subcatchment_id in sorted(subcatchments): sc = subcatchments[subcatchment_id] p = params_subcatchments[subcatchment_id] rain_gage = sc.get("rain_gage") or default_gage_id lines.append( f"{subcatchment_id:<18} {rain_gage:<18} {sc['outlet']:<18} " f"{format_num(sc['area_ha']):<8} {format_num(p['pct_imperv']):<8} {format_num(sc['width_m']):<8} " f"{format_num(sc['slope_pct']):<8} {format_num(sc['curb_length_m']):<8} {sc['snow_pack']}" ) return lines def emit_subareas(subcatchments: dict[str, dict[str, Any]], params_subareas: dict[str, dict[str, Any]]) -> list[str]: lines = [ "[SUBAREAS]", ";;Subcatchment N-Imperv N-Perv S-Imperv S-Perv PctZero RouteTo PctRouted", ] for subcatchment_id in sorted(subcatchments): p = params_subareas[subcatchment_id] lines.append( f"{subcatchment_id:<18} {format_num(p['n_imperv']):<9} {format_num(p['n_perv']):<9} " f"{format_num(p['dstore_imperv_in']):<9} {format_num(p['dstore_perv_in']):<9} " f"{format_num(p['zero_imperv_pct']):<8} {str(p['route_to']):<8} {format_num(p['pct_routed'])}" ) return lines def emit_infiltration(subcatchments: dict[str, dict[str, Any]], params_infiltration: dict[str, dict[str, Any]]) -> list[str]: lines = [ "[INFILTRATION]", ";;Subcatchment Suction Ksat IMDmax", ] for subcatchment_id in sorted(subcatchments): p = params_infiltration[subcatchment_id] lines.append( f"{subcatchment_id:<18} {format_num(p['suction_mm']):<9} {format_num(p['ksat_mm_per_hr']):<9} {format_num(p['imdmax'])}" ) return lines def emit_timeseries(timeseries_body: list[str]) -> list[str]: return ["[TIMESERIES]", *timeseries_body] def validate_coord(coord: Any, *, context: str) -> dict[str, float]: coord_obj = require_object(coord, context=f"{context} coordinates") x = require_number(coord_obj.get("x"), field="x", context=f"{context} coordinates") y = require_number(coord_obj.get("y"), field="y", context=f"{context} coordinates") return {"x": x, "y": y} def validate_and_normalize_network(network_obj: Any, *, source_path: Path) -> dict[str, Any]: network = require_object(network_obj, context=f"Network JSON {source_path}") junctions_raw = require_list(network.get("junctions"), context=f"Network JSON {source_path} field 'junctions'") outfalls_raw = require_list(network.get("outfalls"), context=f"Network JSON {source_path} field 'outfalls'") conduits_raw = require_list(network.get("conduits"), context=f"Network JSON {source_path} field 'conduits'") junctions: list[dict[str, Any]] = [] outfalls: list[dict[str, Any]] = [] conduits: list[dict[str, Any]] = [] node_ids: set[str] = set() for idx, raw in enumerate(junctions_raw, start=1): context = f"[JUNCTIONS] entry {idx} ({source_path})" obj = require_object(raw, context=context) node_id = require_non_blank_string(obj.get("id"), field="id", context=context) if node_id in node_ids: raise ValueError(f"{context} duplicate node id '{node_id}' across junctions/outfalls") invert_elev = require_number(obj.get("invert_elev"), field="invert_elev", context=context) max_depth = require_number(obj.get("max_depth"), field="max_depth", context=context, min_value=0.0) init_depth = require_number(obj.get("init_depth", 0.0), field="init_depth", context=context, min_value=0.0) sur_depth = require_number(obj.get("sur_depth", 0.0), field="sur_depth", context=context, min_value=0.0) aponded = require_number(obj.get("aponded", 0.0), field="aponded", context=context, min_value=0.0) coordinates = validate_coord(obj.get("coordinates"), context=context) junctions.append( { "id": node_id, "invert_elev": invert_elev, "max_depth": max_depth, "init_depth": init_depth, "sur_depth": sur_depth, "aponded": aponded, "coordinates": coordinates, } ) node_ids.add(node_id) for idx, raw in enumerate(outfalls_raw, start=1): context = f"[OUTFALLS] entry {idx} ({source_path})" obj = require_object(raw, context=context) node_id = require_non_blank_string(obj.get("id"), field="id", context=context) if node_id in node_ids: raise ValueError(f"{context} duplicate node id '{node_id}' across junctions/outfalls") invert_elev = require_number(obj.get("invert_elev"), field="invert_elev", context=context) outfall_type = require_non_blank_string(obj.get("type"), field="type", context=context).upper() if outfall_type not in {"FREE", "NORMAL", "FIXED", "TIDAL", "TIMESERIES"}: raise ValueError(f"{context} field 'type' must be FREE/NORMAL/FIXED/TIDAL/TIMESERIES, got {obj.get('type')}") stage_data = obj.get("stage_data") if outfall_type != "FREE": if stage_data is None or str(stage_data).strip() == "": raise ValueError(f"{context} requires non-empty 'stage_data' for type {outfall_type}") gated_raw = obj.get("gated", False) if isinstance(gated_raw, bool): gated = gated_raw else: gated = normalize_yes_no(gated_raw, field="gated", context=context) == "YES" route_to = obj.get("route_to") route_to_value = "" if route_to is not None and str(route_to).strip(): route_to_value = require_non_blank_string(route_to, field="route_to", context=context) coordinates = validate_coord(obj.get("coordinates"), context=context) outfalls.append( { "id": node_id, "invert_elev": invert_elev, "type": outfall_type, "stage_data": stage_data, "gated": gated, "route_to": route_to_value, "coordinates": coordinates, } ) node_ids.add(node_id) conduit_ids: set[str] = set() for idx, raw in enumerate(conduits_raw, start=1): context = f"[CONDUITS] entry {idx} ({source_path})" obj = require_object(raw, context=context) conduit_id = require_non_blank_string(obj.get("id"), field="id", context=context) if conduit_id in conduit_ids: raise ValueError(f"{context} duplicate conduit id '{conduit_id}'") from_node = require_non_blank_string(obj.get("from_node"), field="from_node", context=context) to_node = require_non_blank_string(obj.get("to_node"), field="to_node", context=context) if from_node == to_node: raise ValueError(f"{context} from_node and to_node must differ for conduit '{conduit_id}'") if from_node not in node_ids: raise ValueError(f"{context} from_node '{from_node}' does not exist in [JUNCTIONS]/[OUTFALLS]") if to_node not in node_ids: raise ValueError(f"{context} to_node '{to_node}' does not exist in [JUNCTIONS]/[OUTFALLS]") length = require_number(obj.get("length"), field="length", context=context, min_value=0.0, min_inclusive=False) roughness = require_number( obj.get("roughness"), field="roughness", context=context, min_value=0.0, min_inclusive=False, ) in_offset = require_number(obj.get("in_offset", 0.0), field="in_offset", context=context) out_offset = require_number(obj.get("out_offset", 0.0), field="out_offset", context=context) init_flow = require_number(obj.get("init_flow", 0.0), field="init_flow", context=context) max_flow_raw = obj.get("max_flow") max_flow = None if max_flow_raw is not None and str(max_flow_raw).strip() != "": max_flow = require_number( max_flow_raw, field="max_flow", context=context, min_value=0.0, min_inclusive=False, ) xsection_obj = require_object(obj.get("xsection"), context=f"[XSECTIONS] for conduit '{conduit_id}'") shape = require_non_blank_string(xsection_obj.get("shape"), field="shape", context=f"[XSECTIONS] conduit '{conduit_id}'").upper() geom1 = require_number( xsection_obj.get("geom1"), field="geom1", context=f"[XSECTIONS] conduit '{conduit_id}'", min_value=0.0, min_inclusive=False, ) geom2 = require_number(xsection_obj.get("geom2", 0.0), field="geom2", context=f"[XSECTIONS] conduit '{conduit_id}'") geom3 = require_number(xsection_obj.get("geom3", 0.0), field="geom3", context=f"[XSECTIONS] conduit '{conduit_id}'") geom4 = require_number(xsection_obj.get("geom4", 0.0), field="geom4", context=f"[XSECTIONS] conduit '{conduit_id}'") barrels = require_int( xsection_obj.get("barrels", 1), field="barrels", context=f"[XSECTIONS] conduit '{conduit_id}'", min_value=1, ) vertices_raw = obj.get("vertices") vertices: list[dict[str, float]] = [] if vertices_raw is not None: vertices_list = require_list(vertices_raw, context=f"[VERTICES] conduit '{conduit_id}'") for v_idx, vertex in enumerate(vertices_list, start=1): vertex_obj = require_object(vertex, context=f"[VERTICES] conduit '{conduit_id}' entry {v_idx}") x = require_number(vertex_obj.get("x"), field="x", context=f"[VERTICES] conduit '{conduit_id}' entry {v_idx}") y = require_number(vertex_obj.get("y"), field="y", context=f"[VERTICES] conduit '{conduit_id}' entry {v_idx}") vertices.append({"x": x, "y": y}) conduits.append( { "id": conduit_id, "from_node": from_node, "to_node": to_node, "length": length, "roughness": roughness, "in_offset": in_offset, "out_offset": out_offset, "init_flow": init_flow, "max_flow": max_flow, "xsection": { "shape": shape, "geom1": geom1, "geom2": geom2, "geom3": geom3, "geom4": geom4, "barrels": barrels, }, "vertices": vertices, } ) conduit_ids.add(conduit_id) return { "junctions": junctions, "outfalls": outfalls, "conduits": conduits, } def emit_junctions(network: dict[str, Any]) -> list[str]: lines = ["[JUNCTIONS]", ";;Name Elevation MaxDepth InitDepth SurDepth Aponded"] for j in network.get("junctions", []): lines.append( f"{j['id']:<18} {format_num(j['invert_elev']):<14} {format_num(j['max_depth']):<14} " f"{format_num(j['init_depth']):<14} {format_num(j['sur_depth']):<14} {format_num(j['aponded'])}" ) return lines def emit_outfalls(network: dict[str, Any]) -> list[str]: lines = ["[OUTFALLS]", ";;Name Elevation Type Stage Data Gated Route To"] for o in network.get("outfalls", []): lines.append( f"{o['id']:<18} {format_num(o['invert_elev']):<14} {str(o['type']):<14} " f"{format_num(o.get('stage_data', '')):<15} {format_num(o['gated']):<14} {format_num(o['route_to'])}" ) return lines def emit_conduits(network: dict[str, Any]) -> list[str]: lines = [ "[CONDUITS]", ";;Name From Node To Node Length Roughness InOffset OutOffset InitFlow MaxFlow", ] for c in network.get("conduits", []): lines.append( f"{c['id']:<18} {c['from_node']:<17} {c['to_node']:<17} {format_num(c['length']):<8} " f"{format_num(c['roughness']):<9} {format_num(c['in_offset']):<8} " f"{format_num(c['out_offset']):<9} {format_num(c['init_flow']):<9} {format_num(c['max_flow'])}" ) return lines def emit_xsections(network: dict[str, Any]) -> list[str]: lines = ["[XSECTIONS]", ";;Link Shape Geom1 Geom2 Geom3 Geom4 Barrels"] for c in network.get("conduits", []): xs = c["xsection"] lines.append( f"{c['id']:<18} {xs['shape']:<14} {format_num(xs['geom1']):<8} {format_num(xs['geom2']):<8} " f"{format_num(xs['geom3']):<8} {format_num(xs['geom4']):<8} {format_num(xs['barrels'])}" ) return lines def emit_coordinates(network: dict[str, Any]) -> list[str]: lines = ["[COORDINATES]", ";;Node X-Coord Y-Coord"] for j in network.get("junctions", []): xy = j["coordinates"] lines.append(f"{j['id']:<18} {format_num(xy['x']):<14} {format_num(xy['y'])}") for o in network.get("outfalls", []): xy = o["coordinates"] lines.append(f"{o['id']:<18} {format_num(xy['x']):<14} {format_num(xy['y'])}") return lines def emit_vertices(network: dict[str, Any]) -> list[str]: lines = ["[VERTICES]", ";;Link X-Coord Y-Coord"] count = 0 for c in network.get("conduits", []): for v in c.get("vertices", []): count += 1 lines.append(f"{c['id']:<18} {format_num(v['x']):<14} {format_num(v['y'])}") return lines if count > 0 else [] def emit_pollutants(wq: dict[str, Any]) -> list[str]: """Emit [POLLUTANTS] section. Column order (SWMM 5.2.4 Reference Manual Table A-4): Name Units Cppt Cgw Crdii Kdecay SnowOnly CoPollutant CoFraction InitConc Engine-confirmed (PR1 smoke): InitConc column present in 5.2.4; CoFraction=0 accepted when CoPollutant=*. """ lines = [ "[POLLUTANTS]", ";;Name Units Cppt Cgw Crdii Kdecay SnowOnly CoPollutant CoFraction InitConc", ] for p in wq.get("pollutants", []): name = str(p["name"]) units = str(p.get("units", "MG/L")).upper() cppt = format_num(float(p.get("c_rain", 0.0))) cgw = format_num(float(p.get("c_gw", 0.0))) crdii = format_num(float(p.get("c_ii", 0.0))) kdecay = format_num(float(p.get("k_decay_per_day", 0.0))) snow_only = "YES" if p.get("snow_only", False) else "NO" co_pol = str(p.get("co_pollutant", "*") or "*") co_frac = format_num(float(p.get("co_fraction", 0.0))) init_conc = format_num(float(p.get("init_conc", 0.0))) lines.append( f"{name:<18} {units:<6} {cppt:<6} {cgw:<6} {crdii:<6} {kdecay:<6} {snow_only:<8} " f"{co_pol:<12} {co_frac:<10} {init_conc}" ) return lines def emit_landuses(wq: dict[str, Any]) -> list[str]: """Emit [LANDUSES] section. Column order (SWMM 5.2.4 Reference Manual Table A-5): Name SweepInterval Availability LastSweep Engine-confirmed (PR1 smoke): LastSweep required even when SweepInterval=0; SweepRemoval lives on [WASHOFF] rows (not here) per SWMM 5.2.4. """ lines = [ "[LANDUSES]", ";;Name SweepInterval Availability LastSweep", ] for lu in wq.get("landuses", []): name = str(lu["name"]) sweep_interval = format_num(float(lu.get("sweep_interval", 0.0))) availability = format_num(float(lu.get("availability", 0.0))) last_sweep = format_num(float(lu.get("last_sweep", 0.0))) lines.append(f"{name:<18} {sweep_interval:<13} {availability:<12} {last_sweep}") return lines def emit_coverages(wq: dict[str, Any]) -> list[str]: """Emit [COVERAGES] section. Column order: Subcatchment LandUse Percent """ lines = [ "[COVERAGES]", ";;Subcatchment LandUse Percent", ] for cov in wq.get("coverages", []): sub = str(cov["subcatchment"]) lu = str(cov["landuse"]) pct = format_num(float(cov.get("percent", 0.0))) lines.append(f"{sub:<18} {lu:<16} {pct}") return lines def emit_buildup(wq: dict[str, Any]) -> list[str]: """Emit [BUILDUP] section. Column order (SWMM 5.2.4 Reference Manual Table A-7): LandUse Pollutant FuncType C1 C2 C3 Normalizer EXT func type is rejected at validation time (not emitted here). """ lines = [ "[BUILDUP]", ";;LandUse Pollutant FuncType C1 C2 C3 Normalizer", ] for bu in wq.get("buildup", []): lu = str(bu["landuse"]) pol = str(bu["pollutant"]) ft = str(bu.get("func_type", "EXP")).upper() c1 = format_num(float(bu.get("c1", 0.0))) c2 = format_num(float(bu.get("c2", 0.0))) c3 = format_num(float(bu.get("c3", 0.0))) norm = str(bu.get("normalizer", "AREA")).upper() lines.append( f"{lu:<18} {pol:<16} {ft:<9} {c1:<10} {c2:<10} {c3:<10} {norm}" ) return lines def emit_washoff(wq: dict[str, Any]) -> list[str]: """Emit [WASHOFF] section. Column order (SWMM 5.2.4 Reference Manual Table A-8): LandUse Pollutant FuncType C1 C2 SweepRemoval BMPRemoval Engine-confirmed (PR1 smoke): SweepRemoval is on [WASHOFF] rows (not [LANDUSES]) in SWMM 5.2.4. BMPRemoval accepted; included for grammar correctness. """ lines = [ "[WASHOFF]", ";;LandUse Pollutant FuncType C1 C2 SweepRemoval BMPRemoval", ] for wo in wq.get("washoff", []): lu = str(wo["landuse"]) pol = str(wo["pollutant"]) ft = str(wo.get("func_type", "EMC")).upper() c1 = format_num(float(wo.get("c1", 0.0))) c2 = format_num(float(wo.get("c2", 0.0))) sweep = format_num(float(wo.get("sweep_removal", 0.0))) bmp = format_num(float(wo.get("bmp_removal", 0.0))) lines.append( f"{lu:<18} {pol:<16} {ft:<9} {c1:<10} {c2:<10} {sweep:<12} {bmp}" ) return lines def emit_loadings(wq: dict[str, Any]) -> list[str]: """Emit [LOADINGS] section (optional). Column order: Subcatchment Pollutant InitBuildup Returns an empty list when the loadings array is absent or empty — the section is legally omitted from the INP when no initial buildup is required. """ rows = wq.get("loadings", []) if not rows: return [] lines = [ "[LOADINGS]", ";;Subcatchment Pollutant InitBuildup", ] for lo in rows: sub = str(lo["subcatchment"]) pol = str(lo["pollutant"]) init = format_num(float(lo.get("init_buildup", 0.0))) lines.append(f"{sub:<18} {pol:<16} {init}") return lines def render_inp( *, title: str, options: dict[str, Any], report: dict[str, Any], gages: list[dict[str, Any]], timeseries_body: list[str], subcatchments: dict[str, dict[str, Any]], params_subcatchments: dict[str, dict[str, Any]], params_subareas: dict[str, dict[str, Any]], params_infiltration: dict[str, dict[str, Any]], network: dict[str, Any], wq: dict[str, Any] | None = None, ) -> str: blocks: list[list[str]] = [ emit_title(title), emit_options(options), emit_raingages(gages), emit_subcatchments(subcatchments, params_subcatchments, default_gage_id=str(gages[0]["id"])), emit_subareas(subcatchments, params_subareas), emit_infiltration(subcatchments, params_infiltration), emit_junctions(network), emit_outfalls(network), emit_conduits(network), emit_xsections(network), emit_coordinates(network), emit_timeseries(timeseries_body), emit_report(report), ] vertices = emit_vertices(network) if vertices: blocks.insert(11, vertices) # Insert WQ sections after [INFILTRATION] (index 5) when present. # Insertion order: POLLUTANTS, LANDUSES, COVERAGES, BUILDUP, WASHOFF, # then LOADINGS only when non-empty. Each is inserted after the # previous so the final block order is correct. if wq is not None: wq_loadings = emit_loadings(wq) wq_blocks: list[list[str]] = [ emit_pollutants(wq), emit_landuses(wq), emit_coverages(wq), emit_buildup(wq), emit_washoff(wq), ] if wq_loadings: wq_blocks.append(wq_loadings) # Insert after [INFILTRATION] at index 5 for offset, wb in enumerate(wq_blocks): blocks.insert(6 + offset, wb)
-
-
SKILL.md 6.8 KB
--- name: swmm-builder description: Assemble a runnable SWMM INP deterministically from subcatchment geometry/attributes, merged parameter JSON, network JSON, and climate references. Use when creating auditable INP + manifest artifacts for downstream swmm-runner/calibration. --- # SWMM Builder (INP assembly layer) Part of [Agentic SWMM](https://github.com/Zhonghao1995/agentic-swmm-workflow) — install the project first for the executable toolchain (aiswmm CLI, SWMM solver, MCP servers). ## Contract Build a runnable SWMM `.inp` using explicit file inputs: - `subcatchments.csv` (shape/area/outlet/routing basics) - merged params JSON from `swmm-params` - network JSON from `swmm-network` - rainfall/time-series references from `swmm-climate` - optional options config JSON The builder writes: - final SWMM INP text (`--out-inp`) - manifest JSON (`--out-manifest`) with source paths + SHA256 + key metadata - strict validation diagnostics for critical sections (`[OPTIONS]`, `[RAINGAGES]`, `[TIMESERIES]`, `[SUBCATCHMENTS]`, `[SUBAREAS]`, `[INFILTRATION]`, and current network sections) ## Inputs ### Subcatchments CSV schema (required) Required columns: - `subcatchment_id` - `outlet` - `area_ha` - `width_m` - `slope_pct` Optional columns: - `rain_gage` (falls back to default gage from climate/config) - `curb_length_m` (default `0`) - `snow_pack` (default blank) ### Params JSON (required) Expected to match `skills/swmm-params/scripts/merge_swmm_params.py` output: - `sections.subcatchments` (`id`, `pct_imperv`) - `sections.subareas` (`id`, runoff/subarea fields) - `sections.infiltration` (`id`, Green-Ampt fields) - Required fields are now validated strictly with type/range checks (for example `%Imperv` and routing percentages must be `0..100`). ### Network JSON (required) Expected to match `skills/swmm-network` schema (`junctions`, `outfalls`, `conduits`, etc.). Builder now validates required network fields used to emit `[JUNCTIONS]`, `[OUTFALLS]`, `[CONDUITS]`, `[XSECTIONS]`, `[COORDINATES]`, and `[VERTICES]`. ### Climate references (required in MVP) Provide either: - `--timeseries-text` directly, or - `--rainfall-json` produced by `swmm-climate/format_rainfall.py` (must include `outputs.timeseries_text`) For `[RAINGAGES]`, provide either: - `--raingage-json` from `swmm-climate/build_raingage_section.py`, or - rely on default deterministic gage generation from rainfall `series_name`. `--raingage-json` supports both the original single-gage form and a multi-gage form: ```json { "gages": [ { "id": "RG1", "rain_format": "VOLUME", "interval_min": 5, "scf": 1.0, "source": {"kind": "TIMESERIES", "series_name": "TS_RG1"} }, { "id": "RG2", "rain_format": "VOLUME", "interval_min": 5, "scf": 1.0, "source": {"kind": "TIMESERIES", "series_name": "TS_RG2"} } ] } ``` The timeseries text must include rows for every referenced `series_name`, and each subcatchment `rain_gage` must reference one of the emitted gage IDs. Validation behavior: - Missing critical fields fail fast with explicit section-scoped errors. - `[TIMESERIES]` rows are validated for series-name consistency and basic token/time/value correctness. - Manifest includes `validation` plus `validation_diagnostics` metadata. ## Scripts - `scripts/build_swmm_inp.py` - single entrypoint that reads all inputs and writes INP + manifest. ## MCP MCP wrapper location: - `mcp/swmm-builder/server.js` (was previously `skills/swmm-builder/scripts/mcp/`; moved during the mcp/ root restructure) Exposed tools: - `build_inp` — assemble a runnable SWMM INP + manifest from subcatchments CSV, area-weighted params JSON, network JSON, rainfall JSON + timeseries text, and an options-config JSON. Required args: `subcatchmentsCsvPath`, `paramsJsonPath`, `networkJsonPath`, `outInpPath`, `outManifestPath`. Optional: `rainfallJsonPath`, `raingageJsonPath`, `timeseriesTextPath`, `configJsonPath`, `defaultGageId`, `waterQualityJsonPath` (path to a WQ config JSON — enables [POLLUTANTS]/[LANDUSES]/[BUILDUP]/[WASHOFF]/[COVERAGES]/ [LOADINGS] sections for pollutant buildup/washoff simulation). The subcatchments CSV must carry `outlet` values that point to real upstream junctions (use `swmm-network-mcp.assign_subcatchment_outlets` first if it currently points to the literal outfall). ## Smoke example The builder requires upstream outputs from `swmm-params` and `swmm-climate`. Generate them first, then call `build_swmm_inp.py`: ```bash # 1. Generate merged params from the bundled examples mkdir -p /tmp/builder-smoke python3 skills/swmm-params/scripts/landuse_to_swmm_params.py \ --input skills/swmm-params/examples/landuse_input.csv \ --output /tmp/builder-smoke/landuse.json python3 skills/swmm-params/scripts/soil_to_greenampt.py \ --input skills/swmm-params/examples/soil_input.csv \ --output /tmp/builder-smoke/soil.json python3 skills/swmm-params/scripts/merge_swmm_params.py \ --landuse-json /tmp/builder-smoke/landuse.json \ --soil-json /tmp/builder-smoke/soil.json \ --output /tmp/builder-smoke/merged_params.json # 2. Format rainfall from the bundled climate example python3 skills/swmm-climate/scripts/format_rainfall.py \ --input skills/swmm-climate/examples/rainfall_event.csv \ --out-json /tmp/builder-smoke/rainfall.json \ --out-timeseries /tmp/builder-smoke/rainfall_timeseries.txt # 3. Build the INP python3 skills/swmm-builder/scripts/build_swmm_inp.py \ --subcatchments-csv skills/swmm-builder/examples/subcatchments_input.csv \ --params-json /tmp/builder-smoke/merged_params.json \ --network-json skills/swmm-network/examples/basic-network.json \ --rainfall-json /tmp/builder-smoke/rainfall.json \ --config-json skills/swmm-builder/examples/options_config.json \ --out-inp /tmp/builder-smoke/example_model.inp \ --out-manifest /tmp/builder-smoke/example_manifest.json ``` ## Water quality sections Pass `--water-quality-json <path>` to enable pollutant buildup/washoff simulation. The JSON must satisfy the schema in `skills/swmm-water-quality/SKILL.md`. Omit the flag for pure hydrology runs (Water Quality: NO in the INP). ```bash # Build an INP with water quality (TSS, 1 landuse, executed example): python3 skills/swmm-builder/scripts/build_swmm_inp.py \ --subcatchments-csv skills/swmm-builder/examples/subcatchments_input.csv \ --params-json /tmp/builder-smoke/merged_params.json \ --network-json skills/swmm-network/examples/basic-network.json \ --water-quality-json /tmp/wq_example.json \ --out-inp /tmp/wq_model.inp \ --out-manifest /tmp/wq_manifest.json ``` The builder validates all cross-references (pollutant/landuse/subcatchment consistency) before writing the INP. ## Known limitations - No automatic polygon export, LID controls, snowpack, or RTC rules. - Assumes one raingage source for all subcatchments unless `rain_gage` is explicitly set per row.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.