SWMM Model Integration with PySWMM: A Python Workflow

The EPA Storm Water Management Model (SWMM 5) is the reference engine for urban rainfall-runoff and drainage simulation, and pyswmm exposes its computational core to Python so you can drive a full hydraulic run without opening the desktop application. As part of the Rainfall-Runoff Modeling & Hydrologic Simulation domain, SWMM integration is what turns a static drainage network description into a scripted, reproducible experiment: you load a model, mutate parameters, step the solver, read node and link state as it runs, and pull the binary results back into pandas. This guide covers the toolchain (pyswmm, swmmio, swmm-toolkit), the anatomy of the .inp input file, the runoff and routing algorithms SWMM solves, a complete production function, and the continuity checks that separate a trustworthy run from a plausible-looking one.

Where SWMM excels at engineered drainage — pipes, inlets, storage, pumps, and detention in the urban environment — natural basin response is often better handled by the tools covered in HEC-HMS Python automation. Once a model runs cleanly, the parameters that control its fit to observed flow are tuned with the objective functions described under model calibration objective functions. This page focuses on the mechanics of getting SWMM to run, report, and validate from Python.


Prerequisites & Environment Setup

SWMM integration in Python rests on three packages that share the same underlying C library but serve different roles. Pin versions explicitly — the binary interface between pyswmm and the SWMM engine changes across major releases, and a mismatched swmm-toolkit wheel is the most common cause of an ImportError at Simulation() construction.

Library Minimum version Role
python 3.9 Runtime
pyswmm 2.0 Object API: Simulation, Nodes, Links, Subcatchments, the stepping loop
swmm-toolkit 0.15 Compiled SWMM 5.2 engine plus the low-level output and solver bindings
swmmio 0.7 Reads and edits the .inp as pandas tables; parses the .rpt
pandas 1.5 Time-series assembly and post-processing
matplotlib 3.6 Hydrograph plotting
bash
python -m venv .venv && source .venv/bin/activate
pip install "pyswmm>=2.0" "swmmio>=0.7" pandas matplotlib
# swmm-toolkit is pulled in automatically as a pyswmm dependency;
# pin it explicitly if you need a specific SWMM engine build:
pip install "swmm-toolkit==0.15.5"

pyswmm bundles the SWMM engine as a compiled wheel, so no separate SWMM installation is required and the workflow runs headless on Linux CI runners. The full engine reference lives in the EPA Storm Water Management Model documentation; consult it for the exact meaning of every .inp keyword. Input model requirements: a syntactically valid SWMM 5 .inp with consistent units (either US or SI, set in [OPTIONS]), every node reachable to an outfall, and conduit geometry that will not force perpetual surcharge under design flow.


Anatomy of the SWMM .inp File

A SWMM model is a plain-text .inp file divided into bracketed sections. The engine reads it top to bottom, resolving cross-references by object name, so an entry in [SUBCATCHMENTS] that points at a nonexistent outlet node fails validation before the first timestep. The sections that define the rainfall-to-outfall chain are:

  • [RAINGAGES] — precipitation sources. Each gage names a data source (an inline time series, an external file, or a [TIMESERIES] block) and a recording interval such as 0:05 for 5-minute data. Every subcatchment references exactly one raingage.
  • [SUBCATCHMENTS] — the runoff-producing land units. Each row carries area, imperviousness percentage, width, slope, and the raingage and outlet node it drains to.
  • [SUBAREAS] — per-subcatchment Manning’s n and depression storage for the impervious and pervious fractions, plus the internal routing split between them.
  • [INFILTRATION] — the loss model parameters (Horton, Green-Ampt, or Curve Number) applied to the pervious fraction of each subcatchment.
  • [JUNCTIONS] — the manholes and network nodes, each with invert elevation, maximum depth, and initial depth.
  • [CONDUITS] — the links (pipes and channels) connecting nodes, each with an upstream and downstream node, length, roughness, and a cross-section defined in [XSECTIONS].
  • [OUTFALLS] — the terminal boundary nodes where flow leaves the model, with a boundary condition of FREE, NORMAL, FIXED stage, or a tidal/time-series stage.

