Source code for openswmm_gymnasium._engine.solver_adapter

"""
Solver lifecycle bridge.

Wraps L{openswmm.engine.Solver} (the handle-based v6 engine) to expose
the minimum surface the rest of the package needs:

  - Lifecycle: L{SolverAdapter.open}, L{SolverAdapter.initialize},
    L{SolverAdapter.step}, L{SolverAdapter.stride},
    L{SolverAdapter.end}, L{SolverAdapter.report},
    L{SolverAdapter.close}.
  - Idempotent L{SolverAdapter.close} / context-manager protocol.
  - Lazy L{openswmm.engine.Nodes} / L{openswmm.engine.Links} /
    L{openswmm.engine.Controls} accessors cached per adapter instance.
  - Hard guard against C{openswmm.legacy.engine.Solver} — passing a
    legacy solver into the adapter raises
    L{LegacySolverRejectedError}. Plan §0 #7 / §2.3.

The adapter does B{not} add any high-level domain logic (observations,
rewards, action application). Those live in their respective modules
and consume a L{SolverAdapter} instance.

@author: Caleb Buahin
@copyright: Copyright (c) 2026 Caleb Buahin
@license: MIT
"""

from __future__ import annotations

import os
from pathlib import Path

from openswmm.engine import Controls, EngineState, Gages, Links, Nodes, Solver, Subcatchments

PathLike = str | os.PathLike


