Source code for openswmm_gymnasium.rewards.terms

"""
Built-in reward terms.

Each term is a small class implementing the L{RewardTerm} interface:

  - C{name} — unique key under which the term's per-step contribution
    appears in C{info["reward_components"]}.
  - C{direction} — C{"minimize"} for costs (the default), C{"maximize"}
    for benefits like infiltration volume or freeboard.
  - C{bind(adapter)} — called once at the start of each episode to
    resolve symbolic IDs against the freshly-opened engine.
  - C{reset()} — called at the start of each episode to clear any
    cross-step accumulators inside the term.
  - C{step(adapter, dt_seconds)} — called every env C{step()}, returns
    the per-step contribution (B{positive} for both C{"minimize"} and
    C{"maximize"} terms; the env handles sign flipping per plan §0 #5).

P2 ships five first-class terms:

  - L{FloodingVolume} (P1) — flooding rate × dt, summed across nodes.
  - L{CSOVolume} — same math restricted to tagged overflow nodes.
  - L{PeakOutflow} — running-max increments on link flow(s); cumulative
    equals peak observed.
  - L{ReliabilityMargin} — minimum freeboard (C{max_depth - depth}) at
    each step. Direction: maximize.
  - L{SetpointSmoothness} — L2 norm of Δsetting between successive
    steps; cumulative measures total action churn.

The remaining four plan §5.1 terms (C{PumpEnergy}, C{TSSLoad},
C{CapitalCost}, C{OandMCost}) land in subsequent phases.

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

from __future__ import annotations

from collections.abc import Sequence
from typing import Protocol, runtime_checkable

from openswmm_gymnasium._engine import SolverAdapter


[docs] @runtime_checkable class RewardTerm(Protocol): """Interface every reward term must satisfy. @cvar name: Unique short identifier for the term. @cvar direction: C{"minimize"} or C{"maximize"}. """ name: str direction: str
[docs] def bind(self, adapter: SolverAdapter) -> None: """Resolve symbolic IDs against the freshly-opened engine. @param adapter: Adapter wrapping the open solver. @type adapter: L{SolverAdapter} """ ...
[docs] def reset(self) -> None: """Clear any cross-step accumulators inside the term.""" ...
[docs] def step(self, adapter: SolverAdapter, dt_seconds: float) -> float: """Compute this term's contribution for one env C{step()}. @param adapter: Adapter wrapping the running solver. @type adapter: L{SolverAdapter} @param dt_seconds: Elapsed real-time of the step, in seconds. @type dt_seconds: float @return: Non-negative contribution. Sign flipping for the agent happens in the env, not in the term. @rtype: float """ ...
# ============================================================================= # Flooding / overflow accounting # =============================================================================
[docs] class FloodingVolume: """Sum of overflow volume across a set of nodes, per env step. Computed as C{sum_i overflow_rate_i * dt_seconds}, where C{overflow_rate} is read from L{openswmm.engine.Nodes.get_overflow} (project flow units). If C{node_ids} is C{None}, all nodes in the model contribute. @ivar name: C{"flooding_volume"} by default. @ivar direction: Always C{"minimize"}. """ direction = "minimize" def __init__( self, node_ids: Sequence[str] | None = None, name: str = "flooding_volume", ) -> None: """ @param node_ids: Nodes to sum over, or C{None} for all nodes. @type node_ids: sequence of str or C{None} @param name: Term identifier used in C{info["reward_components"]}. @type name: str """ self.name = name self._node_ids: list[str] | None = list(node_ids) if node_ids is not None else None self._idxs: list[int] | None = None
[docs] def bind(self, adapter: SolverAdapter) -> None: if self._node_ids is None: self._idxs = list(range(adapter.nodes.count())) else: self._idxs = [adapter.nodes.get_index(nid) for nid in self._node_ids]
[docs] def reset(self) -> None: """No cross-step state; no-op."""
[docs] def step(self, adapter: SolverAdapter, dt_seconds: float) -> float: assert self._idxs is not None, "bind() before step()" rate_sum = 0.0 get = adapter.nodes.get_overflow for idx in self._idxs: rate_sum += float(get(idx)) if rate_sum < 0.0: rate_sum = 0.0 return rate_sum * dt_seconds
[docs] class CSOVolume(FloodingVolume): """Overflow volume restricted to a set of CSO / relief nodes. Mechanically identical to L{FloodingVolume} but defaults the term name to C{"cso_volume"} and B{requires} an explicit C{node_ids} list — running over all nodes would conflate CSO with general flooding. """ def __init__( self, node_ids: Sequence[str], name: str = "cso_volume", ) -> None: """ @param node_ids: Tagged CSO / overflow node IDs. @type node_ids: sequence of str (required) @param name: Term identifier. @type name: str @raise ValueError: If C{node_ids} is empty. """ if not node_ids: raise ValueError("CSOVolume requires at least one node_id") super().__init__(node_ids=node_ids, name=name)
# ============================================================================= # Peak outflow tracker # =============================================================================
[docs] class PeakOutflow: """Running-max link flow; per-step contribution is the new-peak increment. Each step, for each tracked link, computes C{max(0, |flow_now| - peak_so_far)}. When a new peak is set the increment equals the delta; otherwise the contribution is zero. Cumulative reward over the episode therefore equals the maximum absolute flow observed at each link (summed across links). Plan §5.1 framing: minimise peak outflow. @ivar name: C{"peak_outflow"} by default. @ivar direction: Always C{"minimize"}. """ direction = "minimize" def __init__( self, link_ids: Sequence[str], name: str = "peak_outflow", ) -> None: """ @param link_ids: Links whose flow magnitudes to track. @type link_ids: sequence of str (required) @param name: Term identifier. @type name: str @raise ValueError: If C{link_ids} is empty. """ if not link_ids: raise ValueError("PeakOutflow requires at least one link_id") self.name = name self._link_ids: list[str] = list(link_ids) self._idxs: list[int] | None = None self._peak: list[float] | None = None
[docs] def bind(self, adapter: SolverAdapter) -> None: self._idxs = [adapter.links.get_index(lid) for lid in self._link_ids]
[docs] def reset(self) -> None: self._peak = [0.0] * len(self._link_ids)
[docs] def step(self, adapter: SolverAdapter, dt_seconds: float) -> float: assert self._idxs is not None and self._peak is not None, "bind() before step()" increment = 0.0 get = adapter.links.get_flow for i, idx in enumerate(self._idxs): mag = abs(float(get(idx))) if mag > self._peak[i]: increment += mag - self._peak[i] self._peak[i] = mag return increment
# ============================================================================= # Reliability margin (maximise) # =============================================================================
[docs] class ReliabilityMargin: """Per-step minimum freeboard across a set of nodes. Freeboard at a node = C{max_depth - depth}. The term returns the minimum across the tracked set, clamped at zero (surcharged nodes yield 0 rather than a negative penalty — the L{FloodingVolume} term already accounts for overflow). Direction: B{maximize}. @ivar name: C{"reliability_margin"} by default. @ivar direction: Always C{"maximize"}. """ direction = "maximize" def __init__( self, node_ids: Sequence[str] | None = None, name: str = "reliability_margin", ) -> None: """ @param node_ids: Nodes to evaluate, or C{None} for all nodes. @type node_ids: sequence of str or C{None} @param name: Term identifier. @type name: str """ self.name = name self._node_ids: list[str] | None = list(node_ids) if node_ids is not None else None self._idxs: list[int] | None = None self._max_depths: list[float] | None = None
[docs] def bind(self, adapter: SolverAdapter) -> None: if self._node_ids is None: self._idxs = list(range(adapter.nodes.count())) else: self._idxs = [adapter.nodes.get_index(nid) for nid in self._node_ids] # max_depth is a model-constant, cache once. self._max_depths = [float(adapter.nodes.get_max_depth(i)) for i in self._idxs]
[docs] def reset(self) -> None: """No cross-step state; no-op."""
[docs] def step(self, adapter: SolverAdapter, dt_seconds: float) -> float: assert self._idxs is not None and self._max_depths is not None, "bind() before step()" get = adapter.nodes.get_depth min_fb = float("inf") for idx, md in zip(self._idxs, self._max_depths, strict=True): fb = md - float(get(idx)) if fb < min_fb: min_fb = fb return max(0.0, min_fb)
# ============================================================================= # Setpoint smoothness (minimise action churn) # =============================================================================
[docs] class SetpointSmoothness: """L2 norm-squared of Δsetting between successive env steps. Each step, for each tracked link, reads the current control setting and adds C{(setting_now - setting_prev) ** 2} to the contribution. The first step after L{reset} returns zero (no previous setting yet). Direction: B{minimize}. @ivar name: C{"setpoint_smoothness"} by default. @ivar direction: Always C{"minimize"}. """ direction = "minimize" def __init__( self, link_ids: Sequence[str], name: str = "setpoint_smoothness", ) -> None: """ @param link_ids: Links whose setting trajectories to penalise. @type link_ids: sequence of str (required) @param name: Term identifier. @type name: str @raise ValueError: If C{link_ids} is empty. """ if not link_ids: raise ValueError("SetpointSmoothness requires at least one link_id") self.name = name self._link_ids: list[str] = list(link_ids) self._idxs: list[int] | None = None self._prev: list[float] | None = None
[docs] def bind(self, adapter: SolverAdapter) -> None: self._idxs = [adapter.links.get_index(lid) for lid in self._link_ids]
[docs] def reset(self) -> None: self._prev = None
[docs] def step(self, adapter: SolverAdapter, dt_seconds: float) -> float: assert self._idxs is not None, "bind() before step()" get = adapter.links.get_control_setting current = [float(get(i)) for i in self._idxs] if self._prev is None: self._prev = current return 0.0 churn = 0.0 for c, p in zip(current, self._prev, strict=True): d = c - p churn += d * d self._prev = current return churn