The schematic below traces one rainfall pulse through these sections: a gage drives two subcatchments, each subcatchment discharges runoff to a junction, conduits route that flow downstream, and everything converges on a single outfall.

SWMM Network Schematic — Raingage to Outfall A raingage at the top feeds rainfall into two subcatchments side by side, each containing subareas and an infiltration model. Each subcatchment discharges runoff into a junction below it. Two conduits, C1 and C2, route the junction flows down to a single shared outfall node at the bottom. [RAINGAGES] RG1 · 5-min intensity SUBCATCHMENT S1 SUBAREAS + INFILTRATION SUBCATCHMENT S2 SUBAREAS + INFILTRATION JUNCTION J1 JUNCTION J2 OUTFALL O1 FREE / FIXED stage rainfall rainfall runoff runoff CONDUIT C1 CONDUIT C2

Editing the .inp with swmmio

You rarely author these sections by hand for a parameter sweep. swmmio reads the entire .inp into a Model object whose sections are exposed as pandas DataFrames, so a parameter change is a DataFrame edit followed by a write:

python
import logging
import swmmio

logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")

def widen_impervious(inp_in: str, inp_out: str, factor: float) -> None:
    """Scale the percent-impervious of every subcatchment and write a new .inp."""
    model = swmmio.Model(inp_in)
    subs = model.inp.subcatchments          # a pandas DataFrame, index = subcatchment name
    logging.info("Loaded %d subcatchments from %s", len(subs), inp_in)

    subs["PercImperv"] = (subs["PercImperv"] * factor).clip(upper=100.0)
    model.inp.subcatchments = subs
    model.inp.save(inp_out)
    logging.info("Wrote scaled model (factor=%.2f) to %s", factor, inp_out)

Because swmmio round-trips the whole file, comments and section ordering are preserved, which keeps model diffs readable in version control. Editing tables this way is far safer than string substitution: a typo in a hand-edited [SUBCATCHMENTS] row silently shifts every column.


Algorithm Mechanics

SWMM solves two coupled physical problems in sequence at every timestep: it converts rainfall to runoff at each subcatchment, then routes that runoff as unsteady flow through the conduit network.

Runoff generation — the nonlinear reservoir. Each subcatchment is modeled as a nonlinear reservoir whose depth of ponded water rises with rainfall and falls with infiltration and outflow. Outflow is computed with Manning’s equation across the subcatchment width, and it only begins once ponded depth exceeds the depression storage in [SUBAREAS]. Infiltration draws down the pervious fraction using the Horton, Green-Ampt, or Curve Number model named in [INFILTRATION]. The result is a runoff hydrograph delivered to the subcatchment’s outlet node. This lumped conceptual runoff response is closely related to the loss-and-transform methods covered under curve number runoff estimation and unit hydrograph methods; SWMM simply solves the reservoir continuously rather than convolving a unit response.

Flow routing — dynamic wave vs. kinematic wave. The runoff entering junctions is routed through conduits using one of three schemes set in [OPTIONS] under ROUTING_MODEL:

  • Steady flow — a uniform, translation-only approximation; fast but ignores storage and backwater.
  • Kinematic wave — solves continuity plus a simplified momentum equation; stable at larger timesteps but cannot represent backwater, surcharge, pressurized flow, or reverse flow.
  • Dynamic wave — solves the full one-dimensional Saint-Venant equations, capturing backwater, surcharge, flow reversal, and looped networks. It is the only option that correctly handles pressurized pipes and downstream boundary effects, at the cost of a much smaller stable routing timestep.

