Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
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
1 change: 1 addition & 0 deletions source/isaaclab/changelog.d/fix-osc-test-feedback.skip
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Stabilized operational-space controller tests with consistent link-origin feedback, unit task-frame orientations, and damped nullspace pose steps.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Allowed standalone smoke tests that reached readiness before the startup deadline to complete their full soak interval.
2 changes: 1 addition & 1 deletion source/isaaclab/test/app/standalone_script_cases.py
Original file line number Diff line number Diff line change
Expand Up @@ -489,7 +489,7 @@ def read_available_output(timeout: float) -> bool:
_terminate_process_group(process)
returncode = process.poll()
break
if now - start_time >= startup_timeout:
if ready_at is None and now - start_time >= startup_timeout:

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.

P2 Non-finite soak can hang

Once readiness is observed, the startup deadline is disabled and soak_time becomes the only remaining bound. Because ISAACLAB_STANDALONE_SOAK_TIME is parsed with float(...) without a finiteness check, values such as inf or nan make the soak comparison permanently false. A healthy but still-running demo would then remain supervised indefinitely, so the configured soak interval should be validated as finite.

_terminate_process_group(process)
returncode = process.poll()
break
Expand Down
32 changes: 32 additions & 0 deletions source/isaaclab/test/app/test_standalone_scripts.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import sys
from dataclasses import replace
from pathlib import Path
from unittest import mock

import pytest
import standalone_script_cases as script_cases
Expand Down Expand Up @@ -371,6 +372,37 @@ def test_subprocess_supervisor_soaks_then_stops_process_group():
assert result.elapsed < 2.0


def test_subprocess_supervisor_completes_soak_after_startup_deadline(monkeypatch):
"""Readiness just before the startup deadline must still receive the full soak."""
process = mock.Mock(returncode=None)
process.poll.side_effect = lambda: process.returncode
process.communicate.return_value = (b"", None)
selector = mock.Mock()
now = 0.0
poll_times = iter((299.0, 300.0, 304.0))

def select(timeout):
nonlocal now
if timeout == 0.0:
return []
now = next(poll_times)
if now == 299.0:
return [(mock.Mock(fileobj=process.stdout), script_cases.selectors.EVENT_READ)]
return []

selector.select.side_effect = select
monkeypatch.setattr(script_cases.subprocess, "Popen", lambda *args, **kwargs: process)
monkeypatch.setattr(script_cases.selectors, "DefaultSelector", lambda: selector)
monkeypatch.setattr(script_cases.os, "read", lambda *args: b"READY\n")
monkeypatch.setattr(script_cases.time, "monotonic", lambda: now)
monkeypatch.setattr(script_cases, "_terminate_process_group", lambda process: setattr(process, "returncode", -15))

result = run_until_ready(["demo.py"], r"READY", startup_timeout=300.0, soak_time=5.0)
assert result.ready
assert result.stopped_after_soak
assert result.elapsed == 304.0


def test_subprocess_supervisor_ignores_fatal_output_after_intentional_teardown(monkeypatch):
"""Fatal-looking output caused by intentional teardown must not fail a healthy launch."""

