Source code for openswmm_gymnasium.observations.builder

"""
Composable observation builder.

Plan §4. Each collector reads a single feature kind (node depths, link
flows, etc.) from the engine; the builder concatenates them into a
flat float32 vector.

P2 ships ten collectors covering the most common SWMM-RL feature sets:
node depths/heads/inflows/overflows, link flows/depths/settings,
subcatchment runoff, gage rainfall, and a clock collector. Forecast
injection and pollutant-concentration collectors land in P5
(forecast wrapper) and the pollutant phase respectively.

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

from __future__ import annotations

import math
from collections.abc import Sequence

import numpy as np
from gymnasium import spaces

from openswmm_gymnasium._engine import SolverAdapter

# =============================================================================
# Internal collector base + helpers
# =============================================================================


def _require_ids(name: str, ids: Sequence[str]) -> list[str]:
    if not ids:
        raise ValueError(f"{name} requires at least one id")
    return list(ids)


class _ScalarReadCollector:
    """Shared base for collectors that read one scalar per element index.

    Subclasses set L{_kind_label}, L{_resolve_idxs}, L{_read_scalar}.

    @ivar _ids: Symbolic element IDs.
    @type _ids: list[str]
    @ivar _idxs: Engine indices, resolved by L{bind}.
    @type _idxs: list[int] or C{None}
    """

    _kind_label: str = "_ScalarReadCollector"

    def __init__(self, ids: Sequence[str]) -> None:
        self._ids: list[str] = _require_ids(self._kind_label, ids)
        self._idxs: list[int] | None = None

    @property
    def size(self) -> int:
        return len(self._ids)

    def bind(self, adapter: SolverAdapter) -> None:
        self._idxs = self._resolve_idxs(adapter)

    def collect(self, adapter: SolverAdapter) -> np.ndarray:
        assert self._idxs is not None, "bind() before collect()"
        return np.fromiter(
            (self._read_scalar(adapter, i) for i in self._idxs),
            dtype=np.float32,
            count=len(self._idxs),
        )

    # Subclass hooks ------------------------------------------------------

    def _resolve_idxs(self, adapter: SolverAdapter) -> list[int]:
        raise NotImplementedError

    def _read_scalar(self, adapter: SolverAdapter, idx: int) -> float:
        raise NotImplementedError


# =============================================================================
# Node collectors
# =============================================================================


class _NodeDepthCollector(_ScalarReadCollector):
    """Instantaneous water depth at each node."""

    _kind_label = "add_node_depths"

    def _resolve_idxs(self, adapter):
        return [adapter.nodes.get_index(i) for i in self._ids]

    def _read_scalar(self, adapter, idx):
        return adapter.nodes.get_depth(idx)


class _NodeHeadCollector(_ScalarReadCollector):
    """Instantaneous hydraulic head at each node."""

    _kind_label = "add_node_heads"

    def _resolve_idxs(self, adapter):
        return [adapter.nodes.get_index(i) for i in self._ids]

    def _read_scalar(self, adapter, idx):
        return adapter.nodes.get_head(idx)


class _NodeInflowCollector(_ScalarReadCollector):
    """Total inflow rate at each node."""

    _kind_label = "add_node_inflows"

    def _resolve_idxs(self, adapter):
        return [adapter.nodes.get_index(i) for i in self._ids]

    def _read_scalar(self, adapter, idx):
        return adapter.nodes.get_inflow(idx)


class _NodeOverflowCollector(_ScalarReadCollector):
    """Overflow (flooding) rate at each node."""

    _kind_label = "add_node_overflows"

    def _resolve_idxs(self, adapter):
        return [adapter.nodes.get_index(i) for i in self._ids]

    def _read_scalar(self, adapter, idx):
        return adapter.nodes.get_overflow(idx)


# =============================================================================
# Link collectors
# =============================================================================


class _LinkFlowCollector(_ScalarReadCollector):
    """Instantaneous flow through each link."""

    _kind_label = "add_link_flows"

    def _resolve_idxs(self, adapter):
        return [adapter.links.get_index(i) for i in self._ids]

    def _read_scalar(self, adapter, idx):
        return adapter.links.get_flow(idx)


class _LinkDepthCollector(_ScalarReadCollector):
    """Instantaneous depth in each link."""

    _kind_label = "add_link_depths"

    def _resolve_idxs(self, adapter):
        return [adapter.links.get_index(i) for i in self._ids]

    def _read_scalar(self, adapter, idx):
        return adapter.links.get_depth(idx)


class _LinkSettingCollector(_ScalarReadCollector):
    """Current control setting C{[0, 1]} for each link.

    Useful for closed-loop RL agents that need to observe the prior
    action they (or another controller) applied.
    """

    _kind_label = "add_link_settings"

    def _resolve_idxs(self, adapter):
        return [adapter.links.get_index(i) for i in self._ids]

    def _read_scalar(self, adapter, idx):
        return adapter.links.get_control_setting(idx)


# =============================================================================
# Subcatchment + rain collectors
# =============================================================================


class _SubcatchRunoffCollector(_ScalarReadCollector):
    """Runoff rate from each subcatchment."""

    _kind_label = "add_subcatch_runoff"

    def _resolve_idxs(self, adapter):
        return [adapter.subcatchments.get_index(i) for i in self._ids]

    def _read_scalar(self, adapter, idx):
        return adapter.subcatchments.get_runoff(idx)


class _RainfallCollector(_ScalarReadCollector):
    """Rainfall intensity at each rain gage."""

    _kind_label = "add_rainfall"

    def _resolve_idxs(self, adapter):
        return [adapter.gages.get_index(i) for i in self._ids]

    def _read_scalar(self, adapter, idx):
        return adapter.gages.get_rainfall(idx)


# =============================================================================
# Clock collector (no per-element repetition)
# =============================================================================


_CLOCK_FEATURES = ("hour_sin", "hour_cos", "elapsed_frac")


class _ClockCollector:
    """Time-of-day + episode-progress features.

    Three features in fixed order:

      - C{hour_sin} — C{sin(2π * hour_of_day / 24)}.
      - C{hour_cos} — C{cos(2π * hour_of_day / 24)}.
      - C{elapsed_frac} — C{(current - start) / (end - start)}, in
        C{[0, 1]}.

    The hour-of-day uses the fractional part of the engine's OADate
    (1.0 == 1 day), so encoding is correct regardless of the model's
    start date.

    @ivar _features: Subset and order of features to return.
    @type _features: tuple[str, ...]
    """

    def __init__(self, features: Sequence[str] | None = None) -> None:
        if features is None:
            features = _CLOCK_FEATURES
        unknown = set(features) - set(_CLOCK_FEATURES)
        if unknown:
            raise ValueError(
                f"Unknown clock features: {sorted(unknown)}; valid: {list(_CLOCK_FEATURES)}"
            )
        self._features: tuple[str, ...] = tuple(features)
        self._adapter: SolverAdapter | None = None

    @property
    def size(self) -> int:
        return len(self._features)

    def bind(self, adapter: SolverAdapter) -> None:
        # No symbolic IDs to resolve; we just stash the adapter for
        # later constant-time access to start/end.
        self._adapter = adapter

    def collect(self, adapter: SolverAdapter) -> np.ndarray:
        assert self._adapter is not None, "bind() before collect()"
        current = adapter.current_time
        start = adapter.start_time
        end = adapter.end_time
        # OADate fractional part = time-of-day in days.
        hour_of_day = (current - math.floor(current)) * 24.0
        angle = 2.0 * math.pi * hour_of_day / 24.0
        span = end - start
        if span <= 0.0:
            elapsed_frac = 0.0
        else:
            elapsed_frac = max(0.0, min(1.0, (current - start) / span))
        values = {
            "hour_sin": math.sin(angle),
            "hour_cos": math.cos(angle),
            "elapsed_frac": elapsed_frac,
        }
        return np.fromiter(
            (values[f] for f in self._features),
            dtype=np.float32,
            count=len(self._features),
        )


# =============================================================================
# Builder
# =============================================================================


[docs] class ObservationBuilder: """Fluent composer for the env's observation space. Returned space is a flat L{gymnasium.spaces.Box} of dtype float32 with shape C{(sum(collector.size),)}. Bounds are C{[-inf, +inf]}; callers wishing finite bounds should wrap with a L{gymnasium.wrappers.NormalizeObservation} or supply their own L{gymnasium.spaces.Box} via a subclass in a later phase. Example:: obs = (ObservationBuilder() .add_node_depths(["J1"]) .add_link_flows(["C1"]) .add_clock()) @ivar _collectors: Ordered list of bound-and-collect units. @type _collectors: list """ def __init__(self) -> None: self._collectors: list = [] # ----- Node features -------------------------------------------------
[docs] def add_node_depths(self, node_ids: Sequence[str]) -> ObservationBuilder: """Append a node-depth collector. @param node_ids: Node IDs whose depths to observe. @type node_ids: sequence of str @return: This builder, for chaining. @rtype: L{ObservationBuilder} """ self._collectors.append(_NodeDepthCollector(node_ids)) return self
[docs] def add_node_heads(self, node_ids: Sequence[str]) -> ObservationBuilder: """Append a node-head collector. @rtype: L{ObservationBuilder} """ self._collectors.append(_NodeHeadCollector(node_ids)) return self
[docs] def add_node_inflows(self, node_ids: Sequence[str]) -> ObservationBuilder: """Append a node-inflow collector. @rtype: L{ObservationBuilder} """ self._collectors.append(_NodeInflowCollector(node_ids)) return self
[docs] def add_node_overflows(self, node_ids: Sequence[str]) -> ObservationBuilder: """Append a node-overflow (flooding rate) collector. @rtype: L{ObservationBuilder} """ self._collectors.append(_NodeOverflowCollector(node_ids)) return self
# ----- Link features ------------------------------------------------- # ----- Subcatchment + rain features ---------------------------------
[docs] def add_subcatch_runoff(self, subcatch_ids: Sequence[str]) -> ObservationBuilder: """Append a subcatchment-runoff collector. @rtype: L{ObservationBuilder} """ self._collectors.append(_SubcatchRunoffCollector(subcatch_ids)) return self
[docs] def add_rainfall(self, gage_ids: Sequence[str]) -> ObservationBuilder: """Append a rain-gage rainfall collector. @rtype: L{ObservationBuilder} """ self._collectors.append(_RainfallCollector(gage_ids)) return self
# ----- Time features -------------------------------------------------
[docs] def add_clock(self, features: Sequence[str] | None = None) -> ObservationBuilder: """Append a clock collector. @param features: Subset of C{("hour_sin", "hour_cos", "elapsed_frac")}. Defaults to all three. @type features: sequence of str or C{None} @rtype: L{ObservationBuilder} @raise ValueError: If C{features} contains an unrecognised key. """ self._collectors.append(_ClockCollector(features)) return self
# ----- Build / bind / collect ---------------------------------------
[docs] def space(self) -> spaces.Box: """Construct the env's observation space. @rtype: L{gymnasium.spaces.Box} @raise ValueError: If no collectors have been added. """ if not self._collectors: raise ValueError("ObservationBuilder is empty; add at least one collector") size = sum(c.size for c in self._collectors) return spaces.Box(low=-np.inf, high=np.inf, shape=(size,), dtype=np.float32)
[docs] def bind(self, adapter: SolverAdapter) -> None: """Resolve symbolic IDs in every collector. @param adapter: Adapter wrapping the open solver. @type adapter: L{SolverAdapter} """ for c in self._collectors: c.bind(adapter)
[docs] def collect(self, adapter: SolverAdapter) -> np.ndarray: """Read the current observation from the engine. @param adapter: Adapter wrapping the running solver. @type adapter: L{SolverAdapter} @return: 1-D float32 array matching L{space}. @rtype: numpy.ndarray """ return np.concatenate([c.collect(adapter) for c in self._collectors])