Concepts & engine lifecycle#
Note
Engine: OpenSWMM 6 — refactored. This page describes the
reentrant openswmm.engine engine. The legacy SWMM 5
solver has its own (simpler, non-reentrant) lifecycle — see
Legacy SWMM 5 Solver.
Most user-visible behaviour follows from three concepts:
The
Solverowns the engine handle and transitions through a deterministic sequence of states.Every domain class (
Nodes,Links, …) holds a reference to a Solver and is only valid in certain states.C-API errors surface as
EngineErrorexceptions; nothing fails silently.
Get these three right and the rest of the API maps directly onto the underlying C headers.
The engine is reentrant#
Unlike SWMM 5, OpenSWMM 6 has no global state. Every Solver instance
owns an opaque SWMM_Engine handle, so multiple independent
simulations can run side-by-side in the same process — useful for
ensemble forecasting, parameter sweeps, or driving SWMM from a multi-
process optimiser.
from openswmm.engine import Solver
s1 = Solver("scenario_a.inp", "a.rpt", "a.out")
s2 = Solver("scenario_b.inp", "b.rpt", "b.out")
# s1 and s2 share no state — safe to interleave or run in threads
# (one thread per Solver; a single Solver is not thread-safe internally).
The cost is that every API call must be associated with a Solver —
either implicitly (with Solver(...) as s: ...) or by passing it to a
domain class (Nodes(s)).
EngineState — what’s legal when#
The Solver moves through a strict sequence of states. The current
state is exposed via Solver.state:
State |
Value |
Meaning |
|---|---|---|
|
0 |
Engine handle allocated, no input file parsed yet. |
|
1 |
|
|
2 |
Initial conditions applied; arrays allocated. |
|
3 |
|
|
4 |
Routing temporarily halted (reserved for future hot-swap support). |
|
5 |
|
|
6 |
|
|
7 |
|
Methods can require the Solver to be in:
a specific state (e.g.
Solver.step()requiresRUNNING),a range (e.g. node setter methods are valid in
OPENED…RUNNING— anywhere exceptCREATED/CLOSED), orany state (e.g.
Solver.stateitself).
When a method is called in the wrong state, the C engine returns a
non-zero error code which the Cython binding raises as
EngineError.
The “happy path” via the context manager#
Calling Solver as a context manager hides the state machine:
from openswmm.engine import EngineState
with Solver("model.inp", "model.rpt", "model.out") as s:
# state == RUNNING on entry
while s.state == EngineState.RUNNING:
rc = s.step()
if rc != 0:
break
# state == CLOSED on exit; engine handle has been freed
The context manager runs:
on entry: create() → open() → initialize() → start()
on exit: end() → report() → close() → destroy()
so the only thing you usually write is the inner loop.
The manual lifecycle#
For finer control (e.g. inspecting parsed objects between open() and
initialize(), or saving results selectively):
from openswmm.engine import EngineState
s = Solver("model.inp", "model.rpt", "model.out")
s.create()
s.open() # state == OPENED — inspect / edit the model here
s.initialize()
s.start(save_results=True) # state == RUNNING
while s.state == EngineState.RUNNING:
rc = s.step()
if rc != 0:
break
s.end() # state == ENDED
s.report() # state == REPORTED
s.close() # state == CLOSED, .rpt / .out flushed
s.destroy() # engine handle freed
Each lifecycle call (open(), initialize(), start(),
step(), stride(), end(), report(),
close()) returns the C error code as an int (0 on
success). create() and destroy() return None.
You must call Solver.destroy() (or use the context manager)
to release the C engine handle. Forgetting to do so leaks memory and
keeps file handles open.
The exception model#
Every Cython binding checks the C return code. Anything non-zero
becomes an EngineError:
from openswmm.engine import Solver, Nodes, EngineError
with Solver("model.inp", "model.rpt", "model.out") as s:
nodes = Nodes(s)
try:
nodes.get_depth("does-not-exist")
except EngineError as e:
print(f"engine returned {e.code}: {e.message}")
The full table of error codes is in the C header
openswmm_engine.h (SWMM_ERR_* constants); the Python enum
ErrorCode lifts the most common ones. The
message attribute is filled by the C API — never construct one
manually.
Common Python-side exceptions worth knowing:
KeyError— passed a string id that isn’t in the model.IndexError— passed an integer index that’s out of range.TypeError— passed the wrong argument type to a setter.ValueError— passed a value the engine rejects (e.g. negative max-depth on a junction).
These are raised before the C call dispatches, so they do not carry an engine error code.
Working with names vs. integer indices#
Every object (node, link, subcatchment, gage, pollutant, table, …) has
both a string id (from the .inp) and an integer index (the
position in the engine’s internal array). Almost every accessor takes
either form:
nodes.get_depth("J1") # by name — looks up the index each call
nodes.get_depth(0) # by index — direct array access
In hot loops, prefer the integer form:
from openswmm.engine import EngineState
j1 = nodes.get_index("J1")
while s.state == EngineState.RUNNING:
if s.step() != 0:
break
d = nodes.get_depth(j1) # no name lookup overhead
Indices are stable for the lifetime of the Solver (they correspond to
positions in the C arrays). They are not stable across runs of
different models — always re-resolve via get_index() after
opening a new Solver.
Bulk arrays#
Where the engine exposes a homogeneous array of values across all
nodes / links / subcatchments, the Python layer offers a vectorised
companion accessor with the suffix _bulk:
nodes.get_depths_bulk() # → np.ndarray[float64], shape (n_nodes,)
nodes.get_heads_bulk()
nodes.set_depths_bulk(arr) # arr must be float64, shape (n_nodes,)
links.get_flows_bulk() # → np.ndarray[float64], shape (n_links,)
The returned array shares memory with an internal scratch buffer
that the engine reuses on the next call. Read-once-and-discard usage
is safe; if you keep the array around (e.g. across a step), call
.copy() first.
Threading & multiprocessing#
Multiple processes: fully supported. Each child process gets its own engine handle. Use
multiprocessing/concurrent.futures.ProcessPoolExecutorfor ensemble runs.Multiple threads, one Solver per thread: supported. The C engine is reentrant; two threads each holding their own Solver do not interact.
Multiple threads, one shared Solver: not supported. The Solver and its domain classes assume a single-threaded caller. If you need shared state, drive a single Solver from one thread and feed work to it via a queue.
Backward compatibility with SWMM 5#
The legacy SWMM 5.x solver remains available via
openswmm.legacy.engine. Existing code that does
from openswmm.legacy.engine import Solver
Solver.run("model.inp", "model.rpt", "model.out")
continues to work unchanged. See Migrating from SWMM 5 to v6 for translating SWMM 5 patterns to the new v6.0 engine.