Two timesteps, two meanings. SWMM distinguishes the routing step (the internal integration interval, ROUTING_STEP, often 5 to 30 seconds for dynamic wave) from the reporting step (REPORT_STEP, the interval at which results are written, commonly 1 to 5 minutes). The routing step governs numerical stability; the reporting step governs output resolution and file size. In the pyswmm loop, sim.step_advance(seconds) sets how far the solver advances before yielding control back to Python, and that advance interval should be a multiple of the routing step.


Step-by-Step Workflow

The pyswmm object API mirrors the .inp structure: Nodes, Links, and Subcatchments are dict-like accessors keyed by the object names in the input file, and Simulation is a context manager that opens the engine, runs it, and closes the report and output files cleanly on exit.

The pyswmm Stepping Loop A vertical flow of six stages. First, open a Simulation as a context manager. Second, set the reporting step and step_advance. Third, enter the loop for step in sim. Fourth, read Nodes and Links state each step. Fifth, append the values to time-series lists; a loopback arrow returns from this stage to the loop head. Sixth, after the loop, call sim.report and check the runoff and flow-routing continuity errors. with Simulation(inp) as sim: opens engine, .rpt and .out handles sim.step_advance(300) advance interval = multiple of routing step for step in sim: Nodes[j].total_inflow · Links[c].flow live read of solver state this step series.append(value) accumulate hydrograph in memory sim.report() · check continuity errors runoff_error, flow_routing_error next step loop ends

Step 1 — Load the model and set the reporting step

Open the .inp inside a with Simulation(...) context so the engine, report file, and output file are always closed even if an exception fires mid-run. Set sim.step_advance once before the loop to control how far the solver runs between yields.

python
from pyswmm import Simulation

with Simulation("urban_model.inp") as sim:
    sim.step_advance(300)   # advance 300 s (5 min) per Python iteration
    for step in sim:
        pass                # solver runs; state is readable here each step

Instantiate the accessor objects against the open simulation. They are lazy views into the running engine, so read them inside the loop to get the current step’s values.

python
from pyswmm import Simulation, Nodes, Links, Subcatchments

with Simulation("urban_model.inp") as sim:
    junction = Nodes(sim)["J1"]
    conduit = Links(sim)["C1"]
    catchment = Subcatchments(sim)["S1"]
    sim.step_advance(300)
    for step in sim:
        inflow = junction.total_inflow          # cfs or cms per model units
        depth = junction.depth                   # ponded/hydraulic depth at node
        flow = conduit.flow                       # discharge in the conduit
        runoff = catchment.runoff                 # subcatchment runoff rate

Step 3 — Collect series and stamp each step with its time

Each yielded step carries the elapsed simulation time via sim.current_time. Append it alongside the readings to build a properly indexed series. Reading state live like this is the exact pattern developed in depth under running and post-processing SWMM simulations with pyswmm.

Step 4 — Validate continuity after the loop

Once the loop exits (but still inside the context), call sim.report() to flush the summary and read sim.runoff_error and sim.flow_routing_error. Both are mass-balance percentages; anything above 5 percent invalidates the run.


Production-Ready Code

The function below wires the whole workflow together: it loads a model, attaches accessors for a chosen node and link, runs the stepping loop while collecting series, and validates continuity before returning a pandas DataFrame ready for plotting or calibration.

python
import logging
from pathlib import Path

import pandas as pd
from pyswmm import Simulation, Nodes, Links, Subcatchments

logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")


