Source code for openswmm_gymnasium.wrappers.scalarize

"""
Wrappers that convert vector reward into scalar reward.

Used to plug an L{openswmm_gymnasium.envs.SwmmMORTCEnv} into a
single-objective agent (DQN, PPO, SAC, ...) by collapsing the per-step
vector reward to a scalar.

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

from __future__ import annotations

from collections.abc import Sequence

import gymnasium as gym
import numpy as np


[docs] class LinearScalarize(gym.RewardWrapper): """Scalar reward = C{dot(weights, vector_reward)}. Weights need not sum to one; the wrapper does not normalise. Each component is multiplied by its corresponding weight before summation. @ivar weights: 1-D weight vector, same length as the env's reward vector. """ def __init__(self, env: gym.Env, weights: Sequence[float]) -> None: """ @param env: Vector-reward env to wrap. @type env: L{gymnasium.Env} @param weights: Weight per objective. @type weights: sequence of float @raise ValueError: If C{weights} is empty. """ super().__init__(env) if not len(weights): raise ValueError("weights must not be empty") self.weights = np.asarray(weights, dtype=np.float64)
[docs] def reward(self, reward: np.ndarray) -> float: r = np.asarray(reward, dtype=np.float64) if r.shape != self.weights.shape: raise ValueError( f"reward shape {r.shape} does not match weights shape {self.weights.shape}" ) return float(np.dot(self.weights, r))
[docs] class TchebycheffScalarize(gym.RewardWrapper): """Weighted-Tchebycheff scalarisation. For B{higher-is-better} vector reward (which is what the MO envs emit), this returns C{-max_d w_d * (utopia_d - r_d)}. The negation keeps the higher-is-better convention at the scalar level: a reward vector closer to the utopia point produces a smaller weighted gap and hence a larger (less negative) scalar. @ivar weights: Per-objective weights. @ivar utopia: Per-objective best-case reward (high values for higher-is-better envs). """ def __init__( self, env: gym.Env, weights: Sequence[float], utopia: Sequence[float], ) -> None: """ @param env: Vector-reward env to wrap. @type env: L{gymnasium.Env} @param weights: Weight per objective. @type weights: sequence of float @param utopia: Per-objective utopia (highest reachable reward). @type utopia: sequence of float @raise ValueError: If C{weights} and C{utopia} differ in shape or are empty. """ super().__init__(env) if not len(weights) or len(weights) != len(utopia): raise ValueError("weights and utopia must have matching non-zero length") self.weights = np.asarray(weights, dtype=np.float64) self.utopia = np.asarray(utopia, dtype=np.float64)
[docs] def reward(self, reward: np.ndarray) -> float: r = np.asarray(reward, dtype=np.float64) if r.shape != self.weights.shape: raise ValueError( f"reward shape {r.shape} does not match weights shape {self.weights.shape}" ) gap = self.weights * np.maximum(0.0, self.utopia - r) return float(-gap.max())