Skip to content

Commit e4e14de

Browse files
committed
Fix cable routing control and training semantics
1 parent 00da35d commit e4e14de

8 files changed

Lines changed: 209 additions & 17 deletions

File tree

source/isaaclab_tasks/changelog.d/maximiliank-cable-routing-yam.minor.rst

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ Added
55
actions, smooth one-meter self-avoiding cable resets, single-span wrap validation, heterogeneous
66
fixture resets, and Newton MJWarp/VBD proxy coupling.
77
* Added a pinned Robot Menagerie YAM USD package with native Newton collision and mimic schemas,
8-
plus contact-capacity stress validation for dense cable interactions.
8+
plus explicit contact-buffer headroom for dense cable interactions.
99
* Added pinned ManipulationNet task-board and F1 USD visuals with primitive-only Newton collision,
1010
reproducible STL conversion metadata, and front-edge dual-YAM placement.
1111
* Added three staged round-peg routing goals plus an explicit seven-goal task spanning both
@@ -21,6 +21,10 @@ Fixed
2121
* Fixed non-finite terminal cable states so they are reset with finite route metrics and rewards
2222
instead of aborting synchronized multi-GPU training; robot/action failures are now sanitized at
2323
the physics boundary and terminated with finite policy observations and rewards.
24+
* Held each relative joint target across control decimation, clamped it to the authored limits,
25+
represented gripper actions by their binary command state, and made terminal success and failure
26+
rewards independent of control frequency. Newton actuator graph capture and complete-episode PPO
27+
startup are now enabled for training.
2428
* Applied explicit Newton contact materials to YAM and fixture assets, proxied every collidable YAM
2529
link into the cable solver, and declared the task's Newton and contrib runtime dependencies.
2630
* Avoided redundant or manager-order-dependent route evaluation and expensive ordinary cable

source/isaaclab_tasks/isaaclab_tasks/contrib/cable_routing/agents/rsl_rl_ppo_cfg.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@ class CableRoutingGaussianDistributionCfg(RslRlMLPModelCfg.GaussianDistributionC
5454
class CableRoutingPPORunnerCfg(RslRlOnPolicyRunnerCfg):
5555
"""PPO configuration for the goal-conditioned bimanual cable-routing policy."""
5656

57-
num_steps_per_env = 32
57+
num_steps_per_env = 36
58+
init_at_random_ep_len = False
5859
max_iterations = 15000
5960
save_interval = 250
6061
experiment_name = "yam_cable_routing"

source/isaaclab_tasks/isaaclab_tasks/contrib/cable_routing/cable_routing_env_cfg.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -390,7 +390,10 @@ class PolicyCfg(ObsGroup):
390390
"right_ee_cfg": SceneEntityCfg("yam_right", body_names=["link_6"]),
391391
},
392392
)
393-
actions = ObsTerm(func=mdp.finite_last_action)
393+
actions = ObsTerm(
394+
func=mdp.finite_last_action,
395+
params={"binary_action_names": ("left_gripper", "right_gripper")},
396+
)
394397

395398
def __post_init__(self) -> None:
396399
self.enable_corruption = False
@@ -502,13 +505,29 @@ class EventCfg:
502505
class RewardsCfg:
503506
"""Sparse ordered-route success reward with safety and control penalties."""
504507