def run_swmm_model(
    inp_path: str,
    node_id: str,
    link_id: str,
    subcatchment_id: str,
    report_step_s: int = 300,
    continuity_threshold: float = 5.0,
) -> pd.DataFrame:
    """
    Run an EPA SWMM 5 model through pyswmm and return a time-indexed hydrograph.

    Loads the .inp, steps the solver at a fixed reporting interval, records node
    inflow/depth, conduit flow, and subcatchment runoff at every step, then checks
    the runoff and flow-routing continuity errors against a mass-balance threshold.

    Args:
        inp_path:             Path to a valid SWMM 5 .inp file.
        node_id:              Name of a JUNCTION or OUTFALL to monitor.
        link_id:              Name of a CONDUIT to monitor.
        subcatchment_id:      Name of a SUBCATCHMENT to monitor.
        report_step_s:        Advance interval per iteration, in seconds.
        continuity_threshold: Max acceptable continuity error, percent.

    Returns:
        DataFrame indexed by datetime with columns
        [node_inflow, node_depth, link_flow, subcatchment_runoff].
    """
    if not Path(inp_path).exists():
        raise FileNotFoundError(f"SWMM input not found: {inp_path}")

    times, node_inflow, node_depth, link_flow, sub_runoff = [], [], [], [], []

    logging.info("Opening SWMM model: %s", inp_path)
    with Simulation(inp_path) as sim:
        junction = Nodes(sim)[node_id]
        conduit = Links(sim)[link_id]
        catchment = Subcatchments(sim)[subcatchment_id]

        sim.step_advance(report_step_s)
        logging.info(
            "System units: %s | advance interval: %d s",
            sim.system_units, report_step_s,
        )

        for _ in sim:
            times.append(sim.current_time)
            node_inflow.append(junction.total_inflow)
            node_depth.append(junction.depth)
            link_flow.append(conduit.flow)
            sub_runoff.append(catchment.runoff)

        # sim.report() writes the .rpt summary; call before leaving the context
        sim.report()
        runoff_err = sim.runoff_error
        routing_err = sim.flow_routing_error
        logging.info(
            "Continuity — runoff: %.2f%%  flow routing: %.2f%%",
            runoff_err, routing_err,
        )

    # --- Validate mass balance ---
    for name, err in (("runoff", runoff_err), ("flow_routing", routing_err)):
        if abs(err) > continuity_threshold:
            logging.warning(
                "%s continuity error %.2f%% exceeds %.1f%% threshold — "
                "reduce ROUTING_STEP or inspect surcharged conduits.",
                name, err, continuity_threshold,
            )

    frame = pd.DataFrame(
        {
            "node_inflow": node_inflow,
            "node_depth": node_depth,
            "link_flow": link_flow,
            "subcatchment_runoff": sub_runoff,
        },
        index=pd.to_datetime(times),
    )
    frame.index.name = "datetime"
    logging.info("Collected %d reporting steps into DataFrame.", len(frame))
    return frame


if __name__ == "__main__":
    hydrograph = run_swmm_model(
        inp_path="data/urban_model.inp",
        node_id="J1",
        link_id="C1",
        subcatchment_id="S1",
    )
    peak = hydrograph["link_flow"].max()
    logging.info("Peak conduit flow: %.3f (model flow units)", peak)
    hydrograph.to_csv("outputs/swmm_hydrograph.csv")

Validation Protocol

A SWMM run is only defensible once its mass balance closes and its output resembles reality. Run these checks before reporting any result.

Continuity (mass-balance) error. SWMM reports two independent continuity errors. The runoff continuity error measures whether rainfall in equals infiltration plus runoff plus storage change across all subcatchments. The flow-routing continuity error measures whether inflow to the conduit network equals outflow plus stored volume change. Both are exposed on the Simulation object as sim.runoff_error and sim.flow_routing_error and are also printed near the top of the .rpt file. Target under 1 percent for a well-conditioned model; treat anything above 5 percent as a failed run.

Peak and volume against a gauge. Compare the modeled outfall hydrograph to observed flow at a monitored outfall or stream gauge. Peak magnitude, time-to-peak, and total runoff volume are the three primary comparison metrics. When you formalize that comparison into a single score, use the efficiency metrics under model calibration objective functions rather than eyeballing the overlay.

Node flooding summary. The .rpt node-flooding table lists any junction that surcharged and lost volume to flooding. Unexpected flooding at a node points to an undersized downstream conduit or an invert set higher than its upstream neighbor.

Timestep sensitivity. Halve ROUTING_STEP and rerun. If the continuity error and peak flow move materially, the original timestep was too large and the coarser run was numerically diffusing the hydrograph.


