Running and Post-Processing SWMM Simulations with PySWMM

Getting a hydrograph out of a SWMM model is a two-stage job: run the solver while optionally watching its state, then read the recorded results back for analysis. This page is the focused how-to for both stages under the SWMM Model Integration with PySWMM workflow, which itself sits within the broader Rainfall-Runoff Modeling & Hydrologic Simulation coverage on this site. Here we run the stepping loop with sim.step_advance, read Nodes[...].total_inflow, Nodes[...].depth, and Links[...].flow live, check the continuity errors, and then open the binary .out file through the Output API to pull complete series into pandas and plot them.

There are two distinct ways to get numbers out of pyswmm, and knowing which to reach for is most of the battle. Live reads happen inside the loop and reflect the solver’s current step. Output-file reads happen after the run and return the full recorded series for any object. Live reads let you react to state; output reads give you the clean, complete record.

Two Ways to Extract SWMM Results On the left, during the run, the stepping loop reads Nodes total_inflow and depth and Links flow into in-memory lists. On the right, after the run, the Output API opens the binary out file and returns node_series and link_series. Both paths converge into a pandas DataFrame that feeds a matplotlib hydrograph. DURING RUN — live reads for step in sim: Nodes / Links append to Python lists stamped with sim.current_time AFTER RUN — Output API Output('model.out') node_series / link_series complete recorded time series pandas DataFrame matplotlib hydrograph

Running the Stepping Loop with Live Reads

The Simulation object is a context manager and an iterator. Entering the with block opens the SWMM engine and its report and output files; iterating for step in sim advances the solver. Call sim.step_advance(seconds) once before the loop to fix how far the engine runs between yields — this is the reporting resolution of your live series, and it should be a whole multiple of the model’s routing step.

python
import logging

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

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


def run_with_live_reads(inp_path: str, node_id: str, link_id: str) -> pd.DataFrame:
    """Run a SWMM model and capture node/link state at every reporting step."""
    times, inflow, depth, flow = [], [], [], []

    with Simulation(inp_path) as sim:
        node = Nodes(sim)[node_id]        # dict-like accessor, keyed by .inp name
        link = Links(sim)[link_id]
        sim.step_advance(300)             # yield to Python every 300 s of sim time
        logging.info("Units: %s", sim.system_units)  # US or SI — read once, trust it

        for _ in sim:                     # each iteration = one advance interval
            times.append(sim.current_time)      # elapsed sim datetime for this step
            inflow.append(node.total_inflow)     # total inflow to the node (flow units)
            depth.append(node.depth)             # hydraulic depth at the node
            flow.append(link.flow)               # discharge in the conduit

        # Read continuity AFTER the loop but BEFORE leaving the context manager,
        # otherwise the engine has already been torn down and the values are gone.
        logging.info(
            "Continuity — runoff %.2f%%  routing %.2f%%",
            sim.runoff_error, sim.flow_routing_error,
        )

    frame = pd.DataFrame(
        {"total_inflow": inflow, "depth": depth, "link_flow": flow},
        index=pd.to_datetime(times),
    )
    frame.index.name = "datetime"
    return frame

The critical ordering rule: sim.runoff_error and sim.flow_routing_error are only valid after the loop finishes and while the context is still open. Read them at the bottom of the with block, never after it. If either exceeds roughly 5 percent, the run’s mass balance did not close and the hydrograph is not trustworthy — reduce the routing step and rerun before doing anything else with the numbers.

Reading the Binary .out File with the Output API

When the run finishes, SWMM has already written every reported value for every object to a compact binary .out file. The Output API reads that file directly, which is the cleanest way to get the complete recorded series without instrumenting the loop. pyswmm exposes it as the Output class, backed by swmm.toolkit.output, and the attribute enums live in swmm.toolkit.shared_enum.

python
import logging

import pandas as pd
from pyswmm import Output
from swmm.toolkit.shared_enum import NodeAttribute, LinkAttribute

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


def read_out_file(out_path: str, node_id: str, link_id: str) -> pd.DataFrame:
    """Load complete node and link series from a binary SWMM .out file."""
    with Output(out_path) as out:            # context manager closes the file handle
        # Each *_series call returns a dict: {datetime: value} at every REPORT_STEP
        node_inflow = out.node_series(node_id, NodeAttribute.TOTAL_INFLOW)
        node_depth = out.node_series(node_id, NodeAttribute.INVERT_DEPTH)
        link_flow = out.link_series(link_id, LinkAttribute.FLOW_RATE)
        logging.info("Read %d reporting steps from %s", len(link_flow), out_path)

    frame = pd.DataFrame(
        {
            "total_inflow": pd.Series(node_inflow),
            "invert_depth": pd.Series(node_depth),
            "link_flow": pd.Series(link_flow),
        }
    )
    frame.index.name = "datetime"
    return frame

Each node_series or link_series call returns an ordered dictionary keyed by datetime, so wrapping it in pd.Series yields a time-indexed column immediately. The .out file stores whatever REPORT_STEP was set to in [OPTIONS], so its resolution is fixed at model build time and is independent of the step_advance interval you chose for live reads.

To sweep every object rather than a named few, the Output object exposes the model’s element inventory: out.nodes, out.links, and out.subcatchments each return the ordered list of names recorded in the file. Looping over out.nodes and calling node_series for each lets you assemble a wide DataFrame of, say, total inflow at every junction with a single pass over the file:

python
import logging

import pandas as pd
from pyswmm import Output
from swmm.toolkit.shared_enum import NodeAttribute

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


