Editing SWMM Input Files Programmatically with swmmio

Parameter sweeps, scenario generation and calibration all need the same thing: a SWMM model whose .inp file can be modified reliably from code. The file format makes that harder than it looks, because it is whitespace-delimited with per-section column semantics and no schema. This guide covers doing it safely, as part of the SWMM model integration with PySWMM topic within rainfall-runoff modeling and hydrologic simulation.

Prerequisites

  • swmmio and pyswmm.
  • A working, validated base model. Every generated variant inherits its problems.
  • A scratch directory for generated files, kept separate from the authored model.

Core Technique: Sections as DataFrames

swmmio parses each .inp section into a DataFrame indexed by the element name, which turns an edit into a normal pandas operation and a write into a round trip.

Parse, Edit, Write to a New File The authored .inp file is parsed into section dataframes for subcatchments, conduits and infiltration. Edits are applied to the dataframes. A new .inp is written into a scratch directory, leaving the authored file unmodified. model.inp authored, in git parse [SUBCATCHMENTS] [CONDUITS] [INFILTRATION] each a DataFrame indexed by name edit pandas operations by name, not by line write variant.inp scratch dir Never write a generated file back over the authored one: comments and section order are not guaranteed to survive a round trip, and the diff becomes unreadable.

Annotated Code Example

python
import logging
import shutil
from pathlib import Path

import pandas as pd
import swmmio

log = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")


def model_fingerprint(inp_path: str) -> dict:
    """
    Structural counts used to prove an edit changed only what it meant to.

    Compare before and after: a change in any count that the edit did not
    intend is a corrupted model, whatever SWMM says when it runs.
    """
    m = swmmio.Model(str(inp_path))
    fp = {
        "subcatchments": len(m.inp.subcatchments),
        "junctions": len(m.inp.junctions),
        "conduits": len(m.inp.conduits),
        "outfalls": len(m.inp.outfalls),
    }
    log.info("fingerprint %s: %s", Path(inp_path).name, fp)
    return fp


def make_variant(
    base_inp: str,
    out_inp: str,
    subcatchment_edits: dict | None = None,
    conduit_edits: dict | None = None,
    scale_imperviousness: float | None = None,
) -> str:
    """
    Write a modified copy of a SWMM model.

    Parameters
    ----------
    base_inp             : Authored model — never modified.
    out_inp              : Destination for the variant.
    subcatchment_edits   : {name: {column: value}} for individual edits.
    conduit_edits        : {name: {column: value}}.
    scale_imperviousness : Multiply every subcatchment's PercImperv by this,
                           clipped to 0–100. Used for scenario sweeps.
    """
    base, out = Path(base_inp), Path(out_inp)
    out.parent.mkdir(parents=True, exist_ok=True)
    if out.resolve() == base.resolve():
        raise ValueError("refusing to write a variant over the authored model")

    before = model_fingerprint(str(base))
    shutil.copy(base, out)
    model = swmmio.Model(str(out))

    subs = model.inp.subcatchments
    if scale_imperviousness is not None:
        original = subs["PercImperv"].astype(float)
        subs["PercImperv"] = (original * scale_imperviousness).clip(0.0, 100.0)
        n_clipped = int(((original * scale_imperviousness) > 100.0).sum())
        log.info("Scaled imperviousness by %.2f across %d subcatchments "
                 "(mean %.1f → %.1f %%)", scale_imperviousness, len(subs),
                 float(original.mean()), float(subs["PercImperv"].mean()))
        if n_clipped:
            log.warning("%d subcatchment(s) clipped at 100 %% — the scaling is "
                        "no longer uniform across the model", n_clipped)

    if subcatchment_edits:
        for name, changes in subcatchment_edits.items():
            if name not in subs.index:
                # Editing by name means a typo is an error rather than a silent
                # no-op, which is the whole reason not to use text substitution.
                raise KeyError(f"subcatchment {name!r} not in the model")
            for col, val in changes.items():
                if col not in subs.columns:
                    raise KeyError(f"column {col!r} not in [SUBCATCHMENTS]")
                log.info("  %s.%s: %s → %s", name, col, subs.at[name, col], val)
                subs.at[name, col] = val

    if conduit_edits:
        conduits = model.inp.conduits
        for name, changes in conduit_edits.items():
            if name not in conduits.index:
                raise KeyError(f"conduit {name!r} not in the model")
            for col, val in changes.items():
                log.info("  %s.%s: %s → %s", name, col, conduits.at[name, col], val)
                conduits.at[name, col] = val
        model.inp.conduits = conduits

    model.inp.subcatchments = subs
    model.inp.save()

    after = model_fingerprint(str(out))
    if before != after:
        raise RuntimeError(f"the edit changed the model structure: "
                           f"{before}{after}")
    log.info("Wrote variant %s with the structure unchanged", out)
    return str(out)


