"""Legacy SWMM (EPA SWMM 5.x) backend.
Translates the indexed-getter / domain-collection shape that tools call
against onto the legacy property-based ``LegacyNode.depth`` API exposed by
:mod:`openswmm.legacy.engine`.
Translations that need to happen:
- Lifecycle: legacy ``Solver.initialize()`` does open + start in one call,
whereas the new engine treats them separately. This adapter routes
``open / initialize / start`` so that callers get the same three-phase
shape regardless of which engine is underneath.
- Stepping: legacy ``step()`` returns ``(elapsed_days, current_datetime)``;
this adapter caches those and returns ``bool`` (continue-or-not) to match
the new engine.
- Mass balance: legacy reports continuity errors in **percent**; this
adapter divides by 100 so callers always see the new-engine fraction
(0.001 = 0.1 %).
- Hotstart: legacy ``Solver.use_hotstart`` / ``save_hotstart`` are exposed
via a class that mirrors the new-engine ``HotStart.save / open / apply``
three-method shape so the hotstart tools work uniformly.
Anything genuinely missing from legacy (ModelBuilder, ModelEditor, Controls,
Inflows, Infrastructure, Quality, Spatial, Tables, Statistics, OutputReader,
GeoPackage) is **not** implemented here. The session's __getattr__ falls
through to the backend, AttributeError bubbles up, and the tool's
``require_new_engine(...)`` guard raises a clean ``NOT_SUPPORTED`` error
before the AttributeError path is ever hit.
"""
from __future__ import annotations
from collections.abc import Iterator
from datetime import datetime, timedelta
from typing import Any
from openswmm.legacy.engine import (
Solver as LegacySolver,
)
from openswmm.legacy.engine import (
SWMMFlowUnits,
SWMMLinkProperties,
SWMMNodeProperties,
SWMMObjects,
SWMMRainGageProperties,
SWMMSubcatchmentProperties,
SWMMSystemProperties,
)
# ---------------------------------------------------------------------------
# Solver adapter
# ---------------------------------------------------------------------------
_FLOW_UNIT_NAMES = {
SWMMFlowUnits.CFS: "CFS",
SWMMFlowUnits.GPM: "GPM",
SWMMFlowUnits.MGD: "MGD",
SWMMFlowUnits.CMS: "CMS",
SWMMFlowUnits.LPS: "LPS",
SWMMFlowUnits.MLD: "MLD",
}
def _datetime_to_days(dt: datetime, epoch: datetime) -> float:
"""Convert a datetime to elapsed decimal days since *epoch*."""
delta = dt - epoch
return delta.total_seconds() / 86400.0
class _LegacySolverAdapter:
"""Wraps the legacy ``Solver`` to expose the new-engine ``Solver`` shape.
Lifecycle method semantics (matching the new engine):
- ``open()`` : parse the .inp file
- ``initialize()`` : allocate arrays, set initial conditions
- ``start()`` : begin the simulation
- ``step()`` -> bool : advance one timestep; ``False`` when finished
- ``end() / report() / close() / destroy()`` : standard teardown
Internally the legacy solver collapses ``initialize()`` into ``start()``
via :meth:`LegacySolver.initialize` (which calls open+start under the
hood), so the adapter tracks which calls have already happened and
routes accordingly.
"""
def __init__(self, inp_path: str, rpt_path: str, out_path: str) -> None:
self._solver = LegacySolver(inp_path, rpt_path, out_path)
self._inp_path = inp_path
self._rpt_path = rpt_path
self._out_path = out_path
self._opened = False
self._initialized = False
self._started = False
self._last_elapsed_days = 0.0
self._last_current_dt: datetime | None = None
self._epoch: datetime | None = None
self._end_dt: datetime | None = None
# -- raw handle ----------------------------------------------------------
@property
def raw(self) -> LegacySolver:
return self._solver
@property
def inp_path(self) -> str:
return self._inp_path
@property
def out_path(self) -> str:
return self._out_path
# -- lifecycle -----------------------------------------------------------
def open(self) -> None:
if self._opened:
return
self._solver.open()
self._opened = True
def initialize(self) -> None:
# Legacy has no separate "initialize" between open and start; treat
# this as a no-op and let start() begin the simulation.
if not self._opened:
self.open()
self._initialized = True
def start(self, save_results: bool = True) -> None:
if self._started:
return
if not self._opened:
self._solver.open()
self._opened = True
self._solver.start()
self._started = True
# Cache start/end datetimes so we can convert legacy datetime returns
# to decimal-day floats compatible with new-engine signatures.
self._epoch = self._solver.start_datetime
self._end_dt = self._solver.end_datetime
self._last_current_dt = self._epoch
def step(self) -> bool:
# Legacy ``step()`` returns an int error code; per-step data is read
# from the solver's properties afterwards (the old tuple-returning
# signature is gone).
from openswmm.legacy.engine import SolverState
self._solver.step()
current_dt = self._solver.current_datetime
self._last_current_dt = current_dt
if self._epoch is not None:
self._last_elapsed_days = (current_dt - self._epoch).total_seconds() / 86400.0
# Mirror the new-engine "continue?" boolean: the legacy solver flips to
# FINISHED once the simulation completes.
return self._solver.solver_state == SolverState.STARTED
def end(self) -> None:
self._solver.end()
def report(self) -> None:
self._solver.report()
def close(self) -> None:
self._solver.close()
def destroy(self) -> None:
# Legacy has no destroy() — close handles teardown.
pass
# -- timing --------------------------------------------------------------
@property
def elapsed(self) -> float:
"""Elapsed simulation time in decimal days."""
return self._last_elapsed_days
@property
def state(self) -> int:
"""Report state using the v6 EngineState codes the shared lifecycle
tools assume (CREATED=1, OPENED=2, INITIALIZED=3, RUNNING=5, ENDED=6,
CLOSED=7), so completion checks (``state != RUNNING``) work uniformly
across backends."""
from openswmm.legacy.engine import SolverState
_MAP = {
SolverState.CREATED: 1,
SolverState.OPEN: 3,
SolverState.STARTED: 5,
SolverState.FINISHED: 6,
SolverState.ENDED: 6,
SolverState.REPORTED: 6,
SolverState.CLOSED: 7,
}
try:
return _MAP.get(self._solver.solver_state, -1)
except Exception:
return -1
def get_start_time(self) -> float:
"""Return start time as decimal days since the same epoch as ``get_current_time``."""
if self._epoch is None:
self._epoch = self._solver.start_datetime
# By construction, the epoch *is* the start time -> 0.0 days.
# We mirror the new engine which also uses start as 0 reference.
return 0.0
def get_end_time(self) -> float:
"""Return end time as decimal days since start."""
if self._epoch is None:
self._epoch = self._solver.start_datetime
if self._end_dt is None:
self._end_dt = self._solver.end_datetime
return _datetime_to_days(self._end_dt, self._epoch)
def get_current_time(self) -> float:
"""Return current simulation time as decimal days since start."""
if self._epoch is None:
self._epoch = self._solver.start_datetime
current = self._last_current_dt or self._solver.current_datetime
return _datetime_to_days(current, self._epoch)
def get_routing_step(self) -> float:
return float(self._solver.routing_step)
def get_option(self, key: str) -> str:
"""Best-effort key/value lookup mirroring the new engine's get_option.
Only a handful of keys map cleanly onto legacy ``SWMMSystemProperties``
/ introspectable Solver state. Unknown keys raise ``KeyError`` so
existing tool code that wraps the call in ``try/except`` falls back
to ``"UNKNOWN"`` automatically.
"""
upper = key.strip().upper()
if upper == "FLOW_UNITS":
code = int(
self._solver.get_value(SWMMObjects.SYSTEM, SWMMSystemProperties.FLOW_UNITS, 0)
)
try:
return _FLOW_UNIT_NAMES[SWMMFlowUnits(code)]
except (KeyError, ValueError):
return f"UNKNOWN({code})"
# The legacy engine doesn't expose ROUTING_MODEL / SURCHARGE_METHOD /
# DPS_* / event_count / steady_state_skip via the toolkit API.
raise KeyError(f"Option '{key}' is not exposed by the legacy engine.")
# ------------------------------------------------------------------------
# v1-shape properties (alongside the v0 get_* methods above).
#
# Tools that have migrated to the v1 surface read these property names
# directly (``solver.start_datetime``, ``solver.options[key]``, …). The
# underlying SWMM 5 toolkit only surfaces a small subset of options, so
# ``options`` is read-only and raises ``KeyError`` on unsupported keys.
# ------------------------------------------------------------------------
@property
def start_datetime(self) -> datetime:
if self._epoch is None:
self._epoch = self._solver.start_datetime
return self._epoch
@property
def end_datetime(self) -> datetime:
if self._end_dt is None:
self._end_dt = self._solver.end_datetime
return self._end_dt
@property
def current_datetime(self) -> datetime:
if self._last_current_dt is not None:
return self._last_current_dt
return self._solver.current_datetime
@property
def routing_step(self) -> timedelta:
return timedelta(seconds=float(self._solver.routing_step))
@property
def options(self) -> _LegacyOptionsView:
view = getattr(self, "_options_view", None)
if view is None:
view = _LegacyOptionsView(self)
self._options_view = view
return view
class _LegacyOptionsView:
"""Read-only v1-shape ``solver.options`` view for the legacy backend.
Forwards ``[key]`` reads through :meth:`_LegacySolverAdapter.get_option`.
Iteration yields the keys legacy actually supports; ``__contains__`` is
a try/except over ``[key]``. Writes are not supported — the legacy
toolkit doesn't expose option setters via the C API.
"""
_SUPPORTED_KEYS: tuple[str, ...] = ("FLOW_UNITS",)
def __init__(self, adapter: _LegacySolverAdapter) -> None:
self._adapter = adapter
def __getitem__(self, key: str) -> str:
return self._adapter.get_option(key)
def __contains__(self, key: object) -> bool:
if not isinstance(key, str):
return False
try:
self._adapter.get_option(key)
return True
except KeyError:
return False
def __iter__(self) -> Iterator[str]:
return iter(self._SUPPORTED_KEYS)
def __len__(self) -> int:
return len(self._SUPPORTED_KEYS)
def get(self, key: str, default: Any = None) -> Any:
try:
return self._adapter.get_option(key)
except KeyError:
return default
# ---------------------------------------------------------------------------
# Element collection adapters
# ---------------------------------------------------------------------------
class _LegacyNode:
"""v1-shape proxy for a single legacy node.
Exposes only the attributes the legacy engine can actually compute.
Anything v1 surfaces that legacy can't provide (``.stats``,
``.storage``, ``.outfall``, ``.divider``, ``.quality()``,
``.set_quality_mass_flux()``, ``.depth_from_volume()``) raises
:class:`AttributeError`; tools that hit those code paths must guard
with :func:`openswmm_mcp.dependencies.require_new_engine`.
"""
__slots__ = ("_collection", "_index")
def __init__(self, collection: _LegacyNodes, index: int) -> None:
self._collection = collection
self._index = index
@property
def id(self) -> str:
return self._collection.get_id(self._index)
@property
def index(self) -> int:
return self._index
@property
def type(self) -> int:
return self._collection.get_type(self._index)
@property
def invert_elev(self) -> float:
return self._collection.get_invert_elev(self._index)
@property
def max_depth(self) -> float:
return self._collection.get_max_depth(self._index)
@property
def depth(self) -> float:
return self._collection.get_depth(self._index)
@property
def head(self) -> float:
return self._collection.get_head(self._index)
@property
def volume(self) -> float:
return self._collection.get_volume(self._index)
@property
def lateral_inflow(self) -> float:
return self._collection.get_lateral_inflow(self._index)
@lateral_inflow.setter
def lateral_inflow(self, value: float) -> None:
self._collection.set_lateral_inflow(self._index, value)
@property
def overflow(self) -> float:
return self._collection.get_overflow(self._index)
def __eq__(self, other: object) -> bool:
if not isinstance(other, _LegacyNode):
return NotImplemented
return self._index == other._index and self._collection is other._collection
def __hash__(self) -> int:
return hash((id(self._collection), self._index))
def __repr__(self) -> str:
try:
return f"<LegacyNode id={self.id!r} index={self._index}>"
except Exception:
return f"<LegacyNode index={self._index}>"
class _LegacyNodes:
"""Indexed-getter shim over the legacy ``Solver`` node API.
Exposes both the v0 ``get_*(idx)`` methods (used by un-migrated tools)
and the v1 container protocol (``len``, ``iter``, ``[key]``,
``in``) returning :class:`_LegacyNode` wrappers.
"""
def __init__(self, solver: LegacySolver, backend: LegacyBackend | None = None) -> None:
self._solver = solver
self._backend = backend
# -- v1 container protocol ----------------------------------------------
def __len__(self) -> int:
return self._solver.get_object_count(SWMMObjects.NODE)
def __iter__(self) -> Any:
for i in range(len(self)):
yield _LegacyNode(self, i)
def __getitem__(self, key: Any) -> _LegacyNode:
if isinstance(key, int):
if key < 0 or key >= len(self):
raise IndexError(f"Node index {key} out of range.")
return _LegacyNode(self, key)
if isinstance(key, str):
idx = self.get_index(key)
if idx < 0:
raise KeyError(f"Node id {key!r} not found.")
return _LegacyNode(self, idx)
raise TypeError(f"Node lookup expects int or str, got {type(key).__name__}.")
def __contains__(self, key: object) -> bool:
if isinstance(key, int):
return 0 <= key < len(self)
if isinstance(key, str):
return self.get_index(key) >= 0
return False
# -- v0-named scalar accessors (implementation backing for _LegacyNode) --
#
# These look like the old v0 method-style API but are *not* a transition
# shim — the v1 ``_LegacyNode`` wrapper class calls back into them as
# the actual SWMM-5-toolkit dispatch path. Removing any of them breaks
# the corresponding wrapper property.
def get_id(self, index: int) -> str:
return self._solver.get_object_name(SWMMObjects.NODE, index)
def get_index(self, node_id: str) -> int:
try:
return self._solver.get_object_index(SWMMObjects.NODE, node_id)
except Exception:
return -1
def get_type(self, index: int) -> int:
return int(self._solver.get_value(SWMMObjects.NODE, SWMMNodeProperties.TYPE, index))
def get_invert_elev(self, index: int) -> float:
return self._solver.get_value(SWMMObjects.NODE, SWMMNodeProperties.INVERT_ELEVATION, index)
def get_max_depth(self, index: int) -> float:
return self._solver.get_value(SWMMObjects.NODE, SWMMNodeProperties.MAX_DEPTH, index)
def get_depth(self, index: int) -> float:
return self._solver.get_value(SWMMObjects.NODE, SWMMNodeProperties.DEPTH, index)
def get_head(self, index: int) -> float:
return self._solver.get_value(SWMMObjects.NODE, SWMMNodeProperties.HYDRAULIC_HEAD, index)
def get_volume(self, index: int) -> float:
return self._solver.get_value(SWMMObjects.NODE, SWMMNodeProperties.VOLUME, index)
def get_lateral_inflow(self, index: int) -> float:
return self._solver.get_value(SWMMObjects.NODE, SWMMNodeProperties.LATERAL_INFLOW, index)
def get_overflow(self, index: int) -> float:
return self._solver.get_value(SWMMObjects.NODE, SWMMNodeProperties.FLOODING, index)
def set_lateral_inflow(self, index: int, value: float) -> None:
self._solver.set_value(SWMMObjects.NODE, SWMMNodeProperties.LATERAL_INFLOW, index, value)
class _LegacyLink:
"""v1-shape proxy for a single legacy link.
Sub-views (``.pump``, ``.weir``, ``.orifice``, ``.outlet``, ``.stats``,
``.xsect``) and bulk quality / pump-stat methods are NOT exposed —
callers must guard with :func:`require_new_engine`.
"""
__slots__ = ("_collection", "_index")
def __init__(self, collection: _LegacyLinks, index: int) -> None:
self._collection = collection
self._index = index
@property
def id(self) -> str:
return self._collection.get_id(self._index)
@property
def index(self) -> int:
return self._index
@property
def type(self) -> int:
return self._collection.get_type(self._index)
@property
def from_node(self) -> _LegacyNode:
# v1 returns a Node wrapper, not an int index. Mirror that shape
# by resolving through the backend's nodes collection.
backend = self._collection._backend
if backend is None:
raise AttributeError("Link.from_node requires a backend-bound Links collection.")
return backend.nodes[self._collection.get_from_node(self._index)]
@property
def to_node(self) -> _LegacyNode:
backend = self._collection._backend
if backend is None:
raise AttributeError("Link.to_node requires a backend-bound Links collection.")
return backend.nodes[self._collection.get_to_node(self._index)]
@property
def length(self) -> float:
return self._collection.get_length(self._index)
@property
def max_depth(self) -> float:
return self._collection.get_max_depth(self._index)
@property
def flow(self) -> float:
return self._collection.get_flow(self._index)
@property
def depth(self) -> float:
return self._collection.get_depth(self._index)
@property
def velocity(self) -> float:
return self._collection.get_velocity(self._index)
@property
def capacity(self) -> float:
return self._collection.get_capacity(self._index)
def __eq__(self, other: object) -> bool:
if not isinstance(other, _LegacyLink):
return NotImplemented
return self._index == other._index and self._collection is other._collection
def __hash__(self) -> int:
return hash((id(self._collection), self._index))
def __repr__(self) -> str:
try:
return f"<LegacyLink id={self.id!r} index={self._index}>"
except Exception:
return f"<LegacyLink index={self._index}>"
class _LegacyLinks:
"""Indexed-getter shim over the legacy ``Solver`` link API.
Exposes both v0 (``get_*(idx)``) and v1 container protocol returning
:class:`_LegacyLink` wrappers.
"""
def __init__(self, solver: LegacySolver, backend: LegacyBackend | None = None) -> None:
self._solver = solver
self._backend = backend
# -- v1 container protocol ----------------------------------------------
def __len__(self) -> int:
return self._solver.get_object_count(SWMMObjects.LINK)
def __iter__(self) -> Any:
for i in range(len(self)):
yield _LegacyLink(self, i)
def __getitem__(self, key: Any) -> _LegacyLink:
if isinstance(key, int):
if key < 0 or key >= len(self):
raise IndexError(f"Link index {key} out of range.")
return _LegacyLink(self, key)
if isinstance(key, str):
idx = self.get_index(key)
if idx < 0:
raise KeyError(f"Link id {key!r} not found.")
return _LegacyLink(self, idx)
raise TypeError(f"Link lookup expects int or str, got {type(key).__name__}.")
def __contains__(self, key: object) -> bool:
if isinstance(key, int):
return 0 <= key < len(self)
if isinstance(key, str):
return self.get_index(key) >= 0
return False
# -- v0-named scalar accessors (implementation backing for _LegacyLink) --
def get_id(self, index: int) -> str:
return self._solver.get_object_name(SWMMObjects.LINK, index)
def get_index(self, link_id: str) -> int:
try:
return self._solver.get_object_index(SWMMObjects.LINK, link_id)
except Exception:
return -1
def get_type(self, index: int) -> int:
return int(self._solver.get_value(SWMMObjects.LINK, SWMMLinkProperties.TYPE, index))
def get_from_node(self, index: int) -> int:
return int(self._solver.get_value(SWMMObjects.LINK, SWMMLinkProperties.START_NODE, index))
def get_to_node(self, index: int) -> int:
return int(self._solver.get_value(SWMMObjects.LINK, SWMMLinkProperties.END_NODE, index))
def get_length(self, index: int) -> float:
return self._solver.get_value(SWMMObjects.LINK, SWMMLinkProperties.LENGTH, index)
def get_roughness(self, index: int) -> float:
# Legacy doesn't expose roughness via the toolkit get_value API on
# the LINK object — roughness lives on the cross-section. Return
# NaN-ish None equivalent so the tool surfaces it as missing.
raise NotImplementedError(
"roughness is not exposed by the legacy toolkit; query the .inp file."
)
def get_max_depth(self, index: int) -> float:
return self._solver.get_value(SWMMObjects.LINK, SWMMLinkProperties.FULL_DEPTH, index)
def get_flow(self, index: int) -> float:
return self._solver.get_value(SWMMObjects.LINK, SWMMLinkProperties.FLOW, index)
def get_depth(self, index: int) -> float:
return self._solver.get_value(SWMMObjects.LINK, SWMMLinkProperties.DEPTH, index)
def get_velocity(self, index: int) -> float:
return self._solver.get_value(SWMMObjects.LINK, SWMMLinkProperties.VELOCITY, index)
def get_capacity(self, index: int) -> float:
return self._solver.get_value(SWMMObjects.LINK, SWMMLinkProperties.CAPACITY, index)
def set_setting(self, index: int, value: float) -> None:
self._solver.set_value(SWMMObjects.LINK, SWMMLinkProperties.SETTING, index, value)
class _LegacySubcatchment:
"""v1-shape proxy for a single legacy subcatchment.
Sub-views (``.stats``, ``.infiltration``, ``.coverage``) and pollutant
methods are NOT exposed — callers must guard with
:func:`require_new_engine`.
"""
__slots__ = ("_collection", "_index")
def __init__(self, collection: _LegacySubcatchments, index: int) -> None:
self._collection = collection
self._index = index
@property
def id(self) -> str:
return self._collection.get_id(self._index)
@property
def index(self) -> int:
return self._index
@property
def area(self) -> float:
return self._collection.get_area(self._index)
@property
def imperv_pct(self) -> float | None:
return self._collection.get_imperv_pct(self._index)
@property
def slope(self) -> float:
return self._collection.get_slope(self._index)
@property
def width(self) -> float:
return self._collection.get_width(self._index)
@property
def rainfall(self) -> float:
return self._collection.get_rainfall(self._index)
@property
def runoff(self) -> float:
return self._collection.get_runoff(self._index)
def __eq__(self, other: object) -> bool:
if not isinstance(other, _LegacySubcatchment):
return NotImplemented
return self._index == other._index and self._collection is other._collection
def __hash__(self) -> int:
return hash((id(self._collection), self._index))
def __repr__(self) -> str:
try:
return f"<LegacySubcatchment id={self.id!r} index={self._index}>"
except Exception:
return f"<LegacySubcatchment index={self._index}>"
class _LegacySubcatchments:
"""Indexed-getter shim over the legacy ``Solver`` subcatchment API.
Exposes both v0 (``get_*(idx)``) and v1 container protocol returning
:class:`_LegacySubcatchment` wrappers.
"""
def __init__(self, solver: LegacySolver, backend: LegacyBackend | None = None) -> None:
self._solver = solver
self._backend = backend
# -- v1 container protocol ----------------------------------------------
def __len__(self) -> int:
return self._solver.get_object_count(SWMMObjects.SUBCATCHMENT)
def __iter__(self) -> Any:
for i in range(len(self)):
yield _LegacySubcatchment(self, i)
def __getitem__(self, key: Any) -> _LegacySubcatchment:
if isinstance(key, int):
if key < 0 or key >= len(self):
raise IndexError(f"Subcatchment index {key} out of range.")
return _LegacySubcatchment(self, key)
if isinstance(key, str):
idx = self.get_index(key)
if idx < 0:
raise KeyError(f"Subcatchment id {key!r} not found.")
return _LegacySubcatchment(self, idx)
raise TypeError(f"Subcatchment lookup expects int or str, got {type(key).__name__}.")
def __contains__(self, key: object) -> bool:
if isinstance(key, int):
return 0 <= key < len(self)
if isinstance(key, str):
return self.get_index(key) >= 0
return False
# -- v0-named scalar accessors (implementation backing for _LegacySubcatchment) --
def get_id(self, index: int) -> str:
return self._solver.get_object_name(SWMMObjects.SUBCATCHMENT, index)
def get_index(self, sc_id: str) -> int:
try:
return self._solver.get_object_index(SWMMObjects.SUBCATCHMENT, sc_id)
except Exception:
return -1
def get_area(self, index: int) -> float:
return self._solver.get_value(
SWMMObjects.SUBCATCHMENT, SWMMSubcatchmentProperties.AREA, index
)
def get_imperv_pct(self, index: int) -> float | None:
# FRACTION_IMPERVIOUS is in some legacy builds but not exposed via
# SWMMSubcatchmentProperties uniformly — return None when absent so
# the tool degrades cleanly.
prop = getattr(SWMMSubcatchmentProperties, "FRACTION_IMPERVIOUS", None)
if prop is None:
return None
try:
return self._solver.get_value(SWMMObjects.SUBCATCHMENT, prop, index) * 100.0
except Exception:
return None
def get_slope(self, index: int) -> float:
return self._solver.get_value(
SWMMObjects.SUBCATCHMENT, SWMMSubcatchmentProperties.SLOPE, index
)
def get_width(self, index: int) -> float:
return self._solver.get_value(
SWMMObjects.SUBCATCHMENT, SWMMSubcatchmentProperties.WIDTH, index
)
def get_rainfall(self, index: int) -> float:
return self._solver.get_value(
SWMMObjects.SUBCATCHMENT, SWMMSubcatchmentProperties.RAINFALL, index
)
def get_runoff(self, index: int) -> float:
return self._solver.get_value(
SWMMObjects.SUBCATCHMENT, SWMMSubcatchmentProperties.RUNOFF, index
)
def set_rainfall_override(self, index: int, value: float) -> None:
self._solver.set_value(
SWMMObjects.SUBCATCHMENT,
SWMMSubcatchmentProperties.API_RAINFALL,
index,
value,
)
def set_snowfall_override(self, index: int, value: float) -> None:
self._solver.set_value(
SWMMObjects.SUBCATCHMENT,
SWMMSubcatchmentProperties.API_SNOWFALL,
index,
value,
)
_GAGE_DATA_SOURCE = {0: 0, 1: 1} # legacy doesn't surface this distinctly
_GAGE_RAIN_TYPE = {0: 0, 1: 1, 2: 2}
class _LegacyGage:
"""v1-shape proxy for a single legacy rain gage."""
__slots__ = ("_collection", "_index")
def __init__(self, collection: _LegacyGages, index: int) -> None:
self._collection = collection
self._index = index
@property
def id(self) -> str:
return self._collection.get_id(self._index)
@property
def index(self) -> int:
return self._index
@property
def rainfall(self) -> float:
return self._collection.get_rainfall(self._index)
@rainfall.setter
def rainfall(self, value: float) -> None:
self._collection.set_rainfall(self._index, value)
def __eq__(self, other: object) -> bool:
if not isinstance(other, _LegacyGage):
return NotImplemented
return self._index == other._index and self._collection is other._collection
def __hash__(self) -> int:
return hash((id(self._collection), self._index))
def __repr__(self) -> str:
try:
return f"<LegacyGage id={self.id!r} index={self._index}>"
except Exception:
return f"<LegacyGage index={self._index}>"
class _LegacyGages:
"""Indexed-getter shim over the legacy ``Solver`` rain-gage API.
Exposes both v0 and v1 surfaces.
"""
def __init__(self, solver: LegacySolver, backend: LegacyBackend | None = None) -> None:
self._solver = solver
self._backend = backend
# -- v1 container protocol ----------------------------------------------
def __len__(self) -> int:
return self._solver.get_object_count(SWMMObjects.RAIN_GAGE)
def __iter__(self) -> Any:
for i in range(len(self)):
yield _LegacyGage(self, i)
def __getitem__(self, key: Any) -> _LegacyGage:
if isinstance(key, int):
if key < 0 or key >= len(self):
raise IndexError(f"Gage index {key} out of range.")
return _LegacyGage(self, key)
if isinstance(key, str):
idx = self.get_index(key)
if idx < 0:
raise KeyError(f"Gage id {key!r} not found.")
return _LegacyGage(self, idx)
raise TypeError(f"Gage lookup expects int or str, got {type(key).__name__}.")
def __contains__(self, key: object) -> bool:
if isinstance(key, int):
return 0 <= key < len(self)
if isinstance(key, str):
return self.get_index(key) >= 0
return False
# -- v0-named scalar accessors (implementation backing for _LegacyGage) --
def get_id(self, index: int) -> str:
return self._solver.get_object_name(SWMMObjects.RAIN_GAGE, index)
def get_index(self, gage_id: str) -> int:
try:
return self._solver.get_object_index(SWMMObjects.RAIN_GAGE, gage_id)
except Exception:
return -1
def get_data_source(self, index: int) -> int:
# Not exposed through the toolkit get_value path; surface 0 as a
# stable fallback (the tool maps ints to "TIMESERIES"/"FILE").
return 0
def get_rain_type(self, index: int) -> int:
return 0
def get_rainfall(self, index: int) -> float:
return self._solver.get_value(
SWMMObjects.RAIN_GAGE, SWMMRainGageProperties.GAGE_RAINFALL, index
)
def set_rainfall(self, index: int, value: float) -> None:
self._solver.set_value(
SWMMObjects.RAIN_GAGE, SWMMRainGageProperties.GAGE_RAINFALL, index, value
)
class _LegacyPollutant:
"""v1-shape proxy for a single legacy pollutant.
Legacy exposes only ``id`` / ``index``; every other v1 attribute
(``.units``, ``.kdecay``, ``.init_conc`` …) raises
:class:`AttributeError`.
"""
__slots__ = ("_collection", "_index")
def __init__(self, collection: _LegacyPollutants, index: int) -> None:
self._collection = collection
self._index = index
@property
def id(self) -> str:
return self._collection.get_id(self._index)
@property
def index(self) -> int:
return self._index
def __eq__(self, other: object) -> bool:
if not isinstance(other, _LegacyPollutant):
return NotImplemented
return self._index == other._index and self._collection is other._collection
def __hash__(self) -> int:
return hash((id(self._collection), self._index))
def __repr__(self) -> str:
try:
return f"<LegacyPollutant id={self.id!r} index={self._index}>"
except Exception:
return f"<LegacyPollutant index={self._index}>"
class _LegacyPollutants:
"""Pollutant accessor — count + name only, no per-element data getters in legacy.
Exposes both v0 and v1 surfaces.
"""
def __init__(self, solver: LegacySolver, backend: LegacyBackend | None = None) -> None:
self._solver = solver
self._backend = backend
# -- v1 container protocol ----------------------------------------------
def __len__(self) -> int:
return self._solver.get_object_count(SWMMObjects.POLLUTANT)
def __iter__(self) -> Any:
for i in range(len(self)):
yield _LegacyPollutant(self, i)
def __getitem__(self, key: Any) -> _LegacyPollutant:
if isinstance(key, int):
if key < 0 or key >= len(self):
raise IndexError(f"Pollutant index {key} out of range.")
return _LegacyPollutant(self, key)
if isinstance(key, str):
idx = self.get_index(key)
if idx < 0:
raise KeyError(f"Pollutant id {key!r} not found.")
return _LegacyPollutant(self, idx)
raise TypeError(f"Pollutant lookup expects int or str, got {type(key).__name__}.")
def __contains__(self, key: object) -> bool:
if isinstance(key, int):
return 0 <= key < len(self)
if isinstance(key, str):
return self.get_index(key) >= 0
return False
def get_index(self, pollutant_id: str) -> int:
try:
return self._solver.get_object_index(SWMMObjects.POLLUTANT, pollutant_id)
except Exception:
return -1
# -- v0-named scalar accessors (implementation backing for _LegacyPollutant) --
def get_id(self, index: int) -> str:
return self._solver.get_object_name(SWMMObjects.POLLUTANT, index)
# ---------------------------------------------------------------------------
# Mass balance, forcing, hotstart adapters
# ---------------------------------------------------------------------------
class _LegacyMassBalance:
"""Continuity-error retrieval that matches the new-engine fraction units.
Legacy reports errors in **percent**; the new engine in **fraction**
(0.001 == 0.1 %). This adapter divides by 100 so callers always see
fractions regardless of which engine is underneath.
"""
def __init__(self, solver: LegacySolver) -> None:
self._solver = solver
@property
def runoff_continuity_error(self) -> float:
runoff_pct, _flow_pct, _qual_pct = self._solver.get_mass_balance_error()
return runoff_pct / 100.0
@property
def routing_continuity_error(self) -> float:
_runoff_pct, flow_pct, _qual_pct = self._solver.get_mass_balance_error()
return flow_pct / 100.0
def quality_continuity_error(self, pollutant: Any = 0) -> float:
"""Per-pollutant continuity-error query (v1-shape).
Legacy reports one aggregate quality error, so the ``pollutant``
argument is accepted for signature parity but ignored.
"""
_runoff, _flow, qual_pct = self._solver.get_mass_balance_error()
return qual_pct / 100.0
class _LegacyForcing:
"""Subset of new-engine ``Forcing`` methods that legacy can implement.
Only the most commonly used overrides translate cleanly:
- ``node_lat_inflow(idx, value, mode, target)`` -> set node lateral inflow
- ``link_setting(idx, value, mode, target)`` -> set link control setting
- ``subcatch_rainfall(idx, value, mode, target)`` -> set API rainfall
- ``gage_rainfall(idx, value, mode, target)`` -> set gage rainfall
- ``clear_all()`` is a no-op (legacy doesn't track an override registry —
values reset on the next step automatically).
Methods that have no legacy equivalent (``node_head_boundary``,
``node_quality``, ``link_flow``, ``subcatch_evap``, ``clear`` for a
specific element) raise :class:`NotImplementedError`, which the
forcing tool maps to a ``NOT_SUPPORTED`` ``ToolError``.
The ``mode`` and ``target`` arguments are accepted for signature
compatibility but ignored on legacy: every override behaves as
``REPLACE`` and is reset on the next step (i.e. equivalent to
``persist=False``).
"""
def __init__(
self,
nodes: _LegacyNodes,
links: _LegacyLinks,
subcatchments: _LegacySubcatchments,
gages: _LegacyGages,
) -> None:
self._nodes = nodes
self._links = links
self._subcatchments = subcatchments
self._gages = gages
@staticmethod
def _resolve_index(accessor: Any, target: Any) -> int:
if isinstance(target, int):
return target
return accessor.get_index(target)
def node_lat_inflow(self, target: Any, value: float, mode: int = 0, persist: int = 0) -> None:
idx = self._resolve_index(self._nodes, target)
self._nodes.set_lateral_inflow(idx, value)
def link_setting(self, target: Any, value: float, mode: int = 0, persist: int = 0) -> None:
idx = self._resolve_index(self._links, target)
self._links.set_setting(idx, value)
def subcatchment_rainfall(
self, target: Any, value: float, mode: int = 0, persist: int = 0
) -> None:
idx = self._resolve_index(self._subcatchments, target)
self._subcatchments.set_rainfall_override(idx, value)
def subcatchment_snowfall(
self, target: Any, value: float, mode: int = 0, persist: int = 0
) -> None:
idx = self._resolve_index(self._subcatchments, target)
self._subcatchments.set_snowfall_override(idx, value)
def gage_rainfall(self, target: Any, value: float, mode: int = 0, persist: int = 0) -> None:
idx = self._resolve_index(self._gages, target)
self._gages.set_rainfall(idx, value)
def node_head_boundary(self, *args: Any, **kw: Any) -> None:
raise NotImplementedError(
"Forcing 'node head boundary' is not supported by the legacy engine."
)
def node_quality(self, *args: Any, **kw: Any) -> None:
raise NotImplementedError("Forcing 'node quality' is not supported by the legacy engine.")
def link_flow(self, *args: Any, **kw: Any) -> None:
raise NotImplementedError(
"Forcing 'link flow override' is not supported by the legacy engine."
)
def subcatchment_evap(self, *args: Any, **kw: Any) -> None:
raise NotImplementedError(
"Forcing 'subcatchment evaporation override' is not supported by the legacy engine."
)
def clear_all(self) -> None:
# Legacy resets API/forcing values on each step automatically; nothing
# to do here.
pass
def clear(self, *args: Any, **kw: Any) -> None:
raise NotImplementedError(
"Per-element forcing clear is not supported by the legacy engine; "
"values reset automatically on each timestep."
)
class _LegacyHotstartHandle:
"""Object returned by :meth:`_LegacyHotstart.open` — has ``apply``."""
def __init__(self, path: str) -> None:
self._path = path
def apply(self, solver: _LegacySolverAdapter) -> None:
solver.raw.use_hotstart(self._path)
class _LegacyHotstart:
"""Mirrors the new-engine ``HotStart`` save/open/apply triplet."""
def __init__(self, _solver: LegacySolver) -> None:
# Constructor signature matches the new engine (which takes a Solver).
# The actual call goes through whichever solver the tool passes to
# save() / apply(), so we don't need to retain it here.
pass
def save(self, solver: _LegacySolverAdapter, path: str) -> None:
solver.raw.save_hotstart(path)
def open(self, path: str) -> _LegacyHotstartHandle:
return _LegacyHotstartHandle(path)
# ---------------------------------------------------------------------------
# Backend
# ---------------------------------------------------------------------------
[docs]
class LegacyBackend:
"""Backend that wraps :mod:`openswmm.legacy.engine`."""
engine_kind = "legacy"
def __init__(self, inp_path: str, rpt_path: str, out_path: str) -> None:
self._adapter = _LegacySolverAdapter(inp_path, rpt_path, out_path)
self._cache: dict[str, Any] = {}
@property
def solver(self) -> _LegacySolverAdapter:
return self._adapter
def __getattr__(self, name: str) -> Any:
# Only invoked when normal attribute lookup fails, so this never
# interferes with `solver` / `_adapter` / `_cache`.
cache = object.__getattribute__(self, "_cache")
if name in cache:
return cache[name]
adapter = object.__getattribute__(self, "_adapter")
raw = adapter.raw
if name == "nodes":
cache[name] = _LegacyNodes(raw, self)
elif name == "links":
cache[name] = _LegacyLinks(raw, self)
elif name == "subcatchments":
cache[name] = _LegacySubcatchments(raw, self)
elif name == "gages":
cache[name] = _LegacyGages(raw, self)
elif name == "pollutants":
cache[name] = _LegacyPollutants(raw, self)
elif name == "mass_balance":
cache[name] = _LegacyMassBalance(raw)
elif name == "forcing":
cache[name] = _LegacyForcing(self.nodes, self.links, self.subcatchments, self.gages)
elif name == "hotstart":
cache[name] = _LegacyHotstart(raw)
else:
raise AttributeError(
f"LegacyBackend has no attribute '{name}'. This feature requires engine='openswmm'."
)
return cache[name]