Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Added
^^^^^

* Added semantic neutral actions for manager-based action terms, including absolute differential IK,
Pink IK, RMPFlow, and operational-space controllers.
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,16 @@ def _initialize_joint_info(self) -> None:
# Resolve hand joints
self._hand_joint_ids, self._hand_joint_names = self._asset.find_joints(self.cfg.hand_joint_names)

# Resolve controlled frames in the same order as their pose commands.
self._controlled_frame_ids, controlled_frame_names = self._asset.find_bodies(
list(self.cfg.target_eef_link_names.values()), preserve_order=True
)
if len(self._controlled_frame_ids) != len(self.cfg.target_eef_link_names):
raise ValueError(
"Expected one controlled body for every Pink IK target. Resolved "
f"{controlled_frame_names} from {list(self.cfg.target_eef_link_names.values())}."
)

# Combine all joint information
self._controlled_joint_ids = self._isaaclab_controlled_joint_ids + self._hand_joint_ids
self._controlled_joint_names = self._isaaclab_controlled_joint_names + self._hand_joint_names
Expand Down Expand Up @@ -109,6 +119,11 @@ def _initialize_helper_tensors(self) -> None:
1 for task in self._ik_controllers[0].cfg.variable_input_tasks if isinstance(task, FrameTask)
)
self._num_frame_tasks = num_frame_tasks
if len(self._controlled_frame_ids) != self._num_frame_tasks:
raise ValueError(
f"Pink IK has {self._num_frame_tasks} variable frame tasks but "
f"{len(self._controlled_frame_ids)} controlled bodies were configured."
)
self._controlled_frame_poses = torch.zeros(num_frame_tasks, self.num_envs, 4, 4, device=self.device)

# Pre-allocate tensor for base frame computations
Expand Down Expand Up @@ -155,6 +170,14 @@ def processed_actions(self) -> torch.Tensor:
"""Get the processed actions tensor."""
return self._processed_actions

@property
def neutral_actions(self) -> torch.Tensor:
"""Raw actions that hold the controlled frames and hand joints at their current state."""
frame_poses = self._asset.data.body_link_pose_w.torch[:, self._controlled_frame_ids].clone()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Warning · Api — Pink neutral pose uses wrong quaternion order

body_link_pose_w stores orientation as (w, x, y, z), but this term documents its pose slots as position plus orientation (x, y, z, w) (see orientation_dim). Concatenating the raw body pose therefore hands process_actions a reordered quaternion, so the "hold current frames" command requests a different orientation than the current one. Convert the quaternion to the action term's documented layout before flattening.

frame_poses[..., :3] -= self._env.scene.env_origins.unsqueeze(1)
hand_joint_positions = self._asset.data.joint_pos.torch[:, self._hand_joint_ids]
return torch.cat((frame_poses.flatten(start_dim=1), hand_joint_positions), dim=-1)

