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 |
|---|---|---|
|
1 |
Engine handle allocated, no input file parsed yet. |
|
2 |
|
|
3 |
Initial conditions applied; arrays allocated. |
|
4 |
|
|
5 |
Inside the routing loop, |
|
6 |
|
|
7 |
|
|
8 |
Programmatic construction in progress (no |
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 binding checks the C return code. A non-zero code raises an
EngineError subclass chosen by the
underlying SWMM_ERR_*. Each subclass also inherits from a standard
library exception, so except IndexError: / except ValueError:
handlers work without any engine-specific imports:
from openswmm.engine import Solver, EngineError, BadIndexError
with Solver("model.inp", "model.rpt", "model.out") as s:
try:
depth = s.nodes["NO_SUCH_NODE"].depth
except KeyError: # also an EngineError subclass
depth = None
try:
depth = s.nodes[10_000].depth
except IndexError: # likewise
depth = None
Every EngineError carries .code (raw integer),
.code_enum (ErrorCode member), and
.message (filled from the C API — never construct one manually).
See Error handling, edge cases & debugging for the full subclass table, the stdlib bases
each one inherits from, and the recommended try / except idioms.
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.
As of OpenSWMM 6.0, the following Cython entry points release the GIL while inside the C engine, so two such threads execute their C work truly in parallel rather than serialising on the interpreter:
Solver.step(),Solver.stride()Every
*_bulkgetter / setter onNodes,Links, andSubcatchments(get_depths_bulk(),get_flows_bulk(),get_quality_bulk(), etc.)
Registered step-begin / step-end callbacks remain safe: their Cython trampolines reacquire the GIL via
noexcept with gil:before invoking the user’s Python callable.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.
Dates and times#
Date / time values exposed by the new bindings are Python
datetime.datetime objects; durations (timesteps, elapsed time,
the report step) are datetime.timedelta objects. The
underlying SWMM representation is a double (decimal days since
1899-12-30) which only surfaces when you reach into the low-level C API
yourself — see SWMM DateTime and Python datetime interop for the conversion rules and precision
bounds.
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.