diff --git a/source/isaaclab/changelog.d/fix-osc-test-feedback.skip b/source/isaaclab/changelog.d/fix-osc-test-feedback.skip new file mode 100644 index 000000000000..ef86a60a4f07 --- /dev/null +++ b/source/isaaclab/changelog.d/fix-osc-test-feedback.skip @@ -0,0 +1 @@ +Stabilized operational-space controller tests with consistent link-origin feedback, unit task-frame orientations, and damped nullspace pose steps. diff --git a/source/isaaclab/changelog.d/fix-standalone-soak-deadline.skip b/source/isaaclab/changelog.d/fix-standalone-soak-deadline.skip new file mode 100644 index 000000000000..f08e4d9ff050 --- /dev/null +++ b/source/isaaclab/changelog.d/fix-standalone-soak-deadline.skip @@ -0,0 +1 @@ +Allowed standalone smoke tests that reached readiness before the startup deadline to complete their full soak interval. diff --git a/source/isaaclab/test/app/standalone_script_cases.py b/source/isaaclab/test/app/standalone_script_cases.py index d9a5e89351b7..f220f033ab16 100644 --- a/source/isaaclab/test/app/standalone_script_cases.py +++ b/source/isaaclab/test/app/standalone_script_cases.py @@ -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: _terminate_process_group(process) returncode = process.poll() break diff --git a/source/isaaclab/test/app/test_standalone_scripts.py b/source/isaaclab/test/app/test_standalone_scripts.py index 3d0b8c6480cf..a43cb2349b27 100644 --- a/source/isaaclab/test/app/test_standalone_scripts.py +++ b/source/isaaclab/test/app/test_standalone_scripts.py @@ -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 @@ -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.""" diff --git a/source/isaaclab/test/controllers/test_operational_space.py b/source/isaaclab/test/controllers/test_operational_space.py index ab4c46acc2d9..6b19de81ee5a 100644 --- a/source/isaaclab/test/controllers/test_operational_space.py +++ b/source/isaaclab/test/controllers/test_operational_space.py @@ -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], @@ -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, ) @@ -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]) diff --git a/source/isaaclab_ov/changelog.d/fix-legacy-ovrtx-instance-cloning.rst b/source/isaaclab_ov/changelog.d/fix-legacy-ovrtx-instance-cloning.rst new file mode 100644 index 000000000000..89726882fc2b --- /dev/null +++ b/source/isaaclab_ov/changelog.d/fix-legacy-ovrtx-instance-cloning.rst @@ -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. diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_usd.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_usd.py index 2d008bd25e1e..891a9e9c0a7d 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_usd.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_usd.py @@ -14,6 +14,8 @@ from pxr import Sdf, Usd, UsdGeom +from isaaclab.sim.utils import make_uninstanceable + logger = logging.getLogger(__name__) @@ -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. @@ -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() diff --git a/source/isaaclab_ov/test/test_ovrtx_usd.py b/source/isaaclab_ov/test/test_ovrtx_usd.py index 1dc05838c820..d45cc07e059b 100644 --- a/source/isaaclab_ov/test/test_ovrtx_usd.py +++ b/source/isaaclab_ov/test/test_ovrtx_usd.py @@ -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) @@ -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 diff --git a/source/isaaclab_tasks/changelog.d/fix-franka-collider-variants.rst b/source/isaaclab_tasks/changelog.d/fix-franka-collider-variants.rst new file mode 100644 index 000000000000..660eeecc30e1 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/fix-franka-collider-variants.rst @@ -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. diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/franka_pour/pour_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/franka_pour/pour_env_cfg.py index 2662affebd7a..e5746725a337 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/franka_pour/pour_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/franka_pour/pour_env_cfg.py @@ -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 = { diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/lift/config/franka/franka_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/lift/config/franka/franka_env_cfg.py index 26c063d78da2..b244c84dcab0 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/lift/config/franka/franka_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/lift/config/franka/franka_env_cfg.py @@ -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"} 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 diff --git a/source/isaaclab_tasks/test/contrib/test_franka_pour_env_cfg.py b/source/isaaclab_tasks/test/contrib/test_franka_pour_env_cfg.py index 17e936d5f5b1..90b6bda2995c 100644 --- a/source/isaaclab_tasks/test/contrib/test_franka_pour_env_cfg.py +++ b/source/isaaclab_tasks/test/contrib/test_franka_pour_env_cfg.py @@ -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 @@ -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() diff --git a/source/isaaclab_tasks/test/core/test_lift_env_cfg.py b/source/isaaclab_tasks/test/core/test_lift_env_cfg.py index e9ab35c1dbba..eb6de255122b 100644 --- a/source/isaaclab_tasks/test/core/test_lift_env_cfg.py +++ b/source/isaaclab_tasks/test/core/test_lift_env_cfg.py @@ -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, @@ -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])