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:

  1. The Solver owns the engine handle and transitions through a deterministic sequence of states.

  2. Every domain class (Nodes, Links, …) holds a reference to a Solver and is only valid in certain states.

  3. C-API errors surface as EngineError exceptions; 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)).



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.ProcessPoolExecutor for 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.