From 395a5fc6fe14bee834d656e9a64e087a23c8ce73 Mon Sep 17 00:00:00 2001 From: Zeng Qingcheng <60593302+NeoZng@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:25:16 +0800 Subject: [PATCH 1/6] Resolve ANYmal symmetry joint mirrors by name The ANYmal symmetry augmentation hard-coded joint indices for the PhysX joint order. On the default Newton backend the articulation uses a depth-first joint order, so every mirrored joint observation and action came from the wrong joint. Build the left-right and front-back permutations and signs from the robot's joint names instead. --- .../fix-anymal-symmetry-joint-order.rst | 6 + .../core/velocity/mdp/symmetry/anymal.py | 143 +++++++++++------- .../test/core/test_anymal_symmetry.py | 71 +++++++++ 3 files changed, 164 insertions(+), 56 deletions(-) create mode 100644 source/isaaclab_tasks/changelog.d/fix-anymal-symmetry-joint-order.rst create mode 100644 source/isaaclab_tasks/test/core/test_anymal_symmetry.py diff --git a/source/isaaclab_tasks/changelog.d/fix-anymal-symmetry-joint-order.rst b/source/isaaclab_tasks/changelog.d/fix-anymal-symmetry-joint-order.rst new file mode 100644 index 000000000000..dcaf7cdfdbc3 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/fix-anymal-symmetry-joint-order.rst @@ -0,0 +1,6 @@ +Fixed +^^^^^ + +* Fixed the ANYmal symmetry augmentation in :mod:`isaaclab_tasks.core.velocity.mdp.symmetry.anymal` mirroring + the wrong joints when the articulation's joint order is not the PhysX order, such as on the default Newton + backend. The left-right and front-back joint permutations are now resolved from the joint names. diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/mdp/symmetry/anymal.py b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/mdp/symmetry/anymal.py index 4a1bc3aa9b20..24dab40fb72e 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/mdp/symmetry/anymal.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/mdp/symmetry/anymal.py @@ -7,6 +7,8 @@ from __future__ import annotations +import functools +import re from typing import TYPE_CHECKING import torch @@ -41,6 +43,11 @@ def compute_symmetric_states( Augmented observations and actions tensors, or None if the respective input was None. """ + # resolve the joint permutations from the robot's joint names (cached per joint order) + joint_names = env.unwrapped.scene["robot"].joint_names + device = obs["policy"].device if obs is not None else actions.device + left_right, front_back = _anymal_joint_symmetry_maps(tuple(joint_names), str(device)) + # observations if obs is not None: batch_size = obs.batch_size[0] @@ -51,14 +58,16 @@ def compute_symmetric_states( # -- original obs_aug["policy"][:batch_size] = obs["policy"][:] # -- left-right - obs_aug["policy"][batch_size : 2 * batch_size] = _transform_policy_obs_left_right(env.unwrapped, obs["policy"]) + obs_aug["policy"][batch_size : 2 * batch_size] = _transform_policy_obs_left_right( + env.unwrapped, obs["policy"], left_right + ) # -- front-back obs_aug["policy"][2 * batch_size : 3 * batch_size] = _transform_policy_obs_front_back( - env.unwrapped, obs["policy"] + env.unwrapped, obs["policy"], front_back ) # -- diagonal obs_aug["policy"][3 * batch_size :] = _transform_policy_obs_front_back( - env.unwrapped, obs_aug["policy"][batch_size : 2 * batch_size] + env.unwrapped, obs_aug["policy"][batch_size : 2 * batch_size], front_back ) else: obs_aug = None @@ -71,11 +80,13 @@ def compute_symmetric_states( # -- original actions_aug[:batch_size] = actions[:] # -- left-right - actions_aug[batch_size : 2 * batch_size] = _transform_actions_left_right(actions) + actions_aug[batch_size : 2 * batch_size] = _transform_actions_left_right(actions, left_right) # -- front-back - actions_aug[2 * batch_size : 3 * batch_size] = _transform_actions_front_back(actions) + actions_aug[2 * batch_size : 3 * batch_size] = _transform_actions_front_back(actions, front_back) # -- diagonal - actions_aug[3 * batch_size :] = _transform_actions_front_back(actions_aug[batch_size : 2 * batch_size]) + actions_aug[3 * batch_size :] = _transform_actions_front_back( + actions_aug[batch_size : 2 * batch_size], front_back + ) else: actions_aug = None @@ -87,7 +98,9 @@ def compute_symmetric_states( """ -def _transform_policy_obs_left_right(env: ManagerBasedRLEnv, obs: torch.Tensor) -> torch.Tensor: +def _transform_policy_obs_left_right( + env: ManagerBasedRLEnv, obs: torch.Tensor, joint_map: tuple[torch.Tensor, torch.Tensor] +) -> torch.Tensor: """Apply a left-right symmetry transformation to the observation tensor. This function modifies the given observation tensor by applying transformations @@ -100,6 +113,7 @@ def _transform_policy_obs_left_right(env: ManagerBasedRLEnv, obs: torch.Tensor) Args: env: The environment instance from which the observation is obtained. obs: The observation tensor to be transformed. + joint_map: The left-right joint permutation and sign in the robot's joint order. Returns: The transformed observation tensor with left-right symmetry applied. @@ -116,11 +130,11 @@ def _transform_policy_obs_left_right(env: ManagerBasedRLEnv, obs: torch.Tensor) # velocity command obs[:, 9:12] = obs[:, 9:12] * torch.tensor([1, -1, -1], device=device) # joint pos - obs[:, 12:24] = _switch_anymal_joints_left_right(obs[:, 12:24]) + obs[:, 12:24] = _switch_joints(obs[:, 12:24], joint_map) # joint vel - obs[:, 24:36] = _switch_anymal_joints_left_right(obs[:, 24:36]) + obs[:, 24:36] = _switch_joints(obs[:, 24:36], joint_map) # last actions - obs[:, 36:48] = _switch_anymal_joints_left_right(obs[:, 36:48]) + obs[:, 36:48] = _switch_joints(obs[:, 36:48], joint_map) # note: this is hard-coded for grid-pattern of ordering "xy" and size (1.6, 1.0) if "height_scan" in env.observation_manager.active_terms["policy"]: @@ -129,7 +143,9 @@ def _transform_policy_obs_left_right(env: ManagerBasedRLEnv, obs: torch.Tensor) return obs -def _transform_policy_obs_front_back(env: ManagerBasedRLEnv, obs: torch.Tensor) -> torch.Tensor: +def _transform_policy_obs_front_back( + env: ManagerBasedRLEnv, obs: torch.Tensor, joint_map: tuple[torch.Tensor, torch.Tensor] +) -> torch.Tensor: """Applies a front-back symmetry transformation to the observation tensor. This function modifies the given observation tensor by applying transformations @@ -141,6 +157,7 @@ def _transform_policy_obs_front_back(env: ManagerBasedRLEnv, obs: torch.Tensor) Args: env: The environment instance from which the observation is obtained. obs: The observation tensor to be transformed. + joint_map: The front-back joint permutation and sign in the robot's joint order. Returns: The transformed observation tensor with front-back symmetry applied. @@ -157,11 +174,11 @@ def _transform_policy_obs_front_back(env: ManagerBasedRLEnv, obs: torch.Tensor) # velocity command obs[:, 9:12] = obs[:, 9:12] * torch.tensor([-1, 1, -1], device=device) # joint pos - obs[:, 12:24] = _switch_anymal_joints_front_back(obs[:, 12:24]) + obs[:, 12:24] = _switch_joints(obs[:, 12:24], joint_map) # joint vel - obs[:, 24:36] = _switch_anymal_joints_front_back(obs[:, 24:36]) + obs[:, 24:36] = _switch_joints(obs[:, 24:36], joint_map) # last actions - obs[:, 36:48] = _switch_anymal_joints_front_back(obs[:, 36:48]) + obs[:, 36:48] = _switch_joints(obs[:, 36:48], joint_map) # note: this is hard-coded for grid-pattern of ordering "xy" and size (1.6, 1.0) if "height_scan" in env.observation_manager.active_terms["policy"]: @@ -175,7 +192,7 @@ def _transform_policy_obs_front_back(env: ManagerBasedRLEnv, obs: torch.Tensor) """ -def _transform_actions_left_right(actions: torch.Tensor) -> torch.Tensor: +def _transform_actions_left_right(actions: torch.Tensor, joint_map: tuple[torch.Tensor, torch.Tensor]) -> torch.Tensor: """Applies a left-right symmetry transformation to the actions tensor. This function modifies the given actions tensor by applying transformations @@ -185,16 +202,15 @@ def _transform_actions_left_right(actions: torch.Tensor) -> torch.Tensor: Args: actions: The actions tensor to be transformed. + joint_map: The left-right joint permutation and sign in the robot's joint order. Returns: The transformed actions tensor with left-right symmetry applied. """ - actions = actions.clone() - actions[:] = _switch_anymal_joints_left_right(actions[:]) - return actions + return _switch_joints(actions, joint_map) -def _transform_actions_front_back(actions: torch.Tensor) -> torch.Tensor: +def _transform_actions_front_back(actions: torch.Tensor, joint_map: tuple[torch.Tensor, torch.Tensor]) -> torch.Tensor: """Applies a front-back symmetry transformation to the actions tensor. This function modifies the given actions tensor by applying transformations @@ -204,57 +220,72 @@ def _transform_actions_front_back(actions: torch.Tensor) -> torch.Tensor: Args: actions: The actions tensor to be transformed. + joint_map: The front-back joint permutation and sign in the robot's joint order. Returns: The transformed actions tensor with front-back symmetry applied. """ - actions = actions.clone() - actions[:] = _switch_anymal_joints_front_back(actions[:]) - return actions + return _switch_joints(actions, joint_map) """ Helper functions for symmetry. -In Isaac Sim, the joint ordering is as follows: -[ - 'LF_HAA', 'LH_HAA', 'RF_HAA', 'RH_HAA', - 'LF_HFE', 'LH_HFE', 'RF_HFE', 'RH_HFE', - 'LF_KFE', 'LH_KFE', 'RF_KFE', 'RH_KFE' -] +The ANYmal joints are named ``_``, where the side is ``L`` (left) or ``R`` (right), the end is +``F`` (front) or ``H`` (hind), and the joint is ``HAA`` (hip abduction/adduction), ``HFE`` (hip flexion/extension) +or ``KFE`` (knee flexion/extension). The joint order of the articulation depends on the physics backend and on +:attr:`~isaaclab.assets.ArticulationCfg.joint_ordering`, so the mirrored joints are resolved by name: -Correspondingly, the joint ordering for the ANYmal robot is: - -* LF = left front --> [0, 4, 8] -* LH = left hind --> [1, 5, 9] -* RF = right front --> [2, 6, 10] -* RH = right hind --> [3, 7, 11] +* left-right: swap ``L`` and ``R`` and negate the ``HAA`` joints. +* front-back: swap ``F`` and ``H`` and negate the ``HFE`` and ``KFE`` joints. """ +_ANYMAL_JOINT_NAME = re.compile(r"(?P[LR])(?P[FH])_(?PHAA|HFE|KFE)") -def _switch_anymal_joints_left_right(joint_data: torch.Tensor) -> torch.Tensor: - """Applies a left-right symmetry transformation to the joint data tensor.""" - joint_data_switched = torch.zeros_like(joint_data) - # left <-- right - joint_data_switched[..., [0, 4, 8, 1, 5, 9]] = joint_data[..., [2, 6, 10, 3, 7, 11]] - # right <-- left - joint_data_switched[..., [2, 6, 10, 3, 7, 11]] = joint_data[..., [0, 4, 8, 1, 5, 9]] - - # Flip the sign of the HAA joints - joint_data_switched[..., [0, 1, 2, 3]] *= -1.0 - return joint_data_switched +@functools.cache +def _anymal_joint_symmetry_maps( + joint_names: tuple[str, ...], device: str +) -> tuple[tuple[torch.Tensor, torch.Tensor], tuple[torch.Tensor, torch.Tensor]]: + """Resolves the left-right and front-back joint permutations and signs from the joint names. + Args: + joint_names: The joint names of the robot, in the order of the joint observations and actions. + device: The device on which to create the tensors. -def _switch_anymal_joints_front_back(joint_data: torch.Tensor) -> torch.Tensor: - """Applies a front-back symmetry transformation to the joint data tensor.""" - joint_data_switched = torch.zeros_like(joint_data) - # front <-- hind - joint_data_switched[..., [0, 4, 8, 2, 6, 10]] = joint_data[..., [1, 5, 9, 3, 7, 11]] - # hind <-- front - joint_data_switched[..., [1, 5, 9, 3, 7, 11]] = joint_data[..., [0, 4, 8, 2, 6, 10]] - - # Flip the sign of the HFE and KFE joints - joint_data_switched[..., 4:] *= -1 + Returns: + The ``(indices, signs)`` pairs for the left-right and the front-back symmetry. The mirrored value of + joint ``i`` is ``signs[i] * data[..., indices[i]]``. - return joint_data_switched + Raises: + ValueError: If a joint name does not follow the ANYmal naming convention or has no mirrored counterpart. + """ + parsed = [] + for name in joint_names: + match = _ANYMAL_JOINT_NAME.fullmatch(name) + if match is None: + raise ValueError( + f"Joint '{name}' does not follow the ANYmal naming convention: {_ANYMAL_JOINT_NAME.pattern}." + ) + parsed.append(match.groupdict()) + name_to_index = {name: i for i, name in enumerate(joint_names)} + + def _resolve(swap: dict[str, str], negated: tuple[str, ...]) -> tuple[torch.Tensor, torch.Tensor]: + indices = [] + for p in parsed: + counterpart = f"{swap.get(p['side'], p['side'])}{swap.get(p['end'], p['end'])}_{p['joint']}" + if counterpart not in name_to_index: + raise ValueError(f"Mirrored joint '{counterpart}' is missing from the joint names: {joint_names}.") + indices.append(name_to_index[counterpart]) + signs = [-1.0 if p["joint"] in negated else 1.0 for p in parsed] + return torch.tensor(indices, device=device), torch.tensor(signs, device=device) + + left_right = _resolve({"L": "R", "R": "L"}, negated=("HAA",)) + front_back = _resolve({"F": "H", "H": "F"}, negated=("HFE", "KFE")) + return left_right, front_back + + +def _switch_joints(joint_data: torch.Tensor, joint_map: tuple[torch.Tensor, torch.Tensor]) -> torch.Tensor: + """Applies a joint permutation and sign flip to the last dimension of the joint data tensor.""" + indices, signs = joint_map + return joint_data[..., indices] * signs diff --git a/source/isaaclab_tasks/test/core/test_anymal_symmetry.py b/source/isaaclab_tasks/test/core/test_anymal_symmetry.py new file mode 100644 index 000000000000..357449fcaa6f --- /dev/null +++ b/source/isaaclab_tasks/test/core/test_anymal_symmetry.py @@ -0,0 +1,71 @@ +# 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 + +"""Unit tests for the ANYmal velocity-task symmetry augmentation.""" + +from types import SimpleNamespace + +import pytest +import torch +from tensordict import TensorDict + +from isaaclab_tasks.core.velocity.mdp.symmetry import anymal + +# Native joint orders of ANYmal-D on the two physics backends. +PHYSX_ORDER = [ + "LF_HAA", "LH_HAA", "RF_HAA", "RH_HAA", + "LF_HFE", "LH_HFE", "RF_HFE", "RH_HFE", + "LF_KFE", "LH_KFE", "RF_KFE", "RH_KFE", +] # fmt: skip +NEWTON_ORDER = [ + "LF_HAA", "LF_HFE", "LF_KFE", + "LH_HAA", "LH_HFE", "LH_KFE", + "RF_HAA", "RF_HFE", "RF_KFE", + "RH_HAA", "RH_HFE", "RH_KFE", +] # fmt: skip + +# Mirrored counterpart and sign of each joint. +LEFT_RIGHT = { + "LF_HAA": ("RF_HAA", -1), "LF_HFE": ("RF_HFE", 1), "LF_KFE": ("RF_KFE", 1), + "LH_HAA": ("RH_HAA", -1), "LH_HFE": ("RH_HFE", 1), "LH_KFE": ("RH_KFE", 1), + "RF_HAA": ("LF_HAA", -1), "RF_HFE": ("LF_HFE", 1), "RF_KFE": ("LF_KFE", 1), + "RH_HAA": ("LH_HAA", -1), "RH_HFE": ("LH_HFE", 1), "RH_KFE": ("LH_KFE", 1), +} # fmt: skip +FRONT_BACK = { + "LF_HAA": ("LH_HAA", 1), "LF_HFE": ("LH_HFE", -1), "LF_KFE": ("LH_KFE", -1), + "LH_HAA": ("LF_HAA", 1), "LH_HFE": ("LF_HFE", -1), "LH_KFE": ("LF_KFE", -1), + "RF_HAA": ("RH_HAA", 1), "RF_HFE": ("RH_HFE", -1), "RF_KFE": ("RH_KFE", -1), + "RH_HAA": ("RF_HAA", 1), "RH_HFE": ("RF_HFE", -1), "RH_KFE": ("RF_KFE", -1), +} # fmt: skip +DIAGONAL = { + "LF_HAA": ("RH_HAA", -1), "LF_HFE": ("RH_HFE", -1), "LF_KFE": ("RH_KFE", -1), + "LH_HAA": ("RF_HAA", -1), "LH_HFE": ("RF_HFE", -1), "LH_KFE": ("RF_KFE", -1), + "RF_HAA": ("LH_HAA", -1), "RF_HFE": ("LH_HFE", -1), "RF_KFE": ("LH_KFE", -1), + "RH_HAA": ("LF_HAA", -1), "RH_HFE": ("LF_HFE", -1), "RH_KFE": ("LF_KFE", -1), +} # fmt: skip + + +@pytest.mark.parametrize("joint_names", [PHYSX_ORDER, NEWTON_ORDER], ids=["physx", "newton"]) +def test_symmetry_mirrors_joints_by_name(joint_names): + """Mirrored joint observations and actions come from the counterpart joint with the right sign.""" + env = SimpleNamespace( + scene={"robot": SimpleNamespace(joint_names=joint_names)}, + observation_manager=SimpleNamespace(active_terms={"policy": []}), + ) + env.unwrapped = env + # tag every joint entry with a distinct value per joint and per observation block + joint_values = torch.arange(1.0, 13.0) + obs = torch.cat([torch.zeros(12), joint_values, joint_values + 100.0, joint_values + 200.0]).unsqueeze(0) + obs_aug, actions_aug = anymal.compute_symmetric_states( + env, TensorDict({"policy": obs}, batch_size=[1]), joint_values.unsqueeze(0) + ) + + value = {name: joint_values[i] for i, name in enumerate(joint_names)} + for row, mirror in ((1, LEFT_RIGHT), (2, FRONT_BACK), (3, DIAGONAL)): + expected = torch.stack([mirror[name][1] * value[mirror[name][0]] for name in joint_names]) + torch.testing.assert_close(actions_aug[row], expected) + for offset, block in enumerate(range(12, 48, 12)): + obs_block = obs_aug["policy"][row, block : block + 12] + torch.testing.assert_close(obs_block, expected + torch.sign(expected) * 100.0 * offset) From 7d5bf26d523bcf6252911e5b6674e26c7e547f12 Mon Sep 17 00:00:00 2001 From: Zeng Qingcheng <60593302+NeoZng@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:49:04 +0800 Subject: [PATCH 2/6] Return early from ANYmal symmetry without observations or actions --- .../isaaclab_tasks/core/velocity/mdp/symmetry/anymal.py | 3 +++ source/isaaclab_tasks/test/core/test_anymal_symmetry.py | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/mdp/symmetry/anymal.py b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/mdp/symmetry/anymal.py index 24dab40fb72e..f71b1ee379e5 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/mdp/symmetry/anymal.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/mdp/symmetry/anymal.py @@ -43,6 +43,9 @@ def compute_symmetric_states( Augmented observations and actions tensors, or None if the respective input was None. """ + if obs is None and actions is None: + return None, None + # resolve the joint permutations from the robot's joint names (cached per joint order) joint_names = env.unwrapped.scene["robot"].joint_names device = obs["policy"].device if obs is not None else actions.device diff --git a/source/isaaclab_tasks/test/core/test_anymal_symmetry.py b/source/isaaclab_tasks/test/core/test_anymal_symmetry.py index 357449fcaa6f..9c96bd68867c 100644 --- a/source/isaaclab_tasks/test/core/test_anymal_symmetry.py +++ b/source/isaaclab_tasks/test/core/test_anymal_symmetry.py @@ -69,3 +69,8 @@ def test_symmetry_mirrors_joints_by_name(joint_names): for offset, block in enumerate(range(12, 48, 12)): obs_block = obs_aug["policy"][row, block : block + 12] torch.testing.assert_close(obs_block, expected + torch.sign(expected) * 100.0 * offset) + + +def test_symmetry_without_inputs_returns_none(): + """Calling without observations and actions returns ``(None, None)`` without touching the scene.""" + assert anymal.compute_symmetric_states(SimpleNamespace()) == (None, None) From b09d42c47373b71a6fc5e36a1b699349d99205c4 Mon Sep 17 00:00:00 2001 From: Zeng Qingcheng <60593302+NeoZng@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:50:21 +0800 Subject: [PATCH 3/6] Call the joint mirror directly for ANYmal actions and move the name pattern up --- .../core/velocity/mdp/symmetry/anymal.py | 53 ++----------------- 1 file changed, 5 insertions(+), 48 deletions(-) diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/mdp/symmetry/anymal.py b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/mdp/symmetry/anymal.py index f71b1ee379e5..66db96f63bc9 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/mdp/symmetry/anymal.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/mdp/symmetry/anymal.py @@ -20,6 +20,8 @@ # specify the functions that are available for import __all__ = ["compute_symmetric_states"] +_ANYMAL_JOINT_NAME = re.compile(r"(?P[LR])(?P[FH])_(?PHAA|HFE|KFE)") + @torch.no_grad() def compute_symmetric_states( @@ -83,13 +85,11 @@ def compute_symmetric_states( # -- original actions_aug[:batch_size] = actions[:] # -- left-right - actions_aug[batch_size : 2 * batch_size] = _transform_actions_left_right(actions, left_right) + actions_aug[batch_size : 2 * batch_size] = _switch_joints(actions, left_right) # -- front-back - actions_aug[2 * batch_size : 3 * batch_size] = _transform_actions_front_back(actions, front_back) + actions_aug[2 * batch_size : 3 * batch_size] = _switch_joints(actions, front_back) # -- diagonal - actions_aug[3 * batch_size :] = _transform_actions_front_back( - actions_aug[batch_size : 2 * batch_size], front_back - ) + actions_aug[3 * batch_size :] = _switch_joints(actions_aug[batch_size : 2 * batch_size], front_back) else: actions_aug = None @@ -190,47 +190,6 @@ def _transform_policy_obs_front_back( return obs -""" -Symmetry functions for actions. -""" - - -def _transform_actions_left_right(actions: torch.Tensor, joint_map: tuple[torch.Tensor, torch.Tensor]) -> torch.Tensor: - """Applies a left-right symmetry transformation to the actions tensor. - - This function modifies the given actions tensor by applying transformations - that represent a symmetry with respect to the left-right axis. This includes - flipping the joint positions, joint velocities, and last actions for the - ANYmal robot. - - Args: - actions: The actions tensor to be transformed. - joint_map: The left-right joint permutation and sign in the robot's joint order. - - Returns: - The transformed actions tensor with left-right symmetry applied. - """ - return _switch_joints(actions, joint_map) - - -def _transform_actions_front_back(actions: torch.Tensor, joint_map: tuple[torch.Tensor, torch.Tensor]) -> torch.Tensor: - """Applies a front-back symmetry transformation to the actions tensor. - - This function modifies the given actions tensor by applying transformations - that represent a symmetry with respect to the front-back axis. This includes - flipping the joint positions, joint velocities, and last actions for the - ANYmal robot. - - Args: - actions: The actions tensor to be transformed. - joint_map: The front-back joint permutation and sign in the robot's joint order. - - Returns: - The transformed actions tensor with front-back symmetry applied. - """ - return _switch_joints(actions, joint_map) - - """ Helper functions for symmetry. @@ -243,8 +202,6 @@ def _transform_actions_front_back(actions: torch.Tensor, joint_map: tuple[torch. * front-back: swap ``F`` and ``H`` and negate the ``HFE`` and ``KFE`` joints. """ -_ANYMAL_JOINT_NAME = re.compile(r"(?P[LR])(?P[FH])_(?PHAA|HFE|KFE)") - @functools.cache def _anymal_joint_symmetry_maps( From 63167c072003a8d5f55ec60a4856ff0d7ae58b3b Mon Sep 17 00:00:00 2001 From: Zeng Qingcheng <60593302+NeoZng@users.noreply.github.com> Date: Thu, 1 Oct 2026 03:48:10 +0800 Subject: [PATCH 4/6] Use the towncrier changelog fragment format --- ...int-order.rst => fix-anymal-symmetry-joint-order.fixed.rst} | 3 --- 1 file changed, 3 deletions(-) rename source/isaaclab_tasks/changelog.d/{fix-anymal-symmetry-joint-order.rst => fix-anymal-symmetry-joint-order.fixed.rst} (96%) diff --git a/source/isaaclab_tasks/changelog.d/fix-anymal-symmetry-joint-order.rst b/source/isaaclab_tasks/changelog.d/fix-anymal-symmetry-joint-order.fixed.rst similarity index 96% rename from source/isaaclab_tasks/changelog.d/fix-anymal-symmetry-joint-order.rst rename to source/isaaclab_tasks/changelog.d/fix-anymal-symmetry-joint-order.fixed.rst index dcaf7cdfdbc3..21fd8748a9e0 100644 --- a/source/isaaclab_tasks/changelog.d/fix-anymal-symmetry-joint-order.rst +++ b/source/isaaclab_tasks/changelog.d/fix-anymal-symmetry-joint-order.fixed.rst @@ -1,6 +1,3 @@ -Fixed -^^^^^ - * Fixed the ANYmal symmetry augmentation in :mod:`isaaclab_tasks.core.velocity.mdp.symmetry.anymal` mirroring the wrong joints when the articulation's joint order is not the PhysX order, such as on the default Newton backend. The left-right and front-back joint permutations are now resolved from the joint names. From bbd9ed942d351164470204677006f35169008ab6 Mon Sep 17 00:00:00 2001 From: Zeng Qingcheng <60593302+NeoZng@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:17:30 +0800 Subject: [PATCH 5/6] Simplify the ANYmal mirror lookup and test it against the PhysX order --- .../core/velocity/mdp/symmetry/anymal.py | 28 ++------ .../test/core/test_anymal_symmetry.py | 68 ++++++------------- 2 files changed, 26 insertions(+), 70 deletions(-) diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/mdp/symmetry/anymal.py b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/mdp/symmetry/anymal.py index 236a9c8195ea..5be817bf86fc 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/mdp/symmetry/anymal.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/mdp/symmetry/anymal.py @@ -8,7 +8,6 @@ from __future__ import annotations import functools -import re from typing import TYPE_CHECKING import torch @@ -20,8 +19,6 @@ # specify the functions that are available for import __all__ = ["compute_symmetric_states"] -_ANYMAL_JOINT_NAME = re.compile(r"(?P[LR])(?P[FH])_(?PHAA|HFE|KFE)") - @torch.no_grad() def compute_symmetric_states( @@ -210,7 +207,7 @@ def _transform_actions_front_back(actions: torch.Tensor, joint_names: tuple[str, "front_back": (-1, 1, 1, 1, -1, -1, -1, 1, 1, -1, 1, -1), } # side and end letters swapped, and joint types negated, per transform -_JOINT_SWAPS = {"left_right": {"L": "R", "R": "L"}, "front_back": {"F": "H", "H": "F"}} +_JOINT_SWAPS = {"left_right": str.maketrans("LR", "RL"), "front_back": str.maketrans("FH", "HF")} _NEGATED_JOINTS = {"left_right": ("HAA",), "front_back": ("HFE", "KFE")} # height-scan grid dimension flipped per transform, for the (11, 17) grid _HEIGHT_SCAN_FLIP_DIM = {"left_right": 0, "front_back": 1} @@ -222,26 +219,9 @@ def _has_height_scan(env: ManagerBasedRLEnv) -> bool: def _joint_permutation(kind: str, joint_names: tuple[str, ...]) -> tuple[list[int], list[float]]: - """Source joint index and sign of each joint for a transform, resolved from the joint names. - - Raises: - ValueError: If a joint name does not follow the ANYmal naming convention or has no mirrored counterpart. - """ - name_to_index = {name: i for i, name in enumerate(joint_names)} - swap = _JOINT_SWAPS[kind] - perm, sign = [], [] - for name in joint_names: - match = _ANYMAL_JOINT_NAME.fullmatch(name) - if match is None: - raise ValueError( - f"Joint '{name}' does not follow the ANYmal naming convention: {_ANYMAL_JOINT_NAME.pattern}." - ) - side, end, joint = match.group("side", "end", "joint") - counterpart = f"{swap.get(side, side)}{swap.get(end, end)}_{joint}" - if counterpart not in name_to_index: - raise ValueError(f"Mirrored joint '{counterpart}' is missing from the joint names: {joint_names}.") - perm.append(name_to_index[counterpart]) - sign.append(-1.0 if joint in _NEGATED_JOINTS[kind] else 1.0) + """Source joint index and sign of each joint for a transform, resolved from the joint names.""" + perm = [joint_names.index(name[:2].translate(_JOINT_SWAPS[kind]) + name[2:]) for name in joint_names] + sign = [-1.0 if name[3:] in _NEGATED_JOINTS[kind] else 1.0 for name in joint_names] return perm, sign diff --git a/source/isaaclab_tasks/test/core/test_anymal_symmetry.py b/source/isaaclab_tasks/test/core/test_anymal_symmetry.py index e32f99503ec0..dd8763755e15 100644 --- a/source/isaaclab_tasks/test/core/test_anymal_symmetry.py +++ b/source/isaaclab_tasks/test/core/test_anymal_symmetry.py @@ -26,26 +26,6 @@ "RH_HAA", "RH_HFE", "RH_KFE", ] # fmt: skip -# Mirrored counterpart and sign of each joint. -LEFT_RIGHT = { - "LF_HAA": ("RF_HAA", -1), "LF_HFE": ("RF_HFE", 1), "LF_KFE": ("RF_KFE", 1), - "LH_HAA": ("RH_HAA", -1), "LH_HFE": ("RH_HFE", 1), "LH_KFE": ("RH_KFE", 1), - "RF_HAA": ("LF_HAA", -1), "RF_HFE": ("LF_HFE", 1), "RF_KFE": ("LF_KFE", 1), - "RH_HAA": ("LH_HAA", -1), "RH_HFE": ("LH_HFE", 1), "RH_KFE": ("LH_KFE", 1), -} # fmt: skip -FRONT_BACK = { - "LF_HAA": ("LH_HAA", 1), "LF_HFE": ("LH_HFE", -1), "LF_KFE": ("LH_KFE", -1), - "LH_HAA": ("LF_HAA", 1), "LH_HFE": ("LF_HFE", -1), "LH_KFE": ("LF_KFE", -1), - "RF_HAA": ("RH_HAA", 1), "RF_HFE": ("RH_HFE", -1), "RF_KFE": ("RH_KFE", -1), - "RH_HAA": ("RF_HAA", 1), "RH_HFE": ("RF_HFE", -1), "RH_KFE": ("RF_KFE", -1), -} # fmt: skip -DIAGONAL = { - "LF_HAA": ("RH_HAA", -1), "LF_HFE": ("RH_HFE", -1), "LF_KFE": ("RH_KFE", -1), - "LH_HAA": ("RF_HAA", -1), "LH_HFE": ("RF_HFE", -1), "LH_KFE": ("RF_KFE", -1), - "RF_HAA": ("LH_HAA", -1), "RF_HFE": ("LH_HFE", -1), "RF_KFE": ("LH_KFE", -1), - "RH_HAA": ("LF_HAA", -1), "RH_HFE": ("LF_HFE", -1), "RH_KFE": ("LF_KFE", -1), -} # fmt: skip - # Reference: the index-assignment formulation the permutation tables must reproduce. _LEFT = [0, 4, 8, 1, 5, 9] @@ -127,30 +107,26 @@ def test_compute_symmetric_states_matches_reference(height_scan: bool): assert torch.equal(actions_aug, expected_actions) -@pytest.mark.parametrize("joint_names", [PHYSX_ORDER, NEWTON_ORDER], ids=["physx", "newton"]) -def test_symmetry_mirrors_joints_by_name(joint_names): - """Mirrored joint observations and actions come from the counterpart joint with the right sign.""" - env = SimpleNamespace( - scene={"robot": SimpleNamespace(joint_names=joint_names)}, - observation_manager=SimpleNamespace(active_terms={"policy": []}), - ) - env.unwrapped = env - # tag every joint entry with a distinct value per joint and per observation block - joint_values = torch.arange(1.0, 13.0) - obs = torch.cat([torch.zeros(12), joint_values, joint_values + 100.0, joint_values + 200.0]).unsqueeze(0) - obs_aug, actions_aug = anymal.compute_symmetric_states( - env, TensorDict({"policy": obs}, batch_size=[1]), joint_values.unsqueeze(0) - ) - - value = {name: joint_values[i] for i, name in enumerate(joint_names)} - for row, mirror in ((1, LEFT_RIGHT), (2, FRONT_BACK), (3, DIAGONAL)): - expected = torch.stack([mirror[name][1] * value[mirror[name][0]] for name in joint_names]) - torch.testing.assert_close(actions_aug[row], expected) - for offset, block in enumerate(range(12, 48, 12)): - obs_block = obs_aug["policy"][row, block : block + 12] - torch.testing.assert_close(obs_block, expected + torch.sign(expected) * 100.0 * offset) - +def test_compute_symmetric_states_follows_joint_names(): + """Verify the Newton joint order is mirrored like the PhysX order, with the joint columns reordered.""" + joint_cols = [PHYSX_ORDER.index(name) for name in NEWTON_ORDER] + obs_cols = list(range(12)) + [start + joint for start in (12, 24, 36) for joint in joint_cols] + obs = torch.randn(5, 48) + actions = torch.randn(5, 12) + + outputs = [] + for joint_names, obs_idx, action_idx in ( + (PHYSX_ORDER, list(range(48)), list(range(12))), + (NEWTON_ORDER, obs_cols, joint_cols), + ): + env = SimpleNamespace( + scene={"robot": SimpleNamespace(joint_names=joint_names)}, + observation_manager=SimpleNamespace(active_terms={"policy": []}), + ) + env.unwrapped = env + policy = TensorDict({"policy": obs[:, obs_idx]}, batch_size=[5]) + outputs.append(anymal.compute_symmetric_states(env, policy, actions[:, action_idx])) + (physx_obs, physx_actions), (newton_obs, newton_actions) = outputs -def test_symmetry_without_inputs_returns_none(): - """Calling without observations and actions returns ``(None, None)`` without touching the scene.""" - assert anymal.compute_symmetric_states(SimpleNamespace()) == (None, None) + assert torch.equal(newton_obs["policy"], physx_obs["policy"][:, obs_cols]) + assert torch.equal(newton_actions, physx_actions[:, joint_cols]) From bdd1888fcead795fc5747f1b74aa1a54cb055a77 Mon Sep 17 00:00:00 2001 From: Zeng Qingcheng <60593302+NeoZng@users.noreply.github.com> Date: Thu, 1 Oct 2026 19:27:40 +0800 Subject: [PATCH 6/6] Cover the ANYmal symmetry call without inputs --- source/isaaclab_tasks/test/core/test_anymal_symmetry.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/source/isaaclab_tasks/test/core/test_anymal_symmetry.py b/source/isaaclab_tasks/test/core/test_anymal_symmetry.py index dd8763755e15..fb2af10f0138 100644 --- a/source/isaaclab_tasks/test/core/test_anymal_symmetry.py +++ b/source/isaaclab_tasks/test/core/test_anymal_symmetry.py @@ -130,3 +130,8 @@ def test_compute_symmetric_states_follows_joint_names(): assert torch.equal(newton_obs["policy"], physx_obs["policy"][:, obs_cols]) assert torch.equal(newton_actions, physx_actions[:, joint_cols]) + + +def test_compute_symmetric_states_without_inputs(): + """Return ``(None, None)`` without reading the scene when there is nothing to augment.""" + assert anymal.compute_symmetric_states(SimpleNamespace()) == (None, None)