@property
def IO_descriptor(self) -> GenericActionIODescriptor:
"""The IO descriptor of the action term.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,16 @@ def raw_actions(self) -> torch.Tensor:
def processed_actions(self) -> torch.Tensor:
return self._processed_actions

@property
def neutral_actions(self) -> torch.Tensor:
"""Raw actions that hold the current end-effector pose."""
if self.cfg.use_relative_mode:
return super().neutral_actions

ee_pos, ee_quat = self._compute_frame_pose()
command = torch.cat((ee_pos, ee_quat), dim=-1)
return torch.where(self._scale != 0.0, command / self._scale, torch.zeros_like(command))

@property
def jacobian_w(self) -> torch.Tensor:
return self._asset.data.body_link_jacobian_w.torch[:, self._jacobi_body_idx, :, self._jacobi_joint_ids]
Expand Down
44 changes: 44 additions & 0 deletions source/isaaclab/isaaclab/envs/mdp/actions/task_space_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,16 @@ def raw_actions(self) -> torch.Tensor:
def processed_actions(self) -> torch.Tensor:
return self._processed_actions

@property
def neutral_actions(self) -> torch.Tensor:
"""Raw actions that hold the current end-effector pose."""
if self.cfg.controller.use_relative_mode:
return super().neutral_actions

ee_pos, ee_quat = self._compute_frame_pose()
command = ee_pos if self.cfg.controller.command_type == "position" else torch.cat((ee_pos, ee_quat), dim=-1)
return torch.where(self._scale != 0.0, command / self._scale, torch.zeros_like(command))

@property
def jacobian_w(self) -> torch.Tensor:
return self._asset.data.body_link_jacobian_w.torch[:, self._jacobi_body_idx, :, self._jacobi_joint_ids]
Expand Down Expand Up @@ -450,6 +460,40 @@ def processed_actions(self) -> torch.Tensor:
"""Processed actions for operational space control."""
return self._processed_actions

@property
def neutral_actions(self) -> torch.Tensor:
"""Raw actions that hold the current end-effector pose and apply no wrench."""
actions = super().neutral_actions
if self._pose_abs_idx is None:
return actions

self._compute_ee_pose()
self._compute_task_frame_pose()
if self._task_frame_pose_b is None:
ee_pos_task = self._ee_pose_b[:, :3]
ee_quat_task = self._ee_pose_b[:, 3:7]
else:
ee_pos_task, ee_quat_task = math_utils.subtract_frame_transforms(
self._task_frame_pose_b[:, :3],
self._task_frame_pose_b[:, 3:7],
self._ee_pose_b[:, :3],
self._ee_pose_b[:, 3:7],
)

position_slice = slice(self._pose_abs_idx, self._pose_abs_idx + 3)
orientation_slice = slice(self._pose_abs_idx + 3, self._pose_abs_idx + 7)
actions[:, position_slice] = torch.where(
self._position_scale != 0.0,
ee_pos_task / self._position_scale,
torch.zeros_like(ee_pos_task),
)
actions[:, orientation_slice] = torch.where(
self._orientation_scale != 0.0,
ee_quat_task / self._orientation_scale,
torch.zeros_like(ee_quat_task),
)
return actions

@property
def jacobian_w(self) -> torch.Tensor:
return self._asset.data.body_link_jacobian_w.torch[:, self._jacobi_ee_body_idx, :, self._jacobi_joint_idx]
Expand Down
21 changes: 21 additions & 0 deletions source/isaaclab/isaaclab/managers/action_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,15 @@ def processed_actions(self) -> torch.Tensor:
"""The actions computed by the term after applying any processing."""
raise NotImplementedError

@property
def neutral_actions(self) -> torch.Tensor:
"""Raw actions suitable for passive agent playback.

The default is a zero-filled tensor. Action terms for which zero has a different or invalid meaning,
such as absolute-pose controllers, should override this property with a semantically neutral command.
"""
return torch.zeros_like(self.raw_actions)

@property
def has_debug_vis_implementation(self) -> bool:
"""Whether the action term has a debug visualization implemented."""
Expand Down Expand Up @@ -263,6 +272,18 @@ def prev_action(self) -> torch.Tensor:
"""The previous actions sent to the environment. Shape is (num_envs, total_action_dim)."""
return self._prev_action

@property
def neutral_actions(self) -> torch.Tensor:
"""Raw actions suitable for passive playback of all active action terms.

The returned tensor has shape ``(num_envs, total_action_dim)``. Since
some terms derive their neutral command from the current simulation
state, consumers should retrieve this property immediately before use.
"""
if not self._terms:
return torch.zeros_like(self._action)
return torch.cat([term.neutral_actions for term in self._terms.values()], dim=-1)

@property
def has_debug_vis_implementation(self) -> bool:
"""Whether the command terms have debug visualization implemented."""
Expand Down
43 changes: 43 additions & 0 deletions source/isaaclab/test/envs/test_neutral_actions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# 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

"""Tests for semantic neutral actions."""

from types import SimpleNamespace

import torch

from isaaclab.envs.mdp.actions.pink_task_space_actions import PinkInverseKinematicsAction


def test_pink_neutral_actions_use_current_frame_poses() -> None:
"""Pink IK neutral actions contain valid current poses and hand joint positions."""
action_term = object.__new__(PinkInverseKinematicsAction)
action_term._controlled_frame_ids = [1, 0]
action_term._hand_joint_ids = [1, 3]

body_poses = torch.tensor(
[
[[1.0, 2.0, 3.0, 0.0, 0.0, 0.0, 1.0], [4.0, 5.0, 6.0, 0.0, 0.0, 1.0, 0.0]],
[[7.0, 8.0, 9.0, 0.0, 1.0, 0.0, 0.0], [10.0, 11.0, 12.0, 1.0, 0.0, 0.0, 0.0]],
]
)
joint_positions = torch.tensor([[0.1, 0.2, 0.3, 0.4], [0.5, 0.6, 0.7, 0.8]])
env_origins = torch.tensor([[1.0, 1.0, 1.0], [2.0, 2.0, 2.0]])
action_term._asset = SimpleNamespace(
data=SimpleNamespace(
body_link_pose_w=SimpleNamespace(torch=body_poses),
joint_pos=SimpleNamespace(torch=joint_positions),
)
)
action_term._env = SimpleNamespace(scene=SimpleNamespace(env_origins=env_origins))

actions = action_term.neutral_actions

expected_poses = body_poses[:, [1, 0]].clone()
expected_poses[..., :3] -= env_origins.unsqueeze(1)
expected = torch.cat((expected_poses.flatten(start_dim=1), joint_positions[:, [1, 3]]), dim=-1)
assert torch.equal(actions, expected)
assert torch.all(torch.linalg.vector_norm(actions[:, :14].reshape(2, 2, 7)[..., 3:7], dim=-1) == 1.0)
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Fixed
^^^^^

* Fixed the zero agent to use semantic neutral actions, support composite and multi-agent action spaces,
and reject invalid task configurations before launching the simulator.
47 changes: 37 additions & 10 deletions source/isaaclab_rl/isaaclab_rl/entrypoints/simple_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"""Checkpoint-free playback workflows for Isaac Lab environments.

