Claude Skill

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.

LLM Mart · 0 points · 5 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download zhonghao1995-agentic-swmm-workflow-skills_swmm-builder-2d743b9.zip · 17 KB
Part of zhonghao1995/agentic-swmm-workflow — 18 skills

Install

skills CLI npx skills add https://github.com/Zhonghao1995/agentic-swmm-workflow/tree/main/skills/swmm-builder
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install zhonghao1995-agentic-swmm-workflow@llmmart
Git 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_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:

{
  "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:

# 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_gage is 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.

No comments yet.

Reviews (0)

No reviews yet.

Related