def validate_model(inp_path: str) -> dict:
    """
    Structural checks that catch an edit-induced corruption in under a second.

    SWMM itself only errors on structural problems, so a width column that has
    become a slope runs happily and returns nonsense.
    """
    m = swmmio.Model(str(inp_path))
    problems = []

    subs = m.inp.subcatchments
    for col, floor in (("Area", 0.0), ("Width", 0.0), ("PercSlope", 0.0)):
        if col in subs.columns:
            bad = subs[subs[col].astype(float) <= floor]
            if len(bad):
                problems.append(f"{len(bad)} subcatchment(s) with {col} <= {floor}")
    if "PercImperv" in subs.columns:
        imp = subs["PercImperv"].astype(float)
        bad = subs[(imp < 0) | (imp > 100)]
        if len(bad):
            problems.append(f"{len(bad)} subcatchment(s) with PercImperv outside 0–100")

    nodes = set(m.inp.junctions.index) | set(m.inp.outfalls.index)
    if hasattr(m.inp, "storage"):
        nodes |= set(m.inp.storage.index)
    conduits = m.inp.conduits
    for end in ("InletNode", "OutletNode"):
        if end in conduits.columns:
            dangling = conduits[~conduits[end].isin(nodes)]
            if len(dangling):
                problems.append(f"{len(dangling)} conduit(s) with a missing {end}")

    outlets = subs["Outlet"] if "Outlet" in subs.columns else pd.Series(dtype=object)
    missing = outlets[~outlets.isin(nodes | set(subs.index))]
    if len(missing):
        problems.append(f"{len(missing)} subcatchment(s) draining to a missing outlet")

    for p in problems:
        log.error("validation: %s", p)
    if not problems:
        log.info("validation: %s passed all structural checks", Path(inp_path).name)
    return {"path": inp_path, "problems": problems, "valid": not problems}


# --- Example usage: an imperviousness sweep ---
# for factor in (0.8, 1.0, 1.2, 1.5):
#     path = make_variant("model.inp", f"scratch/imp_{factor:.1f}.inp",
#                         scale_imperviousness=factor)
#     validate_model(path)

Parameter Reference

Section Common edit Column Watch for
[SUBCATCHMENTS] Imperviousness sweep PercImperv Clipping at 100 % breaks uniformity
[SUBCATCHMENTS] Width calibration Width Width is a routing parameter, not a measurement
[SUBAREAS] Roughness N-Imperv, N-Perv Two separate values, easily transposed
[INFILTRATION] Loss parameters Model-dependent columns Column meaning changes with the infiltration model
[CONDUITS] Roughness Roughness Manning’s n, not a friction factor
[TIMESERIES] Rainfall Time and value pairs Replacing rather than appending

Worked Example: A Sweep and Its Validation

An imperviousness sweep across four factors on a 148-subcatchment model:

Factor Mean PercImperv Clipped Structure unchanged Peak outflow
0.8 34.1 % 0 yes 4.82 m³/s
1.0 42.6 % 0 yes 6.10 m³/s
1.2 51.1 % 0 yes 7.28 m³/s
1.5 61.4 % 9 yes 8.71 m³/s

The 1.5 row is the one to notice. Nine subcatchments hit the 100 % ceiling, so the applied scaling is no longer 1.5 everywhere — the sweep’s independent variable has stopped being what the axis label says. That is exactly the kind of quiet non-linearity a batch script hides unless it counts and reports the clipping, which is why the code above does.

Where the Sweep Stops Being a Sweep Peak outflow rises from 4.82 to 8.71 cubic metres per second as the imperviousness factor rises from 0.8 to 1.5. Beyond a factor of about 1.35 some subcatchments clip at 100 percent imperviousness, so the response flattens for a reason that is an artefact of the sweep rather than hydrology. 0.8 1.0 1.2 1.5 imperviousness scaling factor 4 6 8 10 peak m³/s clipping Beyond factor ≈ 1.35 the applied change is no longer uniform, so the axis no longer means what it says.

The structural checks are worth running on every generated file, because each one catches a distinct edit failure and all four together take under a second.

Four Checks, Four Edit Failures Element counts catch a dropped section. Positive area and width catch a column written into the wrong field. Conduit endpoints catch a renamed node. Subcatchment outlets catch an outlet that no longer exists. element counts before vs after catches a dropped section positive area, width and slope catches a value in the wrong column conduit endpoints exist as nodes catches a renamed or deleted node outlets resolve to a real target catches an orphaned subcatchment SWMM errors on structural problems only, so a value in the wrong column runs happily and returns nonsense. These four checks are what catch that. Run them on every generated variant, not on the base model alone.

Gotchas and Edge Cases

  • Text substitution instead of section editing. Matches the same value in an unrelated section, and the file still parses.
  • Writing over the authored model. Loses comments, reorders sections, and turns the version-control diff into noise.
  • Editing by row position. Row order is not stable across a round trip. Edit by element name.
  • Infiltration columns assumed. The [INFILTRATION] columns mean different things under Horton, Green-Ampt and curve number. Read the model’s option first.
  • Clipping unreported. A sweep whose variable saturates silently produces a response curve that flattens for a non-physical reason.
  • No structural fingerprint. An edit that drops a section leaves a model that runs and is missing a third of its catchment. Comparing counts before and after costs nothing.
  • Units. SWMM’s unit system is a model-level option and the columns follow it. A width in feet applied to a metric model is off by 3.28 and looks plausible.