[docs] class LegacySolverRejectedError(TypeError): """Raised when a legacy v5 singleton solver is passed where the v6 handle-based solver is required. Plan §0 #7."""
def _reject_legacy(solver: object) -> None: """Refuse anything other than the handle-based v6 Solver. The legacy solver lives at C{openswmm.legacy.engine.Solver} and uses global singleton state, which violates the plan's thread-safety requirement (§0 #7 / §2.3). @param solver: Candidate solver instance. @type solver: object @raise LegacySolverRejectedError: If C{solver.__class__.__module__} does not start with C{"openswmm.engine"}. """ module = type(solver).__module__ if not module.startswith("openswmm.engine"): raise LegacySolverRejectedError( f"openswmm.gymnasium requires the handle-based " f"openswmm.engine.Solver (v6); got {type(solver).__module__}." f"{type(solver).__name__}. The legacy v5 solver under " "openswmm.legacy.engine is not supported." )
[docs] class SolverAdapter: """Thin lifecycle wrapper around L{openswmm.engine.Solver}. Construction does B{not} open the engine. Call L{open} (or use C{with adapter: ...}) to allocate the underlying C{SWMM_Engine} handle. Each C{SolverAdapter} owns a distinct C{SWMM_Engine} handle, so two adapters on different threads do not share C-side state. Plan §2.3 (Threading & vectorized rollout). @ivar _inp: Resolved absolute path to the C{.inp} file. @type _inp: str @ivar _rpt: Resolved C{.rpt} path, or empty string if reporting is disabled. @type _rpt: str @ivar _out: Resolved C{.out} path, or empty string if binary output is disabled. @type _out: str @ivar _solver: The wrapped L{openswmm.engine.Solver} instance. @type _solver: L{openswmm.engine.Solver} @ivar _owned: C{True} if this adapter is responsible for closing the solver; C{False} if the solver was injected by the caller. @type _owned: bool """ def __init__( self, inp: PathLike, rpt: PathLike | None = None, out: PathLike | None = None, *, solver: Solver | None = None, ) -> None: """Construct an adapter without opening the engine. @param inp: Path to the SWMM input file (C{.inp}). @type inp: str or C{os.PathLike} @param rpt: Path for the report file. C{None} skips reporting. @type rpt: str, C{os.PathLike}, or C{None} @param out: Path for the binary output file. C{None} skips. @type out: str, C{os.PathLike}, or C{None} @param solver: Optional pre-constructed L{openswmm.engine.Solver} to wrap. Used by tests and advanced wrappers. Per plan §0 Q1 (resolved), the adapter owns the solver lifecycle by default. @type solver: L{openswmm.engine.Solver} or C{None} @raise LegacySolverRejectedError: If C{solver} is a legacy v5 singleton solver. """ self._inp = str(Path(inp)) self._rpt = "" if rpt is None else str(Path(rpt)) self._out = "" if out is None else str(Path(out)) if solver is not None: _reject_legacy(solver) self._solver: Solver = solver self._owned = False else: self._solver = Solver(self._inp, self._rpt, self._out) self._owned = True self._opened = False self._closed = False self._nodes: Nodes | None = None self._links: Links | None = None self._controls: Controls | None = None self._subcatchments: Subcatchments | None = None self._gages: Gages | None = None # ------------------------------------------------------------------ # Lifecycle # ------------------------------------------------------------------
[docs] def open(self, plugin_lib: PathLike | None = None) -> None: """Open the input file and allocate the engine handle. @param plugin_lib: Optional path to a plugin shared library. @type plugin_lib: str, C{os.PathLike}, or C{None} @raise RuntimeError: If the underlying C API returns a non-zero code. """ if self._opened: return rc = self._solver.open() if plugin_lib is None else self._solver.open(str(plugin_lib)) if rc != 0: raise RuntimeError(f"Solver.open returned non-zero code {rc}") self._opened = True
[docs] def initialize(self) -> None: """Initialize the simulation (transitions C{OPENED} -> C{RUNNING}). @raise RuntimeError: If the underlying C API returns a non-zero code. """ rc = self._solver.initialize() if rc != 0: raise RuntimeError(f"Solver.initialize returned non-zero code {rc}")
[docs] def step(self) -> int: """Advance one routing timestep. @return: Engine return code (C{0} on success). @rtype: int """ return self._solver.step()
[docs] def stride(self, n_steps: int) -> int: """Advance C{n_steps} routing timesteps in one call. @param n_steps: Number of timesteps to advance. @type n_steps: int @return: Engine return code (C{0} on success). @rtype: int """ return self._solver.stride(n_steps)
[docs] def end(self) -> None: """End the simulation (transitions to C{ENDED}).""" self._solver.end()
[docs] def report(self) -> None: """Write the report file.""" self._solver.report()
[docs] def close(self) -> None: """Close the engine handle. Idempotent. @note: Safe to call multiple times. Closes only when this adapter owns the solver (i.e. constructed it internally). """ if self._closed or not self._owned: return try: self._solver.close() finally: self._closed = True self._opened = False
# ------------------------------------------------------------------ # State # ------------------------------------------------------------------ @property def state(self) -> EngineState: """Current engine state. @rtype: L{openswmm.engine.EngineState} """ return self._solver.state @property def is_running(self) -> bool: """Whether the simulation is still advancing. @rtype: bool """ return self._solver.state == EngineState.RUNNING @property def elapsed(self) -> float: """Elapsed simulation time in days. @rtype: float """ return self._solver.elapsed @property def start_time(self) -> float: """Simulation start time as an OADate (decimal days). @rtype: float """ return self._solver.get_start_time() @property def end_time(self) -> float: """Simulation end time as an OADate (decimal days). @rtype: float """ return self._solver.get_end_time() @property def current_time(self) -> float: """Current simulation time as an OADate (decimal days). @rtype: float """ return self._solver.get_current_time() # ------------------------------------------------------------------ # Accessors (lazy, cached) # ------------------------------------------------------------------ @property def solver(self) -> Solver: """Underlying real L{openswmm.engine.Solver}. Use sparingly. @rtype: L{openswmm.engine.Solver} """ return self._solver @property def nodes(self) -> Nodes: """Lazily-constructed, cached L{openswmm.engine.Nodes} accessor. @rtype: L{openswmm.engine.Nodes} """ if self._nodes is None: self._nodes = Nodes(self._solver) return self._nodes @property def links(self) -> Links: """Lazily-constructed, cached L{openswmm.engine.Links} accessor. @rtype: L{openswmm.engine.Links} """ if self._links is None: self._links = Links(self._solver) return self._links @property def controls(self) -> Controls: """Lazily-constructed, cached L{openswmm.engine.Controls} accessor. @rtype: L{openswmm.engine.Controls} """ if self._controls is None: self._controls = Controls(self._solver) return self._controls @property def subcatchments(self) -> Subcatchments: """Lazily-constructed, cached L{openswmm.engine.Subcatchments} accessor. @rtype: L{openswmm.engine.Subcatchments} """ if self._subcatchments is None: self._subcatchments = Subcatchments(self._solver) return self._subcatchments @property def gages(self) -> Gages: """Lazily-constructed, cached L{openswmm.engine.Gages} accessor. @rtype: L{openswmm.engine.Gages} """ if self._gages is None: self._gages = Gages(self._solver) return self._gages # ------------------------------------------------------------------ # Context manager # ------------------------------------------------------------------ def __enter__(self) -> SolverAdapter: self.open() self.initialize() return self def __exit__(self, exc_type, exc_val, exc_tb) -> None: try: if self._opened and not self._closed: self.end() self.report() finally: self.close() def __del__(self) -> None: # Best-effort cleanup if the user forgets to close. Safe because # close() is idempotent. try: self.close() except Exception: pass