Architecture#

This page summarises the package layout; see IMPLEMENTATION_PLAN.md for the authoritative architecture document.

Class hierarchy#

gymnasium.Env                                          (Farama)
│
├── SwmmRTCEnv               (P1)
├── SwmmCIPEnv               (P3, single-step contextual bandit)
├── SwmmJointCIPRTCEnv       (P3)
└── SwmmMORTCEnv             (P4, vector reward)
        ↑
        └── mo_gymnasium.MOEnv  (mixed in when mo-gymnasium installed)

Package layout#

src/openswmm_gymnasium/
    __init__.py           # version + Gymnasium env registration
    _engine/              # the *only* module that imports openswmm.engine
    envs/                 # SwmmRTCEnv, SwmmCIPEnv, SwmmJointCIPRTCEnv, SwmmMORTCEnv
    spaces/               # action factories: runtime (RTC), design (CIP)
    observations/         # ObservationBuilder + per-feature collectors
    rewards/              # RewardTerm Protocol, built-in terms, RewardRegistry
    scoring/              # pure-math MOO indicators
    wrappers/             # action_remap, action_mask, scalarize, forecast, record_trajectory
    viz/                  # Plotly figure factories (optional [viz] extra)
    benchmarks/           # bNN scenario subpackages, each with scenario.inp

Thread-safety contract#

The package targets the handle-based, thread-safe openswmm.engine.Solver only. The legacy v5 singleton solver under openswmm.legacy.engine is rejected at adapter construction (openswmm_gymnasium._engine.LegacySolverRejectedError).

Each SwmmRTCEnv instance owns a distinct SWMM_Engine handle, so gymnasium.vector.SyncVectorEnv (threads) and AsyncVectorEnv (processes) both work.

Reward sign convention#

Built-in terms always return non-negative contributions. The env’s reward composer flips signs per term direction:

  • direction = "minimize" → cost = +contribution → reward subtracts.

  • direction = "maximize" → benefit = +contribution → reward adds.

After aggregation, scalar reward = -cost, satisfying Gymnasium’s “higher is better” contract.

Solver lifecycle#

SolverAdapter.open()      # parse .inp; create engine handle
[apply design factories]  # pre-initialize edits via Nodes/Links setters
SolverAdapter.initialize() # engine sets up data structures
loop:
    [apply runtime factories]  # Controls.set_link_setting etc.
    SolverAdapter.step()       # advance one routing timestep
SolverAdapter.end()
SolverAdapter.report()
SolverAdapter.close()      # idempotent