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..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 - modifiers noise seed sensors string + tensor_processors timer types version @@ -124,17 +124,6 @@ Mesh operations :imported-members: :show-inheritance: -Modifier operations -~~~~~~~~~~~~~~~~~~~ - -.. automodule:: isaaclab.utils.modifiers - :members: - :imported-members: - :special-members: __call__ - :inherited-members: - :show-inheritance: - :exclude-members: __init__, func - Noise operations ~~~~~~~~~~~~~~~~ @@ -166,6 +155,68 @@ String operations :members: :show-inheritance: +Tensor processors +~~~~~~~~~~~~~~~~~ + +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 +``isaaclab.utils.modifiers``. Replace ``ObservationTermCfg.modifiers`` with +``ObservationTermCfg.tensor_processors``. The processing order and tensor shapes are unchanged. +Old imports remain supported and emit ``DeprecationWarning`` with migration instructions. + +.. automodule:: isaaclab.utils.tensor_processors + :members: + :imported-members: + :special-members: __call__ + :inherited-members: + :show-inheritance: + :exclude-members: __init__, func + Image operations ~~~~~~~~~~~~~~~~ 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..bfa599222114 --- /dev/null +++ b/source/isaaclab/changelog.d/dict-tensor-processors.added.rst @@ -0,0 +1,5 @@ +* 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/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.minor b/source/isaaclab/changelog.d/dict-tensor-processors.minor new file mode 100644 index 000000000000..e69de29bb2d1 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/managers/manager_base.py b/source/isaaclab/isaaclab/managers/manager_base.py index bb577223857d..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.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..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 @@ -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: @@ -162,20 +163,23 @@ class ObservationTermCfg(ManagerTermBaseCfg): """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. """ - 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 +210,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 +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: @@ -242,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 a4ab4fefd3f4..313d87c66f9f 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. @@ -275,9 +275,9 @@ 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 [ - "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,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 modifiers 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 {} @@ -382,14 +382,14 @@ 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` 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 modifiers + # 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 @@ -433,15 +433,15 @@ 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 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, modifiers.ModifierBase): - 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) @@ -544,6 +544,11 @@ def _prepare_term_output(self, group_name: str, term_name: str, term_cfg: Observ 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 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) @@ -564,9 +569,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 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] = [] + 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 +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) @@ -676,39 +682,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 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 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 ModifierCfg, 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 modifiers 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): + # 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 'ModifierBase'. 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 @@ -717,8 +728,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/isaaclab/utils/__init__.pyi b/source/isaaclab/isaaclab/utils/__init__.pyi index 76dd49752ed0..a6723caf9fd3 100644 --- a/source/isaaclab/isaaclab/utils/__init__.pyi +++ b/source/isaaclab/isaaclab/utils/__init__.pyi @@ -33,6 +33,8 @@ __all__ = [ "PRIMITIVE_MESH_TYPES", "ModifierCfg", "ModifierBase", + "TensorProcessorCfg", + "TensorProcessor", "DigitalFilter", "DigitalFilterCfg", "Integrator", @@ -87,9 +89,10 @@ from .mesh import ( convert_faces_to_triangles, PRIMITIVE_MESH_TYPES, ) -from .modifiers import ( - ModifierCfg, - ModifierBase, +from .modifiers import ModifierCfg, ModifierBase +from .tensor_processors import ( + 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..79d3ee52b8f3 100644 --- a/source/isaaclab/isaaclab/utils/modifiers/__init__.py +++ b/source/isaaclab/isaaclab/utils/modifiers/__init__.py @@ -3,56 +3,42 @@ # # 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. +import sys +import warnings -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. +from .. import tensor_processors as _processors -Usage with a function modifier: +__all__ = [ + "ModifierCfg", + "DigitalFilterCfg", + "IntegratorCfg", + "ModifierBase", + "DigitalFilter", + "Integrator", + "bias", + "clip", + "scale", +] -.. code-block:: python +warnings.warn( + "isaaclab.utils.modifiers is deprecated; use isaaclab.utils.tensor_processors." + " ModifierBase and ModifierCfg are now TensorProcessor and TensorProcessorCfg.", + DeprecationWarning, + stacklevel=2, +) - import torch - from isaaclab.utils import modifiers - # create a random tensor - my_tensor = torch.rand(256, 128, device="cuda") +def __getattr__(name: str): + aliases = {"ModifierBase": "TensorProcessor", "ModifierCfg": "TensorProcessorCfg"} + return getattr(_processors, aliases.get(name, name)) - # 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) +def __dir__(): + return __all__ -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) - -""" - -from ..module import lazy_export - -lazy_export() +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/isaaclab/utils/modifiers/modifier_base.py b/source/isaaclab/isaaclab/utils/modifiers/modifier_base.py deleted file mode 100644 index 65a7fe0bb8a2..000000000000 --- a/source/isaaclab/isaaclab/utils/modifiers/modifier_base.py +++ /dev/null @@ -1,79 +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 - -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 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..4576de38aab2 --- /dev/null +++ b/source/isaaclab/isaaclab/utils/tensor_processors/__init__.py @@ -0,0 +1,67 @@ +# 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 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: + +.. 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/modifiers/__init__.pyi b/source/isaaclab/isaaclab/utils/tensor_processors/__init__.pyi similarity index 52% rename from source/isaaclab/isaaclab/utils/modifiers/__init__.pyi rename to source/isaaclab/isaaclab/utils/tensor_processors/__init__.pyi index e15f1284085c..283e99c94667 100644 --- a/source/isaaclab/isaaclab/utils/modifiers/__init__.pyi +++ b/source/isaaclab/isaaclab/utils/tensor_processors/__init__.pyi @@ -4,10 +4,11 @@ # SPDX-License-Identifier: BSD-3-Clause __all__ = [ - "ModifierCfg", + "TensorProcessorCfg", "DigitalFilterCfg", "IntegratorCfg", - "ModifierBase", + "TensorProcessor", + "TensorProcessorData", "DigitalFilter", "Integrator", "bias", @@ -15,6 +16,6 @@ __all__ = [ "scale", ] -from .modifier_cfg import ModifierCfg, DigitalFilterCfg, IntegratorCfg -from .modifier_base import ModifierBase -from .modifier import DigitalFilter, Integrator, bias, clip, scale +from .tensor_processor_cfg import TensorProcessorCfg, DigitalFilterCfg, IntegratorCfg +from .tensor_processor import TensorProcessor, TensorProcessorData +from .processors import DigitalFilter, Integrator, bias, clip, scale diff --git a/source/isaaclab/isaaclab/utils/modifiers/modifier.py b/source/isaaclab/isaaclab/utils/tensor_processors/processors.py similarity index 92% rename from source/isaaclab/isaaclab/utils/modifiers/modifier.py rename to source/isaaclab/isaaclab/utils/tensor_processors/processors.py index 2a6f456981b1..6440c56b342c 100644 --- a/source/isaaclab/isaaclab/utils/modifiers/modifier.py +++ b/source/isaaclab/isaaclab/utils/tensor_processors/processors.py @@ -11,13 +11,13 @@ import torch from ..array import index_fill_ -from .modifier_base import ModifierBase +from .tensor_processor import TensorProcessor if TYPE_CHECKING: - from . import modifier_cfg + from . import tensor_processor_cfg ## -# Modifiers as functions +# Tensor processors as functions ## @@ -62,12 +62,12 @@ def bias(data: torch.Tensor, value: float) -> torch.Tensor: ## -# Sample of class based modifiers +# Sample of class based tensor processors ## -class DigitalFilter(ModifierBase): - r"""Modifier used to apply digital filtering to the input data. +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. @@ -129,14 +129,14 @@ class DigitalFilter(ModifierBase): :math:`B = [1 - \alpha]`. """ - def __init__(self, cfg: modifier_cfg.DigitalFilterCfg, data_dim: tuple[int, ...], device: str): + 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 modifier on. + device: The device to run the processor on. Raises: ValueError: If filter coefficients are None. @@ -191,8 +191,8 @@ def __call__(self, data: torch.Tensor) -> torch.Tensor: return y_i -class Integrator(ModifierBase): - r"""Modifier that applies a numerical forward integration based on a middle Reimann sum. +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 @@ -213,14 +213,14 @@ class Integrator(ModifierBase): :math:`\Delta t` is the time step between samples. """ - def __init__(self, cfg: modifier_cfg.IntegratorCfg, data_dim: tuple[int, ...], device: str): + 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 modifier on. + device: The device to run the processor on. """ # initialize parent class super().__init__(cfg, data_dim, device) 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..058c3868fe7e --- /dev/null +++ b/source/isaaclab/isaaclab/utils/tensor_processors/tensor_processor.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 + +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Sequence +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 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``. + + 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, ...] | None = None, device: str | None = None + ) -> None: + """Initializes the processor class. + + Args: + cfg: Configuration parameters. + 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 + + 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. + """ + pass + + def close(self) -> None: + """Release owned resources; repeated calls must have no effect.""" + pass + + @abstractmethod + def __call__(self, data: TensorProcessorData) -> TensorProcessorData: + """Process inputs without mutating them and return the result. + + Args: + data: Tensor or nested collection supported by the implementation. + + Returns: + Processed data with the structure defined by the implementation. + """ + raise NotImplementedError diff --git a/source/isaaclab/isaaclab/utils/modifiers/modifier_cfg.py b/source/isaaclab/isaaclab/utils/tensor_processors/tensor_processor_cfg.py similarity index 50% rename from source/isaaclab/isaaclab/utils/modifiers/modifier_cfg.py rename to source/isaaclab/isaaclab/utils/tensor_processors/tensor_processor_cfg.py index d279fc62f2ab..e9becfe63a40 100644 --- a/source/isaaclab/isaaclab/utils/modifiers/modifier_cfg.py +++ b/source/isaaclab/isaaclab/utils/tensor_processors/tensor_processor_cfg.py @@ -9,42 +9,55 @@ from dataclasses import MISSING from typing import TYPE_CHECKING, Any -import torch - from .. import configclass if TYPE_CHECKING: - from .modifier import DigitalFilter, Integrator - from .modifier_base import ModifierBase + from .processors import DigitalFilter, Integrator + from .tensor_processor import TensorProcessor, TensorProcessorData @configclass -class ModifierCfg: - """Configuration parameters for function and class modifiers.""" +class TensorProcessorCfg: + """Configuration parameters for function and class tensor processors.""" - func: Callable[..., torch.Tensor] | type[ModifierBase] | str = MISSING - """Function or :class:`ModifierBase` class used by the modifier. + 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:`ModifierBase`; 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] = {} - """Parameters used by the modifier. Defaults to an empty dictionary. + """Parameters used by the processor. Defaults to an empty dictionary. - Function modifiers receive them as keyword arguments on each call. Class modifiers access them through this + 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. """ + 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(ModifierCfg): - """Configuration parameters for a digital filter modifier. +class DigitalFilterCfg(TensorProcessorCfg): + """Configuration parameters for a digital filter processor. For more information, please check the :class:`DigitalFilter` class. """ - func: type[DigitalFilter] | str = "{DIR}.modifier:DigitalFilter" + func: type[DigitalFilter] | str = "{DIR}.processors:DigitalFilter" """The digital filter function to be called for applying the filter.""" A: list[float] = MISSING @@ -69,13 +82,13 @@ class DigitalFilterCfg(ModifierCfg): @configclass -class IntegratorCfg(ModifierCfg): - """Configuration parameters for an integrator modifier. +class IntegratorCfg(TensorProcessorCfg): + """Configuration parameters for an integrator processor. For more information, please check the :class:`Integrator` class. """ - func: type[Integrator] | str = "{DIR}.modifier:Integrator" + func: type[Integrator] | str = "{DIR}.processors:Integrator" """The integrator function to be called for applying the integrator.""" dt: float = MISSING diff --git a/source/isaaclab/test/managers/test_manager_base.py b/source/isaaclab/test/managers/test_manager_base.py index 212ed630fe03..ea124458c69c 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,10 @@ 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) + tensor_processor: tensor_processors.TensorProcessorCfg = tensor_processors.TensorProcessorCfg( + func=increment_dummy1_by_one + ) + structured_processor: tensor_processors.TensorProcessorCfg | None = None metadata: dict[str, str] = {"func": f"{__name__}:reset_dummy2_to_zero"} opaque: OpaqueCfg = OpaqueCfg() @@ -195,10 +198,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 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"), + structured_processor=tensor_processors.TensorProcessorCfg( + func="isaaclab.utils.tensor_processors:TensorProcessor", + 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} @@ -207,7 +220,11 @@ 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 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/managers/test_observation_manager.py b/source/isaaclab/test/managers/test_observation_manager.py index fe00e34d64fd..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 @@ -23,7 +24,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 @@ -578,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 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) + 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: @@ -596,19 +599,21 @@ 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=[tensor_processor_1]) + term_3 = ObservationTermCfg(func=position_term, tensor_processors=[tensor_processor_1, tensor_processor_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=[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() @@ -623,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) @@ -632,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 @@ -691,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 = modifiers.ModifierCfg(func=modifiers.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: @@ -706,7 +713,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=[tensor_processor]) policy: ObservationGroupCfg = PolicyCfg() @@ -800,10 +807,10 @@ def __init__(self, num_envs: int = 2) -> None: self.observation = torch.arange(num_envs, dtype=torch.float32).unsqueeze(-1) -class StatefulBiasModifier(modifiers.ModifierBase): - """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: 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 @@ -815,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 @@ -838,53 +845,93 @@ 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.modifiers = [modifiers.ModifierCfg(func=StatefulBiasModifier, params={"value": 2.0})] + cfg.policy.dummy.tensor_processors = [ + tensor_processors.TensorProcessorCfg(func=StatefulBiasTensorProcessor, 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, StatefulBiasTensorProcessor) 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.""" +@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.modifiers = [modifiers.ModifierCfg(func=modifiers.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_class_modifier_validates_constructed_instance(): - """Class modifier validation checks the constructed object.""" +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() cfg.policy.history_length = None - cfg.policy.dummy.modifiers = [modifiers.ModifierCfg(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 'ModifierBase'"): + with pytest.raises(TypeError, match="is not an instance of 'TensorProcessor'"): ObservationManager(cfg, cast("ManagerBasedEnv", DummyEnv())) @@ -1010,3 +1057,11 @@ def ordinary_term(env, out=None): result = manager.compute()["policy"] setup_env.data.pos_w.zero_() torch.testing.assert_close(result, expected) + + +def test_dictionary_term_rejected(setup_env): + """Non-concatenated groups still require each term to return a tensor.""" + cfg = ObservationGroupCfg(concatenate_terms=False) + cfg.sensors = ObservationTermCfg(func=lambda env: {"position": env.data.pos_w}) + with pytest.raises(TypeError, match=r"torch\.Tensor"): + ObservationManager({"policy": cfg}, setup_env) 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_modifiers.py b/source/isaaclab/test/utils/test_modifiers.py deleted file mode 100644 index c131ce76addf..000000000000 --- a/source/isaaclab/test/utils/test_modifiers.py +++ /dev/null @@ -1,118 +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 dataclasses import MISSING - -import pytest -import torch - -import isaaclab.utils.modifiers as modifiers -from isaaclab.test.utils import test_devices -from isaaclab.utils import configclass - -pytestmark = pytest.mark.unit - - -@configclass -class ModifierTestCfg: - """Configuration for testing modifiers.""" - - cfg: modifiers.ModifierCfg = MISSING - init_data: torch.Tensor = MISSING - result: torch.Tensor = MISSING - num_iter: int = 10 - - -@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]), - ], - 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) - output = cfg.func(torch.tensor([1.0, 2.0, 3.0]), **cfg.params) - assert torch.allclose(output, torch.tensor(result)) - - -@pytest.mark.parametrize("device", test_devices()) -def test_digital_filter(device): - """Test digital filter modifier.""" - # create test data - init_data = torch.tensor([0.0, 0.0, 0.0], device=device) - A = [0.0, 0.1] - B = [0.5, 0.5] - result = torch.tensor([-0.45661893, -0.45661893, -0.45661893], device=device) - - # create test config - test_cfg = ModifierTestCfg( - cfg=modifiers.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) - - # test the modifier - 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() - - # apply the modifier multiple times - for i in range(test_cfg.num_iter): - data = torch.sin(theta + i * delta) - processed_data = modifier_obj(data) - - assert data.shape == processed_data.shape, "Modified data shape does not equal original" - - # check if the modified data is close to the expected result - torch.testing.assert_close(processed_data, test_cfg.result) - - -@pytest.mark.parametrize("device", test_devices()) -def test_integral(device): - """Test integral modifier.""" - # 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( - cfg=modifiers.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) - - # test the modifier - delta = torch.tensor(1.0, device=device) - - for _ in range(5): - # reset the modifier - modifier_obj.reset() - - # clone the data to avoid modifying the original - data = test_cfg.init_data.clone() - # apply the modifier multiple times - for _ in range(test_cfg.num_iter): - processed_data = modifier_obj(data) - data = data + delta - - assert data.shape == processed_data.shape, "Modified data shape does not equal original" - - # check if the modified data is close to the expected result - torch.testing.assert_close(processed_data, test_cfg.result) diff --git a/source/isaaclab/test/utils/test_tensor_processors.py b/source/isaaclab/test/utils/test_tensor_processors.py new file mode 100644 index 000000000000..22cd25bd2aca --- /dev/null +++ b/source/isaaclab/test/utils/test_tensor_processors.py @@ -0,0 +1,206 @@ +# 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 + +import subprocess +import sys +from dataclasses import MISSING + +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, to_dict +from isaaclab.utils.warp import ProxyArray + +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.""" + + cfg: tensor_processors.TensorProcessorCfg = MISSING + init_data: torch.Tensor = MISSING + result: torch.Tensor = MISSING + num_iter: int = 10 + + +@pytest.mark.parametrize( + "func, params, result", + [ + (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_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)) + + +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.""" + # create test data + init_data = torch.tensor([0.0, 0.0, 0.0], device=device) + A = [0.0, 0.1] + B = [0.5, 0.5] + result = torch.tensor([-0.45661893, -0.45661893, -0.45661893], device=device) + + # create test config + test_cfg = TensorProcessorTestCfg( + cfg=tensor_processors.DigitalFilterCfg(A=A, B=B), init_data=init_data, result=result, num_iter=16 + ) + + # create a tensor processor instance + tensor_processor = test_cfg.cfg.func(test_cfg.cfg, test_cfg.init_data.shape, device=device) + + # 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 tensor processor + tensor_processor.reset() + + # apply the tensor processor multiple times + for i in range(test_cfg.num_iter): + data = torch.sin(theta + i * delta) + processed_data = tensor_processor(data) + + assert data.shape == processed_data.shape, "Modified data shape does not equal original" + + # check if the modified data is close to the expected result + torch.testing.assert_close(processed_data, test_cfg.result) + + +@pytest.mark.parametrize("device", test_devices()) +def test_integral(device): + """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 = TensorProcessorTestCfg( + cfg=tensor_processors.IntegratorCfg(dt=dt), + init_data=init_data, + result=result, + num_iter=6, + ) + + # create a tensor processor instance + tensor_processor = test_cfg.cfg.func(test_cfg.cfg, test_cfg.init_data.shape, device=device) + + # test the tensor processor + delta = torch.tensor(1.0, device=device) + + for _ in range(5): + # reset the tensor processor + tensor_processor.reset() + + # clone the data to avoid modifying the original + data = test_cfg.init_data.clone() + # apply the tensor processor multiple times + for _ in range(test_cfg.num_iter): + processed_data = tensor_processor(data) + data = data + delta + + assert data.shape == processed_data.shape, "Modified data shape does not equal original" + + # check if the modified data is close to the expected result + torch.testing.assert_close(processed_data, test_cfg.result) 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.minor b/source/isaaclab_experimental/changelog.d/dict-tensor-processors.minor new file mode 100644 index 000000000000..e69de29bb2d1 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/managers/observation_manager.py b/source/isaaclab_experimental/isaaclab_experimental/managers/observation_manager.py index 051aaa8a6ff6..b58bfeebe788 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,9 +409,9 @@ 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 - for mod in self._group_obs_class_instances: - mod.reset(env_mask=env_mask) + # call all tensor processors/noise models that are classes + for tensor_processor in self._group_obs_class_instances: + tensor_processor.reset(env_mask=env_mask) # nothing to log here return {} @@ -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,15 +500,15 @@ 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: - modifier.func(term_cfg.out_wp, **modifier.params) + # apply custom tensor processors (in-place on out_wp) + if term_cfg.tensor_processors is not None: + 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): @@ -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,39 +736,44 @@ 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 - if inspect.isclass(mod_cfg.func): - if not issubclass(mod_cfg.func, modifiers.ModifierBase): + # prepare tensor processors for each observation + if term_cfg.tensor_processors is not None: + # initialize list of tensor processors for term + for tensor_processor_cfg in term_cfg.tensor_processors: + # check if class processor and initialize with observation size when adding + if isinstance(tensor_processor_cfg, tensor_processors.TensorProcessorCfg): + # to list of tensor processors + 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 'ModifierBase'. 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 modifiers - self._group_obs_class_instances.append(mod_cfg.func) + # add to list of class tensor processors + 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 ModifierCfg, 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 +781,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}." ) 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..8f9ff81d1a94 100644 --- a/source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/__init__.py +++ b/source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/__init__.py @@ -3,19 +3,32 @@ # # 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 sys +import warnings -Calling convention (matches Warp MDP terms):: +from .. import tensor_processors as _processors - 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 +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, +) -# Override with Warp-native implementations -from .modifier_cfg import ModifierCfg # noqa: F401 + +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_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/modifiers/modifier.py b/source/isaaclab_experimental/isaaclab_experimental/utils/tensor_processors/processors.py similarity index 78% rename from source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/modifier.py rename to source/isaaclab_experimental/isaaclab_experimental/utils/tensor_processors/processors.py index 887db5e4ee06..6406befb1dc4 100644 --- a/source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/modifier.py +++ b/source/isaaclab_experimental/isaaclab_experimental/utils/tensor_processors/processors.py @@ -3,12 +3,12 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Warp-native function-based modifiers (experimental). +"""Warp-native function-based tensor processors (experimental). -Each modifier takes a ``wp.array`` as its first argument and operates **in-place** +Each processor takes a ``wp.array`` as its first argument and operates **in-place** via ``wp.launch``. The calling convention mirrors Warp MDP terms:: - modifier.func(data_wp, **params) -> None + processor.func(data_wp, **params) -> None """ from __future__ import annotations @@ -27,7 +27,8 @@ def _scale_kernel(data: wp.array(dtype=wp.float32, ndim=2), multiplier: wp.float 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`. + 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)``. @@ -48,7 +49,8 @@ def _bias_kernel(data: wp.array(dtype=wp.float32, ndim=2), value: wp.float32): 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`. + 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)``. @@ -69,7 +71,8 @@ def _clip_kernel(data: wp.array(dtype=wp.float32, ndim=2), lo: wp.float32, hi: w 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`. + 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)``. diff --git a/source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/modifier_base.py b/source/isaaclab_experimental/isaaclab_experimental/utils/tensor_processors/tensor_processor.py similarity index 67% rename from source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/modifier_base.py rename to source/isaaclab_experimental/isaaclab_experimental/utils/tensor_processors/tensor_processor.py index 083e1576e891..ccfb87ab6fc9 100644 --- a/source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/modifier_base.py +++ b/source/isaaclab_experimental/isaaclab_experimental/utils/tensor_processors/tensor_processor.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Warp-native modifier base class (experimental).""" +"""Warp-native processor base class (experimental).""" from __future__ import annotations @@ -13,29 +13,29 @@ import warp as wp if TYPE_CHECKING: - from .modifier_cfg import ModifierCfg + from .tensor_processor_cfg import TensorProcessorCfg -class ModifierBase(ABC): - """Base class for Warp-native class-based modifiers. +class TensorProcessor(ABC): + """Base class for Warp-native class-based tensor processors. - Experimental fork of :class:`isaaclab.utils.modifiers.ModifierBase` adapted for the + 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 modifier can be used to store state information between - calls. This is useful for modifiers that require stateful operations, such as + 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: ModifierCfg, data_dim: tuple[int, ...], device: str) -> None: - """Initializes the modifier class. + 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 modifier on. + device: The device to run the processor on. """ self._cfg = cfg self._data_dim = data_dim @@ -43,7 +43,7 @@ def __init__(self, cfg: ModifierCfg, data_dim: tuple[int, ...], device: str) -> @abstractmethod def reset(self, env_mask: wp.array | None = None): - """Resets the modifier. + """Resets the processor. Args: env_mask: Boolean env mask of shape ``(num_envs,)`` selecting environments diff --git a/source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/modifier_cfg.py b/source/isaaclab_experimental/isaaclab_experimental/utils/tensor_processors/tensor_processor_cfg.py similarity index 65% rename from source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/modifier_cfg.py rename to source/isaaclab_experimental/isaaclab_experimental/utils/tensor_processors/tensor_processor_cfg.py index 7cc22eaee6a9..26bb86e33e25 100644 --- a/source/isaaclab_experimental/isaaclab_experimental/utils/modifiers/modifier_cfg.py +++ b/source/isaaclab_experimental/isaaclab_experimental/utils/tensor_processors/tensor_processor_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Warp-native modifier configuration (experimental).""" +"""Warp-native processor configuration (experimental).""" from collections.abc import Callable from dataclasses import MISSING @@ -13,23 +13,23 @@ @configclass -class ModifierCfg: - """Configuration parameters for Warp-native modifiers. +class TensorProcessorCfg: + """Configuration parameters for Warp-native tensor processors. - Experimental fork of :class:`isaaclab.utils.modifiers.ModifierCfg` adapted for the - Warp-first calling convention where modifier functions operate **in-place** on a + 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 modifier. + """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:`ModifierBase` and implement the required methods. + class should inherit from :class:`TensorProcessor` and implement the required methods. """ params: dict[str, Any] = dict() 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, ),