From e7580cd41d948827c2716f38193e30691fad4df3 Mon Sep 17 00:00:00 2001 From: Matthew Taylor Date: Fri, 16 Oct 2026 19:17:17 -0400 Subject: [PATCH 01/20] Rename modifiers to tensor processors and add dictionary processing --- docs/source/api/lab/isaaclab.managers.rst | 6 +- docs/source/api/lab/isaaclab.utils.rst | 23 +- .../dict-tensor-processors.added.rst | 2 + .../dict-tensor-processors.deprecated.rst | 4 + .../changelog.d/dict-tensor-processors.minor | 0 .../isaaclab/managers/manager_base.py | 4 +- .../isaaclab/managers/manager_term_cfg.py | 32 ++- .../isaaclab/managers/observation_manager.py | 45 +-- source/isaaclab/isaaclab/utils/__init__.pyi | 13 +- .../isaaclab/utils/modifiers/__init__.py | 56 +--- .../isaaclab/utils/modifiers/modifier.py | 253 +---------------- .../isaaclab/utils/modifiers/modifier_base.py | 75 +---- .../isaaclab/utils/modifiers/modifier_cfg.py | 79 +----- .../utils/tensor_processors/__init__.py | 57 ++++ .../utils/tensor_processors/__init__.pyi | 25 ++ .../utils/tensor_processors/_pipeline.py | 126 +++++++++ .../dict_tensor_processor.py | 47 ++++ .../dict_tensor_processor_cfg.py | 40 +++ .../utils/tensor_processors/processors.py | 257 ++++++++++++++++++ .../tensor_processors/tensor_processor.py | 60 ++++ .../tensor_processors/tensor_processor_cfg.py | 82 ++++++ .../test/managers/test_manager_base.py | 4 +- .../test/managers/test_observation_manager.py | 103 ++++--- .../isaaclab/test/utils/test_configclass.py | 4 +- .../test/utils/test_dict_tensor_processor.py | 228 ++++++++++++++++ source/isaaclab/test/utils/test_modifiers.py | 26 +- .../dict-tensor-processors.deprecated.rst | 3 + .../changelog.d/dict-tensor-processors.minor | 0 .../managers/observation_manager.py | 65 ++--- .../isaaclab_experimental/utils/__init__.pyi | 3 +- .../utils/modifiers/__init__.py | 24 +- .../utils/modifiers/modifier.py | 76 +----- .../utils/modifiers/modifier_base.py | 59 +--- .../utils/modifiers/modifier_cfg.py | 35 +-- .../utils/tensor_processors/__init__.py | 18 ++ .../utils/tensor_processors/processors.py | 83 ++++++ .../tensor_processors/tensor_processor.py | 63 +++++ .../tensor_processors/tensor_processor_cfg.py | 39 +++ 38 files changed, 1376 insertions(+), 743 deletions(-) create mode 100644 source/isaaclab/changelog.d/dict-tensor-processors.added.rst create mode 100644 source/isaaclab/changelog.d/dict-tensor-processors.deprecated.rst create mode 100644 source/isaaclab/changelog.d/dict-tensor-processors.minor create mode 100644 source/isaaclab/isaaclab/utils/tensor_processors/__init__.py create mode 100644 source/isaaclab/isaaclab/utils/tensor_processors/__init__.pyi create mode 100644 source/isaaclab/isaaclab/utils/tensor_processors/_pipeline.py create mode 100644 source/isaaclab/isaaclab/utils/tensor_processors/dict_tensor_processor.py create mode 100644 source/isaaclab/isaaclab/utils/tensor_processors/dict_tensor_processor_cfg.py create mode 100644 source/isaaclab/isaaclab/utils/tensor_processors/processors.py create mode 100644 source/isaaclab/isaaclab/utils/tensor_processors/tensor_processor.py create mode 100644 source/isaaclab/isaaclab/utils/tensor_processors/tensor_processor_cfg.py create mode 100644 source/isaaclab/test/utils/test_dict_tensor_processor.py create mode 100644 source/isaaclab_experimental/changelog.d/dict-tensor-processors.deprecated.rst create mode 100644 source/isaaclab_experimental/changelog.d/dict-tensor-processors.minor create mode 100644 source/isaaclab_experimental/isaaclab_experimental/utils/tensor_processors/__init__.py create mode 100644 source/isaaclab_experimental/isaaclab_experimental/utils/tensor_processors/processors.py create mode 100644 source/isaaclab_experimental/isaaclab_experimental/utils/tensor_processors/tensor_processor.py create mode 100644 source/isaaclab_experimental/isaaclab_experimental/utils/tensor_processors/tensor_processor_cfg.py diff --git a/docs/source/api/lab/isaaclab.managers.rst b/docs/source/api/lab/isaaclab.managers.rst index d378386b86de..b4cd0fc7dfcc 100644 --- a/docs/source/api/lab/isaaclab.managers.rst +++ b/docs/source/api/lab/isaaclab.managers.rst @@ -103,7 +103,7 @@ On each recorded sample, each environment retains its lag with probability 0.8 a one. Zero redraws on every sample. Holding the lag keeps the latency constant while frames continue to advance; it does not freeze the returned frame. Reset always redraws the selected environments' lags. -The processing order is observation function, modifiers, noise, clipping, scaling, delay, then history. +The processing order is observation function, tensor processors, noise, clipping, scaling, delay, then history. The ``delay_min_lag`` and ``delay_max_lag`` field names and delay placement follow `mjlab's observation configuration `_. ``delay_hold_prob`` follows mjlab's lag-retention semantics, with a default of 1.0 to preserve Isaac Lab's @@ -115,7 +115,7 @@ For a delay buffer with no recorded sample after initialization or reset, the cu without recording it. Once recording starts, delays exceeding the available history return the oldest sample. Partial resets invalidate only the selected environments' histories. -Observation outputs are independent snapshots: later simulation steps, modifier updates, and resets +Observation outputs are independent snapshots: later simulation steps, processor updates, and resets do not overwrite previously returned tensors. The manager automatically copies borrowed storage before mutation or retention. Term and custom callback outputs are treated conservatively, without decorators or task-level copy settings. Clipping and scaling create independent storage when needed, which later @@ -131,7 +131,7 @@ The manager checks the signature once during initialization. Only an explicitly ``out=None`` enables destination writes; accepting arbitrary ``**kwargs`` does not. The manager probes the ordinary call to infer shape, dtype and device, which must remain constant, then allocates a fresh contiguous destination for each computation. ``out`` is reserved and must not appear in term ``params``. -Custom modifiers, noise and history retain their usual snapshot protections. Built-in +Custom tensor processors, noise and history retain their usual snapshot protections. Built-in :class:`~isaaclab.envs.mdp.observations.image_rgb` terms use this interface automatically to avoid a second full-image copy for normalized uint8 images. Existing observation configurations require no changes. diff --git a/docs/source/api/lab/isaaclab.utils.rst b/docs/source/api/lab/isaaclab.utils.rst index d9cdd0e5cfa2..53b28dc39848 100644 --- a/docs/source/api/lab/isaaclab.utils.rst +++ b/docs/source/api/lab/isaaclab.utils.rst @@ -17,7 +17,7 @@ logger math mesh - modifiers + tensor_processors noise seed sensors @@ -124,10 +124,25 @@ Mesh operations :imported-members: :show-inheritance: -Modifier operations -~~~~~~~~~~~~~~~~~~~ +Tensor processors +~~~~~~~~~~~~~~~~~ + +The processor interfaces live together in :mod:`isaaclab.utils.tensor_processors`: + +* ``TensorProcessor``: tensor to tensor. +* ``DictTensorProcessor``: dictionary of named tensors to dictionary of named tensors. + +Dictionary tensors use ``ProxyArray`` for Warp and Torch access without copying. Processors own +any persistent outputs and state. ``DictTensorProcessorCfg`` declares input/output keys so consumers +can resolve dependencies before allocating buffers. Composition preserves untouched keys. + +Migration: import ``TensorProcessor`` and ``TensorProcessorCfg`` from +``isaaclab.utils.tensor_processors`` in place of ``ModifierBase`` and ``ModifierCfg`` from +``isaaclab.utils.modifiers``. Replace ``ObservationTermCfg.modifiers`` with +``ObservationTermCfg.tensor_processors``. The processing order and tensor shapes are unchanged. +The old imports and configuration field remain available with a deprecation warning; do not set both fields. -.. automodule:: isaaclab.utils.modifiers +.. automodule:: isaaclab.utils.tensor_processors :members: :imported-members: :special-members: __call__ diff --git a/source/isaaclab/changelog.d/dict-tensor-processors.added.rst b/source/isaaclab/changelog.d/dict-tensor-processors.added.rst new file mode 100644 index 000000000000..3064479fbaae --- /dev/null +++ b/source/isaaclab/changelog.d/dict-tensor-processors.added.rst @@ -0,0 +1,2 @@ +* Added ``DictTensorProcessor`` and ``DictTensorProcessorCfg`` for named tensor dictionaries with + persistent output storage, ordered composition, and processor-owned reset and cleanup. diff --git a/source/isaaclab/changelog.d/dict-tensor-processors.deprecated.rst b/source/isaaclab/changelog.d/dict-tensor-processors.deprecated.rst new file mode 100644 index 000000000000..f16a74eb0c71 --- /dev/null +++ b/source/isaaclab/changelog.d/dict-tensor-processors.deprecated.rst @@ -0,0 +1,4 @@ +* Deprecated ``isaaclab.utils.modifiers``, ``ModifierBase``, and ``ModifierCfg`` in favor of + ``isaaclab.utils.tensor_processors``, ``TensorProcessor``, and ``TensorProcessorCfg``. Replace + ``ObservationTermCfg.modifiers`` with ``ObservationTermCfg.tensor_processors``; legacy imports + and configuration remain supported during migration. diff --git a/source/isaaclab/changelog.d/dict-tensor-processors.minor b/source/isaaclab/changelog.d/dict-tensor-processors.minor new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab/isaaclab/managers/manager_base.py b/source/isaaclab/isaaclab/managers/manager_base.py index 1ca1435f6c76..843d3c1d429c 100644 --- a/source/isaaclab/isaaclab/managers/manager_base.py +++ b/source/isaaclab/isaaclab/managers/manager_base.py @@ -16,7 +16,7 @@ from ..physics import PhysicsEvent, PhysicsManager from ..utils import string as string_utils from ..utils import string_to_callable, to_dict -from ..utils.modifiers import ModifierCfg +from ..utils.tensor_processors import TensorProcessorCfg from .manager_term_cfg import ManagerTermBaseCfg from .scene_entity_cfg import SceneEntityCfg @@ -434,7 +434,7 @@ def _resolve_param_value( self._scene_entity_cfgs.append(value) elif isinstance(value, ManagerTermBaseCfg): self._process_term_cfg_at_play(f"{term_name}.{key}", value) - elif isinstance(value, ModifierCfg): + elif isinstance(value, TensorProcessorCfg): for field in fields(value): field_value = getattr(value, field.name) resolved_value = self._resolve_param_value( diff --git a/source/isaaclab/isaaclab/managers/manager_term_cfg.py b/source/isaaclab/isaaclab/managers/manager_term_cfg.py index 01df0af92391..0bca2197f210 100644 --- a/source/isaaclab/isaaclab/managers/manager_term_cfg.py +++ b/source/isaaclab/isaaclab/managers/manager_term_cfg.py @@ -7,6 +7,7 @@ from __future__ import annotations +import warnings from collections.abc import Callable from dataclasses import MISSING from typing import TYPE_CHECKING, Any, Literal @@ -14,8 +15,8 @@ import torch from ..utils import configclass -from ..utils.modifiers import ModifierCfg from ..utils.noise import NoiseCfg, NoiseModelCfg +from ..utils.tensor_processors import TensorProcessorCfg from .scene_entity_cfg import SceneEntityCfg if TYPE_CHECKING: @@ -167,15 +168,15 @@ class ObservationTermCfg(ManagerTermBaseCfg): shape (num_envs, obs_term_dim). """ - modifiers: list[ModifierCfg] | None = None - """The list of data modifiers to apply to the observation in order. Defaults to None, + tensor_processors: list[TensorProcessorCfg] | None = None + """The list of tensor processors to apply to the observation in order. Defaults to None, in which case no modifications will be applied. - Modifiers are applied in the order they are specified in the list. They can be stateless + Processors are applied in the order they are specified in the list. They can be stateless or stateful, and can be used to apply transformations to the observation data. For example, - a modifier can be used to normalize the observation data or to apply a rolling average. + a processor can be used to normalize the observation data or to apply a rolling average. - For more information on modifiers, see the :class:`~isaaclab.utils.modifiers.ModifierCfg` class. + For more information, see :class:`~isaaclab.utils.tensor_processors.TensorProcessorCfg`. """ noise: NoiseCfg | NoiseModelCfg | None = None @@ -206,7 +207,7 @@ class ObservationTermCfg(ManagerTermBaseCfg): delay_max_lag: int = 0 """Maximum observation delay, counted in recorded samples. Zero disables delay. - Set both lag bounds equal for a constant delay. Delay is applied after modifiers, noise, clipping, and + Set both lag bounds equal for a constant delay. Delay is applied after tensor processors, noise, clipping, and scaling, before observation history. Until enough samples have been recorded, the oldest available sample is returned. After reset, no data from the previous episode is returned. """ @@ -233,6 +234,23 @@ class ObservationTermCfg(ManagerTermBaseCfg): """Whether or not the observation manager should flatten history-based observation terms to a 2-D (N, D) tensor. Defaults to True.""" + modifiers: list[TensorProcessorCfg] | None = None + """Deprecated alias for :attr:`tensor_processors`; use only one of these fields.""" + + def _migrate_modifiers(self) -> None: + """Resolve the deprecated field after user configuration edits, before manager preparation.""" + if self.modifiers is None: + return + if self.tensor_processors is not None: + raise ValueError("Set only one of 'modifiers' and 'tensor_processors'.") + warnings.warn( + "ObservationTermCfg.modifiers is deprecated. Use tensor_processors instead.", + FutureWarning, + stacklevel=5, + ) + self.tensor_processors = self.modifiers + self.modifiers = None + def validate_config(self): """Validate observation delay bounds.""" if type(self.delay_min_lag) is not int or type(self.delay_max_lag) is not int: diff --git a/source/isaaclab/isaaclab/managers/observation_manager.py b/source/isaaclab/isaaclab/managers/observation_manager.py index a4ab4fefd3f4..058484583e1e 100644 --- a/source/isaaclab/isaaclab/managers/observation_manager.py +++ b/source/isaaclab/isaaclab/managers/observation_manager.py @@ -16,7 +16,7 @@ from prettytable import PrettyTable from ..envs.utils.io_descriptors import _warn_io_descriptors_deprecated -from ..utils import instantiate, modifiers, noise, to_dict +from ..utils import instantiate, noise, tensor_processors, to_dict from ..utils.buffers import CircularBuffer, DelayBuffer from .manager_base import ManagerBase, ManagerTermBase from .manager_term_cfg import ObservationGroupCfg, ObservationTermCfg @@ -58,8 +58,8 @@ class ObservationManager(ManagerBase): called in the order of the terms in the group. The functions are expected to return a tensor with shape (num_envs, ...). - If a noise model or custom modifier is registered for a term, the function is called to corrupt - the observation. The corruption function is expected to return a tensor with the same shape as the observation. + If a noise model or custom tensor processor is registered for a term, it transforms + the observation and returns a tensor with the same shape as the observation. The observations are clipped and scaled as per the configuration settings. Returned observations are independent snapshots, including dictionary entries and history. @@ -277,7 +277,7 @@ def _collect_io_descriptors(self, group_names_to_export: list[str] = ["policy"]) for k, v in term_cfg.__dict__.items(): # For now we do not add the noise modifier if k in [ - "modifiers", + "tensor_processors", "clip", "scale", "delay_min_lag", @@ -315,7 +315,7 @@ def _collect_io_descriptors(self, group_names_to_export: list[str] = ["policy"]) "flatten_history_dim", ]: formatted_item["overloads"][k] = v - elif k in ["modifiers", "description", "units"]: + elif k in ["tensor_processors", "description", "units"]: formatted_item["extras"][k] = v else: formatted_item[k] = v @@ -338,7 +338,7 @@ def reset(self, env_ids: Sequence[int] | None = None) -> dict[str, float]: self._group_obs_term_delay_buffer[group_name][term_name].reset(env_ids) if term_name in self._group_obs_term_history_buffer[group_name]: self._group_obs_term_history_buffer[group_name][term_name].reset(batch_ids=env_ids) - # call all modifiers that are classes + # call all tensor_processors that are classes for mod in self._group_obs_class_instances: mod.reset(env_ids=env_ids) @@ -382,7 +382,7 @@ def compute_group(self, group_name: str, update_history: bool = False) -> torch. The following steps are performed for each observation term: 1. Compute observation term by calling the function - 2. Apply custom modifiers in the order specified in :attr:`ObservationTermCfg.modifiers` + 2. Apply custom tensor processors in the order specified in :attr:`ObservationTermCfg.tensor_processors` 3. Apply corruption/noise model based on :attr:`ObservationTermCfg.noise` 4. Apply clipping based on :attr:`ObservationTermCfg.clip` 5. Apply scaling based on :attr:`ObservationTermCfg.scale` @@ -419,7 +419,7 @@ def compute_group(self, group_name: str, update_history: bool = False) -> torch. term_cfgs = self._group_obs_term_cfgs[group_name] obs_terms = zip(group_term_names, term_cfgs) - # evaluate terms: compute, add noise, clip, scale, custom modifiers + # evaluate terms: compute, add noise, clip, scale, custom tensor_processors for term_name, term_cfg in obs_terms: output_spec = self._term_output_specs.get((group_name, term_name)) owned = output_spec is not None @@ -433,12 +433,12 @@ def compute_group(self, group_name: str, update_history: bool = False) -> torch. else: obs = term_cfg.func(self._env, **term_cfg.params) # apply post-processing - if term_cfg.modifiers is not None: - for modifier in term_cfg.modifiers: + if term_cfg.tensor_processors is not None: + for modifier in term_cfg.tensor_processors: owned = False # Custom callbacks may modify or retain their input. obs = obs.clone() - if isinstance(modifier.func, modifiers.ModifierBase): + if isinstance(modifier.func, tensor_processors.TensorProcessor): obs = modifier.func(obs) else: obs = modifier.func(obs, **modifier.params) @@ -564,9 +564,9 @@ def _prepare_terms(self): self._group_obs_term_delay_buffer: dict[str, dict[str, DelayBuffer]] = {} self._group_obs_term_history_buffer: dict[str, dict] = {} - # create a list to store classes instances, e.g., for modifiers and noise models + # create a list to store classes instances, e.g., for tensor_processors and noise models # we store it as a separate list to only call reset on them and prevent unnecessary calls - self._group_obs_class_instances: list[modifiers.ModifierBase | noise.NoiseModel] = [] + self._group_obs_class_instances: list[tensor_processors.TensorProcessor | noise.NoiseModel] = [] # make sure the simulation is playing since we compute obs dims which needs asset quantities if not self._env.sim.is_playing(): @@ -642,6 +642,7 @@ def _prepare_terms(self): f"Configuration for the term '{term_name}' is not of type ObservationTermCfg." f" Received: '{type(term_cfg)}'." ) + term_cfg._migrate_modifiers() term_cfg.validate_config() # resolve common terms in the config self._resolve_common_term_cfg(f"{group_name}/{term_name}", term_cfg, min_argc=1) @@ -676,23 +677,23 @@ def _prepare_terms(self): # cast the scale into torch tensor term_cfg.scale = torch.tensor(term_cfg.scale, dtype=torch.float, device=self._env.device) - # prepare modifiers for each observation - if term_cfg.modifiers is not None: - # initialize list of modifiers for term - for mod_cfg in term_cfg.modifiers: - if not isinstance(mod_cfg, modifiers.ModifierCfg): + # prepare tensor_processors for each observation + if term_cfg.tensor_processors is not None: + # initialize list of tensor_processors for term + for mod_cfg in term_cfg.tensor_processors: + if not isinstance(mod_cfg, tensor_processors.TensorProcessorCfg): raise TypeError( f"Modifier configuration '{mod_cfg}' of observation term '{term_name}' is not of" - f" required type ModifierCfg, Received: '{type(mod_cfg)}'" + f" required type TensorProcessorCfg, Received: '{type(mod_cfg)}'" ) - # construct stateful modifiers with the observation size + # construct stateful tensor_processors with the observation size if inspect.isclass(mod_cfg.func): mod_cfg.func = mod_cfg.func(cfg=mod_cfg, data_dim=obs_dims, device=self._env.device) - if not isinstance(mod_cfg.func, modifiers.ModifierBase): + if not isinstance(mod_cfg.func, tensor_processors.TensorProcessor): raise TypeError( f"Modifier function '{mod_cfg.func}' for observation term '{term_name}'" - f" is not an instance of 'ModifierBase'. Received: '{type(mod_cfg.func)}'." + f" is not an instance of 'TensorProcessor'. Received: '{type(mod_cfg.func)}'." ) self._group_obs_class_instances.append(mod_cfg.func) diff --git a/source/isaaclab/isaaclab/utils/__init__.pyi b/source/isaaclab/isaaclab/utils/__init__.pyi index 76dd49752ed0..cbe85749f70a 100644 --- a/source/isaaclab/isaaclab/utils/__init__.pyi +++ b/source/isaaclab/isaaclab/utils/__init__.pyi @@ -33,6 +33,10 @@ __all__ = [ "PRIMITIVE_MESH_TYPES", "ModifierCfg", "ModifierBase", + "TensorProcessorCfg", + "TensorProcessor", + "DictTensorProcessor", + "DictTensorProcessorCfg", "DigitalFilter", "DigitalFilterCfg", "Integrator", @@ -87,9 +91,12 @@ from .mesh import ( convert_faces_to_triangles, PRIMITIVE_MESH_TYPES, ) -from .modifiers import ( - ModifierCfg, - ModifierBase, +from .modifiers import ModifierCfg, ModifierBase +from .tensor_processors import ( + DictTensorProcessor, + DictTensorProcessorCfg, + TensorProcessorCfg, + TensorProcessor, DigitalFilter, DigitalFilterCfg, Integrator, diff --git a/source/isaaclab/isaaclab/utils/modifiers/__init__.py b/source/isaaclab/isaaclab/utils/modifiers/__init__.py index d351b42e3875..62822d018588 100644 --- a/source/isaaclab/isaaclab/utils/modifiers/__init__.py +++ b/source/isaaclab/isaaclab/utils/modifiers/__init__.py @@ -3,56 +3,16 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Sub-module containing different modifiers implementations. +"""Deprecated import path for tensor processors.""" -Modifiers are used to apply stateful or stateless modifications to tensor data. They take -in a tensor and a configuration and return a tensor with the modification applied. This way users -can define custom operations to apply to a tensor. For instance, a modifier can be used to normalize -the input data or to apply a rolling average. - -They are primarily used to apply custom operations in the :class:`~isaaclab.managers.ObservationManager` -as an alternative to the built-in noise, clip and scale post-processing operations. For more details, see -the :class:`~isaaclab.managers.ObservationTermCfg` class. - -Usage with a function modifier: - -.. code-block:: python - - import torch - from isaaclab.utils import modifiers - - # create a random tensor - my_tensor = torch.rand(256, 128, device="cuda") - - # create a modifier configuration - cfg = modifiers.ModifierCfg(func=modifiers.clip, params={"bounds": (0.0, torch.inf)}) - - # apply the modifier - my_modified_tensor = cfg.func(my_tensor, cfg) - - -Usage with a class modifier: - -.. code-block:: python - - import torch - from isaaclab.utils import modifiers - - # create a random tensor - my_tensor = torch.rand(256, 128, device="cuda") - - # create a modifier configuration - # a digital filter with a simple delay of 1 timestep - cfg = modifiers.DigitalFilterCfg(A=[0.0], B=[0.0, 1.0]) - - # create the modifier instance - my_modifier = modifiers.DigitalFilter(cfg, my_tensor.shape, "cuda") - - # apply the modifier as a callable object - my_modified_tensor = my_modifier(my_tensor) - -""" +import warnings from ..module import lazy_export +warnings.warn( + "isaaclab.utils.modifiers is deprecated. Use isaaclab.utils.tensor_processors instead.", + DeprecationWarning, + stacklevel=2, +) + lazy_export() diff --git a/source/isaaclab/isaaclab/utils/modifiers/modifier.py b/source/isaaclab/isaaclab/utils/modifiers/modifier.py index 2a6f456981b1..2b5c4f3be4c1 100644 --- a/source/isaaclab/isaaclab/utils/modifiers/modifier.py +++ b/source/isaaclab/isaaclab/utils/modifiers/modifier.py @@ -3,255 +3,8 @@ # # SPDX-License-Identifier: BSD-3-Clause -from __future__ import annotations +"""Deprecated import path for tensor processor implementations.""" -from collections.abc import Sequence -from typing import TYPE_CHECKING +from ..tensor_processors.processors import DigitalFilter, Integrator, bias, clip, scale -import torch - -from ..array import index_fill_ -from .modifier_base import ModifierBase - -if TYPE_CHECKING: - from . import modifier_cfg - -## -# Modifiers as functions -## - - -def scale(data: torch.Tensor, multiplier: float) -> torch.Tensor: - """Scales input data by a multiplier. - - Args: - data: The data to apply the scale to. - multiplier: Value to scale input by. - - Returns: - Scaled data. Shape is the same as data. - """ - return data * multiplier - - -def clip(data: torch.Tensor, bounds: tuple[float | None, float | None]) -> torch.Tensor: - """Clips the data to a minimum and maximum value. - - Args: - data: The data to apply the clip to. - bounds: A tuple containing the minimum and maximum values to clip data to. - If the value is None, that bound is not applied. - - Returns: - Clipped data. Shape is the same as data. - """ - return data.clip(min=bounds[0], max=bounds[1]) - - -def bias(data: torch.Tensor, value: float) -> torch.Tensor: - """Adds a uniform bias to the data. - - Args: - data: The data to add bias to. - value: Value of bias to add to data. - - Returns: - Biased data. Shape is the same as data. - """ - return data + value - - -## -# Sample of class based modifiers -## - - -class DigitalFilter(ModifierBase): - r"""Modifier used to apply digital filtering to the input data. - - `Digital filters `_ are used to process discrete-time - signals to extract useful parts of the signal, such as smoothing, noise reduction, or frequency separation. - - The filter can be implemented as a linear difference equation in the time domain. This equation - can be used to calculate the output at each time-step based on the current and previous inputs and outputs. - - .. math:: - y_{i} = X B - Y A = \sum_{j=0}^{N} b_j x_{i-j} - \sum_{j=1}^{M} a_j y_{i-j} - - where :math:`y_{i}` is the current output of the filter. The array :math:`Y` contains previous - outputs from the filter :math:`\{y_{i-j}\}_{j=1}^M` for :math:`M` previous time-steps. The array - :math:`X` contains current :math:`x_{i}` and previous inputs to the filter - :math:`\{x_{i-j}\}_{j=1}^N` for :math:`N` previous time-steps respectively. - The filter coefficients :math:`A` and :math:`B` are used to design the filter. They are column vectors of - length :math:`M` and :math:`N + 1` respectively. - - Different types of filters can be implemented by choosing different values for :math:`A` and :math:`B`. - We provide some examples below. - - Examples - ^^^^^^^^ - - **Unit Delay Filter** - - A filter that delays the input signal by a single time-step simply outputs the previous input value. - - .. math:: y_{i} = x_{i-1} - - This can be implemented as a digital filter with the coefficients :math:`A = [0.0]` and :math:`B = [0.0, 1.0]`. - - **Moving Average Filter** - - A moving average filter is used to smooth out noise in a signal. It is similar to a low-pass filter - but has a finite impulse response (FIR) and is non-recursive. - - The filter calculates the average of the input signal over a window of time-steps. The linear difference - equation for a moving average filter is: - - .. math:: y_{i} = \frac{1}{N} \sum_{j=0}^{N} x_{i-j} - - This can be implemented as a digital filter with the coefficients :math:`A = [0.0]` and - :math:`B = [1/N, 1/N, \cdots, 1/N]`. - - **First-order recursive low-pass filter** - - A recursive low-pass filter is used to smooth out high-frequency noise in a signal. It is a first-order - infinite impulse response (IIR) filter which means it has a recursive component (previous output) in the - linear difference equation. - - A first-order low-pass IIR filter has the difference equation: - - .. math:: y_{i} = \alpha y_{i-1} + (1-\alpha)x_{i} - - where :math:`\alpha` is a smoothing parameter between 0 and 1. Typically, the value of :math:`\alpha` is - chosen based on the desired cut-off frequency of the filter. - - This filter can be implemented as a digital filter with the coefficients :math:`A = [-\alpha]` and - :math:`B = [1 - \alpha]`. - """ - - def __init__(self, cfg: modifier_cfg.DigitalFilterCfg, data_dim: tuple[int, ...], device: str): - """Initializes digital filter. - - Args: - cfg: Configuration parameters. - data_dim: The dimensions of the data to be modified. First element is the batch size - which usually corresponds to number of environments in the simulation. - device: The device to run the modifier on. - - Raises: - ValueError: If filter coefficients are None. - """ - # check that filter coefficients are not None - if cfg.A is None or cfg.B is None: - raise ValueError("Digital filter coefficients A and B must not be None. Please provide valid coefficients.") - - # initialize parent class - super().__init__(cfg, data_dim, device) - - # assign filter coefficients and make sure they are column vectors - self.A = torch.tensor(self._cfg.A, device=self._device).unsqueeze(1) - self.B = torch.tensor(self._cfg.B, device=self._device).unsqueeze(1) - - # create buffer for input and output history - self.x_n = torch.zeros(self._data_dim + (self.B.shape[0],), device=self._device) - self.y_n = torch.zeros(self._data_dim + (self.A.shape[0],), device=self._device) - - def reset(self, env_ids: Sequence[int] | None = None): - """Resets digital filter history. - - Args: - env_ids: The environment ids. Defaults to None, in which case - all environments are considered. - """ - # reset history buffers - index_fill_(self.x_n, env_ids, 0.0) - index_fill_(self.y_n, env_ids, 0.0) - - def __call__(self, data: torch.Tensor) -> torch.Tensor: - """Applies digital filter modification with a rolling history window inputs and outputs. - - Args: - data: The data to apply filter to. - - Returns: - Filtered data. Shape is the same as data. - """ - # move history window for input - self.x_n = torch.roll(self.x_n, shifts=1, dims=-1) - self.x_n[..., 0] = data - - # calculate current filter value: y[i] = Y*A - X*B - y_i = torch.matmul(self.x_n, self.B) - torch.matmul(self.y_n, self.A) - y_i.squeeze_(-1) - - # move history window for output and add current filter value to history - self.y_n = torch.roll(self.y_n, shifts=1, dims=-1) - self.y_n[..., 0] = y_i - - return y_i - - -class Integrator(ModifierBase): - r"""Modifier that applies a numerical forward integration based on a middle Reimann sum. - - An integrator is used to calculate the integral of a signal over time. The integral of a signal - is the area under the curve of the signal. The integral can be approximated using numerical methods - such as the `Riemann sum `_. - - The middle Riemann sum is a method to approximate the integral of a function by dividing the area - under the curve into rectangles. The height of each rectangle is the value of the function at the - midpoint of the interval. The area of each rectangle is the width of the interval multiplied by the - height of the rectangle. - - This integral method is useful for signals that are sampled at regular intervals. The integral - can be written as: - - .. math:: - \int_{t_0}^{t_n} f(t) dt & \approx \int_{t_0}^{t_{n-1}} f(t) dt + \frac{f(t_{n-1}) + f(t_n)}{2} \Delta t - - where :math:`f(t)` is the signal to integrate, :math:`t_i` is the time at the i-th sample, and - :math:`\Delta t` is the time step between samples. - """ - - def __init__(self, cfg: modifier_cfg.IntegratorCfg, data_dim: tuple[int, ...], device: str): - """Initializes the integrator configuration and state. - - Args: - cfg: Integral parameters. - data_dim: The dimensions of the data to be modified. First element is the batch size - which usually corresponds to number of environments in the simulation. - device: The device to run the modifier on. - """ - # initialize parent class - super().__init__(cfg, data_dim, device) - - # assign buffer for integral and previous value - self.integral = torch.zeros(self._data_dim, device=self._device) - self.y_prev = torch.zeros(self._data_dim, device=self._device) - - def reset(self, env_ids: Sequence[int] | None = None): - """Resets integrator state to zero. - - Args: - env_ids: The environment ids. Defaults to None, in which case - all environments are considered. - """ - # reset history buffers - index_fill_(self.integral, env_ids, 0.0) - index_fill_(self.y_prev, env_ids, 0.0) - - def __call__(self, data: torch.Tensor) -> torch.Tensor: - """Applies integral modification to input data. - - Args: - data: The data to integrate. - - Returns: - Integral of input signal. Shape is the same as data. - """ - # integrate using middle Riemann sum - self.integral += (data + self.y_prev) / 2 * self._cfg.dt - # update previous value - self.y_prev[:] = data - - return self.integral +__all__ = ["DigitalFilter", "Integrator", "bias", "clip", "scale"] diff --git a/source/isaaclab/isaaclab/utils/modifiers/modifier_base.py b/source/isaaclab/isaaclab/utils/modifiers/modifier_base.py index 65a7fe0bb8a2..fbbba711aa1c 100644 --- a/source/isaaclab/isaaclab/utils/modifiers/modifier_base.py +++ b/source/isaaclab/isaaclab/utils/modifiers/modifier_base.py @@ -3,77 +3,8 @@ # # SPDX-License-Identifier: BSD-3-Clause -from __future__ import annotations +"""Deprecated name for the single-tensor processor interface.""" -from abc import ABC, abstractmethod -from collections.abc import Sequence -from typing import TYPE_CHECKING +from ..tensor_processors.tensor_processor import TensorProcessor as ModifierBase -import torch - -if TYPE_CHECKING: - from .modifier_cfg import ModifierCfg - - -class ModifierBase(ABC): - """Base class for modifiers implemented as classes. - - Modifiers implementations can be functions or classes. If a modifier is a class, it should - inherit from this class and implement the required methods. - - A class implementation of a modifier can be used to store state information between calls. - This is useful for modifiers that require stateful operations, such as rolling averages - or delays or decaying filters. - - Example pseudo-code to create and use the class: - - .. code-block:: python - - from isaaclab.utils import modifiers - - # define custom keyword arguments to pass to ModifierCfg - kwarg_dict = {"arg_1": VAL_1, "arg_2": VAL_2} - - # create modifier configuration object - # func is the class name of the modifier and params is the dictionary of arguments - modifier_config = modifiers.ModifierCfg(func=modifiers.ModifierBase, params=kwarg_dict) - - # define modifier instance - my_modifier = modifiers.ModifierBase(cfg=modifier_config) - - """ - - def __init__(self, cfg: ModifierCfg, data_dim: tuple[int, ...], device: str) -> None: - """Initializes the modifier class. - - Args: - cfg: Configuration parameters. - data_dim: The dimensions of the data to be modified. First element is the batch size - which usually corresponds to number of environments in the simulation. - device: The device to run the modifier on. - """ - self._cfg = cfg - self._data_dim = data_dim - self._device = device - - @abstractmethod - def reset(self, env_ids: Sequence[int] | None = None): - """Resets the Modifier. - - Args: - env_ids: The environment ids. Defaults to None, in which case - all environments are considered. - """ - raise NotImplementedError - - @abstractmethod - def __call__(self, data: torch.Tensor) -> torch.Tensor: - """Abstract method for defining the modification function. - - Args: - data: The data to be modified. Shape should match the data_dim passed during initialization. - - Returns: - Modified data. Shape is the same as the input data. - """ - raise NotImplementedError +__all__ = ["ModifierBase"] diff --git a/source/isaaclab/isaaclab/utils/modifiers/modifier_cfg.py b/source/isaaclab/isaaclab/utils/modifiers/modifier_cfg.py index d279fc62f2ab..53ef8764dc6c 100644 --- a/source/isaaclab/isaaclab/utils/modifiers/modifier_cfg.py +++ b/source/isaaclab/isaaclab/utils/modifiers/modifier_cfg.py @@ -3,80 +3,9 @@ # # SPDX-License-Identifier: BSD-3-Clause -from __future__ import annotations +"""Deprecated import path for tensor processor configurations.""" -from collections.abc import Callable -from dataclasses import MISSING -from typing import TYPE_CHECKING, Any +from ..tensor_processors.tensor_processor_cfg import DigitalFilterCfg, IntegratorCfg +from ..tensor_processors.tensor_processor_cfg import TensorProcessorCfg as ModifierCfg -import torch - -from .. import configclass - -if TYPE_CHECKING: - from .modifier import DigitalFilter, Integrator - from .modifier_base import ModifierBase - - -@configclass -class ModifierCfg: - """Configuration parameters for function and class modifiers.""" - - func: Callable[..., torch.Tensor] | type[ModifierBase] | str = MISSING - """Function or :class:`ModifierBase` class used by the modifier. - - Functions must take a tensor as their first argument. Classes must inherit from :class:`ModifierBase`; the - observation manager constructs them with the configuration, observation dimensions, and device. - """ - - params: dict[str, Any] = {} - """Parameters used by the modifier. Defaults to an empty dictionary. - - Function modifiers receive them as keyword arguments on each call. Class modifiers access them through this - configuration during construction; they are not forwarded to the constructed instance. - """ - - -@configclass -class DigitalFilterCfg(ModifierCfg): - """Configuration parameters for a digital filter modifier. - - For more information, please check the :class:`DigitalFilter` class. - """ - - func: type[DigitalFilter] | str = "{DIR}.modifier:DigitalFilter" - """The digital filter function to be called for applying the filter.""" - - A: list[float] = MISSING - """The coefficients corresponding the the filter's response to past outputs. - - These correspond to the weights of the past outputs of the filter. The first element is the coefficient - for the output at the previous time step, the second element is the coefficient for the output at two - time steps ago, and so on. - - It is the denominator coefficients of the transfer function of the filter. - """ - - B: list[float] = MISSING - """The coefficients corresponding the the filter's response to current and past inputs. - - These correspond to the weights of the current and past inputs of the filter. The first element is the - coefficient for the current input, the second element is the coefficient for the input at the previous - time step, and so on. - - It is the numerator coefficients of the transfer function of the filter. - """ - - -@configclass -class IntegratorCfg(ModifierCfg): - """Configuration parameters for an integrator modifier. - - For more information, please check the :class:`Integrator` class. - """ - - func: type[Integrator] | str = "{DIR}.modifier:Integrator" - """The integrator function to be called for applying the integrator.""" - - dt: float = MISSING - """The time step of the integrator.""" +__all__ = ["ModifierCfg", "DigitalFilterCfg", "IntegratorCfg"] diff --git a/source/isaaclab/isaaclab/utils/tensor_processors/__init__.py b/source/isaaclab/isaaclab/utils/tensor_processors/__init__.py new file mode 100644 index 000000000000..c08f57231f29 --- /dev/null +++ b/source/isaaclab/isaaclab/utils/tensor_processors/__init__.py @@ -0,0 +1,57 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Stateful and stateless processing for tensors and dictionaries of named tensors. + +Single-tensor functions and :class:`TensorProcessor` subclasses take and return one tensor. +They apply custom operations in the :class:`~isaaclab.managers.ObservationManager`, such as +normalization or rolling averages. Configure them through +:attr:`~isaaclab.managers.ObservationTermCfg.tensor_processors`. + +:class:`DictTensorProcessor` subclasses take and return dictionaries of named +:class:`~isaaclab.utils.warp.ProxyArray` tensors. They own persistent outputs and state. + +Usage with a function processor: + +.. code-block:: python + + import torch + from isaaclab.utils import tensor_processors + + # create a random tensor + my_tensor = torch.rand(256, 128, device="cuda") + + # create a processor configuration + cfg = tensor_processors.TensorProcessorCfg(func=tensor_processors.clip, params={"bounds": (0.0, torch.inf)}) + + # apply the processor + my_modified_tensor = cfg.func(my_tensor, **cfg.params) + + +Usage with a class processor: + +.. code-block:: python + + import torch + from isaaclab.utils import tensor_processors + + # create a random tensor + my_tensor = torch.rand(256, 128, device="cuda") + + # create a processor configuration + # a digital filter with a simple delay of 1 timestep + cfg = tensor_processors.DigitalFilterCfg(A=[0.0], B=[0.0, 1.0]) + + # create the processor instance + my_processor = tensor_processors.DigitalFilter(cfg, my_tensor.shape, "cuda") + + # apply the processor as a callable object + my_modified_tensor = my_processor(my_tensor) + +""" + +from ..module import lazy_export + +lazy_export() diff --git a/source/isaaclab/isaaclab/utils/tensor_processors/__init__.pyi b/source/isaaclab/isaaclab/utils/tensor_processors/__init__.pyi new file mode 100644 index 000000000000..9c39677d11e6 --- /dev/null +++ b/source/isaaclab/isaaclab/utils/tensor_processors/__init__.pyi @@ -0,0 +1,25 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +__all__ = [ + "DictTensorProcessor", + "DictTensorProcessorCfg", + "TensorProcessorCfg", + "DigitalFilterCfg", + "IntegratorCfg", + "TensorProcessor", + "DigitalFilter", + "Integrator", + "bias", + "clip", + "scale", +] + +from .tensor_processor_cfg import TensorProcessorCfg, DigitalFilterCfg, IntegratorCfg +from .tensor_processor import TensorProcessor +from .processors import DigitalFilter, Integrator, bias, clip, scale + +from .dict_tensor_processor import DictTensorProcessor +from .dict_tensor_processor_cfg import DictTensorProcessorCfg diff --git a/source/isaaclab/isaaclab/utils/tensor_processors/_pipeline.py b/source/isaaclab/isaaclab/utils/tensor_processors/_pipeline.py new file mode 100644 index 000000000000..ddaffcbd30a3 --- /dev/null +++ b/source/isaaclab/isaaclab/utils/tensor_processors/_pipeline.py @@ -0,0 +1,126 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +from __future__ import annotations + +from builtins import ExceptionGroup +from collections.abc import Mapping, Sequence + +from ..configclass import instantiate +from ..warp import ProxyArray +from .dict_tensor_processor import DictTensorProcessor +from .dict_tensor_processor_cfg import DictTensorProcessorCfg + + +class _DictTensorProcessorPipeline: + """Compose dictionary processors in order, preserving untouched input keys. + + Each processor receives current named tensors and returns its named outputs. Preparation + constructs processors once; calls reuse their output storage. Each consumer owns its own + pipeline. Input buffers are borrowed, and returned mappings are independent snapshots of + references, not tensor copies. + """ + + def __init__(self, source_buffers: Mapping[str, ProxyArray], processors: Sequence[DictTensorProcessorCfg]): + self._source_buffers = dict(source_buffers) + self.required_inputs = self.required_source_names(processors) + self._processors: list[tuple[DictTensorProcessor, dict[str, ProxyArray]]] = [] + self._closed = False + self.outputs = dict(source_buffers) + self.produced_outputs = frozenset(name for cfg in processors for name in cfg.outputs) + try: + for cfg in processors: + if not cfg.outputs or len(set(cfg.outputs)) != len(cfg.outputs): + raise ValueError("A processor must declare distinct output names.") + missing = set(cfg.inputs) - self.outputs.keys() + if missing: + raise ValueError(f"Missing input buffers for preparation: {sorted(missing)}.") + processor = instantiate(cfg, dict(self.outputs)) + if not isinstance(processor, DictTensorProcessor): + raise TypeError("Dictionary processors must inherit DictTensorProcessor.") + self._processors.append((processor, dict(processor.outputs))) + if set(processor.outputs) != set(cfg.outputs): + raise ValueError("Processor outputs must match its declared output names.") + if any(not isinstance(buffer, ProxyArray) for buffer in processor.outputs.values()): + raise TypeError("Processor outputs must be ProxyArray objects.") + devices = {str(self.outputs[name].device) for name in cfg.inputs} + devices.update(str(buffer.device) for buffer in processor.outputs.values()) + if len(devices) > 1: + raise ValueError("A processor must use one device for its inputs and outputs.") + self.outputs.update(processor.outputs) + except Exception as error: + try: + self.close() + except Exception as cleanup_error: + raise error from cleanup_error + raise + + @property + def has_reset_callbacks(self) -> bool: + """Whether any processor owns resettable state.""" + return any(type(processor).reset is not DictTensorProcessor.reset for processor, _ in self._processors) + + @staticmethod + def _validate_buffer(name: str, value: ProxyArray, reference: ProxyArray) -> None: + if not isinstance(value, ProxyArray): + raise TypeError(f"Buffer {name!r} must be a ProxyArray.") + if value.shape != reference.shape or value.dtype != reference.dtype or value.device != reference.device: + raise ValueError( + f"Buffer {name!r} must match shape, dtype and device" + f" {reference.shape}, {reference.dtype}, {reference.device}." + ) + + @staticmethod + def required_source_names( + processors: Sequence[DictTensorProcessorCfg], published_names: Sequence[str] = () + ) -> frozenset[str]: + """Resolve external keys before allocating buffers or initializing a renderer.""" + required: set[str] = set() + produced: set[str] = set() + for cfg in processors: + required.update(set(cfg.inputs) - produced) + produced.update(cfg.outputs) + required.update(set(published_names) - produced) + return frozenset(required) + + def __call__(self, data: dict[str, ProxyArray]) -> dict[str, ProxyArray]: + """Apply processors and return their results with untouched inputs passed through.""" + if self._closed: + raise RuntimeError("Cannot execute a closed processor pipeline.") + missing = self.required_inputs - data.keys() + if missing: + raise ValueError(f"Missing required buffers: {sorted(missing)}.") + for name in self._source_buffers.keys() & data.keys(): + self._validate_buffer(name, data[name], self._source_buffers[name]) + current = dict(data) + for processor, outputs in self._processors: + result = processor(dict(current)) + if result.keys() != outputs.keys() or any(result[name] is not output for name, output in outputs.items()): + raise ValueError("Processors must return their persistent named output buffers.") + current.update(result) + self.outputs = dict(current) + return current + + def reset(self, env_ids: Sequence[int] | None = None) -> None: + """Reset processor-owned state for selected batch entries.""" + for processor, _ in self._processors: + processor.reset(env_ids) + + def close(self) -> None: + """Close processors in reverse order, including after failed construction.""" + if self._closed: + return + self._closed = True + processors, self._processors = self._processors, [] + self._source_buffers.clear() + self.outputs = {} + errors = [] + for processor, _ in reversed(processors): + try: + processor.close() + except Exception as error: + errors.append(error) + if errors: + raise ExceptionGroup("Failed to close dictionary processors.", errors) diff --git a/source/isaaclab/isaaclab/utils/tensor_processors/dict_tensor_processor.py b/source/isaaclab/isaaclab/utils/tensor_processors/dict_tensor_processor.py new file mode 100644 index 000000000000..1ee875cdabe0 --- /dev/null +++ b/source/isaaclab/isaaclab/utils/tensor_processors/dict_tensor_processor.py @@ -0,0 +1,47 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Sequence +from typing import TYPE_CHECKING + +from ..warp import ProxyArray + +if TYPE_CHECKING: + from .dict_tensor_processor_cfg import DictTensorProcessorCfg + + +class DictTensorProcessor(ABC): + """Transform a dictionary of named tensors into a dictionary of named tensors. + + This is the dictionary counterpart of :class:`TensorProcessor`. Tensor values use + :class:`ProxyArray` so processors can use Warp or Torch without copying. Inputs are + borrowed read-only. Constructors inspect their shape, dtype and device and allocate + persistent outputs without reading input values or advancing processor state. + + Subclasses populate ``outputs`` with the buffers they produce, implement ``__call__``, + and override ``reset`` or ``close`` when they own state or external resources. Calls + return those same output buffers; values remain valid until the next call. Consumers + that retain results must copy them. GPU work uses the caller's current Warp stream. + """ + + def __init__(self, cfg: DictTensorProcessorCfg, data: dict[str, ProxyArray]) -> None: + self.cfg = cfg + self.outputs: dict[str, ProxyArray] = {} + + @abstractmethod + def __call__(self, data: dict[str, ProxyArray]) -> dict[str, ProxyArray]: + """Process current inputs and return the named outputs without mutating inputs.""" + raise NotImplementedError + + def reset(self, env_ids: Sequence[int] | None = None) -> None: + """Reset state for selected batch entries, or all entries when None.""" + pass + + def close(self) -> None: + """Release owned resources; repeated calls must have no effect.""" + pass diff --git a/source/isaaclab/isaaclab/utils/tensor_processors/dict_tensor_processor_cfg.py b/source/isaaclab/isaaclab/utils/tensor_processors/dict_tensor_processor_cfg.py new file mode 100644 index 000000000000..119afafac7d5 --- /dev/null +++ b/source/isaaclab/isaaclab/utils/tensor_processors/dict_tensor_processor_cfg.py @@ -0,0 +1,40 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +from __future__ import annotations + +from dataclasses import MISSING +from typing import TYPE_CHECKING, Any + +from ..configclass import configclass + +if TYPE_CHECKING: + from .dict_tensor_processor import DictTensorProcessor + + +@configclass +class DictTensorProcessorCfg: + """Configure one dictionary processor and declare its producer dependencies. + + Names are actual dictionary keys, with no separate operation roles. Cameras use these + declarations before renderer initialization. The class receives ``(cfg, data)`` and + owns its output storage, state, and lifecycle, just like a single-tensor processor. + """ + + class_type: type[DictTensorProcessor] | str = MISSING + """Processor class, or a resolvable import string.""" + + inputs: list[str] = MISSING + """Dictionary keys required from the producer or preceding processors.""" + + outputs: list[str] = MISSING + """Dictionary keys produced or replaced by this processor.""" + + params: dict[str, Any] = {} + """Processor-specific settings, accessible through ``cfg.params``. + + Configurations are copied. Explicitly shared device resources must preserve identity + through ``__deepcopy__``; do not place raw Warp allocations in this dictionary. + """ diff --git a/source/isaaclab/isaaclab/utils/tensor_processors/processors.py b/source/isaaclab/isaaclab/utils/tensor_processors/processors.py new file mode 100644 index 000000000000..ff9a4a333f97 --- /dev/null +++ b/source/isaaclab/isaaclab/utils/tensor_processors/processors.py @@ -0,0 +1,257 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +from __future__ import annotations + +from collections.abc import Sequence +from typing import TYPE_CHECKING + +import torch + +from ..array import index_fill_ +from .tensor_processor import TensorProcessor + +if TYPE_CHECKING: + from . import tensor_processor_cfg + +## +# Tensor processors as functions +## + + +def scale(data: torch.Tensor, multiplier: float) -> torch.Tensor: + """Scales input data by a multiplier. + + Args: + data: The data to apply the scale to. + multiplier: Value to scale input by. + + Returns: + Scaled data. Shape is the same as data. + """ + return data * multiplier + + +def clip(data: torch.Tensor, bounds: tuple[float | None, float | None]) -> torch.Tensor: + """Clips the data to a minimum and maximum value. + + Args: + data: The data to apply the clip to. + bounds: A tuple containing the minimum and maximum values to clip data to. + If the value is None, that bound is not applied. + + Returns: + Clipped data. Shape is the same as data. + """ + return data.clip(min=bounds[0], max=bounds[1]) + + +def bias(data: torch.Tensor, value: float) -> torch.Tensor: + """Adds a uniform bias to the data. + + Args: + data: The data to add bias to. + value: Value of bias to add to data. + + Returns: + Biased data. Shape is the same as data. + """ + return data + value + + +## +# Sample of class based tensor_processors +## + + +class DigitalFilter(TensorProcessor): + r"""Processor used to apply digital filtering to the input data. + + `Digital filters `_ are used to process discrete-time + signals to extract useful parts of the signal, such as smoothing, noise reduction, or frequency separation. + + The filter can be implemented as a linear difference equation in the time domain. This equation + can be used to calculate the output at each time-step based on the current and previous inputs and outputs. + + .. math:: + y_{i} = X B - Y A = \sum_{j=0}^{N} b_j x_{i-j} - \sum_{j=1}^{M} a_j y_{i-j} + + where :math:`y_{i}` is the current output of the filter. The array :math:`Y` contains previous + outputs from the filter :math:`\{y_{i-j}\}_{j=1}^M` for :math:`M` previous time-steps. The array + :math:`X` contains current :math:`x_{i}` and previous inputs to the filter + :math:`\{x_{i-j}\}_{j=1}^N` for :math:`N` previous time-steps respectively. + The filter coefficients :math:`A` and :math:`B` are used to design the filter. They are column vectors of + length :math:`M` and :math:`N + 1` respectively. + + Different types of filters can be implemented by choosing different values for :math:`A` and :math:`B`. + We provide some examples below. + + Examples + ^^^^^^^^ + + **Unit Delay Filter** + + A filter that delays the input signal by a single time-step simply outputs the previous input value. + + .. math:: y_{i} = x_{i-1} + + This can be implemented as a digital filter with the coefficients :math:`A = [0.0]` and :math:`B = [0.0, 1.0]`. + + **Moving Average Filter** + + A moving average filter is used to smooth out noise in a signal. It is similar to a low-pass filter + but has a finite impulse response (FIR) and is non-recursive. + + The filter calculates the average of the input signal over a window of time-steps. The linear difference + equation for a moving average filter is: + + .. math:: y_{i} = \frac{1}{N} \sum_{j=0}^{N} x_{i-j} + + This can be implemented as a digital filter with the coefficients :math:`A = [0.0]` and + :math:`B = [1/N, 1/N, \cdots, 1/N]`. + + **First-order recursive low-pass filter** + + A recursive low-pass filter is used to smooth out high-frequency noise in a signal. It is a first-order + infinite impulse response (IIR) filter which means it has a recursive component (previous output) in the + linear difference equation. + + A first-order low-pass IIR filter has the difference equation: + + .. math:: y_{i} = \alpha y_{i-1} + (1-\alpha)x_{i} + + where :math:`\alpha` is a smoothing parameter between 0 and 1. Typically, the value of :math:`\alpha` is + chosen based on the desired cut-off frequency of the filter. + + This filter can be implemented as a digital filter with the coefficients :math:`A = [-\alpha]` and + :math:`B = [1 - \alpha]`. + """ + + def __init__(self, cfg: tensor_processor_cfg.DigitalFilterCfg, data_dim: tuple[int, ...], device: str): + """Initializes digital filter. + + Args: + cfg: Configuration parameters. + data_dim: The dimensions of the data to be modified. First element is the batch size + which usually corresponds to number of environments in the simulation. + device: The device to run the processor on. + + Raises: + ValueError: If filter coefficients are None. + """ + # check that filter coefficients are not None + if cfg.A is None or cfg.B is None: + raise ValueError("Digital filter coefficients A and B must not be None. Please provide valid coefficients.") + + # initialize parent class + super().__init__(cfg, data_dim, device) + + # assign filter coefficients and make sure they are column vectors + self.A = torch.tensor(self._cfg.A, device=self._device).unsqueeze(1) + self.B = torch.tensor(self._cfg.B, device=self._device).unsqueeze(1) + + # create buffer for input and output history + self.x_n = torch.zeros(self._data_dim + (self.B.shape[0],), device=self._device) + self.y_n = torch.zeros(self._data_dim + (self.A.shape[0],), device=self._device) + + def reset(self, env_ids: Sequence[int] | None = None): + """Resets digital filter history. + + Args: + env_ids: The environment ids. Defaults to None, in which case + all environments are considered. + """ + # reset history buffers + index_fill_(self.x_n, env_ids, 0.0) + index_fill_(self.y_n, env_ids, 0.0) + + def __call__(self, data: torch.Tensor) -> torch.Tensor: + """Applies digital filter modification with a rolling history window inputs and outputs. + + Args: + data: The data to apply filter to. + + Returns: + Filtered data. Shape is the same as data. + """ + # move history window for input + self.x_n = torch.roll(self.x_n, shifts=1, dims=-1) + self.x_n[..., 0] = data + + # calculate current filter value: y[i] = Y*A - X*B + y_i = torch.matmul(self.x_n, self.B) - torch.matmul(self.y_n, self.A) + y_i.squeeze_(-1) + + # move history window for output and add current filter value to history + self.y_n = torch.roll(self.y_n, shifts=1, dims=-1) + self.y_n[..., 0] = y_i + + return y_i + + +class Integrator(TensorProcessor): + r"""Processor that applies a numerical forward integration based on a middle Reimann sum. + + An integrator is used to calculate the integral of a signal over time. The integral of a signal + is the area under the curve of the signal. The integral can be approximated using numerical methods + such as the `Riemann sum `_. + + The middle Riemann sum is a method to approximate the integral of a function by dividing the area + under the curve into rectangles. The height of each rectangle is the value of the function at the + midpoint of the interval. The area of each rectangle is the width of the interval multiplied by the + height of the rectangle. + + This integral method is useful for signals that are sampled at regular intervals. The integral + can be written as: + + .. math:: + \int_{t_0}^{t_n} f(t) dt & \approx \int_{t_0}^{t_{n-1}} f(t) dt + \frac{f(t_{n-1}) + f(t_n)}{2} \Delta t + + where :math:`f(t)` is the signal to integrate, :math:`t_i` is the time at the i-th sample, and + :math:`\Delta t` is the time step between samples. + """ + + def __init__(self, cfg: tensor_processor_cfg.IntegratorCfg, data_dim: tuple[int, ...], device: str): + """Initializes the integrator configuration and state. + + Args: + cfg: Integral parameters. + data_dim: The dimensions of the data to be modified. First element is the batch size + which usually corresponds to number of environments in the simulation. + device: The device to run the processor on. + """ + # initialize parent class + super().__init__(cfg, data_dim, device) + + # assign buffer for integral and previous value + self.integral = torch.zeros(self._data_dim, device=self._device) + self.y_prev = torch.zeros(self._data_dim, device=self._device) + + def reset(self, env_ids: Sequence[int] | None = None): + """Resets integrator state to zero. + + Args: + env_ids: The environment ids. Defaults to None, in which case + all environments are considered. + """ + # reset history buffers + index_fill_(self.integral, env_ids, 0.0) + index_fill_(self.y_prev, env_ids, 0.0) + + def __call__(self, data: torch.Tensor) -> torch.Tensor: + """Applies integral modification to input data. + + Args: + data: The data to integrate. + + Returns: + Integral of input signal. Shape is the same as data. + """ + # integrate using middle Riemann sum + self.integral += (data + self.y_prev) / 2 * self._cfg.dt + # update previous value + self.y_prev[:] = data + + return self.integral diff --git a/source/isaaclab/isaaclab/utils/tensor_processors/tensor_processor.py b/source/isaaclab/isaaclab/utils/tensor_processors/tensor_processor.py new file mode 100644 index 000000000000..7c876f470191 --- /dev/null +++ b/source/isaaclab/isaaclab/utils/tensor_processors/tensor_processor.py @@ -0,0 +1,60 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Sequence +from typing import TYPE_CHECKING + +import torch + +if TYPE_CHECKING: + from .tensor_processor_cfg import TensorProcessorCfg + + +class TensorProcessor(ABC): + """Transform one tensor into one tensor, optionally retaining state between calls. + + This is the single-tensor counterpart of :class:`DictTensorProcessor`. Subclasses + implement ``__call__`` and ``reset``. Observation processors preserve the input shape. + Configuration and dimensions + are supplied by the observation manager once during construction. + """ + + def __init__(self, cfg: TensorProcessorCfg, data_dim: tuple[int, ...], device: str) -> None: + """Initializes the processor class. + + Args: + cfg: Configuration parameters. + data_dim: The dimensions of the data to be modified. First element is the batch size + which usually corresponds to number of environments in the simulation. + device: The device to run the processor on. + """ + self._cfg = cfg + self._data_dim = data_dim + self._device = device + + @abstractmethod + def reset(self, env_ids: Sequence[int] | None = None): + """Resets the Processor. + + Args: + env_ids: The environment ids. Defaults to None, in which case + all environments are considered. + """ + raise NotImplementedError + + @abstractmethod + def __call__(self, data: torch.Tensor) -> torch.Tensor: + """Abstract method for defining the modification function. + + Args: + data: The data to be modified. Shape should match the data_dim passed during initialization. + + Returns: + Modified data. Shape is the same as the input data. + """ + raise NotImplementedError diff --git a/source/isaaclab/isaaclab/utils/tensor_processors/tensor_processor_cfg.py b/source/isaaclab/isaaclab/utils/tensor_processors/tensor_processor_cfg.py new file mode 100644 index 000000000000..71d81ea7c90a --- /dev/null +++ b/source/isaaclab/isaaclab/utils/tensor_processors/tensor_processor_cfg.py @@ -0,0 +1,82 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import MISSING +from typing import TYPE_CHECKING, Any + +import torch + +from .. import configclass + +if TYPE_CHECKING: + from .processors import DigitalFilter, Integrator + from .tensor_processor import TensorProcessor + + +@configclass +class TensorProcessorCfg: + """Configuration parameters for function and class tensor processors.""" + + func: Callable[..., torch.Tensor] | type[TensorProcessor] | str = MISSING + """Function or :class:`TensorProcessor` class used by the processor. + + Functions must take a tensor as their first argument. Classes must inherit from :class:`TensorProcessor`; the + observation manager constructs them with the configuration, observation dimensions, and device. + """ + + params: dict[str, Any] = {} + """Parameters used by the processor. Defaults to an empty dictionary. + + Functions receive them as keyword arguments on each call. Classes access them through this + configuration during construction; they are not forwarded to the constructed instance. + """ + + +@configclass +class DigitalFilterCfg(TensorProcessorCfg): + """Configuration parameters for a digital filter processor. + + For more information, please check the :class:`DigitalFilter` class. + """ + + func: type[DigitalFilter] | str = "{DIR}.processors:DigitalFilter" + """The digital filter function to be called for applying the filter.""" + + A: list[float] = MISSING + """The coefficients corresponding the the filter's response to past outputs. + + These correspond to the weights of the past outputs of the filter. The first element is the coefficient + for the output at the previous time step, the second element is the coefficient for the output at two + time steps ago, and so on. + + It is the denominator coefficients of the transfer function of the filter. + """ + + B: list[float] = MISSING + """The coefficients corresponding the the filter's response to current and past inputs. + + These correspond to the weights of the current and past inputs of the filter. The first element is the + coefficient for the current input, the second element is the coefficient for the input at the previous + time step, and so on. + + It is the numerator coefficients of the transfer function of the filter. + """ + + +@configclass +class IntegratorCfg(TensorProcessorCfg): + """Configuration parameters for an integrator processor. + + For more information, please check the :class:`Integrator` class. + """ + + func: type[Integrator] | str = "{DIR}.processors:Integrator" + """The integrator function to be called for applying the integrator.""" + + dt: float = MISSING + """The time step of the integrator.""" diff --git a/source/isaaclab/test/managers/test_manager_base.py b/source/isaaclab/test/managers/test_manager_base.py index bd11ccfcfc27..a123273d3791 100644 --- a/source/isaaclab/test/managers/test_manager_base.py +++ b/source/isaaclab/test/managers/test_manager_base.py @@ -24,7 +24,7 @@ from isaaclab.managers import ManagerTermBase, ManagerTermBaseCfg, SceneEntityCfg from isaaclab.managers.manager_base import ManagerBase from isaaclab.physics import PhysicsEvent -from isaaclab.utils import configclass, modifiers, to_dict, update_from_dict +from isaaclab.utils import configclass, tensor_processors, to_dict, update_from_dict pytestmark = pytest.mark.unit @@ -84,7 +84,7 @@ class NestedFieldTermCfg(ManagerTermBaseCfg): """Manager term with owned and opaque fields outside ``params``.""" nested_term: ManagerTermBaseCfg = ManagerTermBaseCfg(func=increment_dummy1_by_one) - modifier: modifiers.ModifierCfg = modifiers.ModifierCfg(func=increment_dummy1_by_one) + modifier: tensor_processors.TensorProcessorCfg = tensor_processors.TensorProcessorCfg(func=increment_dummy1_by_one) metadata: dict[str, str] = {"func": f"{__name__}:reset_dummy2_to_zero"} opaque: OpaqueCfg = OpaqueCfg() diff --git a/source/isaaclab/test/managers/test_observation_manager.py b/source/isaaclab/test/managers/test_observation_manager.py index fe00e34d64fd..96723979eb06 100644 --- a/source/isaaclab/test/managers/test_observation_manager.py +++ b/source/isaaclab/test/managers/test_observation_manager.py @@ -11,6 +11,8 @@ # ignore private usage of variables warning # pyright: reportPrivateUsage=none from collections import namedtuple +from contextlib import nullcontext +from importlib import import_module from typing import TYPE_CHECKING, cast import pytest @@ -23,7 +25,7 @@ ObservationTermCfg, RewardTermCfg, ) -from isaaclab.utils import DelayBuffer, configclass, modifiers, noise, to_dict, update_from_dict, validate +from isaaclab.utils import DelayBuffer, configclass, noise, tensor_processors, to_dict, update_from_dict, validate pytestmark = pytest.mark.unit @@ -580,12 +582,12 @@ class PolicyCfg(ObservationGroupCfg): @pytest.mark.parametrize("position_term", [pos_w_data, destination_position]) def test_modifier_compute(setup_env, position_term): env = setup_env - """Test the observation computation with modifiers.""" + """Test the observation computation with tensor_processors.""" - modifier_1 = modifiers.ModifierCfg(func=modifiers.bias, params={"value": 1.0}) - modifier_2 = modifiers.ModifierCfg(func=modifiers.scale, params={"multiplier": 2.0}) - modifier_3 = modifiers.ModifierCfg(func=modifiers.clip, params={"bounds": (-0.5, 0.5)}) - modifier_4 = modifiers.IntegratorCfg(dt=env.dt) + modifier_1 = tensor_processors.TensorProcessorCfg(func=tensor_processors.bias, params={"value": 1.0}) + modifier_2 = tensor_processors.TensorProcessorCfg(func=tensor_processors.scale, params={"multiplier": 2.0}) + modifier_3 = tensor_processors.TensorProcessorCfg(func=tensor_processors.clip, params={"bounds": (-0.5, 0.5)}) + modifier_4 = tensor_processors.IntegratorCfg(dt=env.dt) @configclass class MyObservationManagerCfg: @@ -596,19 +598,19 @@ class PolicyCfg(ObservationGroupCfg): """Test config class for policy observation group.""" concatenate_terms = False - term_1 = ObservationTermCfg(func=position_term, modifiers=[]) - term_2 = ObservationTermCfg(func=position_term, modifiers=[modifier_1]) - term_3 = ObservationTermCfg(func=position_term, modifiers=[modifier_1, modifier_4]) + term_1 = ObservationTermCfg(func=position_term, tensor_processors=[]) + term_2 = ObservationTermCfg(func=position_term, tensor_processors=[modifier_1]) + term_3 = ObservationTermCfg(func=position_term, tensor_processors=[modifier_1, modifier_4]) @configclass class CriticCfg(ObservationGroupCfg): """Test config class for critic observation group""" concatenate_terms = False - term_1 = ObservationTermCfg(func=position_term, modifiers=[]) - term_2 = ObservationTermCfg(func=position_term, modifiers=[modifier_1]) - term_3 = ObservationTermCfg(func=position_term, modifiers=[modifier_1, modifier_2]) - term_4 = ObservationTermCfg(func=position_term, modifiers=[modifier_1, modifier_2, modifier_3]) + term_1 = ObservationTermCfg(func=position_term, tensor_processors=[]) + term_2 = ObservationTermCfg(func=position_term, tensor_processors=[modifier_1]) + term_3 = ObservationTermCfg(func=position_term, tensor_processors=[modifier_1, modifier_2]) + term_4 = ObservationTermCfg(func=position_term, tensor_processors=[modifier_1, modifier_2, modifier_3]) policy: ObservationGroupCfg = PolicyCfg() critic: ObservationGroupCfg = CriticCfg() @@ -695,7 +697,7 @@ def test_modifier_invalid_config(setup_env): env = setup_env """Test modifier initialization with invalid config.""" - modifier = modifiers.ModifierCfg(func=modifiers.clip, params={"min": -0.5, "max": 0.5}) + modifier = tensor_processors.TensorProcessorCfg(func=tensor_processors.clip, params={"min": -0.5, "max": 0.5}) @configclass class MyObservationManagerCfg: @@ -706,7 +708,7 @@ class PolicyCfg(ObservationGroupCfg): """Test config class for policy observation group.""" concatenate_terms = False - term_1 = ObservationTermCfg(func=pos_w_data, modifiers=[modifier]) + term_1 = ObservationTermCfg(func=pos_w_data, tensor_processors=[modifier]) policy: ObservationGroupCfg = PolicyCfg() @@ -800,10 +802,10 @@ def __init__(self, num_envs: int = 2) -> None: self.observation = torch.arange(num_envs, dtype=torch.float32).unsqueeze(-1) -class StatefulBiasModifier(modifiers.ModifierBase): +class StatefulBiasModifier(tensor_processors.TensorProcessor): """Stateful modifier used to verify lazy callable resolution.""" - def __init__(self, cfg: modifiers.ModifierCfg, data_dim: tuple[int, ...], device: str) -> None: + def __init__(self, cfg: tensor_processors.TensorProcessorCfg, data_dim: tuple[int, ...], device: str) -> None: super().__init__(cfg, data_dim, device) self.value = cfg.params["value"] self.reset_count = 0 @@ -842,49 +844,82 @@ def test_class_modifier_roundtrip_preserves_func_and_params(): """Reproduce #6067 with a class modifier and non-empty parameters.""" cfg = HistoryObservationsCfg() cfg.policy.history_length = None - cfg.policy.dummy.modifiers = [modifiers.ModifierCfg(func=StatefulBiasModifier, params={"value": 2.0})] + cfg.policy.dummy.tensor_processors = [ + tensor_processors.TensorProcessorCfg(func=StatefulBiasModifier, params={"value": 2.0}) + ] update_from_dict(cfg, to_dict(cfg)) term_cfg = cfg.policy.dummy - assert term_cfg.modifiers is not None - modifier_cfg = term_cfg.modifiers[0] - assert isinstance(modifier_cfg, modifiers.ModifierCfg) - assert isinstance(modifier_cfg.func, str) - assert modifier_cfg.params == {"value": 2.0} + assert term_cfg.tensor_processors is not None + tensor_processor_cfg = term_cfg.tensor_processors[0] + assert isinstance(tensor_processor_cfg, tensor_processors.TensorProcessorCfg) + assert isinstance(tensor_processor_cfg.func, str) + assert tensor_processor_cfg.params == {"value": 2.0} env = DummyEnv() manager = ObservationManager(cfg, cast("ManagerBasedEnv", env)) prepared_term_cfg = manager.cfg.policy.dummy - assert prepared_term_cfg.modifiers is not None - prepared_modifier_cfg = prepared_term_cfg.modifiers[0] - assert isinstance(prepared_modifier_cfg.func, StatefulBiasModifier) + assert prepared_term_cfg.tensor_processors is not None + prepared_tensor_processor_cfg = prepared_term_cfg.tensor_processors[0] + assert isinstance(prepared_tensor_processor_cfg.func, StatefulBiasModifier) observations = manager.compute()["policy"] torch.testing.assert_close(observations, env.observation + 2.0) manager.reset() - assert prepared_modifier_cfg.func.reset_count == 1 + assert prepared_tensor_processor_cfg.func.reset_count == 1 -def test_stateless_modifier_cfg_roundtrip_preserves_signature_validation(): - """A stateless modifier remains callable and inspectable after a configuration round-trip.""" +def _warp_observation(env, out, out_dim=1): + out.assign(env.observation.numpy()) + + +@pytest.mark.parametrize("api", ["current", "legacy", "legacy_warp"]) +def test_stateless_tensor_processor_cfg_roundtrip_preserves_signature_validation(api): + """Current and legacy configs compute observations; core configs also retain serialized callables.""" cfg = HistoryObservationsCfg() cfg.policy.history_length = None - cfg.policy.dummy.modifiers = [modifiers.ModifierCfg(func=modifiers.bias, params={"value": 2.0})] - update_from_dict(cfg, to_dict(cfg)) + manager_type = ObservationManager + if api == "current": + cfg.policy.dummy.tensor_processors = [ + tensor_processors.TensorProcessorCfg(func=tensor_processors.bias, params={"value": 2.0}) + ] + else: + package = "isaaclab_experimental" if api == "legacy_warp" else "isaaclab" + legacy_cfg = import_module(f"{package}.utils.modifiers.modifier_cfg").ModifierCfg + bias = f"{package}.utils.modifiers.modifier:bias" + if api == "legacy_warp": + manager_type = import_module(f"{package}.managers").ObservationManager + cfg.policy.dummy.func = _warp_observation + cfg.policy.dummy.params = {"out_dim": 1} + bias = import_module(f"{package}.utils.modifiers.modifier").bias + cfg.policy.dummy.modifiers = [legacy_cfg(func=bias, params={"value": 2.0})] + if api != "legacy_warp": + update_from_dict(cfg, to_dict(cfg)) env = DummyEnv() - manager = ObservationManager(cfg, cast("ManagerBasedEnv", env)) + warning = pytest.warns(FutureWarning, match="ObservationTermCfg.modifiers") if api != "current" else nullcontext() + with warning: + manager = manager_type(cfg, cast("ManagerBasedEnv", env)) observations = manager.compute()["policy"] torch.testing.assert_close(observations, env.observation + 2.0) +def test_observation_rejects_conflicting_processor_fields(): + """The old and new field names cannot silently override one another, including empty lists.""" + cfg = HistoryObservationsCfg() + cfg.policy.dummy.modifiers = [] + cfg.policy.dummy.tensor_processors = [] + with pytest.raises(ValueError, match="Set only one"): + ObservationManager(cfg, cast("ManagerBasedEnv", DummyEnv())) + + def test_class_modifier_validates_constructed_instance(): """Class modifier validation checks the constructed object.""" cfg = HistoryObservationsCfg() cfg.policy.history_length = None - cfg.policy.dummy.modifiers = [modifiers.ModifierCfg(func=InvalidModifier)] + cfg.policy.dummy.tensor_processors = [tensor_processors.TensorProcessorCfg(func=InvalidModifier)] update_from_dict(cfg, to_dict(cfg)) - with pytest.raises(TypeError, match="is not an instance of 'ModifierBase'"): + with pytest.raises(TypeError, match="is not an instance of 'TensorProcessor'"): ObservationManager(cfg, cast("ManagerBasedEnv", DummyEnv())) diff --git a/source/isaaclab/test/utils/test_configclass.py b/source/isaaclab/test/utils/test_configclass.py index 7d53871de683..a8fbfb09ad13 100644 --- a/source/isaaclab/test/utils/test_configclass.py +++ b/source/isaaclab/test/utils/test_configclass.py @@ -79,7 +79,7 @@ def double(x): @configclass -class ModifierCfg: +class TensorProcessorCfg: params: dict[str, Any] = {"A": 1, "B": 2} @@ -111,7 +111,7 @@ class BasicDemoCfg: device_id: int = 0 env: EnvCfg = EnvCfg() robot_default_state: RobotDefaultStateCfg = RobotDefaultStateCfg() - list_config = [ModifierCfg(), ModifierCfg(params={"A": 3, "B": 4})] + list_config = [TensorProcessorCfg(), TensorProcessorCfg(params={"A": 3, "B": 4})] @configclass diff --git a/source/isaaclab/test/utils/test_dict_tensor_processor.py b/source/isaaclab/test/utils/test_dict_tensor_processor.py new file mode 100644 index 000000000000..19f555e1b10b --- /dev/null +++ b/source/isaaclab/test/utils/test_dict_tensor_processor.py @@ -0,0 +1,228 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Contracts for buffer routing, ownership and lifecycle independent of camera rendering.""" + +from builtins import ExceptionGroup + +import pytest +import torch +import warp as wp + +from isaaclab.utils.tensor_processors import DictTensorProcessor, DictTensorProcessorCfg +from isaaclab.utils.tensor_processors._pipeline import _DictTensorProcessorPipeline +from isaaclab.utils.warp import ProxyArray + +pytestmark = pytest.mark.unit + + +class _Scale(DictTensorProcessor): + def __init__(self, cfg, data): + super().__init__(cfg, data) + self.outputs[cfg.outputs[0]] = ProxyArray(wp.empty_like(data[cfg.inputs[0]].warp)) + + def __call__(self, data): + torch.mul( + data[self.cfg.inputs[0]].torch, + self.cfg.params.get("factor", 2.0), + out=self.outputs[self.cfg.outputs[0]].torch, + ) + return dict(self.outputs) + + +class _Composite(DictTensorProcessor): + def __init__(self, cfg, data): + super().__init__(cfg, data) + color = data["color"] + self.outputs["color"] = ProxyArray(wp.empty((color.shape[0], 1, 1, 3), dtype=color.dtype, device=color.device)) + + def __call__(self, data): + torch.sub(data["color"].torch[:, :1, :1], data["range"].torch[:, :1, :1], out=self.outputs["color"].torch) + return dict(self.outputs) + + +@pytest.fixture +def composite_pipe(): + sources = { + "raw_color": ProxyArray(wp.zeros((2, 2, 2, 3), dtype=wp.float32, device="cpu")), + "range": ProxyArray(wp.zeros((2, 2, 2, 1), dtype=wp.float32, device="cpu")), + } + steps = [ + DictTensorProcessorCfg(class_type=_Scale, inputs=["raw_color"], outputs=["color"]), + DictTensorProcessorCfg( + class_type=_Composite, + inputs=["color", "range"], + outputs=["color"], + ), + ] + assert _DictTensorProcessorPipeline.required_source_names(steps, ["color", "labels"]) == { + "raw_color", + "range", + "labels", + } + pipe = _DictTensorProcessorPipeline(sources, steps) + yield pipe + pipe.close() + + +def test_ordered_multi_input_operations_preserve_sources_and_reuse_output_storage(composite_pipe): + """Bindings consume earlier replacements; shape changes and strided inputs preserve ownership.""" + backing = torch.full((2, 2, 2, 4), 3.0) + color = ProxyArray(wp.from_torch(backing[..., :3])) + depth = ProxyArray(wp.full((2, 2, 2, 1), 2.0, dtype=wp.float32, device="cpu")) + labels = ProxyArray(wp.ones((2, 2, 2, 1), dtype=wp.int32, device="cpu")) + sources = {"raw_color": color, "range": depth, "labels": labels} + output = composite_pipe(sources) + snapshot = output["color"].torch.clone() + destination = output["color"] + + torch.testing.assert_close(snapshot, torch.full((2, 1, 1, 3), 4.0)) + torch.testing.assert_close(backing, torch.full_like(backing, 3.0)) + torch.testing.assert_close(depth.torch, torch.full_like(depth.torch, 2.0)) + assert output["labels"] is labels + assert output["raw_color"] is color + assert set(sources) == {"raw_color", "range", "labels"} + assert composite_pipe.outputs["color"].shape == (2, 1, 1, 3) + + backing.fill_(5.0) + updated = composite_pipe(sources) + assert updated["color"] is destination + torch.testing.assert_close(destination.torch, torch.full_like(snapshot, 8.0)) + torch.testing.assert_close(snapshot, torch.full_like(snapshot, 4.0)) + updated.pop("color") + assert composite_pipe.outputs["color"] is destination + + +@pytest.mark.parametrize("invalid", ["missing", "shape", "dtype"]) +def test_invalid_source_buffers_are_rejected_before_processing(invalid): + """Missing or incompatible external storage must fail before a callback reads it.""" + calls = [] + source = ProxyArray(wp.zeros((2, 1, 1, 1), dtype=wp.float32, device="cpu")) + + class CountCalls(_Scale): + def __call__(self, data): + calls.append(True) + return super().__call__(data) + + pipe = _DictTensorProcessorPipeline( + {"source": source}, + [DictTensorProcessorCfg(class_type=CountCalls, inputs=["source"], outputs=["result"])], + ) + try: + shape = (1, 1, 1, 1) if invalid == "shape" else source.shape + dtype = wp.uint8 if invalid == "dtype" else wp.float32 + inputs = {} if invalid == "missing" else {"source": ProxyArray(wp.zeros(shape, dtype=dtype, device="cpu"))} + with pytest.raises(ValueError, match="Missing required|shape, dtype and device"): + pipe(inputs) + assert not calls + finally: + pipe.close() + + +@pytest.mark.parametrize("invalid", ["missing_dependency", "output_names", "duplicate_outputs", "output_type"]) +def test_invalid_declarations_release_processors(invalid): + """Construction failure closes each successfully constructed processor in reverse order.""" + closed = [] + source = ProxyArray(wp.zeros((2, 1, 1, 1), dtype=wp.float32, device="cpu")) + + class Tracked(_Scale): + def __init__(self, cfg, data): + super().__init__(cfg, data) + if cfg.params["name"] == "second": + if invalid == "output_names": + self.outputs = {"wrong": next(iter(self.outputs.values()))} + elif invalid == "output_type": + self.outputs[cfg.outputs[0]] = self.outputs[cfg.outputs[0]].warp + + def close(self): + closed.append(self.cfg.params["name"]) + + first = DictTensorProcessorCfg(Tracked, ["source"], ["middle"], {"name": "first"}) + second = DictTensorProcessorCfg(Tracked, ["middle"], ["result"], {"name": "second"}) + if invalid == "missing_dependency": + second.inputs = ["absent"] + message = "Missing input buffers for preparation" + elif invalid == "output_names": + message = "declared output names" + elif invalid == "duplicate_outputs": + second.outputs = ["result", "result"] + message = "distinct output names" + else: + message = "outputs must be ProxyArray" + with pytest.raises(TypeError if invalid == "output_type" else ValueError, match=message): + _DictTensorProcessorPipeline({"source": source}, [first, second]) + assert closed == (["second", "first"] if invalid in ("output_names", "output_type") else ["first"]) + + +@pytest.mark.parametrize("close_fails", [False, True], ids=["normal_close", "failing_close"]) +def test_reset_selects_batch_entries_and_close_releases_operations_in_reverse_order(close_fails): + """State belongs to each processor; selected resets and idempotent teardown preserve its lifecycle.""" + closed = [] + source = ProxyArray(wp.zeros((2, 1, 1, 1), dtype=wp.float32, device="cpu")) + + class History(_Scale): + def __init__(self, cfg, data): + super().__init__(cfg, data) + self.history = torch.zeros(data["value"].shape) + + def __call__(self, data): + self.history.add_(data["value"].torch) + self.outputs["value"].torch.copy_(self.history) + return dict(self.outputs) + + def reset(self, env_ids=None): + self.history[slice(None) if env_ids is None else env_ids] = 0 + + def close(self): + name = self.cfg.params["name"] + closed.append(name) + if close_fails and name == "second": + raise RuntimeError("Processor cleanup failed") + + pipe = _DictTensorProcessorPipeline( + {"value": source}, + [DictTensorProcessorCfg(History, ["value"], ["value"], {"name": name}) for name in ("first", "second")], + ) + source = {"value": ProxyArray(wp.ones(source.shape, dtype=wp.float32, device="cpu"))} + try: + torch.testing.assert_close(pipe(source)["value"].torch.flatten(), torch.tensor([1.0, 1.0])) + torch.testing.assert_close(pipe(source)["value"].torch.flatten(), torch.tensor([3.0, 3.0])) + pipe.reset([0]) + torch.testing.assert_close(pipe(source)["value"].torch.flatten(), torch.tensor([1.0, 6.0])) + pipe.reset() + torch.testing.assert_close(pipe(source)["value"].torch.flatten(), torch.tensor([1.0, 1.0])) + finally: + if close_fails: + with pytest.raises(ExceptionGroup, match="Failed to close"): + pipe.close() + else: + pipe.close() + pipe.close() + assert closed == ["second", "first"] + with pytest.raises(RuntimeError, match="closed"): + pipe(source) + + +@pytest.mark.parametrize("replacement", ["missing", "new_storage"]) +def test_processor_cannot_replace_published_output_storage(replacement): + """Camera consumers retain prepared tensors, so missing or replaced outputs must fail.""" + + class ReplacesOutput(_Scale): + def __call__(self, data): + super().__call__(data) + if replacement == "missing": + return {} + self.outputs["result"] = ProxyArray(wp.empty_like(data["source"].warp)) + return dict(self.outputs) + + source = ProxyArray(wp.ones((2,), dtype=wp.float32, device="cpu")) + pipe = _DictTensorProcessorPipeline( + {"source": source}, [DictTensorProcessorCfg(ReplacesOutput, ["source"], ["result"])] + ) + try: + with pytest.raises(ValueError, match="persistent named output buffers"): + pipe({"source": source}) + finally: + pipe.close() diff --git a/source/isaaclab/test/utils/test_modifiers.py b/source/isaaclab/test/utils/test_modifiers.py index c131ce76addf..7ac0700b15a5 100644 --- a/source/isaaclab/test/utils/test_modifiers.py +++ b/source/isaaclab/test/utils/test_modifiers.py @@ -8,7 +8,7 @@ import pytest import torch -import isaaclab.utils.modifiers as modifiers +import isaaclab.utils.tensor_processors as tensor_processors from isaaclab.test.utils import test_devices from isaaclab.utils import configclass @@ -17,9 +17,9 @@ @configclass class ModifierTestCfg: - """Configuration for testing modifiers.""" + """Configuration for testing tensor_processors.""" - cfg: modifiers.ModifierCfg = MISSING + cfg: tensor_processors.TensorProcessorCfg = MISSING init_data: torch.Tensor = MISSING result: torch.Tensor = MISSING num_iter: int = 10 @@ -28,17 +28,17 @@ class ModifierTestCfg: @pytest.mark.parametrize( "func, params, result", [ - (modifiers.scale, {"multiplier": 2.0}, [2.0, 4.0, 6.0]), - (modifiers.bias, {"value": 1.0}, [2.0, 3.0, 4.0]), - (modifiers.clip, {"bounds": (1.5, 2.5)}, [1.5, 2.0, 2.5]), - (modifiers.clip, {"bounds": (1.5, None)}, [1.5, 2.0, 3.0]), - (modifiers.clip, {"bounds": (None, 2.5)}, [1.0, 2.0, 2.5]), + (tensor_processors.scale, {"multiplier": 2.0}, [2.0, 4.0, 6.0]), + (tensor_processors.bias, {"value": 1.0}, [2.0, 3.0, 4.0]), + (tensor_processors.clip, {"bounds": (1.5, 2.5)}, [1.5, 2.0, 2.5]), + (tensor_processors.clip, {"bounds": (1.5, None)}, [1.5, 2.0, 3.0]), + (tensor_processors.clip, {"bounds": (None, 2.5)}, [1.0, 2.0, 2.5]), ], ids=["scale", "bias", "clip", "clip_no_upper_bound", "clip_no_lower_bound"], ) -def test_stateless_modifiers(func, params, result): - """Test the stateless scale, bias, and clip modifiers.""" - cfg = modifiers.ModifierCfg(func=func, params=params) +def test_stateless_tensor_processors(func, params, result): + """Test the stateless scale, bias, and clip tensor_processors.""" + cfg = tensor_processors.TensorProcessorCfg(func=func, params=params) output = cfg.func(torch.tensor([1.0, 2.0, 3.0]), **cfg.params) assert torch.allclose(output, torch.tensor(result)) @@ -54,7 +54,7 @@ def test_digital_filter(device): # create test config test_cfg = ModifierTestCfg( - cfg=modifiers.DigitalFilterCfg(A=A, B=B), init_data=init_data, result=result, num_iter=16 + cfg=tensor_processors.DigitalFilterCfg(A=A, B=B), init_data=init_data, result=result, num_iter=16 ) # create a modifier instance @@ -89,7 +89,7 @@ def test_integral(device): # create test config test_cfg = ModifierTestCfg( - cfg=modifiers.IntegratorCfg(dt=dt), + cfg=tensor_processors.IntegratorCfg(dt=dt), init_data=init_data, result=result, num_iter=6, diff --git a/source/isaaclab_experimental/changelog.d/dict-tensor-processors.deprecated.rst b/source/isaaclab_experimental/changelog.d/dict-tensor-processors.deprecated.rst new file mode 100644 index 000000000000..98f2866a8ade --- /dev/null +++ b/source/isaaclab_experimental/changelog.d/dict-tensor-processors.deprecated.rst @@ -0,0 +1,3 @@ +* Deprecated the modifiers API in favor of ``tensor_processors``, ``TensorProcessor``, and + ``TensorProcessorCfg``. Use ``ObservationTermCfg.tensor_processors`` for observation processing; + legacy imports and configuration remain supported during migration. diff --git a/source/isaaclab_experimental/changelog.d/dict-tensor-processors.minor b/source/isaaclab_experimental/changelog.d/dict-tensor-processors.minor new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_experimental/isaaclab_experimental/managers/observation_manager.py b/source/isaaclab_experimental/isaaclab_experimental/managers/observation_manager.py index 051aaa8a6ff6..7448c924fe68 100644 --- a/source/isaaclab_experimental/isaaclab_experimental/managers/observation_manager.py +++ b/source/isaaclab_experimental/isaaclab_experimental/managers/observation_manager.py @@ -32,11 +32,11 @@ The observation manager can be used to compute observations for all the groups or for a specific group. The observations are computed by calling the registered functions for each term in the group. The functions are -called in the order of the terms in the group. The functions are expected to return a tensor with shape -(num_envs, ...). +called in the order of the terms in the group. The functions write into pre-allocated Warp buffers +with shape (num_envs, ...) and return None. -If a noise model or custom modifier is registered for a term, the function is called to corrupt -the observation. The corruption function is expected to return a tensor with the same shape as the observation. +If a tensor processor or noise model is registered for a term, it updates the observation buffer +in place, preserving its shape. Tensor processors run before noise models. The observations are clipped and scaled as per the configuration settings. Experimental (Warp-first) note: @@ -59,7 +59,7 @@ from isaaclab.managers.manager_term_cfg import ObservationGroupCfg, ObservationTermCfg from isaaclab.utils import instantiate, to_dict -from isaaclab_experimental.utils import modifiers, noise +from isaaclab_experimental.utils import noise, tensor_processors from isaaclab_experimental.utils.buffers import CircularBuffer from isaaclab_experimental.utils.torch_utils import clone_obs_buffer @@ -133,11 +133,11 @@ class ObservationManager(ManagerBase): The observation manager can be used to compute observations for all the groups or for a specific group. The observations are computed by calling the registered functions for each term in the group. The functions are - called in the order of the terms in the group. The functions are expected to return a tensor with shape - (num_envs, ...). + called in the order of the terms in the group. The functions write into pre-allocated Warp buffers + with shape (num_envs, ...) and return None. - If a noise model or custom modifier is registered for a term, the function is called to corrupt - the observation. The corruption function is expected to return a tensor with the same shape as the observation. + If a tensor processor or noise model is registered for a term, it updates the observation buffer + in place, preserving its shape. Tensor processors run before noise models. The observations are clipped and scaled as per the configuration settings. Experimental (Warp-first) note: @@ -350,7 +350,7 @@ def _collect_io_descriptors(self, group_names_to_export: list[str] = ["policy"]) func(self._env, **term_cfg.params, inspect=True) desc = func._descriptor.__dict__.copy() overloads = {} - for k in ["modifiers", "clip", "scale", "history_length", "flatten_history_dim"]: + for k in ["tensor_processors", "clip", "scale", "history_length", "flatten_history_dim"]: if hasattr(term_cfg, k): overloads[k] = getattr(term_cfg, k) desc.update(overloads) @@ -374,7 +374,7 @@ def _collect_io_descriptors(self, group_names_to_export: list[str] = ["policy"]) v = v.detach().cpu().numpy().tolist() if k in ["scale", "clip", "history_length", "flatten_history_dim"]: formatted_item["overloads"][k] = v - elif k in ["modifiers", "description", "units"]: + elif k in ["tensor_processors", "description", "units"]: formatted_item["extras"][k] = v else: formatted_item[k] = v @@ -409,7 +409,7 @@ def reset( for term_name in self._group_obs_term_names[group_name]: if term_name in self._group_obs_term_history_buffer[group_name]: self._group_obs_term_history_buffer[group_name][term_name].reset(env_mask=env_mask) - # call all modifiers/noise models that are classes + # call all tensor processors/noise models that are classes for mod in self._group_obs_class_instances: mod.reset(env_mask=env_mask) @@ -457,13 +457,13 @@ def compute_group(self, group_name: str, update_history: bool = False) -> torch. """Computes the observations for a given group. The observations for a given group are computed by calling the registered functions for each - term in the group. The functions are called in the order of the terms in the group. The functions - are expected to return a tensor with shape (num_envs, ...). + term in the group. The functions are called in the order of the terms in the group and write into + pre-allocated Warp buffers with shape (num_envs, ...). The following steps are performed for each observation term: 1. Compute observation term by calling the function - 2. Apply custom modifiers in the order specified in :attr:`ObservationTermCfg.modifiers` + 2. Apply custom tensor processors in the order specified in :attr:`ObservationTermCfg.tensor_processors` 3. Apply corruption/noise model based on :attr:`ObservationTermCfg.noise` 4. Apply clipping based on :attr:`ObservationTermCfg.clip` 5. Apply scaling based on :attr:`ObservationTermCfg.scale` @@ -500,14 +500,14 @@ def compute_group(self, group_name: str, update_history: bool = False) -> torch. # Persistent per-term obs dict (pre-allocated in _prepare_terms). group_obs = self._group_obs_dict[group_name] - # evaluate terms: compute, add noise, clip, scale, custom modifiers + # evaluate terms: compute, apply tensor processors, add noise, clip, scale for term_name, term_cfg in zip(group_term_names, self._group_obs_term_cfgs[group_name]): # compute term's value into pre-allocated Warp output term_cfg.func(self._env, term_cfg.out_wp, **term_cfg.params) - # apply custom modifiers (in-place on out_wp) - if term_cfg.modifiers is not None: - for modifier in term_cfg.modifiers: + # apply custom tensor processors (in-place on out_wp) + if term_cfg.tensor_processors is not None: + for modifier in term_cfg.tensor_processors: modifier.func(term_cfg.out_wp, **modifier.params) # apply noise (Warp in-place on out_wp) @@ -605,9 +605,9 @@ def _prepare_terms(self): # noqa: C901 self._group_obs_concatenate_dim: dict[str, int] = dict() self._group_obs_term_history_buffer: dict[str, dict] = dict() - # create a list to store classes instances, e.g., for modifiers and noise models + # create a list to store class instances, e.g., for tensor processors and noise models # we store it as a separate list to only call reset on them and prevent unnecessary calls - self._group_obs_class_instances: list[modifiers.ModifierBase | noise.NoiseModel] = list() + self._group_obs_class_instances: list[tensor_processors.TensorProcessor | noise.NoiseModel] = list() # Persistent Warp output buffers for concatenated 2D groups (optional fast-path). # For other cases (non-concat groups, history outputs, non-2D concat dims), we allocate per-term outputs. @@ -682,6 +682,7 @@ def _prepare_terms(self): # noqa: C901 f"Configuration for the term '{term_name}' is not of type ObservationTermCfg." f" Received: '{type(term_cfg)}'." ) + term_cfg._migrate_modifiers() # resolve common terms in the config # Warp-first signature is (env, out, **params) self._resolve_common_term_cfg(f"{group_name}/{term_name}", term_cfg, min_argc=2) @@ -735,27 +736,27 @@ def _prepare_terms(self): # noqa: C901 ) term_cfg.scale_wp = wp.array(scale_vals, dtype=wp.float32, device=self._env.device) - # prepare modifiers for each observation - if term_cfg.modifiers is not None: - # initialize list of modifiers for term - for mod_cfg in term_cfg.modifiers: - # check if class modifier and initialize with observation size when adding - if isinstance(mod_cfg, modifiers.ModifierCfg): - # to list of modifiers + # prepare tensor processors for each observation + if term_cfg.tensor_processors is not None: + # initialize list of tensor processors for term + for mod_cfg in term_cfg.tensor_processors: + # check if class processor and initialize with observation size when adding + if isinstance(mod_cfg, tensor_processors.TensorProcessorCfg): + # to list of tensor processors if inspect.isclass(mod_cfg.func): - if not issubclass(mod_cfg.func, modifiers.ModifierBase): + if not issubclass(mod_cfg.func, tensor_processors.TensorProcessor): raise TypeError( f"Modifier function '{mod_cfg.func}' for observation term '{term_name}'" - f" is not a subclass of 'ModifierBase'. Received: '{type(mod_cfg.func)}'." + f" is not a subclass of 'TensorProcessor'. Received: '{type(mod_cfg.func)}'." ) mod_cfg.func = mod_cfg.func(cfg=mod_cfg, data_dim=obs_dims, device=self._env.device) - # add to list of class modifiers + # add to list of class tensor processors self._group_obs_class_instances.append(mod_cfg.func) else: raise TypeError( f"Modifier configuration '{mod_cfg}' of observation term '{term_name}' is not of" - f" required type ModifierCfg, Received: '{type(mod_cfg)}'" + f" required type TensorProcessorCfg, Received: '{type(mod_cfg)}'" ) # check if function is callable diff --git a/source/isaaclab_experimental/isaaclab_experimental/utils/__init__.pyi b/source/isaaclab_experimental/isaaclab_experimental/utils/__init__.pyi index de2860732514..8211d4785061 100644 --- a/source/isaaclab_experimental/isaaclab_experimental/utils/__init__.pyi +++ b/source/isaaclab_experimental/isaaclab_experimental/utils/__init__.pyi @@ -10,6 +10,7 @@ __all__ = [ "clone_obs_buffer", "buffers", "modifiers", + "tensor_processors", "noise", "warp", ] @@ -17,4 +18,4 @@ __all__ = [ from .manager_call_switch import ManagerCallMode, ManagerCallSwitch from .torch_utils import clone_obs_buffer from .warp_graph_cache import WarpGraphCache -from . import buffers, modifiers, noise, warp +from . import buffers, modifiers, tensor_processors, noise, warp diff --git a/source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/__init__.py b/source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/__init__.py index 3563b166642a..6eafd8bcdb8a 100644 --- a/source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/__init__.py +++ b/source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/__init__.py @@ -3,19 +3,19 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Warp-native modifier implementations (experimental). +"""Deprecated import path for Warp-native tensor processors.""" -Re-exports stable configs and base classes, then overrides the function-based -modifiers (``scale``, ``bias``, ``clip``) with Warp-native versions that -operate in-place on ``wp.array``. +import warnings -Calling convention (matches Warp MDP terms):: +from .modifier import bias, clip, scale +from .modifier_base import ModifierBase +from .modifier_cfg import ModifierCfg - modifier.func(data_wp, **params) -> None # in-place on wp.array -""" +__all__ = ["ModifierBase", "ModifierCfg", "bias", "clip", "scale"] -from .modifier import bias, clip, scale # noqa: F401 -from .modifier_base import ModifierBase # noqa: F401 - -# Override with Warp-native implementations -from .modifier_cfg import ModifierCfg # noqa: F401 +warnings.warn( + "isaaclab_experimental.utils.modifiers is deprecated." + " Use isaaclab_experimental.utils.tensor_processors instead.", + DeprecationWarning, + stacklevel=2, +) diff --git a/source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/modifier.py b/source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/modifier.py index 887db5e4ee06..f57c56f8ea2a 100644 --- a/source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/modifier.py +++ b/source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/modifier.py @@ -3,78 +3,8 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Warp-native function-based modifiers (experimental). +"""Deprecated import path for Warp-native tensor processor implementations.""" -Each modifier takes a ``wp.array`` as its first argument and operates **in-place** -via ``wp.launch``. The calling convention mirrors Warp MDP terms:: +from ..tensor_processors.processors import bias, clip, scale - modifier.func(data_wp, **params) -> None -""" - -from __future__ import annotations - -import warp as wp - -# -- scale -------------------------------------------------------------------- - - -@wp.kernel -def _scale_kernel(data: wp.array(dtype=wp.float32, ndim=2), multiplier: wp.float32): - i, j = wp.tid() - data[i, j] = data[i, j] * multiplier - - -def scale(data: wp.array, multiplier: float) -> None: - """Scale all elements of *data* by *multiplier* in-place. - - Warp-native drop-in replacement for :func:`isaaclab.utils.modifiers.scale`. - - Args: - data: The observation buffer to modify. Shape ``(num_envs, D)``. - multiplier: Scalar multiplier. - """ - wp.launch(_scale_kernel, dim=data.shape, inputs=[data, float(multiplier)], device=data.device) - - -# -- bias --------------------------------------------------------------------- - - -@wp.kernel -def _bias_kernel(data: wp.array(dtype=wp.float32, ndim=2), value: wp.float32): - i, j = wp.tid() - data[i, j] = data[i, j] + value - - -def bias(data: wp.array, value: float) -> None: - """Add a uniform *value* to all elements of *data* in-place. - - Warp-native drop-in replacement for :func:`isaaclab.utils.modifiers.bias`. - - Args: - data: The observation buffer to modify. Shape ``(num_envs, D)``. - value: Scalar bias to add. - """ - wp.launch(_bias_kernel, dim=data.shape, inputs=[data, float(value)], device=data.device) - - -# -- clip --------------------------------------------------------------------- - - -@wp.kernel -def _clip_kernel(data: wp.array(dtype=wp.float32, ndim=2), lo: wp.float32, hi: wp.float32): - i, j = wp.tid() - data[i, j] = wp.clamp(data[i, j], lo, hi) - - -def clip(data: wp.array, bounds: tuple[float | None, float | None]) -> None: - """Clamp all elements of *data* to [lo, hi] in-place. - - Warp-native drop-in replacement for :func:`isaaclab.utils.modifiers.clip`. - - Args: - data: The observation buffer to modify. Shape ``(num_envs, D)``. - bounds: ``(min, max)`` tuple. ``None`` means no bound on that side. - """ - lo = float(bounds[0]) if bounds[0] is not None else float(-1e38) - hi = float(bounds[1]) if bounds[1] is not None else float(1e38) - wp.launch(_clip_kernel, dim=data.shape, inputs=[data, lo, hi], device=data.device) +__all__ = ["bias", "clip", "scale"] diff --git a/source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/modifier_base.py b/source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/modifier_base.py index 083e1576e891..fae9dbd4bb52 100644 --- a/source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/modifier_base.py +++ b/source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/modifier_base.py @@ -3,61 +3,8 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Warp-native modifier base class (experimental).""" +"""Deprecated name for the Warp-native tensor processor interface.""" -from __future__ import annotations +from ..tensor_processors.tensor_processor import TensorProcessor as ModifierBase -from abc import ABC, abstractmethod -from typing import TYPE_CHECKING - -import warp as wp - -if TYPE_CHECKING: - from .modifier_cfg import ModifierCfg - - -class ModifierBase(ABC): - """Base class for Warp-native class-based modifiers. - - Experimental fork of :class:`isaaclab.utils.modifiers.ModifierBase` adapted for the - Warp-first calling convention. Subclasses operate **in-place** on ``wp.array`` - buffers and return ``None``. - - A class implementation of a modifier can be used to store state information between - calls. This is useful for modifiers that require stateful operations, such as - rolling averages, delays, or decaying filters. - """ - - def __init__(self, cfg: ModifierCfg, data_dim: tuple[int, ...], device: str) -> None: - """Initializes the modifier class. - - Args: - cfg: Configuration parameters. - data_dim: The dimensions of the data to be modified. First element is the - batch size (number of environments). - device: The device to run the modifier on. - """ - self._cfg = cfg - self._data_dim = data_dim - self._device = device - - @abstractmethod - def reset(self, env_mask: wp.array | None = None): - """Resets the modifier. - - Args: - env_mask: Boolean env mask of shape ``(num_envs,)`` selecting environments - to reset. Defaults to None, in which case all environments are - considered. - """ - raise NotImplementedError - - @abstractmethod - def __call__(self, data: wp.array) -> None: - """Apply the modification in-place. - - Args: - data: The ``wp.array`` buffer to modify. Shape should match the - *data_dim* passed during initialization. - """ - raise NotImplementedError +__all__ = ["ModifierBase"] diff --git a/source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/modifier_cfg.py b/source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/modifier_cfg.py index 7cc22eaee6a9..ab7bc02fbc75 100644 --- a/source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/modifier_cfg.py +++ b/source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/modifier_cfg.py @@ -3,37 +3,8 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Warp-native modifier configuration (experimental).""" +"""Deprecated name for Warp-native tensor processor configurations.""" -from collections.abc import Callable -from dataclasses import MISSING -from typing import Any +from ..tensor_processors.tensor_processor_cfg import TensorProcessorCfg as ModifierCfg -from isaaclab.utils import configclass - - -@configclass -class ModifierCfg: - """Configuration parameters for Warp-native modifiers. - - Experimental fork of :class:`isaaclab.utils.modifiers.ModifierCfg` adapted for the - Warp-first calling convention where modifier functions operate **in-place** on a - ``wp.array`` buffer and return ``None``. - """ - - func: Callable[..., None] = MISSING - """Function or callable class used by modifier. - - The function must take a ``wp.array`` as the first argument and operate on it - **in-place** (no return value). The remaining arguments are specified in the - :attr:`params` attribute. - - It also supports callable classes that implement ``__call__()``. In this case the - class should inherit from :class:`ModifierBase` and implement the required methods. - """ - - params: dict[str, Any] = dict() - """The parameters to be passed to the function or callable class as keyword arguments. - - Defaults to an empty dictionary. - """ +__all__ = ["ModifierCfg"] diff --git a/source/isaaclab_experimental/isaaclab_experimental/utils/tensor_processors/__init__.py b/source/isaaclab_experimental/isaaclab_experimental/utils/tensor_processors/__init__.py new file mode 100644 index 000000000000..fa6f870c2a67 --- /dev/null +++ b/source/isaaclab_experimental/isaaclab_experimental/utils/tensor_processors/__init__.py @@ -0,0 +1,18 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Warp-native processor implementations (experimental). + +Provides Warp-native configuration and base classes, plus function-based tensor +processors (``scale``, ``bias``, ``clip``) that operate in-place on ``wp.array``. + +Calling convention (matches Warp MDP terms):: + + processor.func(data_wp, **params) -> None # in-place on wp.array +""" + +from .processors import bias, clip, scale # noqa: F401 +from .tensor_processor import TensorProcessor # noqa: F401 +from .tensor_processor_cfg import TensorProcessorCfg # noqa: F401 diff --git a/source/isaaclab_experimental/isaaclab_experimental/utils/tensor_processors/processors.py b/source/isaaclab_experimental/isaaclab_experimental/utils/tensor_processors/processors.py new file mode 100644 index 000000000000..6406befb1dc4 --- /dev/null +++ b/source/isaaclab_experimental/isaaclab_experimental/utils/tensor_processors/processors.py @@ -0,0 +1,83 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Warp-native function-based tensor processors (experimental). + +Each processor takes a ``wp.array`` as its first argument and operates **in-place** +via ``wp.launch``. The calling convention mirrors Warp MDP terms:: + + processor.func(data_wp, **params) -> None +""" + +from __future__ import annotations + +import warp as wp + +# -- scale -------------------------------------------------------------------- + + +@wp.kernel +def _scale_kernel(data: wp.array(dtype=wp.float32, ndim=2), multiplier: wp.float32): + i, j = wp.tid() + data[i, j] = data[i, j] * multiplier + + +def scale(data: wp.array, multiplier: float) -> None: + """Scale all elements of *data* by *multiplier* in-place. + + Warp-native counterpart of :func:`isaaclab.utils.tensor_processors.scale`, updating the supplied + array in place and returning None. + + Args: + data: The observation buffer to modify. Shape ``(num_envs, D)``. + multiplier: Scalar multiplier. + """ + wp.launch(_scale_kernel, dim=data.shape, inputs=[data, float(multiplier)], device=data.device) + + +# -- bias --------------------------------------------------------------------- + + +@wp.kernel +def _bias_kernel(data: wp.array(dtype=wp.float32, ndim=2), value: wp.float32): + i, j = wp.tid() + data[i, j] = data[i, j] + value + + +def bias(data: wp.array, value: float) -> None: + """Add a uniform *value* to all elements of *data* in-place. + + Warp-native counterpart of :func:`isaaclab.utils.tensor_processors.bias`, updating the supplied + array in place and returning None. + + Args: + data: The observation buffer to modify. Shape ``(num_envs, D)``. + value: Scalar bias to add. + """ + wp.launch(_bias_kernel, dim=data.shape, inputs=[data, float(value)], device=data.device) + + +# -- clip --------------------------------------------------------------------- + + +@wp.kernel +def _clip_kernel(data: wp.array(dtype=wp.float32, ndim=2), lo: wp.float32, hi: wp.float32): + i, j = wp.tid() + data[i, j] = wp.clamp(data[i, j], lo, hi) + + +def clip(data: wp.array, bounds: tuple[float | None, float | None]) -> None: + """Clamp all elements of *data* to [lo, hi] in-place. + + Warp-native counterpart of :func:`isaaclab.utils.tensor_processors.clip`, updating the supplied + array in place and returning None. + + Args: + data: The observation buffer to modify. Shape ``(num_envs, D)``. + bounds: ``(min, max)`` tuple. ``None`` means no bound on that side. + """ + lo = float(bounds[0]) if bounds[0] is not None else float(-1e38) + hi = float(bounds[1]) if bounds[1] is not None else float(1e38) + wp.launch(_clip_kernel, dim=data.shape, inputs=[data, lo, hi], device=data.device) diff --git a/source/isaaclab_experimental/isaaclab_experimental/utils/tensor_processors/tensor_processor.py b/source/isaaclab_experimental/isaaclab_experimental/utils/tensor_processors/tensor_processor.py new file mode 100644 index 000000000000..ccfb87ab6fc9 --- /dev/null +++ b/source/isaaclab_experimental/isaaclab_experimental/utils/tensor_processors/tensor_processor.py @@ -0,0 +1,63 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Warp-native processor base class (experimental).""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +import warp as wp + +if TYPE_CHECKING: + from .tensor_processor_cfg import TensorProcessorCfg + + +class TensorProcessor(ABC): + """Base class for Warp-native class-based tensor processors. + + Experimental fork of :class:`isaaclab.utils.tensor_processors.TensorProcessor` adapted for the + Warp-first calling convention. Subclasses operate **in-place** on ``wp.array`` + buffers and return ``None``. + + A class implementation of a processor can be used to store state information between + calls. This is useful for tensor processors that require stateful operations, such as + rolling averages, delays, or decaying filters. + """ + + def __init__(self, cfg: TensorProcessorCfg, data_dim: tuple[int, ...], device: str) -> None: + """Initializes the processor class. + + Args: + cfg: Configuration parameters. + data_dim: The dimensions of the data to be modified. First element is the + batch size (number of environments). + device: The device to run the processor on. + """ + self._cfg = cfg + self._data_dim = data_dim + self._device = device + + @abstractmethod + def reset(self, env_mask: wp.array | None = None): + """Resets the processor. + + Args: + env_mask: Boolean env mask of shape ``(num_envs,)`` selecting environments + to reset. Defaults to None, in which case all environments are + considered. + """ + raise NotImplementedError + + @abstractmethod + def __call__(self, data: wp.array) -> None: + """Apply the modification in-place. + + Args: + data: The ``wp.array`` buffer to modify. Shape should match the + *data_dim* passed during initialization. + """ + raise NotImplementedError diff --git a/source/isaaclab_experimental/isaaclab_experimental/utils/tensor_processors/tensor_processor_cfg.py b/source/isaaclab_experimental/isaaclab_experimental/utils/tensor_processors/tensor_processor_cfg.py new file mode 100644 index 000000000000..26bb86e33e25 --- /dev/null +++ b/source/isaaclab_experimental/isaaclab_experimental/utils/tensor_processors/tensor_processor_cfg.py @@ -0,0 +1,39 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Warp-native processor configuration (experimental).""" + +from collections.abc import Callable +from dataclasses import MISSING +from typing import Any + +from isaaclab.utils import configclass + + +@configclass +class TensorProcessorCfg: + """Configuration parameters for Warp-native tensor processors. + + Experimental fork of :class:`isaaclab.utils.tensor_processors.TensorProcessorCfg` adapted for the + Warp-first calling convention where processor functions operate **in-place** on a + ``wp.array`` buffer and return ``None``. + """ + + func: Callable[..., None] = MISSING + """Function or callable class used by processor. + + The function must take a ``wp.array`` as the first argument and operate on it + **in-place** (no return value). The remaining arguments are specified in the + :attr:`params` attribute. + + It also supports callable classes that implement ``__call__()``. In this case the + class should inherit from :class:`TensorProcessor` and implement the required methods. + """ + + params: dict[str, Any] = dict() + """The parameters to be passed to the function or callable class as keyword arguments. + + Defaults to an empty dictionary. + """ From 4d3c5e80593fb7e1884320ba0e9ad71186c127ad Mon Sep 17 00:00:00 2001 From: Matthew Taylor Date: Fri, 16 Oct 2026 19:48:32 -0400 Subject: [PATCH 02/20] Centralize modifier deprecation compatibility --- docs/source/api/lab/isaaclab.utils.rst | 3 +- .../dict-tensor-processors.deprecated.rst | 2 +- .../isaaclab/managers/manager_term_cfg.py | 8 +--- .../isaaclab/utils/modifiers/__init__.py | 41 ++++++++++++++-- .../isaaclab/utils/modifiers/__init__.pyi | 20 -------- .../isaaclab/utils/modifiers/modifier.py | 10 ---- .../isaaclab/utils/modifiers/modifier_base.py | 10 ---- .../isaaclab/utils/modifiers/modifier_cfg.py | 11 ----- .../test/managers/test_observation_manager.py | 47 +++++++++++++++++-- .../dict-tensor-processors.deprecated.rst | 2 +- .../utils/modifiers/__init__.py | 13 ++--- .../utils/modifiers/modifier.py | 10 ---- .../utils/modifiers/modifier_base.py | 10 ---- .../utils/modifiers/modifier_cfg.py | 10 ---- 14 files changed, 89 insertions(+), 108 deletions(-) delete mode 100644 source/isaaclab/isaaclab/utils/modifiers/__init__.pyi delete mode 100644 source/isaaclab/isaaclab/utils/modifiers/modifier.py delete mode 100644 source/isaaclab/isaaclab/utils/modifiers/modifier_base.py delete mode 100644 source/isaaclab/isaaclab/utils/modifiers/modifier_cfg.py delete mode 100644 source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/modifier.py delete mode 100644 source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/modifier_base.py delete mode 100644 source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/modifier_cfg.py diff --git a/docs/source/api/lab/isaaclab.utils.rst b/docs/source/api/lab/isaaclab.utils.rst index 53b28dc39848..a629718994e5 100644 --- a/docs/source/api/lab/isaaclab.utils.rst +++ b/docs/source/api/lab/isaaclab.utils.rst @@ -140,7 +140,8 @@ Migration: import ``TensorProcessor`` and ``TensorProcessorCfg`` from ``isaaclab.utils.tensor_processors`` in place of ``ModifierBase`` and ``ModifierCfg`` from ``isaaclab.utils.modifiers``. Replace ``ObservationTermCfg.modifiers`` with ``ObservationTermCfg.tensor_processors``. The processing order and tensor shapes are unchanged. -The old imports and configuration field remain available with a deprecation warning; do not set both fields. +The old imports and configuration field remain available, with one shared deprecation warning on first use. +Do not set both fields. .. automodule:: isaaclab.utils.tensor_processors :members: diff --git a/source/isaaclab/changelog.d/dict-tensor-processors.deprecated.rst b/source/isaaclab/changelog.d/dict-tensor-processors.deprecated.rst index f16a74eb0c71..7f1b575ebdba 100644 --- a/source/isaaclab/changelog.d/dict-tensor-processors.deprecated.rst +++ b/source/isaaclab/changelog.d/dict-tensor-processors.deprecated.rst @@ -1,4 +1,4 @@ * Deprecated ``isaaclab.utils.modifiers``, ``ModifierBase``, and ``ModifierCfg`` in favor of ``isaaclab.utils.tensor_processors``, ``TensorProcessor``, and ``TensorProcessorCfg``. Replace ``ObservationTermCfg.modifiers`` with ``ObservationTermCfg.tensor_processors``; legacy imports - and configuration remain supported during migration. + and configuration remain supported with one shared warning on first use. diff --git a/source/isaaclab/isaaclab/managers/manager_term_cfg.py b/source/isaaclab/isaaclab/managers/manager_term_cfg.py index 0bca2197f210..c535c4a37011 100644 --- a/source/isaaclab/isaaclab/managers/manager_term_cfg.py +++ b/source/isaaclab/isaaclab/managers/manager_term_cfg.py @@ -7,9 +7,9 @@ from __future__ import annotations -import warnings from collections.abc import Callable from dataclasses import MISSING +from importlib import import_module from typing import TYPE_CHECKING, Any, Literal import torch @@ -243,11 +243,7 @@ def _migrate_modifiers(self) -> None: return if self.tensor_processors is not None: raise ValueError("Set only one of 'modifiers' and 'tensor_processors'.") - warnings.warn( - "ObservationTermCfg.modifiers is deprecated. Use tensor_processors instead.", - FutureWarning, - stacklevel=5, - ) + import_module("isaaclab.utils.modifiers") self.tensor_processors = self.modifiers self.modifiers = None diff --git a/source/isaaclab/isaaclab/utils/modifiers/__init__.py b/source/isaaclab/isaaclab/utils/modifiers/__init__.py index 62822d018588..f40b216c2d21 100644 --- a/source/isaaclab/isaaclab/utils/modifiers/__init__.py +++ b/source/isaaclab/isaaclab/utils/modifiers/__init__.py @@ -5,14 +5,47 @@ """Deprecated import path for tensor processors.""" +import sys import warnings +from types import ModuleType + +from .. import tensor_processors + +__all__ = [ + "ModifierCfg", + "DigitalFilterCfg", + "IntegratorCfg", + "ModifierBase", + "DigitalFilter", + "Integrator", + "bias", + "clip", + "scale", +] + + +def _install_aliases(module_name: str, processors: ModuleType) -> None: + """Preserve legacy package and submodule imports without loading processor implementations.""" + module = sys.modules[module_name] + renamed = {"ModifierBase": "TensorProcessor", "ModifierCfg": "TensorProcessorCfg"} + + def __getattr__(name: str): + if name not in module.__all__: + raise AttributeError(f"module {module_name!r} has no attribute {name!r}") + return getattr(processors, renamed.get(name, name)) + + module.__getattr__ = __getattr__ + module.__dir__ = lambda: sorted(set(module.__dict__) | set(module.__all__)) + for name in ("modifier", "modifier_base", "modifier_cfg"): + sys.modules[f"{module_name}.{name}"] = module + setattr(module, name, module) -from ..module import lazy_export warnings.warn( - "isaaclab.utils.modifiers is deprecated. Use isaaclab.utils.tensor_processors instead.", - DeprecationWarning, + "isaaclab.utils.modifiers, isaaclab_experimental.utils.modifiers, and ObservationTermCfg.modifiers are deprecated." + " Use the corresponding tensor_processors APIs instead.", + FutureWarning, stacklevel=2, ) -lazy_export() +_install_aliases(__name__, tensor_processors) diff --git a/source/isaaclab/isaaclab/utils/modifiers/__init__.pyi b/source/isaaclab/isaaclab/utils/modifiers/__init__.pyi deleted file mode 100644 index e15f1284085c..000000000000 --- a/source/isaaclab/isaaclab/utils/modifiers/__init__.pyi +++ /dev/null @@ -1,20 +0,0 @@ -# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). -# All rights reserved. -# -# SPDX-License-Identifier: BSD-3-Clause - -__all__ = [ - "ModifierCfg", - "DigitalFilterCfg", - "IntegratorCfg", - "ModifierBase", - "DigitalFilter", - "Integrator", - "bias", - "clip", - "scale", -] - -from .modifier_cfg import ModifierCfg, DigitalFilterCfg, IntegratorCfg -from .modifier_base import ModifierBase -from .modifier import DigitalFilter, Integrator, bias, clip, scale diff --git a/source/isaaclab/isaaclab/utils/modifiers/modifier.py b/source/isaaclab/isaaclab/utils/modifiers/modifier.py deleted file mode 100644 index 2b5c4f3be4c1..000000000000 --- a/source/isaaclab/isaaclab/utils/modifiers/modifier.py +++ /dev/null @@ -1,10 +0,0 @@ -# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). -# All rights reserved. -# -# SPDX-License-Identifier: BSD-3-Clause - -"""Deprecated import path for tensor processor implementations.""" - -from ..tensor_processors.processors import DigitalFilter, Integrator, bias, clip, scale - -__all__ = ["DigitalFilter", "Integrator", "bias", "clip", "scale"] diff --git a/source/isaaclab/isaaclab/utils/modifiers/modifier_base.py b/source/isaaclab/isaaclab/utils/modifiers/modifier_base.py deleted file mode 100644 index fbbba711aa1c..000000000000 --- a/source/isaaclab/isaaclab/utils/modifiers/modifier_base.py +++ /dev/null @@ -1,10 +0,0 @@ -# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). -# All rights reserved. -# -# SPDX-License-Identifier: BSD-3-Clause - -"""Deprecated name for the single-tensor processor interface.""" - -from ..tensor_processors.tensor_processor import TensorProcessor as ModifierBase - -__all__ = ["ModifierBase"] diff --git a/source/isaaclab/isaaclab/utils/modifiers/modifier_cfg.py b/source/isaaclab/isaaclab/utils/modifiers/modifier_cfg.py deleted file mode 100644 index 53ef8764dc6c..000000000000 --- a/source/isaaclab/isaaclab/utils/modifiers/modifier_cfg.py +++ /dev/null @@ -1,11 +0,0 @@ -# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). -# All rights reserved. -# -# SPDX-License-Identifier: BSD-3-Clause - -"""Deprecated import path for tensor processor configurations.""" - -from ..tensor_processors.tensor_processor_cfg import DigitalFilterCfg, IntegratorCfg -from ..tensor_processors.tensor_processor_cfg import TensorProcessorCfg as ModifierCfg - -__all__ = ["ModifierCfg", "DigitalFilterCfg", "IntegratorCfg"] diff --git a/source/isaaclab/test/managers/test_observation_manager.py b/source/isaaclab/test/managers/test_observation_manager.py index 96723979eb06..46014423e6e5 100644 --- a/source/isaaclab/test/managers/test_observation_manager.py +++ b/source/isaaclab/test/managers/test_observation_manager.py @@ -8,10 +8,12 @@ # needed to import for allowing type-hinting: torch.Tensor | None from __future__ import annotations +import subprocess +import sys + # ignore private usage of variables warning # pyright: reportPrivateUsage=none from collections import namedtuple -from contextlib import nullcontext from importlib import import_module from typing import TYPE_CHECKING, cast @@ -896,13 +898,50 @@ def test_stateless_tensor_processor_cfg_roundtrip_preserves_signature_validation update_from_dict(cfg, to_dict(cfg)) env = DummyEnv() - warning = pytest.warns(FutureWarning, match="ObservationTermCfg.modifiers") if api != "current" else nullcontext() - with warning: - manager = manager_type(cfg, cast("ManagerBasedEnv", env)) + manager = manager_type(cfg, cast("ManagerBasedEnv", env)) observations = manager.compute()["policy"] torch.testing.assert_close(observations, env.observation + 2.0) +def test_legacy_modifiers_share_one_migration_warning(): + """Legacy fields and imports share one warning, regardless of repeated manager construction.""" + # A fresh interpreter keeps the first-use warning independent of test collection and ordering. + script = """ +import warnings +from importlib import import_module +from types import SimpleNamespace + +with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + import torch + from isaaclab.managers import ObservationGroupCfg, ObservationManager, ObservationTermCfg + from isaaclab.utils.tensor_processors import TensorProcessorCfg, bias + + env = SimpleNamespace(num_envs=1, device="cpu", sim=SimpleNamespace(is_playing=lambda: True)) + group = ObservationGroupCfg() + group.value = ObservationTermCfg( + func=lambda env: torch.ones((1, 1)), + tensor_processors=[TensorProcessorCfg(func=bias, params={"value": 2.0})], + ) + ObservationManager({"policy": group}, env).compute() + assert not [warning for warning in caught if "modifiers" in str(warning.message)] + + group.value.modifiers = group.value.tensor_processors + group.value.tensor_processors = None + for _ in range(2): + result = ObservationManager({"policy": group}, env).compute()["policy"] + torch.testing.assert_close(result, torch.full((1, 1), 3.0)) + for package in ("isaaclab", "isaaclab_experimental"): + import_module(f"{package}.utils.modifiers.modifier_cfg") + + migration_warnings = [warning for warning in caught if "modifiers" in str(warning.message)] + assert len(migration_warnings) == 1, migration_warnings + assert migration_warnings[0].category is FutureWarning +""" + result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, timeout=60) + assert result.returncode == 0, result.stderr + + def test_observation_rejects_conflicting_processor_fields(): """The old and new field names cannot silently override one another, including empty lists.""" cfg = HistoryObservationsCfg() diff --git a/source/isaaclab_experimental/changelog.d/dict-tensor-processors.deprecated.rst b/source/isaaclab_experimental/changelog.d/dict-tensor-processors.deprecated.rst index 98f2866a8ade..dfd8bcfcb8ae 100644 --- a/source/isaaclab_experimental/changelog.d/dict-tensor-processors.deprecated.rst +++ b/source/isaaclab_experimental/changelog.d/dict-tensor-processors.deprecated.rst @@ -1,3 +1,3 @@ * Deprecated the modifiers API in favor of ``tensor_processors``, ``TensorProcessor``, and ``TensorProcessorCfg``. Use ``ObservationTermCfg.tensor_processors`` for observation processing; - legacy imports and configuration remain supported during migration. + legacy imports and configuration remain supported with the shared core migration warning on first use. diff --git a/source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/__init__.py b/source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/__init__.py index 6eafd8bcdb8a..ff0bcab1ed4c 100644 --- a/source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/__init__.py +++ b/source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/__init__.py @@ -5,17 +5,10 @@ """Deprecated import path for Warp-native tensor processors.""" -import warnings +from isaaclab.utils.modifiers import _install_aliases -from .modifier import bias, clip, scale -from .modifier_base import ModifierBase -from .modifier_cfg import ModifierCfg +from .. import tensor_processors __all__ = ["ModifierBase", "ModifierCfg", "bias", "clip", "scale"] -warnings.warn( - "isaaclab_experimental.utils.modifiers is deprecated." - " Use isaaclab_experimental.utils.tensor_processors instead.", - DeprecationWarning, - stacklevel=2, -) +_install_aliases(__name__, tensor_processors) diff --git a/source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/modifier.py b/source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/modifier.py deleted file mode 100644 index f57c56f8ea2a..000000000000 --- a/source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/modifier.py +++ /dev/null @@ -1,10 +0,0 @@ -# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). -# All rights reserved. -# -# SPDX-License-Identifier: BSD-3-Clause - -"""Deprecated import path for Warp-native tensor processor implementations.""" - -from ..tensor_processors.processors import bias, clip, scale - -__all__ = ["bias", "clip", "scale"] diff --git a/source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/modifier_base.py b/source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/modifier_base.py deleted file mode 100644 index fae9dbd4bb52..000000000000 --- a/source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/modifier_base.py +++ /dev/null @@ -1,10 +0,0 @@ -# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). -# All rights reserved. -# -# SPDX-License-Identifier: BSD-3-Clause - -"""Deprecated name for the Warp-native tensor processor interface.""" - -from ..tensor_processors.tensor_processor import TensorProcessor as ModifierBase - -__all__ = ["ModifierBase"] diff --git a/source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/modifier_cfg.py b/source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/modifier_cfg.py deleted file mode 100644 index ab7bc02fbc75..000000000000 --- a/source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/modifier_cfg.py +++ /dev/null @@ -1,10 +0,0 @@ -# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). -# All rights reserved. -# -# SPDX-License-Identifier: BSD-3-Clause - -"""Deprecated name for Warp-native tensor processor configurations.""" - -from ..tensor_processors.tensor_processor_cfg import TensorProcessorCfg as ModifierCfg - -__all__ = ["ModifierCfg"] From 93aa16951bb8e51985063609bf90d4aaab73e696 Mon Sep 17 00:00:00 2001 From: Matthew Taylor Date: Mon, 19 Oct 2026 15:01:26 -0400 Subject: [PATCH 03/20] Replace modifier aliases with migration errors --- docs/source/api/lab/isaaclab.utils.rst | 3 +- .../dict-tensor-processors.deprecated.rst | 4 - ...ors.minor => dict-tensor-processors.major} | 0 .../dict-tensor-processors.removed.rst | 3 + .../isaaclab/managers/manager_term_cfg.py | 17 +-- .../isaaclab/managers/observation_manager.py | 1 - source/isaaclab/isaaclab/utils/__init__.py | 5 + source/isaaclab/isaaclab/utils/__init__.pyi | 3 - .../isaaclab/utils/modifiers/__init__.py | 48 +------- .../test/managers/test_observation_manager.py | 104 ++++++------------ .../dict-tensor-processors.deprecated.rst | 3 - ...ors.minor => dict-tensor-processors.major} | 0 .../dict-tensor-processors.removed.rst | 2 + .../managers/observation_manager.py | 2 +- .../isaaclab_experimental/utils/__init__.pyi | 3 +- .../utils/modifiers/__init__.py | 13 +-- 16 files changed, 58 insertions(+), 153 deletions(-) delete mode 100644 source/isaaclab/changelog.d/dict-tensor-processors.deprecated.rst rename source/isaaclab/changelog.d/{dict-tensor-processors.minor => dict-tensor-processors.major} (100%) create mode 100644 source/isaaclab/changelog.d/dict-tensor-processors.removed.rst delete mode 100644 source/isaaclab_experimental/changelog.d/dict-tensor-processors.deprecated.rst rename source/isaaclab_experimental/changelog.d/{dict-tensor-processors.minor => dict-tensor-processors.major} (100%) create mode 100644 source/isaaclab_experimental/changelog.d/dict-tensor-processors.removed.rst diff --git a/docs/source/api/lab/isaaclab.utils.rst b/docs/source/api/lab/isaaclab.utils.rst index a629718994e5..ccd8a10e650f 100644 --- a/docs/source/api/lab/isaaclab.utils.rst +++ b/docs/source/api/lab/isaaclab.utils.rst @@ -140,8 +140,7 @@ Migration: import ``TensorProcessor`` and ``TensorProcessorCfg`` from ``isaaclab.utils.tensor_processors`` in place of ``ModifierBase`` and ``ModifierCfg`` from ``isaaclab.utils.modifiers``. Replace ``ObservationTermCfg.modifiers`` with ``ObservationTermCfg.tensor_processors``. The processing order and tensor shapes are unchanged. -The old imports and configuration field remain available, with one shared deprecation warning on first use. -Do not set both fields. +Old imports and non-None ``modifiers`` settings raise ``ValueError`` with migration instructions. .. automodule:: isaaclab.utils.tensor_processors :members: diff --git a/source/isaaclab/changelog.d/dict-tensor-processors.deprecated.rst b/source/isaaclab/changelog.d/dict-tensor-processors.deprecated.rst deleted file mode 100644 index 7f1b575ebdba..000000000000 --- a/source/isaaclab/changelog.d/dict-tensor-processors.deprecated.rst +++ /dev/null @@ -1,4 +0,0 @@ -* Deprecated ``isaaclab.utils.modifiers``, ``ModifierBase``, and ``ModifierCfg`` in favor of - ``isaaclab.utils.tensor_processors``, ``TensorProcessor``, and ``TensorProcessorCfg``. Replace - ``ObservationTermCfg.modifiers`` with ``ObservationTermCfg.tensor_processors``; legacy imports - and configuration remain supported with one shared warning on first use. diff --git a/source/isaaclab/changelog.d/dict-tensor-processors.minor b/source/isaaclab/changelog.d/dict-tensor-processors.major similarity index 100% rename from source/isaaclab/changelog.d/dict-tensor-processors.minor rename to source/isaaclab/changelog.d/dict-tensor-processors.major diff --git a/source/isaaclab/changelog.d/dict-tensor-processors.removed.rst b/source/isaaclab/changelog.d/dict-tensor-processors.removed.rst new file mode 100644 index 000000000000..ba15d8078587 --- /dev/null +++ b/source/isaaclab/changelog.d/dict-tensor-processors.removed.rst @@ -0,0 +1,3 @@ +* **Breaking:** Removed ``isaaclab.utils.modifiers``, ``ModifierBase``, and ``ModifierCfg`` in favor of + ``isaaclab.utils.tensor_processors``, ``TensorProcessor``, and ``TensorProcessorCfg``. Replace + ``ObservationTermCfg.modifiers`` with ``ObservationTermCfg.tensor_processors``. diff --git a/source/isaaclab/isaaclab/managers/manager_term_cfg.py b/source/isaaclab/isaaclab/managers/manager_term_cfg.py index c535c4a37011..8536a6793ea1 100644 --- a/source/isaaclab/isaaclab/managers/manager_term_cfg.py +++ b/source/isaaclab/isaaclab/managers/manager_term_cfg.py @@ -9,7 +9,6 @@ from collections.abc import Callable from dataclasses import MISSING -from importlib import import_module from typing import TYPE_CHECKING, Any, Literal import torch @@ -235,20 +234,12 @@ class ObservationTermCfg(ManagerTermBaseCfg): Defaults to True.""" modifiers: list[TensorProcessorCfg] | None = None - """Deprecated alias for :attr:`tensor_processors`; use only one of these fields.""" - - def _migrate_modifiers(self) -> None: - """Resolve the deprecated field after user configuration edits, before manager preparation.""" - if self.modifiers is None: - return - if self.tensor_processors is not None: - raise ValueError("Set only one of 'modifiers' and 'tensor_processors'.") - import_module("isaaclab.utils.modifiers") - self.tensor_processors = self.modifiers - self.modifiers = None + """Removed field, rejected during validation. Use :attr:`tensor_processors`.""" def validate_config(self): - """Validate observation delay bounds.""" + """Reject removed fields and validate observation delay bounds.""" + if self.modifiers is not None: + raise ValueError("ObservationTermCfg.modifiers was removed. Use 'tensor_processors' instead.") if type(self.delay_min_lag) is not int or type(self.delay_max_lag) is not int: raise TypeError("Observation delay bounds must be integers.") if not 0 <= self.delay_min_lag <= self.delay_max_lag: diff --git a/source/isaaclab/isaaclab/managers/observation_manager.py b/source/isaaclab/isaaclab/managers/observation_manager.py index 058484583e1e..9b024e4a266b 100644 --- a/source/isaaclab/isaaclab/managers/observation_manager.py +++ b/source/isaaclab/isaaclab/managers/observation_manager.py @@ -642,7 +642,6 @@ def _prepare_terms(self): f"Configuration for the term '{term_name}' is not of type ObservationTermCfg." f" Received: '{type(term_cfg)}'." ) - term_cfg._migrate_modifiers() term_cfg.validate_config() # resolve common terms in the config self._resolve_common_term_cfg(f"{group_name}/{term_name}", term_cfg, min_argc=1) diff --git a/source/isaaclab/isaaclab/utils/__init__.py b/source/isaaclab/isaaclab/utils/__init__.py index 5c210a0e10a9..02f583a6fe35 100644 --- a/source/isaaclab/isaaclab/utils/__init__.py +++ b/source/isaaclab/isaaclab/utils/__init__.py @@ -13,6 +13,11 @@ def __getattr__(name: str): + if name in {"ModifierBase", "ModifierCfg"}: + raise ValueError( + "ModifierBase and ModifierCfg were removed. Use TensorProcessor and TensorProcessorCfg" + " from isaaclab.utils.tensor_processors instead." + ) # ``configclass`` names both a sub-module of this package and the decorator that sub-module # defines, so ``isaaclab.utils.configclass`` can only ever be one of the two. Importing the # sub-module binds the module object here and shadows the lazily attached decorator, while diff --git a/source/isaaclab/isaaclab/utils/__init__.pyi b/source/isaaclab/isaaclab/utils/__init__.pyi index cbe85749f70a..99f7ef906c6b 100644 --- a/source/isaaclab/isaaclab/utils/__init__.pyi +++ b/source/isaaclab/isaaclab/utils/__init__.pyi @@ -31,8 +31,6 @@ __all__ = [ "create_trimesh_from_geom_shape", "convert_faces_to_triangles", "PRIMITIVE_MESH_TYPES", - "ModifierCfg", - "ModifierBase", "TensorProcessorCfg", "TensorProcessor", "DictTensorProcessor", @@ -91,7 +89,6 @@ from .mesh import ( convert_faces_to_triangles, PRIMITIVE_MESH_TYPES, ) -from .modifiers import ModifierCfg, ModifierBase from .tensor_processors import ( DictTensorProcessor, DictTensorProcessorCfg, diff --git a/source/isaaclab/isaaclab/utils/modifiers/__init__.py b/source/isaaclab/isaaclab/utils/modifiers/__init__.py index f40b216c2d21..1e0f291139e5 100644 --- a/source/isaaclab/isaaclab/utils/modifiers/__init__.py +++ b/source/isaaclab/isaaclab/utils/modifiers/__init__.py @@ -3,49 +3,9 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Deprecated import path for tensor processors.""" +"""Migration error for the removed modifiers API.""" -import sys -import warnings -from types import ModuleType - -from .. import tensor_processors - -__all__ = [ - "ModifierCfg", - "DigitalFilterCfg", - "IntegratorCfg", - "ModifierBase", - "DigitalFilter", - "Integrator", - "bias", - "clip", - "scale", -] - - -def _install_aliases(module_name: str, processors: ModuleType) -> None: - """Preserve legacy package and submodule imports without loading processor implementations.""" - module = sys.modules[module_name] - renamed = {"ModifierBase": "TensorProcessor", "ModifierCfg": "TensorProcessorCfg"} - - def __getattr__(name: str): - if name not in module.__all__: - raise AttributeError(f"module {module_name!r} has no attribute {name!r}") - return getattr(processors, renamed.get(name, name)) - - module.__getattr__ = __getattr__ - module.__dir__ = lambda: sorted(set(module.__dict__) | set(module.__all__)) - for name in ("modifier", "modifier_base", "modifier_cfg"): - sys.modules[f"{module_name}.{name}"] = module - setattr(module, name, module) - - -warnings.warn( - "isaaclab.utils.modifiers, isaaclab_experimental.utils.modifiers, and ObservationTermCfg.modifiers are deprecated." - " Use the corresponding tensor_processors APIs instead.", - FutureWarning, - stacklevel=2, +raise ValueError( + "isaaclab.utils.modifiers was removed. Use isaaclab.utils.tensor_processors instead:" + " ModifierBase is now TensorProcessor, and ModifierCfg is now TensorProcessorCfg." ) - -_install_aliases(__name__, tensor_processors) diff --git a/source/isaaclab/test/managers/test_observation_manager.py b/source/isaaclab/test/managers/test_observation_manager.py index 46014423e6e5..b88364bcd87d 100644 --- a/source/isaaclab/test/managers/test_observation_manager.py +++ b/source/isaaclab/test/managers/test_observation_manager.py @@ -8,9 +8,6 @@ # needed to import for allowing type-hinting: torch.Tensor | None from __future__ import annotations -import subprocess -import sys - # ignore private usage of variables warning # pyright: reportPrivateUsage=none from collections import namedtuple @@ -28,6 +25,7 @@ RewardTermCfg, ) from isaaclab.utils import DelayBuffer, configclass, noise, tensor_processors, to_dict, update_from_dict, validate +from isaaclab.utils.string import string_to_callable pytestmark = pytest.mark.unit @@ -870,85 +868,47 @@ def test_class_modifier_roundtrip_preserves_func_and_params(): assert prepared_tensor_processor_cfg.func.reset_count == 1 -def _warp_observation(env, out, out_dim=1): - out.assign(env.observation.numpy()) - - -@pytest.mark.parametrize("api", ["current", "legacy", "legacy_warp"]) -def test_stateless_tensor_processor_cfg_roundtrip_preserves_signature_validation(api): - """Current and legacy configs compute observations; core configs also retain serialized callables.""" +def test_stateless_tensor_processor_cfg_roundtrip_preserves_signature_validation(): + """Serialized function processors retain their parameters and compute observations.""" cfg = HistoryObservationsCfg() cfg.policy.history_length = None - manager_type = ObservationManager - if api == "current": - cfg.policy.dummy.tensor_processors = [ - tensor_processors.TensorProcessorCfg(func=tensor_processors.bias, params={"value": 2.0}) - ] - else: - package = "isaaclab_experimental" if api == "legacy_warp" else "isaaclab" - legacy_cfg = import_module(f"{package}.utils.modifiers.modifier_cfg").ModifierCfg - bias = f"{package}.utils.modifiers.modifier:bias" - if api == "legacy_warp": - manager_type = import_module(f"{package}.managers").ObservationManager - cfg.policy.dummy.func = _warp_observation - cfg.policy.dummy.params = {"out_dim": 1} - bias = import_module(f"{package}.utils.modifiers.modifier").bias - cfg.policy.dummy.modifiers = [legacy_cfg(func=bias, params={"value": 2.0})] - if api != "legacy_warp": - update_from_dict(cfg, to_dict(cfg)) + cfg.policy.dummy.tensor_processors = [ + tensor_processors.TensorProcessorCfg(func=tensor_processors.bias, params={"value": 2.0}) + ] + update_from_dict(cfg, to_dict(cfg)) env = DummyEnv() - manager = manager_type(cfg, cast("ManagerBasedEnv", env)) + manager = ObservationManager(cfg, cast("ManagerBasedEnv", env)) observations = manager.compute()["policy"] torch.testing.assert_close(observations, env.observation + 2.0) -def test_legacy_modifiers_share_one_migration_warning(): - """Legacy fields and imports share one warning, regardless of repeated manager construction.""" - # A fresh interpreter keeps the first-use warning independent of test collection and ordering. - script = """ -import warnings -from importlib import import_module -from types import SimpleNamespace - -with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") - import torch - from isaaclab.managers import ObservationGroupCfg, ObservationManager, ObservationTermCfg - from isaaclab.utils.tensor_processors import TensorProcessorCfg, bias - - env = SimpleNamespace(num_envs=1, device="cpu", sim=SimpleNamespace(is_playing=lambda: True)) - group = ObservationGroupCfg() - group.value = ObservationTermCfg( - func=lambda env: torch.ones((1, 1)), - tensor_processors=[TensorProcessorCfg(func=bias, params={"value": 2.0})], - ) - ObservationManager({"policy": group}, env).compute() - assert not [warning for warning in caught if "modifiers" in str(warning.message)] - - group.value.modifiers = group.value.tensor_processors - group.value.tensor_processors = None - for _ in range(2): - result = ObservationManager({"policy": group}, env).compute()["policy"] - torch.testing.assert_close(result, torch.full((1, 1), 3.0)) - for package in ("isaaclab", "isaaclab_experimental"): - import_module(f"{package}.utils.modifiers.modifier_cfg") - - migration_warnings = [warning for warning in caught if "modifiers" in str(warning.message)] - assert len(migration_warnings) == 1, migration_warnings - assert migration_warnings[0].category is FutureWarning -""" - result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, timeout=60) - assert result.returncode == 0, result.stderr - - -def test_observation_rejects_conflicting_processor_fields(): - """The old and new field names cannot silently override one another, including empty lists.""" +@pytest.mark.parametrize( + "path", + [ + "isaaclab.utils:ModifierBase", + "isaaclab.utils.modifiers:ModifierCfg", + "isaaclab_experimental.utils.modifiers.modifier_base:ModifierBase", + ], +) +def test_removed_modifier_imports_report_replacements(path): + """Old class imports and serialized paths fail with the replacement API.""" + with pytest.raises(ValueError, match="TensorProcessor.*TensorProcessorCfg"): + string_to_callable(path) + + +@pytest.mark.parametrize("package", ["isaaclab", "isaaclab_experimental"]) +def test_observation_rejects_removed_modifiers(package): + """Both managers reject the removed field, including edits that set an empty list.""" cfg = HistoryObservationsCfg() + cfg.policy.history_length = None cfg.policy.dummy.modifiers = [] - cfg.policy.dummy.tensor_processors = [] - with pytest.raises(ValueError, match="Set only one"): - ObservationManager(cfg, cast("ManagerBasedEnv", DummyEnv())) + if package == "isaaclab_experimental": + cfg.policy.dummy.func = lambda env, out, out_dim: out.zero_() + cfg.policy.dummy.params = {"out_dim": 1} + manager_type = import_module(f"{package}.managers").ObservationManager + with pytest.raises(ValueError, match="modifiers.*tensor_processors"): + manager_type(cfg, cast("ManagerBasedEnv", DummyEnv())) def test_class_modifier_validates_constructed_instance(): diff --git a/source/isaaclab_experimental/changelog.d/dict-tensor-processors.deprecated.rst b/source/isaaclab_experimental/changelog.d/dict-tensor-processors.deprecated.rst deleted file mode 100644 index dfd8bcfcb8ae..000000000000 --- a/source/isaaclab_experimental/changelog.d/dict-tensor-processors.deprecated.rst +++ /dev/null @@ -1,3 +0,0 @@ -* Deprecated the modifiers API in favor of ``tensor_processors``, ``TensorProcessor``, and - ``TensorProcessorCfg``. Use ``ObservationTermCfg.tensor_processors`` for observation processing; - legacy imports and configuration remain supported with the shared core migration warning on first use. diff --git a/source/isaaclab_experimental/changelog.d/dict-tensor-processors.minor b/source/isaaclab_experimental/changelog.d/dict-tensor-processors.major similarity index 100% rename from source/isaaclab_experimental/changelog.d/dict-tensor-processors.minor rename to source/isaaclab_experimental/changelog.d/dict-tensor-processors.major diff --git a/source/isaaclab_experimental/changelog.d/dict-tensor-processors.removed.rst b/source/isaaclab_experimental/changelog.d/dict-tensor-processors.removed.rst new file mode 100644 index 000000000000..f918a81d751a --- /dev/null +++ b/source/isaaclab_experimental/changelog.d/dict-tensor-processors.removed.rst @@ -0,0 +1,2 @@ +* **Breaking:** Removed the modifiers API in favor of ``tensor_processors``, ``TensorProcessor``, and + ``TensorProcessorCfg``. Use ``ObservationTermCfg.tensor_processors`` for observation processing. diff --git a/source/isaaclab_experimental/isaaclab_experimental/managers/observation_manager.py b/source/isaaclab_experimental/isaaclab_experimental/managers/observation_manager.py index 7448c924fe68..a7ddb7024890 100644 --- a/source/isaaclab_experimental/isaaclab_experimental/managers/observation_manager.py +++ b/source/isaaclab_experimental/isaaclab_experimental/managers/observation_manager.py @@ -682,7 +682,7 @@ def _prepare_terms(self): # noqa: C901 f"Configuration for the term '{term_name}' is not of type ObservationTermCfg." f" Received: '{type(term_cfg)}'." ) - term_cfg._migrate_modifiers() + term_cfg.validate_config() # resolve common terms in the config # Warp-first signature is (env, out, **params) self._resolve_common_term_cfg(f"{group_name}/{term_name}", term_cfg, min_argc=2) diff --git a/source/isaaclab_experimental/isaaclab_experimental/utils/__init__.pyi b/source/isaaclab_experimental/isaaclab_experimental/utils/__init__.pyi index 8211d4785061..f4aaa2e64681 100644 --- a/source/isaaclab_experimental/isaaclab_experimental/utils/__init__.pyi +++ b/source/isaaclab_experimental/isaaclab_experimental/utils/__init__.pyi @@ -9,7 +9,6 @@ __all__ = [ "WarpGraphCache", "clone_obs_buffer", "buffers", - "modifiers", "tensor_processors", "noise", "warp", @@ -18,4 +17,4 @@ __all__ = [ from .manager_call_switch import ManagerCallMode, ManagerCallSwitch from .torch_utils import clone_obs_buffer from .warp_graph_cache import WarpGraphCache -from . import buffers, modifiers, tensor_processors, noise, warp +from . import buffers, tensor_processors, noise, warp diff --git a/source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/__init__.py b/source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/__init__.py index ff0bcab1ed4c..27bd26d4aa8f 100644 --- a/source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/__init__.py +++ b/source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/__init__.py @@ -3,12 +3,9 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Deprecated import path for Warp-native tensor processors.""" +"""Migration error for the removed Warp-native modifiers API.""" -from isaaclab.utils.modifiers import _install_aliases - -from .. import tensor_processors - -__all__ = ["ModifierBase", "ModifierCfg", "bias", "clip", "scale"] - -_install_aliases(__name__, tensor_processors) +raise ValueError( + "isaaclab_experimental.utils.modifiers was removed. Use isaaclab_experimental.utils.tensor_processors instead:" + " ModifierBase is now TensorProcessor, and ModifierCfg is now TensorProcessorCfg." +) From 51ecf64b3b07bc85b10f099e39b41cbd265d93d3 Mon Sep 17 00:00:00 2001 From: Matthew Taylor Date: Mon, 19 Oct 2026 15:08:29 -0400 Subject: [PATCH 04/20] Remove the legacy observation modifiers field --- docs/source/api/lab/isaaclab.utils.rst | 2 +- .../isaaclab/managers/manager_term_cfg.py | 7 +------ .../test/managers/test_observation_manager.py | 15 --------------- .../managers/observation_manager.py | 1 - 4 files changed, 2 insertions(+), 23 deletions(-) diff --git a/docs/source/api/lab/isaaclab.utils.rst b/docs/source/api/lab/isaaclab.utils.rst index ccd8a10e650f..08f62cb2dd55 100644 --- a/docs/source/api/lab/isaaclab.utils.rst +++ b/docs/source/api/lab/isaaclab.utils.rst @@ -140,7 +140,7 @@ Migration: import ``TensorProcessor`` and ``TensorProcessorCfg`` from ``isaaclab.utils.tensor_processors`` in place of ``ModifierBase`` and ``ModifierCfg`` from ``isaaclab.utils.modifiers``. Replace ``ObservationTermCfg.modifiers`` with ``ObservationTermCfg.tensor_processors``. The processing order and tensor shapes are unchanged. -Old imports and non-None ``modifiers`` settings raise ``ValueError`` with migration instructions. +Old imports raise ``ValueError`` with migration instructions. .. automodule:: isaaclab.utils.tensor_processors :members: diff --git a/source/isaaclab/isaaclab/managers/manager_term_cfg.py b/source/isaaclab/isaaclab/managers/manager_term_cfg.py index 8536a6793ea1..985339b64e8a 100644 --- a/source/isaaclab/isaaclab/managers/manager_term_cfg.py +++ b/source/isaaclab/isaaclab/managers/manager_term_cfg.py @@ -233,13 +233,8 @@ class ObservationTermCfg(ManagerTermBaseCfg): """Whether or not the observation manager should flatten history-based observation terms to a 2-D (N, D) tensor. Defaults to True.""" - modifiers: list[TensorProcessorCfg] | None = None - """Removed field, rejected during validation. Use :attr:`tensor_processors`.""" - def validate_config(self): - """Reject removed fields and validate observation delay bounds.""" - if self.modifiers is not None: - raise ValueError("ObservationTermCfg.modifiers was removed. Use 'tensor_processors' instead.") + """Validate observation delay bounds.""" if type(self.delay_min_lag) is not int or type(self.delay_max_lag) is not int: raise TypeError("Observation delay bounds must be integers.") if not 0 <= self.delay_min_lag <= self.delay_max_lag: diff --git a/source/isaaclab/test/managers/test_observation_manager.py b/source/isaaclab/test/managers/test_observation_manager.py index b88364bcd87d..4c7dd57efa1d 100644 --- a/source/isaaclab/test/managers/test_observation_manager.py +++ b/source/isaaclab/test/managers/test_observation_manager.py @@ -11,7 +11,6 @@ # ignore private usage of variables warning # pyright: reportPrivateUsage=none from collections import namedtuple -from importlib import import_module from typing import TYPE_CHECKING, cast import pytest @@ -897,20 +896,6 @@ def test_removed_modifier_imports_report_replacements(path): string_to_callable(path) -@pytest.mark.parametrize("package", ["isaaclab", "isaaclab_experimental"]) -def test_observation_rejects_removed_modifiers(package): - """Both managers reject the removed field, including edits that set an empty list.""" - cfg = HistoryObservationsCfg() - cfg.policy.history_length = None - cfg.policy.dummy.modifiers = [] - if package == "isaaclab_experimental": - cfg.policy.dummy.func = lambda env, out, out_dim: out.zero_() - cfg.policy.dummy.params = {"out_dim": 1} - manager_type = import_module(f"{package}.managers").ObservationManager - with pytest.raises(ValueError, match="modifiers.*tensor_processors"): - manager_type(cfg, cast("ManagerBasedEnv", DummyEnv())) - - def test_class_modifier_validates_constructed_instance(): """Class modifier validation checks the constructed object.""" cfg = HistoryObservationsCfg() diff --git a/source/isaaclab_experimental/isaaclab_experimental/managers/observation_manager.py b/source/isaaclab_experimental/isaaclab_experimental/managers/observation_manager.py index a7ddb7024890..5fed28b70c63 100644 --- a/source/isaaclab_experimental/isaaclab_experimental/managers/observation_manager.py +++ b/source/isaaclab_experimental/isaaclab_experimental/managers/observation_manager.py @@ -682,7 +682,6 @@ def _prepare_terms(self): # noqa: C901 f"Configuration for the term '{term_name}' is not of type ObservationTermCfg." f" Received: '{type(term_cfg)}'." ) - term_cfg.validate_config() # resolve common terms in the config # Warp-first signature is (env, out, **params) self._resolve_common_term_cfg(f"{group_name}/{term_name}", term_cfg, min_argc=2) From 72f3f191e756632053fd497aa1601531fd7d5711 Mon Sep 17 00:00:00 2001 From: Matthew Taylor Date: Mon, 19 Oct 2026 15:31:15 -0400 Subject: [PATCH 05/20] Integrate dictionary processors with observation groups --- docs/source/api/lab/isaaclab.managers.rst | 31 +++++ docs/source/api/lab/isaaclab.utils.rst | 15 +-- .../observation-dictionary-pipeline.added.rst | 2 + .../observation-dictionary-pipeline.minor | 0 .../isaaclab/envs/manager_based_env.py | 1 + .../isaaclab/envs/manager_based_rl_env.py | 9 ++ .../isaaclab/managers/manager_term_cfg.py | 12 +- .../isaaclab/managers/observation_manager.py | 94 ++++++++++++++- .../dict_tensor_processor_cfg.py | 7 +- .../test/managers/test_observation_manager.py | 111 ++++++++++++++++++ ...bservation-dictionary-pipeline.changed.rst | 1 + .../managers/observation_manager.py | 3 + 12 files changed, 265 insertions(+), 21 deletions(-) create mode 100644 source/isaaclab/changelog.d/observation-dictionary-pipeline.added.rst create mode 100644 source/isaaclab/changelog.d/observation-dictionary-pipeline.minor create mode 100644 source/isaaclab_experimental/changelog.d/observation-dictionary-pipeline.changed.rst diff --git a/docs/source/api/lab/isaaclab.managers.rst b/docs/source/api/lab/isaaclab.managers.rst index b4cd0fc7dfcc..2ac991fc4c45 100644 --- a/docs/source/api/lab/isaaclab.managers.rst +++ b/docs/source/api/lab/isaaclab.managers.rst @@ -140,6 +140,37 @@ second full-image copy for normalized uint8 images. Existing observation configu :inherited-members: :show-inheritance: +Dictionary observation processing +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Use :attr:`ObservationGroupCfg.pipeline` to combine or transform named observation terms. +Set ``concatenate_terms=False``. Each processor runs after the terms' processing, delay, and +history, and can read outputs from preceding processors. Outputs add or replace dictionary +entries; untouched terms remain available. + +For example, given a ``FuseSensors`` subclass of +:class:`~isaaclab.utils.tensor_processors.DictTensorProcessor`, configure a group with terms +named ``position`` and ``velocity`` as follows: + +.. code-block:: python + + from isaaclab.utils.tensor_processors import DictTensorProcessorCfg + + sensor_group.concatenate_terms = False + sensor_group.pipeline = [ + DictTensorProcessorCfg( + class_type="my_project.processors:FuseSensors", + inputs=["position", "velocity"], + outputs=["features"], + ), + ] + +The returned dictionary contains ``position``, ``velocity``, and ``features``. Processors run +on every observation computation, including calls that do not advance history. Resetting the +manager resets processor state for the selected environments. Returned observations remain +valid across later computations. This option is supported by the standard observation manager; +the experimental Warp observation manager does not support dictionary pipelines. + .. autoclass:: ObservationGroupCfg :members: :exclude-members: __init__ diff --git a/docs/source/api/lab/isaaclab.utils.rst b/docs/source/api/lab/isaaclab.utils.rst index 08f62cb2dd55..6c5db4cfd92a 100644 --- a/docs/source/api/lab/isaaclab.utils.rst +++ b/docs/source/api/lab/isaaclab.utils.rst @@ -127,20 +127,15 @@ Mesh operations Tensor processors ~~~~~~~~~~~~~~~~~ -The processor interfaces live together in :mod:`isaaclab.utils.tensor_processors`: +Use :mod:`isaaclab.utils.tensor_processors` to transform observations: * ``TensorProcessor``: tensor to tensor. * ``DictTensorProcessor``: dictionary of named tensors to dictionary of named tensors. -Dictionary tensors use ``ProxyArray`` for Warp and Torch access without copying. Processors own -any persistent outputs and state. ``DictTensorProcessorCfg`` declares input/output keys so consumers -can resolve dependencies before allocating buffers. Composition preserves untouched keys. - -Migration: import ``TensorProcessor`` and ``TensorProcessorCfg`` from -``isaaclab.utils.tensor_processors`` in place of ``ModifierBase`` and ``ModifierCfg`` from -``isaaclab.utils.modifiers``. Replace ``ObservationTermCfg.modifiers`` with -``ObservationTermCfg.tensor_processors``. The processing order and tensor shapes are unchanged. -Old imports raise ``ValueError`` with migration instructions. +Configure single-tensor processors with :attr:`~isaaclab.managers.ObservationTermCfg.tensor_processors`. +Configure dictionary processors with :attr:`~isaaclab.managers.ObservationGroupCfg.pipeline`. +Each dictionary processor declares the input keys it needs and the output keys it adds or replaces. +Other keys remain available to subsequent processors. .. automodule:: isaaclab.utils.tensor_processors :members: diff --git a/source/isaaclab/changelog.d/observation-dictionary-pipeline.added.rst b/source/isaaclab/changelog.d/observation-dictionary-pipeline.added.rst new file mode 100644 index 000000000000..564ab77896e9 --- /dev/null +++ b/source/isaaclab/changelog.d/observation-dictionary-pipeline.added.rst @@ -0,0 +1,2 @@ +* Added dictionary tensor processing to observation groups through ``ObservationGroupCfg.pipeline``, + including named outputs, partial resets, resource cleanup, and matching Gym observation spaces. diff --git a/source/isaaclab/changelog.d/observation-dictionary-pipeline.minor b/source/isaaclab/changelog.d/observation-dictionary-pipeline.minor new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab/isaaclab/envs/manager_based_env.py b/source/isaaclab/isaaclab/envs/manager_based_env.py index f4026af3bd43..2818134181e7 100644 --- a/source/isaaclab/isaaclab/envs/manager_based_env.py +++ b/source/isaaclab/isaaclab/envs/manager_based_env.py @@ -609,6 +609,7 @@ def close(self): # destructor is order-sensitive del self.action_manager + self.observation_manager.close() del self.observation_manager del self.event_manager del self.recorder_manager diff --git a/source/isaaclab/isaaclab/envs/manager_based_rl_env.py b/source/isaaclab/isaaclab/envs/manager_based_rl_env.py index 859cb837cd5b..d6c0ef9752e3 100644 --- a/source/isaaclab/isaaclab/envs/manager_based_rl_env.py +++ b/source/isaaclab/isaaclab/envs/manager_based_rl_env.py @@ -364,6 +364,15 @@ def _configure_gym_env_spaces(self): if has_concatenated_obs: self.single_observation_space[group_name] = gym.spaces.Box(low=-np.inf, high=np.inf, shape=group_dim) else: + pipeline = self.observation_manager._group_pipelines.get(group_name) + if pipeline is not None: + self.single_observation_space[group_name] = gym.spaces.Dict( + { + name: gym.spaces.Box(low=-np.inf, high=np.inf, shape=shape) + for name, shape in zip(pipeline.outputs, group_dim) + } + ) + continue group_term_cfgs = self.observation_manager._group_obs_term_cfgs[group_name] term_dict = {} for term_name, term_dim, term_cfg in zip(group_term_names, group_dim, group_term_cfgs): diff --git a/source/isaaclab/isaaclab/managers/manager_term_cfg.py b/source/isaaclab/isaaclab/managers/manager_term_cfg.py index 985339b64e8a..b485f585e2c0 100644 --- a/source/isaaclab/isaaclab/managers/manager_term_cfg.py +++ b/source/isaaclab/isaaclab/managers/manager_term_cfg.py @@ -15,7 +15,7 @@ from ..utils import configclass from ..utils.noise import NoiseCfg, NoiseModelCfg -from ..utils.tensor_processors import TensorProcessorCfg +from ..utils.tensor_processors import DictTensorProcessorCfg, TensorProcessorCfg from .scene_entity_cfg import SceneEntityCfg if TYPE_CHECKING: @@ -247,6 +247,16 @@ def validate_config(self): class ObservationGroupCfg: """Configuration for an observation group.""" + pipeline: list[DictTensorProcessorCfg] = [] + """Dictionary processors applied in order after term processing, delay, and history. + + Inputs are observation term names or outputs from preceding processors. Outputs add or + replace named observations; untouched terms remain available. Requires + :attr:`concatenate_terms` to be False. Returned observations are independent snapshots. + Processors run on every observation computation and reset with the selected environments. + Supported by the standard observation manager, not the experimental Warp manager. + """ + concatenate_terms: bool = True """Whether to concatenate the observation terms in the group. Defaults to True. diff --git a/source/isaaclab/isaaclab/managers/observation_manager.py b/source/isaaclab/isaaclab/managers/observation_manager.py index 9b024e4a266b..f23b5903953d 100644 --- a/source/isaaclab/isaaclab/managers/observation_manager.py +++ b/source/isaaclab/isaaclab/managers/observation_manager.py @@ -8,16 +8,21 @@ from __future__ import annotations import inspect +from builtins import ExceptionGroup from collections.abc import Sequence +from contextlib import nullcontext from typing import TYPE_CHECKING import numpy as np import torch +import warp as wp from prettytable import PrettyTable from ..envs.utils.io_descriptors import _warn_io_descriptors_deprecated from ..utils import instantiate, noise, tensor_processors, to_dict from ..utils.buffers import CircularBuffer, DelayBuffer +from ..utils.tensor_processors._pipeline import _DictTensorProcessorPipeline +from ..utils.warp import ProxyArray from .manager_base import ManagerBase, ManagerTermBase from .manager_term_cfg import ObservationGroupCfg, ObservationTermCfg @@ -86,7 +91,15 @@ def __init__(self, cfg: object, env: ManagerBasedEnv): raise ValueError("Observation manager configuration is None. Please provide a valid configuration.") # call the base class constructor (this will parse the terms config) - super().__init__(cfg, env) + self._group_pipelines: dict[str, _DictTensorProcessorPipeline] = {} + try: + super().__init__(cfg, env) + except Exception as error: + try: + self.close() + except Exception as cleanup_error: + error.add_note(f"Failed to close observation pipelines after initialization failed: {cleanup_error}") + raise # compute combined vector for obs group self._group_obs_dim: dict[str, tuple[int, ...] | list[tuple[int, ...]]] = {} @@ -115,11 +128,34 @@ def __init__(self, cfg: object, env: ManagerBasedEnv): " Otherwise, set 'concatenate_terms' to False in the group configuration." ) else: - self._group_obs_dim[group_name] = group_term_dims + pipeline = self._group_pipelines.get(group_name) + self._group_obs_dim[group_name] = ( + [tuple(value.torch.shape[1:]) for value in pipeline.outputs.values()] + if pipeline is not None + else group_term_dims + ) # Stores the latest observations. self._obs_buffer: dict[str, torch.Tensor | dict[str, torch.Tensor]] | None = None + def close(self) -> None: + """Release dictionary processor resources. Repeated calls have no effect.""" + errors = [] + for pipeline in self._group_pipelines.values(): + try: + pipeline.close() + except Exception as error: + errors.append(error) + if errors: + raise ExceptionGroup("Failed to close observation pipelines.", errors) + + def __del__(self): + try: + if hasattr(self, "_group_pipelines"): + self.close() + finally: + super().__del__() + def __str__(self) -> str: """Returns: A string representation for the observation manager.""" msg = f" contains {len(self._group_obs_term_names)} groups.\n" @@ -210,7 +246,7 @@ def group_obs_dim(self) -> dict[str, tuple[int, ...] | list[tuple[int, ...]]]: The key is the group name and the value is the shape of the observation tensor. If the terms in the group are concatenated, the value is a single tuple representing the shape of the concatenated observation tensor. Otherwise, the value is a list of tuples, - where each tuple represents the shape of the observation tensor for a term in the group. + where each tuple represents an output in dictionary order, including pipeline outputs. """ return self._group_obs_dim @@ -341,6 +377,11 @@ def reset(self, env_ids: Sequence[int] | None = None) -> dict[str, float]: # call all tensor_processors that are classes for mod in self._group_obs_class_instances: mod.reset(env_ids=env_ids) + if self._group_pipelines: + stream = wp.ScopedStream(wp.stream_from_torch(self.device)) if "cuda" in self.device else nullcontext() + with stream: + for pipeline in self._group_pipelines.values(): + pipeline.reset(env_ids) # nothing to log here return {} @@ -389,6 +430,9 @@ def compute_group(self, group_name: str, update_history: bool = False) -> torch. 6. Apply delay based on :attr:`ObservationTermCfg.delay_min_lag` and :attr:`ObservationTermCfg.delay_max_lag` 7. Append the delayed observation to history if requested + The group's :attr:`ObservationGroupCfg.pipeline` then processes the named observations. + Outputs may replace existing terms or add new keys. Returned tensors are independent snapshots. + We apply noise to the computed term first to maintain the integrity of how noise affects the data as it truly exists in the real world. If the noise is applied after clipping or scaling, the noise could be artificially constrained or amplified, which might misrepresent how noise naturally occurs @@ -489,6 +533,17 @@ def compute_group(self, group_name: str, update_history: bool = False) -> torch. # Secure borrowed storage before another term can overwrite it, including shared scratch buffers. group_obs[term_name] = obs if owned else obs.clone() + pipeline = self._group_pipelines.get(group_name) + if pipeline is not None: + # Run Warp callbacks on the stream producing and consuming the Torch observations. + stream = wp.ScopedStream(wp.stream_from_torch(self.device)) if "cuda" in self.device else nullcontext() + with stream: + result = pipeline({name: ProxyArray(wp.from_torch(value)) for name, value in group_obs.items()}) + return { + name: value.torch.clone() if name in pipeline.produced_outputs else group_obs[name] + for name, value in result.items() + } + # concatenate all observations in the group together if self._group_obs_concatenate[group_name]: if len(group_obs) == 1: @@ -537,7 +592,7 @@ def _resolve_common_term_cfg(self, term_name: str, term_cfg: ObservationTermCfg, # The destination is supplied by the manager, not by the term configuration. term_cfg.params.pop("out", None) - def _prepare_term_output(self, group_name: str, term_name: str, term_cfg: ObservationTermCfg) -> tuple[int, ...]: + def _prepare_term_output(self, group_name: str, term_name: str, term_cfg: ObservationTermCfg) -> torch.Tensor: """Probe output dimensions and cache destination specifications for explicitly supported terms.""" out_param = inspect.signature(term_cfg.func).parameters.get("out") writes_output = out_param is not None and out_param.kind is inspect.Parameter.KEYWORD_ONLY @@ -547,7 +602,7 @@ def _prepare_term_output(self, group_name: str, term_name: str, term_cfg: Observ shape = tuple(sample.shape) if writes_output: self._term_output_specs[group_name, term_name] = (shape, sample.dtype, sample.device) - return shape + return sample def _prepare_terms(self): """Prepares a list of observation terms functions.""" @@ -592,6 +647,7 @@ def _prepare_terms(self): f"Observation group '{group_name}' is not of type 'ObservationGroupCfg'." f" Received: '{type(group_cfg)}'." ) + pipeline_specs = {} # initialize list for the group settings self._group_obs_term_names[group_name] = [] self._group_obs_term_dim[group_name] = [] @@ -632,6 +688,7 @@ def _prepare_terms(self): "flatten_history_dim", "history_order", "concatenate_dim", + "pipeline", ]: continue # check for non config @@ -657,7 +714,8 @@ def _prepare_terms(self): self._group_obs_term_names[group_name].append(term_name) self._group_obs_term_cfgs[group_name].append(term_cfg) # call function the first time to fill up dimensions - obs_dims = self._prepare_term_output(group_name, term_name, term_cfg) + sample = self._prepare_term_output(group_name, term_name, term_cfg) + obs_dims = tuple(sample.shape) # if scale is set, check if single float or tuple if term_cfg.scale is not None: @@ -761,6 +819,7 @@ def _prepare_terms(self): obs_dims = (obs_dims[0], int(np.prod(obs_dims[1:]))) self._group_obs_term_dim[group_name].append(obs_dims[1:]) + pipeline_specs[term_name] = (obs_dims, sample.dtype, sample.device) # add term in a separate list if term is a class if isinstance(term_cfg.func, ManagerTermBase): @@ -770,3 +829,26 @@ def _prepare_terms(self): # add history buffers for each group self._group_obs_term_delay_buffer[group_name] = group_entry_delay_buffer self._group_obs_term_history_buffer[group_name] = group_entry_history_buffer + self._prepare_group_pipeline(group_name, group_cfg, pipeline_specs) + + def _prepare_group_pipeline( + self, + group_name: str, + cfg: ObservationGroupCfg, + specs: dict[str, tuple[tuple[int, ...], torch.dtype, torch.device]], + ) -> None: + if not cfg.pipeline: + return + if cfg.concatenate_terms: + raise ValueError(f"Observation group '{group_name}' with a pipeline requires concatenate_terms=False.") + stream = wp.ScopedStream(wp.stream_from_torch(self.device)) if "cuda" in self.device else nullcontext() + with stream: + inputs = { + name: ProxyArray(wp.from_torch(torch.empty(shape, dtype=dtype, device=device))) + for name, (shape, dtype, device) in specs.items() + } + pipeline = _DictTensorProcessorPipeline(inputs, cfg.pipeline) + self._group_pipelines[group_name] = pipeline + for name, value in pipeline.outputs.items(): + if not value.torch.shape or value.torch.shape[0] != self.num_envs: + raise ValueError(f"Observation pipeline output '{name}' must preserve the environment batch size.") diff --git a/source/isaaclab/isaaclab/utils/tensor_processors/dict_tensor_processor_cfg.py b/source/isaaclab/isaaclab/utils/tensor_processors/dict_tensor_processor_cfg.py index 119afafac7d5..a0967de549af 100644 --- a/source/isaaclab/isaaclab/utils/tensor_processors/dict_tensor_processor_cfg.py +++ b/source/isaaclab/isaaclab/utils/tensor_processors/dict_tensor_processor_cfg.py @@ -16,11 +16,10 @@ @configclass class DictTensorProcessorCfg: - """Configure one dictionary processor and declare its producer dependencies. + """Configure a processor for named tensors. - Names are actual dictionary keys, with no separate operation roles. Cameras use these - declarations before renderer initialization. The class receives ``(cfg, data)`` and - owns its output storage, state, and lifecycle, just like a single-tensor processor. + Declare the keys it reads in :attr:`inputs` and the keys it adds or replaces in + :attr:`outputs`. Inputs may come from the source or an earlier processor in the pipeline. """ class_type: type[DictTensorProcessor] | str = MISSING diff --git a/source/isaaclab/test/managers/test_observation_manager.py b/source/isaaclab/test/managers/test_observation_manager.py index 4c7dd57efa1d..8d3b34f04559 100644 --- a/source/isaaclab/test/managers/test_observation_manager.py +++ b/source/isaaclab/test/managers/test_observation_manager.py @@ -23,6 +23,7 @@ ObservationTermCfg, RewardTermCfg, ) +from isaaclab.test.utils import DeviceScope, test_devices from isaaclab.utils import DelayBuffer, configclass, noise, tensor_processors, to_dict, update_from_dict, validate from isaaclab.utils.string import string_to_callable @@ -1029,3 +1030,113 @@ def ordinary_term(env, out=None): result = manager.compute()["policy"] setup_env.data.pos_w.zero_() torch.testing.assert_close(result, expected) + + +@pytest.mark.parametrize("device", test_devices(DeviceScope.CPU_AND_DEFAULT_CUDA)) +def test_dictionary_pipeline_processing_reset_and_spaces(setup_env, device): + """Named outputs compose after term history, survive later calls, and match Gym spaces.""" + from types import SimpleNamespace + + import warp as wp + + from isaaclab.envs import ManagerBasedRLEnv + from isaaclab.utils.warp import ProxyArray + + closed = [] + + class Accumulate(tensor_processors.DictTensorProcessor): + def __init__(self, cfg, data): + super().__init__(cfg, data) + source = data[cfg.inputs[0]] + self.outputs[cfg.outputs[0]] = ProxyArray(wp.zeros((source.shape[0], 1), device=source.device)) + + def __call__(self, data): + output = self.outputs[self.cfg.outputs[0]].torch + for name in self.cfg.inputs: + output.add_(data[name].torch.sum(dim=1, keepdim=True) * self.cfg.params.get("scale", 1)) + return self.outputs + + def reset(self, env_ids=None): + output = self.outputs[self.cfg.outputs[0]] + if env_ids is None: + output.warp.zero_() + else: + output.torch[env_ids] = 0 + + def close(self): + closed.append(self.cfg.params.get("scale", 1)) + + setup_env = setup_env._replace(device=device, data=MyDataClass(setup_env.num_envs, device)) + setup_env.data.pos_w.fill_(1) + setup_env.data.lin_vel_w.fill_(1) + cfg = ObservationGroupCfg( + concatenate_terms=False, + pipeline=[ + tensor_processors.DictTensorProcessorCfg(Accumulate, ["position", "velocity"], ["total"]), + tensor_processors.DictTensorProcessorCfg(Accumulate, ["total"], ["total"], {"scale": 2}), + ], + ) + cfg.position = ObservationTermCfg(func=pos_w_data, scale=2, history_length=2) + cfg.velocity = ObservationTermCfg(func=lin_vel_w_data) + manager = ObservationManager({"policy": cfg}, setup_env) + first = manager.compute(update_history=True)["policy"] + assert list(first) == ["position", "velocity", "total"] + assert manager.group_obs_dim["policy"] == [(6,), (3,), (1,)] + torch.testing.assert_close(first["position"], torch.full((20, 6), 2.0, device=device)) + torch.testing.assert_close(first["velocity"], torch.ones(20, 3, device=device)) + torch.testing.assert_close(first["total"], torch.full((20, 1), 30.0, device=device)) + second = manager.compute()["policy"] + torch.testing.assert_close(second["total"], torch.full((20, 1), 90.0, device=device)) + torch.testing.assert_close(first["total"], torch.full((20, 1), 30.0, device=device)) + manager.reset([1]) + third = manager.compute(update_history=True)["policy"] + expected = torch.full((20, 1), 180.0, device=device) + expected[1] = 30.0 + torch.testing.assert_close(third["total"], expected) + torch.testing.assert_close(setup_env.data.pos_w, torch.ones(20, 3, device=device)) + manager.reset() + torch.testing.assert_close(manager.compute(update_history=True)["policy"]["total"], first["total"]) + + env = SimpleNamespace(observation_manager=manager, action_manager=SimpleNamespace(action_term_dim=[1]), num_envs=20) + ManagerBasedRLEnv._configure_gym_env_spaces(env) + assert env.single_observation_space.contains( + {"policy": {name: value[0].cpu().numpy() for name, value in third.items()}} + ) + manager.close() + manager.close() + assert closed == [2, 1] + + +@pytest.mark.parametrize("invalid", ["concatenation", "missing_input", "batch_size"]) +def test_dictionary_pipeline_rejects_invalid_configuration(setup_env, invalid): + """Reject incompatible publication and missing dependencies before observations are computed.""" + import warp as wp + + from isaaclab.utils.warp import ProxyArray + + closed = [] + + class WrongBatch(tensor_processors.DictTensorProcessor): + def __init__(self, cfg, data): + super().__init__(cfg, data) + self.outputs["result"] = ProxyArray(wp.zeros((1, 3), device="cpu")) + + def __call__(self, data): + raise AssertionError("Preparation must not advance processor state.") + + def close(self): + closed.append(True) + + cfg = ObservationGroupCfg( + concatenate_terms=invalid == "concatenation", + pipeline=[ + tensor_processors.DictTensorProcessorCfg( + WrongBatch, ["missing" if invalid == "missing_input" else "position"], ["result"] + ) + ], + ) + cfg.position = ObservationTermCfg(func=pos_w_data) + match = {"concatenation": "concatenate_terms=False", "missing_input": "Missing input", "batch_size": "batch size"} + with pytest.raises(ValueError, match=match[invalid]): + ObservationManager({"policy": cfg}, setup_env) + assert closed == ([True] if invalid == "batch_size" else []) diff --git a/source/isaaclab_experimental/changelog.d/observation-dictionary-pipeline.changed.rst b/source/isaaclab_experimental/changelog.d/observation-dictionary-pipeline.changed.rst new file mode 100644 index 000000000000..96ecb74e061b --- /dev/null +++ b/source/isaaclab_experimental/changelog.d/observation-dictionary-pipeline.changed.rst @@ -0,0 +1 @@ +* Rejected unsupported dictionary observation pipelines explicitly in the experimental Warp manager. diff --git a/source/isaaclab_experimental/isaaclab_experimental/managers/observation_manager.py b/source/isaaclab_experimental/isaaclab_experimental/managers/observation_manager.py index 5fed28b70c63..e5c2a004c647 100644 --- a/source/isaaclab_experimental/isaaclab_experimental/managers/observation_manager.py +++ b/source/isaaclab_experimental/isaaclab_experimental/managers/observation_manager.py @@ -640,6 +640,8 @@ def _prepare_terms(self): # noqa: C901 f"Observation group '{group_name}' is not of type 'ObservationGroupCfg'." f" Received: '{type(group_cfg)}'." ) + if group_cfg.pipeline: + raise ValueError("Dictionary observation pipelines require the standard ObservationManager.") # initialize list for the group settings # group name to list of group term names self._group_obs_term_names[group_name] = list() @@ -672,6 +674,7 @@ def _prepare_terms(self): # noqa: C901 "flatten_history_dim", "history_order", "concatenate_dim", + "pipeline", ]: continue # check for non config From 32ad9f033dd5aaf6b50269005865476333b53113 Mon Sep 17 00:00:00 2001 From: Matthew Taylor Date: Mon, 19 Oct 2026 15:41:42 -0400 Subject: [PATCH 06/20] Apply dictionary processors to individual observation terms --- docs/source/api/lab/isaaclab.managers.rst | 49 +++-- docs/source/api/lab/isaaclab.utils.rst | 5 +- .../observation-dictionary-pipeline.added.rst | 5 +- .../isaaclab/envs/manager_based_rl_env.py | 23 ++- .../isaaclab/managers/manager_term_cfg.py | 25 ++- .../isaaclab/managers/observation_manager.py | 184 ++++++++++-------- .../test/managers/test_observation_manager.py | 98 +++++++--- ...bservation-dictionary-pipeline.changed.rst | 1 - .../managers/observation_manager.py | 3 - 9 files changed, 239 insertions(+), 154 deletions(-) delete mode 100644 source/isaaclab_experimental/changelog.d/observation-dictionary-pipeline.changed.rst diff --git a/docs/source/api/lab/isaaclab.managers.rst b/docs/source/api/lab/isaaclab.managers.rst index 2ac991fc4c45..0a162a925251 100644 --- a/docs/source/api/lab/isaaclab.managers.rst +++ b/docs/source/api/lab/isaaclab.managers.rst @@ -143,33 +143,44 @@ second full-image copy for normalized uint8 images. Existing observation configu Dictionary observation processing ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Use :attr:`ObservationGroupCfg.pipeline` to combine or transform named observation terms. -Set ``concatenate_terms=False``. Each processor runs after the terms' processing, delay, and -history, and can read outputs from preceding processors. Outputs add or replace dictionary -entries; untouched terms remain available. +An :class:`ObservationTermCfg` function may return a dictionary of named Torch tensors or +:class:`~isaaclab.utils.warp.ProxyArray` values. Set the group's ``concatenate_terms=False`` +and configure dictionary processors through the term's ``tensor_processors`` field. +Each processor can read outputs from preceding processors and add or replace dictionary +entries. Untouched entries remain available. For example, given a ``FuseSensors`` subclass of -:class:`~isaaclab.utils.tensor_processors.DictTensorProcessor`, configure a group with terms -named ``position`` and ``velocity`` as follows: +:class:`~isaaclab.utils.tensor_processors.DictTensorProcessor`, configure a term as follows: .. code-block:: python + from isaaclab.managers import ObservationTermCfg from isaaclab.utils.tensor_processors import DictTensorProcessorCfg + def sensor_data(env): + robot = env.scene["robot"] + return {"position": robot.data.root_pos_w, "velocity": robot.data.root_lin_vel_w} + sensor_group.concatenate_terms = False - sensor_group.pipeline = [ - DictTensorProcessorCfg( - class_type="my_project.processors:FuseSensors", - inputs=["position", "velocity"], - outputs=["features"], - ), - ] - -The returned dictionary contains ``position``, ``velocity``, and ``features``. Processors run -on every observation computation, including calls that do not advance history. Resetting the -manager resets processor state for the selected environments. Returned observations remain -valid across later computations. This option is supported by the standard observation manager; -the experimental Warp observation manager does not support dictionary pipelines. + sensor_group.sensors = ObservationTermCfg( + func=sensor_data, + tensor_processors=[ + DictTensorProcessorCfg( + class_type="my_project.processors:FuseSensors", + inputs=["position", "velocity"], + outputs=["features"], + ), + ], + ) + +For a group named ``policy``, read the result as ``observations["policy"]["sensors"]["features"]``. +The ``sensors`` dictionary also retains ``position`` and ``velocity``. Processors run on every +observation computation, and resetting the manager resets their state for the selected environments. +Returned observations remain valid across later computations. + +Dictionary terms do not support the tensor-only ``noise``, ``clip``, ``scale``, delay, or history +settings. Use dictionary processors for their transformations. They are supported by the standard +observation manager; the experimental Warp manager requires tensor terms. .. autoclass:: ObservationGroupCfg :members: diff --git a/docs/source/api/lab/isaaclab.utils.rst b/docs/source/api/lab/isaaclab.utils.rst index 6c5db4cfd92a..9e66bb9ccf88 100644 --- a/docs/source/api/lab/isaaclab.utils.rst +++ b/docs/source/api/lab/isaaclab.utils.rst @@ -132,8 +132,9 @@ Use :mod:`isaaclab.utils.tensor_processors` to transform observations: * ``TensorProcessor``: tensor to tensor. * ``DictTensorProcessor``: dictionary of named tensors to dictionary of named tensors. -Configure single-tensor processors with :attr:`~isaaclab.managers.ObservationTermCfg.tensor_processors`. -Configure dictionary processors with :attr:`~isaaclab.managers.ObservationGroupCfg.pipeline`. +Configure processors with :attr:`~isaaclab.managers.ObservationTermCfg.tensor_processors`. +Use ``TensorProcessorCfg`` for a term returning one tensor and ``DictTensorProcessorCfg`` for +a term returning a dictionary of named tensors. Dictionary terms require ``concatenate_terms=False``. Each dictionary processor declares the input keys it needs and the output keys it adds or replaces. Other keys remain available to subsequent processors. diff --git a/source/isaaclab/changelog.d/observation-dictionary-pipeline.added.rst b/source/isaaclab/changelog.d/observation-dictionary-pipeline.added.rst index 564ab77896e9..65593c06f9a1 100644 --- a/source/isaaclab/changelog.d/observation-dictionary-pipeline.added.rst +++ b/source/isaaclab/changelog.d/observation-dictionary-pipeline.added.rst @@ -1,2 +1,3 @@ -* Added dictionary tensor processing to observation groups through ``ObservationGroupCfg.pipeline``, - including named outputs, partial resets, resource cleanup, and matching Gym observation spaces. +* Added dictionary-returning observation terms with ``DictTensorProcessorCfg`` entries in + ``ObservationTermCfg.tensor_processors``, including nested outputs, partial resets, resource cleanup, + and matching Gym observation spaces. diff --git a/source/isaaclab/isaaclab/envs/manager_based_rl_env.py b/source/isaaclab/isaaclab/envs/manager_based_rl_env.py index d6c0ef9752e3..f96364276aaa 100644 --- a/source/isaaclab/isaaclab/envs/manager_based_rl_env.py +++ b/source/isaaclab/isaaclab/envs/manager_based_rl_env.py @@ -364,18 +364,23 @@ def _configure_gym_env_spaces(self): if has_concatenated_obs: self.single_observation_space[group_name] = gym.spaces.Box(low=-np.inf, high=np.inf, shape=group_dim) else: - pipeline = self.observation_manager._group_pipelines.get(group_name) - if pipeline is not None: - self.single_observation_space[group_name] = gym.spaces.Dict( - { - name: gym.spaces.Box(low=-np.inf, high=np.inf, shape=shape) - for name, shape in zip(pipeline.outputs, group_dim) - } - ) - continue group_term_cfgs = self.observation_manager._group_obs_term_cfgs[group_name] term_dict = {} for term_name, term_dim, term_cfg in zip(group_term_names, group_dim, group_term_cfgs): + if isinstance(term_dim, dict): + pipeline = self.observation_manager._term_pipelines[group_name, term_name] + spaces = {} + for name, shape in term_dim.items(): + dtype = torch.empty((), dtype=pipeline.outputs[name].torch.dtype).numpy().dtype + if np.issubdtype(dtype, np.integer): + low, high = np.iinfo(dtype).min, np.iinfo(dtype).max + elif np.issubdtype(dtype, np.bool_): + low, high = 0, 1 + else: + low, high = -np.inf, np.inf + spaces[name] = gym.spaces.Box(low=low, high=high, shape=shape, dtype=dtype) + term_dict[term_name] = gym.spaces.Dict(spaces) + continue low = -np.inf if term_cfg.clip is None else term_cfg.clip[0] high = np.inf if term_cfg.clip is None else term_cfg.clip[1] term_dict[term_name] = gym.spaces.Box(low=low, high=high, shape=term_dim) diff --git a/source/isaaclab/isaaclab/managers/manager_term_cfg.py b/source/isaaclab/isaaclab/managers/manager_term_cfg.py index b485f585e2c0..28681e80d8b3 100644 --- a/source/isaaclab/isaaclab/managers/manager_term_cfg.py +++ b/source/isaaclab/isaaclab/managers/manager_term_cfg.py @@ -19,6 +19,7 @@ from .scene_entity_cfg import SceneEntityCfg if TYPE_CHECKING: + from ..utils.warp import ProxyArray from .action_manager import ActionTerm from .command_manager import CommandTerm from .manager_base import ManagerTermBase @@ -158,16 +159,20 @@ class CurriculumTermCfg(ManagerTermBaseCfg): class ObservationTermCfg(ManagerTermBaseCfg): """Configuration for an observation term.""" - func: Callable[..., torch.Tensor | None] | type[ManagerTermBase] = MISSING + func: Callable[..., torch.Tensor | dict[str, torch.Tensor | ProxyArray] | None] | type[ManagerTermBase] = MISSING """The function or :class:`ManagerTermBase` subclass to be called. The manager instantiates class-based terms before calling them. The function or the class's ``__call__`` method should take the environment object and any other parameters as input and return the observation signal as torch float tensors of - shape (num_envs, obs_term_dim). + shape (num_envs, obs_term_dim), or a dictionary of named batched tensors. Dictionary values + may be Torch tensors or ProxyArray objects. Dictionary terms require a non-concatenated + group and remain nested under their term name. They do not support the tensor-only noise, + clip, scale, delay, or history settings; use dictionary processors for their transformations. + Dictionary terms are supported by the standard observation manager. """ - tensor_processors: list[TensorProcessorCfg] | None = None + tensor_processors: list[TensorProcessorCfg] | list[DictTensorProcessorCfg] | None = None """The list of tensor processors to apply to the observation in order. Defaults to None, in which case no modifications will be applied. @@ -175,7 +180,9 @@ class ObservationTermCfg(ManagerTermBaseCfg): or stateful, and can be used to apply transformations to the observation data. For example, a processor can be used to normalize the observation data or to apply a rolling average. - For more information, see :class:`~isaaclab.utils.tensor_processors.TensorProcessorCfg`. + Use :class:`~isaaclab.utils.tensor_processors.TensorProcessorCfg` for tensor-returning terms + and :class:`~isaaclab.utils.tensor_processors.DictTensorProcessorCfg` for dictionary-returning + terms. Dictionary processors may add or replace keys; other keys are preserved. """ noise: NoiseCfg | NoiseModelCfg | None = None @@ -247,16 +254,6 @@ def validate_config(self): class ObservationGroupCfg: """Configuration for an observation group.""" - pipeline: list[DictTensorProcessorCfg] = [] - """Dictionary processors applied in order after term processing, delay, and history. - - Inputs are observation term names or outputs from preceding processors. Outputs add or - replace named observations; untouched terms remain available. Requires - :attr:`concatenate_terms` to be False. Returned observations are independent snapshots. - Processors run on every observation computation and reset with the selected environments. - Supported by the standard observation manager, not the experimental Warp manager. - """ - concatenate_terms: bool = True """Whether to concatenate the observation terms in the group. Defaults to True. diff --git a/source/isaaclab/isaaclab/managers/observation_manager.py b/source/isaaclab/isaaclab/managers/observation_manager.py index f23b5903953d..d99490993f8c 100644 --- a/source/isaaclab/isaaclab/managers/observation_manager.py +++ b/source/isaaclab/isaaclab/managers/observation_manager.py @@ -30,6 +30,10 @@ from ..envs import ManagerBasedEnv +_TermObservation = torch.Tensor | dict[str, torch.Tensor] +_GroupObservation = torch.Tensor | dict[str, _TermObservation] + + class ObservationManager(ManagerBase): """Manager for computing observation signals for a given world. @@ -41,7 +45,8 @@ class ObservationManager(ManagerBase): Each observation group should inherit from the :class:`ObservationGroupCfg` class. Within each group, each observation term should instantiate the :class:`ObservationTermCfg` class. Based on the configuration, the observations in a group can be concatenated into a single tensor or returned as a dictionary with keys - corresponding to the term's name. + corresponding to the term's name. Dictionary-returning terms remain nested under their term name + and require a non-concatenated group. If the observations in a group are concatenated, the shape of the concatenated tensor is computed based on the shapes of the individual observation terms. This information is stored in the :attr:`group_obs_dim` dictionary @@ -91,7 +96,7 @@ def __init__(self, cfg: object, env: ManagerBasedEnv): raise ValueError("Observation manager configuration is None. Please provide a valid configuration.") # call the base class constructor (this will parse the terms config) - self._group_pipelines: dict[str, _DictTensorProcessorPipeline] = {} + self._term_pipelines: dict[tuple[str, str], _DictTensorProcessorPipeline] = {} try: super().__init__(cfg, env) except Exception as error: @@ -102,7 +107,7 @@ def __init__(self, cfg: object, env: ManagerBasedEnv): raise # compute combined vector for obs group - self._group_obs_dim: dict[str, tuple[int, ...] | list[tuple[int, ...]]] = {} + self._group_obs_dim: dict[str, tuple[int, ...] | list[tuple[int, ...] | dict[str, tuple[int, ...]]]] = {} for group_name, group_term_dims in self._group_obs_term_dim.items(): # if terms are concatenated, compute the combined shape into a single tuple # otherwise, keep the list of shapes as is @@ -128,20 +133,15 @@ def __init__(self, cfg: object, env: ManagerBasedEnv): " Otherwise, set 'concatenate_terms' to False in the group configuration." ) else: - pipeline = self._group_pipelines.get(group_name) - self._group_obs_dim[group_name] = ( - [tuple(value.torch.shape[1:]) for value in pipeline.outputs.values()] - if pipeline is not None - else group_term_dims - ) + self._group_obs_dim[group_name] = group_term_dims # Stores the latest observations. - self._obs_buffer: dict[str, torch.Tensor | dict[str, torch.Tensor]] | None = None + self._obs_buffer: dict[str, _GroupObservation] | None = None def close(self) -> None: """Release dictionary processor resources. Repeated calls have no effect.""" errors = [] - for pipeline in self._group_pipelines.values(): + for pipeline in self._term_pipelines.values(): try: pipeline.close() except Exception as error: @@ -151,7 +151,7 @@ def close(self) -> None: def __del__(self): try: - if hasattr(self, "_group_pipelines"): + if hasattr(self, "_term_pipelines"): self.close() finally: super().__del__() @@ -177,7 +177,7 @@ def __str__(self) -> str: ) for index, (name, dims) in enumerate(obs_terms): # resolve inputs to simplify prints - tab_dims = tuple(dims) + tab_dims = dims if isinstance(dims, dict) else tuple(dims) # add row table.add_row([index, name, tab_dims]) # convert table to string @@ -201,12 +201,17 @@ def get_active_iterable_terms(self, env_idx: int) -> Sequence[tuple[str, Sequenc if self._obs_buffer is None: self.compute() - obs_buffer: dict[str, torch.Tensor | dict[str, torch.Tensor]] = self._obs_buffer + obs_buffer: dict[str, _GroupObservation] = self._obs_buffer for group_name, _ in self._group_obs_dim.items(): if not self.group_obs_concatenate[group_name]: for name, term in obs_buffer[group_name].items(): - terms.append((group_name + "-" + name, term[env_idx].cpu().tolist())) + if isinstance(term, dict): + terms.extend( + (f"{group_name}-{name}/{key}", value[env_idx].cpu().tolist()) for key, value in term.items() + ) + else: + terms.append((group_name + "-" + name, term[env_idx].cpu().tolist())) continue idx = 0 @@ -240,22 +245,22 @@ def active_terms(self) -> dict[str, list[str]]: return self._group_obs_term_names @property - def group_obs_dim(self) -> dict[str, tuple[int, ...] | list[tuple[int, ...]]]: + def group_obs_dim(self) -> dict[str, tuple[int, ...] | list[tuple[int, ...] | dict[str, tuple[int, ...]]]]: """Shape of computed observations in each group. The key is the group name and the value is the shape of the observation tensor. If the terms in the group are concatenated, the value is a single tuple representing the shape of the concatenated observation tensor. Otherwise, the value is a list of tuples, - where each tuple represents an output in dictionary order, including pipeline outputs. + where each entry is a term shape or a dictionary of named tensor shapes. """ return self._group_obs_dim @property - def group_obs_term_dim(self) -> dict[str, list[tuple[int, ...]]]: + def group_obs_term_dim(self) -> dict[str, list[tuple[int, ...] | dict[str, tuple[int, ...]]]]: """Shape of individual observation terms in each group. - The key is the group name and the value is a list of tuples representing the shape of the observation terms - in the group. The order of the tuples corresponds to the order of the terms in the group. + The key is the group name and the value is a list of term shapes. Each entry is a tuple for a tensor term + or a dictionary of named shapes for a dictionary term. The order corresponds to the terms in the group. This matches the order of the terms in the :attr:`active_terms`. """ return self._group_obs_term_dim @@ -377,16 +382,16 @@ def reset(self, env_ids: Sequence[int] | None = None) -> dict[str, float]: # call all tensor_processors that are classes for mod in self._group_obs_class_instances: mod.reset(env_ids=env_ids) - if self._group_pipelines: + if self._term_pipelines: stream = wp.ScopedStream(wp.stream_from_torch(self.device)) if "cuda" in self.device else nullcontext() with stream: - for pipeline in self._group_pipelines.values(): + for pipeline in self._term_pipelines.values(): pipeline.reset(env_ids) # nothing to log here return {} - def compute(self, update_history: bool = False) -> dict[str, torch.Tensor | dict[str, torch.Tensor]]: + def compute(self, update_history: bool = False) -> dict[str, _GroupObservation]: """Compute the observations per group for all groups. The method computes the observations for all the groups handled by the observation manager. @@ -413,7 +418,7 @@ def compute(self, update_history: bool = False) -> dict[str, torch.Tensor | dict self._obs_buffer = obs_buffer return obs_buffer - def compute_group(self, group_name: str, update_history: bool = False) -> torch.Tensor | dict[str, torch.Tensor]: + def compute_group(self, group_name: str, update_history: bool = False) -> _GroupObservation: """Computes the observations for a given group. The observations for a given group are computed by calling the registered functions for each @@ -430,8 +435,8 @@ def compute_group(self, group_name: str, update_history: bool = False) -> torch. 6. Apply delay based on :attr:`ObservationTermCfg.delay_min_lag` and :attr:`ObservationTermCfg.delay_max_lag` 7. Append the delayed observation to history if requested - The group's :attr:`ObservationGroupCfg.pipeline` then processes the named observations. - Outputs may replace existing terms or add new keys. Returned tensors are independent snapshots. + Dictionary-returning terms apply their dictionary processors and return named tensors + under the term name. Tensor-only post-processing settings are not supported for these terms. We apply noise to the computed term first to maintain the integrity of how noise affects the data as it truly exists in the real world. If the noise is applied after clipping or scaling, the noise @@ -476,6 +481,10 @@ def compute_group(self, group_name: str, update_history: bool = False) -> torch. ) else: obs = term_cfg.func(self._env, **term_cfg.params) + pipeline = self._term_pipelines.get((group_name, term_name)) + if pipeline is not None: + group_obs[term_name] = self._compute_dict_term(group_name, term_name, pipeline, obs) + continue # apply post-processing if term_cfg.tensor_processors is not None: for modifier in term_cfg.tensor_processors: @@ -533,17 +542,6 @@ def compute_group(self, group_name: str, update_history: bool = False) -> torch. # Secure borrowed storage before another term can overwrite it, including shared scratch buffers. group_obs[term_name] = obs if owned else obs.clone() - pipeline = self._group_pipelines.get(group_name) - if pipeline is not None: - # Run Warp callbacks on the stream producing and consuming the Torch observations. - stream = wp.ScopedStream(wp.stream_from_torch(self.device)) if "cuda" in self.device else nullcontext() - with stream: - result = pipeline({name: ProxyArray(wp.from_torch(value)) for name, value in group_obs.items()}) - return { - name: value.torch.clone() if name in pipeline.produced_outputs else group_obs[name] - for name, value in result.items() - } - # concatenate all observations in the group together if self._group_obs_concatenate[group_name]: if len(group_obs) == 1: @@ -592,13 +590,19 @@ def _resolve_common_term_cfg(self, term_name: str, term_cfg: ObservationTermCfg, # The destination is supplied by the manager, not by the term configuration. term_cfg.params.pop("out", None) - def _prepare_term_output(self, group_name: str, term_name: str, term_cfg: ObservationTermCfg) -> torch.Tensor: + def _prepare_term_output( + self, group_name: str, term_name: str, term_cfg: ObservationTermCfg + ) -> torch.Tensor | dict[str, torch.Tensor | ProxyArray]: """Probe output dimensions and cache destination specifications for explicitly supported terms.""" out_param = inspect.signature(term_cfg.func).parameters.get("out") writes_output = out_param is not None and out_param.kind is inspect.Parameter.KEYWORD_ONLY if writes_output and out_param.default is not None: raise ValueError(f"Observation term '{group_name}/{term_name}' must declare keyword-only 'out=None'.") sample = term_cfg.func(self._env, **term_cfg.params) + if isinstance(sample, dict): + if writes_output: + raise ValueError("Dictionary observation terms cannot declare an 'out' parameter.") + return sample shape = tuple(sample.shape) if writes_output: self._term_output_specs[group_name, term_name] = (shape, sample.dtype, sample.device) @@ -610,7 +614,7 @@ def _prepare_terms(self): # TODO: Make this more convenient by using data structures. self._term_output_specs: dict[tuple[str, str], tuple[tuple[int, ...], torch.dtype, torch.device]] = {} self._group_obs_term_names: dict[str, list[str]] = {} - self._group_obs_term_dim: dict[str, list[tuple[int, ...]]] = {} + self._group_obs_term_dim: dict[str, list[tuple[int, ...] | dict[str, tuple[int, ...]]]] = {} self._group_obs_term_cfgs: dict[str, list[ObservationTermCfg]] = {} self._group_obs_class_term_cfgs: dict[str, list[ObservationTermCfg]] = {} self._group_obs_concatenate: dict[str, bool] = {} @@ -647,7 +651,6 @@ def _prepare_terms(self): f"Observation group '{group_name}' is not of type 'ObservationGroupCfg'." f" Received: '{type(group_cfg)}'." ) - pipeline_specs = {} # initialize list for the group settings self._group_obs_term_names[group_name] = [] self._group_obs_term_dim[group_name] = [] @@ -681,18 +684,18 @@ def _prepare_terms(self): # iterate over all the terms in each group for term_name, term_cfg in term_cfg_items: # skip non-obs settings - if term_name in [ - "enable_corruption", - "concatenate_terms", - "history_length", - "flatten_history_dim", - "history_order", - "concatenate_dim", - "pipeline", - ]: - continue - # check for non config - if term_cfg is None: + if ( + term_name + in [ + "enable_corruption", + "concatenate_terms", + "history_length", + "flatten_history_dim", + "history_order", + "concatenate_dim", + ] + or term_cfg is None + ): continue if not isinstance(term_cfg, ObservationTermCfg): raise TypeError( @@ -715,6 +718,13 @@ def _prepare_terms(self): self._group_obs_term_cfgs[group_name].append(term_cfg) # call function the first time to fill up dimensions sample = self._prepare_term_output(group_name, term_name, term_cfg) + if isinstance(term_cfg.func, ManagerTermBase): + self._group_obs_class_term_cfgs[group_name].append(term_cfg) + term_cfg.func.reset() + if isinstance(sample, dict): + dimensions = self._prepare_dict_term(group_name, term_name, term_cfg, sample) + self._group_obs_term_dim[group_name].append(dimensions) + continue obs_dims = tuple(sample.shape) # if scale is set, check if single float or tuple @@ -819,36 +829,58 @@ def _prepare_terms(self): obs_dims = (obs_dims[0], int(np.prod(obs_dims[1:]))) self._group_obs_term_dim[group_name].append(obs_dims[1:]) - pipeline_specs[term_name] = (obs_dims, sample.dtype, sample.device) - # add term in a separate list if term is a class - if isinstance(term_cfg.func, ManagerTermBase): - self._group_obs_class_term_cfgs[group_name].append(term_cfg) - # call reset (in-case above call to get obs dims changed the state) - term_cfg.func.reset() # add history buffers for each group self._group_obs_term_delay_buffer[group_name] = group_entry_delay_buffer self._group_obs_term_history_buffer[group_name] = group_entry_history_buffer - self._prepare_group_pipeline(group_name, group_cfg, pipeline_specs) - def _prepare_group_pipeline( - self, - group_name: str, - cfg: ObservationGroupCfg, - specs: dict[str, tuple[tuple[int, ...], torch.dtype, torch.device]], - ) -> None: - if not cfg.pipeline: - return - if cfg.concatenate_terms: - raise ValueError(f"Observation group '{group_name}' with a pipeline requires concatenate_terms=False.") + @staticmethod + def _dict_arrays(data: dict[str, torch.Tensor | ProxyArray]) -> dict[str, ProxyArray]: + if not isinstance(data, dict) or not all( + isinstance(name, str) and isinstance(value, (torch.Tensor, ProxyArray)) for name, value in data.items() + ): + raise TypeError("Dictionary observation terms must return string keys and Tensor or ProxyArray values.") + return { + name: value if isinstance(value, ProxyArray) else ProxyArray(wp.from_torch(value)) + for name, value in data.items() + } + + def _prepare_dict_term( + self, group_name: str, term_name: str, cfg: ObservationTermCfg, sample: dict[str, torch.Tensor | ProxyArray] + ) -> dict[str, tuple[int, ...]]: + if self._group_obs_concatenate[group_name]: + raise ValueError(f"Dictionary observation term '{term_name}' requires concatenate_terms=False.") + if ( + any(value is not None for value in (cfg.noise, cfg.clip, cfg.scale)) + or cfg.delay_max_lag + or cfg.history_length + ): + raise ValueError( + f"Dictionary observation term '{term_name}' does not support noise, clip, scale, delay, or history." + " Use dictionary processors to transform its named tensors." + ) + if any(not isinstance(step, tensor_processors.DictTensorProcessorCfg) for step in cfg.tensor_processors or []): + raise TypeError(f"Dictionary observation term '{term_name}' requires DictTensorProcessorCfg processors.") stream = wp.ScopedStream(wp.stream_from_torch(self.device)) if "cuda" in self.device else nullcontext() with stream: - inputs = { - name: ProxyArray(wp.from_torch(torch.empty(shape, dtype=dtype, device=device))) - for name, (shape, dtype, device) in specs.items() - } - pipeline = _DictTensorProcessorPipeline(inputs, cfg.pipeline) - self._group_pipelines[group_name] = pipeline + pipeline = _DictTensorProcessorPipeline(self._dict_arrays(sample), cfg.tensor_processors or []) + self._term_pipelines[group_name, term_name] = pipeline for name, value in pipeline.outputs.items(): if not value.torch.shape or value.torch.shape[0] != self.num_envs: - raise ValueError(f"Observation pipeline output '{name}' must preserve the environment batch size.") + raise ValueError(f"Dictionary observation output '{term_name}/{name}' must preserve the batch size.") + return {name: tuple(value.torch.shape[1:]) for name, value in pipeline.outputs.items()} + + def _compute_dict_term( + self, + group_name: str, + term_name: str, + pipeline: _DictTensorProcessorPipeline, + data: dict[str, torch.Tensor | ProxyArray], + ) -> dict[str, torch.Tensor]: + stream = wp.ScopedStream(wp.stream_from_torch(self.device)) if "cuda" in self.device else nullcontext() + with stream: + expected_keys = pipeline.outputs.keys() + result = pipeline(self._dict_arrays(data)) + if result.keys() != expected_keys: + raise ValueError(f"Dictionary observation term '{group_name}/{term_name}' changed its output keys.") + return {name: value.torch.clone() for name, value in result.items()} diff --git a/source/isaaclab/test/managers/test_observation_manager.py b/source/isaaclab/test/managers/test_observation_manager.py index 8d3b34f04559..ebbebb3ea2c2 100644 --- a/source/isaaclab/test/managers/test_observation_manager.py +++ b/source/isaaclab/test/managers/test_observation_manager.py @@ -1033,8 +1033,8 @@ def ordinary_term(env, out=None): @pytest.mark.parametrize("device", test_devices(DeviceScope.CPU_AND_DEFAULT_CUDA)) -def test_dictionary_pipeline_processing_reset_and_spaces(setup_env, device): - """Named outputs compose after term history, survive later calls, and match Gym spaces.""" +def test_dictionary_term_processing_reset_and_spaces(setup_env, device): + """A term's dictionary stays nested, composes processors, and owns stable snapshots.""" from types import SimpleNamespace import warp as wp @@ -1066,50 +1066,82 @@ def reset(self, env_ids=None): def close(self): closed.append(self.cfg.params.get("scale", 1)) + def sensor_data(env): + return {"position": env.data.pos_w, "velocity": ProxyArray(wp.from_torch(env.data.lin_vel_w))} + setup_env = setup_env._replace(device=device, data=MyDataClass(setup_env.num_envs, device)) setup_env.data.pos_w.fill_(1) setup_env.data.lin_vel_w.fill_(1) - cfg = ObservationGroupCfg( - concatenate_terms=False, - pipeline=[ + cfg = ObservationGroupCfg(concatenate_terms=False) + cfg.sensors = ObservationTermCfg( + func=sensor_data, + tensor_processors=[ tensor_processors.DictTensorProcessorCfg(Accumulate, ["position", "velocity"], ["total"]), tensor_processors.DictTensorProcessorCfg(Accumulate, ["total"], ["total"], {"scale": 2}), ], ) cfg.position = ObservationTermCfg(func=pos_w_data, scale=2, history_length=2) - cfg.velocity = ObservationTermCfg(func=lin_vel_w_data) manager = ObservationManager({"policy": cfg}, setup_env) first = manager.compute(update_history=True)["policy"] - assert list(first) == ["position", "velocity", "total"] - assert manager.group_obs_dim["policy"] == [(6,), (3,), (1,)] + assert list(first) == ["sensors", "position"] + assert list(first["sensors"]) == ["position", "velocity", "total"] + assert manager.group_obs_dim["policy"] == [{"position": (3,), "velocity": (3,), "total": (1,)}, (6,)] torch.testing.assert_close(first["position"], torch.full((20, 6), 2.0, device=device)) - torch.testing.assert_close(first["velocity"], torch.ones(20, 3, device=device)) - torch.testing.assert_close(first["total"], torch.full((20, 1), 30.0, device=device)) + torch.testing.assert_close(first["sensors"]["velocity"], torch.ones(20, 3, device=device)) + torch.testing.assert_close(first["sensors"]["total"], torch.full((20, 1), 12.0, device=device)) second = manager.compute()["policy"] - torch.testing.assert_close(second["total"], torch.full((20, 1), 90.0, device=device)) - torch.testing.assert_close(first["total"], torch.full((20, 1), 30.0, device=device)) + torch.testing.assert_close(second["sensors"]["total"], torch.full((20, 1), 36.0, device=device)) + torch.testing.assert_close(first["sensors"]["total"], torch.full((20, 1), 12.0, device=device)) manager.reset([1]) third = manager.compute(update_history=True)["policy"] - expected = torch.full((20, 1), 180.0, device=device) - expected[1] = 30.0 - torch.testing.assert_close(third["total"], expected) + expected = torch.full((20, 1), 72.0, device=device) + expected[1] = 12.0 + torch.testing.assert_close(third["sensors"]["total"], expected) torch.testing.assert_close(setup_env.data.pos_w, torch.ones(20, 3, device=device)) manager.reset() - torch.testing.assert_close(manager.compute(update_history=True)["policy"]["total"], first["total"]) + torch.testing.assert_close(manager.compute()["policy"]["sensors"]["total"], first["sensors"]["total"]) + assert "sensors" in str(manager) + assert "policy-sensors/total" in dict(manager.get_active_iterable_terms(0)) env = SimpleNamespace(observation_manager=manager, action_manager=SimpleNamespace(action_term_dim=[1]), num_envs=20) ManagerBasedRLEnv._configure_gym_env_spaces(env) - assert env.single_observation_space.contains( - {"policy": {name: value[0].cpu().numpy() for name, value in third.items()}} - ) + sample = { + "sensors": {name: value[0].cpu().numpy() for name, value in third["sensors"].items()}, + "position": third["position"][0].cpu().numpy(), + } + assert env.single_observation_space.contains({"policy": sample}) manager.close() manager.close() assert closed == [2, 1] -@pytest.mark.parametrize("invalid", ["concatenation", "missing_input", "batch_size"]) -def test_dictionary_pipeline_rejects_invalid_configuration(setup_env, invalid): - """Reject incompatible publication and missing dependencies before observations are computed.""" +def test_dictionary_term_without_processors(setup_env): + """Raw image dictionaries preserve dtype and snapshots, and reject changing schemas.""" + from types import SimpleNamespace + + from isaaclab.envs import ManagerBasedRLEnv + + pixels = torch.full((20, 2, 3), 255, dtype=torch.uint8) + data = {"rgb": pixels} + cfg = ObservationGroupCfg(concatenate_terms=False) + cfg.camera = ObservationTermCfg(func=lambda env: data) + manager = ObservationManager({"policy": cfg}, setup_env) + first = manager.compute()["policy"]["camera"]["rgb"] + pixels.zero_() + torch.testing.assert_close(first, torch.full((20, 2, 3), 255, dtype=torch.uint8)) + torch.testing.assert_close(manager.compute()["policy"]["camera"]["rgb"], torch.zeros_like(pixels)) + env = SimpleNamespace(observation_manager=manager, action_manager=SimpleNamespace(action_term_dim=[1]), num_envs=20) + ManagerBasedRLEnv._configure_gym_env_spaces(env) + assert env.single_observation_space.contains({"policy": {"camera": {"rgb": first[0].numpy()}}}) + data["extra"] = pixels + with pytest.raises(ValueError, match="changed its output keys"): + manager.compute() + manager.close() + + +@pytest.mark.parametrize("invalid", ["concatenation", "missing_input", "batch_size", "scale", "processor_type"]) +def test_dictionary_term_rejects_invalid_configuration(setup_env, invalid): + """Reject incompatible settings and dependencies without advancing processor state.""" import warp as wp from isaaclab.utils.warp import ProxyArray @@ -1127,16 +1159,26 @@ def __call__(self, data): def close(self): closed.append(True) - cfg = ObservationGroupCfg( - concatenate_terms=invalid == "concatenation", - pipeline=[ + cfg = ObservationGroupCfg(concatenate_terms=invalid == "concatenation") + cfg.sensors = ObservationTermCfg( + func=lambda env: {"position": env.data.pos_w}, + scale=2 if invalid == "scale" else None, + tensor_processors=[ tensor_processors.DictTensorProcessorCfg( WrongBatch, ["missing" if invalid == "missing_input" else "position"], ["result"] ) ], ) - cfg.position = ObservationTermCfg(func=pos_w_data) - match = {"concatenation": "concatenate_terms=False", "missing_input": "Missing input", "batch_size": "batch size"} - with pytest.raises(ValueError, match=match[invalid]): + if invalid == "processor_type": + cfg.sensors.tensor_processors = [tensor_processors.TensorProcessorCfg(func=tensor_processors.clip)] + match = { + "concatenation": "concatenate_terms=False", + "missing_input": "Missing input", + "batch_size": "batch size", + "scale": "does not support", + "processor_type": "DictTensorProcessorCfg", + } + error = TypeError if invalid == "processor_type" else ValueError + with pytest.raises(error, match=match[invalid]): ObservationManager({"policy": cfg}, setup_env) assert closed == ([True] if invalid == "batch_size" else []) diff --git a/source/isaaclab_experimental/changelog.d/observation-dictionary-pipeline.changed.rst b/source/isaaclab_experimental/changelog.d/observation-dictionary-pipeline.changed.rst deleted file mode 100644 index 96ecb74e061b..000000000000 --- a/source/isaaclab_experimental/changelog.d/observation-dictionary-pipeline.changed.rst +++ /dev/null @@ -1 +0,0 @@ -* Rejected unsupported dictionary observation pipelines explicitly in the experimental Warp manager. diff --git a/source/isaaclab_experimental/isaaclab_experimental/managers/observation_manager.py b/source/isaaclab_experimental/isaaclab_experimental/managers/observation_manager.py index e5c2a004c647..5fed28b70c63 100644 --- a/source/isaaclab_experimental/isaaclab_experimental/managers/observation_manager.py +++ b/source/isaaclab_experimental/isaaclab_experimental/managers/observation_manager.py @@ -640,8 +640,6 @@ def _prepare_terms(self): # noqa: C901 f"Observation group '{group_name}' is not of type 'ObservationGroupCfg'." f" Received: '{type(group_cfg)}'." ) - if group_cfg.pipeline: - raise ValueError("Dictionary observation pipelines require the standard ObservationManager.") # initialize list for the group settings # group name to list of group term names self._group_obs_term_names[group_name] = list() @@ -674,7 +672,6 @@ def _prepare_terms(self): # noqa: C901 "flatten_history_dim", "history_order", "concatenate_dim", - "pipeline", ]: continue # check for non config From 29edf1a647ac5a0f1d0d84b109708e29228f0783 Mon Sep 17 00:00:00 2001 From: Matthew Taylor Date: Mon, 19 Oct 2026 15:53:27 -0400 Subject: [PATCH 07/20] Finish environment teardown after processor cleanup errors --- .../changelog.d/processor-cleanup.fixed.rst | 2 + .../isaaclab/envs/manager_based_env.py | 31 +++++++------- .../test/envs/test_env_destructors.py | 41 ++++++++++++++++++- 3 files changed, 59 insertions(+), 15 deletions(-) create mode 100644 source/isaaclab/changelog.d/processor-cleanup.fixed.rst diff --git a/source/isaaclab/changelog.d/processor-cleanup.fixed.rst b/source/isaaclab/changelog.d/processor-cleanup.fixed.rst new file mode 100644 index 000000000000..10029c9852eb --- /dev/null +++ b/source/isaaclab/changelog.d/processor-cleanup.fixed.rst @@ -0,0 +1,2 @@ +* Fixed manager-based environment teardown to finish releasing resources when an observation + processor raised during cleanup, while preserving the error and allowing repeated close calls. diff --git a/source/isaaclab/isaaclab/envs/manager_based_env.py b/source/isaaclab/isaaclab/envs/manager_based_env.py index 2818134181e7..61c3279597cb 100644 --- a/source/isaaclab/isaaclab/envs/manager_based_env.py +++ b/source/isaaclab/isaaclab/envs/manager_based_env.py @@ -609,20 +609,23 @@ def close(self): # destructor is order-sensitive del self.action_manager - self.observation_manager.close() - del self.observation_manager - del self.event_manager - del self.recorder_manager - del self.scene - - # clear callbacks and instance - self.sim.clear_instance() - - # destroy the window - if self._window is not None: - self._window = None - # update closing status - self._is_closed = True + try: + self.observation_manager.close() + finally: + # Processor cleanup errors must not leave the environment partially closed. + del self.observation_manager + del self.event_manager + del self.recorder_manager + del self.scene + + # clear callbacks and instance + self.sim.clear_instance() + + # destroy the window + if self._window is not None: + self._window = None + # update closing status + self._is_closed = True """ Helper functions. diff --git a/source/isaaclab/test/envs/test_env_destructors.py b/source/isaaclab/test/envs/test_env_destructors.py index 0c9cc1a6b260..65907135cf49 100644 --- a/source/isaaclab/test/envs/test_env_destructors.py +++ b/source/isaaclab/test/envs/test_env_destructors.py @@ -5,17 +5,56 @@ from __future__ import annotations +from builtins import ExceptionGroup from types import SimpleNamespace +from unittest.mock import Mock import gymnasium as gym import pytest -from isaaclab.envs import DirectMARLEnv, DirectRLEnv, ManagerBasedEnv +from isaaclab.envs import DirectMARLEnv, DirectRLEnv, ManagerBasedEnv, ManagerBasedRLEnv from isaaclab.envs.utils.marl import multi_agent_to_single_agent, multi_agent_with_one_agent pytestmark = pytest.mark.unit +def test_manager_env_close_finishes_after_processor_cleanup_error(): + """Processor errors propagate after releasing the environment, and close remains idempotent.""" + error = ExceptionGroup("Failed to close observation pipelines.", [RuntimeError("Processor cleanup failed")]) + observation_manager = SimpleNamespace(close=Mock(side_effect=error)) + sim = SimpleNamespace(stop=Mock(), clear_instance=Mock()) + env = object.__new__(ManagerBasedRLEnv) + env._is_closed = False + env._window = object() + env.sim = sim + env.scene = object() + env.action_manager = object() + env.observation_manager = observation_manager + env.event_manager = object() + env.recorder_manager = object() + env.command_manager = object() + env.reward_manager = object() + env.termination_manager = object() + env.curriculum_manager = object() + env.obs_buf = {"policy": object()} + + try: + with pytest.raises(ExceptionGroup) as raised: + env.close() + assert raised.value is error + sim.clear_instance.assert_called_once_with() + assert env._is_closed + assert env._window is None + assert not hasattr(env, "scene") + assert not hasattr(env, "observation_manager") + assert not env.obs_buf + env.close() + sim.stop.assert_called_once_with() + observation_manager.close.assert_called_once_with() + finally: + env._is_closed = True + + class _FakeMultiAgentEnv: possible_agents = ["agent_0", "agent_1"] observation_spaces = { From b794e751e5a2daa50c8832e631257ea606c586a5 Mon Sep 17 00:00:00 2001 From: Matthew Taylor Date: Mon, 19 Oct 2026 16:36:05 -0400 Subject: [PATCH 08/20] Fix observation type annotations for Sphinx imports --- .../isaaclab/managers/observation_manager.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/source/isaaclab/isaaclab/managers/observation_manager.py b/source/isaaclab/isaaclab/managers/observation_manager.py index d99490993f8c..d0d0a36de64e 100644 --- a/source/isaaclab/isaaclab/managers/observation_manager.py +++ b/source/isaaclab/isaaclab/managers/observation_manager.py @@ -30,10 +30,6 @@ from ..envs import ManagerBasedEnv -_TermObservation = torch.Tensor | dict[str, torch.Tensor] -_GroupObservation = torch.Tensor | dict[str, _TermObservation] - - class ObservationManager(ManagerBase): """Manager for computing observation signals for a given world. @@ -136,7 +132,7 @@ def __init__(self, cfg: object, env: ManagerBasedEnv): self._group_obs_dim[group_name] = group_term_dims # Stores the latest observations. - self._obs_buffer: dict[str, _GroupObservation] | None = None + self._obs_buffer: dict[str, torch.Tensor | dict[str, torch.Tensor | dict[str, torch.Tensor]]] | None = None def close(self) -> None: """Release dictionary processor resources. Repeated calls have no effect.""" @@ -201,7 +197,7 @@ def get_active_iterable_terms(self, env_idx: int) -> Sequence[tuple[str, Sequenc if self._obs_buffer is None: self.compute() - obs_buffer: dict[str, _GroupObservation] = self._obs_buffer + obs_buffer: dict[str, torch.Tensor | dict[str, torch.Tensor | dict[str, torch.Tensor]]] = self._obs_buffer for group_name, _ in self._group_obs_dim.items(): if not self.group_obs_concatenate[group_name]: @@ -391,7 +387,9 @@ def reset(self, env_ids: Sequence[int] | None = None) -> dict[str, float]: # nothing to log here return {} - def compute(self, update_history: bool = False) -> dict[str, _GroupObservation]: + def compute( + self, update_history: bool = False + ) -> dict[str, torch.Tensor | dict[str, torch.Tensor | dict[str, torch.Tensor]]]: """Compute the observations per group for all groups. The method computes the observations for all the groups handled by the observation manager. @@ -418,7 +416,9 @@ def compute(self, update_history: bool = False) -> dict[str, _GroupObservation]: self._obs_buffer = obs_buffer return obs_buffer - def compute_group(self, group_name: str, update_history: bool = False) -> _GroupObservation: + def compute_group( + self, group_name: str, update_history: bool = False + ) -> torch.Tensor | dict[str, torch.Tensor | dict[str, torch.Tensor]]: """Computes the observations for a given group. The observations for a given group are computed by calling the registered functions for each From 0ae9669cada8e5751bea3f0c93632cedf11dee47 Mon Sep 17 00:00:00 2001 From: Matthew Taylor Date: Mon, 19 Oct 2026 16:46:24 -0400 Subject: [PATCH 09/20] Preserve dictionary observation schemas after validation failures --- .../fix-observation-dictionary-schema.fixed.rst | 1 + .../isaaclab/managers/observation_manager.py | 14 +++++++------- .../test/managers/test_observation_manager.py | 10 ++++++++-- 3 files changed, 16 insertions(+), 9 deletions(-) create mode 100644 source/isaaclab/changelog.d/fix-observation-dictionary-schema.fixed.rst diff --git a/source/isaaclab/changelog.d/fix-observation-dictionary-schema.fixed.rst b/source/isaaclab/changelog.d/fix-observation-dictionary-schema.fixed.rst new file mode 100644 index 000000000000..cdaa87997a2f --- /dev/null +++ b/source/isaaclab/changelog.d/fix-observation-dictionary-schema.fixed.rst @@ -0,0 +1 @@ +* Fixed dictionary observation schema validation to keep rejecting unexpected keys after a failed computation. diff --git a/source/isaaclab/isaaclab/managers/observation_manager.py b/source/isaaclab/isaaclab/managers/observation_manager.py index d0d0a36de64e..3dcf668f821f 100644 --- a/source/isaaclab/isaaclab/managers/observation_manager.py +++ b/source/isaaclab/isaaclab/managers/observation_manager.py @@ -466,10 +466,10 @@ def compute_group( group_term_names = self._group_obs_term_names[group_name] group_obs = dict.fromkeys(group_term_names, None) term_cfgs = self._group_obs_term_cfgs[group_name] - obs_terms = zip(group_term_names, term_cfgs) + obs_terms = zip(group_term_names, term_cfgs, self._group_obs_term_dim[group_name]) # evaluate terms: compute, add noise, clip, scale, custom tensor_processors - for term_name, term_cfg in obs_terms: + for term_name, term_cfg, term_dims in obs_terms: output_spec = self._term_output_specs.get((group_name, term_name)) owned = output_spec is not None if output_spec is not None: @@ -481,9 +481,9 @@ def compute_group( ) else: obs = term_cfg.func(self._env, **term_cfg.params) - pipeline = self._term_pipelines.get((group_name, term_name)) - if pipeline is not None: - group_obs[term_name] = self._compute_dict_term(group_name, term_name, pipeline, obs) + if isinstance(term_dims, dict): + pipeline = self._term_pipelines[group_name, term_name] + group_obs[term_name] = self._compute_dict_term(group_name, term_name, pipeline, obs, term_dims) continue # apply post-processing if term_cfg.tensor_processors is not None: @@ -876,11 +876,11 @@ def _compute_dict_term( term_name: str, pipeline: _DictTensorProcessorPipeline, data: dict[str, torch.Tensor | ProxyArray], + expected_dims: dict[str, tuple[int, ...]], ) -> dict[str, torch.Tensor]: stream = wp.ScopedStream(wp.stream_from_torch(self.device)) if "cuda" in self.device else nullcontext() with stream: - expected_keys = pipeline.outputs.keys() result = pipeline(self._dict_arrays(data)) - if result.keys() != expected_keys: + if result.keys() != expected_dims.keys(): raise ValueError(f"Dictionary observation term '{group_name}/{term_name}' changed its output keys.") return {name: value.torch.clone() for name, value in result.items()} diff --git a/source/isaaclab/test/managers/test_observation_manager.py b/source/isaaclab/test/managers/test_observation_manager.py index ebbebb3ea2c2..bfe63b579949 100644 --- a/source/isaaclab/test/managers/test_observation_manager.py +++ b/source/isaaclab/test/managers/test_observation_manager.py @@ -1134,8 +1134,14 @@ def test_dictionary_term_without_processors(setup_env): ManagerBasedRLEnv._configure_gym_env_spaces(env) assert env.single_observation_space.contains({"policy": {"camera": {"rgb": first[0].numpy()}}}) data["extra"] = pixels - with pytest.raises(ValueError, match="changed its output keys"): - manager.compute() + for _ in range(2): + with pytest.raises(ValueError, match="changed its output keys"): + manager.compute() + del data["extra"] + recovered = manager.compute()["policy"]["camera"] + assert env.single_observation_space.contains( + {"policy": {"camera": {name: value[0].numpy() for name, value in recovered.items()}}} + ) manager.close() From 12d30f49f7dc3713740e2d1b3e5001881740e3ee Mon Sep 17 00:00:00 2001 From: Matthew Taylor Date: Mon, 19 Oct 2026 17:01:57 -0400 Subject: [PATCH 10/20] Simplify dictionary observation Gym spaces --- .../observation-dictionary-pipeline.added.rst | 2 +- .../isaaclab/envs/manager_based_rl_env.py | 18 ++++++------------ .../test/managers/test_observation_manager.py | 5 +++++ 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/source/isaaclab/changelog.d/observation-dictionary-pipeline.added.rst b/source/isaaclab/changelog.d/observation-dictionary-pipeline.added.rst index 65593c06f9a1..8256b4e6eb44 100644 --- a/source/isaaclab/changelog.d/observation-dictionary-pipeline.added.rst +++ b/source/isaaclab/changelog.d/observation-dictionary-pipeline.added.rst @@ -1,3 +1,3 @@ * Added dictionary-returning observation terms with ``DictTensorProcessorCfg`` entries in ``ObservationTermCfg.tensor_processors``, including nested outputs, partial resets, resource cleanup, - and matching Gym observation spaces. + and nested float32 Gym observation spaces. diff --git a/source/isaaclab/isaaclab/envs/manager_based_rl_env.py b/source/isaaclab/isaaclab/envs/manager_based_rl_env.py index f96364276aaa..bbc2e45de99b 100644 --- a/source/isaaclab/isaaclab/envs/manager_based_rl_env.py +++ b/source/isaaclab/isaaclab/envs/manager_based_rl_env.py @@ -368,18 +368,12 @@ def _configure_gym_env_spaces(self): term_dict = {} for term_name, term_dim, term_cfg in zip(group_term_names, group_dim, group_term_cfgs): if isinstance(term_dim, dict): - pipeline = self.observation_manager._term_pipelines[group_name, term_name] - spaces = {} - for name, shape in term_dim.items(): - dtype = torch.empty((), dtype=pipeline.outputs[name].torch.dtype).numpy().dtype - if np.issubdtype(dtype, np.integer): - low, high = np.iinfo(dtype).min, np.iinfo(dtype).max - elif np.issubdtype(dtype, np.bool_): - low, high = 0, 1 - else: - low, high = -np.inf, np.inf - spaces[name] = gym.spaces.Box(low=low, high=high, shape=shape, dtype=dtype) - term_dict[term_name] = gym.spaces.Dict(spaces) + term_dict[term_name] = gym.spaces.Dict( + { + name: gym.spaces.Box(low=-np.inf, high=np.inf, shape=shape) + for name, shape in term_dim.items() + } + ) continue low = -np.inf if term_cfg.clip is None else term_cfg.clip[0] high = np.inf if term_cfg.clip is None else term_cfg.clip[1] diff --git a/source/isaaclab/test/managers/test_observation_manager.py b/source/isaaclab/test/managers/test_observation_manager.py index bfe63b579949..c9a7980f3894 100644 --- a/source/isaaclab/test/managers/test_observation_manager.py +++ b/source/isaaclab/test/managers/test_observation_manager.py @@ -1119,6 +1119,8 @@ def test_dictionary_term_without_processors(setup_env): """Raw image dictionaries preserve dtype and snapshots, and reject changing schemas.""" from types import SimpleNamespace + import numpy as np + from isaaclab.envs import ManagerBasedRLEnv pixels = torch.full((20, 2, 3), 255, dtype=torch.uint8) @@ -1132,6 +1134,9 @@ def test_dictionary_term_without_processors(setup_env): torch.testing.assert_close(manager.compute()["policy"]["camera"]["rgb"], torch.zeros_like(pixels)) env = SimpleNamespace(observation_manager=manager, action_manager=SimpleNamespace(action_term_dim=[1]), num_envs=20) ManagerBasedRLEnv._configure_gym_env_spaces(env) + space = env.single_observation_space["policy"]["camera"]["rgb"] + assert space.dtype == np.float32 + assert np.isneginf(space.low).all() and np.isposinf(space.high).all() assert env.single_observation_space.contains({"policy": {"camera": {"rgb": first[0].numpy()}}}) data["extra"] = pixels for _ in range(2): From 1a73657572cae01d4a73cf906da37a6e9c1a6b34 Mon Sep 17 00:00:00 2001 From: Matthew Taylor Date: Mon, 19 Oct 2026 18:06:19 -0400 Subject: [PATCH 11/20] Allow optional output parameters on dictionary observation terms --- source/isaaclab/isaaclab/managers/observation_manager.py | 2 -- source/isaaclab/test/managers/test_observation_manager.py | 7 ++++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/source/isaaclab/isaaclab/managers/observation_manager.py b/source/isaaclab/isaaclab/managers/observation_manager.py index 3dcf668f821f..6c860e196e1b 100644 --- a/source/isaaclab/isaaclab/managers/observation_manager.py +++ b/source/isaaclab/isaaclab/managers/observation_manager.py @@ -600,8 +600,6 @@ def _prepare_term_output( raise ValueError(f"Observation term '{group_name}/{term_name}' must declare keyword-only 'out=None'.") sample = term_cfg.func(self._env, **term_cfg.params) if isinstance(sample, dict): - if writes_output: - raise ValueError("Dictionary observation terms cannot declare an 'out' parameter.") return sample shape = tuple(sample.shape) if writes_output: diff --git a/source/isaaclab/test/managers/test_observation_manager.py b/source/isaaclab/test/managers/test_observation_manager.py index c9a7980f3894..428fbd0a8acc 100644 --- a/source/isaaclab/test/managers/test_observation_manager.py +++ b/source/isaaclab/test/managers/test_observation_manager.py @@ -1125,8 +1125,13 @@ def test_dictionary_term_without_processors(setup_env): pixels = torch.full((20, 2, 3), 255, dtype=torch.uint8) data = {"rgb": pixels} + + def camera_data(env, *, out=None): + assert out is None + return data + cfg = ObservationGroupCfg(concatenate_terms=False) - cfg.camera = ObservationTermCfg(func=lambda env: data) + cfg.camera = ObservationTermCfg(func=camera_data) manager = ObservationManager({"policy": cfg}, setup_env) first = manager.compute()["policy"]["camera"]["rgb"] pixels.zero_() From db9a1f9c0813827e3c62dfb923152697557a118c Mon Sep 17 00:00:00 2001 From: Matthew Taylor Date: Tue, 20 Oct 2026 15:14:18 -0400 Subject: [PATCH 12/20] Keep observation terms tensor-valued --- docs/source/api/lab/isaaclab.managers.rst | 42 ---- docs/source/api/lab/isaaclab.utils.rst | 18 +- .../dict-tensor-processors.added.rst | 2 - ...ix-observation-dictionary-schema.fixed.rst | 1 - .../observation-dictionary-pipeline.added.rst | 3 - .../observation-dictionary-pipeline.minor | 0 .../changelog.d/processor-cleanup.fixed.rst | 2 - .../isaaclab/envs/manager_based_env.py | 30 +-- .../isaaclab/envs/manager_based_rl_env.py | 8 - .../isaaclab/managers/manager_term_cfg.py | 17 +- .../isaaclab/managers/observation_manager.py | 189 ++++----------- source/isaaclab/isaaclab/utils/__init__.pyi | 4 - .../utils/tensor_processors/__init__.py | 5 +- .../utils/tensor_processors/__init__.pyi | 5 - .../utils/tensor_processors/_pipeline.py | 126 ---------- .../dict_tensor_processor.py | 47 ---- .../dict_tensor_processor_cfg.py | 39 --- .../tensor_processors/tensor_processor.py | 3 +- .../test/envs/test_env_destructors.py | 41 +--- .../test/managers/test_observation_manager.py | 169 +------------ .../test/utils/test_dict_tensor_processor.py | 228 ------------------ 21 files changed, 74 insertions(+), 905 deletions(-) delete mode 100644 source/isaaclab/changelog.d/dict-tensor-processors.added.rst delete mode 100644 source/isaaclab/changelog.d/fix-observation-dictionary-schema.fixed.rst delete mode 100644 source/isaaclab/changelog.d/observation-dictionary-pipeline.added.rst delete mode 100644 source/isaaclab/changelog.d/observation-dictionary-pipeline.minor delete mode 100644 source/isaaclab/changelog.d/processor-cleanup.fixed.rst delete mode 100644 source/isaaclab/isaaclab/utils/tensor_processors/_pipeline.py delete mode 100644 source/isaaclab/isaaclab/utils/tensor_processors/dict_tensor_processor.py delete mode 100644 source/isaaclab/isaaclab/utils/tensor_processors/dict_tensor_processor_cfg.py delete mode 100644 source/isaaclab/test/utils/test_dict_tensor_processor.py diff --git a/docs/source/api/lab/isaaclab.managers.rst b/docs/source/api/lab/isaaclab.managers.rst index 0a162a925251..b4cd0fc7dfcc 100644 --- a/docs/source/api/lab/isaaclab.managers.rst +++ b/docs/source/api/lab/isaaclab.managers.rst @@ -140,48 +140,6 @@ second full-image copy for normalized uint8 images. Existing observation configu :inherited-members: :show-inheritance: -Dictionary observation processing -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -An :class:`ObservationTermCfg` function may return a dictionary of named Torch tensors or -:class:`~isaaclab.utils.warp.ProxyArray` values. Set the group's ``concatenate_terms=False`` -and configure dictionary processors through the term's ``tensor_processors`` field. -Each processor can read outputs from preceding processors and add or replace dictionary -entries. Untouched entries remain available. - -For example, given a ``FuseSensors`` subclass of -:class:`~isaaclab.utils.tensor_processors.DictTensorProcessor`, configure a term as follows: - -.. code-block:: python - - from isaaclab.managers import ObservationTermCfg - from isaaclab.utils.tensor_processors import DictTensorProcessorCfg - - def sensor_data(env): - robot = env.scene["robot"] - return {"position": robot.data.root_pos_w, "velocity": robot.data.root_lin_vel_w} - - sensor_group.concatenate_terms = False - sensor_group.sensors = ObservationTermCfg( - func=sensor_data, - tensor_processors=[ - DictTensorProcessorCfg( - class_type="my_project.processors:FuseSensors", - inputs=["position", "velocity"], - outputs=["features"], - ), - ], - ) - -For a group named ``policy``, read the result as ``observations["policy"]["sensors"]["features"]``. -The ``sensors`` dictionary also retains ``position`` and ``velocity``. Processors run on every -observation computation, and resetting the manager resets their state for the selected environments. -Returned observations remain valid across later computations. - -Dictionary terms do not support the tensor-only ``noise``, ``clip``, ``scale``, delay, or history -settings. Use dictionary processors for their transformations. They are supported by the standard -observation manager; the experimental Warp manager requires tensor terms. - .. autoclass:: ObservationGroupCfg :members: :exclude-members: __init__ diff --git a/docs/source/api/lab/isaaclab.utils.rst b/docs/source/api/lab/isaaclab.utils.rst index 9e66bb9ccf88..631032e3aa3c 100644 --- a/docs/source/api/lab/isaaclab.utils.rst +++ b/docs/source/api/lab/isaaclab.utils.rst @@ -127,16 +127,14 @@ Mesh operations Tensor processors ~~~~~~~~~~~~~~~~~ -Use :mod:`isaaclab.utils.tensor_processors` to transform observations: - -* ``TensorProcessor``: tensor to tensor. -* ``DictTensorProcessor``: dictionary of named tensors to dictionary of named tensors. - -Configure processors with :attr:`~isaaclab.managers.ObservationTermCfg.tensor_processors`. -Use ``TensorProcessorCfg`` for a term returning one tensor and ``DictTensorProcessorCfg`` for -a term returning a dictionary of named tensors. Dictionary terms require ``concatenate_terms=False``. -Each dictionary processor declares the input keys it needs and the output keys it adds or replaces. -Other keys remain available to subsequent processors. +Use :mod:`isaaclab.utils.tensor_processors` to transform observation tensors. Configure processors +with ``TensorProcessorCfg`` through :attr:`~isaaclab.managers.ObservationTermCfg.tensor_processors`. + +Migration: import ``TensorProcessor`` and ``TensorProcessorCfg`` from +``isaaclab.utils.tensor_processors`` in place of ``ModifierBase`` and ``ModifierCfg`` from +``isaaclab.utils.modifiers``. Replace ``ObservationTermCfg.modifiers`` with +``ObservationTermCfg.tensor_processors``. The processing order and tensor shapes are unchanged. +Old imports raise ``ValueError`` with migration instructions. .. automodule:: isaaclab.utils.tensor_processors :members: diff --git a/source/isaaclab/changelog.d/dict-tensor-processors.added.rst b/source/isaaclab/changelog.d/dict-tensor-processors.added.rst deleted file mode 100644 index 3064479fbaae..000000000000 --- a/source/isaaclab/changelog.d/dict-tensor-processors.added.rst +++ /dev/null @@ -1,2 +0,0 @@ -* Added ``DictTensorProcessor`` and ``DictTensorProcessorCfg`` for named tensor dictionaries with - persistent output storage, ordered composition, and processor-owned reset and cleanup. diff --git a/source/isaaclab/changelog.d/fix-observation-dictionary-schema.fixed.rst b/source/isaaclab/changelog.d/fix-observation-dictionary-schema.fixed.rst deleted file mode 100644 index cdaa87997a2f..000000000000 --- a/source/isaaclab/changelog.d/fix-observation-dictionary-schema.fixed.rst +++ /dev/null @@ -1 +0,0 @@ -* Fixed dictionary observation schema validation to keep rejecting unexpected keys after a failed computation. diff --git a/source/isaaclab/changelog.d/observation-dictionary-pipeline.added.rst b/source/isaaclab/changelog.d/observation-dictionary-pipeline.added.rst deleted file mode 100644 index 8256b4e6eb44..000000000000 --- a/source/isaaclab/changelog.d/observation-dictionary-pipeline.added.rst +++ /dev/null @@ -1,3 +0,0 @@ -* Added dictionary-returning observation terms with ``DictTensorProcessorCfg`` entries in - ``ObservationTermCfg.tensor_processors``, including nested outputs, partial resets, resource cleanup, - and nested float32 Gym observation spaces. diff --git a/source/isaaclab/changelog.d/observation-dictionary-pipeline.minor b/source/isaaclab/changelog.d/observation-dictionary-pipeline.minor deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/source/isaaclab/changelog.d/processor-cleanup.fixed.rst b/source/isaaclab/changelog.d/processor-cleanup.fixed.rst deleted file mode 100644 index 10029c9852eb..000000000000 --- a/source/isaaclab/changelog.d/processor-cleanup.fixed.rst +++ /dev/null @@ -1,2 +0,0 @@ -* Fixed manager-based environment teardown to finish releasing resources when an observation - processor raised during cleanup, while preserving the error and allowing repeated close calls. diff --git a/source/isaaclab/isaaclab/envs/manager_based_env.py b/source/isaaclab/isaaclab/envs/manager_based_env.py index 61c3279597cb..f4026af3bd43 100644 --- a/source/isaaclab/isaaclab/envs/manager_based_env.py +++ b/source/isaaclab/isaaclab/envs/manager_based_env.py @@ -609,23 +609,19 @@ def close(self): # destructor is order-sensitive del self.action_manager - try: - self.observation_manager.close() - finally: - # Processor cleanup errors must not leave the environment partially closed. - del self.observation_manager - del self.event_manager - del self.recorder_manager - del self.scene - - # clear callbacks and instance - self.sim.clear_instance() - - # destroy the window - if self._window is not None: - self._window = None - # update closing status - self._is_closed = True + del self.observation_manager + del self.event_manager + del self.recorder_manager + del self.scene + + # clear callbacks and instance + self.sim.clear_instance() + + # destroy the window + if self._window is not None: + self._window = None + # update closing status + self._is_closed = True """ Helper functions. diff --git a/source/isaaclab/isaaclab/envs/manager_based_rl_env.py b/source/isaaclab/isaaclab/envs/manager_based_rl_env.py index bbc2e45de99b..859cb837cd5b 100644 --- a/source/isaaclab/isaaclab/envs/manager_based_rl_env.py +++ b/source/isaaclab/isaaclab/envs/manager_based_rl_env.py @@ -367,14 +367,6 @@ def _configure_gym_env_spaces(self): group_term_cfgs = self.observation_manager._group_obs_term_cfgs[group_name] term_dict = {} for term_name, term_dim, term_cfg in zip(group_term_names, group_dim, group_term_cfgs): - if isinstance(term_dim, dict): - term_dict[term_name] = gym.spaces.Dict( - { - name: gym.spaces.Box(low=-np.inf, high=np.inf, shape=shape) - for name, shape in term_dim.items() - } - ) - continue low = -np.inf if term_cfg.clip is None else term_cfg.clip[0] high = np.inf if term_cfg.clip is None else term_cfg.clip[1] term_dict[term_name] = gym.spaces.Box(low=low, high=high, shape=term_dim) diff --git a/source/isaaclab/isaaclab/managers/manager_term_cfg.py b/source/isaaclab/isaaclab/managers/manager_term_cfg.py index 28681e80d8b3..0d374c287b08 100644 --- a/source/isaaclab/isaaclab/managers/manager_term_cfg.py +++ b/source/isaaclab/isaaclab/managers/manager_term_cfg.py @@ -15,11 +15,10 @@ from ..utils import configclass from ..utils.noise import NoiseCfg, NoiseModelCfg -from ..utils.tensor_processors import DictTensorProcessorCfg, TensorProcessorCfg +from ..utils.tensor_processors import TensorProcessorCfg from .scene_entity_cfg import SceneEntityCfg if TYPE_CHECKING: - from ..utils.warp import ProxyArray from .action_manager import ActionTerm from .command_manager import CommandTerm from .manager_base import ManagerTermBase @@ -159,20 +158,16 @@ class CurriculumTermCfg(ManagerTermBaseCfg): class ObservationTermCfg(ManagerTermBaseCfg): """Configuration for an observation term.""" - func: Callable[..., torch.Tensor | dict[str, torch.Tensor | ProxyArray] | None] | type[ManagerTermBase] = MISSING + func: Callable[..., torch.Tensor] | type[ManagerTermBase] = MISSING """The function or :class:`ManagerTermBase` subclass to be called. The manager instantiates class-based terms before calling them. The function or the class's ``__call__`` method should take the environment object and any other parameters as input and return the observation signal as torch float tensors of - shape (num_envs, obs_term_dim), or a dictionary of named batched tensors. Dictionary values - may be Torch tensors or ProxyArray objects. Dictionary terms require a non-concatenated - group and remain nested under their term name. They do not support the tensor-only noise, - clip, scale, delay, or history settings; use dictionary processors for their transformations. - Dictionary terms are supported by the standard observation manager. + shape (num_envs, obs_term_dim). """ - tensor_processors: list[TensorProcessorCfg] | list[DictTensorProcessorCfg] | None = None + tensor_processors: list[TensorProcessorCfg] | None = None """The list of tensor processors to apply to the observation in order. Defaults to None, in which case no modifications will be applied. @@ -180,9 +175,7 @@ class ObservationTermCfg(ManagerTermBaseCfg): or stateful, and can be used to apply transformations to the observation data. For example, a processor can be used to normalize the observation data or to apply a rolling average. - Use :class:`~isaaclab.utils.tensor_processors.TensorProcessorCfg` for tensor-returning terms - and :class:`~isaaclab.utils.tensor_processors.DictTensorProcessorCfg` for dictionary-returning - terms. Dictionary processors may add or replace keys; other keys are preserved. + For more information, see :class:`~isaaclab.utils.tensor_processors.TensorProcessorCfg`. """ noise: NoiseCfg | NoiseModelCfg | None = None diff --git a/source/isaaclab/isaaclab/managers/observation_manager.py b/source/isaaclab/isaaclab/managers/observation_manager.py index 6c860e196e1b..9b5423df7e3a 100644 --- a/source/isaaclab/isaaclab/managers/observation_manager.py +++ b/source/isaaclab/isaaclab/managers/observation_manager.py @@ -8,21 +8,16 @@ from __future__ import annotations import inspect -from builtins import ExceptionGroup from collections.abc import Sequence -from contextlib import nullcontext from typing import TYPE_CHECKING import numpy as np import torch -import warp as wp from prettytable import PrettyTable from ..envs.utils.io_descriptors import _warn_io_descriptors_deprecated from ..utils import instantiate, noise, tensor_processors, to_dict from ..utils.buffers import CircularBuffer, DelayBuffer -from ..utils.tensor_processors._pipeline import _DictTensorProcessorPipeline -from ..utils.warp import ProxyArray from .manager_base import ManagerBase, ManagerTermBase from .manager_term_cfg import ObservationGroupCfg, ObservationTermCfg @@ -41,8 +36,7 @@ class ObservationManager(ManagerBase): Each observation group should inherit from the :class:`ObservationGroupCfg` class. Within each group, each observation term should instantiate the :class:`ObservationTermCfg` class. Based on the configuration, the observations in a group can be concatenated into a single tensor or returned as a dictionary with keys - corresponding to the term's name. Dictionary-returning terms remain nested under their term name - and require a non-concatenated group. + corresponding to the term's name. If the observations in a group are concatenated, the shape of the concatenated tensor is computed based on the shapes of the individual observation terms. This information is stored in the :attr:`group_obs_dim` dictionary @@ -92,18 +86,10 @@ def __init__(self, cfg: object, env: ManagerBasedEnv): raise ValueError("Observation manager configuration is None. Please provide a valid configuration.") # call the base class constructor (this will parse the terms config) - self._term_pipelines: dict[tuple[str, str], _DictTensorProcessorPipeline] = {} - try: - super().__init__(cfg, env) - except Exception as error: - try: - self.close() - except Exception as cleanup_error: - error.add_note(f"Failed to close observation pipelines after initialization failed: {cleanup_error}") - raise + super().__init__(cfg, env) # compute combined vector for obs group - self._group_obs_dim: dict[str, tuple[int, ...] | list[tuple[int, ...] | dict[str, tuple[int, ...]]]] = {} + self._group_obs_dim: dict[str, tuple[int, ...] | list[tuple[int, ...]]] = {} for group_name, group_term_dims in self._group_obs_term_dim.items(): # if terms are concatenated, compute the combined shape into a single tuple # otherwise, keep the list of shapes as is @@ -132,25 +118,7 @@ def __init__(self, cfg: object, env: ManagerBasedEnv): self._group_obs_dim[group_name] = group_term_dims # Stores the latest observations. - self._obs_buffer: dict[str, torch.Tensor | dict[str, torch.Tensor | dict[str, torch.Tensor]]] | None = None - - def close(self) -> None: - """Release dictionary processor resources. Repeated calls have no effect.""" - errors = [] - for pipeline in self._term_pipelines.values(): - try: - pipeline.close() - except Exception as error: - errors.append(error) - if errors: - raise ExceptionGroup("Failed to close observation pipelines.", errors) - - def __del__(self): - try: - if hasattr(self, "_term_pipelines"): - self.close() - finally: - super().__del__() + self._obs_buffer: dict[str, torch.Tensor | dict[str, torch.Tensor]] | None = None def __str__(self) -> str: """Returns: A string representation for the observation manager.""" @@ -173,7 +141,7 @@ def __str__(self) -> str: ) for index, (name, dims) in enumerate(obs_terms): # resolve inputs to simplify prints - tab_dims = dims if isinstance(dims, dict) else tuple(dims) + tab_dims = tuple(dims) # add row table.add_row([index, name, tab_dims]) # convert table to string @@ -197,17 +165,12 @@ def get_active_iterable_terms(self, env_idx: int) -> Sequence[tuple[str, Sequenc if self._obs_buffer is None: self.compute() - obs_buffer: dict[str, torch.Tensor | dict[str, torch.Tensor | dict[str, torch.Tensor]]] = self._obs_buffer + obs_buffer: dict[str, torch.Tensor | dict[str, torch.Tensor]] = self._obs_buffer for group_name, _ in self._group_obs_dim.items(): if not self.group_obs_concatenate[group_name]: for name, term in obs_buffer[group_name].items(): - if isinstance(term, dict): - terms.extend( - (f"{group_name}-{name}/{key}", value[env_idx].cpu().tolist()) for key, value in term.items() - ) - else: - terms.append((group_name + "-" + name, term[env_idx].cpu().tolist())) + terms.append((group_name + "-" + name, term[env_idx].cpu().tolist())) continue idx = 0 @@ -241,22 +204,22 @@ def active_terms(self) -> dict[str, list[str]]: return self._group_obs_term_names @property - def group_obs_dim(self) -> dict[str, tuple[int, ...] | list[tuple[int, ...] | dict[str, tuple[int, ...]]]]: + def group_obs_dim(self) -> dict[str, tuple[int, ...] | list[tuple[int, ...]]]: """Shape of computed observations in each group. The key is the group name and the value is the shape of the observation tensor. If the terms in the group are concatenated, the value is a single tuple representing the shape of the concatenated observation tensor. Otherwise, the value is a list of tuples, - where each entry is a term shape or a dictionary of named tensor shapes. + where each tuple represents the shape of the observation tensor for a term in the group. """ return self._group_obs_dim @property - def group_obs_term_dim(self) -> dict[str, list[tuple[int, ...] | dict[str, tuple[int, ...]]]]: + def group_obs_term_dim(self) -> dict[str, list[tuple[int, ...]]]: """Shape of individual observation terms in each group. - The key is the group name and the value is a list of term shapes. Each entry is a tuple for a tensor term - or a dictionary of named shapes for a dictionary term. The order corresponds to the terms in the group. + The key is the group name and the value is a list of tuples representing the shape of the observation terms + in the group. The order of the tuples corresponds to the order of the terms in the group. This matches the order of the terms in the :attr:`active_terms`. """ return self._group_obs_term_dim @@ -378,18 +341,11 @@ def reset(self, env_ids: Sequence[int] | None = None) -> dict[str, float]: # call all tensor_processors that are classes for mod in self._group_obs_class_instances: mod.reset(env_ids=env_ids) - if self._term_pipelines: - stream = wp.ScopedStream(wp.stream_from_torch(self.device)) if "cuda" in self.device else nullcontext() - with stream: - for pipeline in self._term_pipelines.values(): - pipeline.reset(env_ids) # nothing to log here return {} - def compute( - self, update_history: bool = False - ) -> dict[str, torch.Tensor | dict[str, torch.Tensor | dict[str, torch.Tensor]]]: + def compute(self, update_history: bool = False) -> dict[str, torch.Tensor | dict[str, torch.Tensor]]: """Compute the observations per group for all groups. The method computes the observations for all the groups handled by the observation manager. @@ -416,9 +372,7 @@ def compute( self._obs_buffer = obs_buffer return obs_buffer - def compute_group( - self, group_name: str, update_history: bool = False - ) -> torch.Tensor | dict[str, torch.Tensor | dict[str, torch.Tensor]]: + def compute_group(self, group_name: str, update_history: bool = False) -> torch.Tensor | dict[str, torch.Tensor]: """Computes the observations for a given group. The observations for a given group are computed by calling the registered functions for each @@ -435,9 +389,6 @@ def compute_group( 6. Apply delay based on :attr:`ObservationTermCfg.delay_min_lag` and :attr:`ObservationTermCfg.delay_max_lag` 7. Append the delayed observation to history if requested - Dictionary-returning terms apply their dictionary processors and return named tensors - under the term name. Tensor-only post-processing settings are not supported for these terms. - We apply noise to the computed term first to maintain the integrity of how noise affects the data as it truly exists in the real world. If the noise is applied after clipping or scaling, the noise could be artificially constrained or amplified, which might misrepresent how noise naturally occurs @@ -466,10 +417,10 @@ def compute_group( group_term_names = self._group_obs_term_names[group_name] group_obs = dict.fromkeys(group_term_names, None) term_cfgs = self._group_obs_term_cfgs[group_name] - obs_terms = zip(group_term_names, term_cfgs, self._group_obs_term_dim[group_name]) + obs_terms = zip(group_term_names, term_cfgs) # evaluate terms: compute, add noise, clip, scale, custom tensor_processors - for term_name, term_cfg, term_dims in obs_terms: + for term_name, term_cfg in obs_terms: output_spec = self._term_output_specs.get((group_name, term_name)) owned = output_spec is not None if output_spec is not None: @@ -481,10 +432,6 @@ def compute_group( ) else: obs = term_cfg.func(self._env, **term_cfg.params) - if isinstance(term_dims, dict): - pipeline = self._term_pipelines[group_name, term_name] - group_obs[term_name] = self._compute_dict_term(group_name, term_name, pipeline, obs, term_dims) - continue # apply post-processing if term_cfg.tensor_processors is not None: for modifier in term_cfg.tensor_processors: @@ -590,21 +537,22 @@ def _resolve_common_term_cfg(self, term_name: str, term_cfg: ObservationTermCfg, # The destination is supplied by the manager, not by the term configuration. term_cfg.params.pop("out", None) - def _prepare_term_output( - self, group_name: str, term_name: str, term_cfg: ObservationTermCfg - ) -> torch.Tensor | dict[str, torch.Tensor | ProxyArray]: + def _prepare_term_output(self, group_name: str, term_name: str, term_cfg: ObservationTermCfg) -> tuple[int, ...]: """Probe output dimensions and cache destination specifications for explicitly supported terms.""" out_param = inspect.signature(term_cfg.func).parameters.get("out") writes_output = out_param is not None and out_param.kind is inspect.Parameter.KEYWORD_ONLY if writes_output and out_param.default is not None: raise ValueError(f"Observation term '{group_name}/{term_name}' must declare keyword-only 'out=None'.") sample = term_cfg.func(self._env, **term_cfg.params) - if isinstance(sample, dict): - return sample + if not isinstance(sample, torch.Tensor): + raise TypeError( + f"Observation term '{group_name}/{term_name}' must return a torch.Tensor." + f" Received: {type(sample).__name__}." + ) shape = tuple(sample.shape) if writes_output: self._term_output_specs[group_name, term_name] = (shape, sample.dtype, sample.device) - return sample + return shape def _prepare_terms(self): """Prepares a list of observation terms functions.""" @@ -612,7 +560,7 @@ def _prepare_terms(self): # TODO: Make this more convenient by using data structures. self._term_output_specs: dict[tuple[str, str], tuple[tuple[int, ...], torch.dtype, torch.device]] = {} self._group_obs_term_names: dict[str, list[str]] = {} - self._group_obs_term_dim: dict[str, list[tuple[int, ...] | dict[str, tuple[int, ...]]]] = {} + self._group_obs_term_dim: dict[str, list[tuple[int, ...]]] = {} self._group_obs_term_cfgs: dict[str, list[ObservationTermCfg]] = {} self._group_obs_class_term_cfgs: dict[str, list[ObservationTermCfg]] = {} self._group_obs_concatenate: dict[str, bool] = {} @@ -682,18 +630,17 @@ def _prepare_terms(self): # iterate over all the terms in each group for term_name, term_cfg in term_cfg_items: # skip non-obs settings - if ( - term_name - in [ - "enable_corruption", - "concatenate_terms", - "history_length", - "flatten_history_dim", - "history_order", - "concatenate_dim", - ] - or term_cfg is None - ): + if term_name in [ + "enable_corruption", + "concatenate_terms", + "history_length", + "flatten_history_dim", + "history_order", + "concatenate_dim", + ]: + continue + # check for non config + if term_cfg is None: continue if not isinstance(term_cfg, ObservationTermCfg): raise TypeError( @@ -715,15 +662,7 @@ def _prepare_terms(self): self._group_obs_term_names[group_name].append(term_name) self._group_obs_term_cfgs[group_name].append(term_cfg) # call function the first time to fill up dimensions - sample = self._prepare_term_output(group_name, term_name, term_cfg) - if isinstance(term_cfg.func, ManagerTermBase): - self._group_obs_class_term_cfgs[group_name].append(term_cfg) - term_cfg.func.reset() - if isinstance(sample, dict): - dimensions = self._prepare_dict_term(group_name, term_name, term_cfg, sample) - self._group_obs_term_dim[group_name].append(dimensions) - continue - obs_dims = tuple(sample.shape) + obs_dims = self._prepare_term_output(group_name, term_name, term_cfg) # if scale is set, check if single float or tuple if term_cfg.scale is not None: @@ -828,57 +767,11 @@ def _prepare_terms(self): self._group_obs_term_dim[group_name].append(obs_dims[1:]) + # add term in a separate list if term is a class + if isinstance(term_cfg.func, ManagerTermBase): + self._group_obs_class_term_cfgs[group_name].append(term_cfg) + # call reset (in-case above call to get obs dims changed the state) + term_cfg.func.reset() # add history buffers for each group self._group_obs_term_delay_buffer[group_name] = group_entry_delay_buffer self._group_obs_term_history_buffer[group_name] = group_entry_history_buffer - - @staticmethod - def _dict_arrays(data: dict[str, torch.Tensor | ProxyArray]) -> dict[str, ProxyArray]: - if not isinstance(data, dict) or not all( - isinstance(name, str) and isinstance(value, (torch.Tensor, ProxyArray)) for name, value in data.items() - ): - raise TypeError("Dictionary observation terms must return string keys and Tensor or ProxyArray values.") - return { - name: value if isinstance(value, ProxyArray) else ProxyArray(wp.from_torch(value)) - for name, value in data.items() - } - - def _prepare_dict_term( - self, group_name: str, term_name: str, cfg: ObservationTermCfg, sample: dict[str, torch.Tensor | ProxyArray] - ) -> dict[str, tuple[int, ...]]: - if self._group_obs_concatenate[group_name]: - raise ValueError(f"Dictionary observation term '{term_name}' requires concatenate_terms=False.") - if ( - any(value is not None for value in (cfg.noise, cfg.clip, cfg.scale)) - or cfg.delay_max_lag - or cfg.history_length - ): - raise ValueError( - f"Dictionary observation term '{term_name}' does not support noise, clip, scale, delay, or history." - " Use dictionary processors to transform its named tensors." - ) - if any(not isinstance(step, tensor_processors.DictTensorProcessorCfg) for step in cfg.tensor_processors or []): - raise TypeError(f"Dictionary observation term '{term_name}' requires DictTensorProcessorCfg processors.") - stream = wp.ScopedStream(wp.stream_from_torch(self.device)) if "cuda" in self.device else nullcontext() - with stream: - pipeline = _DictTensorProcessorPipeline(self._dict_arrays(sample), cfg.tensor_processors or []) - self._term_pipelines[group_name, term_name] = pipeline - for name, value in pipeline.outputs.items(): - if not value.torch.shape or value.torch.shape[0] != self.num_envs: - raise ValueError(f"Dictionary observation output '{term_name}/{name}' must preserve the batch size.") - return {name: tuple(value.torch.shape[1:]) for name, value in pipeline.outputs.items()} - - def _compute_dict_term( - self, - group_name: str, - term_name: str, - pipeline: _DictTensorProcessorPipeline, - data: dict[str, torch.Tensor | ProxyArray], - expected_dims: dict[str, tuple[int, ...]], - ) -> dict[str, torch.Tensor]: - stream = wp.ScopedStream(wp.stream_from_torch(self.device)) if "cuda" in self.device else nullcontext() - with stream: - result = pipeline(self._dict_arrays(data)) - if result.keys() != expected_dims.keys(): - raise ValueError(f"Dictionary observation term '{group_name}/{term_name}' changed its output keys.") - return {name: value.torch.clone() for name, value in result.items()} diff --git a/source/isaaclab/isaaclab/utils/__init__.pyi b/source/isaaclab/isaaclab/utils/__init__.pyi index 99f7ef906c6b..09c6f837ddeb 100644 --- a/source/isaaclab/isaaclab/utils/__init__.pyi +++ b/source/isaaclab/isaaclab/utils/__init__.pyi @@ -33,8 +33,6 @@ __all__ = [ "PRIMITIVE_MESH_TYPES", "TensorProcessorCfg", "TensorProcessor", - "DictTensorProcessor", - "DictTensorProcessorCfg", "DigitalFilter", "DigitalFilterCfg", "Integrator", @@ -90,8 +88,6 @@ from .mesh import ( PRIMITIVE_MESH_TYPES, ) from .tensor_processors import ( - DictTensorProcessor, - DictTensorProcessorCfg, TensorProcessorCfg, TensorProcessor, DigitalFilter, diff --git a/source/isaaclab/isaaclab/utils/tensor_processors/__init__.py b/source/isaaclab/isaaclab/utils/tensor_processors/__init__.py index c08f57231f29..a009bd9934ac 100644 --- a/source/isaaclab/isaaclab/utils/tensor_processors/__init__.py +++ b/source/isaaclab/isaaclab/utils/tensor_processors/__init__.py @@ -3,16 +3,13 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Stateful and stateless processing for tensors and dictionaries of named tensors. +"""Stateful and stateless processing for tensors. Single-tensor functions and :class:`TensorProcessor` subclasses take and return one tensor. They apply custom operations in the :class:`~isaaclab.managers.ObservationManager`, such as normalization or rolling averages. Configure them through :attr:`~isaaclab.managers.ObservationTermCfg.tensor_processors`. -:class:`DictTensorProcessor` subclasses take and return dictionaries of named -:class:`~isaaclab.utils.warp.ProxyArray` tensors. They own persistent outputs and state. - Usage with a function processor: .. code-block:: python diff --git a/source/isaaclab/isaaclab/utils/tensor_processors/__init__.pyi b/source/isaaclab/isaaclab/utils/tensor_processors/__init__.pyi index 9c39677d11e6..9e533f827764 100644 --- a/source/isaaclab/isaaclab/utils/tensor_processors/__init__.pyi +++ b/source/isaaclab/isaaclab/utils/tensor_processors/__init__.pyi @@ -4,8 +4,6 @@ # SPDX-License-Identifier: BSD-3-Clause __all__ = [ - "DictTensorProcessor", - "DictTensorProcessorCfg", "TensorProcessorCfg", "DigitalFilterCfg", "IntegratorCfg", @@ -20,6 +18,3 @@ __all__ = [ from .tensor_processor_cfg import TensorProcessorCfg, DigitalFilterCfg, IntegratorCfg from .tensor_processor import TensorProcessor from .processors import DigitalFilter, Integrator, bias, clip, scale - -from .dict_tensor_processor import DictTensorProcessor -from .dict_tensor_processor_cfg import DictTensorProcessorCfg diff --git a/source/isaaclab/isaaclab/utils/tensor_processors/_pipeline.py b/source/isaaclab/isaaclab/utils/tensor_processors/_pipeline.py deleted file mode 100644 index ddaffcbd30a3..000000000000 --- a/source/isaaclab/isaaclab/utils/tensor_processors/_pipeline.py +++ /dev/null @@ -1,126 +0,0 @@ -# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). -# All rights reserved. -# -# SPDX-License-Identifier: BSD-3-Clause - -from __future__ import annotations - -from builtins import ExceptionGroup -from collections.abc import Mapping, Sequence - -from ..configclass import instantiate -from ..warp import ProxyArray -from .dict_tensor_processor import DictTensorProcessor -from .dict_tensor_processor_cfg import DictTensorProcessorCfg - - -class _DictTensorProcessorPipeline: - """Compose dictionary processors in order, preserving untouched input keys. - - Each processor receives current named tensors and returns its named outputs. Preparation - constructs processors once; calls reuse their output storage. Each consumer owns its own - pipeline. Input buffers are borrowed, and returned mappings are independent snapshots of - references, not tensor copies. - """ - - def __init__(self, source_buffers: Mapping[str, ProxyArray], processors: Sequence[DictTensorProcessorCfg]): - self._source_buffers = dict(source_buffers) - self.required_inputs = self.required_source_names(processors) - self._processors: list[tuple[DictTensorProcessor, dict[str, ProxyArray]]] = [] - self._closed = False - self.outputs = dict(source_buffers) - self.produced_outputs = frozenset(name for cfg in processors for name in cfg.outputs) - try: - for cfg in processors: - if not cfg.outputs or len(set(cfg.outputs)) != len(cfg.outputs): - raise ValueError("A processor must declare distinct output names.") - missing = set(cfg.inputs) - self.outputs.keys() - if missing: - raise ValueError(f"Missing input buffers for preparation: {sorted(missing)}.") - processor = instantiate(cfg, dict(self.outputs)) - if not isinstance(processor, DictTensorProcessor): - raise TypeError("Dictionary processors must inherit DictTensorProcessor.") - self._processors.append((processor, dict(processor.outputs))) - if set(processor.outputs) != set(cfg.outputs): - raise ValueError("Processor outputs must match its declared output names.") - if any(not isinstance(buffer, ProxyArray) for buffer in processor.outputs.values()): - raise TypeError("Processor outputs must be ProxyArray objects.") - devices = {str(self.outputs[name].device) for name in cfg.inputs} - devices.update(str(buffer.device) for buffer in processor.outputs.values()) - if len(devices) > 1: - raise ValueError("A processor must use one device for its inputs and outputs.") - self.outputs.update(processor.outputs) - except Exception as error: - try: - self.close() - except Exception as cleanup_error: - raise error from cleanup_error - raise - - @property - def has_reset_callbacks(self) -> bool: - """Whether any processor owns resettable state.""" - return any(type(processor).reset is not DictTensorProcessor.reset for processor, _ in self._processors) - - @staticmethod - def _validate_buffer(name: str, value: ProxyArray, reference: ProxyArray) -> None: - if not isinstance(value, ProxyArray): - raise TypeError(f"Buffer {name!r} must be a ProxyArray.") - if value.shape != reference.shape or value.dtype != reference.dtype or value.device != reference.device: - raise ValueError( - f"Buffer {name!r} must match shape, dtype and device" - f" {reference.shape}, {reference.dtype}, {reference.device}." - ) - - @staticmethod - def required_source_names( - processors: Sequence[DictTensorProcessorCfg], published_names: Sequence[str] = () - ) -> frozenset[str]: - """Resolve external keys before allocating buffers or initializing a renderer.""" - required: set[str] = set() - produced: set[str] = set() - for cfg in processors: - required.update(set(cfg.inputs) - produced) - produced.update(cfg.outputs) - required.update(set(published_names) - produced) - return frozenset(required) - - def __call__(self, data: dict[str, ProxyArray]) -> dict[str, ProxyArray]: - """Apply processors and return their results with untouched inputs passed through.""" - if self._closed: - raise RuntimeError("Cannot execute a closed processor pipeline.") - missing = self.required_inputs - data.keys() - if missing: - raise ValueError(f"Missing required buffers: {sorted(missing)}.") - for name in self._source_buffers.keys() & data.keys(): - self._validate_buffer(name, data[name], self._source_buffers[name]) - current = dict(data) - for processor, outputs in self._processors: - result = processor(dict(current)) - if result.keys() != outputs.keys() or any(result[name] is not output for name, output in outputs.items()): - raise ValueError("Processors must return their persistent named output buffers.") - current.update(result) - self.outputs = dict(current) - return current - - def reset(self, env_ids: Sequence[int] | None = None) -> None: - """Reset processor-owned state for selected batch entries.""" - for processor, _ in self._processors: - processor.reset(env_ids) - - def close(self) -> None: - """Close processors in reverse order, including after failed construction.""" - if self._closed: - return - self._closed = True - processors, self._processors = self._processors, [] - self._source_buffers.clear() - self.outputs = {} - errors = [] - for processor, _ in reversed(processors): - try: - processor.close() - except Exception as error: - errors.append(error) - if errors: - raise ExceptionGroup("Failed to close dictionary processors.", errors) diff --git a/source/isaaclab/isaaclab/utils/tensor_processors/dict_tensor_processor.py b/source/isaaclab/isaaclab/utils/tensor_processors/dict_tensor_processor.py deleted file mode 100644 index 1ee875cdabe0..000000000000 --- a/source/isaaclab/isaaclab/utils/tensor_processors/dict_tensor_processor.py +++ /dev/null @@ -1,47 +0,0 @@ -# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). -# All rights reserved. -# -# SPDX-License-Identifier: BSD-3-Clause - -from __future__ import annotations - -from abc import ABC, abstractmethod -from collections.abc import Sequence -from typing import TYPE_CHECKING - -from ..warp import ProxyArray - -if TYPE_CHECKING: - from .dict_tensor_processor_cfg import DictTensorProcessorCfg - - -class DictTensorProcessor(ABC): - """Transform a dictionary of named tensors into a dictionary of named tensors. - - This is the dictionary counterpart of :class:`TensorProcessor`. Tensor values use - :class:`ProxyArray` so processors can use Warp or Torch without copying. Inputs are - borrowed read-only. Constructors inspect their shape, dtype and device and allocate - persistent outputs without reading input values or advancing processor state. - - Subclasses populate ``outputs`` with the buffers they produce, implement ``__call__``, - and override ``reset`` or ``close`` when they own state or external resources. Calls - return those same output buffers; values remain valid until the next call. Consumers - that retain results must copy them. GPU work uses the caller's current Warp stream. - """ - - def __init__(self, cfg: DictTensorProcessorCfg, data: dict[str, ProxyArray]) -> None: - self.cfg = cfg - self.outputs: dict[str, ProxyArray] = {} - - @abstractmethod - def __call__(self, data: dict[str, ProxyArray]) -> dict[str, ProxyArray]: - """Process current inputs and return the named outputs without mutating inputs.""" - raise NotImplementedError - - def reset(self, env_ids: Sequence[int] | None = None) -> None: - """Reset state for selected batch entries, or all entries when None.""" - pass - - def close(self) -> None: - """Release owned resources; repeated calls must have no effect.""" - pass diff --git a/source/isaaclab/isaaclab/utils/tensor_processors/dict_tensor_processor_cfg.py b/source/isaaclab/isaaclab/utils/tensor_processors/dict_tensor_processor_cfg.py deleted file mode 100644 index a0967de549af..000000000000 --- a/source/isaaclab/isaaclab/utils/tensor_processors/dict_tensor_processor_cfg.py +++ /dev/null @@ -1,39 +0,0 @@ -# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). -# All rights reserved. -# -# SPDX-License-Identifier: BSD-3-Clause - -from __future__ import annotations - -from dataclasses import MISSING -from typing import TYPE_CHECKING, Any - -from ..configclass import configclass - -if TYPE_CHECKING: - from .dict_tensor_processor import DictTensorProcessor - - -@configclass -class DictTensorProcessorCfg: - """Configure a processor for named tensors. - - Declare the keys it reads in :attr:`inputs` and the keys it adds or replaces in - :attr:`outputs`. Inputs may come from the source or an earlier processor in the pipeline. - """ - - class_type: type[DictTensorProcessor] | str = MISSING - """Processor class, or a resolvable import string.""" - - inputs: list[str] = MISSING - """Dictionary keys required from the producer or preceding processors.""" - - outputs: list[str] = MISSING - """Dictionary keys produced or replaced by this processor.""" - - params: dict[str, Any] = {} - """Processor-specific settings, accessible through ``cfg.params``. - - Configurations are copied. Explicitly shared device resources must preserve identity - through ``__deepcopy__``; do not place raw Warp allocations in this dictionary. - """ diff --git a/source/isaaclab/isaaclab/utils/tensor_processors/tensor_processor.py b/source/isaaclab/isaaclab/utils/tensor_processors/tensor_processor.py index 7c876f470191..7bd7da2476d6 100644 --- a/source/isaaclab/isaaclab/utils/tensor_processors/tensor_processor.py +++ b/source/isaaclab/isaaclab/utils/tensor_processors/tensor_processor.py @@ -18,8 +18,7 @@ class TensorProcessor(ABC): """Transform one tensor into one tensor, optionally retaining state between calls. - This is the single-tensor counterpart of :class:`DictTensorProcessor`. Subclasses - implement ``__call__`` and ``reset``. Observation processors preserve the input shape. + Subclasses implement ``__call__`` and ``reset``. Observation processors preserve the input shape. Configuration and dimensions are supplied by the observation manager once during construction. """ diff --git a/source/isaaclab/test/envs/test_env_destructors.py b/source/isaaclab/test/envs/test_env_destructors.py index 65907135cf49..0c9cc1a6b260 100644 --- a/source/isaaclab/test/envs/test_env_destructors.py +++ b/source/isaaclab/test/envs/test_env_destructors.py @@ -5,56 +5,17 @@ from __future__ import annotations -from builtins import ExceptionGroup from types import SimpleNamespace -from unittest.mock import Mock import gymnasium as gym import pytest -from isaaclab.envs import DirectMARLEnv, DirectRLEnv, ManagerBasedEnv, ManagerBasedRLEnv +from isaaclab.envs import DirectMARLEnv, DirectRLEnv, ManagerBasedEnv from isaaclab.envs.utils.marl import multi_agent_to_single_agent, multi_agent_with_one_agent pytestmark = pytest.mark.unit -def test_manager_env_close_finishes_after_processor_cleanup_error(): - """Processor errors propagate after releasing the environment, and close remains idempotent.""" - error = ExceptionGroup("Failed to close observation pipelines.", [RuntimeError("Processor cleanup failed")]) - observation_manager = SimpleNamespace(close=Mock(side_effect=error)) - sim = SimpleNamespace(stop=Mock(), clear_instance=Mock()) - env = object.__new__(ManagerBasedRLEnv) - env._is_closed = False - env._window = object() - env.sim = sim - env.scene = object() - env.action_manager = object() - env.observation_manager = observation_manager - env.event_manager = object() - env.recorder_manager = object() - env.command_manager = object() - env.reward_manager = object() - env.termination_manager = object() - env.curriculum_manager = object() - env.obs_buf = {"policy": object()} - - try: - with pytest.raises(ExceptionGroup) as raised: - env.close() - assert raised.value is error - sim.clear_instance.assert_called_once_with() - assert env._is_closed - assert env._window is None - assert not hasattr(env, "scene") - assert not hasattr(env, "observation_manager") - assert not env.obs_buf - env.close() - sim.stop.assert_called_once_with() - observation_manager.close.assert_called_once_with() - finally: - env._is_closed = True - - class _FakeMultiAgentEnv: possible_agents = ["agent_0", "agent_1"] observation_spaces = { diff --git a/source/isaaclab/test/managers/test_observation_manager.py b/source/isaaclab/test/managers/test_observation_manager.py index 428fbd0a8acc..edc686c97c31 100644 --- a/source/isaaclab/test/managers/test_observation_manager.py +++ b/source/isaaclab/test/managers/test_observation_manager.py @@ -23,7 +23,6 @@ ObservationTermCfg, RewardTermCfg, ) -from isaaclab.test.utils import DeviceScope, test_devices from isaaclab.utils import DelayBuffer, configclass, noise, tensor_processors, to_dict, update_from_dict, validate from isaaclab.utils.string import string_to_callable @@ -1032,169 +1031,9 @@ def ordinary_term(env, out=None): torch.testing.assert_close(result, expected) -@pytest.mark.parametrize("device", test_devices(DeviceScope.CPU_AND_DEFAULT_CUDA)) -def test_dictionary_term_processing_reset_and_spaces(setup_env, device): - """A term's dictionary stays nested, composes processors, and owns stable snapshots.""" - from types import SimpleNamespace - - import warp as wp - - from isaaclab.envs import ManagerBasedRLEnv - from isaaclab.utils.warp import ProxyArray - - closed = [] - - class Accumulate(tensor_processors.DictTensorProcessor): - def __init__(self, cfg, data): - super().__init__(cfg, data) - source = data[cfg.inputs[0]] - self.outputs[cfg.outputs[0]] = ProxyArray(wp.zeros((source.shape[0], 1), device=source.device)) - - def __call__(self, data): - output = self.outputs[self.cfg.outputs[0]].torch - for name in self.cfg.inputs: - output.add_(data[name].torch.sum(dim=1, keepdim=True) * self.cfg.params.get("scale", 1)) - return self.outputs - - def reset(self, env_ids=None): - output = self.outputs[self.cfg.outputs[0]] - if env_ids is None: - output.warp.zero_() - else: - output.torch[env_ids] = 0 - - def close(self): - closed.append(self.cfg.params.get("scale", 1)) - - def sensor_data(env): - return {"position": env.data.pos_w, "velocity": ProxyArray(wp.from_torch(env.data.lin_vel_w))} - - setup_env = setup_env._replace(device=device, data=MyDataClass(setup_env.num_envs, device)) - setup_env.data.pos_w.fill_(1) - setup_env.data.lin_vel_w.fill_(1) - cfg = ObservationGroupCfg(concatenate_terms=False) - cfg.sensors = ObservationTermCfg( - func=sensor_data, - tensor_processors=[ - tensor_processors.DictTensorProcessorCfg(Accumulate, ["position", "velocity"], ["total"]), - tensor_processors.DictTensorProcessorCfg(Accumulate, ["total"], ["total"], {"scale": 2}), - ], - ) - cfg.position = ObservationTermCfg(func=pos_w_data, scale=2, history_length=2) - manager = ObservationManager({"policy": cfg}, setup_env) - first = manager.compute(update_history=True)["policy"] - assert list(first) == ["sensors", "position"] - assert list(first["sensors"]) == ["position", "velocity", "total"] - assert manager.group_obs_dim["policy"] == [{"position": (3,), "velocity": (3,), "total": (1,)}, (6,)] - torch.testing.assert_close(first["position"], torch.full((20, 6), 2.0, device=device)) - torch.testing.assert_close(first["sensors"]["velocity"], torch.ones(20, 3, device=device)) - torch.testing.assert_close(first["sensors"]["total"], torch.full((20, 1), 12.0, device=device)) - second = manager.compute()["policy"] - torch.testing.assert_close(second["sensors"]["total"], torch.full((20, 1), 36.0, device=device)) - torch.testing.assert_close(first["sensors"]["total"], torch.full((20, 1), 12.0, device=device)) - manager.reset([1]) - third = manager.compute(update_history=True)["policy"] - expected = torch.full((20, 1), 72.0, device=device) - expected[1] = 12.0 - torch.testing.assert_close(third["sensors"]["total"], expected) - torch.testing.assert_close(setup_env.data.pos_w, torch.ones(20, 3, device=device)) - manager.reset() - torch.testing.assert_close(manager.compute()["policy"]["sensors"]["total"], first["sensors"]["total"]) - assert "sensors" in str(manager) - assert "policy-sensors/total" in dict(manager.get_active_iterable_terms(0)) - - env = SimpleNamespace(observation_manager=manager, action_manager=SimpleNamespace(action_term_dim=[1]), num_envs=20) - ManagerBasedRLEnv._configure_gym_env_spaces(env) - sample = { - "sensors": {name: value[0].cpu().numpy() for name, value in third["sensors"].items()}, - "position": third["position"][0].cpu().numpy(), - } - assert env.single_observation_space.contains({"policy": sample}) - manager.close() - manager.close() - assert closed == [2, 1] - - -def test_dictionary_term_without_processors(setup_env): - """Raw image dictionaries preserve dtype and snapshots, and reject changing schemas.""" - from types import SimpleNamespace - - import numpy as np - - from isaaclab.envs import ManagerBasedRLEnv - - pixels = torch.full((20, 2, 3), 255, dtype=torch.uint8) - data = {"rgb": pixels} - - def camera_data(env, *, out=None): - assert out is None - return data - +def test_dictionary_term_rejected(setup_env): + """Non-concatenated groups still require each term to return a tensor.""" cfg = ObservationGroupCfg(concatenate_terms=False) - cfg.camera = ObservationTermCfg(func=camera_data) - manager = ObservationManager({"policy": cfg}, setup_env) - first = manager.compute()["policy"]["camera"]["rgb"] - pixels.zero_() - torch.testing.assert_close(first, torch.full((20, 2, 3), 255, dtype=torch.uint8)) - torch.testing.assert_close(manager.compute()["policy"]["camera"]["rgb"], torch.zeros_like(pixels)) - env = SimpleNamespace(observation_manager=manager, action_manager=SimpleNamespace(action_term_dim=[1]), num_envs=20) - ManagerBasedRLEnv._configure_gym_env_spaces(env) - space = env.single_observation_space["policy"]["camera"]["rgb"] - assert space.dtype == np.float32 - assert np.isneginf(space.low).all() and np.isposinf(space.high).all() - assert env.single_observation_space.contains({"policy": {"camera": {"rgb": first[0].numpy()}}}) - data["extra"] = pixels - for _ in range(2): - with pytest.raises(ValueError, match="changed its output keys"): - manager.compute() - del data["extra"] - recovered = manager.compute()["policy"]["camera"] - assert env.single_observation_space.contains( - {"policy": {"camera": {name: value[0].numpy() for name, value in recovered.items()}}} - ) - manager.close() - - -@pytest.mark.parametrize("invalid", ["concatenation", "missing_input", "batch_size", "scale", "processor_type"]) -def test_dictionary_term_rejects_invalid_configuration(setup_env, invalid): - """Reject incompatible settings and dependencies without advancing processor state.""" - import warp as wp - - from isaaclab.utils.warp import ProxyArray - - closed = [] - - class WrongBatch(tensor_processors.DictTensorProcessor): - def __init__(self, cfg, data): - super().__init__(cfg, data) - self.outputs["result"] = ProxyArray(wp.zeros((1, 3), device="cpu")) - - def __call__(self, data): - raise AssertionError("Preparation must not advance processor state.") - - def close(self): - closed.append(True) - - cfg = ObservationGroupCfg(concatenate_terms=invalid == "concatenation") - cfg.sensors = ObservationTermCfg( - func=lambda env: {"position": env.data.pos_w}, - scale=2 if invalid == "scale" else None, - tensor_processors=[ - tensor_processors.DictTensorProcessorCfg( - WrongBatch, ["missing" if invalid == "missing_input" else "position"], ["result"] - ) - ], - ) - if invalid == "processor_type": - cfg.sensors.tensor_processors = [tensor_processors.TensorProcessorCfg(func=tensor_processors.clip)] - match = { - "concatenation": "concatenate_terms=False", - "missing_input": "Missing input", - "batch_size": "batch size", - "scale": "does not support", - "processor_type": "DictTensorProcessorCfg", - } - error = TypeError if invalid == "processor_type" else ValueError - with pytest.raises(error, match=match[invalid]): + cfg.sensors = ObservationTermCfg(func=lambda env: {"position": env.data.pos_w}) + with pytest.raises(TypeError, match=r"torch\.Tensor"): ObservationManager({"policy": cfg}, setup_env) - assert closed == ([True] if invalid == "batch_size" else []) diff --git a/source/isaaclab/test/utils/test_dict_tensor_processor.py b/source/isaaclab/test/utils/test_dict_tensor_processor.py deleted file mode 100644 index 19f555e1b10b..000000000000 --- a/source/isaaclab/test/utils/test_dict_tensor_processor.py +++ /dev/null @@ -1,228 +0,0 @@ -# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). -# All rights reserved. -# -# SPDX-License-Identifier: BSD-3-Clause - -"""Contracts for buffer routing, ownership and lifecycle independent of camera rendering.""" - -from builtins import ExceptionGroup - -import pytest -import torch -import warp as wp - -from isaaclab.utils.tensor_processors import DictTensorProcessor, DictTensorProcessorCfg -from isaaclab.utils.tensor_processors._pipeline import _DictTensorProcessorPipeline -from isaaclab.utils.warp import ProxyArray - -pytestmark = pytest.mark.unit - - -class _Scale(DictTensorProcessor): - def __init__(self, cfg, data): - super().__init__(cfg, data) - self.outputs[cfg.outputs[0]] = ProxyArray(wp.empty_like(data[cfg.inputs[0]].warp)) - - def __call__(self, data): - torch.mul( - data[self.cfg.inputs[0]].torch, - self.cfg.params.get("factor", 2.0), - out=self.outputs[self.cfg.outputs[0]].torch, - ) - return dict(self.outputs) - - -class _Composite(DictTensorProcessor): - def __init__(self, cfg, data): - super().__init__(cfg, data) - color = data["color"] - self.outputs["color"] = ProxyArray(wp.empty((color.shape[0], 1, 1, 3), dtype=color.dtype, device=color.device)) - - def __call__(self, data): - torch.sub(data["color"].torch[:, :1, :1], data["range"].torch[:, :1, :1], out=self.outputs["color"].torch) - return dict(self.outputs) - - -@pytest.fixture -def composite_pipe(): - sources = { - "raw_color": ProxyArray(wp.zeros((2, 2, 2, 3), dtype=wp.float32, device="cpu")), - "range": ProxyArray(wp.zeros((2, 2, 2, 1), dtype=wp.float32, device="cpu")), - } - steps = [ - DictTensorProcessorCfg(class_type=_Scale, inputs=["raw_color"], outputs=["color"]), - DictTensorProcessorCfg( - class_type=_Composite, - inputs=["color", "range"], - outputs=["color"], - ), - ] - assert _DictTensorProcessorPipeline.required_source_names(steps, ["color", "labels"]) == { - "raw_color", - "range", - "labels", - } - pipe = _DictTensorProcessorPipeline(sources, steps) - yield pipe - pipe.close() - - -def test_ordered_multi_input_operations_preserve_sources_and_reuse_output_storage(composite_pipe): - """Bindings consume earlier replacements; shape changes and strided inputs preserve ownership.""" - backing = torch.full((2, 2, 2, 4), 3.0) - color = ProxyArray(wp.from_torch(backing[..., :3])) - depth = ProxyArray(wp.full((2, 2, 2, 1), 2.0, dtype=wp.float32, device="cpu")) - labels = ProxyArray(wp.ones((2, 2, 2, 1), dtype=wp.int32, device="cpu")) - sources = {"raw_color": color, "range": depth, "labels": labels} - output = composite_pipe(sources) - snapshot = output["color"].torch.clone() - destination = output["color"] - - torch.testing.assert_close(snapshot, torch.full((2, 1, 1, 3), 4.0)) - torch.testing.assert_close(backing, torch.full_like(backing, 3.0)) - torch.testing.assert_close(depth.torch, torch.full_like(depth.torch, 2.0)) - assert output["labels"] is labels - assert output["raw_color"] is color - assert set(sources) == {"raw_color", "range", "labels"} - assert composite_pipe.outputs["color"].shape == (2, 1, 1, 3) - - backing.fill_(5.0) - updated = composite_pipe(sources) - assert updated["color"] is destination - torch.testing.assert_close(destination.torch, torch.full_like(snapshot, 8.0)) - torch.testing.assert_close(snapshot, torch.full_like(snapshot, 4.0)) - updated.pop("color") - assert composite_pipe.outputs["color"] is destination - - -@pytest.mark.parametrize("invalid", ["missing", "shape", "dtype"]) -def test_invalid_source_buffers_are_rejected_before_processing(invalid): - """Missing or incompatible external storage must fail before a callback reads it.""" - calls = [] - source = ProxyArray(wp.zeros((2, 1, 1, 1), dtype=wp.float32, device="cpu")) - - class CountCalls(_Scale): - def __call__(self, data): - calls.append(True) - return super().__call__(data) - - pipe = _DictTensorProcessorPipeline( - {"source": source}, - [DictTensorProcessorCfg(class_type=CountCalls, inputs=["source"], outputs=["result"])], - ) - try: - shape = (1, 1, 1, 1) if invalid == "shape" else source.shape - dtype = wp.uint8 if invalid == "dtype" else wp.float32 - inputs = {} if invalid == "missing" else {"source": ProxyArray(wp.zeros(shape, dtype=dtype, device="cpu"))} - with pytest.raises(ValueError, match="Missing required|shape, dtype and device"): - pipe(inputs) - assert not calls - finally: - pipe.close() - - -@pytest.mark.parametrize("invalid", ["missing_dependency", "output_names", "duplicate_outputs", "output_type"]) -def test_invalid_declarations_release_processors(invalid): - """Construction failure closes each successfully constructed processor in reverse order.""" - closed = [] - source = ProxyArray(wp.zeros((2, 1, 1, 1), dtype=wp.float32, device="cpu")) - - class Tracked(_Scale): - def __init__(self, cfg, data): - super().__init__(cfg, data) - if cfg.params["name"] == "second": - if invalid == "output_names": - self.outputs = {"wrong": next(iter(self.outputs.values()))} - elif invalid == "output_type": - self.outputs[cfg.outputs[0]] = self.outputs[cfg.outputs[0]].warp - - def close(self): - closed.append(self.cfg.params["name"]) - - first = DictTensorProcessorCfg(Tracked, ["source"], ["middle"], {"name": "first"}) - second = DictTensorProcessorCfg(Tracked, ["middle"], ["result"], {"name": "second"}) - if invalid == "missing_dependency": - second.inputs = ["absent"] - message = "Missing input buffers for preparation" - elif invalid == "output_names": - message = "declared output names" - elif invalid == "duplicate_outputs": - second.outputs = ["result", "result"] - message = "distinct output names" - else: - message = "outputs must be ProxyArray" - with pytest.raises(TypeError if invalid == "output_type" else ValueError, match=message): - _DictTensorProcessorPipeline({"source": source}, [first, second]) - assert closed == (["second", "first"] if invalid in ("output_names", "output_type") else ["first"]) - - -@pytest.mark.parametrize("close_fails", [False, True], ids=["normal_close", "failing_close"]) -def test_reset_selects_batch_entries_and_close_releases_operations_in_reverse_order(close_fails): - """State belongs to each processor; selected resets and idempotent teardown preserve its lifecycle.""" - closed = [] - source = ProxyArray(wp.zeros((2, 1, 1, 1), dtype=wp.float32, device="cpu")) - - class History(_Scale): - def __init__(self, cfg, data): - super().__init__(cfg, data) - self.history = torch.zeros(data["value"].shape) - - def __call__(self, data): - self.history.add_(data["value"].torch) - self.outputs["value"].torch.copy_(self.history) - return dict(self.outputs) - - def reset(self, env_ids=None): - self.history[slice(None) if env_ids is None else env_ids] = 0 - - def close(self): - name = self.cfg.params["name"] - closed.append(name) - if close_fails and name == "second": - raise RuntimeError("Processor cleanup failed") - - pipe = _DictTensorProcessorPipeline( - {"value": source}, - [DictTensorProcessorCfg(History, ["value"], ["value"], {"name": name}) for name in ("first", "second")], - ) - source = {"value": ProxyArray(wp.ones(source.shape, dtype=wp.float32, device="cpu"))} - try: - torch.testing.assert_close(pipe(source)["value"].torch.flatten(), torch.tensor([1.0, 1.0])) - torch.testing.assert_close(pipe(source)["value"].torch.flatten(), torch.tensor([3.0, 3.0])) - pipe.reset([0]) - torch.testing.assert_close(pipe(source)["value"].torch.flatten(), torch.tensor([1.0, 6.0])) - pipe.reset() - torch.testing.assert_close(pipe(source)["value"].torch.flatten(), torch.tensor([1.0, 1.0])) - finally: - if close_fails: - with pytest.raises(ExceptionGroup, match="Failed to close"): - pipe.close() - else: - pipe.close() - pipe.close() - assert closed == ["second", "first"] - with pytest.raises(RuntimeError, match="closed"): - pipe(source) - - -@pytest.mark.parametrize("replacement", ["missing", "new_storage"]) -def test_processor_cannot_replace_published_output_storage(replacement): - """Camera consumers retain prepared tensors, so missing or replaced outputs must fail.""" - - class ReplacesOutput(_Scale): - def __call__(self, data): - super().__call__(data) - if replacement == "missing": - return {} - self.outputs["result"] = ProxyArray(wp.empty_like(data["source"].warp)) - return dict(self.outputs) - - source = ProxyArray(wp.ones((2,), dtype=wp.float32, device="cpu")) - pipe = _DictTensorProcessorPipeline( - {"source": source}, [DictTensorProcessorCfg(ReplacesOutput, ["source"], ["result"])] - ) - try: - with pytest.raises(ValueError, match="persistent named output buffers"): - pipe({"source": source}) - finally: - pipe.close() From 236c3aec776b793cd503ef29d23845b64cbe76d1 Mon Sep 17 00:00:00 2001 From: Matthew Taylor Date: Tue, 20 Oct 2026 15:24:38 -0400 Subject: [PATCH 13/20] Add dictionary tensor processor configuration and interface --- docs/source/api/lab/isaaclab.utils.rst | 13 +++++ .../dict-tensor-processors.added.rst | 2 + source/isaaclab/isaaclab/utils/__init__.pyi | 4 ++ .../utils/tensor_processors/__init__.py | 7 ++- .../utils/tensor_processors/__init__.pyi | 5 ++ .../dict_tensor_processor.py | 47 +++++++++++++++++++ .../dict_tensor_processor_cfg.py | 41 ++++++++++++++++ 7 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 source/isaaclab/changelog.d/dict-tensor-processors.added.rst create mode 100644 source/isaaclab/isaaclab/utils/tensor_processors/dict_tensor_processor.py create mode 100644 source/isaaclab/isaaclab/utils/tensor_processors/dict_tensor_processor_cfg.py diff --git a/docs/source/api/lab/isaaclab.utils.rst b/docs/source/api/lab/isaaclab.utils.rst index 631032e3aa3c..34ec4b5a7bf3 100644 --- a/docs/source/api/lab/isaaclab.utils.rst +++ b/docs/source/api/lab/isaaclab.utils.rst @@ -130,6 +130,19 @@ Tensor processors Use :mod:`isaaclab.utils.tensor_processors` to transform observation tensors. Configure processors with ``TensorProcessorCfg`` through :attr:`~isaaclab.managers.ObservationTermCfg.tensor_processors`. +For named tensor dictionaries, implement :class:`~isaaclab.utils.tensor_processors.DictTensorProcessor` +and configure it with :class:`~isaaclab.utils.tensor_processors.DictTensorProcessorCfg`. +Set ``class_type`` to the implementation class or a resolvable import string, ``inputs`` and +``outputs`` to the dictionary keys it reads and produces, and ``params`` to its settings. +Construct the processor with ``instantiate(cfg, data)``, where ``data`` maps input names to +:class:`~isaaclab.utils.warp.ProxyArray` values. String-valued ``class_type`` fields resolve lazily +through ``configclass`` when the implementation is needed. Processors read settings from +``cfg.params``; these settings are not forwarded as constructor keyword arguments. + +Observation terms remain tensor-valued: ``ObservationTermCfg.tensor_processors`` accepts only +``TensorProcessorCfg``. Dictionary processors are constructed directly; camera attachment and +dictionary processor pipelines are not provided by this API. + Migration: import ``TensorProcessor`` and ``TensorProcessorCfg`` from ``isaaclab.utils.tensor_processors`` in place of ``ModifierBase`` and ``ModifierCfg`` from ``isaaclab.utils.modifiers``. Replace ``ObservationTermCfg.modifiers`` with diff --git a/source/isaaclab/changelog.d/dict-tensor-processors.added.rst b/source/isaaclab/changelog.d/dict-tensor-processors.added.rst new file mode 100644 index 000000000000..e9002867087b --- /dev/null +++ b/source/isaaclab/changelog.d/dict-tensor-processors.added.rst @@ -0,0 +1,2 @@ +* Added the ``DictTensorProcessor`` interface and ``DictTensorProcessorCfg`` for configuring + processors of named tensor dictionaries independently of tensor-valued observation terms. diff --git a/source/isaaclab/isaaclab/utils/__init__.pyi b/source/isaaclab/isaaclab/utils/__init__.pyi index 09c6f837ddeb..99f7ef906c6b 100644 --- a/source/isaaclab/isaaclab/utils/__init__.pyi +++ b/source/isaaclab/isaaclab/utils/__init__.pyi @@ -33,6 +33,8 @@ __all__ = [ "PRIMITIVE_MESH_TYPES", "TensorProcessorCfg", "TensorProcessor", + "DictTensorProcessor", + "DictTensorProcessorCfg", "DigitalFilter", "DigitalFilterCfg", "Integrator", @@ -88,6 +90,8 @@ from .mesh import ( PRIMITIVE_MESH_TYPES, ) from .tensor_processors import ( + DictTensorProcessor, + DictTensorProcessorCfg, TensorProcessorCfg, TensorProcessor, DigitalFilter, diff --git a/source/isaaclab/isaaclab/utils/tensor_processors/__init__.py b/source/isaaclab/isaaclab/utils/tensor_processors/__init__.py index a009bd9934ac..1b94b2eac403 100644 --- a/source/isaaclab/isaaclab/utils/tensor_processors/__init__.py +++ b/source/isaaclab/isaaclab/utils/tensor_processors/__init__.py @@ -3,13 +3,18 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Stateful and stateless processing for tensors. +"""Stateful and stateless processing for tensors and dictionaries of named tensors. Single-tensor functions and :class:`TensorProcessor` subclasses take and return one tensor. They apply custom operations in the :class:`~isaaclab.managers.ObservationManager`, such as normalization or rolling averages. Configure them through :attr:`~isaaclab.managers.ObservationTermCfg.tensor_processors`. +:class:`DictTensorProcessor` and :class:`DictTensorProcessorCfg` define a separate interface +for named :class:`~isaaclab.utils.warp.ProxyArray` buffers. Construct these processors with +:func:`~isaaclab.utils.instantiate` and call them directly. Observation terms return one tensor +and use :class:`TensorProcessorCfg`. + Usage with a function processor: .. code-block:: python diff --git a/source/isaaclab/isaaclab/utils/tensor_processors/__init__.pyi b/source/isaaclab/isaaclab/utils/tensor_processors/__init__.pyi index 9e533f827764..9c39677d11e6 100644 --- a/source/isaaclab/isaaclab/utils/tensor_processors/__init__.pyi +++ b/source/isaaclab/isaaclab/utils/tensor_processors/__init__.pyi @@ -4,6 +4,8 @@ # SPDX-License-Identifier: BSD-3-Clause __all__ = [ + "DictTensorProcessor", + "DictTensorProcessorCfg", "TensorProcessorCfg", "DigitalFilterCfg", "IntegratorCfg", @@ -18,3 +20,6 @@ __all__ = [ from .tensor_processor_cfg import TensorProcessorCfg, DigitalFilterCfg, IntegratorCfg from .tensor_processor import TensorProcessor from .processors import DigitalFilter, Integrator, bias, clip, scale + +from .dict_tensor_processor import DictTensorProcessor +from .dict_tensor_processor_cfg import DictTensorProcessorCfg diff --git a/source/isaaclab/isaaclab/utils/tensor_processors/dict_tensor_processor.py b/source/isaaclab/isaaclab/utils/tensor_processors/dict_tensor_processor.py new file mode 100644 index 000000000000..1ee875cdabe0 --- /dev/null +++ b/source/isaaclab/isaaclab/utils/tensor_processors/dict_tensor_processor.py @@ -0,0 +1,47 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Sequence +from typing import TYPE_CHECKING + +from ..warp import ProxyArray + +if TYPE_CHECKING: + from .dict_tensor_processor_cfg import DictTensorProcessorCfg + + +class DictTensorProcessor(ABC): + """Transform a dictionary of named tensors into a dictionary of named tensors. + + This is the dictionary counterpart of :class:`TensorProcessor`. Tensor values use + :class:`ProxyArray` so processors can use Warp or Torch without copying. Inputs are + borrowed read-only. Constructors inspect their shape, dtype and device and allocate + persistent outputs without reading input values or advancing processor state. + + Subclasses populate ``outputs`` with the buffers they produce, implement ``__call__``, + and override ``reset`` or ``close`` when they own state or external resources. Calls + return those same output buffers; values remain valid until the next call. Consumers + that retain results must copy them. GPU work uses the caller's current Warp stream. + """ + + def __init__(self, cfg: DictTensorProcessorCfg, data: dict[str, ProxyArray]) -> None: + self.cfg = cfg + self.outputs: dict[str, ProxyArray] = {} + + @abstractmethod + def __call__(self, data: dict[str, ProxyArray]) -> dict[str, ProxyArray]: + """Process current inputs and return the named outputs without mutating inputs.""" + raise NotImplementedError + + def reset(self, env_ids: Sequence[int] | None = None) -> None: + """Reset state for selected batch entries, or all entries when None.""" + pass + + def close(self) -> None: + """Release owned resources; repeated calls must have no effect.""" + pass diff --git a/source/isaaclab/isaaclab/utils/tensor_processors/dict_tensor_processor_cfg.py b/source/isaaclab/isaaclab/utils/tensor_processors/dict_tensor_processor_cfg.py new file mode 100644 index 000000000000..0b1b2b16d3c6 --- /dev/null +++ b/source/isaaclab/isaaclab/utils/tensor_processors/dict_tensor_processor_cfg.py @@ -0,0 +1,41 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +from __future__ import annotations + +from dataclasses import MISSING +from typing import TYPE_CHECKING, Any + +from ..configclass import configclass + +if TYPE_CHECKING: + from .dict_tensor_processor import DictTensorProcessor + + +@configclass +class DictTensorProcessorCfg: + """Configure a processor for named tensors. + + Declare the keys it reads in :attr:`inputs` and the keys it adds or replaces in + :attr:`outputs`. Construct the processor with :func:`~isaaclab.utils.instantiate`, + passing this configuration and the dictionary of input buffers. String class references + resolve lazily at construction. + """ + + class_type: type[DictTensorProcessor] | str = MISSING + """Processor class, or a resolvable import string.""" + + inputs: list[str] = MISSING + """Dictionary keys required from the producer or preceding processors.""" + + outputs: list[str] = MISSING + """Dictionary keys produced or replaced by this processor.""" + + params: dict[str, Any] = {} + """Processor-specific settings, accessible through ``cfg.params``. + + Configuration fields are copied at construction. Shared device resources must preserve identity + through ``__deepcopy__``; do not place raw Warp allocations in this dictionary. + """ From 9df69eb24fcafe26f18a2ff491f9dc0823a1ffaa Mon Sep 17 00:00:00 2001 From: Matthew Taylor Date: Tue, 20 Oct 2026 15:40:21 -0400 Subject: [PATCH 14/20] Resolve nested dictionary processor parameters --- .../dict-tensor-processors.added.rst | 1 + .../isaaclab/isaaclab/managers/manager_base.py | 4 ++-- .../isaaclab/test/managers/test_manager_base.py | 17 ++++++++++++++++- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/source/isaaclab/changelog.d/dict-tensor-processors.added.rst b/source/isaaclab/changelog.d/dict-tensor-processors.added.rst index e9002867087b..bf044370ee59 100644 --- a/source/isaaclab/changelog.d/dict-tensor-processors.added.rst +++ b/source/isaaclab/changelog.d/dict-tensor-processors.added.rst @@ -1,2 +1,3 @@ * Added the ``DictTensorProcessor`` interface and ``DictTensorProcessorCfg`` for configuring processors of named tensor dictionaries independently of tensor-valued observation terms. + Managers resolve nested scene entities and manager terms in dictionary processor parameters. diff --git a/source/isaaclab/isaaclab/managers/manager_base.py b/source/isaaclab/isaaclab/managers/manager_base.py index 843d3c1d429c..c350e9409051 100644 --- a/source/isaaclab/isaaclab/managers/manager_base.py +++ b/source/isaaclab/isaaclab/managers/manager_base.py @@ -16,7 +16,7 @@ from ..physics import PhysicsEvent, PhysicsManager from ..utils import string as string_utils from ..utils import string_to_callable, to_dict -from ..utils.tensor_processors import TensorProcessorCfg +from ..utils.tensor_processors import DictTensorProcessorCfg, TensorProcessorCfg from .manager_term_cfg import ManagerTermBaseCfg from .scene_entity_cfg import SceneEntityCfg @@ -434,7 +434,7 @@ def _resolve_param_value( self._scene_entity_cfgs.append(value) elif isinstance(value, ManagerTermBaseCfg): self._process_term_cfg_at_play(f"{term_name}.{key}", value) - elif isinstance(value, TensorProcessorCfg): + elif isinstance(value, (TensorProcessorCfg, DictTensorProcessorCfg)): for field in fields(value): field_value = getattr(value, field.name) resolved_value = self._resolve_param_value( diff --git a/source/isaaclab/test/managers/test_manager_base.py b/source/isaaclab/test/managers/test_manager_base.py index a123273d3791..c161ba240b46 100644 --- a/source/isaaclab/test/managers/test_manager_base.py +++ b/source/isaaclab/test/managers/test_manager_base.py @@ -85,6 +85,7 @@ class NestedFieldTermCfg(ManagerTermBaseCfg): nested_term: ManagerTermBaseCfg = ManagerTermBaseCfg(func=increment_dummy1_by_one) modifier: tensor_processors.TensorProcessorCfg = tensor_processors.TensorProcessorCfg(func=increment_dummy1_by_one) + dict_processor: tensor_processors.DictTensorProcessorCfg | None = None metadata: dict[str, str] = {"func": f"{__name__}:reset_dummy2_to_zero"} opaque: OpaqueCfg = OpaqueCfg() @@ -195,10 +196,20 @@ def test_string_func_in_nested_term_cfg(env): def test_resolution_walks_declared_term_fields_outside_params(env): - """Resolve nested term and modifier callables without interpreting arbitrary ``func`` keys.""" + """Resolve processor parameters and nested terms while preserving lazy classes and opaque configs.""" + env = SimpleNamespace(**env._asdict(), scene=dict(robot=SimpleNamespace(joint_names=["a", "b", "c"]))) outer_cfg = NestedFieldTermCfg( func=increment_dummy1_by_one, nested_term=ManagerTermBaseCfg(func=f"{__name__}:reset_dummy2_to_zero"), + dict_processor=tensor_processors.DictTensorProcessorCfg( + class_type="isaaclab.utils.tensor_processors:DictTensorProcessor", + inputs=["source"], + outputs=["result"], + params={ + "asset_cfg": SceneEntityCfg("robot", joint_ids=[2, 0]), + "term": ManagerTermBaseCfg(func=f"{__name__}:reset_dummy2_to_zero_class"), + }, + ), ) update_from_dict(outer_cfg, to_dict(outer_cfg)) cfg = {"outer": outer_cfg} @@ -208,6 +219,10 @@ def test_resolution_walks_declared_term_fields_outside_params(env): assert isinstance(term_cfg, NestedFieldTermCfg) assert term_cfg.nested_term.func is reset_dummy2_to_zero assert term_cfg.modifier.func is increment_dummy1_by_one + assert isinstance(term_cfg.dict_processor.class_type, str) + assert isinstance(term_cfg.dict_processor.params["term"].func, reset_dummy2_to_zero_class) + assert term_cfg.dict_processor.params["asset_cfg"].joint_names == ["c", "a"] + torch.testing.assert_close(term_cfg.dict_processor.params["asset_cfg"].joint_ids, torch.tensor([2, 0])) assert isinstance(term_cfg.metadata["func"], str) assert isinstance(term_cfg.opaque.func, str) From d44e2ba340880702615fa233b93be116eeb59eed Mon Sep 17 00:00:00 2001 From: Matthew Taylor Date: Tue, 20 Oct 2026 16:17:08 -0400 Subject: [PATCH 15/20] Use tensor processor terminology consistently --- .../isaaclab/managers/observation_manager.py | 63 +++++++++--------- .../test/managers/test_manager_base.py | 6 +- .../test/managers/test_observation_manager.py | 64 ++++++++++--------- ...modifiers.py => test_tensor_processors.py} | 42 ++++++------ .../managers/observation_manager.py | 47 ++++++++------ 5 files changed, 120 insertions(+), 102 deletions(-) rename source/isaaclab/test/utils/{test_modifiers.py => test_tensor_processors.py} (75%) diff --git a/source/isaaclab/isaaclab/managers/observation_manager.py b/source/isaaclab/isaaclab/managers/observation_manager.py index 9b5423df7e3a..f625af2fdf5c 100644 --- a/source/isaaclab/isaaclab/managers/observation_manager.py +++ b/source/isaaclab/isaaclab/managers/observation_manager.py @@ -275,7 +275,7 @@ def _collect_io_descriptors(self, group_names_to_export: list[str] = ["policy"]) overloads = {} # Iterate over the term's own parameters and add them to the overloads dictionary for k, v in term_cfg.__dict__.items(): - # For now we do not add the noise modifier + # For now we do not add the noise model if k in [ "tensor_processors", "clip", @@ -338,9 +338,9 @@ def reset(self, env_ids: Sequence[int] | None = None) -> dict[str, float]: self._group_obs_term_delay_buffer[group_name][term_name].reset(env_ids) if term_name in self._group_obs_term_history_buffer[group_name]: self._group_obs_term_history_buffer[group_name][term_name].reset(batch_ids=env_ids) - # call all tensor_processors that are classes - for mod in self._group_obs_class_instances: - mod.reset(env_ids=env_ids) + # call all tensor processors that are classes + for tensor_processor in self._group_obs_class_instances: + tensor_processor.reset(env_ids=env_ids) # nothing to log here return {} @@ -434,14 +434,14 @@ def compute_group(self, group_name: str, update_history: bool = False) -> torch. obs = term_cfg.func(self._env, **term_cfg.params) # apply post-processing if term_cfg.tensor_processors is not None: - for modifier in term_cfg.tensor_processors: + for tensor_processor_cfg in term_cfg.tensor_processors: owned = False # Custom callbacks may modify or retain their input. obs = obs.clone() - if isinstance(modifier.func, tensor_processors.TensorProcessor): - obs = modifier.func(obs) + if isinstance(tensor_processor_cfg.func, tensor_processors.TensorProcessor): + obs = tensor_processor_cfg.func(obs) else: - obs = modifier.func(obs, **modifier.params) + obs = tensor_processor_cfg.func(obs, **tensor_processor_cfg.params) # Noise callbacks must not modify their input, but may return borrowed storage. if isinstance(term_cfg.noise, noise.NoiseCfg): obs = term_cfg.noise.func(obs, term_cfg.noise) @@ -681,39 +681,44 @@ def _prepare_terms(self): # cast the scale into torch tensor term_cfg.scale = torch.tensor(term_cfg.scale, dtype=torch.float, device=self._env.device) - # prepare tensor_processors for each observation + # prepare tensor processors for each observation if term_cfg.tensor_processors is not None: - # initialize list of tensor_processors for term - for mod_cfg in term_cfg.tensor_processors: - if not isinstance(mod_cfg, tensor_processors.TensorProcessorCfg): + # initialize list of tensor processors for term + for tensor_processor_cfg in term_cfg.tensor_processors: + if not isinstance(tensor_processor_cfg, tensor_processors.TensorProcessorCfg): raise TypeError( - f"Modifier configuration '{mod_cfg}' of observation term '{term_name}' is not of" - f" required type TensorProcessorCfg, Received: '{type(mod_cfg)}'" + f"Tensor processor configuration '{tensor_processor_cfg}' of observation term" + f" '{term_name}' is not of" + f" required type TensorProcessorCfg, Received: '{type(tensor_processor_cfg)}'" ) - # construct stateful tensor_processors with the observation size - if inspect.isclass(mod_cfg.func): - mod_cfg.func = mod_cfg.func(cfg=mod_cfg, data_dim=obs_dims, device=self._env.device) - if not isinstance(mod_cfg.func, tensor_processors.TensorProcessor): + # construct stateful tensor processors with the observation size + if inspect.isclass(tensor_processor_cfg.func): + tensor_processor_cfg.func = tensor_processor_cfg.func( + cfg=tensor_processor_cfg, data_dim=obs_dims, device=self._env.device + ) + if not isinstance(tensor_processor_cfg.func, tensor_processors.TensorProcessor): raise TypeError( - f"Modifier function '{mod_cfg.func}' for observation term '{term_name}'" - f" is not an instance of 'TensorProcessor'. Received: '{type(mod_cfg.func)}'." + f"Tensor processor function '{tensor_processor_cfg.func}' for observation term" + f" '{term_name}' is not an instance of 'TensorProcessor'." + f" Received: '{type(tensor_processor_cfg.func)}'." ) - self._group_obs_class_instances.append(mod_cfg.func) + self._group_obs_class_instances.append(tensor_processor_cfg.func) # check if function is callable - if not callable(mod_cfg.func): + if not callable(tensor_processor_cfg.func): raise AttributeError( - f"Modifier '{mod_cfg}' of observation term '{term_name}' is not callable." - f" Received: {mod_cfg.func}" + f"Tensor processor '{tensor_processor_cfg}' of observation term '{term_name}'" + " is not callable." + f" Received: {tensor_processor_cfg.func}" ) # TODO(jichuanh): improvement can be made in two ways: - # 1. modifier specific check can be done in the modifier class + # 1. checks specific to a tensor processor can be done in its class # 2. general param vs function matching check can be a common utility # check if term's arguments are matched by params - term_params = list(mod_cfg.params.keys()) - args = inspect.signature(mod_cfg.func).parameters + term_params = list(tensor_processor_cfg.params.keys()) + args = inspect.signature(tensor_processor_cfg.func).parameters args_with_defaults = [arg for arg in args if args[arg].default is not inspect.Parameter.empty] args_without_defaults = [arg for arg in args if args[arg].default is inspect.Parameter.empty] args = args_without_defaults + args_with_defaults @@ -722,8 +727,8 @@ def _prepare_terms(self): if len(args) > 1: if set(args[1:]) != set(term_params + args_with_defaults): raise ValueError( - f"Modifier '{mod_cfg}' of observation term '{term_name}' expects" - f" mandatory parameters: {args_without_defaults[1:]}" + f"Tensor processor '{tensor_processor_cfg}' of observation term '{term_name}'" + f" expects mandatory parameters: {args_without_defaults[1:]}" f" and optional parameters: {args_with_defaults}, but received: {term_params}." ) diff --git a/source/isaaclab/test/managers/test_manager_base.py b/source/isaaclab/test/managers/test_manager_base.py index c161ba240b46..af2293e29904 100644 --- a/source/isaaclab/test/managers/test_manager_base.py +++ b/source/isaaclab/test/managers/test_manager_base.py @@ -84,7 +84,9 @@ class NestedFieldTermCfg(ManagerTermBaseCfg): """Manager term with owned and opaque fields outside ``params``.""" nested_term: ManagerTermBaseCfg = ManagerTermBaseCfg(func=increment_dummy1_by_one) - modifier: tensor_processors.TensorProcessorCfg = tensor_processors.TensorProcessorCfg(func=increment_dummy1_by_one) + tensor_processor: tensor_processors.TensorProcessorCfg = tensor_processors.TensorProcessorCfg( + func=increment_dummy1_by_one + ) dict_processor: tensor_processors.DictTensorProcessorCfg | None = None metadata: dict[str, str] = {"func": f"{__name__}:reset_dummy2_to_zero"} opaque: OpaqueCfg = OpaqueCfg() @@ -218,7 +220,7 @@ def test_resolution_walks_declared_term_fields_outside_params(env): term_cfg = manager._term_cfgs[0][1] assert isinstance(term_cfg, NestedFieldTermCfg) assert term_cfg.nested_term.func is reset_dummy2_to_zero - assert term_cfg.modifier.func is increment_dummy1_by_one + assert term_cfg.tensor_processor.func is increment_dummy1_by_one assert isinstance(term_cfg.dict_processor.class_type, str) assert isinstance(term_cfg.dict_processor.params["term"].func, reset_dummy2_to_zero_class) assert term_cfg.dict_processor.params["asset_cfg"].joint_names == ["c", "a"] diff --git a/source/isaaclab/test/managers/test_observation_manager.py b/source/isaaclab/test/managers/test_observation_manager.py index edc686c97c31..d3beec79a0d2 100644 --- a/source/isaaclab/test/managers/test_observation_manager.py +++ b/source/isaaclab/test/managers/test_observation_manager.py @@ -579,14 +579,16 @@ class PolicyCfg(ObservationGroupCfg): @pytest.mark.parametrize("position_term", [pos_w_data, destination_position]) -def test_modifier_compute(setup_env, position_term): +def test_tensor_processor_compute(setup_env, position_term): env = setup_env - """Test the observation computation with tensor_processors.""" + """Test the observation computation with tensor processors.""" - modifier_1 = tensor_processors.TensorProcessorCfg(func=tensor_processors.bias, params={"value": 1.0}) - modifier_2 = tensor_processors.TensorProcessorCfg(func=tensor_processors.scale, params={"multiplier": 2.0}) - modifier_3 = tensor_processors.TensorProcessorCfg(func=tensor_processors.clip, params={"bounds": (-0.5, 0.5)}) - modifier_4 = tensor_processors.IntegratorCfg(dt=env.dt) + tensor_processor_1 = tensor_processors.TensorProcessorCfg(func=tensor_processors.bias, params={"value": 1.0}) + tensor_processor_2 = tensor_processors.TensorProcessorCfg(func=tensor_processors.scale, params={"multiplier": 2.0}) + tensor_processor_3 = tensor_processors.TensorProcessorCfg( + func=tensor_processors.clip, params={"bounds": (-0.5, 0.5)} + ) + tensor_processor_4 = tensor_processors.IntegratorCfg(dt=env.dt) @configclass class MyObservationManagerCfg: @@ -598,8 +600,8 @@ class PolicyCfg(ObservationGroupCfg): concatenate_terms = False term_1 = ObservationTermCfg(func=position_term, tensor_processors=[]) - term_2 = ObservationTermCfg(func=position_term, tensor_processors=[modifier_1]) - term_3 = ObservationTermCfg(func=position_term, tensor_processors=[modifier_1, modifier_4]) + term_2 = ObservationTermCfg(func=position_term, tensor_processors=[tensor_processor_1]) + term_3 = ObservationTermCfg(func=position_term, tensor_processors=[tensor_processor_1, tensor_processor_4]) @configclass class CriticCfg(ObservationGroupCfg): @@ -607,9 +609,11 @@ class CriticCfg(ObservationGroupCfg): concatenate_terms = False term_1 = ObservationTermCfg(func=position_term, tensor_processors=[]) - term_2 = ObservationTermCfg(func=position_term, tensor_processors=[modifier_1]) - term_3 = ObservationTermCfg(func=position_term, tensor_processors=[modifier_1, modifier_2]) - term_4 = ObservationTermCfg(func=position_term, tensor_processors=[modifier_1, modifier_2, modifier_3]) + term_2 = ObservationTermCfg(func=position_term, tensor_processors=[tensor_processor_1]) + term_3 = ObservationTermCfg(func=position_term, tensor_processors=[tensor_processor_1, tensor_processor_2]) + term_4 = ObservationTermCfg( + func=position_term, tensor_processors=[tensor_processor_1, tensor_processor_2, tensor_processor_3] + ) policy: ObservationGroupCfg = PolicyCfg() critic: ObservationGroupCfg = CriticCfg() @@ -624,7 +628,7 @@ class CriticCfg(ObservationGroupCfg): obs_policy: dict[str, torch.Tensor] = observations["policy"] obs_critic: dict[str, torch.Tensor] = observations["critic"] - # check correct application of modifications + # check correct application of tensor processors assert torch.equal(obs_policy["term_1"] + 1.0, obs_policy["term_2"]) # the first integration step averages the biased signal with a zero initial value torch.testing.assert_close(obs_policy["term_3"], 0.5 * (obs_policy["term_1"] + 1.0) * env.dt) @@ -633,13 +637,13 @@ class CriticCfg(ObservationGroupCfg): assert torch.min(obs_critic["term_4"]) >= -0.5 assert torch.max(obs_critic["term_4"]) <= 0.5 - # Dictionary outputs must also survive updates to a modifier's internal state. + # Dictionary outputs must also survive updates to a tensor processor's internal state. expected_integral = 0.5 * (env.data.pos_w + 1.0) * env.dt obs_man.compute() obs_man.reset() torch.testing.assert_close(obs_policy["term_3"], expected_integral) - # A concatenated observation must survive subsequent updates to a modifier's internal state. + # A concatenated observation must survive subsequent updates to a tensor processor's internal state. cfg.policy.term_1 = None cfg.policy.term_2 = None cfg.policy.concatenate_terms = True @@ -692,11 +696,13 @@ class PolicyCfg(ObservationGroupCfg): assert obs_man.serialize() == {"policy": {"term_1": serialize_data}} -def test_modifier_invalid_config(setup_env): +def test_tensor_processor_invalid_config(setup_env): env = setup_env - """Test modifier initialization with invalid config.""" + """Test tensor processor initialization with invalid config.""" - modifier = tensor_processors.TensorProcessorCfg(func=tensor_processors.clip, params={"min": -0.5, "max": 0.5}) + tensor_processor = tensor_processors.TensorProcessorCfg( + func=tensor_processors.clip, params={"min": -0.5, "max": 0.5} + ) @configclass class MyObservationManagerCfg: @@ -707,7 +713,7 @@ class PolicyCfg(ObservationGroupCfg): """Test config class for policy observation group.""" concatenate_terms = False - term_1 = ObservationTermCfg(func=pos_w_data, tensor_processors=[modifier]) + term_1 = ObservationTermCfg(func=pos_w_data, tensor_processors=[tensor_processor]) policy: ObservationGroupCfg = PolicyCfg() @@ -801,8 +807,8 @@ def __init__(self, num_envs: int = 2) -> None: self.observation = torch.arange(num_envs, dtype=torch.float32).unsqueeze(-1) -class StatefulBiasModifier(tensor_processors.TensorProcessor): - """Stateful modifier used to verify lazy callable resolution.""" +class StatefulBiasTensorProcessor(tensor_processors.TensorProcessor): + """Stateful tensor processor used to verify lazy callable resolution.""" def __init__(self, cfg: tensor_processors.TensorProcessorCfg, data_dim: tuple[int, ...], device: str) -> None: super().__init__(cfg, data_dim, device) @@ -816,8 +822,8 @@ def __call__(self, data: torch.Tensor) -> torch.Tensor: return data + self.value -class InvalidModifier: - """Class with the modifier constructor contract but the wrong base type.""" +class InvalidTensorProcessor: + """Class with the tensor processor constructor contract but the wrong base type.""" def __init__(self, cfg, data_dim, device): pass @@ -839,12 +845,12 @@ def __post_init__(self): policy: PolicyCfg = PolicyCfg() -def test_class_modifier_roundtrip_preserves_func_and_params(): - """Reproduce #6067 with a class modifier and non-empty parameters.""" +def test_class_tensor_processor_roundtrip_preserves_func_and_params(): + """Reproduce #6067 with a class tensor processor and non-empty parameters.""" cfg = HistoryObservationsCfg() cfg.policy.history_length = None cfg.policy.dummy.tensor_processors = [ - tensor_processors.TensorProcessorCfg(func=StatefulBiasModifier, params={"value": 2.0}) + tensor_processors.TensorProcessorCfg(func=StatefulBiasTensorProcessor, params={"value": 2.0}) ] update_from_dict(cfg, to_dict(cfg)) term_cfg = cfg.policy.dummy @@ -859,7 +865,7 @@ def test_class_modifier_roundtrip_preserves_func_and_params(): prepared_term_cfg = manager.cfg.policy.dummy assert prepared_term_cfg.tensor_processors is not None prepared_tensor_processor_cfg = prepared_term_cfg.tensor_processors[0] - assert isinstance(prepared_tensor_processor_cfg.func, StatefulBiasModifier) + assert isinstance(prepared_tensor_processor_cfg.func, StatefulBiasTensorProcessor) observations = manager.compute()["policy"] torch.testing.assert_close(observations, env.observation + 2.0) @@ -896,11 +902,11 @@ def test_removed_modifier_imports_report_replacements(path): string_to_callable(path) -def test_class_modifier_validates_constructed_instance(): - """Class modifier validation checks the constructed object.""" +def test_class_tensor_processor_validates_constructed_instance(): + """Class tensor processor validation checks the constructed object.""" cfg = HistoryObservationsCfg() cfg.policy.history_length = None - cfg.policy.dummy.tensor_processors = [tensor_processors.TensorProcessorCfg(func=InvalidModifier)] + cfg.policy.dummy.tensor_processors = [tensor_processors.TensorProcessorCfg(func=InvalidTensorProcessor)] update_from_dict(cfg, to_dict(cfg)) with pytest.raises(TypeError, match="is not an instance of 'TensorProcessor'"): diff --git a/source/isaaclab/test/utils/test_modifiers.py b/source/isaaclab/test/utils/test_tensor_processors.py similarity index 75% rename from source/isaaclab/test/utils/test_modifiers.py rename to source/isaaclab/test/utils/test_tensor_processors.py index 7ac0700b15a5..140f824c6112 100644 --- a/source/isaaclab/test/utils/test_modifiers.py +++ b/source/isaaclab/test/utils/test_tensor_processors.py @@ -16,8 +16,8 @@ @configclass -class ModifierTestCfg: - """Configuration for testing tensor_processors.""" +class TensorProcessorTestCfg: + """Configuration for testing tensor processors.""" cfg: tensor_processors.TensorProcessorCfg = MISSING init_data: torch.Tensor = MISSING @@ -37,7 +37,7 @@ class ModifierTestCfg: ids=["scale", "bias", "clip", "clip_no_upper_bound", "clip_no_lower_bound"], ) def test_stateless_tensor_processors(func, params, result): - """Test the stateless scale, bias, and clip tensor_processors.""" + """Test the stateless scale, bias, and clip tensor processors.""" cfg = tensor_processors.TensorProcessorCfg(func=func, params=params) output = cfg.func(torch.tensor([1.0, 2.0, 3.0]), **cfg.params) assert torch.allclose(output, torch.tensor(result)) @@ -45,7 +45,7 @@ def test_stateless_tensor_processors(func, params, result): @pytest.mark.parametrize("device", test_devices()) def test_digital_filter(device): - """Test digital filter modifier.""" + """Test digital filter tensor processor.""" # create test data init_data = torch.tensor([0.0, 0.0, 0.0], device=device) A = [0.0, 0.1] @@ -53,25 +53,25 @@ def test_digital_filter(device): result = torch.tensor([-0.45661893, -0.45661893, -0.45661893], device=device) # create test config - test_cfg = ModifierTestCfg( + test_cfg = TensorProcessorTestCfg( cfg=tensor_processors.DigitalFilterCfg(A=A, B=B), init_data=init_data, result=result, num_iter=16 ) - # create a modifier instance - modifier_obj = test_cfg.cfg.func(test_cfg.cfg, test_cfg.init_data.shape, device=device) + # create a tensor processor instance + tensor_processor = test_cfg.cfg.func(test_cfg.cfg, test_cfg.init_data.shape, device=device) - # test the modifier + # test the tensor processor theta = torch.tensor([0.0], device=device) delta = torch.pi / torch.tensor([8.0, 8.0, 8.0], device=device) for _ in range(5): - # reset the modifier - modifier_obj.reset() + # reset the tensor processor + tensor_processor.reset() - # apply the modifier multiple times + # apply the tensor processor multiple times for i in range(test_cfg.num_iter): data = torch.sin(theta + i * delta) - processed_data = modifier_obj(data) + processed_data = tensor_processor(data) assert data.shape == processed_data.shape, "Modified data shape does not equal original" @@ -81,35 +81,35 @@ def test_digital_filter(device): @pytest.mark.parametrize("device", test_devices()) def test_integral(device): - """Test integral modifier.""" + """Test integral tensor processor.""" # create test data init_data = torch.tensor([0.0], device=device) dt = 1.0 result = torch.tensor([12.5], device=device) # create test config - test_cfg = ModifierTestCfg( + test_cfg = TensorProcessorTestCfg( cfg=tensor_processors.IntegratorCfg(dt=dt), init_data=init_data, result=result, num_iter=6, ) - # create a modifier instance - modifier_obj = test_cfg.cfg.func(test_cfg.cfg, test_cfg.init_data.shape, device=device) + # create a tensor processor instance + tensor_processor = test_cfg.cfg.func(test_cfg.cfg, test_cfg.init_data.shape, device=device) - # test the modifier + # test the tensor processor delta = torch.tensor(1.0, device=device) for _ in range(5): - # reset the modifier - modifier_obj.reset() + # reset the tensor processor + tensor_processor.reset() # clone the data to avoid modifying the original data = test_cfg.init_data.clone() - # apply the modifier multiple times + # apply the tensor processor multiple times for _ in range(test_cfg.num_iter): - processed_data = modifier_obj(data) + processed_data = tensor_processor(data) data = data + delta assert data.shape == processed_data.shape, "Modified data shape does not equal original" diff --git a/source/isaaclab_experimental/isaaclab_experimental/managers/observation_manager.py b/source/isaaclab_experimental/isaaclab_experimental/managers/observation_manager.py index 5fed28b70c63..fa5504fb2f63 100644 --- a/source/isaaclab_experimental/isaaclab_experimental/managers/observation_manager.py +++ b/source/isaaclab_experimental/isaaclab_experimental/managers/observation_manager.py @@ -410,8 +410,8 @@ def reset( if term_name in self._group_obs_term_history_buffer[group_name]: self._group_obs_term_history_buffer[group_name][term_name].reset(env_mask=env_mask) # call all tensor processors/noise models that are classes - for mod in self._group_obs_class_instances: - mod.reset(env_mask=env_mask) + for tensor_processor in self._group_obs_class_instances: + tensor_processor.reset(env_mask=env_mask) # nothing to log here return {} @@ -507,8 +507,8 @@ def compute_group(self, group_name: str, update_history: bool = False) -> torch. # apply custom tensor processors (in-place on out_wp) if term_cfg.tensor_processors is not None: - for modifier in term_cfg.tensor_processors: - modifier.func(term_cfg.out_wp, **modifier.params) + for tensor_processor_cfg in term_cfg.tensor_processors: + tensor_processor_cfg.func(term_cfg.out_wp, **tensor_processor_cfg.params) # apply noise (Warp in-place on out_wp) if isinstance(term_cfg.noise, noise.NoiseCfg): @@ -738,36 +738,41 @@ def _prepare_terms(self): # noqa: C901 # prepare tensor processors for each observation if term_cfg.tensor_processors is not None: # initialize list of tensor processors for term - for mod_cfg in term_cfg.tensor_processors: + for tensor_processor_cfg in term_cfg.tensor_processors: # check if class processor and initialize with observation size when adding - if isinstance(mod_cfg, tensor_processors.TensorProcessorCfg): + if isinstance(tensor_processor_cfg, tensor_processors.TensorProcessorCfg): # to list of tensor processors - if inspect.isclass(mod_cfg.func): - if not issubclass(mod_cfg.func, tensor_processors.TensorProcessor): + if inspect.isclass(tensor_processor_cfg.func): + if not issubclass(tensor_processor_cfg.func, tensor_processors.TensorProcessor): raise TypeError( - f"Modifier function '{mod_cfg.func}' for observation term '{term_name}'" - f" is not a subclass of 'TensorProcessor'. Received: '{type(mod_cfg.func)}'." + f"Tensor processor function '{tensor_processor_cfg.func}' for observation term" + f" '{term_name}' is not a subclass of 'TensorProcessor'." + f" Received: '{type(tensor_processor_cfg.func)}'." ) - mod_cfg.func = mod_cfg.func(cfg=mod_cfg, data_dim=obs_dims, device=self._env.device) + tensor_processor_cfg.func = tensor_processor_cfg.func( + cfg=tensor_processor_cfg, data_dim=obs_dims, device=self._env.device + ) # add to list of class tensor processors - self._group_obs_class_instances.append(mod_cfg.func) + self._group_obs_class_instances.append(tensor_processor_cfg.func) else: raise TypeError( - f"Modifier configuration '{mod_cfg}' of observation term '{term_name}' is not of" - f" required type TensorProcessorCfg, Received: '{type(mod_cfg)}'" + f"Tensor processor configuration '{tensor_processor_cfg}' of observation term" + f" '{term_name}' is not of" + f" required type TensorProcessorCfg, Received: '{type(tensor_processor_cfg)}'" ) # check if function is callable - if not callable(mod_cfg.func): + if not callable(tensor_processor_cfg.func): raise AttributeError( - f"Modifier '{mod_cfg}' of observation term '{term_name}' is not callable." - f" Received: {mod_cfg.func}" + f"Tensor processor '{tensor_processor_cfg}' of observation term '{term_name}'" + " is not callable." + f" Received: {tensor_processor_cfg.func}" ) # check if term's arguments are matched by params - term_params = list(mod_cfg.params.keys()) - args = inspect.signature(mod_cfg.func).parameters + term_params = list(tensor_processor_cfg.params.keys()) + args = inspect.signature(tensor_processor_cfg.func).parameters args_with_defaults = [arg for arg in args if args[arg].default is not inspect.Parameter.empty] args_without_defaults = [arg for arg in args if args[arg].default is inspect.Parameter.empty] args = args_without_defaults + args_with_defaults @@ -775,8 +780,8 @@ def _prepare_terms(self): # noqa: C901 if len(args) > 1: if set(args[1:]) != set(term_params + args_with_defaults): raise ValueError( - f"Modifier '{mod_cfg}' of observation term '{term_name}' expects" - f" mandatory parameters: {args_without_defaults[1:]}" + f"Tensor processor '{tensor_processor_cfg}' of observation term '{term_name}'" + f" expects mandatory parameters: {args_without_defaults[1:]}" f" and optional parameters: {args_with_defaults}, but received: {term_params}." ) From 91274651a335322f17ccac288788e40232895596 Mon Sep 17 00:00:00 2001 From: Matthew Taylor Date: Tue, 20 Oct 2026 18:27:46 -0400 Subject: [PATCH 16/20] Preserve modifier imports with deprecation warnings --- docs/source/api/lab/isaaclab.utils.rst | 2 +- .../dict-tensor-processors.removed.rst | 5 +-- .../modifier-import-shim.deprecated.rst | 3 ++ source/isaaclab/isaaclab/utils/__init__.py | 5 --- source/isaaclab/isaaclab/utils/__init__.pyi | 3 ++ .../isaaclab/utils/modifiers/__init__.py | 41 +++++++++++++++++-- .../test/managers/test_observation_manager.py | 15 ------- .../test/utils/test_tensor_processors.py | 38 +++++++++++++++++ .../dict-tensor-processors.removed.rst | 4 +- .../modifier-import-shim.deprecated.rst | 3 ++ .../isaaclab_experimental/utils/__init__.pyi | 3 +- .../utils/modifiers/__init__.py | 31 ++++++++++++-- 12 files changed, 118 insertions(+), 35 deletions(-) create mode 100644 source/isaaclab/changelog.d/modifier-import-shim.deprecated.rst create mode 100644 source/isaaclab_experimental/changelog.d/modifier-import-shim.deprecated.rst diff --git a/docs/source/api/lab/isaaclab.utils.rst b/docs/source/api/lab/isaaclab.utils.rst index 34ec4b5a7bf3..2a2f1d8ee9f2 100644 --- a/docs/source/api/lab/isaaclab.utils.rst +++ b/docs/source/api/lab/isaaclab.utils.rst @@ -147,7 +147,7 @@ Migration: import ``TensorProcessor`` and ``TensorProcessorCfg`` from ``isaaclab.utils.tensor_processors`` in place of ``ModifierBase`` and ``ModifierCfg`` from ``isaaclab.utils.modifiers``. Replace ``ObservationTermCfg.modifiers`` with ``ObservationTermCfg.tensor_processors``. The processing order and tensor shapes are unchanged. -Old imports raise ``ValueError`` with migration instructions. +Old imports remain supported and emit ``DeprecationWarning`` with migration instructions. .. automodule:: isaaclab.utils.tensor_processors :members: diff --git a/source/isaaclab/changelog.d/dict-tensor-processors.removed.rst b/source/isaaclab/changelog.d/dict-tensor-processors.removed.rst index ba15d8078587..516eeb8428e2 100644 --- a/source/isaaclab/changelog.d/dict-tensor-processors.removed.rst +++ b/source/isaaclab/changelog.d/dict-tensor-processors.removed.rst @@ -1,3 +1,2 @@ -* **Breaking:** Removed ``isaaclab.utils.modifiers``, ``ModifierBase``, and ``ModifierCfg`` in favor of - ``isaaclab.utils.tensor_processors``, ``TensorProcessor``, and ``TensorProcessorCfg``. Replace - ``ObservationTermCfg.modifiers`` with ``ObservationTermCfg.tensor_processors``. +* **Breaking:** Removed ``ObservationTermCfg.modifiers`` in favor of + ``ObservationTermCfg.tensor_processors``. diff --git a/source/isaaclab/changelog.d/modifier-import-shim.deprecated.rst b/source/isaaclab/changelog.d/modifier-import-shim.deprecated.rst new file mode 100644 index 000000000000..6be21abd1990 --- /dev/null +++ b/source/isaaclab/changelog.d/modifier-import-shim.deprecated.rst @@ -0,0 +1,3 @@ +* Deprecated ``isaaclab.utils.modifiers``, ``ModifierBase``, and ``ModifierCfg`` in favor of + ``isaaclab.utils.tensor_processors``, ``TensorProcessor``, and ``TensorProcessorCfg``. Preserved + legacy imports and serialized callable paths with an import-time deprecation warning. diff --git a/source/isaaclab/isaaclab/utils/__init__.py b/source/isaaclab/isaaclab/utils/__init__.py index 02f583a6fe35..5c210a0e10a9 100644 --- a/source/isaaclab/isaaclab/utils/__init__.py +++ b/source/isaaclab/isaaclab/utils/__init__.py @@ -13,11 +13,6 @@ def __getattr__(name: str): - if name in {"ModifierBase", "ModifierCfg"}: - raise ValueError( - "ModifierBase and ModifierCfg were removed. Use TensorProcessor and TensorProcessorCfg" - " from isaaclab.utils.tensor_processors instead." - ) # ``configclass`` names both a sub-module of this package and the decorator that sub-module # defines, so ``isaaclab.utils.configclass`` can only ever be one of the two. Importing the # sub-module binds the module object here and shadows the lazily attached decorator, while diff --git a/source/isaaclab/isaaclab/utils/__init__.pyi b/source/isaaclab/isaaclab/utils/__init__.pyi index 99f7ef906c6b..cbe85749f70a 100644 --- a/source/isaaclab/isaaclab/utils/__init__.pyi +++ b/source/isaaclab/isaaclab/utils/__init__.pyi @@ -31,6 +31,8 @@ __all__ = [ "create_trimesh_from_geom_shape", "convert_faces_to_triangles", "PRIMITIVE_MESH_TYPES", + "ModifierCfg", + "ModifierBase", "TensorProcessorCfg", "TensorProcessor", "DictTensorProcessor", @@ -89,6 +91,7 @@ from .mesh import ( convert_faces_to_triangles, PRIMITIVE_MESH_TYPES, ) +from .modifiers import ModifierCfg, ModifierBase from .tensor_processors import ( DictTensorProcessor, DictTensorProcessorCfg, diff --git a/source/isaaclab/isaaclab/utils/modifiers/__init__.py b/source/isaaclab/isaaclab/utils/modifiers/__init__.py index 1e0f291139e5..79d3ee52b8f3 100644 --- a/source/isaaclab/isaaclab/utils/modifiers/__init__.py +++ b/source/isaaclab/isaaclab/utils/modifiers/__init__.py @@ -3,9 +3,42 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Migration error for the removed modifiers API.""" +"""Deprecated import path for tensor processors.""" -raise ValueError( - "isaaclab.utils.modifiers was removed. Use isaaclab.utils.tensor_processors instead:" - " ModifierBase is now TensorProcessor, and ModifierCfg is now TensorProcessorCfg." +import sys +import warnings + +from .. import tensor_processors as _processors + +__all__ = [ + "ModifierCfg", + "DigitalFilterCfg", + "IntegratorCfg", + "ModifierBase", + "DigitalFilter", + "Integrator", + "bias", + "clip", + "scale", +] + +warnings.warn( + "isaaclab.utils.modifiers is deprecated; use isaaclab.utils.tensor_processors." + " ModifierBase and ModifierCfg are now TensorProcessor and TensorProcessorCfg.", + DeprecationWarning, + stacklevel=2, ) + + +def __getattr__(name: str): + aliases = {"ModifierBase": "TensorProcessor", "ModifierCfg": "TensorProcessorCfg"} + return getattr(_processors, aliases.get(name, name)) + + +def __dir__(): + return __all__ + + +for _name in ("modifier", "modifier_base", "modifier_cfg"): + sys.modules[f"{__name__}.{_name}"] = sys.modules[__name__] + setattr(sys.modules[__name__], _name, sys.modules[__name__]) diff --git a/source/isaaclab/test/managers/test_observation_manager.py b/source/isaaclab/test/managers/test_observation_manager.py index d3beec79a0d2..39efd7f0a6ff 100644 --- a/source/isaaclab/test/managers/test_observation_manager.py +++ b/source/isaaclab/test/managers/test_observation_manager.py @@ -24,7 +24,6 @@ RewardTermCfg, ) from isaaclab.utils import DelayBuffer, configclass, noise, tensor_processors, to_dict, update_from_dict, validate -from isaaclab.utils.string import string_to_callable pytestmark = pytest.mark.unit @@ -888,20 +887,6 @@ def test_stateless_tensor_processor_cfg_roundtrip_preserves_signature_validation torch.testing.assert_close(observations, env.observation + 2.0) -@pytest.mark.parametrize( - "path", - [ - "isaaclab.utils:ModifierBase", - "isaaclab.utils.modifiers:ModifierCfg", - "isaaclab_experimental.utils.modifiers.modifier_base:ModifierBase", - ], -) -def test_removed_modifier_imports_report_replacements(path): - """Old class imports and serialized paths fail with the replacement API.""" - with pytest.raises(ValueError, match="TensorProcessor.*TensorProcessorCfg"): - string_to_callable(path) - - def test_class_tensor_processor_validates_constructed_instance(): """Class tensor processor validation checks the constructed object.""" cfg = HistoryObservationsCfg() diff --git a/source/isaaclab/test/utils/test_tensor_processors.py b/source/isaaclab/test/utils/test_tensor_processors.py index 140f824c6112..884020381843 100644 --- a/source/isaaclab/test/utils/test_tensor_processors.py +++ b/source/isaaclab/test/utils/test_tensor_processors.py @@ -3,6 +3,8 @@ # # SPDX-License-Identifier: BSD-3-Clause +import subprocess +import sys from dataclasses import MISSING import pytest @@ -15,6 +17,42 @@ pytestmark = pytest.mark.unit +def test_legacy_modifier_imports_warn_and_preserve_aliases(): + """Legacy imports and serialized paths resolve to the canonical processor APIs.""" + script = """ +import warnings +from importlib import import_module + +from isaaclab.utils.string import string_to_callable + +with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + from isaaclab.utils import ModifierBase, ModifierCfg, modifiers, tensor_processors + from isaaclab_experimental.utils import modifiers as warp_modifiers + +assert ModifierBase is tensor_processors.TensorProcessor +assert ModifierCfg is tensor_processors.TensorProcessorCfg +for legacy in (modifiers, warp_modifiers): + processors = import_module(legacy.__name__.replace("modifiers", "tensor_processors")) + assert import_module(f"{legacy.__name__}.modifier_base").ModifierBase is processors.TensorProcessor + assert import_module(f"{legacy.__name__}.modifier_cfg").ModifierCfg is processors.TensorProcessorCfg + assert string_to_callable(f"{legacy.__name__}.modifier:bias") is processors.bias + exported = {} + exec(f"from {legacy.__name__} import *", exported) + assert exported["ModifierBase"] is processors.TensorProcessor + assert exported["ModifierCfg"] is processors.TensorProcessorCfg + assert exported["scale"] is processors.scale + assert any( + warning.category is DeprecationWarning + and legacy.__name__ in str(warning.message) + and "tensor_processors" in str(warning.message) + for warning in caught + ) +""" + result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, timeout=60) + assert result.returncode == 0, result.stderr + + @configclass class TensorProcessorTestCfg: """Configuration for testing tensor processors.""" diff --git a/source/isaaclab_experimental/changelog.d/dict-tensor-processors.removed.rst b/source/isaaclab_experimental/changelog.d/dict-tensor-processors.removed.rst index f918a81d751a..ea2e4ce0e811 100644 --- a/source/isaaclab_experimental/changelog.d/dict-tensor-processors.removed.rst +++ b/source/isaaclab_experimental/changelog.d/dict-tensor-processors.removed.rst @@ -1,2 +1,2 @@ -* **Breaking:** Removed the modifiers API in favor of ``tensor_processors``, ``TensorProcessor``, and - ``TensorProcessorCfg``. Use ``ObservationTermCfg.tensor_processors`` for observation processing. +* **Breaking:** Removed support for ``ObservationTermCfg.modifiers``. Use + ``ObservationTermCfg.tensor_processors`` for observation processing. diff --git a/source/isaaclab_experimental/changelog.d/modifier-import-shim.deprecated.rst b/source/isaaclab_experimental/changelog.d/modifier-import-shim.deprecated.rst new file mode 100644 index 000000000000..757987438aa5 --- /dev/null +++ b/source/isaaclab_experimental/changelog.d/modifier-import-shim.deprecated.rst @@ -0,0 +1,3 @@ +* Deprecated ``isaaclab_experimental.utils.modifiers``, ``ModifierBase``, and ``ModifierCfg`` in favor of + ``isaaclab_experimental.utils.tensor_processors``, ``TensorProcessor``, and ``TensorProcessorCfg``. + Preserved legacy imports and serialized callable paths with an import-time deprecation warning. diff --git a/source/isaaclab_experimental/isaaclab_experimental/utils/__init__.pyi b/source/isaaclab_experimental/isaaclab_experimental/utils/__init__.pyi index f4aaa2e64681..8211d4785061 100644 --- a/source/isaaclab_experimental/isaaclab_experimental/utils/__init__.pyi +++ b/source/isaaclab_experimental/isaaclab_experimental/utils/__init__.pyi @@ -9,6 +9,7 @@ __all__ = [ "WarpGraphCache", "clone_obs_buffer", "buffers", + "modifiers", "tensor_processors", "noise", "warp", @@ -17,4 +18,4 @@ __all__ = [ from .manager_call_switch import ManagerCallMode, ManagerCallSwitch from .torch_utils import clone_obs_buffer from .warp_graph_cache import WarpGraphCache -from . import buffers, tensor_processors, noise, warp +from . import buffers, modifiers, tensor_processors, noise, warp diff --git a/source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/__init__.py b/source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/__init__.py index 27bd26d4aa8f..8f9ff81d1a94 100644 --- a/source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/__init__.py +++ b/source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/__init__.py @@ -3,9 +3,32 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Migration error for the removed Warp-native modifiers API.""" +"""Deprecated import path for Warp-native tensor processors.""" -raise ValueError( - "isaaclab_experimental.utils.modifiers was removed. Use isaaclab_experimental.utils.tensor_processors instead:" - " ModifierBase is now TensorProcessor, and ModifierCfg is now TensorProcessorCfg." +import sys +import warnings + +from .. import tensor_processors as _processors + +__all__ = ["ModifierBase", "ModifierCfg", "bias", "clip", "scale"] + +warnings.warn( + "isaaclab_experimental.utils.modifiers is deprecated; use isaaclab_experimental.utils.tensor_processors." + " ModifierBase and ModifierCfg are now TensorProcessor and TensorProcessorCfg.", + DeprecationWarning, + stacklevel=2, ) + + +def __getattr__(name: str): + aliases = {"ModifierBase": "TensorProcessor", "ModifierCfg": "TensorProcessorCfg"} + return getattr(_processors, aliases.get(name, name)) + + +def __dir__(): + return __all__ + + +for _name in ("modifier", "modifier_base", "modifier_cfg"): + sys.modules[f"{__name__}.{_name}"] = sys.modules[__name__] + setattr(sys.modules[__name__], _name, sys.modules[__name__]) From e5f4c0a4af486111833565476a0bac78a5f4fd7e Mon Sep 17 00:00:00 2001 From: Matthew Taylor Date: Tue, 20 Oct 2026 22:40:59 -0400 Subject: [PATCH 17/20] Restore None return type for Warp observation terms --- source/isaaclab/isaaclab/managers/manager_term_cfg.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/source/isaaclab/isaaclab/managers/manager_term_cfg.py b/source/isaaclab/isaaclab/managers/manager_term_cfg.py index 0d374c287b08..021e75887529 100644 --- a/source/isaaclab/isaaclab/managers/manager_term_cfg.py +++ b/source/isaaclab/isaaclab/managers/manager_term_cfg.py @@ -158,13 +158,16 @@ class CurriculumTermCfg(ManagerTermBaseCfg): class ObservationTermCfg(ManagerTermBaseCfg): """Configuration for an observation term.""" - func: Callable[..., torch.Tensor] | type[ManagerTermBase] = MISSING + func: Callable[..., torch.Tensor | None] | type[ManagerTermBase] = MISSING """The function or :class:`ManagerTermBase` subclass to be called. The manager instantiates class-based terms before calling them. - The function or the class's ``__call__`` method should take the environment object and any other parameters - as input and return the observation signal as torch float tensors of - shape (num_envs, obs_term_dim). + With the standard observation manager, the function or the class's ``__call__`` method takes the + environment object and any other parameters as input and returns the observation signal as a + torch tensor of shape (num_envs, obs_term_dim). + + With the experimental Warp observation manager, it also takes a pre-allocated output buffer as + its second argument, writes the observations into that buffer, and returns None. """ tensor_processors: list[TensorProcessorCfg] | None = None From 383f324f9ffd82abed9593e20c0398e63efc8c06 Mon Sep 17 00:00:00 2001 From: Matthew Taylor Date: Wed, 21 Oct 2026 12:09:50 -0400 Subject: [PATCH 18/20] Migrate LEAPP export fixture to tensor processors --- source/isaaclab_rl/changelog.d/leapp-tensor-processors.skip | 0 source/isaaclab_rl/test/export/leapp_tensor_env.py | 4 ++-- 2 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 source/isaaclab_rl/changelog.d/leapp-tensor-processors.skip diff --git a/source/isaaclab_rl/changelog.d/leapp-tensor-processors.skip b/source/isaaclab_rl/changelog.d/leapp-tensor-processors.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_rl/test/export/leapp_tensor_env.py b/source/isaaclab_rl/test/export/leapp_tensor_env.py index c69fac41b26e..12fb8c3abcc8 100644 --- a/source/isaaclab_rl/test/export/leapp_tensor_env.py +++ b/source/isaaclab_rl/test/export/leapp_tensor_env.py @@ -18,8 +18,8 @@ from isaaclab.envs import ManagerBasedRLEnv, mdp from isaaclab.managers import ActionManager, ObservationGroupCfg, ObservationManager, ObservationTermCfg, SceneEntityCfg from isaaclab.sensors.camera import CameraData -from isaaclab.utils.modifiers import ModifierCfg, bias from isaaclab.utils.string import resolve_matching_names +from isaaclab.utils.tensor_processors import TensorProcessorCfg, bias from isaaclab.utils.warp import ProxyArray @@ -118,7 +118,7 @@ def __init__(self, cfg, *, task: str, backend: str, width: int, render_mode=None policy = { "joint_pos": ObservationTermCfg( func=mdp.joint_pos_rel, - modifiers=[ModifierCfg(func=bias, params={"value": 0.1})], + tensor_processors=[TensorProcessorCfg(func=bias, params={"value": 0.1})], clip=(-1.0, 1.0), scale=2.0, ), From 85a34655d1d53db2310d547ba7f4c20758f39b80 Mon Sep 17 00:00:00 2001 From: Matthew Taylor Date: Thu, 22 Oct 2026 18:04:34 -0400 Subject: [PATCH 19/20] Unify tensor and structured data processors --- docs/source/api/lab/isaaclab.utils.rst | 59 ++++++++++++++----- .../dict-tensor-processors.added.rst | 8 ++- .../isaaclab/managers/manager_base.py | 4 +- source/isaaclab/isaaclab/utils/__init__.pyi | 4 -- .../utils/tensor_processors/__init__.py | 30 ++++++---- .../utils/tensor_processors/__init__.pyi | 8 +-- .../dict_tensor_processor.py | 47 --------------- .../dict_tensor_processor_cfg.py | 41 ------------- .../tensor_processors/tensor_processor.py | 58 ++++++++++++------ .../tensor_processors/tensor_processor_cfg.py | 25 ++++++-- .../test/managers/test_manager_base.py | 16 ++--- .../test/utils/test_tensor_processors.py | 52 +++++++++++++++- 12 files changed, 191 insertions(+), 161 deletions(-) delete mode 100644 source/isaaclab/isaaclab/utils/tensor_processors/dict_tensor_processor.py delete mode 100644 source/isaaclab/isaaclab/utils/tensor_processors/dict_tensor_processor_cfg.py diff --git a/docs/source/api/lab/isaaclab.utils.rst b/docs/source/api/lab/isaaclab.utils.rst index 2a2f1d8ee9f2..91c8d53d67ec 100644 --- a/docs/source/api/lab/isaaclab.utils.rst +++ b/docs/source/api/lab/isaaclab.utils.rst @@ -127,21 +127,50 @@ Mesh operations Tensor processors ~~~~~~~~~~~~~~~~~ -Use :mod:`isaaclab.utils.tensor_processors` to transform observation tensors. Configure processors -with ``TensorProcessorCfg`` through :attr:`~isaaclab.managers.ObservationTermCfg.tensor_processors`. - -For named tensor dictionaries, implement :class:`~isaaclab.utils.tensor_processors.DictTensorProcessor` -and configure it with :class:`~isaaclab.utils.tensor_processors.DictTensorProcessorCfg`. -Set ``class_type`` to the implementation class or a resolvable import string, ``inputs`` and -``outputs`` to the dictionary keys it reads and produces, and ``params`` to its settings. -Construct the processor with ``instantiate(cfg, data)``, where ``data`` maps input names to -:class:`~isaaclab.utils.warp.ProxyArray` values. String-valued ``class_type`` fields resolve lazily -through ``configclass`` when the implementation is needed. Processors read settings from -``cfg.params``; these settings are not forwarded as constructor keyword arguments. - -Observation terms remain tensor-valued: ``ObservationTermCfg.tensor_processors`` accepts only -``TensorProcessorCfg``. Dictionary processors are constructed directly; camera attachment and -dictionary processor pipelines are not provided by this API. +Use :mod:`isaaclab.utils.tensor_processors` to transform tensors or nested tensor collections. +:class:`~isaaclab.utils.tensor_processors.TensorProcessor` and +:class:`~isaaclab.utils.tensor_processors.TensorProcessorCfg` support ``torch.Tensor``, +:class:`~isaaclab.utils.warp.ProxyArray`, and recursively nested lists, tuples, and dictionaries +through ``TensorProcessorData``. Each implementation defines the structures it accepts and returns; +the base class does not traverse collections automatically. + +Set ``cfg.func`` to a function, a processor class, or a lazily resolved import string. Functions +receive ``cfg.params`` as keyword arguments; classes read settings from their configuration. +Optional ``inputs`` and ``outputs`` lists describe named keys but do not select or route data. +For example, a subclass can stack a nonempty list of dictionaries whose ``position`` tensors +share a shape, dtype, and device: + +.. code-block:: python + + import torch + from isaaclab.utils.tensor_processors import TensorProcessor, TensorProcessorCfg + + + class StackPositions(TensorProcessor): + def __init__(self, cfg: TensorProcessorCfg, data: list[dict[str, torch.Tensor]]) -> None: + super().__init__(cfg) + first = data[0]["position"] + self.output = torch.empty((len(data), *first.shape), dtype=first.dtype, device=first.device) + + def __call__(self, data: list[dict[str, torch.Tensor]]) -> torch.Tensor: + return torch.stack([item["position"] for item in data], out=self.output) + + + data = [{"position": torch.zeros(3)}, {"position": torch.ones(3)}] + cfg = TensorProcessorCfg(func=StackPositions, inputs=["position"]) + processor = cfg.func(cfg, data) + stacked_positions = processor(data) + +Construction uses input metadata without reading values or advancing state. Subsequent calls use +the same number of input dictionaries and tensor shapes. Inputs are read-only and owned by the +caller. This subclass owns its persistent output, which remains valid until the next call; copy +results that must be retained. ``reset`` and ``close`` do nothing unless overridden. Processors +using Warp launch work on the caller's current Warp stream. + +Observation terms continue to use shape-preserving single-tensor processors through +:attr:`~isaaclab.managers.ObservationTermCfg.tensor_processors`. The observation manager constructs +class processors with ``cfg.func(cfg, data_dim, device)``. Built-in filters retain this constructor +and their single-tensor behavior; structured processors are constructed directly as above. Migration: import ``TensorProcessor`` and ``TensorProcessorCfg`` from ``isaaclab.utils.tensor_processors`` in place of ``ModifierBase`` and ``ModifierCfg`` from diff --git a/source/isaaclab/changelog.d/dict-tensor-processors.added.rst b/source/isaaclab/changelog.d/dict-tensor-processors.added.rst index bf044370ee59..bfa599222114 100644 --- a/source/isaaclab/changelog.d/dict-tensor-processors.added.rst +++ b/source/isaaclab/changelog.d/dict-tensor-processors.added.rst @@ -1,3 +1,5 @@ -* Added the ``DictTensorProcessor`` interface and ``DictTensorProcessorCfg`` for configuring - processors of named tensor dictionaries independently of tensor-valued observation terms. - Managers resolve nested scene entities and manager terms in dictionary processor parameters. +* Extended ``TensorProcessor`` and ``TensorProcessorCfg`` to support tensors, ``ProxyArray`` values, + and nested lists, tuples, and dictionaries through the shared ``TensorProcessorData`` type. + Added optional named input/output metadata and lifecycle hooks while preserving single-tensor + observation processing. Added manager resolution of nested scene entities and manager terms + in processor parameters. diff --git a/source/isaaclab/isaaclab/managers/manager_base.py b/source/isaaclab/isaaclab/managers/manager_base.py index 16a5fbf5cf39..dd0ebb0f437b 100644 --- a/source/isaaclab/isaaclab/managers/manager_base.py +++ b/source/isaaclab/isaaclab/managers/manager_base.py @@ -16,7 +16,7 @@ from ..physics import PhysicsEvent, PhysicsManager from ..utils import string as string_utils from ..utils import string_to_callable, to_dict -from ..utils.tensor_processors import DictTensorProcessorCfg, TensorProcessorCfg +from ..utils.tensor_processors import TensorProcessorCfg from .manager_term_cfg import ManagerTermBaseCfg from .scene_entity_cfg import SceneEntityCfg @@ -434,7 +434,7 @@ def _resolve_param_value( self._scene_entity_cfgs.append(value) elif isinstance(value, ManagerTermBaseCfg): self._process_term_cfg_at_play(f"{term_name}.{key}", value) - elif isinstance(value, (TensorProcessorCfg, DictTensorProcessorCfg)): + elif isinstance(value, TensorProcessorCfg): for field in fields(value): field_value = getattr(value, field.name) resolved_value = self._resolve_param_value( diff --git a/source/isaaclab/isaaclab/utils/__init__.pyi b/source/isaaclab/isaaclab/utils/__init__.pyi index cbe85749f70a..a6723caf9fd3 100644 --- a/source/isaaclab/isaaclab/utils/__init__.pyi +++ b/source/isaaclab/isaaclab/utils/__init__.pyi @@ -35,8 +35,6 @@ __all__ = [ "ModifierBase", "TensorProcessorCfg", "TensorProcessor", - "DictTensorProcessor", - "DictTensorProcessorCfg", "DigitalFilter", "DigitalFilterCfg", "Integrator", @@ -93,8 +91,6 @@ from .mesh import ( ) from .modifiers import ModifierCfg, ModifierBase from .tensor_processors import ( - DictTensorProcessor, - DictTensorProcessorCfg, TensorProcessorCfg, TensorProcessor, DigitalFilter, diff --git a/source/isaaclab/isaaclab/utils/tensor_processors/__init__.py b/source/isaaclab/isaaclab/utils/tensor_processors/__init__.py index 1b94b2eac403..4576de38aab2 100644 --- a/source/isaaclab/isaaclab/utils/tensor_processors/__init__.py +++ b/source/isaaclab/isaaclab/utils/tensor_processors/__init__.py @@ -3,17 +3,25 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Stateful and stateless processing for tensors and dictionaries of named tensors. - -Single-tensor functions and :class:`TensorProcessor` subclasses take and return one tensor. -They apply custom operations in the :class:`~isaaclab.managers.ObservationManager`, such as -normalization or rolling averages. Configure them through -:attr:`~isaaclab.managers.ObservationTermCfg.tensor_processors`. - -:class:`DictTensorProcessor` and :class:`DictTensorProcessorCfg` define a separate interface -for named :class:`~isaaclab.utils.warp.ProxyArray` buffers. Construct these processors with -:func:`~isaaclab.utils.instantiate` and call them directly. Observation terms return one tensor -and use :class:`TensorProcessorCfg`. +"""Stateful and stateless processing for tensors and nested tensor collections. + +Functions and :class:`TensorProcessor` subclasses share :class:`TensorProcessorCfg`. +``TensorProcessorData`` describes :class:`torch.Tensor`, :class:`~isaaclab.utils.warp.ProxyArray`, +and recursively nested lists, tuples, and dictionaries of those values. Each implementation +defines the types and structures it accepts; the base class does not traverse collections. + +Inputs belong to the caller and are read-only. Structured processors can inspect input metadata +and allocate outputs during construction, without reading input values or advancing state. +Subclasses own any persistent output buffers they allocate; their contents remain valid until +the next call, so consumers must copy results they need to retain. Warp operations use the +caller's current Warp stream. Override ``reset`` or ``close`` when a subclass owns state or +external resources; both methods do nothing by default. + +The :class:`~isaaclab.managers.ObservationManager` uses shape-preserving single-tensor processors, +configured through :attr:`~isaaclab.managers.ObservationTermCfg.tensor_processors`. Built-in +filters also operate on single tensors. Construct structured processors directly using their +implementation's constructor. Configuration ``inputs`` and ``outputs`` fields describe named +keys only; they do not select values or route data between processors. Usage with a function processor: diff --git a/source/isaaclab/isaaclab/utils/tensor_processors/__init__.pyi b/source/isaaclab/isaaclab/utils/tensor_processors/__init__.pyi index 9c39677d11e6..283e99c94667 100644 --- a/source/isaaclab/isaaclab/utils/tensor_processors/__init__.pyi +++ b/source/isaaclab/isaaclab/utils/tensor_processors/__init__.pyi @@ -4,12 +4,11 @@ # SPDX-License-Identifier: BSD-3-Clause __all__ = [ - "DictTensorProcessor", - "DictTensorProcessorCfg", "TensorProcessorCfg", "DigitalFilterCfg", "IntegratorCfg", "TensorProcessor", + "TensorProcessorData", "DigitalFilter", "Integrator", "bias", @@ -18,8 +17,5 @@ __all__ = [ ] from .tensor_processor_cfg import TensorProcessorCfg, DigitalFilterCfg, IntegratorCfg -from .tensor_processor import TensorProcessor +from .tensor_processor import TensorProcessor, TensorProcessorData from .processors import DigitalFilter, Integrator, bias, clip, scale - -from .dict_tensor_processor import DictTensorProcessor -from .dict_tensor_processor_cfg import DictTensorProcessorCfg diff --git a/source/isaaclab/isaaclab/utils/tensor_processors/dict_tensor_processor.py b/source/isaaclab/isaaclab/utils/tensor_processors/dict_tensor_processor.py deleted file mode 100644 index 1ee875cdabe0..000000000000 --- a/source/isaaclab/isaaclab/utils/tensor_processors/dict_tensor_processor.py +++ /dev/null @@ -1,47 +0,0 @@ -# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). -# All rights reserved. -# -# SPDX-License-Identifier: BSD-3-Clause - -from __future__ import annotations - -from abc import ABC, abstractmethod -from collections.abc import Sequence -from typing import TYPE_CHECKING - -from ..warp import ProxyArray - -if TYPE_CHECKING: - from .dict_tensor_processor_cfg import DictTensorProcessorCfg - - -class DictTensorProcessor(ABC): - """Transform a dictionary of named tensors into a dictionary of named tensors. - - This is the dictionary counterpart of :class:`TensorProcessor`. Tensor values use - :class:`ProxyArray` so processors can use Warp or Torch without copying. Inputs are - borrowed read-only. Constructors inspect their shape, dtype and device and allocate - persistent outputs without reading input values or advancing processor state. - - Subclasses populate ``outputs`` with the buffers they produce, implement ``__call__``, - and override ``reset`` or ``close`` when they own state or external resources. Calls - return those same output buffers; values remain valid until the next call. Consumers - that retain results must copy them. GPU work uses the caller's current Warp stream. - """ - - def __init__(self, cfg: DictTensorProcessorCfg, data: dict[str, ProxyArray]) -> None: - self.cfg = cfg - self.outputs: dict[str, ProxyArray] = {} - - @abstractmethod - def __call__(self, data: dict[str, ProxyArray]) -> dict[str, ProxyArray]: - """Process current inputs and return the named outputs without mutating inputs.""" - raise NotImplementedError - - def reset(self, env_ids: Sequence[int] | None = None) -> None: - """Reset state for selected batch entries, or all entries when None.""" - pass - - def close(self) -> None: - """Release owned resources; repeated calls must have no effect.""" - pass diff --git a/source/isaaclab/isaaclab/utils/tensor_processors/dict_tensor_processor_cfg.py b/source/isaaclab/isaaclab/utils/tensor_processors/dict_tensor_processor_cfg.py deleted file mode 100644 index 0b1b2b16d3c6..000000000000 --- a/source/isaaclab/isaaclab/utils/tensor_processors/dict_tensor_processor_cfg.py +++ /dev/null @@ -1,41 +0,0 @@ -# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). -# All rights reserved. -# -# SPDX-License-Identifier: BSD-3-Clause - -from __future__ import annotations - -from dataclasses import MISSING -from typing import TYPE_CHECKING, Any - -from ..configclass import configclass - -if TYPE_CHECKING: - from .dict_tensor_processor import DictTensorProcessor - - -@configclass -class DictTensorProcessorCfg: - """Configure a processor for named tensors. - - Declare the keys it reads in :attr:`inputs` and the keys it adds or replaces in - :attr:`outputs`. Construct the processor with :func:`~isaaclab.utils.instantiate`, - passing this configuration and the dictionary of input buffers. String class references - resolve lazily at construction. - """ - - class_type: type[DictTensorProcessor] | str = MISSING - """Processor class, or a resolvable import string.""" - - inputs: list[str] = MISSING - """Dictionary keys required from the producer or preceding processors.""" - - outputs: list[str] = MISSING - """Dictionary keys produced or replaced by this processor.""" - - params: dict[str, Any] = {} - """Processor-specific settings, accessible through ``cfg.params``. - - Configuration fields are copied at construction. Shared device resources must preserve identity - through ``__deepcopy__``; do not place raw Warp allocations in this dictionary. - """ diff --git a/source/isaaclab/isaaclab/utils/tensor_processors/tensor_processor.py b/source/isaaclab/isaaclab/utils/tensor_processors/tensor_processor.py index 7bd7da2476d6..058c3868fe7e 100644 --- a/source/isaaclab/isaaclab/utils/tensor_processors/tensor_processor.py +++ b/source/isaaclab/isaaclab/utils/tensor_processors/tensor_processor.py @@ -7,53 +7,77 @@ from abc import ABC, abstractmethod from collections.abc import Sequence -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Union import torch if TYPE_CHECKING: + from ..warp import ProxyArray from .tensor_processor_cfg import TensorProcessorCfg +TensorProcessorData = Union[ # noqa: UP007 + torch.Tensor, + "ProxyArray", + list["TensorProcessorData"], + tuple["TensorProcessorData", ...], + dict[str, "TensorProcessorData"], +] +"""A tensor or a nested collection of tensors accepted or returned by a processor.""" + + class TensorProcessor(ABC): - """Transform one tensor into one tensor, optionally retaining state between calls. + """Transform tensors or nested collections, optionally retaining state between calls. + + Subclasses implement ``__call__`` and define the input and output structures they support; + these need not match. Collections may contain tensors, ProxyArrays, lists, tuples, or + dictionaries. Inputs are borrowed read-only. Stateful processors override ``reset``; + processors that own external resources override ``close``. - Subclasses implement ``__call__`` and ``reset``. Observation processors preserve the input shape. - Configuration and dimensions - are supplied by the observation manager once during construction. + Observation processors accept and return one tensor of the same shape. Their configuration, + dimensions, and device are supplied by the observation manager. Structured processors may + instead define a constructor accepting input buffers and call ``super().__init__(cfg)``. + Constructors inspect input metadata and allocate outputs without reading input values or + advancing state. Persistent outputs remain valid until the next call; consumers retaining + results must copy them. Warp-based GPU work uses the caller's current Warp stream. """ - def __init__(self, cfg: TensorProcessorCfg, data_dim: tuple[int, ...], device: str) -> None: + def __init__( + self, cfg: TensorProcessorCfg, data_dim: tuple[int, ...] | None = None, device: str | None = None + ) -> None: """Initializes the processor class. Args: cfg: Configuration parameters. - data_dim: The dimensions of the data to be modified. First element is the batch size - which usually corresponds to number of environments in the simulation. - device: The device to run the processor on. + data_dim: Dimensions for a single-tensor processor. The first element is the batch size, + usually the number of environments. Omit when the subclass handles structured inputs. + device: Device for a single-tensor processor. Structured processors may infer it from inputs. """ self._cfg = cfg self._data_dim = data_dim self._device = device - @abstractmethod - def reset(self, env_ids: Sequence[int] | None = None): - """Resets the Processor. + def reset(self, env_ids: Sequence[int] | None = None) -> None: + """Reset state for selected batch entries, or all entries when None. Args: env_ids: The environment ids. Defaults to None, in which case all environments are considered. """ - raise NotImplementedError + pass + + def close(self) -> None: + """Release owned resources; repeated calls must have no effect.""" + pass @abstractmethod - def __call__(self, data: torch.Tensor) -> torch.Tensor: - """Abstract method for defining the modification function. + def __call__(self, data: TensorProcessorData) -> TensorProcessorData: + """Process inputs without mutating them and return the result. Args: - data: The data to be modified. Shape should match the data_dim passed during initialization. + data: Tensor or nested collection supported by the implementation. Returns: - Modified data. Shape is the same as the input data. + Processed data with the structure defined by the implementation. """ raise NotImplementedError diff --git a/source/isaaclab/isaaclab/utils/tensor_processors/tensor_processor_cfg.py b/source/isaaclab/isaaclab/utils/tensor_processors/tensor_processor_cfg.py index 71d81ea7c90a..e9becfe63a40 100644 --- a/source/isaaclab/isaaclab/utils/tensor_processors/tensor_processor_cfg.py +++ b/source/isaaclab/isaaclab/utils/tensor_processors/tensor_processor_cfg.py @@ -9,24 +9,25 @@ from dataclasses import MISSING from typing import TYPE_CHECKING, Any -import torch - from .. import configclass if TYPE_CHECKING: from .processors import DigitalFilter, Integrator - from .tensor_processor import TensorProcessor + from .tensor_processor import TensorProcessor, TensorProcessorData @configclass class TensorProcessorCfg: """Configuration parameters for function and class tensor processors.""" - func: Callable[..., torch.Tensor] | type[TensorProcessor] | str = MISSING + func: Callable[..., TensorProcessorData] | type[TensorProcessor] | str = MISSING """Function or :class:`TensorProcessor` class used by the processor. - Functions must take a tensor as their first argument. Classes must inherit from :class:`TensorProcessor`; the - observation manager constructs them with the configuration, observation dimensions, and device. + Functions take their input data as the first argument. Classes inherit from :class:`TensorProcessor`. + Each implementation defines the tensor or nested collection structures it accepts and returns. + Construct classes with ``cfg.func(cfg, ...)``, supplying the arguments their constructor requires. + Import strings resolve lazily through ``configclass``. The observation manager constructs + single-tensor processors with the configuration, observation dimensions, and device. """ params: dict[str, Any] = {} @@ -36,6 +37,18 @@ class TensorProcessorCfg: configuration during construction; they are not forwarded to the constructed instance. """ + inputs: list[str] = [] + """Named input keys for callers that route dictionaries. Empty means unspecified. + + This is metadata only; the configuration does not select or validate input data. + """ + + outputs: list[str] = [] + """Named output keys for callers that route dictionaries. Empty means unspecified. + + This is metadata only; the processor implementation defines the returned structure. + """ + @configclass class DigitalFilterCfg(TensorProcessorCfg): diff --git a/source/isaaclab/test/managers/test_manager_base.py b/source/isaaclab/test/managers/test_manager_base.py index 9bb2aa40ebb3..ea124458c69c 100644 --- a/source/isaaclab/test/managers/test_manager_base.py +++ b/source/isaaclab/test/managers/test_manager_base.py @@ -87,7 +87,7 @@ class NestedFieldTermCfg(ManagerTermBaseCfg): tensor_processor: tensor_processors.TensorProcessorCfg = tensor_processors.TensorProcessorCfg( func=increment_dummy1_by_one ) - dict_processor: tensor_processors.DictTensorProcessorCfg | None = None + structured_processor: tensor_processors.TensorProcessorCfg | None = None metadata: dict[str, str] = {"func": f"{__name__}:reset_dummy2_to_zero"} opaque: OpaqueCfg = OpaqueCfg() @@ -198,13 +198,13 @@ def test_string_func_in_nested_term_cfg(env): def test_resolution_walks_declared_term_fields_outside_params(env): - """Resolve processor parameters and nested terms while preserving lazy classes and opaque configs.""" + """Resolve processor parameters and nested terms while preserving opaque configs.""" env = SimpleNamespace(**env._asdict(), scene=dict(robot=SimpleNamespace(joint_names=["a", "b", "c"]))) outer_cfg = NestedFieldTermCfg( func=increment_dummy1_by_one, nested_term=ManagerTermBaseCfg(func=f"{__name__}:reset_dummy2_to_zero"), - dict_processor=tensor_processors.DictTensorProcessorCfg( - class_type="isaaclab.utils.tensor_processors:DictTensorProcessor", + structured_processor=tensor_processors.TensorProcessorCfg( + func="isaaclab.utils.tensor_processors:TensorProcessor", inputs=["source"], outputs=["result"], params={ @@ -221,10 +221,10 @@ def test_resolution_walks_declared_term_fields_outside_params(env): assert isinstance(term_cfg, NestedFieldTermCfg) assert term_cfg.nested_term.func is reset_dummy2_to_zero assert term_cfg.tensor_processor.func is increment_dummy1_by_one - assert isinstance(term_cfg.dict_processor.class_type, str) - assert isinstance(term_cfg.dict_processor.params["term"].func, reset_dummy2_to_zero_class) - assert term_cfg.dict_processor.params["asset_cfg"].joint_names == ["c", "a"] - torch.testing.assert_close(term_cfg.dict_processor.params["asset_cfg"].joint_ids, torch.tensor([2, 0])) + assert term_cfg.structured_processor.func is tensor_processors.TensorProcessor + assert isinstance(term_cfg.structured_processor.params["term"].func, reset_dummy2_to_zero_class) + assert term_cfg.structured_processor.params["asset_cfg"].joint_names == ["c", "a"] + torch.testing.assert_close(term_cfg.structured_processor.params["asset_cfg"].joint_ids, torch.tensor([2, 0])) assert isinstance(term_cfg.metadata["func"], str) assert isinstance(term_cfg.opaque.func, str) diff --git a/source/isaaclab/test/utils/test_tensor_processors.py b/source/isaaclab/test/utils/test_tensor_processors.py index 884020381843..22cd25bd2aca 100644 --- a/source/isaaclab/test/utils/test_tensor_processors.py +++ b/source/isaaclab/test/utils/test_tensor_processors.py @@ -9,10 +9,12 @@ import pytest import torch +import warp as wp import isaaclab.utils.tensor_processors as tensor_processors from isaaclab.test.utils import test_devices -from isaaclab.utils import configclass +from isaaclab.utils import configclass, to_dict +from isaaclab.utils.warp import ProxyArray pytestmark = pytest.mark.unit @@ -81,6 +83,54 @@ def test_stateless_tensor_processors(func, params, result): assert torch.allclose(output, torch.tensor(result)) +class ScaledSumProcessor(tensor_processors.TensorProcessor): + """Combine named inputs into one persistent tensor without requiring dimensions.""" + + def __init__(self, cfg: tensor_processors.TensorProcessorCfg, data: list[dict[str, ProxyArray]]) -> None: + super().__init__(cfg) + self.output = torch.empty_like(data[0][cfg.inputs[0]].torch) + + def __call__(self, data: list[dict[str, ProxyArray]]) -> torch.Tensor: + key = self._cfg.inputs[0] + torch.add(data[0][key].torch, data[1][key].torch, out=self.output) + return self.output.mul_(self._cfg.params["multiplier"]) + + +def test_structured_tensor_processor(): + """Structured inputs share the processor lifecycle, configuration, and callable interface.""" + first = torch.tensor([1.0, 2.0, 3.0]) + second = torch.tensor([4.0, 5.0, 6.0]) + data = [{"samples": ProxyArray(wp.from_torch(value))} for value in (first, second)] + cfg = tensor_processors.TensorProcessorCfg( + func=ScaledSumProcessor, inputs=["samples"], outputs=["sum"], params={"multiplier": 2.0} + ) + processor = cfg.func(cfg, data) + + output = processor(data) + torch.testing.assert_close(output, torch.tensor([10.0, 14.0, 18.0])) + torch.testing.assert_close(first, torch.tensor([1.0, 2.0, 3.0])) + torch.testing.assert_close(second, torch.tensor([4.0, 5.0, 6.0])) + + first.add_(1.0) + assert processor(data) is output + torch.testing.assert_close(output, torch.tensor([12.0, 16.0, 20.0])) + torch.testing.assert_close(first, torch.tensor([2.0, 3.0, 4.0])) + torch.testing.assert_close(second, torch.tensor([4.0, 5.0, 6.0])) + + processor.reset(env_ids=[0]) + processor.reset() + processor.close() + processor.close() + torch.testing.assert_close(output, torch.tensor([12.0, 16.0, 20.0])) + + restored_cfg = tensor_processors.TensorProcessorCfg(**to_dict(cfg)) + assert isinstance(restored_cfg.func, str) + assert restored_cfg.inputs == ["samples"] + assert restored_cfg.outputs == ["sum"] + restored_processor = restored_cfg.func(restored_cfg, data) + torch.testing.assert_close(restored_processor(data), torch.tensor([12.0, 16.0, 20.0])) + + @pytest.mark.parametrize("device", test_devices()) def test_digital_filter(device): """Test digital filter tensor processor.""" From ee9d78d872f7e79000a19fcf8ef66176dd906d95 Mon Sep 17 00:00:00 2001 From: Matthew Taylor Date: Fri, 23 Oct 2026 17:33:21 -0400 Subject: [PATCH 20/20] Address tensor processor review feedback --- docs/source/api/lab/isaaclab.utils.rst | 64 +++++++++---------- .../dict-tensor-processors.deprecated.rst | 3 + ...ors.major => dict-tensor-processors.minor} | 0 .../dict-tensor-processors.removed.rst | 2 - .../isaaclab/managers/manager_term_cfg.py | 24 +++++++ .../isaaclab/managers/observation_manager.py | 7 +- .../utils/tensor_processors/processors.py | 2 +- .../test/managers/test_observation_manager.py | 51 +++++++++++++-- .../dict-tensor-processors.deprecated.rst | 3 + ...ors.major => dict-tensor-processors.minor} | 0 .../dict-tensor-processors.removed.rst | 2 - .../managers/observation_manager.py | 1 + 12 files changed, 112 insertions(+), 47 deletions(-) create mode 100644 source/isaaclab/changelog.d/dict-tensor-processors.deprecated.rst rename source/isaaclab/changelog.d/{dict-tensor-processors.major => dict-tensor-processors.minor} (100%) delete mode 100644 source/isaaclab/changelog.d/dict-tensor-processors.removed.rst create mode 100644 source/isaaclab_experimental/changelog.d/dict-tensor-processors.deprecated.rst rename source/isaaclab_experimental/changelog.d/{dict-tensor-processors.major => dict-tensor-processors.minor} (100%) delete mode 100644 source/isaaclab_experimental/changelog.d/dict-tensor-processors.removed.rst diff --git a/docs/source/api/lab/isaaclab.utils.rst b/docs/source/api/lab/isaaclab.utils.rst index 91c8d53d67ec..27a2979f6757 100644 --- a/docs/source/api/lab/isaaclab.utils.rst +++ b/docs/source/api/lab/isaaclab.utils.rst @@ -17,11 +17,11 @@ logger math mesh - tensor_processors noise seed sensors string + tensor_processors timer types version @@ -124,6 +124,37 @@ Mesh operations :imported-members: :show-inheritance: +Noise operations +~~~~~~~~~~~~~~~~ + +.. automodule:: isaaclab.utils.noise + :members: + :imported-members: + :inherited-members: + :show-inheritance: + :exclude-members: __init__, func + +Seed operations +~~~~~~~~~~~~~~~ + +.. automodule:: isaaclab.utils.seed + :members: + :show-inheritance: + +Sensor operations +~~~~~~~~~~~~~~~~~ + +.. automodule:: isaaclab.utils.sensors + :members: + :show-inheritance: + +String operations +~~~~~~~~~~~~~~~~~ + +.. automodule:: isaaclab.utils.string + :members: + :show-inheritance: + Tensor processors ~~~~~~~~~~~~~~~~~ @@ -186,37 +217,6 @@ Old imports remain supported and emit ``DeprecationWarning`` with migration inst :show-inheritance: :exclude-members: __init__, func -Noise operations -~~~~~~~~~~~~~~~~ - -.. automodule:: isaaclab.utils.noise - :members: - :imported-members: - :inherited-members: - :show-inheritance: - :exclude-members: __init__, func - -Seed operations -~~~~~~~~~~~~~~~ - -.. automodule:: isaaclab.utils.seed - :members: - :show-inheritance: - -Sensor operations -~~~~~~~~~~~~~~~~~ - -.. automodule:: isaaclab.utils.sensors - :members: - :show-inheritance: - -String operations -~~~~~~~~~~~~~~~~~ - -.. automodule:: isaaclab.utils.string - :members: - :show-inheritance: - Image operations ~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab/changelog.d/dict-tensor-processors.deprecated.rst b/source/isaaclab/changelog.d/dict-tensor-processors.deprecated.rst new file mode 100644 index 000000000000..144fbe2abbd8 --- /dev/null +++ b/source/isaaclab/changelog.d/dict-tensor-processors.deprecated.rst @@ -0,0 +1,3 @@ +* Deprecated ``ObservationTermCfg.modifiers`` in favor of ``ObservationTermCfg.tensor_processors``. + Retained the legacy field with a targeted warning and forwarding during manager initialization. + Setting both fields now raises ``ValueError`` to prevent silently overriding either pipeline. diff --git a/source/isaaclab/changelog.d/dict-tensor-processors.major b/source/isaaclab/changelog.d/dict-tensor-processors.minor similarity index 100% rename from source/isaaclab/changelog.d/dict-tensor-processors.major rename to source/isaaclab/changelog.d/dict-tensor-processors.minor diff --git a/source/isaaclab/changelog.d/dict-tensor-processors.removed.rst b/source/isaaclab/changelog.d/dict-tensor-processors.removed.rst deleted file mode 100644 index 516eeb8428e2..000000000000 --- a/source/isaaclab/changelog.d/dict-tensor-processors.removed.rst +++ /dev/null @@ -1,2 +0,0 @@ -* **Breaking:** Removed ``ObservationTermCfg.modifiers`` in favor of - ``ObservationTermCfg.tensor_processors``. diff --git a/source/isaaclab/isaaclab/managers/manager_term_cfg.py b/source/isaaclab/isaaclab/managers/manager_term_cfg.py index 021e75887529..0a62de79c601 100644 --- a/source/isaaclab/isaaclab/managers/manager_term_cfg.py +++ b/source/isaaclab/isaaclab/managers/manager_term_cfg.py @@ -7,6 +7,7 @@ from __future__ import annotations +import warnings from collections.abc import Callable from dataclasses import MISSING from typing import TYPE_CHECKING, Any, Literal @@ -236,6 +237,14 @@ class ObservationTermCfg(ManagerTermBaseCfg): """Whether or not the observation manager should flatten history-based observation terms to a 2-D (N, D) tensor. Defaults to True.""" + modifiers: list[TensorProcessorCfg] | None = None + """Deprecated alias for :attr:`tensor_processors`, to be removed in a future release. + + During manager initialization, a non-None value emits a :class:`FutureWarning`, is forwarded + to :attr:`tensor_processors`, and is cleared from this field. Set only one of the two fields; + providing both raises :class:`ValueError`, even for empty lists. + """ + def validate_config(self): """Validate observation delay bounds.""" if type(self.delay_min_lag) is not int or type(self.delay_max_lag) is not int: @@ -245,6 +254,21 @@ def validate_config(self): if not 0.0 <= self.delay_hold_prob <= 1.0: raise ValueError("delay_hold_prob must be in [0, 1].") + def _migrate_modifiers(self) -> None: + """Normalize the deprecated field after user edits and before manager preparation.""" + if self.modifiers is None: + return + if self.tensor_processors is not None: + raise ValueError("Set only one of 'modifiers' and 'tensor_processors'.") + warnings.warn( + "ObservationTermCfg.modifiers is deprecated and will be removed in a future release." + " Use ObservationTermCfg.tensor_processors instead.", + FutureWarning, + stacklevel=5, + ) + self.tensor_processors = self.modifiers + self.modifiers = None + @configclass class ObservationGroupCfg: diff --git a/source/isaaclab/isaaclab/managers/observation_manager.py b/source/isaaclab/isaaclab/managers/observation_manager.py index f625af2fdf5c..313d87c66f9f 100644 --- a/source/isaaclab/isaaclab/managers/observation_manager.py +++ b/source/isaaclab/isaaclab/managers/observation_manager.py @@ -389,7 +389,7 @@ def compute_group(self, group_name: str, update_history: bool = False) -> torch. 6. Apply delay based on :attr:`ObservationTermCfg.delay_min_lag` and :attr:`ObservationTermCfg.delay_max_lag` 7. Append the delayed observation to history if requested - We apply noise to the computed term first to maintain the integrity of how noise affects the data + We apply noise before clipping and scaling to maintain the integrity of how noise affects the data as it truly exists in the real world. If the noise is applied after clipping or scaling, the noise could be artificially constrained or amplified, which might misrepresent how noise naturally occurs in the data. @@ -419,7 +419,7 @@ def compute_group(self, group_name: str, update_history: bool = False) -> torch. term_cfgs = self._group_obs_term_cfgs[group_name] obs_terms = zip(group_term_names, term_cfgs) - # evaluate terms: compute, add noise, clip, scale, custom tensor_processors + # evaluate terms: compute, apply custom tensor processors, add noise, clip, scale for term_name, term_cfg in obs_terms: output_spec = self._term_output_specs.get((group_name, term_name)) owned = output_spec is not None @@ -569,7 +569,7 @@ def _prepare_terms(self): self._group_obs_term_delay_buffer: dict[str, dict[str, DelayBuffer]] = {} self._group_obs_term_history_buffer: dict[str, dict] = {} - # create a list to store classes instances, e.g., for tensor_processors and noise models + # create a list to store class instances, e.g., for tensor processors and noise models # we store it as a separate list to only call reset on them and prevent unnecessary calls self._group_obs_class_instances: list[tensor_processors.TensorProcessor | noise.NoiseModel] = [] @@ -647,6 +647,7 @@ def _prepare_terms(self): f"Configuration for the term '{term_name}' is not of type ObservationTermCfg." f" Received: '{type(term_cfg)}'." ) + term_cfg._migrate_modifiers() term_cfg.validate_config() # resolve common terms in the config self._resolve_common_term_cfg(f"{group_name}/{term_name}", term_cfg, min_argc=1) diff --git a/source/isaaclab/isaaclab/utils/tensor_processors/processors.py b/source/isaaclab/isaaclab/utils/tensor_processors/processors.py index ff9a4a333f97..6440c56b342c 100644 --- a/source/isaaclab/isaaclab/utils/tensor_processors/processors.py +++ b/source/isaaclab/isaaclab/utils/tensor_processors/processors.py @@ -62,7 +62,7 @@ def bias(data: torch.Tensor, value: float) -> torch.Tensor: ## -# Sample of class based tensor_processors +# Sample of class based tensor processors ## diff --git a/source/isaaclab/test/managers/test_observation_manager.py b/source/isaaclab/test/managers/test_observation_manager.py index 39efd7f0a6ff..43437bff1579 100644 --- a/source/isaaclab/test/managers/test_observation_manager.py +++ b/source/isaaclab/test/managers/test_observation_manager.py @@ -11,6 +11,7 @@ # ignore private usage of variables warning # pyright: reportPrivateUsage=none from collections import namedtuple +from contextlib import nullcontext from typing import TYPE_CHECKING, cast import pytest @@ -872,21 +873,57 @@ def test_class_tensor_processor_roundtrip_preserves_func_and_params(): assert prepared_tensor_processor_cfg.func.reset_count == 1 -def test_stateless_tensor_processor_cfg_roundtrip_preserves_signature_validation(): - """Serialized function processors retain their parameters and compute observations.""" +@pytest.mark.parametrize("configuration", ["current", "legacy_constructor", "legacy_assignment", "legacy_warp"]) +def test_stateless_tensor_processor_cfg_roundtrip_preserves_signature_validation(configuration): + """Current and deprecated processor fields preserve parameters and compute observations.""" cfg = HistoryObservationsCfg() cfg.policy.history_length = None - cfg.policy.dummy.tensor_processors = [ - tensor_processors.TensorProcessorCfg(func=tensor_processors.bias, params={"value": 2.0}) - ] - update_from_dict(cfg, to_dict(cfg)) + manager_type = ObservationManager + processors = [tensor_processors.TensorProcessorCfg(func=tensor_processors.bias, params={"value": 2.0})] + if configuration == "current": + cfg.policy.dummy.tensor_processors = processors + elif configuration == "legacy_constructor": + cfg.policy.dummy = ObservationTermCfg(func=dummy_observation, modifiers=processors) + elif configuration == "legacy_assignment": + cfg.policy.dummy.modifiers = processors + else: + from isaaclab_experimental.managers import ObservationManager as WarpObservationManager + from isaaclab_experimental.utils import tensor_processors as warp_processors + + def warp_observation(env, out, out_dim): + out.assign(env.observation.numpy().reshape(-1)) + + manager_type = WarpObservationManager + cfg.policy.dummy = ObservationTermCfg(func=warp_observation, params={"out_dim": 1}) + cfg.policy.dummy.modifiers = [ + warp_processors.TensorProcessorCfg(func=warp_processors.bias, params={"value": 2.0}) + ] + if configuration != "legacy_warp": + update_from_dict(cfg, to_dict(cfg)) env = DummyEnv() - manager = ObservationManager(cfg, cast("ManagerBasedEnv", env)) + warning = ( + nullcontext() + if configuration == "current" + else pytest.warns(FutureWarning, match=r"ObservationTermCfg\.modifiers.*tensor_processors") + ) + with warning as recorded: + manager = manager_type(cfg, cast("ManagerBasedEnv", env)) + if recorded is not None: + assert recorded[0].filename == __file__ observations = manager.compute()["policy"] torch.testing.assert_close(observations, env.observation + 2.0) +def test_observation_rejects_conflicting_processor_fields(): + """Neither processor field silently overrides the other, even for empty lists.""" + cfg = HistoryObservationsCfg() + cfg.policy.dummy.modifiers = [] + cfg.policy.dummy.tensor_processors = [] + with pytest.raises(ValueError, match="Set only one of 'modifiers' and 'tensor_processors'"): + ObservationManager(cfg, cast("ManagerBasedEnv", DummyEnv())) + + def test_class_tensor_processor_validates_constructed_instance(): """Class tensor processor validation checks the constructed object.""" cfg = HistoryObservationsCfg() diff --git a/source/isaaclab_experimental/changelog.d/dict-tensor-processors.deprecated.rst b/source/isaaclab_experimental/changelog.d/dict-tensor-processors.deprecated.rst new file mode 100644 index 000000000000..144fbe2abbd8 --- /dev/null +++ b/source/isaaclab_experimental/changelog.d/dict-tensor-processors.deprecated.rst @@ -0,0 +1,3 @@ +* Deprecated ``ObservationTermCfg.modifiers`` in favor of ``ObservationTermCfg.tensor_processors``. + Retained the legacy field with a targeted warning and forwarding during manager initialization. + Setting both fields now raises ``ValueError`` to prevent silently overriding either pipeline. diff --git a/source/isaaclab_experimental/changelog.d/dict-tensor-processors.major b/source/isaaclab_experimental/changelog.d/dict-tensor-processors.minor similarity index 100% rename from source/isaaclab_experimental/changelog.d/dict-tensor-processors.major rename to source/isaaclab_experimental/changelog.d/dict-tensor-processors.minor diff --git a/source/isaaclab_experimental/changelog.d/dict-tensor-processors.removed.rst b/source/isaaclab_experimental/changelog.d/dict-tensor-processors.removed.rst deleted file mode 100644 index ea2e4ce0e811..000000000000 --- a/source/isaaclab_experimental/changelog.d/dict-tensor-processors.removed.rst +++ /dev/null @@ -1,2 +0,0 @@ -* **Breaking:** Removed support for ``ObservationTermCfg.modifiers``. Use - ``ObservationTermCfg.tensor_processors`` for observation processing. diff --git a/source/isaaclab_experimental/isaaclab_experimental/managers/observation_manager.py b/source/isaaclab_experimental/isaaclab_experimental/managers/observation_manager.py index fa5504fb2f63..b58bfeebe788 100644 --- a/source/isaaclab_experimental/isaaclab_experimental/managers/observation_manager.py +++ b/source/isaaclab_experimental/isaaclab_experimental/managers/observation_manager.py @@ -682,6 +682,7 @@ def _prepare_terms(self): # noqa: C901 f"Configuration for the term '{term_name}' is not of type ObservationTermCfg." f" Received: '{type(term_cfg)}'." ) + term_cfg._migrate_modifiers() # resolve common terms in the config # Warp-first signature is (env, out, **params) self._resolve_common_term_cfg(f"{group_name}/{term_name}", term_cfg, min_argc=2)