Error handling, edge cases & debugging#

Note

Engine: OpenSWMM 6 — refactored.

This page is a cross-cutting reference for the exception model, the EngineState rules that govern when each method is callable, and the patterns we recommend for robust scripts.

For the underlying lifecycle see Concepts & engine lifecycle.


The exception model in one paragraph#

Every Cython binding checks the C return code. Anything non-zero raises an EngineError, and the message is filled in by the C API — you don’t construct one yourself. Pure-Python checks (wrong-type argument, out-of-range index, missing id) raise TypeError / IndexError / KeyError before the C call dispatches, so they don’t carry an engine error code.

from openswmm.engine import Solver, Nodes, EngineError, EngineState

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}")
    except KeyError as e:
        print(f"missing id: {e}")

EngineState reference (cheat-sheet)#

The Solver moves through these states in strict order:

State

Value

Meaning

CREATED

0

Engine handle allocated; no input parsed.

OPENED

1

.inp parsed; objects accessible for inspection / editing.

INITIALIZED

2

Initial conditions applied; arrays allocated.

RUNNING

3

start() called; routing loop active, step() callable.

PAUSED

4

Routing temporarily halted (reserved for future hot-swap support).

ENDED

5

end() called; cumulative results available.

REPORTED

6

report() called; summary written.

CLOSED

7

close() called; .rpt / .out flushed.

What you can do in each state#

You want to …

Required state(s)

See

Add objects (ModelBuilder)

pre-Solver

Programmatic model construction

Edit / delete / convert objects

OPENED

Model editing (deletion + type conversion)

Read object identity / topology

OPENED …

Nodes, Links, Subcatchments

Read geometry (invert, length, …)

OPENED …

Nodes, Links

Set geometry / parameters (invert, n, …)

OPENED

same

Apply initial conditions

INITIALIZED

Running a simulation — Solver

Read hydraulic state (depth, flow, …)

RUNNING or ENDED

Nodes, Links

One-shot per-step setters (set_depth, set_flow, …)

RUNNING

Nodes, Links

Persistent runtime forcing

RUNNING

Advanced forcing

Add / clear control rules

OPENED or RUNNING

Control rules

Read continuity / mass-balance

ENDED (final), RUNNING (partial)

Mass balance

Read accumulated statistics

RUNNING or ENDED

Statistics

Save a hot-start

RUNNING or ENDED

Hot start

Apply a hot-start

OPENED or INITIALIZED

Hot start

Persist edits to .inp

OPENED …

Running a simulation — Solver (Solver.model_write())

A method called outside its state envelope raises EngineError. The most common codes:

Code

Name

Meaning

20

ERR_API_NOT_OPEN

Method needs OPENED; solver still CREATED.

21

ERR_API_NOT_STARTED

Called step() before start().

22

ERR_API_NOT_ENDED

Called report() before end().

23

ERR_API_INVALID_TYPE

Wrong object kind (e.g. setting a pump curve on a conduit).

The full enum is ErrorCode.


Defensive patterns#

Always use the context manager for the Solver#

with Solver("model.inp", "model.rpt", "model.out") as s:
    ...                # raises here are still cleaned up

The context manager runs end → report → close → destroy even if your loop body raises. Skipping it leaks the engine handle on error.

Resolve names once, outside the loop#

j1 = nodes.get_index("J1")     # raises KeyError if not in model
while s.state == EngineState.RUNNING:
    if s.step() != 0:
        break
    d = nodes.get_depth(j1)    # no per-step name-lookup overhead

This catches typos at startup rather than after a long run.

Validate model state before running#

s.open()
if nodes.count() == 0:
    raise RuntimeError("model has no nodes")
if links.count() == 0:
    raise RuntimeError("model has no links")

# Programmatic edits: catch issues now, not at step()
s.initialize()

Verify continuity after the run#

from openswmm.engine import MassBalance

with Solver("model.inp", "model.rpt", "model.out") as s:
    while s.state == EngineState.RUNNING:
        if s.step() != 0:
            break
    mb = MassBalance(s)
    if abs(mb.get_routing_continuity_error()) > 2.0:
        raise RuntimeError(
            f"routing continuity {mb.get_routing_continuity_error():+.4f}% > 2%"
        )

Wrap third-party callbacks#

If you register progress / step callbacks that call back into your own Python code, isolate exceptions so a callback bug doesn’t tear down the run mid-way:

def _safe(fn):
    def wrapper(*a, **kw):
        try:
            return fn(*a, **kw)
        except Exception as e:
            import traceback; traceback.print_exc()
            # swallow — never propagate into the C engine
    return wrapper

run_with_callback("model.inp", "model.rpt", "model.out",
                  callback=_safe(my_progress_handler))

Edge cases & gotchas#

  • Bulk-array memory aliasing. *_bulk() methods return arrays that share memory with an internal scratch buffer. Read-once is fine; if you keep the array, .copy() it.

  • Index stability. Integer indices are stable for the lifetime of a single Solver but not across runs of different models. Always re-resolve via get_index() after Solver.open().

  • Float precision in `set_*` / `get_*` round-trips. The C engine stores most values in double precision; a few legacy code paths use single precision internally. set_x(v); get_x() == v is not guaranteed to be exact for those paths.

  • String ids are bytes-encoded UTF-8 on the C side. Non-ASCII ids work, but the C engine truncates at the first NUL byte and caps at ~80 chars.

  • Threading. One Solver per thread is supported; a single Solver is not thread-safe. See Concepts & engine lifecycle for the full contract.


Debugging tips#

Build with DEBUG=1#

DEBUG=1 pip install -e . --no-build-isolation

Produces unoptimised binaries with full debug symbols, suitable for lldb / gdb / IDE step-through into the C engine. See Installation for the rest of the env-var matrix.

Inspect the parsed model before stepping#

s = Solver("model.inp", "", "")
s.create()
s.open()
print(f"nodes: {Nodes(s).count()}, links: {Links(s).count()}")
# … check every domain class you care about …
s.destroy()         # without initializing / starting

This is the fastest way to confirm a parse without paying for the full simulation.


See also#