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.
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.
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.
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:
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.
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_UNITSin[OPTIONS]— CFS/GPM/MGD for US, CMS/LPS/MLD for SI. Nothing in the API converts for you. Readsim.system_unitsonce 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
Outputobject 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
NaNfrom the Output API instead means you indexed with a timestamp that does not land exactly on aREPORT_STEPboundary — the series only holds values at those boundaries. -
Read continuity before leaving the
withblock.sim.runoff_errorandsim.flow_routing_errorare 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’swithblock. -
step_advanceresolution is not the.outresolution. The live-read cadence set bystep_advanceand the recorded cadence set byREPORT_STEPare independent. If your live series looks coarser or finer than the.outseries for the same model, that mismatch is why — reconcile them by settingstep_advanceequal toREPORT_STEPwhen 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.
Related Topics
- SWMM Model Integration with PySWMM — the parent guide covering the
.inpstructure, swmmio editing, algorithm mechanics, and the full production workflow - Automating HEC-HMS Simulations with the Jython Scripting API — the contrasting natural-basin approach, where simulations are scripted through HEC-HMS’s embedded Jython rather than a native Python object API
- Computing Nash-Sutcliffe Efficiency and KGE in Python — turning the extracted hydrograph into a quantitative goodness-of-fit score against a gauge