def all_node_inflows(out_path: str) -> pd.DataFrame:
    """Return a wide DataFrame of total inflow for every node in the .out file."""
    with Output(out_path) as out:
        logging.info("Model has %d nodes recorded", len(out.nodes))
        columns = {
            name: pd.Series(out.node_series(name, NodeAttribute.TOTAL_INFLOW))
            for name in out.nodes
        }
    frame = pd.concat(columns, axis=1)
    frame.index.name = "datetime"
    return frame

Because the Output API reads directly from the compact binary file, this whole-network pull is fast even for models with hundreds of nodes, and it avoids the memory cost of holding every object’s live series in Python during the run.

Plotting the Hydrograph

With either DataFrame in hand, plotting is a few lines of matplotlib. Overlay node inflow and conduit flow to see how the network attenuates the runoff peak.

python
import logging

import matplotlib.pyplot as plt

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


def plot_hydrograph(frame, title: str, png_path: str) -> None:
    """Plot node inflow and conduit flow versus time and save to disk."""
    fig, ax = plt.subplots(figsize=(9, 4))
    ax.plot(frame.index, frame["total_inflow"], label="Node total inflow")
    ax.plot(frame.index, frame["link_flow"], label="Conduit flow", linestyle="--")
    ax.set_xlabel("Time")
    ax.set_ylabel("Flow (model units)")
    ax.set_title(title)
    ax.legend()
    fig.autofmt_xdate()
    fig.tight_layout()
    fig.savefig(png_path, dpi=120)
    plt.close(fig)
    logging.info("Peak conduit flow %.3f; wrote plot to %s",
                 frame["link_flow"].max(), png_path)


if __name__ == "__main__":
    df = read_out_file("data/urban_model.out", node_id="J1", link_id="C1")
    plot_hydrograph(df, "Outfall Hydrograph — J1 / C1", "outputs/hydrograph.png")

For a formal fit against observed flow, feed these series into the efficiency metrics in computing Nash-Sutcliffe efficiency and KGE in Python rather than judging the overlay by eye.

Parameter Reference

Call / attribute Type Meaning
sim.step_advance(seconds) method Seconds of simulation time the solver runs per loop iteration; must be a whole multiple of ROUTING_STEP
REPORT_STEP (in [OPTIONS]) model setting Interval at which results are written to .rpt/.out; fixes the resolution of the Output API series
sim.current_time property Elapsed simulation datetime at the current step; use to index live series
sim.system_units property US or SI — governs the units of every node, link, and subcatchment reading
sim.runoff_error property Runoff continuity (mass-balance) error, percent; read after the loop
sim.flow_routing_error property Flow-routing continuity error, percent; read after the loop
Nodes(sim)[id].total_inflow property Total inflow to a node in the model’s flow units
Nodes(sim)[id].depth property Live hydraulic depth at a node during the run
out.node_series(id, NodeAttribute.X) method Full {datetime: value} series for a node attribute from the .out
out.link_series(id, LinkAttribute.X) method Full {datetime: value} series for a link attribute from the .out
NodeAttribute enum TOTAL_INFLOW, INVERT_DEPTH, FLOODING_LOSSES, HYDRAULIC_HEAD, and more
LinkAttribute enum FLOW_RATE, FLOW_DEPTH, FLOW_VELOCITY, CAPACITY, and more

Gotchas & Edge Cases

  • US vs. SI units are silent. Every value you read carries the units declared by FLOW_UNITS in [OPTIONS] — CFS/GPM/MGD for US, CMS/LPS/MLD for SI. Nothing in the API converts for you. Read sim.system_units once and assert it matches your downstream assumptions, or a US-units model post-processed as SI will scale every hydrograph without any error being raised.

  • Close the Output handle. The Output object holds an open file descriptor to the binary .out. Always use it as a context manager (with Output(path) as out:). Leaving it open locks the file on Windows so a re-run cannot overwrite it, and it leaks descriptors across a batch of hundreds of models.

  • NaN and zeros at warmup. The opening reporting steps cover the dry period before rainfall and before storage fills, so zero flow and zero depth are physically correct there, not a bug. Genuine NaN from the Output API instead means you indexed with a timestamp that does not land exactly on a REPORT_STEP boundary — the series only holds values at those boundaries.

  • Read continuity before leaving the with block. sim.runoff_error and sim.flow_routing_error are populated by the engine at the end of the run and become inaccessible once the context manager tears the simulation down. Read and log them at the bottom of the loop’s with block.

  • step_advance resolution is not the .out resolution. The live-read cadence set by step_advance and the recorded cadence set by REPORT_STEP are independent. If your live series looks coarser or finer than the .out series for the same model, that mismatch is why — reconcile them by setting step_advance equal to REPORT_STEP when you want the two to align.

Frequently Asked Questions

Why do my SWMM output series start with NaN or zero values?

The first few reporting steps cover the model warmup before rainfall arrives and before storage fills, so node depths and conduit flows are legitimately zero or near zero. If you see genuine NaN rather than zero, it usually means you indexed the series with a timestamp that does not fall exactly on a reporting step. The Output API only returns values at REPORT_STEP boundaries.

Do I need to close the Output handle explicitly?

Yes. The Output object holds an open file handle to the binary .out. Use it as a context manager with with Output(path) as out so the handle is released on exit. Leaving it open locks the file on Windows and leaks descriptors in long-running batch jobs.

Should I collect results live in the loop or read them from the .out file afterward?

Read from the .out file when you want the complete recorded time series for every object and only need it after the run. Collect live in the loop when you need to react to state during the simulation, or when you want a value at a resolution the .out does not store. The two approaches are complementary and often used together.