Source code for pettingzoo.utils.wrappers.pad_observations

"""Wrappers that pad every agent's observation up to a single shared space."""

from __future__ import annotations

from typing import Any, cast

import numpy as np
from gymnasium.spaces import Box, Discrete, Space
from typing_extensions import override

from pettingzoo.utils.env import ActionType, AECEnv, AgentID, ObsType, ParallelEnv
from pettingzoo.utils.wrappers.base import BaseWrapper
from pettingzoo.utils.wrappers.base_parallel import BaseParallelWrapper


def _check_paddable(spaces: list[Space[Any]]) -> None:
    """Checks that a list of observation spaces can be padded to a common space."""
    assert len(spaces) > 0, "environment must have at least one possible agent"
    first = spaces[0]
    assert isinstance(first, (Box, Discrete)), (
        "padding only supports Box and Discrete observation spaces, "
        f"got {type(first).__name__}"
    )

    expected = Box if isinstance(first, Box) else Discrete
    for space in spaces:
        assert isinstance(space, expected), (
            "all observation spaces must be either Box or Discrete, not a mix"
        )

    if isinstance(first, Box):
        for space in spaces:
            assert isinstance(space, Box)
            assert len(first.shape) == len(space.shape), (
                "all Box observation spaces must have the same number of dimensions"
            )
            assert first.dtype == space.dtype, (
                "all Box observation spaces must have the same dtype"
            )


def _pad_to(array: np.ndarray, shape: tuple[int, ...], value: Any) -> np.ndarray:
    """Pads ``array`` up to ``shape`` at the end of every axis."""
    if array.shape == shape:
        return array
    pad_widths = [(0, new - old) for new, old in zip(shape, array.shape)]
    return np.pad(array, pad_widths, constant_values=value)


def _padded_space(spaces: list[Space[Any]]) -> Space[Any]:
    """Builds the space every agent is padded up to."""
    if isinstance(spaces[0], Discrete):
        discretes = cast("list[Discrete[Any]]", spaces)
        start = min(int(space.start) for space in discretes)
        end = max(int(space.start) + int(space.n) for space in discretes)
        return Discrete(end - start, start=start)

    boxes = cast("list[Box]", spaces)
    shape = tuple(int(dim) for dim in np.max([box.shape for box in boxes], axis=0))
    # the fill values are what SuperSuit uses: a bound wide enough that padding an
    # agent's own bounds can never tighten the shared space
    lows = np.stack(
        [_pad_to(box.low, shape, np.minimum(0, np.min(box.low))) for box in boxes]
    )
    highs = np.stack(
        [_pad_to(box.high, shape, np.maximum(1e-5, np.max(box.high))) for box in boxes]
    )
    # every space has the same dtype, and padding keeps it
    return Box(
        low=np.min(lows, axis=0), high=np.max(highs, axis=0), dtype=lows.dtype.type
    )


def _pad_observation(space: Space[Any], obs: Any) -> Any:
    """Pads a single observation up to ``space``."""
    if obs is None:
        return None
    if not isinstance(space, Box):
        # Discrete observations already fit the shared space. SuperSuit returns the
        # space here instead of the observation, which is a bug.
        return obs
    return _pad_to(np.asarray(obs), space.shape, 0)


[docs] class PadObservationsV1(BaseWrapper[AgentID, Any, ActionType]): """Pads each agent's observation up to one shared observation space. The shared space covers the observation spaces of ``possible_agents``. For Box spaces it takes the largest size along each axis, and each observation is padded with zeros at the end of every axis, so the real values stay at the front. For Discrete spaces it takes the range covering all of them and observations pass through unchanged. Every agent then reports that same space. Box spaces have to agree on dtype and number of dimensions. Anything other than Box and Discrete is rejected. An agent whose observation is already the full shape gets the array back without a copy, so writing into it writes into the environment. Ported from SuperSuit's pad_observations_v0; the version suffix continues that numbering. :param env: The AEC environment to wrap. """ def __init__(self, env: AECEnv[AgentID, ObsType, ActionType]): assert isinstance(env, AECEnv), ( "PadObservationsV1 is only compatible with AEC environments, " "use PadObservationsParallelV1 instead." ) assert hasattr(env, "possible_agents"), ( "environment passed to PadObservationsV1 must have a possible_agents list." ) super().__init__(env) spaces = [env.observation_space(agent) for agent in env.possible_agents] _check_paddable(spaces) self._obs_space = _padded_space(spaces) @override def observation_space(self, agent: AgentID) -> Space[Any]: return self._obs_space @override def observe(self, agent: AgentID) -> Any: return _pad_observation(self._obs_space, self.env.observe(agent)) @override def __str__(self) -> str: return f"PadObservationsV1<{self.env!s}>"
[docs] class PadObservationsParallelV1(BaseParallelWrapper[AgentID, Any, ActionType]): """Pads each agent's observation up to one shared observation space. The shared space covers the observation spaces of ``possible_agents``. For Box spaces it takes the largest size along each axis, and each observation is padded with zeros at the end of every axis, so the real values stay at the front. For Discrete spaces it takes the range covering all of them and observations pass through unchanged. Every agent then reports that same space. Box spaces have to agree on dtype and number of dimensions. Anything other than Box and Discrete is rejected. An agent whose observation is already the full shape gets the array back without a copy, so writing into it writes into the environment. Ported from SuperSuit's pad_observations_v0; the version suffix continues that numbering. :param env: The parallel environment to wrap. """ def __init__(self, env: ParallelEnv[AgentID, ObsType, ActionType]): assert isinstance(env, ParallelEnv), ( "PadObservationsParallelV1 is only compatible with parallel environments, " "use PadObservationsV1 instead." ) assert hasattr(env, "possible_agents"), ( "environment passed to PadObservationsParallelV1 must have a " "possible_agents list." ) super().__init__(env) spaces = [env.observation_space(agent) for agent in env.possible_agents] _check_paddable(spaces) self._obs_space = _padded_space(spaces) @override def observation_space(self, agent: AgentID) -> Space[Any]: return self._obs_space def _pad(self, observations: dict[AgentID, Any]) -> dict[AgentID, Any]: return { agent: _pad_observation(self._obs_space, obs) for agent, obs in observations.items() } @override def reset( self, seed: int | None = None, options: dict[str, Any] | None = None ) -> tuple[dict[AgentID, Any], dict[AgentID, dict[str, Any]]]: observations, infos = self.env.reset(seed=seed, options=options) return self._pad(observations), infos @override def step( self, actions: dict[AgentID, ActionType] ) -> tuple[ dict[AgentID, Any], dict[AgentID, float], dict[AgentID, bool], dict[AgentID, bool], dict[AgentID, dict[str, Any]], ]: observations, rewards, terminations, truncations, infos = self.env.step(actions) return self._pad(observations), rewards, terminations, truncations, infos @override def __str__(self) -> str: return f"PadObservationsParallelV1<{self.env!s}>"