Source code for openswmm_gymnasium.spaces.runtime
"""
Runtime (RTC) action factories.
Each factory exposes a L{gymnasium.spaces.Box}-or-similar over a set of
controllable elements and translates sampled values into
L{openswmm.engine.Controls.set_link_setting} (or related) calls each
step.
Plan §3.2.
@author: Caleb Buahin
@copyright: Copyright (c) 2026 Caleb Buahin
@license: MIT
"""
from __future__ import annotations
from collections.abc import Sequence
import numpy as np
from gymnasium import spaces
from openswmm_gymnasium._engine import SolverAdapter
[docs]
class OrificeSetting:
"""Box action over the control setting of one or more links.
The setting is a real number in C{[0, 1]}, where C{0} = fully closed
and C{1} = fully open. Applied via
L{openswmm.engine.Controls.set_link_setting}.
Although the name reflects the primary use case, the underlying
engine call accepts any controllable link (orifices, weirs, pumps,
and conduits with the C{LINK_OFFSETS} option set appropriately).
@ivar _link_ids: Symbolic link IDs supplied at construction.
@type _link_ids: list[str]
@ivar _name: Action-space key under which this factory's component
appears in the env's runtime Dict.
@type _name: str
@ivar _link_idxs: Engine link indices, resolved by L{bind}.
@type _link_idxs: list[int] or C{None}
"""
def __init__(
self,
link_ids: Sequence[str],
name: str = "orifice_setting",
) -> None:
"""
@param link_ids: Symbolic IDs of links to control.
@type link_ids: sequence of str
@param name: Action-space key for this factory.
@type name: str
"""
if not link_ids:
raise ValueError("OrificeSetting requires at least one link_id")
self._link_ids: list[str] = list(link_ids)
self._name = name
self._link_idxs: list[int] | None = None
@property
def name(self) -> str:
"""Action-space key for this factory.
@rtype: str
"""
return self._name
@property
def space(self) -> spaces.Box:
"""Per-link setting in C{[0, 1]}.
@rtype: L{gymnasium.spaces.Box}
"""
n = len(self._link_ids)
return spaces.Box(
low=0.0,
high=1.0,
shape=(n,),
dtype=np.float32,
)
[docs]
def bind(self, adapter: SolverAdapter) -> None:
"""Resolve symbolic link IDs to engine indices.
Must be called after L{SolverAdapter.open} (so the engine knows
about the model's links) and before L{apply}.
@param adapter: Adapter wrapping the open solver.
@type adapter: L{SolverAdapter}
"""
self._link_idxs = [adapter.links.get_index(lid) for lid in self._link_ids]
[docs]
def apply(self, adapter: SolverAdapter, value: np.ndarray) -> None:
"""Push the sampled action value into the engine.
@param adapter: Adapter wrapping the running solver.
@type adapter: L{SolverAdapter}
@param value: 1-D float array of length C{len(link_ids)}.
@type value: numpy.ndarray
@raise RuntimeError: If L{bind} was not called first.
"""
if self._link_idxs is None:
raise RuntimeError("OrificeSetting.bind() must be called before apply()")
# Defensive clip — agents may sample outside [0,1] under
# numerical noise; the engine refuses values outside its expected
# range and we'd rather silently clamp.
clipped = np.clip(np.asarray(value, dtype=np.float32), 0.0, 1.0)
for idx, v in zip(self._link_idxs, clipped, strict=True):
adapter.controls.set_link_setting(idx, float(v))