Common Failure Modes & Optimization

Routing instability under dynamic wave. The full Saint-Venant solver becomes unstable when the Courant condition is violated — flow crosses a conduit in less time than one routing step. Symptoms are oscillating depths, spurious spikes in Links[...].flow, and a climbing continuity error. Reduce ROUTING_STEP, enable variable-step routing with a Courant-based VARIABLE_STEP factor, and check for very short conduits that dominate the stability limit. Lengthening a trivially short pipe or merging it with its neighbor often restores stability.

Continuity error above 5 percent. Almost always a timestep or topology problem. Reduce the routing step first. If the error persists, look for a node with no path to an outfall (flow accumulates with nowhere to go), a conduit with adverse slope, or an offset/invert inconsistency between [JUNCTIONS] and [CONDUITS].

Timestep too large. A REPORT_STEP much larger than the true hydrograph rise time aliases the peak — you record the shoulders of the flood wave but miss its crest. Set the reporting step no coarser than about one tenth of the time-to-peak, and keep the routing step well below that.

Negative or spurious depths. Negative node depths signal that the solver over-drained a node in a single step, again a Courant/timestep symptom under dynamic wave. They can also arise from an initial depth in [JUNCTIONS] that is inconsistent with the outfall boundary. Zero the initial depths and rerun to isolate the cause.

Unit confusion between US and SI. SWMM runs entirely in the unit system declared in [OPTIONS] FLOW_UNITS. CFS, GPM, and MGD select US units; CMS, LPS, and MLD select SI. Every value you read from Nodes, Links, and Subcatchments is in those units, and mixing a US model with SI post-processing silently scales every hydrograph. Read sim.system_units once and assert it matches your expectations.


When to Use SWMM vs. Alternatives

SWMM is the right engine when the drainage system itself is the model: storm sewers, inlets, detention basins, pumps, weirs, orifices, and the pressurized or surcharged flow that dynamic-wave routing captures. Its subcatchment-plus-network structure maps directly onto engineered urban catchments and combined-sewer systems, and pyswmm’s live state access makes it the natural choice when you need real-time control rules or step-by-step coupling to another model.

For natural basins — forested headwaters, large rural watersheds, reservoir operations — the lumped and semi-distributed methods in HEC-HMS Python automation are usually a better fit: they represent basin-scale loss, transform, and baseflow processes without requiring you to enumerate every pipe. Many production studies use both, with SWMM handling the urbanized sub-basins and HEC-HMS the surrounding rural drainage.

Whichever engine you choose, the parameters that make it match observed flow are found by optimization, not by hand. Once your SWMM model runs with acceptable continuity, move to model calibration objective functions to tune imperviousness, roughness, and infiltration against a gauge, and revisit curve number runoff estimation if the runoff volumes are systematically biased before routing ever enters the picture.


Frequently Asked Questions

What is the difference between the reporting step and the routing step in SWMM?

The routing step is the internal integration interval the dynamic-wave or kinematic solver uses to advance the flow equations, and it must be small enough for numerical stability under the Courant condition. The reporting step is the coarser interval at which SWMM writes results to the .rpt and .out files. In pyswmm the loop yields at the reporting step by default, but sim.step_advance(seconds) lets you set a custom advance interval, which must be a whole multiple of the routing step.

What continuity error is acceptable for a SWMM run?

Both the runoff continuity error and the flow-routing continuity error should stay under about 5 percent for a defensible result, and under 1 percent for a well-conditioned urban model. Values above 10 percent almost always indicate a routing timestep that is too large, an undersized conduit forcing perpetual surcharge, or a node with no valid path to an outfall.

Do I have to run SWMM step by step, or can I run it all at once?

You can call sim.execute() to run the whole simulation without a Python loop, which is fastest when you only need the output files. Use the for step in sim loop when you need to read or change state during the run — reading a node depth to drive a control rule, or logging live flows. The stepping loop trades raw speed for interactivity.