505-
success = RewTerm(func=mdp.route_success, weight=20.0, params={"command_name": "route"})
508+
success = RewTerm(
509+
func=mdp.route_success,
510+
weight=20.0,
511+
params={
512+
"command_name": "route",
513+
"failure_termination_names": ("invalid_cable", "invalid_robot_or_action"),
514+
},
515+
)
516+
failure = RewTerm(
517+
func=mdp.route_failure,
518+
weight=-20.0,
519+
params={"termination_names": ("invalid_cable", "invalid_robot_or_action")},
520+
)
506521
stretch = RewTerm(
507522
func=mdp.cable_stretch,
508523
weight=-0.25,
509524
params={"cable_cfg": SceneEntityCfg("cable"), "rest_length": CABLE_SEGMENT_LENGTH},
510525
)
511-
action_rate = RewTerm(func=mdp.finite_action_rate_l2, weight=-0.002)
526+
action_rate = RewTerm(
527+
func=mdp.finite_action_rate_l2,
528+
weight=-0.002,
529+
params={"binary_action_names": ("left_gripper", "right_gripper")},
530+
)
512531
left_joint_velocity = RewTerm(
513532
func=mdp.finite_joint_vel_l2,
514533
weight=-0.0001,
@@ -559,6 +578,7 @@ class CableRoutingEnvCfg(ManagerBasedRLEnvCfg):
559578
terminations: TerminationsCfg = TerminationsCfg()
560579
sim: SimulationCfg = SimulationCfg(
561580
dt=1.0 / 120.0,
581+
use_newton_actuators=True,
562582
physics=NewtonCfg(
563583
solver_cfg=CouplerProxyCfg(
564584
entries=[

source/isaaclab_tasks/isaaclab_tasks/contrib/cable_routing/mdp/__init__.pyi

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ __all__ = [
2828
"cable_relative_joint_gap",
2929
"cable_stretch",
3030
"cable_unrouted_mask",
31+
"canonical_task_actions",
3132
"finite_action_rate_l2",
3233
"finite_joint_pos_rel",
3334
"finite_joint_vel_l2",
@@ -45,6 +46,7 @@ __all__ = [
4546
"reset_peg_offsets",
4647
"robot_or_action_invalid",
4748
"route_complete",
49+
"route_failure",
4850
"route_progress",
4951
"route_success",
5052
"route_task_state",
@@ -66,6 +68,7 @@ from .actions import (
6668
FiniteBinaryJointPositionActionCfg,
6769
FiniteRelativeJointPositionAction,
6870
FiniteRelativeJointPositionActionCfg,
71+
canonical_task_actions,
6972
)
7073
from .cable_geometry import cable_relative_joint_gap
7174
from .commands import CableRoutingCommand, CableRoutingCommandCfg
@@ -122,6 +125,7 @@ from .rewards import (
122125
finite_action_rate_l2,
123126
finite_joint_vel_l2,
124127
grippers_near_cable,
128+
route_failure,
125129
route_progress,
126130
route_success,
127131
)

source/isaaclab_tasks/isaaclab_tasks/contrib/cable_routing/mdp/actions.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
from __future__ import annotations
99

10+
from collections.abc import Sequence
11+
1012
import torch
1113

1214
from isaaclab.envs.mdp.actions import BinaryJointPositionAction, RelativeJointPositionAction
@@ -19,12 +21,47 @@ def _finite_unit_actions(actions: torch.Tensor) -> torch.Tensor:
1921
return torch.nan_to_num(actions, nan=0.0, posinf=1.0, neginf=-1.0).clamp(-1.0, 1.0)
2022

2123

24+
def canonical_task_actions(env, actions: torch.Tensor, binary_action_names: Sequence[str]) -> torch.Tensor:
25+
"""Return finite unit actions with binary terms represented only by their selected state."""
26+
actions = _finite_unit_actions(actions)
27+
binary_names = set(binary_action_names)
28+
unknown_names = binary_names.difference(env.action_manager.active_terms)
29+
if unknown_names:
30+
raise ValueError(f"Unknown binary action terms: {sorted(unknown_names)}.")
31+
32+
chunks: list[torch.Tensor] = []
33+
offset = 0
34+
for name, dim in zip(env.action_manager.active_terms, env.action_manager.action_term_dim):
35+
term_actions = actions[:, offset : offset + dim]
36+
if name in binary_names:
37+
term_actions = torch.where(
38+
term_actions < 0.0, -torch.ones_like(term_actions), torch.ones_like(term_actions)
39+
)
40+
chunks.append(term_actions)
41+
offset += dim
42+
return torch.cat(chunks, dim=1)
43+
44+
2245
class FiniteRelativeJointPositionAction(RelativeJointPositionAction):
23-
"""Relative joint action that cannot forward non-finite targets to physics."""
46+
"""Finite relative joint action that holds one limit-clamped target per policy step."""
2447

2548
def process_actions(self, actions: torch.Tensor) -> None:
2649
super().process_actions(_finite_unit_actions(actions))
2750

51+
# The stock relative action adds the delta to the live joint position in apply_actions(),
52+
# which is called once per simulation step. Resolve the absolute target here instead so
53+
# control semantics do not change with environment decimation or Newton graph capture.
54+
current = self._asset.data.joint_pos.torch[:, self._joint_ids]
55+
default = self._asset.data.default_joint_pos.torch[:, self._joint_ids]
56+
current = torch.where(torch.isfinite(current), current, default)
57+
limits = self._asset.data.soft_joint_pos_limits.torch[:, self._joint_ids]
58+
target = current + self._processed_actions
59+
target = torch.where(torch.isfinite(target), target, default)
60+
self._processed_actions = torch.maximum(torch.minimum(target, limits[..., 1]), limits[..., 0])
61+
62+
def apply_actions(self) -> None:
63+
self._asset.set_joint_position_target_index(target=self.processed_actions, joint_ids=self._joint_ids)
64+
2865

2966
class FiniteBinaryJointPositionAction(BinaryJointPositionAction):
3067
"""Binary gripper action that cannot forward non-finite commands to physics."""

source/isaaclab_tasks/isaaclab_tasks/contrib/cable_routing/mdp/observations.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from isaaclab.managers import SceneEntityCfg
1313

1414
from ..yam_frames import yam_contact_frame_position_w
15+
from .actions import canonical_task_actions
1516

1617

1718
def route_task_state(env, command_name: str) -> torch.Tensor:
@@ -76,10 +77,20 @@ def active_goal_geometry(
7677
return torch.nan_to_num(geometry, nan=0.0, posinf=0.0, neginf=0.0)
7778

7879

79-
def finite_last_action(env, action_name: str | None = None) -> torch.Tensor:
80-
"""Return the bounded action that the task's action terms can apply."""
81-
action = env.action_manager.action if action_name is None else env.action_manager.get_term(action_name).raw_actions
82-
return torch.nan_to_num(action, nan=0.0, posinf=1.0, neginf=-1.0).clamp(-1.0, 1.0)
80+
def finite_last_action(
81+
env,
82+
action_name: str | None = None,
83+
binary_action_names: tuple[str, ...] = (),
84+
) -> torch.Tensor:
85+
"""Return bounded actions with binary terms represented by their physical command state."""
86+
if action_name is None:
87+
return canonical_task_actions(env, env.action_manager.action, binary_action_names)
88+
89+
action = env.action_manager.get_term(action_name).raw_actions
90+
action = torch.nan_to_num(action, nan=0.0, posinf=1.0, neginf=-1.0).clamp(-1.0, 1.0)
91+
if action_name in binary_action_names:
92+
action = torch.where(action < 0.0, -torch.ones_like(action), torch.ones_like(action))
93+
return action
8394

8495

8596
def finite_joint_pos_rel(env, asset_cfg: SceneEntityCfg) -> torch.Tensor:

source/isaaclab_tasks/isaaclab_tasks/contrib/cable_routing/mdp/rewards.py

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from isaaclab.managers import SceneEntityCfg
1313

1414
from ..yam_frames import yam_contact_frame_position_w
15+
from .actions import canonical_task_actions
1516
from .cable_geometry import cable_relative_joint_gap
1617

1718

@@ -40,17 +41,32 @@ def route_progress(env, command_name: str) -> torch.Tensor:
4041
return command.route_progress_delta
4142

4243

43-
def route_success(env, command_name: str) -> torch.Tensor:
44-
"""Return one for environments that have completed their sampled route."""
44+
def route_success(
45+
env,
46+
command_name: str,
47+
failure_termination_names: tuple[str, ...] = (),
48+
) -> torch.Tensor:
49+
"""Return a unit-integral pulse for valid terminal route success."""
4550
command = env.command_manager.get_term(command_name)
4651
command.ensure_route_state_current(update_reward_delta=True)
47-
return command.succeeded.float()
52+
succeeded = command.succeeded.clone()
53+
for term_name in failure_termination_names:
54+
succeeded &= ~env.termination_manager.get_term(term_name)
55+
return succeeded.float() / max(float(env.step_dt), 1.0e-8)
56+
57+
58+
def route_failure(env, termination_names: tuple[str, ...]) -> torch.Tensor:
59+
"""Return one unit-integral failure pulse for invalid terminal transitions."""
60+
failed = torch.zeros(env.num_envs, dtype=torch.bool, device=env.device)
61+
for term_name in termination_names:
62+
failed |= env.termination_manager.get_term(term_name)
63+
return failed.float() / max(float(env.step_dt), 1.0e-8)
4864

4965

50-
def finite_action_rate_l2(env) -> torch.Tensor:
51-
"""Penalize changes between the finite, bounded actions applied by this task."""
52-
action = torch.nan_to_num(env.action_manager.action, nan=0.0, posinf=1.0, neginf=-1.0).clamp(-1.0, 1.0)
53-
previous = torch.nan_to_num(env.action_manager.prev_action, nan=0.0, posinf=1.0, neginf=-1.0).clamp(-1.0, 1.0)
66+
def finite_action_rate_l2(env, binary_action_names: tuple[str, ...] = ()) -> torch.Tensor:
67+
"""Penalize changes in finite arm actions and binary gripper command states."""
68+
action = canonical_task_actions(env, env.action_manager.action, binary_action_names)
69+
previous = canonical_task_actions(env, env.action_manager.prev_action, binary_action_names)
5470
return torch.square(action - previous).sum(dim=1)
5571

5672

source/isaaclab_tasks/test/contrib/test_cable_routing_cfg.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
from isaaclab_contrib.deformable import VBDSolverCfg
3434

3535
import isaaclab_tasks.contrib.cable_routing # noqa: F401
36+
from isaaclab_tasks.contrib.cable_routing.agents.rsl_rl_ppo_cfg import CableRoutingPPORunnerCfg
3637
from isaaclab_tasks.contrib.cable_routing.cable_routing_env_cfg import (
3738
BOARD_SIZE,
3839
BOARD_THICKNESS,
@@ -63,11 +64,14 @@
6364
CableRoutingSevenGoalsEnvCfg,
6465
CableRoutingTier1PegsEnvCfg,
6566
)
67+
from isaaclab_tasks.contrib.cable_routing.mdp.actions import FiniteRelativeJointPositionAction
6668
from isaaclab_tasks.contrib.cable_routing.mdp.commands import (
6769
CableRoutingCommand,
6870
CableRoutingCommandCfg,
6971
_route_goal_marker_data,
7072
)
73+
from isaaclab_tasks.contrib.cable_routing.mdp.observations import finite_last_action
74+
from isaaclab_tasks.contrib.cable_routing.mdp.rewards import finite_action_rate_l2, route_failure, route_success
7175

7276
_TASK_CONFIGS = {
7377
"IsaacContrib-CableRouting-YAM": "CableRoutingEnvCfg",
@@ -123,6 +127,99 @@ def test_cable_routing_actions_are_relative_joint_position_with_binary_grippers(
123127
assert resolved_policy_action_dim == 14
124128

125129

130+
def test_relative_joint_action_holds_one_limit_clamped_target() -> None:
131+
"""The relative target is resolved once instead of advancing during decimation."""
132+
captured: dict[str, torch.Tensor] = {}
133+
joint_position = torch.tensor(((0.95, -0.95),))
134+
asset = SimpleNamespace(
135+
data=SimpleNamespace(
136+
joint_pos=SimpleNamespace(torch=joint_position),
137+
default_joint_pos=SimpleNamespace(torch=torch.zeros_like(joint_position)),
138+
soft_joint_pos_limits=SimpleNamespace(torch=torch.tensor((((-1.0, 1.0), (-1.0, 1.0)),))),
139+
)
140+
)
141+
142+
def set_target(*, target: torch.Tensor, joint_ids: list[int]) -> None:
143+
captured["target"] = target.clone()
144+
captured["joint_ids"] = torch.tensor(joint_ids)
145+
146+
asset.set_joint_position_target_index = set_target
147+
term = FiniteRelativeJointPositionAction.__new__(FiniteRelativeJointPositionAction)
148+
term.cfg = SimpleNamespace(clip=None)
149+
term._asset = asset
150+
term._joint_ids = [0, 1]
151+
term._raw_actions = torch.zeros((1, 2))
152+
term._scale = 0.1
153+
term._offset = 0.0
154+
155+
term.process_actions(torch.tensor(((torch.inf, -1.0),)))
156+
joint_position.zero_()
157+
term.apply_actions()
158+
term.apply_actions()
159+
160+
torch.testing.assert_close(term.raw_actions, torch.tensor(((1.0, -1.0),)))
161+
torch.testing.assert_close(captured["target"], torch.tensor(((1.0, -1.0),)))
162+
torch.testing.assert_close(captured["joint_ids"], torch.tensor((0, 1)))
163+
164+
165+
def test_gripper_observation_and_action_rate_use_binary_command_state() -> None:
166+
"""Equivalent continuous gripper magnitudes have identical policy semantics and cost."""
167+
manager = SimpleNamespace(
168+
active_terms=["left_arm", "left_gripper", "right_arm", "right_gripper"],
169+
action_term_dim=[2, 1, 2, 1],
170+
action=torch.tensor(((0.1, -0.2, 0.01, 0.3, -0.4, -0.01),)),
171+
prev_action=torch.tensor(((0.1, -0.2, 0.9, 0.3, -0.4, -0.8),)),
172+
)
173+
env = SimpleNamespace(action_manager=manager)
174+
binary_names = ("left_gripper", "right_gripper")
175+
176+
observation = finite_last_action(env, binary_action_names=binary_names)
177+
action_rate = finite_action_rate_l2(env, binary_action_names=binary_names)
178+
179+
torch.testing.assert_close(observation, torch.tensor(((0.1, -0.2, 1.0, 0.3, -0.4, -1.0),)))
180+
torch.testing.assert_close(action_rate, torch.zeros(1))
181+
182+
manager.prev_action[:, -1] = 0.8
183+
torch.testing.assert_close(finite_action_rate_l2(env, binary_names), torch.tensor((4.0,)))
184+
185+
186+
def test_terminal_outcome_rewards_are_control_frequency_independent() -> None:
187+
"""Configured terminal weights are exact one-time returns, with failure overriding success."""
188+
command = SimpleNamespace(
189+
succeeded=torch.tensor((True, False, True)),
190+
ensure_route_state_current=lambda **_kwargs: None,
191+
)
192+
term_values = {
193+
"invalid_cable": torch.tensor((False, True, False)),
194+
"invalid_robot_or_action": torch.tensor((False, False, True)),
195+
}
196+
step_dt = 1.0 / 30.0
197+
env = SimpleNamespace(
198+
num_envs=3,
199+
device="cpu",
200+
step_dt=step_dt,
201+
command_manager=SimpleNamespace(get_term=lambda _name: command),
202+
termination_manager=SimpleNamespace(get_term=lambda name: term_values[name]),
203+
)
204+
failure_names = tuple(term_values)
205+
206+
success_return = route_success(env, "route", failure_names) * 20.0 * step_dt
207+
failure_return = route_failure(env, failure_names) * -20.0 * step_dt
208+
209+
torch.testing.assert_close(success_return, torch.tensor((20.0, 0.0, 0.0)))
210+
torch.testing.assert_close(failure_return, torch.tensor((0.0, -20.0, -20.0)))
211+
212+
213+
def test_cable_routing_ppo_starts_from_complete_1p2_second_rollouts() -> None:
214+
"""The first SuccessMonitor outcomes come from complete episodes and sufficiently long rollouts."""
215+
env_cfg = CableRoutingEnvCfg()
216+
runner_cfg = CableRoutingPPORunnerCfg()
217+
218+
assert env_cfg.sim.use_newton_actuators
219+
assert runner_cfg.num_steps_per_env * env_cfg.decimation * env_cfg.sim.dt == pytest.approx(1.2)
220+
assert not runner_cfg.init_at_random_ep_len
221+
222+
126223
def test_cable_routing_manager_terms_use_menagerie_joint_and_body_names() -> None:
127224
"""Test observations, events, and rewards resolve against the canonical YAM names."""
128225
cfg = CableRoutingEnvCfg()
@@ -155,6 +252,7 @@ def test_cable_routing_rewards_are_sparse_success_with_penalties_only() -> None:
155252
"""Test every route variant excludes dense geometric reward shaping."""
156253
expected_reward_names = {
157254
"success",
255+
"failure",
158256
"stretch",
159257
"action_rate",
160258
"left_joint_velocity",
@@ -512,6 +610,7 @@ def test_cable_routing_uses_disjoint_newton_collision_ownership() -> None:
512610
physics = cfg.sim.physics
513611

514612
assert isinstance(physics, NewtonCfg)
613+
assert cfg.sim.use_newton_actuators
515614
assert physics.num_substeps == 10
516615
assert physics.use_cuda_graph
517616
assert physics.default_shape_cfg.ke == 4.0e4

0 commit comments

Comments
 (0)