The zero and random agents are variations of playback that need no trained checkpoint:
the policy either emits constant zero actions or samples uniform random actions.
the policy either emits neutral actions or samples uniform random actions.
"""

from __future__ import annotations
Expand All @@ -20,6 +20,7 @@
import torch

from isaaclab.app import add_launcher_args, launch_simulation
from isaaclab.envs.utils.spaces import sample_space

import isaaclab_tasks # noqa: F401
from isaaclab_tasks.utils import (
Expand All @@ -46,7 +47,7 @@ def run(argv: list[str] | None = None, *, policy: PolicyName) -> None:

Args:
argv: Command-line arguments excluding the executable name. Reads ``sys.argv`` when omitted.
policy: Action policy to apply, either constant zero actions or uniform random actions.
policy: Action policy to apply, either neutral actions or uniform random actions.

Raises:
ValueError: If the requested policy is not supported.
Expand All @@ -61,13 +62,18 @@ def run(argv: list[str] | None = None, *, policy: PolicyName) -> None:
# parse configuration via Hydra (supports preset selection, e.g. env.sim.physics=newton_mjwarp)
env_cfg, _ = resolve_task_config(args_cli.task, "")

with launch_simulation(env_cfg, args_cli):
# override with CLI arguments
env_cfg.scene.num_envs = args_cli.num_envs if args_cli.num_envs is not None else env_cfg.scene.num_envs
env_cfg.sim.device = args_cli.device if args_cli.device is not None else env_cfg.sim.device
if args_cli.disable_fabric:
env_cfg.sim.use_fabric = False
# override with CLI arguments and reject unsupported configurations before
# launching Kit or initializing a native physics backend.
env_cfg.scene.num_envs = args_cli.num_envs if args_cli.num_envs is not None else env_cfg.scene.num_envs
env_cfg.sim.device = args_cli.device if args_cli.device is not None else env_cfg.sim.device
if args_cli.disable_fabric:
env_cfg.sim.use_fabric = False
try:
env_cfg.validate()
except (TypeError, ValueError) as exc:
raise SystemExit(f"Invalid environment configuration: {exc}") from None

with launch_simulation(env_cfg, args_cli):
# create environment
env = gym.make(args_cli.task, cfg=env_cfg)

Expand All @@ -80,7 +86,6 @@ def run(argv: list[str] | None = None, *, policy: PolicyName) -> None:
# keep running while any visualizer is open, and until the step budget is exhausted
sim = env.unwrapped.sim
device = env.unwrapped.device
zero_actions = torch.zeros(env.action_space.shape, device=device)
step = 0
while sim.is_headless_or_exist_active_visualizer():
if args_cli.max_steps is not None and step >= args_cli.max_steps:
Expand All @@ -89,7 +94,7 @@ def run(argv: list[str] | None = None, *, policy: PolicyName) -> None:
# run everything in inference mode
with torch.inference_mode():
if policy == "zero":
actions = zero_actions
actions = _get_neutral_actions(env)
else:
# sample actions from -1 to 1
actions = 2 * torch.rand(env.action_space.shape, device=device) - 1
Expand All @@ -99,6 +104,28 @@ def run(argv: list[str] | None = None, *, policy: PolicyName) -> None:
env.close()


def _get_neutral_actions(env: gym.Env):
"""Create semantically neutral actions for passive environment playback.

