"""
Multi-objective Gymnasium environments.
Plan §2.1 + §5.3. Returns vector reward (one component per reward term)
instead of the scalar-summed reward the single-objective envs return.
The agent sees per-step vector reward; at episode termination,
C{info["mo_score"]} is populated with the single-point normalised
hypervolume of the episode's cumulative cost vector against a
user-supplied L{ideal_point} and L{reference_point}.
If L{mo_gymnasium} is installed, L{SwmmMORTCEnv} additionally subclasses
L{mo_gymnasium.MOEnv} so MORL-Baselines and similar agents recognise it
out of the box. When L{mo_gymnasium} is absent, the env still works as
a plain L{gymnasium.Env} returning a numpy-array reward (any
MO-Gymnasium-API-compatible consumer will accept it).
@author: Caleb Buahin
@copyright: Copyright (c) 2026 Caleb Buahin
@license: MIT
"""
from __future__ import annotations
from collections.abc import Sequence
from typing import Any
import numpy as np
from gymnasium import spaces
from openswmm_gymnasium.envs.base import SwmmRTCEnv
from openswmm_gymnasium.observations import ObservationBuilder
from openswmm_gymnasium.rewards import RewardTerm
from openswmm_gymnasium.scoring import normalized_hypervolume
from openswmm_gymnasium.spaces.runtime import OrificeSetting
# Soft-import mo_gymnasium so the package works without it.
try:
from mo_gymnasium import MOEnv as _MOEnvBase # type: ignore
_HAS_MO_GYMNASIUM = True
except ImportError: # pragma: no cover - exercised only when missing
_MOEnvBase = None
_HAS_MO_GYMNASIUM = False
class SwmmMORTCEnv(SwmmRTCEnv):
"""Multi-objective RTC environment returning vector reward.
Each component of the returned reward vector is the per-step
contribution of one entry in C{reward_terms}, with sign flipping
applied per term (C{minimize} → negated, C{maximize} → kept) so
that B{higher is better} across all components — matching the
Gymnasium convention.
At episode termination, C{info["mo_score"]} carries the single-point
normalised hypervolume of the episode's cumulative B{cost} vector
(i.e. before the higher-is-better sign flip) against
C{(ideal_point, reference_point)}. The resulting score is in
C{[0, 1]}.
@ivar reward_space: A L{gymnasium.spaces.Box} of shape
C{(n_objectives,)} for MO-Gymnasium compatibility.
"""
def __init__(
self,
*args: Any,
ideal_point: Sequence[float] | None = None,
reference_point: Sequence[float] | None = None,
runtime_factories: Sequence[OrificeSetting] | None = None,
observation_builder: ObservationBuilder | None = None,
reward_terms: Sequence[RewardTerm] | None = None,
**kwargs: Any,
) -> None:
"""
@param ideal_point: Per-objective best-case (lowest cost / most
beneficial) value used for hypervolume normalisation. Must
match the order of C{reward_terms}. Required.
@type ideal_point: sequence of float
@param reference_point: Per-objective nadir / worst-case value
used for hypervolume normalisation. Must match the order of
C{reward_terms}. Required.
@type reference_point: sequence of float
@raise ValueError: If C{reward_terms} is empty, or
C{ideal_point} / C{reference_point} mismatch the term count
or each other.
"""
if not reward_terms:
raise ValueError("SwmmMORTCEnv requires at least one reward term")
if ideal_point is None or reference_point is None:
raise ValueError("SwmmMORTCEnv requires both ideal_point and reference_point")
super().__init__(
*args,
runtime_factories=runtime_factories,
observation_builder=observation_builder,
reward_terms=reward_terms,
**kwargs,
)
n = len(self._reward_terms)
if len(ideal_point) != n or len(reference_point) != n:
raise ValueError(
f"ideal_point/reference_point lengths must match reward_terms count ({n})"
)
self._ideal = np.asarray(ideal_point, dtype=np.float64)
self._ref = np.asarray(reference_point, dtype=np.float64)
if np.any(self._ref <= self._ideal):
raise ValueError(
"reference_point must be strictly greater than ideal_point in every dimension"
)
# Vector reward space — used by MO-Gymnasium and analogue agents.
self.reward_space = spaces.Box(
low=-np.inf,
high=np.inf,
shape=(n,),
dtype=np.float32,
)
self._objective_names: list[str] = [t.name for t in self._reward_terms]
self._cumulative_cost: np.ndarray = np.zeros(n, dtype=np.float64)
# ------------------------------------------------------------------
# Gymnasium API overrides
# ------------------------------------------------------------------
def reset(
self,
*,
seed: int | None = None,
options: dict[str, Any] | None = None,
) -> tuple[np.ndarray, dict[str, Any]]:
"""Reset episode state and cumulative cost accumulator."""
obs, info = super().reset(seed=seed, options=options)
self._cumulative_cost[:] = 0.0
return obs, info
def step(
self, action: dict[str, Any]
) -> tuple[np.ndarray, np.ndarray, bool, bool, dict[str, Any]]:
"""Same lifecycle as L{SwmmRTCEnv.step} but returns vector reward.
@return: Standard 5-tuple with C{reward} as a 1-D
L{numpy.ndarray} of shape C{(n_objectives,)}.
"""
obs, _, terminated, truncated, info = super().step(action)
components = info["reward_components"]
# Vector cost (positive = bad for minimize terms).
cost_vec = np.array([components[name] for name in self._objective_names], dtype=np.float64)
# Higher-is-better sign convention per term direction.
signs = np.array(
[-1.0 if t.direction == "minimize" else 1.0 for t in self._reward_terms],
dtype=np.float64,
)
reward_vec = (signs * cost_vec).astype(np.float32)
self._cumulative_cost += np.where(signs < 0, cost_vec, -cost_vec)
if terminated:
# Single-point HV in normalised space. Plan §5.3: result in [0, 1].
mo_score = normalized_hypervolume(
self._cumulative_cost.reshape(1, -1),
self._ideal,
self._ref,
)
info["mo_score"] = float(mo_score)
info["cumulative_cost"] = self._cumulative_cost.copy()
return obs, reward_vec, terminated, truncated, info
# ---------------------------------------------------------------------------
# MO-Gymnasium interface mixing (optional)
# ---------------------------------------------------------------------------
if _HAS_MO_GYMNASIUM: # pragma: no cover - exercised only when installed
# Inject MOEnv into the MRO so isinstance(env, MOEnv) is True.
[docs]
class SwmmMORTCEnv(SwmmMORTCEnv, _MOEnvBase): # type: ignore[no-redef]
"""L{SwmmMORTCEnv} additionally subclassing L{mo_gymnasium.MOEnv}."""
pass