-
Notifications
You must be signed in to change notification settings - Fork 3.8k
[Task Clean-up] Dexterous Part 4/9: Fix MARL-to-single-agent training and enable handover Direct RSL-RL #6414
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
c29c460
da6a7db
d29fa40
f142a22
41b7ed5
6ec4220
4144bf8
c7ff94b
24b25fd
4fec9dd
e9d60c7
2c6156e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| Fixed | ||
| ^^^^^ | ||
|
|
||
| * Fixed :meth:`~isaaclab.envs.DirectRLEnv.reset` to store the observation buffer like | ||
| :meth:`~isaaclab.envs.DirectRLEnv.step`, and exposed it on the | ||
| multi-agent-to-single-agent adapter. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| # Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. AI Review: The PR changes RslRlVecEnvWrapper.get_observations() from actively computing observations to unconditionally reading env.obs_buf, but there is no wrapper-level regression test. Please add tests showing that get_observations() exactly matches the most recent reset and step outputs, including a noisy/non-idempotent observation case. We should also verify every supported environment family exposes obs_buf.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There wasn't one. |
||
| # All rights reserved. | ||
| # | ||
| # SPDX-License-Identifier: BSD-3-Clause | ||
|
|
||
| from types import SimpleNamespace | ||
|
|
||
| import gymnasium as gym | ||
| import torch | ||
|
|
||
| from isaaclab.envs.utils.marl import multi_agent_to_single_agent | ||
|
|
||
|
|
||
| class _FakeMultiAgentEnv: | ||
| possible_agents = ["agent_0", "agent_1"] | ||
| observation_spaces = { | ||
| "agent_0": gym.spaces.Box(low=-1.0, high=1.0, shape=(2,)), | ||
| "agent_1": gym.spaces.Box(low=-1.0, high=1.0, shape=(1,)), | ||
| } | ||
| action_spaces = { | ||
| "agent_0": gym.spaces.Box(low=-1.0, high=1.0, shape=(1,)), | ||
| "agent_1": gym.spaces.Box(low=-1.0, high=1.0, shape=(1,)), | ||
| } | ||
| render_mode = None | ||
|
|
||
| def __init__(self): | ||
| self.unwrapped = self | ||
| self.cfg = SimpleNamespace(state_space=2) | ||
| self.state_space = gym.spaces.Box(low=-1.0, high=1.0, shape=(2,)) | ||
| self.sim = object() | ||
| self.scene = SimpleNamespace(num_envs=2) | ||
| self.episode_length_buf = torch.tensor([1, 2]) | ||
| self.obs_dict = { | ||
| "agent_0": torch.tensor([[1.0, 2.0], [3.0, 4.0]]), | ||
| "agent_1": torch.tensor([[5.0], [6.0]]), | ||
| } | ||
|
|
||
| def reset(self, seed=None, options=None): | ||
| return self.obs_dict, {} | ||
|
|
||
| def step(self, actions): | ||
| # shift the observations so a step is distinguishable from a reset | ||
| self.obs_dict = {agent: obs + 10.0 for agent, obs in self.obs_dict.items()} | ||
| rewards = {agent: torch.zeros(2) for agent in self.possible_agents} | ||
| dones = {agent: torch.zeros(2, dtype=torch.bool) for agent in self.possible_agents} | ||
| return self.obs_dict, rewards, dones, dones, {} | ||
|
|
||
| def state(self): | ||
| return torch.tensor([[7.0, 8.0], [9.0, 10.0]]) | ||
|
|
||
| def close(self): | ||
| pass | ||
|
|
||
|
|
||
| def test_multi_agent_to_single_agent_reset_concatenates_agents(): | ||
| """The adapter reset should concatenate the agents' observations.""" | ||
| env = multi_agent_to_single_agent(_FakeMultiAgentEnv()) | ||
|
|
||
| observations, _ = env.reset() | ||
|
|
||
| torch.testing.assert_close(observations["policy"], torch.tensor([[1.0, 2.0, 5.0], [3.0, 4.0, 6.0]])) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. AI Review: This only verifies that the property converts the initial dictionary. It does not verify “latest observation” behavior. A stronger test would make reset() and step() return data different from env.obs_dict, or make state() return a different result on each call. That would catch unwanted recomputation.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That test would have passed without the buffer ever updating. |
||
|
|
||
|
|
||
| def test_multi_agent_to_single_agent_reset_can_use_state(): | ||
| """The adapter reset should support the state-as-observation mode.""" | ||
| env = multi_agent_to_single_agent(_FakeMultiAgentEnv(), state_as_observation=True) | ||
|
|
||
| observations, _ = env.reset() | ||
|
|
||
| torch.testing.assert_close(observations["policy"], torch.tensor([[7.0, 8.0], [9.0, 10.0]])) | ||
|
|
||
|
|
||
| def test_multi_agent_to_single_agent_forwards_episode_lengths(): | ||
| """RSL-RL episode randomization should update the wrapped environment buffer in place.""" | ||
| source_env = _FakeMultiAgentEnv() | ||
| wrapped_buffer = source_env.episode_length_buf | ||
| env = multi_agent_to_single_agent(source_env) | ||
| episode_lengths = torch.tensor([3, 4]) | ||
|
|
||
| env.episode_length_buf = episode_lengths | ||
|
|
||
| torch.testing.assert_close(env.episode_length_buf, episode_lengths) | ||
| torch.testing.assert_close(source_env.episode_length_buf, episode_lengths) | ||
| # written in place, so references taken before the assignment observe the new values | ||
| assert source_env.episode_length_buf is wrapped_buffer | ||
|
|
||
|
|
||
| def test_multi_agent_to_single_agent_exposes_latest_observations(): | ||
| """The public observation buffer should track the observations last returned by reset and step.""" | ||
| env = multi_agent_to_single_agent(_FakeMultiAgentEnv()) | ||
|
|
||
| reset_obs, _ = env.reset() | ||
| torch.testing.assert_close(env.obs_buf["policy"], reset_obs["policy"]) | ||
| torch.testing.assert_close(env.obs_buf["policy"], torch.tensor([[1.0, 2.0, 5.0], [3.0, 4.0, 6.0]])) | ||
|
|
||
| step_obs = env.step(torch.zeros(2, 2))[0] | ||
| torch.testing.assert_close(env.obs_buf["policy"], step_obs["policy"]) | ||
| torch.testing.assert_close(env.obs_buf["policy"], torch.tensor([[11.0, 12.0, 15.0], [13.0, 14.0, 16.0]])) | ||
|
|
||
|
|
||
| def test_multi_agent_to_single_agent_state_observation_tracks_steps(): | ||
| """The state-as-observation mode should also refresh the buffer on every transition.""" | ||
| env = multi_agent_to_single_agent(_FakeMultiAgentEnv(), state_as_observation=True) | ||
|
|
||
| reset_obs, _ = env.reset() | ||
| torch.testing.assert_close(env.obs_buf["policy"], reset_obs["policy"]) | ||
|
|
||
| step_obs = env.step(torch.zeros(2, 2))[0] | ||
| torch.testing.assert_close(env.obs_buf["policy"], step_obs["policy"]) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| Added | ||
| ^^^^^ | ||
|
|
||
| * Added ``SHADOW_HAND_NEWTON_CFG``, the Newton (MJWarp) Shadow Hand configuration, | ||
| shared by the reorientation and handover tasks. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,7 +7,8 @@ | |
|
|
||
| The following configurations are available: | ||
|
|
||
| * :obj:`SHADOW_HAND_CFG`: Shadow Hand with implicit actuator model. | ||
| * :obj:`SHADOW_HAND_CFG`: Shadow Hand on the PhysX asset with implicit actuator model. | ||
| * :obj:`SHADOW_HAND_NEWTON_CFG`: Shadow Hand on the Newton (MJWarp) asset. | ||
|
|
||
| Reference: | ||
|
|
||
|
|
@@ -81,7 +82,97 @@ | |
| }, | ||
| soft_joint_pos_limit_factor=1.0, | ||
| ) | ||
| """Configuration of Shadow Hand robot.""" | ||
| """Configuration of the Shadow Hand robot on the PhysX asset.""" | ||
|
|
||
|
|
||
| SHADOW_HAND_NEWTON_CFG = ArticulationCfg( | ||
| spawn=sim_utils.UsdFileCfg( | ||
| # Newton/MuJoCo use a separate USD schema; this asset renumbers the finger | ||
| # joints (+1) relative to the PhysX asset above (e.g. FFJ4 vs FFJ3, LFJ5 vs LFJ4). | ||
| usd_path=f"{ISAAC_NUCLEUS_DIR}/Robots/ShadowRobot/ShadowHandNewton/shadow_hand_instanceable.usda", | ||
| activate_contact_sensors=False, | ||
| rigid_props=sim_utils.RigidBodyPropertiesCfg( | ||
| disable_gravity=True, | ||
| retain_accelerations=True, | ||
| max_depenetration_velocity=1000.0, | ||
| ), | ||
| articulation_props=sim_utils.ArticulationRootPropertiesCfg(enabled_self_collisions=True), | ||
| joint_drive_props=sim_utils.JointDrivePropertiesCfg(drive_type="force", ensure_drives_exist=True), | ||
| fixed_tendons_props=sim_utils.FixedTendonPropertiesCfg(damping=0.1), | ||
| ), | ||
| init_state=ArticulationCfg.InitialStateCfg( | ||
| pos=(0.0, 0.0, 0.5), | ||
| # WARNING(Octi): Newton's import_usd.py bakes the USD body xformOp rotation into | ||
| # joint_X_p for the root fixed joint, which cancels with the matching localPose1 | ||
| # rotation in joint_X_c during FK (joint_X_p * inv(joint_X_c) ≈ identity). This | ||
| # discards the root body's native USD orientation, so we must re-apply it here as a | ||
| # spawn rotation. PhysX or USD does not have this issue. Remove once Newton fixes root joint | ||
| # transform handling in import_usd.py. | ||
| rot=(0.0, 0.0, -0.70710678118, 0.70710678118), | ||
| joint_pos={".*": 0.0}, | ||
| ), | ||
| actuators={ | ||
| # Drives the joints named by :obj:`SHADOW_ACTUATED_JOINT_NAMES`, which resolve on this | ||
| # asset despite its +1 finger renumbering. | ||
| # | ||
| # The per-finger ``J1``/``J2`` pair is coupled by a fixed tendon (``coef=[1, 1]``) that | ||
| # the MJWarp solver currently skips, so the configuration is what holds the pair | ||
| # together: both ends are driven with identical gains and effort limits, which | ||
| # reproduces the 1:1 coupling. Keep them symmetric and in the same actuator group -- | ||
| # driving one end while clamping the other makes the two fight, and an uncapped effort | ||
| # limit on either end diverges to NaN within a few hundred steps. | ||
| # | ||
| # Known limitation: the ``J4`` knuckle abduction joints (and ``LFJ5``) are left | ||
| # undriven, so the fingers cannot spread laterally. Correcting that requires resolving | ||
| # the skipped tendon first and is deferred to a follow-up. | ||
| "fingers": ImplicitActuatorCfg( | ||
| joint_names_expr=[ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is WR wrist? If so shouldn't this be called "hand" This might also be useful when creating subgroups for the actual "fingers" which might have different PD from other joints
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
One real consequence though: handover's |
||
| "robot0_WR.*", | ||
| "robot0_(FF|MF|RF|LF|TH)J(3|2|1)", | ||
| "robot0_(LF|TH)J4", | ||
| "robot0_THJ0", | ||
| ], | ||
| effort_limit_sim={ | ||
| "robot0_WRJ1": 4.785, | ||
| "robot0_WRJ0": 2.175, | ||
| "robot0_(FF|MF|RF|LF)J1": 0.7245, | ||
| "robot0_FFJ(3|2)": 0.9, | ||
| "robot0_MFJ(3|2)": 0.9, | ||
| "robot0_RFJ(3|2)": 0.9, | ||
| "robot0_LFJ(4|3|2)": 0.9, | ||
| "robot0_THJ4": 2.3722, | ||
| "robot0_THJ3": 1.45, | ||
| "robot0_THJ(2|1)": 0.99, | ||
| "robot0_THJ0": 0.81, | ||
| }, | ||
| # Default gains match the PhysX cfg (wrists 5.0/0.5, fingers 1.0/0.1). Tasks that | ||
| # need more joint authority override these -- e.g. the handover catch on MJWarp | ||
| # raises them to 20.0/2.0, since MJWarp's implicit-PD path lacks PhysX's | ||
| # fixed-tendon limit stiffness + solver-iteration torque amplification. | ||
| stiffness={ | ||
| "robot0_WRJ.*": 5.0, | ||
| "robot0_(FF|MF|RF|LF|TH)J(3|2|1)": 1.0, | ||
| "robot0_(LF|TH)J4": 1.0, | ||
| "robot0_THJ0": 1.0, | ||
| }, | ||
| damping={ | ||
| "robot0_WRJ.*": 0.5, | ||
| "robot0_(FF|MF|RF|LF|TH)J(3|2|1)": 0.1, | ||
| "robot0_(LF|TH)J4": 0.1, | ||
| "robot0_THJ0": 0.1, | ||
| }, | ||
| friction=1e-2, | ||
| armature=2e-3, | ||
| ), | ||
| }, | ||
| soft_joint_pos_limit_factor=1.0, | ||
| ) | ||
| """Configuration of the Shadow Hand robot on the Newton (MJWarp) asset. | ||
|
|
||
| The Newton USD renumbers the finger joints (+1) relative to :obj:`SHADOW_HAND_CFG`, but the | ||
| names in :obj:`SHADOW_ACTUATED_JOINT_NAMES` resolve on both assets, so the two backends share | ||
| one actuated-joint list. Gains default to the PhysX values; tasks override them as needed. | ||
| """ | ||
|
|
||
|
|
||
| SHADOW_FINGERTIP_BODY_NAMES: list[str] = [ | ||
|
|
@@ -115,4 +206,7 @@ | |
| "robot0_THJ1", | ||
| "robot0_THJ0", | ||
| ] | ||
| """Shadow Hand actuated joint names, in the Direct task's actuation order.""" | ||
| """Shadow Hand actuated joint names, in the Direct task's actuation order. | ||
|
|
||
| These names resolve on both the PhysX and Newton assets, so every backend shares this list. | ||
| """ | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| Fixed | ||
| ^^^^^ | ||
|
|
||
| * Fixed :meth:`~isaaclab_experimental.envs.DirectRLEnvWarp.step` and | ||
| :meth:`~isaaclab_experimental.envs.DirectRLEnvWarp.reset` to store observations in | ||
| ``obs_buf``. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -372,7 +372,9 @@ def reset(self, seed: int | None = None, options: dict[str, Any] | None = None) | |
|
|
||
| # return observations | ||
| self._get_observations() | ||
| return {"policy": self.torch_obs_buf.clone()}, self.extras | ||
| # store the returned buffer so RslRlVecEnvWrapper.get_observations() can read env.obs_buf | ||
| self.obs_buf = {"policy": self.torch_obs_buf.clone()} | ||
| return self.obs_buf, self.extras | ||
|
|
||
| @Timer(name="env_step", msg="Step took:", enable=DEBUG_TIMER_STEP or DEBUG_TIMERS) | ||
| def step(self, action: torch.Tensor) -> VecEnvStepReturn: | ||
|
|
@@ -455,8 +457,10 @@ def step(self, action: torch.Tensor) -> VecEnvStepReturn: | |
| self._post_step_visualize() | ||
|
|
||
| # return observations, rewards, resets and extras | ||
| # store the returned buffer so RslRlVecEnvWrapper.get_observations() can read env.obs_buf | ||
| self.obs_buf = {"policy": self.torch_obs_buf.clone()} | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Here and above, .clone() is probably expensive. Is there a specific rationale for cloning rather than having a reference/view
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not new —
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this is coming from som rl libraries messing with the data from the simulator inplace |
||
| return ( | ||
| {"policy": self.torch_obs_buf.clone()}, | ||
| self.obs_buf, | ||
| self.torch_reward_buf, | ||
| self.torch_reset_terminated, | ||
| self.torch_reset_time_outs, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| Changed | ||
| ^^^^^^^ | ||
|
|
||
| * Changed :meth:`~isaaclab_rl.rsl_rl.RslRlVecEnvWrapper.get_observations` to read the | ||
| environment-owned observation buffer instead of private environment methods. The | ||
| returned observations now match the latest reset/step returns, including observation | ||
| noise. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -171,11 +171,7 @@ def reset(self) -> tuple[TensorDict, dict]: # noqa: D102 | |
|
|
||
| def get_observations(self) -> TensorDict: | ||
| """Returns the current observations of the environment.""" | ||
| if hasattr(self.unwrapped, "observation_manager"): | ||
| obs_dict = self.unwrapped.observation_manager.compute() | ||
| else: | ||
| obs_dict = self.unwrapped._get_observations() | ||
| return TensorDict(obs_dict, batch_size=[self.num_envs]) | ||
| return TensorDict(self.unwrapped.obs_buf, batch_size=[self.num_envs]) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's add a test to make sure all envs implement obs_buf (unless that test is already there?)
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Covered by the same file in |
||
|
|
||
| def step(self, actions: torch.Tensor) -> tuple[TensorDict, torch.Tensor, torch.Tensor, dict]: | ||
| # clip actions | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It might be cleaner to do something like:
So obs buffer stays true to it's propery, instead of being a conversion mechanism
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done in
f142a22, as sketched — stored inreset()/step(),obs_bufis now a plain property. Also removes the repeated conversion greptile flagged.