Manager-based environments can provide semantic neutral actions for terms
where literal zeros are unsafe, such as absolute-pose IK. Direct-workflow
environments fall back to zero-filled samples of their declared Gymnasium
spaces, including composite and multi-agent spaces.
"""
unwrapped = env.unwrapped
action_manager = getattr(unwrapped, "action_manager", None)
if action_manager is not None:
return action_manager.neutral_actions

if hasattr(unwrapped, "action_spaces"):
return {
agent: sample_space(space, unwrapped.device, batch_size=unwrapped.num_envs, fill_value=0)
for agent, space in unwrapped.action_spaces.items()
}

return sample_space(unwrapped.single_action_space, unwrapped.device, batch_size=unwrapped.num_envs, fill_value=0)


def _parse_args(argv: list[str] | None, policy: PolicyName) -> argparse.Namespace:
"""Parse the command line of a checkpoint-free agent and hand the remainder to Hydra."""
parser = argparse.ArgumentParser(description=_DESCRIPTIONS[policy])
Expand Down
75 changes: 75 additions & 0 deletions source/isaaclab_rl/test/test_entrypoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,86 @@
import runpy
import sys
import types
from types import SimpleNamespace

import gymnasium as gym
import numpy as np
import pytest
import torch

from isaaclab_rl.entrypoints import PlaybackRequest, TrainingRequest, api, dispatch
from isaaclab_rl.entrypoints import simple_agents as _simple_agents
from isaaclab_rl.entrypoints.simple_agents import _get_neutral_actions


def test_zero_agent_uses_manager_semantic_neutral_actions() -> None:
"""The zero agent honors action-term neutral commands instead of forcing literal zeros."""
expected = torch.tensor([[0.1, 0.2, 0.3, 1.0]])
unwrapped = SimpleNamespace(action_manager=SimpleNamespace(neutral_actions=expected))

assert _get_neutral_actions(SimpleNamespace(unwrapped=unwrapped)) is expected


def test_zero_agent_supports_composite_direct_action_spaces() -> None:
"""Direct environments receive tensorized zeros matching composite action spaces."""
action_space = gym.spaces.Dict(
{
"continuous": gym.spaces.Box(low=-1.0, high=1.0, shape=(2,), dtype=np.float32),
"discrete": gym.spaces.Discrete(3),
}
)
unwrapped = SimpleNamespace(
action_manager=None,
single_action_space=action_space,
device="cpu",
num_envs=2,
)

actions = _get_neutral_actions(SimpleNamespace(unwrapped=unwrapped))

assert torch.equal(actions["continuous"], torch.zeros(2, 2))
assert torch.equal(actions["discrete"], torch.zeros(2, 1, dtype=torch.int64))


def test_zero_agent_supports_direct_multi_agent_action_spaces() -> None:
"""Direct multi-agent environments receive a zero action for every agent."""
unwrapped = SimpleNamespace(
action_manager=None,
action_spaces={
"robot": gym.spaces.Box(low=-1.0, high=1.0, shape=(2,), dtype=np.float32),
"object": gym.spaces.Discrete(2),
},
device="cpu",
num_envs=3,
)

actions = _get_neutral_actions(SimpleNamespace(unwrapped=unwrapped))

assert torch.equal(actions["robot"], torch.zeros(3, 2))
assert torch.equal(actions["object"], torch.zeros(3, 1, dtype=torch.int64))


def test_zero_agent_rejects_invalid_config_before_launch(monkeypatch: pytest.MonkeyPatch) -> None:
"""Unsupported task presets fail cleanly before a simulator backend is initialized."""

class _InvalidCfg:
scene = SimpleNamespace(num_envs=1)
sim = SimpleNamespace(device="cpu", use_fabric=True)

def validate(self) -> None:
raise ValueError("unsupported physics backend")

args = SimpleNamespace(num_envs=None, device=None, disable_fabric=False, task="Invalid-Task")
monkeypatch.setattr(_simple_agents, "_parse_args", lambda argv, policy: args)
monkeypatch.setattr(_simple_agents, "resolve_task_config", lambda task, agent: (_InvalidCfg(), None))
monkeypatch.setattr(
_simple_agents,
"launch_simulation",
lambda *args, **kwargs: pytest.fail("simulation launched before config validation"),
)

with pytest.raises(SystemExit, match="Invalid environment configuration: unsupported physics backend"):
_simple_agents.run([], policy="zero")


def test_train_request_adapts_typed_parameters_to_cli(monkeypatch) -> None:
Expand Down
Loading