Expand Down
11 changes: 7 additions & 4 deletions source/isaaclab/test/controllers/test_operational_space.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ def sim():
],
device=sim.device,
)
# These orientations also define task frames, whose transforms require unit quaternions.
ee_goal_abs_quad_set_b /= torch.linalg.vector_norm(ee_goal_abs_quad_set_b, dim=-1, keepdim=True)
ee_goal_rel_pos_set = torch.tensor(
[
[0.2, 0.0, 0.0],
Expand Down Expand Up @@ -1174,7 +1176,8 @@ def test_franka_pose_abs_with_nullspace_centering(sim):
partial_inertial_dynamics_decoupling=False,
gravity_compensation=False,
motion_stiffness_task=500.0,
motion_damping_ratio_task=1.0,
# Avoid hitting joint limits during the large pose steps while centering the nullspace.
motion_damping_ratio_task=2.0,
nullspace_control="position",
nullspace_stiffness=1.0,
)
Expand Down Expand Up @@ -1635,9 +1638,9 @@ def _update_states(
)
ee_pose_b = torch.cat([ee_pos_b, ee_quat_b], dim=-1)

# Compute the current velocity of the end-effector
ee_vel_w = robot.data.body_vel_w.torch[:, ee_frame_idx, :] # Extract end-effector velocity in the world frame
root_vel_w = robot.data.root_vel_w.torch # Extract root velocity in the world frame
# Match the link-origin reference point used by the pose and Jacobian.
ee_vel_w = robot.data.body_link_vel_w.torch[:, ee_frame_idx, :]
root_vel_w = robot.data.root_link_vel_w.torch
relative_vel_w = ee_vel_w - root_vel_w # Compute the relative velocity in the world frame
ee_lin_vel_b = quat_apply_inverse(robot.data.root_quat_w.torch, relative_vel_w[:, 0:3]) # From world to root frame
ee_ang_vel_b = quat_apply_inverse(robot.data.root_quat_w.torch, relative_vel_w[:, 3:6])
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Fixed
^^^^^

* Fixed missing cloned visuals in the legacy OVRTX renderer by expanding nested USD instances
in its exported clone sources. Simulation stages and the ovstage renderer path retained
their original instancing.
13 changes: 10 additions & 3 deletions source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_usd.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@

from pxr import Sdf, Usd, UsdGeom

from isaaclab.sim.utils import make_uninstanceable

logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -426,9 +428,10 @@ def export_stage_to_string(
anonymous session layer used only for export, so the input stage remains unchanged.

When ``keep_env_roots`` is True (the legacy ``renderer.clone_usd`` path) the non-source env root prims stay
active so the exported stage retains a slot for every env. The ovstage ``stage.clone`` path passes False, which
additionally trims the non-source env roots themselves; ``stage.clone`` recreates them and the RenderProduct's
camera relationship is re-authored after clone.
active so the exported stage retains a slot for every env. Nested instances in source subtrees are expanded
on the export session because legacy cloning otherwise drops their geometry. The ovstage ``stage.clone``
path passes False, retaining source instancing and trimming the non-source env roots themselves;
``stage.clone`` recreates them and the RenderProduct's camera relationship is re-authored after clone.

Args:
stage: USD stage to export.
Expand All @@ -455,6 +458,10 @@ def export_stage_to_string(
prim_paths: list[Sdf.Path] = []

if keep_env_roots:
# Native legacy cloning omits instanced visuals; expand only the renderer's copy.
with Usd.EditContext(export_stage, export_session):
for source_path in source_paths:
make_uninstanceable(source_path, stage=export_stage)
for child in envs_prim.GetChildren():
# Legacy code path: keep env roots so we can query their xforms after opening stage
child_path = child.GetPath()
Expand Down
42 changes: 40 additions & 2 deletions source/isaaclab_ov/test/test_ovrtx_usd.py
Original file line number Diff line number Diff line change
Expand Up @@ -430,8 +430,8 @@ def test_export_stage_without_keep_env_roots_trims_non_source_env_roots():
"""The ovstage clone path also trims the non-source env roots themselves.

``ovstage.Stage.clone`` requires every target path to not already exist, so the exported stage
must not retain env roots that the clone will recreate. This is the only difference from the
legacy ``renderer.clone_usd`` path, which keeps the roots as placeholders.
must not retain env roots that the clone will recreate. The legacy ``renderer.clone_usd``
path keeps the roots as placeholders.
"""
num_envs = 4
stage = _make_multi_env_stage(num_envs)
Expand Down Expand Up @@ -494,6 +494,44 @@ def test_export_stage_restores_active_state():
assert stage.GetPrimAtPath(f"{env_path}/Object_env{env_idx}_only").IsActive()


@pytest.mark.parametrize(
("num_envs", "keep_env_roots", "keep_instances"),
[(4, True, False), (4, False, True), (1, True, True), (1, False, True)],
)
def test_export_stage_expands_nested_instances_only_for_legacy_cloning(num_envs, keep_env_roots, keep_instances):
"""Legacy cloning receives concrete geometry without changing the simulation's instancing."""
stage = _make_multi_env_stage(num_envs)
mesh = UsdGeom.Mesh.Define(stage, "/MeshPrototype/Mesh")
mesh.CreatePointsAttr([(0, 0, 0), (1, 0, 0), (0, 1, 0)])
mesh.CreateFaceVertexCountsAttr([3])
mesh.CreateFaceVertexIndicesAttr([0, 1, 2])
visuals = UsdGeom.Xform.Define(stage, "/RobotPrototype/Visuals").GetPrim()
visuals.GetReferences().AddInternalReference("/MeshPrototype")
visuals.SetInstanceable(True)
robot_path = "/World/envs/env_0/Robot"
robot = stage.GetPrimAtPath(robot_path)
robot.GetReferences().AddInternalReference("/RobotPrototype")
robot.SetInstanceable(True)
mesh_path = f"{robot_path}/Visuals/Mesh"
assert robot.IsInstance()
assert stage.GetPrimAtPath(mesh_path).IsInstanceProxy()
input_layers = [layer.ExportToString() for layer in stage.GetLayerStack()]

exported = export_stage_to_string(stage, num_envs, ("/World/envs/env_0",), keep_env_roots)
exported_layer = Sdf.Layer.CreateAnonymous("exported.usda")
exported_layer.ImportFromString(exported)
exported_stage = Usd.Stage.Open(exported_layer)

assert exported_stage.GetPrimAtPath(robot_path).IsInstance() == keep_instances
assert exported_stage.GetPrimAtPath(f"{robot_path}/Visuals").IsInstanceable() == keep_instances
exported_mesh = exported_stage.GetPrimAtPath(mesh_path)
assert exported_mesh.IsInstanceProxy() == keep_instances
assert UsdGeom.Mesh(exported_mesh).GetPointsAttr().Get() == mesh.GetPointsAttr().Get()
assert [layer.ExportToString() for layer in stage.GetLayerStack()] == input_layers
assert robot.IsInstance()
assert stage.GetPrimAtPath(mesh_path).IsInstanceProxy()


def test_create_scene_partition_attributes_all_envs():
"""Scene partition attributes are authored on every env root and camera."""
num_envs = 4
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Fixed
^^^^^

* Fixed Franka Pour initialization and Franka Lift/Reorient reset sampling with updated assets by explicitly
selecting the convex-hull arm colliders required by these tasks.
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,8 @@ class PourSceneCfg(InteractiveSceneCfg):
)
robot = FRANKA_PANDA_CFG.replace(prim_path="{ENV_REGEX_NS}/Robot")
robot.spawn.usd_path = FRANKA_POUR_ROBOT_USD_PATH
# The task's arm-collision overrides require mesh proxies, not the asset's primitive default.
robot.spawn.variants = {"Colliders": "convex_hulls"}
robot.spawn.func = spawn_franka_with_arm_collisions
robot.spawn.articulation_props.enabled_self_collisions = True
robot.actuators = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
# legacy asset so the upstream franka tasks keep their demos and baselines.
FRANKA_PANDA_LIFT_CFG = FRANKA_PANDA_CFG.copy()
FRANKA_PANDA_LIFT_CFG.spawn.usd_path = f"{ISAACLAB_NUCLEUS_DIR}/Robots/FrankaEmika/franka_panda.usda"
# Reset clearance was calibrated for these arm meshes; the asset's primitive colliders intersect the ground.
FRANKA_PANDA_LIFT_CFG.spawn.variants = {"Colliders": "convex_hulls"}

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.

did you check that this doesnt create regressions in other tasks?

FRANKA_PANDA_LIFT_CFG.actuators = {
# Inspired by libfranka's joint_impedance_control.cpp. ``actuator_velocity_limit``
# remains the soft task-limit snapshot; ``joint_velocity_limit`` is the
Expand Down
25 changes: 25 additions & 0 deletions source/isaaclab_tasks/test/contrib/test_franka_pour_env_cfg.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@

import pytest

from pxr import Usd

from isaaclab.sim import select_usd_variants
from isaaclab.utils.assets import ISAACLAB_NUCLEUS_DIR

from isaaclab_tasks.contrib.franka_pour import pour_env
Expand Down Expand Up @@ -113,6 +116,28 @@ def test_reset_dataset_contract_stores_root_relative_robot_asset_path():
assert f"{ISAACLAB_NUCLEUS_DIR}/{robot_asset}" == FRANKA_POUR_ROBOT_ASSET_ID


def test_robot_selects_arm_collision_proxies_from_asset_variants():
"""Pouring retains its arm collision meshes when the asset defaults to primitives."""
cfg = FrankaPourResetDatasetEnvCfg()
stage = Usd.Stage.CreateInMemory()
robot = stage.DefinePrim("/Robot", "Xform")
colliders = robot.GetVariantSets().AddVariantSet("Colliders")
for selection, prim_path, prim_type in (
("convex_hulls", "/Robot/link0_c/link0_c", "Mesh"),
("primitives", "/Robot/link0_capsule", "Capsule"),
):
colliders.AddVariant(selection)
colliders.SetVariantSelection(selection)
with colliders.GetVariantEditContext():
stage.DefinePrim(prim_path, prim_type)
colliders.SetVariantSelection("primitives")

select_usd_variants("/Robot", cfg.scene.robot.spawn.variants or {}, stage=stage)

assert stage.GetPrimAtPath("/Robot/link0_c/link0_c").IsValid()
assert not stage.GetPrimAtPath("/Robot/link0_capsule").IsValid()


def test_capacity_resolution_only_updates_world_dependent_solver_limits():
"""Late world-count resolution does not clone or reconstruct task assets."""
cfg = FrankaPourResetDatasetEnvCfg()
Expand Down
27 changes: 27 additions & 0 deletions source/isaaclab_tasks/test/core/test_lift_env_cfg.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,13 @@
import pytest
import torch

from pxr import Usd

from isaaclab.managers import CommandTerm
from isaaclab.sim import select_usd_variants

from isaaclab_tasks.core.lift import mdp
from isaaclab_tasks.core.lift.config.franka.franka_env_cfg import FrankaLiftEnvCfg, FrankaReorientEnvCfg
from isaaclab_tasks.core.lift.config.franka_soft.franka_soft_env_cfg import FrankaSoftEnvCfg
from isaaclab_tasks.core.lift.mdp.commands.pose_commands import (
CableUniformPoseCommand,
Expand Down Expand Up @@ -58,6 +62,29 @@ def test_franka_soft_robot_physics_variant_matches_backend(
assert cfg.scene.robot.spawn.variants == {"Physics": expected_physics}


@pytest.mark.parametrize("cfg_type", [FrankaLiftEnvCfg, FrankaReorientEnvCfg])
def test_franka_rigid_tasks_select_collision_meshes_for_reset_clearance(cfg_type) -> None:
"""Reset validation keeps the original arm meshes when the asset defaults to capsules."""
cfg = cfg_type()
stage = Usd.Stage.CreateInMemory()
robot = stage.DefinePrim("/Robot", "Xform")
colliders = robot.GetVariantSets().AddVariantSet("Colliders")
for selection, prim_path, prim_type in (
("convex_hulls", "/Robot/link1_c/link1_c", "Mesh"),
("primitives", "/Robot/link1_capsule", "Capsule"),
):
colliders.AddVariant(selection)
colliders.SetVariantSelection(selection)
with colliders.GetVariantEditContext():
stage.DefinePrim(prim_path, prim_type)
colliders.SetVariantSelection("primitives")

select_usd_variants("/Robot", cfg.scene.robot.spawn.variants or {}, stage=stage)

assert stage.GetPrimAtPath("/Robot/link1_c/link1_c").IsValid()
assert not stage.GetPrimAtPath("/Robot/link1_capsule").IsValid()


def test_camera_normalization_is_stationary() -> None:
"""RGB and depth normalization must not depend on per-frame statistics."""
rgb = torch.tensor([0.0, 127.5, 255.0])
Expand Down
Loading