Skip to content

Commit 397c56f

Browse files
committed
Resolve nested manager callable references
1 parent 860facd commit 397c56f

6 files changed

Lines changed: 96 additions & 25 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Fixed
2+
^^^^^
3+
4+
* Fixed observation modifiers failing class detection and signature validation after a configuration dictionary
5+
round-trip by recursively resolving callable references across complete manager-term configurations.

source/isaaclab/isaaclab/managers/manager_base.py

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,7 @@ def _resolve_common_term_cfg(self, term_name: str, term_cfg: ManagerTermBaseCfg,
301301
Usually, called by the :meth:`_prepare_terms` method to resolve common attributes of the term
302302
configuration. These include:
303303
304-
* Resolving the term function and checking if it is callable.
304+
* Resolving callable references throughout the term configuration and checking the term function.
305305
* Checking if the term function's arguments are matched by the parameters.
306306
* Resolving special attributes of the term configuration like ``asset_cfg``, ``sensor_cfg``, etc.
307307
* Initializing the term if it is a class.
@@ -334,8 +334,8 @@ def _resolve_common_term_cfg(self, term_name: str, term_cfg: ManagerTermBaseCfg,
334334
)
335335

336336
# get the corresponding function or functional class
337-
if isinstance(term_cfg.func, str):
338-
term_cfg.func = string_to_callable(term_cfg.func)
337+
term_cfg.func = self._resolve_param_value(term_name, "func", term_cfg.func)
338+
339339
# check if function is callable
340340
if not callable(term_cfg.func):
341341
raise AttributeError(f"The term '{term_name}' is not callable. Received: {term_cfg.func}")
@@ -381,7 +381,7 @@ def _process_term_cfg_at_play(self, term_name: str, term_cfg: ManagerTermBaseCfg
381381
This function is called when the simulation starts playing. It is used to process the term
382382
configuration at runtime. This includes:
383383
384-
* Resolving the scene entity configuration for the term.
384+
* Resolving callable references and scene entity configurations throughout the term configuration.
385385
* Initializing the term if it is a class.
386386
387387
Since the above steps rely on PhysX to parse over the simulation scene, they are deferred
@@ -391,17 +391,17 @@ def _process_term_cfg_at_play(self, term_name: str, term_cfg: ManagerTermBaseCfg
391391
term_name: The name of the term.
392392
term_cfg: The term configuration.
393393
"""
394-
for key, value in term_cfg.params.items():
395-
self._resolve_param_value(term_name, key, value)
394+
for key, value in term_cfg.__dict__.items():
395+
setattr(term_cfg, key, self._resolve_param_value(term_name, key, value))
396396

397-
# resolve string func references then initialize class-based terms
398-
if isinstance(term_cfg.func, str):
399-
term_cfg.func = string_to_callable(term_cfg.func)
397+
# initialize class-based terms
400398
if inspect.isclass(term_cfg.func):
401399
term_cfg.func = term_cfg.func(cfg=term_cfg, env=self._env)
402400

403-
def _resolve_param_value(self, term_name: str, key: str | int, value: Any):
404-
"""Recursively resolve a single param value (SceneEntityCfg, nested term cfgs, dicts, lists)."""
401+
def _resolve_param_value(self, term_name: str, key: str | int, value: Any) -> Any:
402+
"""Recursively resolve a value in a manager term configuration."""
403+
if key == "func" and isinstance(value, str):
404+
return string_to_callable(value)
405405
if isinstance(value, SceneEntityCfg):
406406
try:
407407
value.resolve(self._env.scene)
@@ -411,7 +411,11 @@ def _resolve_param_value(self, term_name: str, key: str | int, value: Any):
411411
self._process_term_cfg_at_play(f"{term_name}.{key}", value)
412412
elif isinstance(value, dict):
413413
for sub_key, sub_value in value.items():
414-
self._resolve_param_value(f"{term_name}.{key}", sub_key, sub_value)
414+
value[sub_key] = self._resolve_param_value(f"{term_name}.{key}", sub_key, sub_value)
415415
elif isinstance(value, (list, tuple)):
416416
for i, item in enumerate(value):
417417
self._resolve_param_value(f"{term_name}.{key}", i, item)
418+
elif not isinstance(value, type) and hasattr(value, "__dataclass_fields__") and hasattr(value, "__dict__"):
419+
for sub_key, sub_value in value.__dict__.items():
420+
setattr(value, sub_key, self._resolve_param_value(f"{term_name}.{key}", sub_key, sub_value))
421+
return value

source/isaaclab/isaaclab/utils/modifiers/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
cfg = modifiers.ModifierCfg(func=modifiers.clip, params={"bounds": (0.0, torch.inf)})
2929
3030
# apply the modifier
31-
my_modified_tensor = cfg.func(my_tensor, cfg)
31+
my_modified_tensor = cfg.func(my_tensor, **cfg.params)
3232
3333
3434
Usage with a class modifier:

source/isaaclab/isaaclab/utils/modifiers/modifier_base.py

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,21 +25,15 @@ class ModifierBase(ABC):
2525
This is useful for modifiers that require stateful operations, such as rolling averages
2626
or delays or decaying filters.
2727
28-
Example pseudo-code to create and use the class:
28+
Constructor settings for a class-based modifier belong to a dedicated
29+
:class:`~isaaclab.utils.modifiers.ModifierCfg` subclass. For example:
2930
3031
.. code-block:: python
3132
3233
from isaaclab.utils import modifiers
3334
34-
# define custom keyword arguments to pass to ModifierCfg
35-
kwarg_dict = {"arg_1": VAL_1, "arg_2": VAL_2}
36-
37-
# create modifier configuration object
38-
# func is the class name of the modifier and params is the dictionary of arguments
39-
modifier_config = modifiers.ModifierCfg(func=modifiers.ModifierBase, params=kwarg_dict)
40-
41-
# define modifier instance
42-
my_modifier = modifiers.ModifierBase(cfg=modifier_config)
35+
modifier_config = modifiers.DigitalFilterCfg(A=[0.0], B=[0.0, 1.0])
36+
my_modifier = modifiers.DigitalFilter(cfg=modifier_config, data_dim=(256, 128), device="cuda")
4337
4438
"""
4539

source/isaaclab/test/managers/test_manager_base.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
# ignore private usage of variables warning
77
# pyright: reportPrivateUsage=none
88

9-
"""Tests for recursive _process_term_cfg_at_play / _resolve_param_value.
9+
"""Tests for recursive manager term configuration resolution.
1010
1111
These tests exercise ManagerBase's parameter resolution logic and do NOT
1212
require an Isaac Sim launch, so they can run without AppLauncher.
@@ -20,8 +20,9 @@
2020
import torch
2121

2222
from isaaclab.envs import ManagerBasedEnv
23-
from isaaclab.managers import ManagerTermBase, ManagerTermBaseCfg
23+
from isaaclab.managers import ManagerTermBase, ManagerTermBaseCfg, ObservationTermCfg
2424
from isaaclab.managers.manager_base import ManagerBase
25+
from isaaclab.utils import modifiers
2526

2627
pytestmark = pytest.mark.integration
2728

@@ -217,6 +218,23 @@ def test_string_func_in_nested_term_cfg(env):
217218
torch.testing.assert_close(env.dummy1, 11 * torch.ones_like(env.dummy1))
218219

219220

221+
def test_callable_resolution_walks_term_fields_outside_params(env):
222+
"""Callable references in nested term fields are resolved by ManagerBase."""
223+
cfg = {
224+
"observation": ObservationTermCfg(
225+
func=increment_dummy1_by_one,
226+
modifiers=[modifiers.ModifierCfg(func=f"{__name__}:reset_dummy2_to_zero")],
227+
)
228+
}
229+
manager = SimpleManager(cfg, env)
230+
231+
term_cfg = manager._term_cfgs[0][1]
232+
assert isinstance(term_cfg, ObservationTermCfg)
233+
assert term_cfg.modifiers is not None
234+
modifier_cfg = term_cfg.modifiers[0]
235+
assert modifier_cfg.func is reset_dummy2_to_zero
236+
237+
220238
def test_string_func_top_level_class_term(env):
221239
"""Test that a top-level string-based func pointing to a class is properly instantiated."""
222240
this_module = __name__

source/isaaclab/test/managers/test_observation_manager_unit.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import torch
1616

1717
from isaaclab.managers import ObservationGroupCfg, ObservationManager, ObservationTermCfg
18+
from isaaclab.utils import modifiers
1819
from isaaclab.utils.configclass import configclass
1920

2021
pytestmark = pytest.mark.unit
@@ -46,6 +47,29 @@ def __init__(self, num_envs: int = 2) -> None:
4647
self.observation = torch.arange(num_envs, dtype=torch.float32).unsqueeze(-1)
4748

4849

50+
class StatefulBiasModifier(modifiers.ModifierBase):
51+
"""Stateful modifier used to verify lazy callable resolution."""
52+
53+
def __init__(self, cfg: StatefulBiasModifierCfg, data_dim: tuple[int, ...], device: str) -> None:
54+
super().__init__(cfg, data_dim, device)
55+
self.value = cfg.value
56+
self.reset_count = 0
57+
58+
def reset(self, env_ids=None) -> None:
59+
self.reset_count += 1
60+
61+
def __call__(self, data: torch.Tensor) -> torch.Tensor:
62+
return data + self.value
63+
64+
65+
@configclass
66+
class StatefulBiasModifierCfg(modifiers.ModifierCfg):
67+
"""Configuration for :class:`StatefulBiasModifier`."""
68+
69+
func: type[StatefulBiasModifier] = StatefulBiasModifier
70+
value: float = 2.0
71+
72+
4973
@configclass
5074
class HistoryObservationsCfg:
5175
"""Observation configuration with group-level history."""
@@ -62,6 +86,32 @@ def __post_init__(self):
6286
policy: PolicyCfg = PolicyCfg()
6387

6488

89+
def test_stateful_modifier_cfg_roundtrip_preserves_func():
90+
"""A stateful modifier remains usable after its function becomes a lazy string."""
91+
cfg = HistoryObservationsCfg()
92+
cfg.policy.history_length = None
93+
cfg.policy.dummy.modifiers = [StatefulBiasModifierCfg(value=2.0)]
94+
cfg.from_dict(cfg.to_dict())
95+
term_cfg = cfg.policy.dummy
96+
assert term_cfg.modifiers is not None
97+
modifier_cfg = term_cfg.modifiers[0]
98+
assert isinstance(modifier_cfg, StatefulBiasModifierCfg)
99+
assert modifier_cfg.params == {}
100+
assert not hasattr(modifier_cfg, "class_type")
101+
102+
env = DummyEnv()
103+
manager = ObservationManager(cfg, cast("ManagerBasedEnv", env))
104+
prepared_term_cfg = manager.cfg.policy.dummy
105+
assert prepared_term_cfg.modifiers is not None
106+
prepared_modifier_cfg = prepared_term_cfg.modifiers[0]
107+
assert isinstance(prepared_modifier_cfg.func, StatefulBiasModifier)
108+
observations = manager.compute()["policy"]
109+
torch.testing.assert_close(observations, env.observation + 2.0)
110+
111+
manager.reset()
112+
assert prepared_modifier_cfg.func.reset_count == 1
113+
114+
65115
def test_compute_updates_history_only_when_requested():
66116
"""Observation history changes only when ``update_history`` is enabled."""
67117
env = DummyEnv()

0 commit comments

Comments
 (0)