From df8ba08e423304df096355451273723776c03d63 Mon Sep 17 00:00:00 2001 From: "isaaclab-bot[bot]" <282401363+isaaclab-bot[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 05:12:09 +0000 Subject: [PATCH 01/10] [CI][Auto Version Bump] Compile changelog fragments (schedule) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumped packages: - isaaclab: 28.0.0 → 29.0.0 - isaaclab_newton: 7.0.1 → 8.0.0 - isaaclab_ov: 3.3.2 → 3.4.0 - isaaclab_physx: 7.2.2 → 7.2.3 - isaaclab_visualizers: 1.12.1 → 1.13.0 --- .../isaaclab/changelog.d/controller-fixes.rst | 14 ---- .../core-cleanup-backend-signatures.skip | 1 - .../direct-reset-observation-noise.rst | 5 -- .../fix-ovphysx-joint-wrench-frame.skip | 1 - .../fix-task-space-body-offset-jacobian.rst | 8 -- .../opencv-fisheye-angular-domain.minor.rst | 6 -- .../changelog.d/package-demos.major.rst | 42 ----------- .../changelog.d/render-batch.minor.rst | 10 --- source/isaaclab/docs/CHANGELOG.rst | 75 +++++++++++++++++++ source/isaaclab/pyproject.toml | 2 +- .../changelog.d/clone-lifecycle.skip | 1 - .../changelog.d/package-demos.skip | 0 .../changelog.d/clone-lifecycle.major.rst | 12 --- .../core-cleanup-backend-signatures.rst | 7 -- .../changelog.d/newton-controllers.minor.rst | 20 ----- .../opencv-fisheye-angular-domain.rst | 6 -- .../changelog.d/package-demos.skip | 0 .../shared-joint-wrench-contract.skip | 1 - .../test-consolidate-newton-tests.skip | 1 - source/isaaclab_newton/docs/CHANGELOG.rst | 45 +++++++++++ source/isaaclab_newton/pyproject.toml | 2 +- .../changelog.d/fix-joint-wrench-frame.rst | 7 -- .../changelog.d/ovrtx-render-batch.minor.rst | 5 -- source/isaaclab_ov/docs/CHANGELOG.rst | 18 +++++ source/isaaclab_ov/pyproject.toml | 2 +- .../core-cleanup-backend-signatures.rst | 15 ---- .../changelog.d/isaac-rtx-render-batch.rst | 5 -- .../shared-joint-wrench-contract.skip | 1 - source/isaaclab_physx/docs/CHANGELOG.rst | 22 ++++++ source/isaaclab_physx/pyproject.toml | 2 +- .../newton-controllers-ik-imports.skip | 1 - .../changelog.d/program-browser.minor.rst | 9 --- .../isaaclab_visualizers/docs/CHANGELOG.rst | 14 ++++ source/isaaclab_visualizers/pyproject.toml | 2 +- 34 files changed, 179 insertions(+), 183 deletions(-) delete mode 100644 source/isaaclab/changelog.d/controller-fixes.rst delete mode 100644 source/isaaclab/changelog.d/core-cleanup-backend-signatures.skip delete mode 100644 source/isaaclab/changelog.d/direct-reset-observation-noise.rst delete mode 100644 source/isaaclab/changelog.d/fix-ovphysx-joint-wrench-frame.skip delete mode 100644 source/isaaclab/changelog.d/fix-task-space-body-offset-jacobian.rst delete mode 100644 source/isaaclab/changelog.d/opencv-fisheye-angular-domain.minor.rst delete mode 100644 source/isaaclab/changelog.d/package-demos.major.rst delete mode 100644 source/isaaclab/changelog.d/render-batch.minor.rst delete mode 100644 source/isaaclab_contrib/changelog.d/clone-lifecycle.skip delete mode 100644 source/isaaclab_contrib/changelog.d/package-demos.skip delete mode 100644 source/isaaclab_newton/changelog.d/clone-lifecycle.major.rst delete mode 100644 source/isaaclab_newton/changelog.d/core-cleanup-backend-signatures.rst delete mode 100644 source/isaaclab_newton/changelog.d/newton-controllers.minor.rst delete mode 100644 source/isaaclab_newton/changelog.d/opencv-fisheye-angular-domain.rst delete mode 100644 source/isaaclab_newton/changelog.d/package-demos.skip delete mode 100644 source/isaaclab_newton/changelog.d/shared-joint-wrench-contract.skip delete mode 100644 source/isaaclab_newton/changelog.d/test-consolidate-newton-tests.skip delete mode 100644 source/isaaclab_ov/changelog.d/fix-joint-wrench-frame.rst delete mode 100644 source/isaaclab_ov/changelog.d/ovrtx-render-batch.minor.rst delete mode 100644 source/isaaclab_physx/changelog.d/core-cleanup-backend-signatures.rst delete mode 100644 source/isaaclab_physx/changelog.d/isaac-rtx-render-batch.rst delete mode 100644 source/isaaclab_physx/changelog.d/shared-joint-wrench-contract.skip delete mode 100644 source/isaaclab_tasks/changelog.d/newton-controllers-ik-imports.skip delete mode 100644 source/isaaclab_visualizers/changelog.d/program-browser.minor.rst diff --git a/source/isaaclab/changelog.d/controller-fixes.rst b/source/isaaclab/changelog.d/controller-fixes.rst deleted file mode 100644 index ae0af99d4a8a..000000000000 --- a/source/isaaclab/changelog.d/controller-fixes.rst +++ /dev/null @@ -1,14 +0,0 @@ -Changed -^^^^^^^ - -* Improved :meth:`~isaaclab.controllers.OperationalSpaceController.set_command` performance by reusing a - preallocated identity task frame and rotating the gains and selection matrices into the root frame in one - batched step. The cost no longer grows with the number of environments. No migration is needed. - -Fixed -^^^^^ - -* Fixed variable joint-impedance gain clamping for robot batches with more than two joints. -* Fixed batched joint-impedance inertia compensation when the robot and joint counts differed. -* Fixed the differential IK SVD solver for position-only and under-actuated tasks. -* Fixed differential IK joint-limit avoidance failing when joint limits were given as float64. diff --git a/source/isaaclab/changelog.d/core-cleanup-backend-signatures.skip b/source/isaaclab/changelog.d/core-cleanup-backend-signatures.skip deleted file mode 100644 index 2d429f207a4c..000000000000 --- a/source/isaaclab/changelog.d/core-cleanup-backend-signatures.skip +++ /dev/null @@ -1 +0,0 @@ -Test coverage for backend signature fixes. diff --git a/source/isaaclab/changelog.d/direct-reset-observation-noise.rst b/source/isaaclab/changelog.d/direct-reset-observation-noise.rst deleted file mode 100644 index 3f10e2cf7c17..000000000000 --- a/source/isaaclab/changelog.d/direct-reset-observation-noise.rst +++ /dev/null @@ -1,5 +0,0 @@ -Fixed -^^^^^ - -* Applied configured observation noise to explicit resets in ``DirectRLEnv`` and - ``DirectMARLEnv``, matching the observation-noise behavior of ``step()``. diff --git a/source/isaaclab/changelog.d/fix-ovphysx-joint-wrench-frame.skip b/source/isaaclab/changelog.d/fix-ovphysx-joint-wrench-frame.skip deleted file mode 100644 index fe56d14e2e27..000000000000 --- a/source/isaaclab/changelog.d/fix-ovphysx-joint-wrench-frame.skip +++ /dev/null @@ -1 +0,0 @@ -Moved the shared joint-wrench physics test into the test tree and checked registration by every backend. diff --git a/source/isaaclab/changelog.d/fix-task-space-body-offset-jacobian.rst b/source/isaaclab/changelog.d/fix-task-space-body-offset-jacobian.rst deleted file mode 100644 index 6e0f862f317e..000000000000 --- a/source/isaaclab/changelog.d/fix-task-space-body-offset-jacobian.rst +++ /dev/null @@ -1,8 +0,0 @@ -Fixed -^^^^^ - -* Fixed the body-offset Jacobian correction in :class:`~isaaclab.envs.mdp.actions.DifferentialInverseKinematicsAction` - and :class:`~isaaclab.envs.mdp.actions.OperationalSpaceControllerAction`. The offset is now rotated into the root - frame by the body orientation before shifting the translational rows, and the angular rows are no longer rotated by - the offset rotation, matching the offset frame's pose and velocity. Tasks that set ``body_offset`` (for example, the - Franka IK tasks) now receive the Jacobian of the offset frame instead of an approximation. diff --git a/source/isaaclab/changelog.d/opencv-fisheye-angular-domain.minor.rst b/source/isaaclab/changelog.d/opencv-fisheye-angular-domain.minor.rst deleted file mode 100644 index 6da078b643eb..000000000000 --- a/source/isaaclab/changelog.d/opencv-fisheye-angular-domain.minor.rst +++ /dev/null @@ -1,6 +0,0 @@ -Added -^^^^^ - -* Added a configurable ``OpenCvFisheyeDistortionCfg.max_fov`` angular domain for Newton, - preserving the existing 180-degree default. Documented that RTX/OVRTX's native OpenCV - schema does not consume this setting and can clip rectangular sensor corners to its image circle. diff --git a/source/isaaclab/changelog.d/package-demos.major.rst b/source/isaaclab/changelog.d/package-demos.major.rst deleted file mode 100644 index a9e07cc2a199..000000000000 --- a/source/isaaclab/changelog.d/package-demos.major.rst +++ /dev/null @@ -1,42 +0,0 @@ -Added -^^^^^ - -* Added curated showcases through ``isaaclab demo `` and focused standalone programs through - ``isaaclab example ``. Both catalogs are included in the released wheel and can be run with ``uvx``. -* Added the ``zoo`` demo, which animates several robot families and rigid objects in one deterministic scene. -* Added a Newton GL program selector that puts curated demos ahead of focused examples. -* Reused the Isaac Lab terminal startup screen for packaged demos and examples. -* Added ``--max_steps`` to the ``arl-robot-1``, ``bin-packing``, ``deformables``, ``markers``, ``multi-asset``, - ``procedural-terrain``, ``multi-mesh-ray-caster``, and ``visual-color-randomization`` examples so every packaged - program can stop after a fixed number of steps. -* Added :meth:`~isaaclab.sim.SimulationContext.add_reset_callback` and - :meth:`~isaaclab.sim.SimulationContext.remove_reset_callback` for callbacks that must be registered before a - script creates its simulation context. - -Changed -^^^^^^^ - -* **Breaking:** Moved maintained programs from ``scripts/demos`` into the repository-level ``examples`` directory, - with showcases in ``examples/demos`` and focused programs in topic directories such as ``examples/mpm``. Use - ``isaaclab demo `` or ``isaaclab example `` instead of direct script paths. -* **Breaking:** Reclassified the following technical programs as examples without changing their public names: - ``arl-robot-1``, ``bin-packing``, ``cables``, ``deformables``, ``haply-teleoperation``, ``heterogeneous-scene``, - ``markers``, ``multi-asset``, ``newton-dominoes``, ``procedural-terrain``, ``visual-color-randomization``, - ``mpm-granular``, ``mpm-two-way-coupling``, ``camera``, ``contact-sensor``, ``frame-transformer``, ``imu``, - ``multi-mesh-ray-caster``, ``multi-mesh-ray-caster-camera``, ``ppisp-camera``, ``pva``, ``ray-caster``, and - ``tactile-sensor``. Replace direct ``scripts/demos`` invocations with ``isaaclab example ``. -* Changed ``isaaclab demo h1-locomotion`` to run on Newton with the Newton GL viewer by default. Robots are driven - with I/J/K/L, selected with N, and followed with C. Pass ``--physics isaacsim_physx`` for PhysX. -* Changed ``isaaclab demo`` and ``isaaclab example`` to compile Warp kernels without backward passes, since - packaged programs only run inference. -* **Breaking:** Consolidated ``scripts/demos/sensors/newton_raycast_heightfield.py`` and - ``scripts/demos/sensors/newton_raycast_moving_geometry.py`` as - ``isaaclab example newton-raycast --scene {heightfield,moving-geometry}``. - -Removed -^^^^^^^ - -* **Breaking:** Replaced the separate ``scripts/demos/arms.py``, ``bipeds.py``, ``hands.py``, ``quadcopter.py``, and - ``quadrupeds.py`` galleries with ``isaaclab demo zoo``. -* **Breaking:** Removed the temporary ``ppisp_camera_ovrtx.py`` QA script. Use - ``isaaclab example ppisp-camera --renderer newton_renderer`` instead. diff --git a/source/isaaclab/changelog.d/render-batch.minor.rst b/source/isaaclab/changelog.d/render-batch.minor.rst deleted file mode 100644 index 51df5f5c29b4..000000000000 --- a/source/isaaclab/changelog.d/render-batch.minor.rst +++ /dev/null @@ -1,10 +0,0 @@ -Added -^^^^^ - -* Added ``BaseRenderer.render_batch()`` with a default loop over the existing single-camera - ``render()`` interface, allowing renderers to optimize multiple camera captures. -* Added ``SensorBase.supports_batch_update`` to opt sensors into eager scene batching and - ``SensorBase.update_batch()`` for standalone updates of batch-capable sensors. -* Added ``RenderContext.render_into_cameras()`` to group prepared captures by renderer and - batch due cameras during eager scene updates. Preserved per-camera lazy reads, update - periods, and reset state. diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index 8accbe39bab5..58b985f73c34 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,81 @@ Changelog --------- +29.0.0 (2026-09-25) +~~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added a configurable ``OpenCvFisheyeDistortionCfg.max_fov`` angular domain for Newton, + preserving the existing 180-degree default. Documented that RTX/OVRTX's native OpenCV + schema does not consume this setting and can clip rectangular sensor corners to its image circle. +* Added ``BaseRenderer.render_batch()`` with a default loop over the existing single-camera + ``render()`` interface, allowing renderers to optimize multiple camera captures. +* Added ``SensorBase.supports_batch_update`` to opt sensors into eager scene batching and + ``SensorBase.update_batch()`` for standalone updates of batch-capable sensors. +* Added ``RenderContext.render_into_cameras()`` to group prepared captures by renderer and + batch due cameras during eager scene updates. Preserved per-camera lazy reads, update + periods, and reset state. +* Added curated showcases through ``isaaclab demo `` and focused standalone programs through + ``isaaclab example ``. Both catalogs are included in the released wheel and can be run with ``uvx``. +* Added the ``zoo`` demo, which animates several robot families and rigid objects in one deterministic scene. +* Added a Newton GL program selector that puts curated demos ahead of focused examples. +* Reused the Isaac Lab terminal startup screen for packaged demos and examples. +* Added ``--max_steps`` to the ``arl-robot-1``, ``bin-packing``, ``deformables``, ``markers``, ``multi-asset``, + ``procedural-terrain``, ``multi-mesh-ray-caster``, and ``visual-color-randomization`` examples so every packaged + program can stop after a fixed number of steps. +* Added :meth:`~isaaclab.sim.SimulationContext.add_reset_callback` and + :meth:`~isaaclab.sim.SimulationContext.remove_reset_callback` for callbacks that must be registered before a + script creates its simulation context. + +Changed +^^^^^^^ + +* **Breaking:** Moved maintained programs from ``scripts/demos`` into the repository-level ``examples`` directory, + with showcases in ``examples/demos`` and focused programs in topic directories such as ``examples/mpm``. Use + ``isaaclab demo `` or ``isaaclab example `` instead of direct script paths. +* **Breaking:** Reclassified the following technical programs as examples without changing their public names: + ``arl-robot-1``, ``bin-packing``, ``cables``, ``deformables``, ``haply-teleoperation``, ``heterogeneous-scene``, + ``markers``, ``multi-asset``, ``newton-dominoes``, ``procedural-terrain``, ``visual-color-randomization``, + ``mpm-granular``, ``mpm-two-way-coupling``, ``camera``, ``contact-sensor``, ``frame-transformer``, ``imu``, + ``multi-mesh-ray-caster``, ``multi-mesh-ray-caster-camera``, ``ppisp-camera``, ``pva``, ``ray-caster``, and + ``tactile-sensor``. Replace direct ``scripts/demos`` invocations with ``isaaclab example ``. +* Changed ``isaaclab demo h1-locomotion`` to run on Newton with the Newton GL viewer by default. Robots are driven + with I/J/K/L, selected with N, and followed with C. Pass ``--physics isaacsim_physx`` for PhysX. +* Changed ``isaaclab demo`` and ``isaaclab example`` to compile Warp kernels without backward passes, since + packaged programs only run inference. +* **Breaking:** Consolidated ``scripts/demos/sensors/newton_raycast_heightfield.py`` and + ``scripts/demos/sensors/newton_raycast_moving_geometry.py`` as + ``isaaclab example newton-raycast --scene {heightfield,moving-geometry}``. +* Improved :meth:`~isaaclab.controllers.OperationalSpaceController.set_command` performance by reusing a + preallocated identity task frame and rotating the gains and selection matrices into the root frame in one + batched step. The cost no longer grows with the number of environments. No migration is needed. + +Removed +^^^^^^^ + +* **Breaking:** Replaced the separate ``scripts/demos/arms.py``, ``bipeds.py``, ``hands.py``, ``quadcopter.py``, and + ``quadrupeds.py`` galleries with ``isaaclab demo zoo``. +* **Breaking:** Removed the temporary ``ppisp_camera_ovrtx.py`` QA script. Use + ``isaaclab example ppisp-camera --renderer newton_renderer`` instead. + +Fixed +^^^^^ + +* Fixed the body-offset Jacobian correction in :class:`~isaaclab.envs.mdp.actions.DifferentialInverseKinematicsAction` + and :class:`~isaaclab.envs.mdp.actions.OperationalSpaceControllerAction`. The offset is now rotated into the root + frame by the body orientation before shifting the translational rows, and the angular rows are no longer rotated by + the offset rotation, matching the offset frame's pose and velocity. Tasks that set ``body_offset`` (for example, the + Franka IK tasks) now receive the Jacobian of the offset frame instead of an approximation. +* Applied configured observation noise to explicit resets in ``DirectRLEnv`` and + ``DirectMARLEnv``, matching the observation-noise behavior of ``step()``. +* Fixed variable joint-impedance gain clamping for robot batches with more than two joints. +* Fixed batched joint-impedance inertia compensation when the robot and joint counts differed. +* Fixed the differential IK SVD solver for position-only and under-actuated tasks. +* Fixed differential IK joint-limit avoidance failing when joint limits were given as float64. + + 28.0.0 (2026-09-24) ~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab/pyproject.toml b/source/isaaclab/pyproject.toml index c127158b8eb4..36d860c65d96 100644 --- a/source/isaaclab/pyproject.toml +++ b/source/isaaclab/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab" -version = "28.0.0" +version = "29.0.0" description = "Extension providing main framework interfaces and abstractions for robot learning." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_contrib/changelog.d/clone-lifecycle.skip b/source/isaaclab_contrib/changelog.d/clone-lifecycle.skip deleted file mode 100644 index 382cd47a06cb..000000000000 --- a/source/isaaclab_contrib/changelog.d/clone-lifecycle.skip +++ /dev/null @@ -1 +0,0 @@ -Migrated standalone test scene builders to explicit clone plans. diff --git a/source/isaaclab_contrib/changelog.d/package-demos.skip b/source/isaaclab_contrib/changelog.d/package-demos.skip deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/source/isaaclab_newton/changelog.d/clone-lifecycle.major.rst b/source/isaaclab_newton/changelog.d/clone-lifecycle.major.rst deleted file mode 100644 index 45971dd180b9..000000000000 --- a/source/isaaclab_newton/changelog.d/clone-lifecycle.major.rst +++ /dev/null @@ -1,12 +0,0 @@ -Changed -^^^^^^^ - -* **Breaking:** Removed implicit USD-stage import when Newton started without a builder. - Use ``InteractiveScene`` to construct and replicate configured USD assets before initialization. - Native tools can continue supplying a builder - through ``NewtonManager.set_builder(builder)`` without declaring a clone plan. - -Fixed -^^^^^ - -* Prevented globally declared native deformables from being imported twice through clone plans. diff --git a/source/isaaclab_newton/changelog.d/core-cleanup-backend-signatures.rst b/source/isaaclab_newton/changelog.d/core-cleanup-backend-signatures.rst deleted file mode 100644 index f56968d1867a..000000000000 --- a/source/isaaclab_newton/changelog.d/core-cleanup-backend-signatures.rst +++ /dev/null @@ -1,7 +0,0 @@ -Fixed -^^^^^ - -* Fixed the deprecated :meth:`~isaaclab_newton.assets.Articulation.write_joint_friction_coefficient_to_sim` and - :meth:`~isaaclab_newton.assets.Articulation.write_joint_friction_to_sim` always raising ``TypeError``. The Newton - override passed arguments that :meth:`~isaaclab_newton.assets.Articulation.write_joint_friction_coefficient_to_sim_index` - does not accept; it is removed in favor of the base class implementation. diff --git a/source/isaaclab_newton/changelog.d/newton-controllers.minor.rst b/source/isaaclab_newton/changelog.d/newton-controllers.minor.rst deleted file mode 100644 index f286daeb7404..000000000000 --- a/source/isaaclab_newton/changelog.d/newton-controllers.minor.rst +++ /dev/null @@ -1,20 +0,0 @@ -Added -^^^^^ - -* Added :mod:`isaaclab_newton.controllers` with :class:`~isaaclab_newton.controllers.NewtonDifferentialIKController`, - :class:`~isaaclab_newton.controllers.NewtonJointImpedanceController`, and - :class:`~isaaclab_newton.controllers.NewtonOperationalSpaceController`. They wrap the model-free controllers in - :mod:`newton.controllers` for any physics backend, and their configurations expose every Newton option, - including differential IK posture control, joint-impedance Coriolis and acceleration feedforward, and - operational-space selection frames and live gains. -* Added :class:`~isaaclab_newton.envs.mdp.NewtonDifferentialInverseKinematicsActionCfg` and - :class:`~isaaclab_newton.envs.mdp.NewtonOperationalSpaceControllerActionCfg` to drive the Newton controllers - from manager-based environments. - -Removed -^^^^^^^ - -* **Breaking:** Moved :mod:`isaaclab_newton.ik` to :mod:`isaaclab_newton.controllers.ik` without an alias. Replace - imports such as ``from isaaclab_newton.ik import NewtonIKSolver`` with - ``from isaaclab_newton.controllers.ik import NewtonIKSolver``, and ``isaaclab_newton.ik.`` with - ``isaaclab_newton.controllers.ik.``. diff --git a/source/isaaclab_newton/changelog.d/opencv-fisheye-angular-domain.rst b/source/isaaclab_newton/changelog.d/opencv-fisheye-angular-domain.rst deleted file mode 100644 index 9e938e6a151b..000000000000 --- a/source/isaaclab_newton/changelog.d/opencv-fisheye-angular-domain.rst +++ /dev/null @@ -1,6 +0,0 @@ -Fixed -^^^^^ - -* Replaced the fixed 180-degree OpenCV fisheye ray limit with the camera's configured - ``max_fov``. Calibrated rays beyond the forward hemisphere can now be rendered without - changing the existing default or introducing per-frame ray generation. diff --git a/source/isaaclab_newton/changelog.d/package-demos.skip b/source/isaaclab_newton/changelog.d/package-demos.skip deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/source/isaaclab_newton/changelog.d/shared-joint-wrench-contract.skip b/source/isaaclab_newton/changelog.d/shared-joint-wrench-contract.skip deleted file mode 100644 index a5378c233af7..000000000000 --- a/source/isaaclab_newton/changelog.d/shared-joint-wrench-contract.skip +++ /dev/null @@ -1 +0,0 @@ -Collected the shared joint-wrench frame test directly with the existing Newton simulation fixture. diff --git a/source/isaaclab_newton/changelog.d/test-consolidate-newton-tests.skip b/source/isaaclab_newton/changelog.d/test-consolidate-newton-tests.skip deleted file mode 100644 index fe6c803a9b4b..000000000000 --- a/source/isaaclab_newton/changelog.d/test-consolidate-newton-tests.skip +++ /dev/null @@ -1 +0,0 @@ -Consolidated redundant Newton tests and trimmed device and shape matrices that repeated the same code paths. diff --git a/source/isaaclab_newton/docs/CHANGELOG.rst b/source/isaaclab_newton/docs/CHANGELOG.rst index deaee44471cc..5dae32acafcd 100644 --- a/source/isaaclab_newton/docs/CHANGELOG.rst +++ b/source/isaaclab_newton/docs/CHANGELOG.rst @@ -1,6 +1,51 @@ Changelog --------- +8.0.0 (2026-09-25) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :mod:`isaaclab_newton.controllers` with :class:`~isaaclab_newton.controllers.NewtonDifferentialIKController`, + :class:`~isaaclab_newton.controllers.NewtonJointImpedanceController`, and + :class:`~isaaclab_newton.controllers.NewtonOperationalSpaceController`. They wrap the model-free controllers in + :mod:`newton.controllers` for any physics backend, and their configurations expose every Newton option, + including differential IK posture control, joint-impedance Coriolis and acceleration feedforward, and + operational-space selection frames and live gains. +* Added :class:`~isaaclab_newton.envs.mdp.NewtonDifferentialInverseKinematicsActionCfg` and + :class:`~isaaclab_newton.envs.mdp.NewtonOperationalSpaceControllerActionCfg` to drive the Newton controllers + from manager-based environments. + +Changed +^^^^^^^ + +* **Breaking:** Removed implicit USD-stage import when Newton started without a builder. + Use ``InteractiveScene`` to construct and replicate configured USD assets before initialization. + Native tools can continue supplying a builder + through ``NewtonManager.set_builder(builder)`` without declaring a clone plan. + +Removed +^^^^^^^ + +* **Breaking:** Moved :mod:`isaaclab_newton.ik` to :mod:`isaaclab_newton.controllers.ik` without an alias. Replace + imports such as ``from isaaclab_newton.ik import NewtonIKSolver`` with + ``from isaaclab_newton.controllers.ik import NewtonIKSolver``, and ``isaaclab_newton.ik.`` with + ``isaaclab_newton.controllers.ik.``. + +Fixed +^^^^^ + +* Replaced the fixed 180-degree OpenCV fisheye ray limit with the camera's configured + ``max_fov``. Calibrated rays beyond the forward hemisphere can now be rendered without + changing the existing default or introducing per-frame ray generation. +* Fixed the deprecated :meth:`~isaaclab_newton.assets.Articulation.write_joint_friction_coefficient_to_sim` and + :meth:`~isaaclab_newton.assets.Articulation.write_joint_friction_to_sim` always raising ``TypeError``. The Newton + override passed arguments that :meth:`~isaaclab_newton.assets.Articulation.write_joint_friction_coefficient_to_sim_index` + does not accept; it is removed in favor of the base class implementation. +* Prevented globally declared native deformables from being imported twice through clone plans. + + 7.0.1 (2026-09-24) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_newton/pyproject.toml b/source/isaaclab_newton/pyproject.toml index 995e6541380b..dc247790e87f 100644 --- a/source/isaaclab_newton/pyproject.toml +++ b/source/isaaclab_newton/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_newton" -version = "7.0.1" +version = "8.0.0" description = "Extension providing IsaacLab with Newton specific abstractions." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_ov/changelog.d/fix-joint-wrench-frame.rst b/source/isaaclab_ov/changelog.d/fix-joint-wrench-frame.rst deleted file mode 100644 index 7a1119c2e80e..000000000000 --- a/source/isaaclab_ov/changelog.d/fix-joint-wrench-frame.rst +++ /dev/null @@ -1,7 +0,0 @@ -Fixed -^^^^^ - -* Fixed OVPhysX joint-wrench sensors applying an extra frame transformation to readings that are already - expressed in the child-side joint frame at the joint anchor, and removed the redundant USD frame buffers. - Force and torque values changed for joints with non-identity child frames; the documented frame - convention is unchanged. diff --git a/source/isaaclab_ov/changelog.d/ovrtx-render-batch.minor.rst b/source/isaaclab_ov/changelog.d/ovrtx-render-batch.minor.rst deleted file mode 100644 index b7bbdd0e30e7..000000000000 --- a/source/isaaclab_ov/changelog.d/ovrtx-render-batch.minor.rst +++ /dev/null @@ -1,5 +0,0 @@ -Added -^^^^^ - -* Added an OVRTX ``render_batch()`` implementation that submitted all requested camera - products in one native renderer step while preserving the single-camera ``render()`` interface. diff --git a/source/isaaclab_ov/docs/CHANGELOG.rst b/source/isaaclab_ov/docs/CHANGELOG.rst index f7a1a1a217d8..f5fb387d9fcd 100644 --- a/source/isaaclab_ov/docs/CHANGELOG.rst +++ b/source/isaaclab_ov/docs/CHANGELOG.rst @@ -1,6 +1,24 @@ Changelog --------- +3.4.0 (2026-09-25) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added an OVRTX ``render_batch()`` implementation that submitted all requested camera + products in one native renderer step while preserving the single-camera ``render()`` interface. + +Fixed +^^^^^ + +* Fixed OVPhysX joint-wrench sensors applying an extra frame transformation to readings that are already + expressed in the child-side joint frame at the joint anchor, and removed the redundant USD frame buffers. + Force and torque values changed for joints with non-identity child frames; the documented frame + convention is unchanged. + + 3.3.2 (2026-09-24) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_ov/pyproject.toml b/source/isaaclab_ov/pyproject.toml index c4126844a93a..de806662593e 100644 --- a/source/isaaclab_ov/pyproject.toml +++ b/source/isaaclab_ov/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_ov" -version = "3.3.2" +version = "3.4.0" description = "Extension providing Omniverse rendering and OVPhysX simulation integrations." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_physx/changelog.d/core-cleanup-backend-signatures.rst b/source/isaaclab_physx/changelog.d/core-cleanup-backend-signatures.rst deleted file mode 100644 index 139ebf412368..000000000000 --- a/source/isaaclab_physx/changelog.d/core-cleanup-backend-signatures.rst +++ /dev/null @@ -1,15 +0,0 @@ -Fixed -^^^^^ - -* Fixed :meth:`~isaaclab_physx.assets.RigidObjectCollection.write_body_link_pose_to_sim_mask`, - :meth:`~isaaclab_physx.assets.RigidObjectCollection.write_body_com_pose_to_sim_mask`, - :meth:`~isaaclab_physx.assets.RigidObjectCollection.write_body_com_velocity_to_sim_mask`, and - :meth:`~isaaclab_physx.assets.RigidObjectCollection.write_body_link_velocity_to_sim_mask` not accepting the - ``body_mask`` argument that the base class and the other backends declare. - -Deprecated -^^^^^^^^^^ - -* Deprecated the ``body_ids`` argument of the :class:`~isaaclab_physx.assets.RigidObjectCollection` mask writers - listed above. Pass a boolean ``body_mask`` of shape (num_bodies,) instead, or use the ``*_index`` writers with - ``body_ids``. diff --git a/source/isaaclab_physx/changelog.d/isaac-rtx-render-batch.rst b/source/isaaclab_physx/changelog.d/isaac-rtx-render-batch.rst deleted file mode 100644 index 2c604eaa95e1..000000000000 --- a/source/isaaclab_physx/changelog.d/isaac-rtx-render-batch.rst +++ /dev/null @@ -1,5 +0,0 @@ -Fixed -^^^^^ - -* Avoided repeated Isaac RTX render-update checks by checking once per camera batch before - extracting each camera's annotator outputs. diff --git a/source/isaaclab_physx/changelog.d/shared-joint-wrench-contract.skip b/source/isaaclab_physx/changelog.d/shared-joint-wrench-contract.skip deleted file mode 100644 index fed859c9ff19..000000000000 --- a/source/isaaclab_physx/changelog.d/shared-joint-wrench-contract.skip +++ /dev/null @@ -1 +0,0 @@ -Collected the shared joint-wrench frame test directly with the existing PhysX simulation fixture. diff --git a/source/isaaclab_physx/docs/CHANGELOG.rst b/source/isaaclab_physx/docs/CHANGELOG.rst index 40da1b4dff2c..41ea87105650 100644 --- a/source/isaaclab_physx/docs/CHANGELOG.rst +++ b/source/isaaclab_physx/docs/CHANGELOG.rst @@ -1,6 +1,28 @@ Changelog --------- +7.2.3 (2026-09-25) +~~~~~~~~~~~~~~~~~~ + +Deprecated +^^^^^^^^^^ + +* Deprecated the ``body_ids`` argument of the :class:`~isaaclab_physx.assets.RigidObjectCollection` mask writers + listed above. Pass a boolean ``body_mask`` of shape (num_bodies,) instead, or use the ``*_index`` writers with + ``body_ids``. + +Fixed +^^^^^ + +* Avoided repeated Isaac RTX render-update checks by checking once per camera batch before + extracting each camera's annotator outputs. +* Fixed :meth:`~isaaclab_physx.assets.RigidObjectCollection.write_body_link_pose_to_sim_mask`, + :meth:`~isaaclab_physx.assets.RigidObjectCollection.write_body_com_pose_to_sim_mask`, + :meth:`~isaaclab_physx.assets.RigidObjectCollection.write_body_com_velocity_to_sim_mask`, and + :meth:`~isaaclab_physx.assets.RigidObjectCollection.write_body_link_velocity_to_sim_mask` not accepting the + ``body_mask`` argument that the base class and the other backends declare. + + 7.2.2 (2026-09-24) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_physx/pyproject.toml b/source/isaaclab_physx/pyproject.toml index b8f93adfddef..02c03ce7cb22 100644 --- a/source/isaaclab_physx/pyproject.toml +++ b/source/isaaclab_physx/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_physx" -version = "7.2.2" +version = "7.2.3" description = "Extension providing IsaacLab with PhysX specific abstractions." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_tasks/changelog.d/newton-controllers-ik-imports.skip b/source/isaaclab_tasks/changelog.d/newton-controllers-ik-imports.skip deleted file mode 100644 index d7859b0a851f..000000000000 --- a/source/isaaclab_tasks/changelog.d/newton-controllers-ik-imports.skip +++ /dev/null @@ -1 +0,0 @@ -Updated Newton IK imports to isaaclab_newton.controllers.ik. diff --git a/source/isaaclab_visualizers/changelog.d/program-browser.minor.rst b/source/isaaclab_visualizers/changelog.d/program-browser.minor.rst deleted file mode 100644 index 1a6250acdddf..000000000000 --- a/source/isaaclab_visualizers/changelog.d/program-browser.minor.rst +++ /dev/null @@ -1,9 +0,0 @@ -Added -^^^^^ - -* Added a clickable demo and example selector to Newton GL for packaged Isaac Lab programs. -* Added :meth:`~isaaclab_visualizers.newton.NewtonGLVisualizer.is_key_down` so scripts can read - keyboard input from the Newton viewer window. -* Added :meth:`~isaaclab_visualizers.newton.NewtonGLVisualizer.register_ui_callback` and - :meth:`~isaaclab_visualizers.newton.NewtonGLVisualizer.request_close` so callers can add viewer panels and close - the window safely from inside them. diff --git a/source/isaaclab_visualizers/docs/CHANGELOG.rst b/source/isaaclab_visualizers/docs/CHANGELOG.rst index 9e60e986ec3b..309cc205b1f7 100644 --- a/source/isaaclab_visualizers/docs/CHANGELOG.rst +++ b/source/isaaclab_visualizers/docs/CHANGELOG.rst @@ -1,6 +1,20 @@ Changelog --------- +1.13.0 (2026-09-25) +~~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added a clickable demo and example selector to Newton GL for packaged Isaac Lab programs. +* Added :meth:`~isaaclab_visualizers.newton.NewtonGLVisualizer.is_key_down` so scripts can read + keyboard input from the Newton viewer window. +* Added :meth:`~isaaclab_visualizers.newton.NewtonGLVisualizer.register_ui_callback` and + :meth:`~isaaclab_visualizers.newton.NewtonGLVisualizer.request_close` so callers can add viewer panels and close + the window safely from inside them. + + 1.12.1 (2026-09-24) ~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_visualizers/pyproject.toml b/source/isaaclab_visualizers/pyproject.toml index 8c02bc67c99b..c0460641e467 100644 --- a/source/isaaclab_visualizers/pyproject.toml +++ b/source/isaaclab_visualizers/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab-visualizers" -version = "1.12.1" +version = "1.13.0" description = "Visualizer backends for Isaac Lab (Kit, Newton, Rerun, Viser)." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] From ae94c3c2b5b8755afe0b8d37eb7d35597e37a383 Mon Sep 17 00:00:00 2001 From: Mustafa H <34825877+StafaH@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:40:35 -0700 Subject: [PATCH 02/10] [Tests] Prune OVPhysX tests with the test-audit skill (#8010) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # OVPhysX/OVRTX test suite: 11:29 → 6:32 (−43%) The `isaaclab_ov` suite runs through `pytest tools` on the same machine with a warm Warp cache, both device passes, and the `ov` extra. All 36 files pass. | | `develop` | this PR | |---|---:|---:| | Suite wall time | 11:29 | **6:32** | | Test functions | 473 | 345 | | Test lines | 19,636 | 16,136 | ## What changed The whole test surface was pruned with the `isaaclab-auditing-tests` skill (#8007). **Method.** Every test was marked keep, fix, merge or delete. Whenever a deletion's keeper does not carry the assertions verbatim, a deliberate production mutation proves the keeper fails on both devices. An independent review then restored every gap it found. Only test files changed. - **Merged:** - frame transformer: 6 builds → 1 - IMU/PVA sanity tests into the analytic keepers - articulation tests that rebuilt a sibling's fixture (5 Anymal builds → 1) - `test_ovphysx_view.py` error micro-tests into parametrized keepers - **Deleted:** - tests of the mock fixtures themselves - private ordering-state checks - WrenchComposer and effort-limit replays - joint-wrench identity copies - contact tests that can never run (production raises `NotImplementedError`) - OVRTX dtype × layout replays - **Fixed tests that could not fail:** - material randomization (the default values sat inside the sampling ranges) - IMU partial reset - contact threshold (#3498) - `apply_joint_command` - reset tests - **Test bug fixed:** a test mutated the shared `FRANKA_PANDA_CFG`, so every later Panda test ran on a robot that went NaN and still passed. ## Product defect found (not fixed here) - OVPhysX has no ArticulationRootAPI guard for rigid objects. Pinned with a strict `xfail`. ## Release backport - [x] Backport this pull request to the active release branch after it merges into `develop` ## Checklist - [x] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html) - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [x] My changes generate no new warnings - [x] I have added a changelog fragment under `source//changelog.d/` for every touched package - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Signed-off-by: Octi Zhang Co-authored-by: Octi Zhang --- .../changelog.d/test-audit-ov-ordering.skip | 1 + .../test_articulation_ordering_iface.py | 46 +- .../changelog.d/test-audit-ov-tests.skip | 1 + .../test/assets/test_articulation.py | 1381 ++++------------- .../test/assets/test_articulation_helpers.py | 38 +- .../test/assets/test_articulation_kernels.py | 38 +- .../test/assets/test_deformable_object.py | 267 +--- .../assets/test_deformable_object_helpers.py | 113 +- .../test/assets/test_deformable_views.py | 57 - .../test/assets/test_rigid_object.py | 534 ++----- .../assets/test_rigid_object_collection.py | 455 ++---- .../test/assets/test_rigid_object_helpers.py | 44 - .../test/physics/test_ovphysx_compat.py | 31 +- .../test_ovphysx_scene_data_backend.py | 131 +- source/isaaclab_ov/test/sensors/check_imu.py | 79 - source/isaaclab_ov/test/sensors/check_pva.py | 92 -- .../test/sensors/test_contact_sensor.py | 344 +--- .../test/sensors/test_frame_transformer.py | 648 ++------ source/isaaclab_ov/test/sensors/test_imu.py | 261 +--- .../test/sensors/test_joint_wrench_sensor.py | 268 +--- source/isaaclab_ov/test/sensors/test_pva.py | 331 +--- .../test/sensors/test_ray_caster.py | 5 +- .../isaaclab_ov/test/sim/test_ovphysx_view.py | 293 ++-- .../test/sim/test_views_xform_prim_ovphysx.py | 23 +- source/isaaclab_ov/test/test_cloner.py | 29 +- .../test/test_ovrtx_annotator_utils.py | 38 +- .../test/test_ovrtx_deformable_bindings.py | 1 - .../test/test_ovrtx_renderer_contract.py | 54 +- .../test/test_ovrtx_renderer_kernels.py | 333 +--- .../test/test_ovrtx_shader_cache_redirect.py | 17 +- source/isaaclab_ov/test/test_ovrtx_usd.py | 54 +- .../test/test_ovrtx_visual_material_sync.py | 10 - .../test_randomize_rigid_body_material_mdp.py | 65 +- 33 files changed, 1224 insertions(+), 4858 deletions(-) create mode 100644 source/isaaclab/changelog.d/test-audit-ov-ordering.skip create mode 100644 source/isaaclab_ov/changelog.d/test-audit-ov-tests.skip delete mode 100644 source/isaaclab_ov/test/assets/test_rigid_object_helpers.py delete mode 100644 source/isaaclab_ov/test/sensors/check_imu.py delete mode 100644 source/isaaclab_ov/test/sensors/check_pva.py diff --git a/source/isaaclab/changelog.d/test-audit-ov-ordering.skip b/source/isaaclab/changelog.d/test-audit-ov-ordering.skip new file mode 100644 index 000000000000..8c7727aeae88 --- /dev/null +++ b/source/isaaclab/changelog.d/test-audit-ov-ordering.skip @@ -0,0 +1 @@ +Preserved distinct-body wrench rotation and fresh reordered friction reads in the shared articulation tests. diff --git a/source/isaaclab/test/assets/test_articulation_ordering_iface.py b/source/isaaclab/test/assets/test_articulation_ordering_iface.py index 0e27ed25d386..d5bad9e6fcd0 100644 --- a/source/isaaclab/test/assets/test_articulation_ordering_iface.py +++ b/source/isaaclab/test/assets/test_articulation_ordering_iface.py @@ -679,10 +679,12 @@ def _set_body_ordering_backend_data( def _set_rotated_body_poses(backend: str, art, raw_backend) -> None: - """Use a known 90-degree Z rotation and nonzero positions for wrench packing.""" + """Use distinct quarter-turn Z rotations and nonzero positions for wrench packing.""" poses = np.zeros((art.num_instances, art.num_bodies, 7), dtype=np.float32) poses[..., 0] = np.arange(art.num_bodies) + 10.0 - poses[..., 5:7] = 2.0**-0.5 + half_angles = np.arange(art.num_bodies) * (np.pi / 4) + poses[..., 5] = np.sin(half_angles) + poses[..., 6] = np.cos(half_angles) if backend == "newton": poses_wp = wp.array(poses[:, None], dtype=wp.transformf, device=art.device) raw_backend.set_mock_link_transforms(poses_wp) @@ -1917,11 +1919,11 @@ def test_ovphysx_ordered_joint_state_is_cached_per_sim_timestamp(self): @_non_mock_backends @pytest.mark.parametrize("num_instances, num_joints, num_bodies", [(2, 3, 2)]) @pytest.mark.parametrize("device", ["cpu"]) - def test_reversed_joint_ordering_reorders_public_joint_properties( + def test_joint_ordering_reorders_public_joint_properties( self, backend, num_instances, num_joints, num_bodies, device ): """Expose every backend joint property under the matching public joint name.""" - joint_ordering = tuple(f"joint_{index}" for index in reversed(range(num_joints))) + joint_ordering = _joint_ordering_for_mode("cyclic", num_joints) art, raw_backend = get_articulation( backend, num_instances, @@ -1953,6 +1955,22 @@ def test_reversed_joint_ordering_reorders_public_joint_properties( for property_name, public_property in public_properties.items(): _assert_proxy_close(public_property, backend_properties[property_name][:, user_to_backend]) + if backend == "ovphysx": + from isaaclab_ov import tensor_types as TT + + # A fresh native read must gather too; initialization and writer caches can hide a missing gather. + friction = np.arange(num_instances * num_joints * 3, dtype=np.float32).reshape(num_instances, num_joints, 3) + friction += 100.0 + raw_backend.bindings[TT.DOF_FRICTION_PROPERTIES]._data = friction + art.data._joint_friction_props_buf.timestamp = -1 + art.data._joint_friction_props_backend.timestamp = -1 + for component, property_name in enumerate( + ("joint_friction_coeff", "joint_dynamic_friction_coeff", "joint_viscous_friction_coeff") + ): + _assert_proxy_close( + getattr(art.data, property_name), torch.from_numpy(friction[:, user_to_backend, component]) + ) + def _make_item_mask(total: int, selected: list[int], device: str) -> wp.array: """Create a bool Warp mask with the selected indices set.""" @@ -2033,15 +2051,25 @@ def capture_wrench(*, force_data, torque_data, position_data, indices, is_global art.write_data_to_sim() assert compose.call_count == int(is_global and backend == "newton") - expected_force, expected_torque = forces, torques + expected_force, expected_torque = forces[:, backend_to_user], torques[:, backend_to_user] if backend == "physx": assert captured["is_global"] is is_global elif not is_global: - expected_force = np.stack((-forces[..., 1], forces[..., 0], forces[..., 2]), axis=-1) - expected_torque = np.stack((-torques[..., 1], torques[..., 0], torques[..., 2]), axis=-1) + # Known 0/90/180/270-degree rotations, independently of the backend quaternion transform. + rotations = np.asarray( + [ + [[1, 0, 0], [0, 1, 0], [0, 0, 1]], + [[0, -1, 0], [1, 0, 0], [0, 0, 1]], + [[-1, 0, 0], [0, -1, 0], [0, 0, 1]], + [[0, 1, 0], [-1, 0, 0], [0, 0, 1]], + ], + dtype=np.float32, + ) + expected_force = np.einsum("bij,nbj->nbi", rotations, expected_force) + expected_torque = np.einsum("bij,nbj->nbi", rotations, expected_torque) backend_force, backend_torque = _read_backend_wrench(backend, art, raw_backend, captured) - np.testing.assert_allclose(backend_force, expected_force[:, backend_to_user], atol=1e-4) - np.testing.assert_allclose(backend_torque, expected_torque[:, backend_to_user], atol=1e-4) + np.testing.assert_allclose(backend_force, expected_force, atol=1e-4) + np.testing.assert_allclose(backend_torque, expected_torque, atol=1e-4) if backend == "ovphysx": from isaaclab_ov import tensor_types as TT diff --git a/source/isaaclab_ov/changelog.d/test-audit-ov-tests.skip b/source/isaaclab_ov/changelog.d/test-audit-ov-tests.skip new file mode 100644 index 000000000000..14063b2ad021 --- /dev/null +++ b/source/isaaclab_ov/changelog.d/test-audit-ov-tests.skip @@ -0,0 +1 @@ +Consolidated duplicated OVPhysX tests and repaired regression tests that could not fail. diff --git a/source/isaaclab_ov/test/assets/test_articulation.py b/source/isaaclab_ov/test/assets/test_articulation.py index e5bdb0890793..ac15647b1664 100644 --- a/source/isaaclab_ov/test/assets/test_articulation.py +++ b/source/isaaclab_ov/test/assets/test_articulation.py @@ -88,12 +88,8 @@ import isaaclab.utils.string as string_utils # noqa: E402 from isaaclab.actuators import DelayedPDActuatorCfg, IdealPDActuatorCfg, ImplicitActuatorCfg # noqa: E402 from isaaclab.assets import ArticulationCfg, get_articulation_name_ordering # noqa: E402 -from isaaclab.assets.articulation import ordering_kernels # noqa: E402 -from isaaclab.envs.mdp.terminations import joint_effort_out_of_limit # noqa: E402 -from isaaclab.managers import SceneEntityCfg # noqa: E402 from isaaclab.sim import SimulationCfg, build_simulation_context # noqa: E402 from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR # noqa: E402 -from isaaclab.utils.version import get_isaac_sim_version, has_kit # noqa: E402 from isaaclab.utils.warp.launch_cache import _WarpLaunchCache # noqa: E402 ## @@ -106,12 +102,6 @@ pytestmark = pytest.mark.device_split -_OMNI_PHYSX_SCHEMAS_GAP_REASON = ( - "Schema-level fixed-joint creation in :mod:`isaaclab.sim.schemas` imports the Kit-only " - "``omni.physx.scripts.utils`` module, which is not shipped by the ovphysx wheel." -) - - def test_prepare_native_actuators_leaves_implicit_only_articulation_on_standard_path(monkeypatch): """Keep implicit-only articulations on the unchanged solver-drive path.""" from isaaclab_ov.assets.articulation import actuator_control @@ -139,11 +129,6 @@ def test_prepare_native_actuators_leaves_implicit_only_articulation_on_standard_ assert runtime_prepare_calls == [] -_SPATIAL_TENDON_OVSTAGE_GAP_REASON = ( - "OVPhysX 0.5.9 segfaults while attaching OVStage scenes containing spatial tendon schemas." -) - - def test_cached_read_launches_reset_on_ordering_and_invalidation(): """Ordering installation and simulation invalidation should discard recorded reads.""" @@ -180,46 +165,6 @@ class Buffer: assert data._sim_timestamp == 0.0 -def test_generalized_dynamics_reorder_uses_public_joint_order(): - """OVPhysX dynamics reads should gather both matrix joint axes into public order.""" - - class Buffer: - def __init__(self): - self.data = wp.zeros((1, 2, 2), dtype=wp.float32, device="cpu") - self.timestamp = -1.0 - - data = ArticulationData.__new__(ArticulationData) - data.device = "cpu" - data._sim_timestamp = 1.0 - data._read_launch_cache = _WarpLaunchCache("cpu") - data.joint_ordering = object() - data._jacobian_joint_user_to_backend = wp.array([1, 0], dtype=wp.int32, device="cpu") - data._joint_dof_signs = wp.ones(2, dtype=wp.int32, device="cpu") - data._has_reversed_joints = False - data._num_base_dofs = 0 - - backend_values = wp.array([[[1.0, 2.0], [3.0, 4.0]]], dtype=wp.float32, device="cpu") - backend_buffer = wp.zeros_like(backend_values) - buffer = Buffer() - - def read_binding(tensor_type, dst): - dst.assign(backend_values) - - data._binding_read = read_binding - data._refresh_generalized_dynamics_buffer( - buffer, - backend_buffer, - TT.MASS_MATRIX, - ordering_kernels.reorder_mass_matrix_backend_to_user, - ) - - torch.testing.assert_close( - wp.to_torch(buffer.data), - torch.tensor([[[4.0, 3.0], [2.0, 1.0]]]), - ) - assert buffer.timestamp == 1.0 - - def test_static_property_reads_are_not_invalidated_by_simulation_steps(): """Joint properties and body mass/inertia should be read once per invalidation, not once per step. @@ -446,24 +391,10 @@ def generate_articulation_cfg( ), }, ) - elif articulation_type == "spatial_tendon_test_asset": - # we set 80.0 default for max force because default in USD is 10e10 which makes testing annoying. - articulation_cfg = ArticulationCfg( - spawn=sim_utils.UsdFileCfg( - usd_path=f"{ISAAC_NUCLEUS_DIR}/IsaacLab/Tests/spatial_tendons.usd", - ), - actuators={ - "joint": ImplicitActuatorCfg( - joint_names_expr=[".*"], - stiffness=2000.0, - damping=100.0, - ), - }, - ) else: raise ValueError( f"Invalid articulation type: {articulation_type}, valid options are 'humanoid', 'panda', 'anymal'," - " 'shadow_hand', 'single_joint_implicit', 'single_joint_explicit' or 'spatial_tendon_test_asset'." + " 'shadow_hand', 'single_joint_implicit' or 'single_joint_explicit'." ) return articulation_cfg @@ -716,34 +647,8 @@ def sim(request): yield sim -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -@pytest.mark.parametrize("gravity_enabled", [False]) -def test_write_joint_state_accepts_int64_selector(sim, device, gravity_enabled): - """Write joint state with int64 selectors.""" - articulation_cfg = generate_articulation_cfg(articulation_type="panda") - articulation, _ = generate_articulation(articulation_cfg, 2, device=device) - sim.reset() - assert articulation.num_joints >= 2 - - env_ids = torch.tensor([1, 0], dtype=torch.int64, device=device) - joint_ids = torch.tensor([articulation.num_joints - 1, 0], dtype=torch.int64, device=device) - position = torch.tensor([[0.21, 0.11], [0.22, 0.12]], device=device) - velocity = torch.tensor([[1.21, 1.11], [1.22, 1.12]], device=device) - expected_position = articulation.data.joint_pos.torch.clone() - expected_velocity = articulation.data.joint_vel.torch.clone() - - articulation.write_joint_state_to_sim_index( - position=position, velocity=velocity, env_ids=env_ids, joint_ids=joint_ids - ) - - expected_position[env_ids[:, None], joint_ids[None, :]] = position - expected_velocity[env_ids[:, None], joint_ids[None, :]] = velocity - torch.testing.assert_close(articulation.data.joint_pos.torch, expected_position) - torch.testing.assert_close(articulation.data.joint_vel.torch, expected_velocity) - - @pytest.mark.parametrize("device", ["cpu", "cuda:0"]) -@pytest.mark.parametrize("gravity_enabled", [False, True]) +@pytest.mark.parametrize("gravity_enabled", [True]) @pytest.mark.parametrize("user_ordering", [False, True]) # Keep integration-time pose/velocity differences below the dynamics tolerance. @pytest.mark.parametrize("dt", [1e-4]) @@ -802,10 +707,7 @@ def test_reversed_joint_dynamics_use_public_joint_basis(sim, device, gravity_ena expected_compensation = -torch.einsum( "nbij,nbi->nj", articulation.data.body_com_jacobian_w.torch[:, :, :3], body_weight ) - if gravity_enabled: - assert torch.all(expected_compensation.abs() > 0.1) - else: - assert torch.count_nonzero(expected_compensation) == 0 + assert torch.all(expected_compensation.abs() > 0.1) torch.testing.assert_close( articulation.data.gravity_compensation_forces.torch, expected_compensation, atol=1e-5, rtol=1e-5 ) @@ -834,38 +736,6 @@ def test_joint_dof_sign_resolution_traverses_instance_proxies(): assert Articulation._resolve_joint_dof_signs(articulation, stage) == (-1,) -@pytest.mark.parametrize("num_articulations", [1]) -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -def test_live_anymal_c_manual_joint_ordering_preserves_unselected_backend_state(sim, num_articulations, device): - """Test that a partial ordered write preserves every unselected backend joint.""" - articulation_cfg = generate_articulation_cfg("anymal").replace( - joint_ordering=tuple(reversed(ANYMAL_C_PHYSX_JOINT_NAMES)) - ) - articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=device) - sim.reset() - - joint_ordering = articulation.joint_ordering - assert joint_ordering is not None - backend_seed = torch.arange(1, articulation.num_joints + 1, dtype=torch.float32, device=device).reshape(1, -1) - backend_seed *= 0.001 - articulation.root_view.set_attribute(TT.DOF_POSITION, wp.from_torch(backend_seed)) - backend_before = wp.to_torch(articulation.root_view.get_attribute(TT.DOF_POSITION)).clone() - torch.testing.assert_close(backend_before, backend_seed, rtol=0.0, atol=0.0) - backend_joint_id = joint_ordering.user_to_backend_indices[0] - selected_value = backend_before[0, backend_joint_id] + 0.001 - - articulation.write_joint_position_to_sim_index( - position=selected_value.reshape(1, 1), - env_ids=wp.array([0], dtype=wp.int32, device=device), - joint_ids=wp.array([0], dtype=wp.int32, device=device), - ) - - backend_after = wp.to_torch(articulation.root_view.get_attribute(TT.DOF_POSITION)).clone() - expected = backend_before.clone() - expected[0, backend_joint_id] = selected_value - torch.testing.assert_close(backend_after, expected, rtol=0.0, atol=0.0) - - @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) def test_live_anymal_c_manual_joint_ordering_reorders_joint_targets(sim, device): """Write nonidentity-ordered joint targets into their intended backend columns.""" @@ -912,40 +782,9 @@ def test_live_anymal_c_manual_joint_ordering_reorders_joint_targets(sim, device) assert not torch.allclose(expected_telemetry, effort_target) -@pytest.mark.parametrize("device", ["cpu"]) -def test_live_anymal_c_manual_joint_ordering_reorders_joint_friction_properties(sim, device): - """Read every friction component from backend order into public joint order.""" - backend_joint_names = ANYMAL_C_PHYSX_JOINT_NAMES - joint_ordering = (*backend_joint_names[1:], backend_joint_names[0]) - articulation_cfg = generate_articulation_cfg("anymal").replace(joint_ordering=joint_ordering) - articulation, _ = generate_articulation(articulation_cfg, 1, device=device) - sim.reset() - - ordering = articulation.joint_ordering - assert ordering is not None - user_to_backend = torch.as_tensor(ordering.user_to_backend_indices, dtype=torch.long, device=device) - joint_index = torch.arange(articulation.num_joints, dtype=torch.float32, device=device).unsqueeze(0) - backend_friction = torch.stack( - (20.0 + joint_index, 10.0 + 0.5 * joint_index, 1.0 + 0.25 * joint_index), - dim=-1, - ) - articulation.root_view.set_attribute( - TT.DOF_FRICTION_PROPERTIES, - wp.from_torch(backend_friction.contiguous()), - ) - articulation.data._joint_friction_props_buf.timestamp = -1.0 - articulation.data._joint_friction_props_backend.timestamp = -1.0 - - expected = backend_friction[:, user_to_backend] - torch.testing.assert_close(articulation.data.joint_friction_coeff.torch, expected[..., 0]) - torch.testing.assert_close(articulation.data.joint_dynamic_friction_coeff.torch, expected[..., 1]) - torch.testing.assert_close(articulation.data.joint_viscous_friction_coeff.torch, expected[..., 2]) - - -@pytest.mark.parametrize("selection", ["full", "partial"]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -def test_reversed_joint_ordering_joint_state_index_writes_backend_order(sim, selection, device): - """Write full and partial indexed joint state through a nonidentity public joint axis.""" +def test_reversed_joint_ordering_joint_state_index_writes_backend_order(sim, device): + """Read dynamics and write full, partial, and position-only joint state through a nonidentity joint axis.""" articulation_cfg = generate_articulation_cfg("anymal").replace( joint_ordering=tuple(reversed(ANYMAL_C_PHYSX_JOINT_NAMES)) ) @@ -958,6 +797,26 @@ def test_reversed_joint_ordering_joint_state_index_writes_backend_order(sim, sel user_to_backend = torch.as_tensor(ordering.user_to_backend_indices, dtype=torch.long, device=device) backend_to_user = torch.as_tensor(ordering.backend_to_user_indices, dtype=torch.long, device=device) + # Floating-base columns stay leading while the actuated-joint axes are gathered. + generalized_user_to_backend = torch.cat((torch.arange(6, device=device), 6 + user_to_backend)) + raw_jacobian = _read_binding_to_torch(articulation, TT.JACOBIAN, device).reshape( + 2, articulation.num_bodies, 6, num_joints + 6 + ) + raw_mass_matrix = _read_binding_to_torch(articulation, TT.MASS_MATRIX, device) + raw_gravity = _read_binding_to_torch(articulation, TT.GRAVITY_FORCE, device) + torch.testing.assert_close( + articulation.data.body_com_jacobian_w.torch, + raw_jacobian[:, :, :, generalized_user_to_backend], + ) + torch.testing.assert_close( + articulation.data.mass_matrix.torch, + raw_mass_matrix[:, generalized_user_to_backend, :][:, :, generalized_user_to_backend], + ) + torch.testing.assert_close( + articulation.data.gravity_compensation_forces.torch, + raw_gravity[:, generalized_user_to_backend], + ) + backend_pos_before = torch.arange(2 * num_joints, dtype=torch.float32, device=device).reshape(2, num_joints) backend_vel_before = backend_pos_before + 100.0 articulation.root_view.set_attribute(TT.DOF_POSITION, wp.from_torch(backend_pos_before.contiguous())) @@ -971,212 +830,56 @@ def test_reversed_joint_ordering_joint_state_index_writes_backend_order(sim, sel if buffer is not None: buffer.timestamp = -1.0 - public_pos_before = articulation.data.joint_pos.torch.clone() - public_vel_before = articulation.data.joint_vel.torch.clone() - torch.testing.assert_close(public_pos_before, backend_pos_before[:, user_to_backend]) - torch.testing.assert_close(public_vel_before, backend_vel_before[:, user_to_backend]) - - if selection == "full": - position = torch.arange(2 * num_joints, dtype=torch.float32, device=device).reshape(2, num_joints) + 200.0 - velocity = position + 100.0 - env_ids = None - joint_ids = None - expected_public_pos = position - expected_public_vel = velocity - expected_backend_pos = position[:, backend_to_user] - expected_backend_vel = velocity[:, backend_to_user] - else: - position = torch.tensor([[201.0, 203.0]], device=device) - velocity = torch.tensor([[301.0, 303.0]], device=device) - env_ids = [1] - joint_ids = [0, 2] - expected_public_pos = public_pos_before.clone() - expected_public_vel = public_vel_before.clone() - expected_public_pos[1, joint_ids] = position[0] - expected_public_vel[1, joint_ids] = velocity[0] - expected_backend_pos = backend_pos_before.clone() - expected_backend_vel = backend_vel_before.clone() - backend_joint_ids = user_to_backend[joint_ids] - expected_backend_pos[1, backend_joint_ids] = position[0] - expected_backend_vel[1, backend_joint_ids] = velocity[0] - - articulation.write_joint_state_to_sim_index( - position=position, - velocity=velocity, - env_ids=env_ids, - joint_ids=joint_ids, - ) + torch.testing.assert_close(articulation.data.joint_pos.torch, backend_pos_before[:, user_to_backend]) + torch.testing.assert_close(articulation.data.joint_vel.torch, backend_vel_before[:, user_to_backend]) - torch.testing.assert_close(articulation.data.joint_pos.torch, expected_public_pos) - torch.testing.assert_close(articulation.data.joint_vel.torch, expected_public_vel) - torch.testing.assert_close( - _read_binding_to_torch(articulation, TT.DOF_POSITION, device), - expected_backend_pos, - ) - torch.testing.assert_close( - _read_binding_to_torch(articulation, TT.DOF_VELOCITY, device), - expected_backend_vel, - ) - - -@pytest.mark.parametrize("num_articulations", [1]) -# COM pose is a CPU-resident OVPhysX binding (``_CPU_ONLY_TYPES``) even on a GPU sim, and this test -# restores it via the low-level ``root_view.set_attribute`` which forbids cross-device staging, so it -# is inherently CPU-only (aligning it to ``cuda:0`` would fail on the CPU-native COM binding). -@pytest.mark.parametrize("device", ["cpu"]) -def test_live_panda_manual_body_ordering_preserves_unselected_coms(sim, num_articulations, device): - """Test that a partial ordered COM write preserves every unselected backend body.""" - articulation_cfg = FRANKA_PANDA_CFG.replace(body_ordering=PANDA_ROOT_PRESERVING_REVERSED_BODY_NAMES) - articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=device) - sim.reset() - - body_ordering = articulation.body_ordering - assert body_ordering is not None - backend_before = _read_binding_to_torch(articulation, TT.BODY_COM_POSE, device).clone() - assert torch.unique(backend_before[0], dim=0).shape[0] > 1 - - articulation.data._body_com_pose_b.timestamp = -1.0 - backend_staging = articulation.data._body_com_pose_b_backend - if backend_staging is not None: - backend_staging.timestamp = -1.0 - - public_body_id = 1 - backend_body_id = body_ordering.user_to_backend_indices[public_body_id] - assert backend_body_id != public_body_id - selected_com = backend_before[0, backend_body_id].clone() - selected_com[0] += 0.001 + def write_and_check(position, velocity, env_ids, joint_ids, expected_public_pos, expected_public_vel): + articulation.write_joint_state_to_sim_index( + position=position, + velocity=velocity, + env_ids=env_ids, + joint_ids=joint_ids, + ) + torch.testing.assert_close(articulation.data.joint_pos.torch, expected_public_pos) + torch.testing.assert_close(articulation.data.joint_vel.torch, expected_public_vel) + torch.testing.assert_close( + _read_binding_to_torch(articulation, TT.DOF_POSITION, device), + expected_public_pos[:, backend_to_user], + ) + torch.testing.assert_close( + _read_binding_to_torch(articulation, TT.DOF_VELOCITY, device), + expected_public_vel[:, backend_to_user], + ) - articulation.set_coms_index( - coms=wp.from_torch(selected_com.reshape(1, 1, 7).contiguous(), dtype=wp.transformf), + # Full write. + position = torch.arange(2 * num_joints, dtype=torch.float32, device=device).reshape(2, num_joints) + 200.0 + velocity = position + 100.0 + write_and_check(position, velocity, None, None, position, velocity) + + # Partial write keeps every unselected public and backend entry. + joint_ids = [0, 2] + expected_public_pos = position.clone() + expected_public_vel = velocity.clone() + partial_position = torch.tensor([[401.0, 403.0]], device=device) + partial_velocity = torch.tensor([[501.0, 503.0]], device=device) + expected_public_pos[1, joint_ids] = partial_position[0] + expected_public_vel[1, joint_ids] = partial_velocity[0] + write_and_check(partial_position, partial_velocity, [1], joint_ids, expected_public_pos, expected_public_vel) + + # A position-only partial write (a distinct kernel) preserves every unselected backend joint. + backend_before = wp.to_torch(articulation.root_view.get_attribute(TT.DOF_POSITION)).clone() + backend_joint_id = ordering.user_to_backend_indices[0] + selected_value = backend_before[0, backend_joint_id] + 0.001 + articulation.write_joint_position_to_sim_index( + position=selected_value.reshape(1, 1), env_ids=wp.array([0], dtype=wp.int32, device=device), - body_ids=wp.array([public_body_id], dtype=wp.int32, device=device), + joint_ids=wp.array([0], dtype=wp.int32, device=device), ) - - backend_after = _read_binding_to_torch(articulation, TT.BODY_COM_POSE, device).clone() - - articulation.root_view.set_attribute(TT.BODY_COM_POSE, wp.from_torch(backend_before.contiguous())) - noop_after = _read_binding_to_torch(articulation, TT.BODY_COM_POSE, device).clone() - unselected_body_mask = torch.ones(backend_before.shape[1], dtype=torch.bool, device=device) - unselected_body_mask[backend_body_id] = False - - assert torch.equal(noop_after[..., :3], backend_before[..., :3]) - assert torch.equal(backend_after[0, backend_body_id, :3], selected_com[:3]) - assert torch.equal(backend_after[0, unselected_body_mask, :3], backend_before[0, unselected_body_mask, :3]) - - # Bound semantic orientation equality by the native setter's float32 no-op normalization. - native_orientation_atol = torch.max( - torch.abs(noop_after[0, unselected_body_mask, 3:7] - backend_before[0, unselected_body_mask, 3:7]) - ).item() - assert native_orientation_atol <= torch.finfo(backend_before.dtype).eps + expected_backend = backend_before.clone() + expected_backend[0, backend_joint_id] = selected_value torch.testing.assert_close( - backend_after[0, unselected_body_mask, 3:7], - backend_before[0, unselected_body_mask, 3:7], - rtol=0.0, - atol=native_orientation_atol, + wp.to_torch(articulation.root_view.get_attribute(TT.DOF_POSITION)), expected_backend, rtol=0.0, atol=0.0 ) - assert torch.equal(backend_after[..., 3:7], noop_after[..., 3:7]) - - -@pytest.mark.parametrize("num_articulations", [1]) -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -def test_reversed_body_ordering_wrench_composes_from_backend_pose_without_shadow_refresh( - sim, num_articulations, device -): - """Reversed body ordering: an external wrench composes from the backend-order link pose and - ``write_data_to_sim`` no longer refreshes the public ``body_link_pose_w`` shadow. - """ - articulation_cfg = FRANKA_PANDA_CFG.replace(body_ordering=PANDA_ROOT_PRESERVING_REVERSED_BODY_NAMES) - articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=device) - sim.reset() - - body_ordering = articulation.body_ordering - assert body_ordering is not None - - # Apply a body-frame wrench to a single named body (public order). - body_ids, _ = articulation.find_bodies("panda_hand") - public_body_id = body_ids[0] - backend_body_id = int(body_ordering.user_to_backend_indices[public_body_id]) - assert backend_body_id != public_body_id # exercises the reorder - - force_b = torch.zeros(articulation.num_instances, len(body_ids), 3, device=device) - torque_b = torch.zeros(articulation.num_instances, len(body_ids), 3, device=device) - force_b[..., 0], force_b[..., 1], force_b[..., 2] = 3.0, -5.0, 7.0 - torque_b[..., 0], torque_b[..., 1], torque_b[..., 2] = 0.5, -1.5, 2.5 - articulation.permanent_wrench_composer.set_forces_and_torques_index( - forces=force_b, torques=torque_b, body_ids=body_ids - ) - - # Step once so the link poses are non-trivial (rotated), giving the quaternion rotation teeth. - articulation.set_joint_position_target_index(target=articulation.data.default_joint_pos.torch.clone()) - articulation.write_data_to_sim() - sim.step() - articulation.update(sim.cfg.dt) - - # write_data_to_sim must NOT advance the public body_link_pose_w shadow timestamp: the wrench - # path now reads the backend-order pose buffer instead of refreshing the public shadow. - shadow_ts_before = articulation.data._body_link_pose_w.timestamp - articulation.write_data_to_sim() - assert articulation.data._body_link_pose_w.timestamp == shadow_ts_before - - # The wrench buffer is in backend order; the world-frame wrench must match the one composed - # from the SAME physical body's pose read via the public (user-order) shadow. - wrench_buf = wp.to_torch(articulation._wrench_buf).to(device) - pose = articulation.data.body_link_pose_w.torch[0, public_body_id] # user order, [pos(3), quat_xyzw(4)] - quat_xyzw = pose[3:7] - expected_force_w = math_utils.quat_apply(quat_xyzw, force_b[0, 0]) - expected_torque_w = math_utils.quat_apply(quat_xyzw, torque_b[0, 0]) - torch.testing.assert_close(wrench_buf[0, backend_body_id, 0:3], expected_force_w, rtol=1e-4, atol=1e-4) - torch.testing.assert_close(wrench_buf[0, backend_body_id, 3:6], expected_torque_w, rtol=1e-4, atol=1e-4) - torch.testing.assert_close(wrench_buf[0, backend_body_id, 6:9], pose[0:3], rtol=1e-4, atol=1e-4) - - -@pytest.mark.parametrize("num_articulations", [1]) -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -def test_reversed_joint_ordering_joint_acc_matches_canonicalized_finite_difference(sim, num_articulations, device): - """Reversed joint ordering: the fused ``joint_acc`` finite difference reads the backend-order - velocity source and equals the identity-order acceleration permuted into public order. - """ - articulation_cfg = generate_articulation_cfg("anymal").replace( - joint_ordering=tuple(reversed(ANYMAL_C_PHYSX_JOINT_NAMES)) - ) - articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=device) - sim.reset() - data = articulation.data - - joint_ordering = articulation.joint_ordering - assert joint_ordering is not None - num_joints = articulation.num_joints - - user_to_backend = torch.as_tensor( - [int(joint_ordering.user_to_backend_indices[u]) for u in range(num_joints)], - dtype=torch.long, - device=device, - ) - - # Controlled, non-uniform finite-difference scenario in backend order (so a wrong permutation - # in the kernel would produce a different result -- i.e. the test has teeth). - cur_vel_backend = (torch.arange(1, num_joints + 1, dtype=torch.float32, device=device) * 0.1).reshape(1, -1) - prev_vel_backend = (torch.arange(1, num_joints + 1, dtype=torch.float32, device=device) * -0.03).reshape(1, -1) - - # Push the current velocity into the backend DOF_VELOCITY binding (backend order). - articulation.root_view.set_attribute(TT.DOF_VELOCITY, wp.from_torch(cur_vel_backend.contiguous())) - - # ``_previous_joint_vel`` is stored in PUBLIC order: prev_user[u] = prev_backend[map[u]]. - prev_vel_user = prev_vel_backend[:, user_to_backend].contiguous() - data._previous_joint_vel.assign(wp.from_torch(prev_vel_user)) - - # Force a stale finite-difference state with a known dt so the ordered branch recomputes, and a - # stale backend velocity staging so it re-reads the value we just set. - dt = 0.02 - data._joint_acc.timestamp = data._sim_timestamp - dt - data._joint_vel_backend.timestamp = -1.0 - - joint_acc_user = data.joint_acc.torch.clone() - - # Expected identity-order acceleration, then permuted into public order. - acc_backend = (cur_vel_backend - prev_vel_backend) / dt - expected_user = acc_backend[:, user_to_backend] - torch.testing.assert_close(joint_acc_user, expected_user, rtol=1e-5, atol=1e-6) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @@ -1215,43 +918,6 @@ def test_branching_fixture_physx_ordering_is_identity_on_ovphysx(sim, device): assert articulation.body_ordering is None -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -def test_branching_fixture_mjwarp_ordering_reorders_ovphysx_to_dfs(sim, device): - """Resolve depth-first MJWarp order cross-backend for ``joint_ordering="mjwarp"`` on OVPhysX. - - Live coverage for the cross-backend symbolic-convention path: OVPhysX is a - PhysX-family backend (native breadth-first order), so requesting ``mjwarp`` triggers a temporary - Newton USD discovery of the depth-first order and reorders the public joint/body axes to it. The - MJWarp/DFS ground truth is the same tuple isaaclab_newton's - ``test_mjwarp_ordering_resolver_matches_newton_backend_names`` pins for its live Newton backend. - """ - articulation = Articulation( - ArticulationCfg( - prim_path="/World/Robot", - spawn=sim_utils.UsdFileCfg( - usd_path=str(Path(__file__).parent / "data" / "articulation_ordering_branching.usda") - ), - actuators={}, - joint_ordering="mjwarp", - body_ordering="mjwarp", - ) - ) - sim.reset() - assert articulation.is_initialized - - # OVPhysX exposes the native breadth-first PhysX order on the backend axis. - assert tuple(articulation.backend_joint_names) == BRANCHING_PHYSX_JOINT_NAMES - assert tuple(articulation.backend_body_names) == BRANCHING_PHYSX_BODY_NAMES - - # Cross-backend Newton discovery resolves the depth-first MJWarp order and reorders the public axis. - assert get_articulation_name_ordering(articulation, "mjwarp", kind="joint") == BRANCHING_MJWARP_JOINT_NAMES - assert get_articulation_name_ordering(articulation, "mjwarp", kind="body") == BRANCHING_MJWARP_BODY_NAMES - assert tuple(articulation.joint_names) == BRANCHING_MJWARP_JOINT_NAMES - assert tuple(articulation.body_names) == BRANCHING_MJWARP_BODY_NAMES - assert articulation.joint_ordering is not None - assert articulation.body_ordering is not None - - @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) def test_articulation_dynamics_fixed_base_match_raw_ovphysx_bindings(sim, device): """Expose fixed-base computed dynamics through the backend-agnostic data API.""" @@ -1305,17 +971,8 @@ def test_articulation_dynamics_fixed_base_match_raw_ovphysx_bindings(sim, device expected_link_velocity, articulation.data.body_link_vel_w.torch[:, 1:], atol=1e-5, rtol=1e-4 ) - -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -def test_articulation_dynamics_refresh_after_same_timestamp_model_writes(sim, device): - """Refresh computed dynamics after model-property writes without advancing simulation time.""" - articulation, _ = generate_articulation(generate_articulation_cfg("panda"), 1, device=device) - sim.reset() - + # Model-property writes refresh the computed dynamics without advancing simulation time. data = articulation.data - num_jacobian_bodies = articulation.num_bodies - 1 - num_generalized_dofs = articulation.num_joints - # COM offsets affect COM Jacobians, mass matrices, and gravity forces. data.body_com_jacobian_w data.mass_matrix @@ -1364,7 +1021,13 @@ def test_articulation_dynamics_refresh_after_same_timestamp_model_writes(sim, de @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) def test_articulation_dynamics_reorder_body_rows_and_joint_axes(sim, device): - """Gather computed dynamics into MJWarp body and joint order.""" + """Resolve depth-first MJWarp order for ``"mjwarp"`` on OVPhysX and gather computed dynamics into it. + + OVPhysX is a PhysX-family backend (native breadth-first order), so requesting ``mjwarp`` triggers a + temporary Newton USD discovery of the depth-first order and reorders the public joint/body axes to it. + The MJWarp/DFS ground truth is the same tuple isaaclab_newton's + ``test_mjwarp_ordering_resolver_matches_newton_backend_names`` pins for its live Newton backend. + """ articulation = Articulation( ArticulationCfg( prim_path="/World/Robot", @@ -1377,6 +1040,17 @@ def test_articulation_dynamics_reorder_body_rows_and_joint_axes(sim, device): ) ) sim.reset() + assert articulation.is_initialized + + # OVPhysX exposes the native breadth-first PhysX order on the backend axis. + assert tuple(articulation.backend_joint_names) == BRANCHING_PHYSX_JOINT_NAMES + assert tuple(articulation.backend_body_names) == BRANCHING_PHYSX_BODY_NAMES + + # Cross-backend Newton discovery resolves the depth-first MJWarp order and reorders the public axis. + assert get_articulation_name_ordering(articulation, "mjwarp", kind="joint") == BRANCHING_MJWARP_JOINT_NAMES + assert get_articulation_name_ordering(articulation, "mjwarp", kind="body") == BRANCHING_MJWARP_BODY_NAMES + assert tuple(articulation.joint_names) == BRANCHING_MJWARP_JOINT_NAMES + assert tuple(articulation.body_names) == BRANCHING_MJWARP_BODY_NAMES joint_ordering = articulation.joint_ordering body_ordering = articulation.body_ordering @@ -1417,45 +1091,7 @@ def test_articulation_dynamics_reorder_body_rows_and_joint_axes(sim, device): torch.testing.assert_close(articulation.data.gravity_compensation_forces.torch, expected_gravity) -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -def test_articulation_dynamics_preserve_floating_base_columns_during_joint_reordering(sim, device): - """Keep floating-base columns leading while gathering actuated-joint axes.""" - articulation_cfg = generate_articulation_cfg("anymal").replace( - joint_ordering=tuple(reversed(ANYMAL_C_PHYSX_JOINT_NAMES)) - ) - articulation, _ = generate_articulation(articulation_cfg, 1, device=device) - sim.reset() - - joint_ordering = articulation.joint_ordering - assert joint_ordering is not None - joint_user_to_backend = torch.as_tensor(joint_ordering.user_to_backend_indices, dtype=torch.long, device=device) - generalized_user_to_backend = torch.cat( - ( - torch.arange(6, device=device), - 6 + joint_user_to_backend, - ) - ) - raw_jacobian = _read_binding_to_torch(articulation, TT.JACOBIAN, device).reshape( - 1, articulation.num_bodies, 6, articulation.num_joints + 6 - ) - raw_mass_matrix = _read_binding_to_torch(articulation, TT.MASS_MATRIX, device) - raw_gravity = _read_binding_to_torch(articulation, TT.GRAVITY_FORCE, device) - - torch.testing.assert_close( - articulation.data.body_com_jacobian_w.torch, - raw_jacobian[:, :, :, generalized_user_to_backend], - ) - torch.testing.assert_close( - articulation.data.mass_matrix.torch, - raw_mass_matrix[:, generalized_user_to_backend, :][:, :, generalized_user_to_backend], - ) - torch.testing.assert_close( - articulation.data.gravity_compensation_forces.torch, - raw_gravity[:, generalized_user_to_backend], - ) - - -@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("num_articulations", [2]) @pytest.mark.parametrize("device", test_devices()) @pytest.mark.parametrize("add_ground_plane", [True]) def test_initialization_floating_base_non_root(sim, num_articulations, device, add_ground_plane): @@ -1520,7 +1156,7 @@ def test_initialization_floating_base_non_root(sim, num_articulations, device, a articulation.update(sim.cfg.dt) -@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("num_articulations", [2]) @pytest.mark.parametrize("device", test_devices()) @pytest.mark.parametrize("add_ground_plane", [True]) def test_initialization_floating_base(sim, num_articulations, device, add_ground_plane): @@ -1585,7 +1221,7 @@ def test_initialization_floating_base(sim, num_articulations, device, add_ground articulation.update(sim.cfg.dt) -@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("num_articulations", [2]) @pytest.mark.parametrize("device", test_devices()) def test_initialization_fixed_base(sim, num_articulations, device): """Test initialization for fixed base. @@ -1660,7 +1296,7 @@ def test_initialization_fixed_base(sim, num_articulations, device): torch.testing.assert_close(articulation.data.root_com_vel_w.torch, default_root_vel) -@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("num_articulations", [2]) @pytest.mark.parametrize("device", test_devices()) @pytest.mark.parametrize("add_ground_plane", [True]) def test_initialization_fixed_base_single_joint(sim, num_articulations, device, add_ground_plane): @@ -1733,21 +1369,13 @@ def test_initialization_fixed_base_single_joint(sim, num_articulations, device, torch.testing.assert_close(articulation.data.root_com_vel_w.torch, default_root_vel) -@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("num_articulations", [2]) @pytest.mark.parametrize("device", test_devices()) -def test_initialization_hand_with_tendons(sim, num_articulations, device): - """Test initialization for fixed base articulated hand with tendons. - - This test verifies that: - 1. The articulation is properly initialized - 2. The articulation is fixed base - 3. All buffers have correct shapes - 4. The articulation can be simulated +def test_fixed_tendon_position_target_writes_offset(sim, num_articulations, device): + """Initialize the fixed-base tendon hand and land tendon length targets on the selected cells only. - Args: - sim: The simulation fixture - num_articulations: Number of articulations to test - device: The device to run the simulation on + A target lands in the simulation as ``rest_length - target``. The index form commands every tendon of + environment 0; the mask form commands tendon 0 of environment 1. Every other cell must keep its initial offset. """ articulation_cfg = generate_articulation_cfg(articulation_type="shadow_hand") articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=device) @@ -1755,23 +1383,14 @@ def test_initialization_hand_with_tendons(sim, num_articulations, device): # Check that the framework doesn't hold excessive strong references. assert sys.getrefcount(articulation) < 10 - # Play sim sim.reset() - # Check if articulation is initialized assert articulation.is_initialized - # Check that fixed base assert articulation.is_fixed_base - # Check buffers that exists and have correct shapes assert articulation.data.root_pos_w.torch.shape == (num_articulations, 3) assert articulation.data.root_quat_w.torch.shape == (num_articulations, 4) assert articulation.data.joint_pos.torch.shape == (num_articulations, 24) assert articulation.data.body_mass.torch.shape == (num_articulations, articulation.num_bodies) assert articulation.data.body_inertia.torch.shape == (num_articulations, articulation.num_bodies, 9) - - # Cross-check binding shapes against cached counts. See the equivalent - # block in test_initialization_fixed_base_single_joint for why the verbatim - # PhysX ``root_view.max_dofs == shared_metatype.dof_count`` identity is - # replaced with binding-shape checks on OVPhysX. for tt in (TT.DOF_POSITION, TT.DOF_VELOCITY, TT.DOF_STIFFNESS): binding = articulation.root_view.try_binding_for(tt) if binding is not None: @@ -1780,32 +1399,10 @@ def test_initialization_hand_with_tendons(sim, num_articulations, device): binding = articulation.root_view.try_binding_for(tt) if binding is not None: assert binding.shape[1] == articulation.num_bodies - # -- actuator type for actuator_name, actuator in articulation.actuators.items(): is_implicit_model_cfg = isinstance(articulation_cfg.actuators[actuator_name], ImplicitActuatorCfg) assert actuator.is_implicit_model == is_implicit_model_cfg - # Simulate physics - for _ in range(10): - # perform rendering - sim.step() - # update articulation - articulation.update(sim.cfg.dt) - - -@pytest.mark.parametrize("num_articulations", [2]) -@pytest.mark.parametrize("device", test_devices()) -def test_fixed_tendon_position_target_writes_offset(sim, num_articulations, device): - """A tendon length target lands in the simulation as ``rest_length - target`` on the selected cells only. - - The index form commands every tendon of environment 0; the mask form commands tendon 0 of - environment 1. Every other cell must keep its initial offset. - """ - articulation_cfg = generate_articulation_cfg(articulation_type="shadow_hand") - articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=device) - - sim.reset() - assert articulation.is_initialized num_tendons = articulation.num_fixed_tendons assert num_tendons > 0 rest_length = articulation.data.fixed_tendon_rest_length.torch.clone() @@ -1836,10 +1433,9 @@ def test_fixed_tendon_position_target_writes_offset(sim, num_articulations, devi torch.testing.assert_close(articulation.data.fixed_tendon_offset.torch, expected) -@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("num_articulations", [2]) @pytest.mark.parametrize("device", test_devices()) @pytest.mark.parametrize("add_ground_plane", [True]) -@pytest.mark.xfail(reason=_OMNI_PHYSX_SCHEMAS_GAP_REASON, strict=False) def test_initialization_floating_base_made_fixed_base(sim, num_articulations, device, add_ground_plane): """Test initialization for a floating-base articulation made fixed-base using schema properties. @@ -1946,7 +1542,7 @@ def test_fragment_fix_root_reenables_existing_joint(sim, device): assert articulation.is_fixed_base -@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("num_articulations", [2]) @pytest.mark.parametrize("device", test_devices()) @pytest.mark.parametrize("add_ground_plane", [True]) def test_initialization_fixed_base_made_floating_base(sim, num_articulations, device, add_ground_plane): @@ -1962,7 +1558,8 @@ def test_initialization_fixed_base_made_floating_base(sim, num_articulations, de sim: The simulation fixture num_articulations: Number of articulations to test """ - articulation_cfg = generate_articulation_cfg(articulation_type="panda") + # Copy so the shared panda cfg stays fixed-base for later tests. + articulation_cfg = generate_articulation_cfg(articulation_type="panda").copy() # Unfix root link by making it non-kinematic articulation_cfg.spawn.fix_root_link = False articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=sim.device) @@ -2006,7 +1603,7 @@ def test_initialization_fixed_base_made_floating_base(sim, num_articulations, de articulation.update(sim.cfg.dt) -@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("num_articulations", [2]) @pytest.mark.parametrize("device", test_devices()) @pytest.mark.parametrize("add_ground_plane", [True]) def test_out_of_range_default_joint_pos(sim, num_articulations, device, add_ground_plane): @@ -2060,7 +1657,7 @@ def test_out_of_range_default_joint_vel(sim, device): sim.reset() -@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("num_articulations", [2]) @pytest.mark.parametrize("device", test_devices()) @pytest.mark.parametrize("add_ground_plane", [True]) def test_joint_pos_limits(sim, num_articulations, device, add_ground_plane): @@ -2127,132 +1724,15 @@ def test_joint_pos_limits(sim, num_articulations, device, add_ground_plane): limits[..., 1] = torch.rand(env_ids.shape[0], joint_ids.shape[0], device=device) * 0.1 articulation.write_joint_position_limit_to_sim_index(limits=limits, env_ids=env_ids, joint_ids=joint_ids) - # Check if all values are within the bounds - default_joint_pos_torch = articulation._data.default_joint_pos.torch - within_bounds = (default_joint_pos_torch[env_ids][:, joint_ids] >= limits[..., 0]) & ( - default_joint_pos_torch[env_ids][:, joint_ids] <= limits[..., 1] - ) - assert torch.all(within_bounds) - - -@pytest.mark.parametrize("num_articulations", [1, 2]) -@pytest.mark.parametrize("device", test_devices()) -@pytest.mark.parametrize("add_ground_plane", [True]) -def test_joint_effort_limits(sim, num_articulations, device, add_ground_plane): - """Validate joint effort limits via joint_effort_out_of_limit().""" - # Create articulation - articulation_cfg = generate_articulation_cfg(articulation_type="panda") - articulation, _ = generate_articulation(articulation_cfg, num_articulations, device) - - # Minimal env wrapper exposing scene["robot"] - class _Env: - def __init__(self, art): - self.scene = {"robot": art} - - env = _Env(articulation) - robot_all = SceneEntityCfg(name="robot") - - sim.reset() - assert articulation.is_initialized - - # Case A: no clipping → should NOT terminate - articulation._data.computed_torque.torch.zero_() - articulation._data.applied_torque.torch.zero_() - out = joint_effort_out_of_limit(env, robot_all) # [N] - assert torch.all(~out) - - # Case B: simulate clipping → should terminate - articulation._data.computed_torque.torch.fill_(100.0) # pretend controller commanded 100 - articulation._data.applied_torque.torch.fill_(50.0) # pretend actuator clipped to 50 - out = joint_effort_out_of_limit(env, robot_all) # [N] - assert torch.all(out) - - -@pytest.mark.parametrize("num_articulations", [1, 2]) -@pytest.mark.parametrize("device", test_devices()) -def test_external_force_buffer(sim, num_articulations, device): - """Test if external force buffer correctly updates in the force value is zero case. - - This test verifies that: - 1. External forces can be applied correctly - 2. Force buffers are updated properly - 3. Zero forces are handled correctly - - Args: - sim: The simulation fixture - num_articulations: Number of articulations to test - """ - articulation_cfg = generate_articulation_cfg(articulation_type="anymal") - articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=sim.device) - - # play the simulator - sim.reset() - - # find bodies to apply the force - body_ids, _ = articulation.find_bodies("base") - - # reset root state - articulation.write_root_pose_to_sim_index(root_pose=articulation.data.default_root_pose.torch.clone()) - articulation.write_root_velocity_to_sim_index(root_velocity=articulation.data.default_root_vel.torch.clone()) - - # reset dof state - joint_pos, joint_vel = ( - articulation.data.default_joint_pos.torch, - articulation.data.default_joint_vel.torch, - ) - articulation.write_joint_position_to_sim_index(position=joint_pos) - articulation.write_joint_velocity_to_sim_index(velocity=joint_vel) - - # reset articulation - articulation.reset() - - # perform simulation - for step in range(5): - # initiate force tensor - external_wrench_b = torch.zeros(articulation.num_instances, len(body_ids), 6, device=sim.device) - - if step == 0 or step == 3: - # set a non-zero force - force = 1 - else: - # set a zero force - force = 0 - - # set force value - external_wrench_b[:, :, 0] = force - external_wrench_b[:, :, 3] = force - - # apply force - articulation.permanent_wrench_composer.set_forces_and_torques_index( - forces=external_wrench_b[..., :3], - torques=external_wrench_b[..., 3:], - body_ids=body_ids, - ) - - # check if the articulation's force and torque buffers are correctly updated - for i in range(num_articulations): - assert articulation.permanent_wrench_composer.composed_force.torch[i, 0, 0].item() == force - assert articulation.permanent_wrench_composer.composed_torque.torch[i, 0, 0].item() == force - - # Check if the instantaneous wrench is correctly added to the permanent wrench - articulation.instantaneous_wrench_composer.add_forces_and_torques_index( - forces=external_wrench_b[..., :3], - torques=external_wrench_b[..., 3:], - body_ids=body_ids, - ) - - # apply action to the articulation - articulation.set_joint_position_target_index(target=articulation.data.default_joint_pos.torch.clone()) - articulation.write_data_to_sim() - - # perform step - sim.step() - - # update buffers - articulation.update(sim.cfg.dt) + # Check if all values are within the bounds + default_joint_pos_torch = articulation._data.default_joint_pos.torch + within_bounds = (default_joint_pos_torch[env_ids][:, joint_ids] >= limits[..., 0]) & ( + default_joint_pos_torch[env_ids][:, joint_ids] <= limits[..., 1] + ) + assert torch.all(within_bounds) -@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("num_articulations", [2]) @pytest.mark.parametrize("device", test_devices()) def test_external_force_on_single_body(sim, num_articulations, device): """Test application of external force on the base of the articulation. @@ -2309,17 +1789,19 @@ def test_external_force_on_single_body(sim, num_articulations, device): assert articulation.data.root_pos_w.torch[i, 2].item() < 0.2 -@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("num_articulations", [2]) @pytest.mark.parametrize("device", test_devices()) def test_external_force_on_single_body_at_position(sim, num_articulations, device): """Test application of external force on the base of the articulation at a given position. This test verifies that: 1. External forces can be applied to specific bodies at a given position - 2. External forces can be applied to specific bodies in the global frame - 3. External forces are calculated and composed correctly - 4. The forces affect the articulation's motion correctly - 5. The articulation responds to the forces as expected + 2. External forces are calculated and composed correctly + 3. The forces affect the articulation's motion correctly + 4. The articulation responds to the forces as expected + + The global-frame position path is covered by :func:`test_external_force_on_multiple_bodies_at_position`; + a fixed world-frame force here would lift the robot instead of tipping it over. Args: sim: The simulation fixture @@ -2403,7 +1885,7 @@ def test_external_force_on_single_body_at_position(sim, num_articulations, devic assert articulation.data.root_pos_w.torch[i, 2].item() < 0.2 -@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("num_articulations", [2]) @pytest.mark.parametrize("device", test_devices()) def test_external_force_on_multiple_bodies(sim, num_articulations, device): """Test application of external force on the legs of the articulation. @@ -2462,7 +1944,7 @@ def test_external_force_on_multiple_bodies(sim, num_articulations, device): assert articulation.data.root_ang_vel_w.torch[i, 2].item() > 0.1 -@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("num_articulations", [2]) @pytest.mark.parametrize("device", test_devices()) def test_external_force_on_multiple_bodies_at_position(sim, num_articulations, device): """Test application of external force on the legs of the articulation at a given position. @@ -2557,7 +2039,7 @@ def test_external_force_on_multiple_bodies_at_position(sim, num_articulations, d assert torch.linalg.vector_norm(articulation.data.root_ang_vel_w.torch[i]).item() > 0.1 -@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("num_articulations", [2]) @pytest.mark.parametrize("device", test_devices()) def test_loading_gains_from_usd(sim, num_articulations, device): """Test that gains are loaded from USD file if actuator model has them as None. @@ -2618,86 +2100,22 @@ def test_loading_gains_from_usd(sim, num_articulations, device): torch.testing.assert_close(articulation.actuators["body"].damping, expected_damping) -@pytest.mark.parametrize("num_articulations", [1, 2]) -@pytest.mark.parametrize("device", test_devices()) -@pytest.mark.parametrize("add_ground_plane", [True]) -def test_setting_gains_from_cfg(sim, num_articulations, device, add_ground_plane): - """Test that gains are loaded from the configuration correctly. - - This test verifies that: - 1. Gains are loaded correctly from configuration - 2. The gains match the expected values - 3. The gains are applied correctly to the actuators - - Args: - sim: The simulation fixture - num_articulations: Number of articulations to test - """ - articulation_cfg = generate_articulation_cfg(articulation_type="humanoid") - articulation, _ = generate_articulation( - articulation_cfg=articulation_cfg, num_articulations=num_articulations, device=sim.device - ) - - # Play sim - sim.reset() - - # Expected gains - expected_stiffness = torch.full( - (articulation.num_instances, articulation.num_joints), 10.0, device=articulation.device - ) - expected_damping = torch.full_like(expected_stiffness, 2.0) - - # Check that gains are loaded from USD file - torch.testing.assert_close(articulation.actuators["body"].stiffness, expected_stiffness) - torch.testing.assert_close(articulation.actuators["body"].damping, expected_damping) - - -@pytest.mark.parametrize("num_articulations", [1, 2]) -@pytest.mark.parametrize("device", test_devices()) -def test_setting_gains_from_cfg_dict(sim, num_articulations, device): - """Test that gains are loaded from the configuration dictionary correctly. - - This test verifies that: - 1. Gains are loaded correctly from configuration dictionary - 2. The gains match the expected values - 3. The gains are applied correctly to the actuators - - Args: - sim: The simulation fixture - num_articulations: Number of articulations to test - """ - articulation_cfg = generate_articulation_cfg(articulation_type="humanoid") - articulation, _ = generate_articulation( - articulation_cfg=articulation_cfg, num_articulations=num_articulations, device=sim.device - ) - # Play sim - sim.reset() - - # Expected gains - expected_stiffness = torch.full( - (articulation.num_instances, articulation.num_joints), 10.0, device=articulation.device - ) - expected_damping = torch.full_like(expected_stiffness, 2.0) - - # Check that gains are loaded from USD file - torch.testing.assert_close(articulation.actuators["body"].stiffness, expected_stiffness) - torch.testing.assert_close(articulation.actuators["body"].damping, expected_damping) - - @pytest.mark.parametrize("device", test_devices()) -@pytest.mark.parametrize("joint_velocity_limit", [1e5, None]) -def test_setting_velocity_limit_writes_to_solver(sim, device, joint_velocity_limit): - """Test that the resolved joint velocity limit reaches the PhysX tensor-API solver. +@pytest.mark.parametrize("joint_limit", [1e5, None]) +def test_setting_velocity_and_effort_limits_write_to_solver(sim, device, joint_limit): + """Test that the resolved joint velocity and effort limits reach the PhysX tensor-API solver. The full limit-resolution matrix (config override vs. USD default, implicit and explicit actuators, actuator-limit soft fallback) is covered on the Newton backend and at unit - level. This smoke test only verifies the PhysX tensor-API write path: the configured limit (or the - USD-authored default when unset) lands in the native solver buffers and matches - ``data.joint_vel_limits``. + level. This smoke test only verifies the PhysX tensor-API write path: the configured limits (or the + USD-authored defaults when unset) land in the native solver buffers and match + ``data.joint_vel_limits`` and ``data.joint_effort_limits``. """ + joint_velocity_limit = joint_effort_limit = joint_limit articulation_cfg = generate_articulation_cfg( articulation_type="single_joint_implicit", joint_velocity_limit=joint_velocity_limit, + joint_effort_limit=joint_effort_limit, ) articulation, _ = generate_articulation( articulation_cfg=articulation_cfg, @@ -2721,30 +2139,6 @@ def test_setting_velocity_limit_writes_to_solver(sim, device, joint_velocity_lim expected_velocity_limit = torch.full_like(physx_vel_limit, limit) torch.testing.assert_close(physx_vel_limit, expected_velocity_limit) - -@pytest.mark.parametrize("device", test_devices()) -@pytest.mark.parametrize("joint_effort_limit", [1e5, None]) -def test_setting_effort_limit_writes_to_solver(sim, device, joint_effort_limit): - """Test that the resolved joint effort limit reaches the PhysX tensor-API solver. - - The full limit-resolution matrix (config override vs. USD default, implicit and explicit - actuators, actuator-limit soft fallback) is covered on the Newton backend and at unit - level. This smoke test only verifies the PhysX tensor-API write path: the configured limit (or the - USD-authored default when unset) lands in the native solver buffers and matches - ``data.joint_effort_limits``. - """ - articulation_cfg = generate_articulation_cfg( - articulation_type="single_joint_implicit", - joint_effort_limit=joint_effort_limit, - ) - articulation, _ = generate_articulation( - articulation_cfg=articulation_cfg, - num_articulations=1, - device=device, - ) - # Play sim - sim.reset() - # obtain the physx effort limits physx_effort_limit = _read_binding_to_torch(articulation, TT.DOF_MAX_FORCE, device) # check data buffer @@ -2760,7 +2154,7 @@ def test_setting_effort_limit_writes_to_solver(sim, device, joint_effort_limit): torch.testing.assert_close(physx_effort_limit, expected_effort_limit) -@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("num_articulations", [2]) @pytest.mark.parametrize("device", test_devices()) def test_reset(sim, num_articulations, device): """Test that reset method works properly.""" @@ -2784,56 +2178,25 @@ def test_reset(sim, num_articulations, device): assert torch.count_nonzero(articulation._permanent_wrench_composer.composed_force.torch) == 0 assert torch.count_nonzero(articulation._permanent_wrench_composer.composed_torque.torch) == 0 - if num_articulations > 1: - num_bodies = articulation.num_bodies - articulation.permanent_wrench_composer.set_forces_and_torques_index( - forces=torch.ones((num_articulations, num_bodies, 3), device=device), - torques=torch.ones((num_articulations, num_bodies, 3), device=device), - ) - articulation.instantaneous_wrench_composer.add_forces_and_torques_index( - forces=torch.ones((num_articulations, num_bodies, 3), device=device), - torques=torch.ones((num_articulations, num_bodies, 3), device=device), - ) - articulation.reset(env_ids=torch.tensor([0], device=device)) - assert articulation._instantaneous_wrench_composer.active - assert articulation._permanent_wrench_composer.active - assert torch.count_nonzero(articulation._instantaneous_wrench_composer.composed_force.torch) == num_bodies * 3 - assert torch.count_nonzero(articulation._instantaneous_wrench_composer.composed_torque.torch) == num_bodies * 3 - assert torch.count_nonzero(articulation._permanent_wrench_composer.composed_force.torch) == num_bodies * 3 - assert torch.count_nonzero(articulation._permanent_wrench_composer.composed_torque.torch) == num_bodies * 3 - - -@pytest.mark.parametrize("num_articulations", [1, 2]) -@pytest.mark.parametrize("device", test_devices()) -def test_write_root_velocity_invalidates_body_frame_cache(sim, num_articulations, device): - """Writing root velocity refreshes cached body-frame root velocities before a step.""" - articulation_cfg = generate_articulation_cfg(articulation_type="humanoid") - articulation, _ = generate_articulation(articulation_cfg, num_articulations, device) - - sim.reset() - for _ in range(3): - sim.step() - articulation.update(sim.cfg.dt) - ang_before = articulation.data.root_ang_vel_b.torch.clone() - - new_vel = torch.zeros(num_articulations, 6, device=device) - new_vel[:, :3] = torch.tensor([3.0, 0.0, 0.0], device=device) - new_vel[:, 3:] = torch.tensor([0.0, 0.0, 5.0], device=device) - articulation.write_root_velocity_to_sim_index( - root_velocity=wp.from_torch(new_vel.contiguous(), dtype=wp.spatial_vectorf) + num_bodies = articulation.num_bodies + articulation.permanent_wrench_composer.set_forces_and_torques_index( + forces=torch.ones((num_articulations, num_bodies, 3), device=device), + torques=torch.ones((num_articulations, num_bodies, 3), device=device), ) - - ang_after = articulation.data.root_ang_vel_b.torch - torch.testing.assert_close( - ang_after.norm(dim=-1), - torch.full((num_articulations,), 5.0, device=device), - atol=1e-3, - rtol=1e-3, + articulation.instantaneous_wrench_composer.add_forces_and_torques_index( + forces=torch.ones((num_articulations, num_bodies, 3), device=device), + torques=torch.ones((num_articulations, num_bodies, 3), device=device), ) - assert not torch.allclose(ang_after, ang_before) + articulation.reset(env_ids=torch.tensor([0], device=device)) + assert articulation._instantaneous_wrench_composer.active + assert articulation._permanent_wrench_composer.active + assert torch.count_nonzero(articulation._instantaneous_wrench_composer.composed_force.torch) == num_bodies * 3 + assert torch.count_nonzero(articulation._instantaneous_wrench_composer.composed_torque.torch) == num_bodies * 3 + assert torch.count_nonzero(articulation._permanent_wrench_composer.composed_force.torch) == num_bodies * 3 + assert torch.count_nonzero(articulation._permanent_wrench_composer.composed_torque.torch) == num_bodies * 3 -@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("num_articulations", [2]) @pytest.mark.parametrize("device", test_devices()) @pytest.mark.parametrize("add_ground_plane", [True]) def test_apply_joint_command(sim, num_articulations, device, add_ground_plane): @@ -2866,28 +2229,26 @@ def test_apply_joint_command(sim, num_articulations, device, add_ground_plane): # update buffers articulation.update(sim.cfg.dt) - # Check that current joint position is not the same as default joint position, meaning - # the articulation moved. We can't check that it reached its desired joint position as the gains - # are not properly tuned - assert not torch.allclose(articulation.data.joint_pos.torch, joint_pos) + # The elbow, wrist, and finger joints track the commanded target. The shoulder joints sag under + # gravity with these gains, so they are not checked. Without the target write, the drives pull + # every joint toward zero instead (the wrist ends near 0.3 rad instead of 3.0 rad). + torch.testing.assert_close(articulation.data.joint_pos.torch[:, 3:], joint_pos[:, 3:], atol=0.1, rtol=0.0) -@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("num_articulations", [2]) @pytest.mark.parametrize("device", test_devices()) -@pytest.mark.parametrize("with_offset", [True, False]) -def test_body_root_state(sim, num_articulations, device, with_offset): +def test_body_root_state(sim, num_articulations, device): """Test for reading the `body_state_w` property. This test verifies that: 1. Body states can be read correctly - 2. States are correct with and without offsets + 2. States are correct with a COM offset from the link frame 3. States are consistent across different devices Args: sim: The simulation fixture num_articulations: Number of articulations to test device: The device to run the simulation on - with_offset: Whether to test with offset """ sim._app_control_on_stop_handle = None articulation_cfg = generate_articulation_cfg(articulation_type="single_joint_implicit") @@ -2907,10 +2268,7 @@ def test_body_root_state(sim, num_articulations, device, with_offset): arm_idx = articulation.body_names.index("Arm") # change center of mass offset from link frame - if with_offset: - offset = [0.5, 0.0, 0.0] - else: - offset = [0.0, 0.0, 0.0] + offset = [0.5, 0.0, 0.0] # create com offsets — apply offset to the Arm body num_bodies = articulation.num_bodies @@ -2935,7 +2293,6 @@ def test_body_root_state(sim, num_articulations, device, with_offset): articulation.update(sim.cfg.dt) # get state properties - root_link_pose_w = articulation.data.root_link_pose_w.torch root_link_vel_w = articulation.data.root_link_vel_w.torch root_com_pose_w = articulation.data.root_com_pose_w.torch root_com_vel_w = articulation.data.root_com_vel_w.torch @@ -2944,72 +2301,64 @@ def test_body_root_state(sim, num_articulations, device, with_offset): body_com_pose_w = articulation.data.body_com_pose_w.torch body_com_vel_w = articulation.data.body_com_vel_w.torch - if with_offset: - # get joint state - joint_pos = articulation.data.joint_pos.torch.unsqueeze(-1) - joint_vel = articulation.data.joint_vel.torch.unsqueeze(-1) - - # LINK state - # angular velocity should be the same for both COM and link frames - torch.testing.assert_close(root_com_vel_w[..., 3:], root_link_vel_w[..., 3:]) - torch.testing.assert_close(body_com_vel_w[..., 3:], body_link_vel_w[..., 3:]) - - # lin_vel arm - lin_vel_gt = torch.zeros(num_articulations, num_bodies, 3, device=device) - vx = -(link_offset[0]) * joint_vel * torch.sin(joint_pos) - vy = torch.zeros(num_articulations, 1, 1, device=device) - vz = (link_offset[0]) * joint_vel * torch.cos(joint_pos) - lin_vel_gt[:, arm_idx, :] = torch.cat([vx, vy, vz], dim=-1).squeeze(-2) - - # linear velocity of root link should be zero - torch.testing.assert_close(lin_vel_gt[:, root_idx, :], root_link_vel_w[..., :3], atol=1e-3, rtol=1e-1) - # linear velocity of pendulum link should be - torch.testing.assert_close(lin_vel_gt, body_link_vel_w[..., :3], atol=1e-3, rtol=1e-1) - - # ang_vel - torch.testing.assert_close(root_com_vel_w[..., 3:], root_link_vel_w[..., 3:]) - torch.testing.assert_close(body_com_vel_w[..., 3:], body_link_vel_w[..., 3:]) - - # COM state - # position and orientation shouldn't match for the _state_com_w but everything else will - # OVStage determines the runtime link pose from the joint frames, which may differ - # from the authored USD Xform. Verify the COM offset relative to that runtime pose. - pos_gt = body_link_pose_w[..., :3].clone() - px = offset[0] * torch.cos(joint_pos) - py = torch.zeros(num_articulations, 1, 1, device=device) - pz = offset[0] * torch.sin(joint_pos) - pos_gt[:, arm_idx, :] += torch.cat([px, py, pz], dim=-1).squeeze(-2) - torch.testing.assert_close(pos_gt[:, root_idx, :], root_com_pose_w[..., :3], atol=1e-3, rtol=1e-1) - torch.testing.assert_close(pos_gt, body_com_pose_w[..., :3], atol=1e-3, rtol=1e-1) - - # orientation - com_quat_b = articulation.data.body_com_quat_b.torch - com_quat_w = math_utils.quat_mul(body_link_pose_w[..., 3:], com_quat_b) - torch.testing.assert_close(com_quat_w, body_com_pose_w[..., 3:]) - torch.testing.assert_close(com_quat_w[:, root_idx, :], root_com_pose_w[..., 3:]) - - # angular velocity should be the same for both COM and link frames - torch.testing.assert_close(root_com_vel_w[..., 3:], root_link_vel_w[..., 3:]) - torch.testing.assert_close(body_com_vel_w[..., 3:], body_link_vel_w[..., 3:]) - else: - # single joint center of masses are at link frames so they will be the same - torch.testing.assert_close(root_link_pose_w, root_com_pose_w) - torch.testing.assert_close(root_com_vel_w, root_link_vel_w) - torch.testing.assert_close(body_link_pose_w, body_com_pose_w) - torch.testing.assert_close(body_com_vel_w, body_link_vel_w) + # get joint state + joint_pos = articulation.data.joint_pos.torch.unsqueeze(-1) + joint_vel = articulation.data.joint_vel.torch.unsqueeze(-1) + + # LINK state + # angular velocity should be the same for both COM and link frames + torch.testing.assert_close(root_com_vel_w[..., 3:], root_link_vel_w[..., 3:]) + torch.testing.assert_close(body_com_vel_w[..., 3:], body_link_vel_w[..., 3:]) + + # lin_vel arm + lin_vel_gt = torch.zeros(num_articulations, num_bodies, 3, device=device) + vx = -(link_offset[0]) * joint_vel * torch.sin(joint_pos) + vy = torch.zeros(num_articulations, 1, 1, device=device) + vz = (link_offset[0]) * joint_vel * torch.cos(joint_pos) + lin_vel_gt[:, arm_idx, :] = torch.cat([vx, vy, vz], dim=-1).squeeze(-2) + + # linear velocity of root link should be zero + torch.testing.assert_close(lin_vel_gt[:, root_idx, :], root_link_vel_w[..., :3], atol=1e-3, rtol=1e-1) + # linear velocity of pendulum link should be + torch.testing.assert_close(lin_vel_gt, body_link_vel_w[..., :3], atol=1e-3, rtol=1e-1) + + # ang_vel + torch.testing.assert_close(root_com_vel_w[..., 3:], root_link_vel_w[..., 3:]) + torch.testing.assert_close(body_com_vel_w[..., 3:], body_link_vel_w[..., 3:]) + + # COM state + # position and orientation shouldn't match for the _state_com_w but everything else will + # OVStage determines the runtime link pose from the joint frames, which may differ + # from the authored USD Xform. Verify the COM offset relative to that runtime pose. + pos_gt = body_link_pose_w[..., :3].clone() + px = offset[0] * torch.cos(joint_pos) + py = torch.zeros(num_articulations, 1, 1, device=device) + pz = offset[0] * torch.sin(joint_pos) + pos_gt[:, arm_idx, :] += torch.cat([px, py, pz], dim=-1).squeeze(-2) + torch.testing.assert_close(pos_gt[:, root_idx, :], root_com_pose_w[..., :3], atol=1e-3, rtol=1e-1) + torch.testing.assert_close(pos_gt, body_com_pose_w[..., :3], atol=1e-3, rtol=1e-1) + + # orientation + com_quat_b = articulation.data.body_com_quat_b.torch + com_quat_w = math_utils.quat_mul(body_link_pose_w[..., 3:], com_quat_b) + torch.testing.assert_close(com_quat_w, body_com_pose_w[..., 3:]) + torch.testing.assert_close(com_quat_w[:, root_idx, :], root_com_pose_w[..., 3:]) + + # angular velocity should be the same for both COM and link frames + torch.testing.assert_close(root_com_vel_w[..., 3:], root_link_vel_w[..., 3:]) + torch.testing.assert_close(body_com_vel_w[..., 3:], body_link_vel_w[..., 3:]) -@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("num_articulations", [2]) @pytest.mark.parametrize("device", test_devices()) -@pytest.mark.parametrize("with_offset", [True, False]) @pytest.mark.parametrize("state_location", ["com", "link"]) @pytest.mark.parametrize("gravity_enabled", [False]) -def test_write_root_state(sim, num_articulations, device, with_offset, state_location, gravity_enabled): +def test_write_root_state(sim, num_articulations, device, state_location, gravity_enabled): """Test the setters for root_state using both the link frame and center of mass as reference frame. This test verifies that: 1. Root states can be written correctly - 2. States are correct with and without offsets + 2. The other frame follows the written one through the COM offset 3. States can be written for both COM and link frames 4. States are consistent across different devices @@ -3017,7 +2366,6 @@ def test_write_root_state(sim, num_articulations, device, with_offset, state_loc sim: The simulation fixture num_articulations: Number of articulations to test device: The device to run the simulation on - with_offset: Whether to test with offset state_location: Whether to test COM or link frame """ sim._app_control_on_stop_handle = None @@ -3029,10 +2377,7 @@ def test_write_root_state(sim, num_articulations, device, with_offset, state_loc sim.reset() # change center of mass offset from link frame - if with_offset: - offset = torch.tensor([1.0, 0.0, 0.0]).repeat(num_articulations, 1, 1) - else: - offset = torch.tensor([0.0, 0.0, 0.0]).repeat(num_articulations, 1, 1) + offset = torch.tensor([1.0, 0.0, 0.0]).repeat(num_articulations, 1, 1) # create com offsets com = _read_binding_to_torch(articulation, TT.BODY_COM_POSE, device) @@ -3053,6 +2398,10 @@ def test_write_root_state(sim, num_articulations, device, with_offset, state_loc # make quaternion a unit vector rand_state[..., 3:7] = torch.nn.functional.normalize(rand_state[..., 3:7], dim=-1) + # Root-body COM frame in the link frame, from the values written above. + com_pos_b, com_quat_b = com[:, 0, :3], com[:, 0, 3:7] + link_pos_in_com, link_quat_in_com = math_utils.subtract_frame_transforms(com_pos_b, com_quat_b) + env_idx = env_idx.to(device) for i in range(10): # perform step @@ -3078,62 +2427,30 @@ def test_write_root_state(sim, num_articulations, device, with_offset, state_loc if state_location == "com": torch.testing.assert_close(rand_state[..., :7], articulation.data.root_com_pose_w.torch) torch.testing.assert_close(rand_state[..., 7:], articulation.data.root_com_vel_w.torch) + # The link frame is the written COM frame composed with the inverse COM offset. + expected_link_pos, expected_link_quat = math_utils.combine_frame_transforms( + rand_state[..., :3], rand_state[..., 3:7], link_pos_in_com, link_quat_in_com + ) + torch.testing.assert_close(articulation.data.root_link_pos_w.torch, expected_link_pos) + torch.testing.assert_close(articulation.data.root_link_quat_w.torch, expected_link_quat) elif state_location == "link": torch.testing.assert_close(rand_state[..., :7], articulation.data.root_link_pose_w.torch) torch.testing.assert_close(rand_state[..., 7:], articulation.data.root_link_vel_w.torch) - - -@pytest.mark.parametrize("device", ["cpu"]) -def test_body_com_pose_b_cache_and_set_coms_invalidation(sim, device): - """Body-frame COM offsets stay cached and invalidate derived buffers after writes.""" - sim._app_control_on_stop_handle = None - articulation_cfg = generate_articulation_cfg(articulation_type="single_joint_implicit") - articulation, _ = generate_articulation(articulation_cfg, 2, device=device) - - sim.reset() - articulation.update(sim.cfg.dt) - - articulation.data.body_com_pose_b - first_timestamp = articulation.data._body_com_pose_b.timestamp - articulation.update(sim.cfg.dt) - articulation.data.body_com_pose_b - assert articulation.data._body_com_pose_b.timestamp == first_timestamp - - dependent_buffers = [ - ("root_com_pose_w", articulation.data._root_com_pose_w), - ("root_com_vel_w", articulation.data._root_com_vel_w), - ("root_link_vel_w", articulation.data._root_link_vel_w), - ("body_com_pose_w", articulation.data._body_com_pose_w), - ("body_com_vel_w", articulation.data._body_com_vel_w), - ("body_link_vel_w", articulation.data._body_link_vel_w), - ("root_link_lin_vel_b", articulation.data._root_link_lin_vel_b), - ("root_link_ang_vel_b", articulation.data._root_link_ang_vel_b), - ("root_com_lin_vel_b", articulation.data._root_com_lin_vel_b), - ("root_com_ang_vel_b", articulation.data._root_com_ang_vel_b), - ("root_state_w", articulation.data._root_state_w_buf), - ("root_link_state_w", articulation.data._root_link_state_w_buf), - ("root_com_state_w", articulation.data._root_com_state_w_buf), - ("body_state_w", articulation.data._body_state_w_buf), - ("body_link_state_w", articulation.data._body_link_state_w_buf), - ("body_com_state_w", articulation.data._body_com_state_w_buf), - ("body_com_jacobian_w", articulation.data._body_com_jacobian_w), - ("mass_matrix", articulation.data._mass_matrix), - ("gravity_compensation_forces", articulation.data._gravity_compensation_forces), - ] - for _, buffer in dependent_buffers: - buffer.timestamp = articulation.data._sim_timestamp - - coms = wp.zeros((articulation.num_instances, articulation.num_bodies), dtype=wp.transformf, device=device) - articulation.set_coms_index(coms=coms) - - assert articulation.data._body_com_pose_b.timestamp >= 0.0 - for name, buffer in dependent_buffers: - assert buffer.timestamp < articulation.data._sim_timestamp, name + # The COM frame is the written link frame composed with the COM offset. + expected_com_pos, expected_com_quat = math_utils.combine_frame_transforms( + rand_state[..., :3], rand_state[..., 3:7], com_pos_b, com_quat_b + ) + torch.testing.assert_close(articulation.data.root_com_pos_w.torch, expected_com_pos) + torch.testing.assert_close(articulation.data.root_com_quat_w.torch, expected_com_quat) @pytest.mark.parametrize("device", ["cpu"]) def test_com_orientation_write_invalidates_static_inertia_cache_with_body_ordering(sim, device): - """A COM rotation refreshes the static inertia cache in non-identity body order.""" + """Partial COM writes and COM rotations follow non-identity body order. + + COM pose is a CPU-resident OVPhysX binding, and the raw ``root_view.set_attribute`` restore + below forbids cross-device staging, so this test is CPU-only. + """ sim._app_control_on_stop_handle = None articulation_cfg = FRANKA_PANDA_CFG.replace(body_ordering=PANDA_ROOT_PRESERVING_REVERSED_BODY_NAMES) articulation, _ = generate_articulation(articulation_cfg, 1, device=device) @@ -3146,6 +2463,42 @@ def test_com_orientation_write_invalidates_static_inertia_cache_with_body_orderi backend_body_id = articulation.body_ordering.user_to_backend_indices[public_body_id] assert backend_body_id != public_body_id + # A partial ordered COM write lands on the selected backend body and preserves every other one. + backend_before = _read_binding_to_torch(articulation, TT.BODY_COM_POSE, device).clone() + assert torch.unique(backend_before[0], dim=0).shape[0] > 1 + articulation.data._body_com_pose_b.timestamp = -1.0 + backend_staging = articulation.data._body_com_pose_b_backend + if backend_staging is not None: + backend_staging.timestamp = -1.0 + selected_com = backend_before[0, backend_body_id].clone() + selected_com[0] += 0.001 + articulation.set_coms_index( + coms=wp.from_torch(selected_com.reshape(1, 1, 7).contiguous(), dtype=wp.transformf), + env_ids=wp.array([0], dtype=wp.int32, device=device), + body_ids=wp.array([public_body_id], dtype=wp.int32, device=device), + ) + backend_after = _read_binding_to_torch(articulation, TT.BODY_COM_POSE, device).clone() + articulation.root_view.set_attribute(TT.BODY_COM_POSE, wp.from_torch(backend_before.contiguous())) + noop_after = _read_binding_to_torch(articulation, TT.BODY_COM_POSE, device).clone() + unselected_body_mask = torch.ones(backend_before.shape[1], dtype=torch.bool, device=device) + unselected_body_mask[backend_body_id] = False + assert torch.equal(noop_after[..., :3], backend_before[..., :3]) + assert torch.equal(backend_after[0, backend_body_id, :3], selected_com[:3]) + assert torch.equal(backend_after[0, unselected_body_mask, :3], backend_before[0, unselected_body_mask, :3]) + # Bound semantic orientation equality by the native setter's float32 no-op normalization. + native_orientation_atol = torch.max( + torch.abs(noop_after[0, unselected_body_mask, 3:7] - backend_before[0, unselected_body_mask, 3:7]) + ).item() + assert native_orientation_atol <= torch.finfo(backend_before.dtype).eps + torch.testing.assert_close( + backend_after[0, unselected_body_mask, 3:7], + backend_before[0, unselected_body_mask, 3:7], + rtol=0.0, + atol=native_orientation_atol, + ) + assert torch.equal(backend_after[..., 3:7], noop_after[..., 3:7]) + + # A COM rotation refreshes the static inertia cache. coms = articulation.data.body_com_pose_b.torch[:, public_body_id : public_body_id + 1].clone() coms[..., 3:7] = torch.tensor([0.0, 0.0, 0.0, 1.0], device=device) articulation.set_coms_index(coms=wp.from_torch(coms.contiguous(), dtype=wp.transformf), body_ids=[public_body_id]) @@ -3247,33 +2600,13 @@ def test_setting_invalid_articulation_root_prim_path(sim, device): sim.reset() -@pytest.mark.parametrize("device", ["cpu"]) -def test_deprecated_joint_state_writer_delegates_to_index_writers(sim, device, mocker): - """Keep the deprecated combined API as a thin composition of public writers.""" - articulation_cfg = generate_articulation_cfg(articulation_type="anymal") - articulation, _ = generate_articulation(articulation_cfg, 1, device) - sim.reset() - - position = torch.tensor([[0.1]], device=device) - velocity = torch.tensor([[0.2]], device=device) - position_writer = mocker.patch.object(articulation, "write_joint_position_to_sim_index") - velocity_writer = mocker.patch.object(articulation, "write_joint_velocity_to_sim_index") - - with pytest.warns(DeprecationWarning): - articulation.write_joint_state_to_sim( - position=position, - velocity=velocity, - joint_ids=[0], - env_ids=[0], - ) - - position_writer.assert_called_once_with(position=position, joint_ids=[0], env_ids=[0]) - velocity_writer.assert_called_once_with(velocity=velocity, joint_ids=[0], env_ids=[0]) - - @pytest.mark.parametrize("device", test_devices()) -def test_write_joint_state_to_sim_index_partial(sim, device): - """Test fused joint-state writes with partial environment and joint indices.""" +def test_write_joint_state_to_sim_index_partial(sim, device, mocker): + """Test fused joint-state writes with partial environment and joint indices. + + The first write uses unsorted int64 selectors; the deprecated combined writer must delegate + to the public index writers. + """ sim._app_control_on_stop_handle = None articulation_cfg = generate_articulation_cfg(articulation_type="anymal") articulation, _ = generate_articulation(articulation_cfg, 2, device) @@ -3294,8 +2627,13 @@ def test_write_joint_state_to_sim_index_partial(sim, device): position = torch.tensor([[0.1, -0.1]], device=device) velocity = torch.tensor([[0.2, -0.2]], device=device) + # int64 selectors with the joints in reverse order; the payload follows the selector order. articulation.write_joint_state_to_sim_index( - position=position, velocity=velocity, env_ids=[1], joint_ids=[0, 2], skip_forward=True + position=position[:, [1, 0]], + velocity=velocity[:, [1, 0]], + env_ids=torch.tensor([1], dtype=torch.int64, device=device), + joint_ids=torch.tensor([2, 0], dtype=torch.int64, device=device), + skip_forward=True, ) expected_joint_pos = original_joint_pos.clone() @@ -3321,8 +2659,18 @@ def test_write_joint_state_to_sim_index_partial(sim, device): assert articulation.data._body_link_pose_w.timestamp < articulation.data._sim_timestamp assert articulation.data._body_com_vel_w.timestamp < articulation.data._sim_timestamp + # The deprecated combined API stays a thin composition of the public writers. + position = torch.tensor([[0.1]], device=device) + velocity = torch.tensor([[0.2]], device=device) + position_writer = mocker.patch.object(articulation, "write_joint_position_to_sim_index") + velocity_writer = mocker.patch.object(articulation, "write_joint_velocity_to_sim_index") + with pytest.warns(DeprecationWarning): + articulation.write_joint_state_to_sim(position=position, velocity=velocity, joint_ids=[0], env_ids=[0]) + position_writer.assert_called_once_with(position=position, joint_ids=[0], env_ids=[0]) + velocity_writer.assert_called_once_with(velocity=velocity, joint_ids=[0], env_ids=[0]) + -@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("num_articulations", [2]) @pytest.mark.parametrize("device", test_devices()) @pytest.mark.parametrize("gravity_enabled", [False]) def test_write_joint_state_data_consistency(sim, num_articulations, device, gravity_enabled): @@ -3373,7 +2721,6 @@ def test_write_joint_state_data_consistency(sim, num_articulations, device, grav ) # validate body - link consistency body_link_vel_w = articulation.data.body_link_vel_w.torch - torch.testing.assert_close(body_link_pose_w, articulation.data.body_link_pose_w.torch) # skip lin_vel because it differs from link frame, this should be fine because we are only checking # if velocity update is triggered, which can be determined by comparing angular velocity torch.testing.assert_close(body_com_vel_w[..., 3:], body_link_vel_w[..., 3:]) @@ -3421,68 +2768,14 @@ def test_write_joint_state_data_consistency(sim, num_articulations, device, grav body_com_vel_w_fresh = articulation.data.body_com_vel_w.torch torch.testing.assert_close(body_pose_w, body_link_pose_w) torch.testing.assert_close(body_vel_w, body_com_vel_w) - torch.testing.assert_close(body_link_pose_w_fresh, body_link_pose_w) - torch.testing.assert_close(body_com_pose_w, articulation.data.body_com_pose_w.torch) torch.testing.assert_close(body_vel_w, body_com_vel_w_fresh) -@pytest.mark.parametrize("num_articulations", [1, 2]) -@pytest.mark.parametrize("device", test_devices()) -@pytest.mark.skip(reason=_SPATIAL_TENDON_OVSTAGE_GAP_REASON) -def test_spatial_tendons(sim, num_articulations, device): - """Test spatial tendons apis. - This test verifies that: - 1. The articulation is properly initialized - 2. The articulation has spatial tendons - 3. All buffers have correct shapes - 4. The articulation can be simulated - Args: - sim: The simulation fixture - num_articulations: Number of articulations to test - device: The device to run the simulation on - """ - # skip test if Isaac Sim version is less than 5.0 - if has_kit() and get_isaac_sim_version().major < 5: - pytest.skip("Spatial tendons are not supported in Isaac Sim < 5.0. Please update to Isaac Sim 5.0 or later.") - return - articulation_cfg = generate_articulation_cfg(articulation_type="spatial_tendon_test_asset") - articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=device) - - # Check that the framework doesn't hold excessive strong references. - assert sys.getrefcount(articulation) < 10 - - # Play sim - sim.reset() - # Check if articulation is initialized - assert articulation.is_initialized - # Check that fixed base - assert articulation.is_fixed_base - # Check buffers that exists and have correct shapes - assert articulation.data.root_pos_w.torch.shape == (num_articulations, 3) - assert articulation.data.root_quat_w.torch.shape == (num_articulations, 4) - assert articulation.data.joint_pos.torch.shape == (num_articulations, 3) - assert articulation.data.body_mass.torch.shape == (num_articulations, articulation.num_bodies) - assert articulation.data.body_inertia.torch.shape == (num_articulations, articulation.num_bodies, 9) - assert articulation.num_spatial_tendons == 1 - - articulation.set_spatial_tendon_stiffness_index(stiffness=10.0) - articulation.set_spatial_tendon_limit_stiffness_index(limit_stiffness=10.0) - articulation.set_spatial_tendon_damping_index(damping=10.0) - articulation.set_spatial_tendon_offset_index(offset=10.0) - - # Simulate physics - for _ in range(10): - # perform rendering - sim.step() - # update articulation - articulation.update(sim.cfg.dt) - - @pytest.mark.parametrize("add_ground_plane", [True]) -@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("num_articulations", [2]) @pytest.mark.parametrize("device", test_devices()) def test_write_joint_frictions_to_sim(sim, num_articulations, device, add_ground_plane): - """Test applying of joint position target functions correctly for a robotic arm.""" + """Write joint friction coefficients and read them back from the simulation and public getters.""" articulation_cfg = generate_articulation_cfg(articulation_type="panda") articulation, _ = generate_articulation( articulation_cfg=articulation_cfg, num_articulations=num_articulations, device=device @@ -3491,12 +2784,6 @@ def test_write_joint_frictions_to_sim(sim, num_articulations, device, add_ground # Play the simulator sim.reset() - for _ in range(100): - # perform step - sim.step() - # update buffers - articulation.update(sim.cfg.dt) - # apply action to the articulation dynamic_friction = torch.rand(num_articulations, articulation.num_joints, device=device) viscous_friction = torch.rand(num_articulations, articulation.num_joints, device=device) @@ -3513,12 +2800,8 @@ def test_write_joint_frictions_to_sim(sim, num_articulations, device, add_ground joint_viscous_friction_coeff=viscous_friction, ) articulation.write_data_to_sim() - - for _ in range(100): - # perform step - sim.step() - # update buffers - articulation.update(sim.cfg.dt) + sim.step() + articulation.update(sim.cfg.dt) friction_props_from_sim = _read_binding_to_torch(articulation, TT.DOF_FRICTION_PROPERTIES, "cpu") joint_friction_coeff_sim = friction_props_from_sim[:, :, 0] @@ -3528,67 +2811,27 @@ def test_write_joint_frictions_to_sim(sim, num_articulations, device, add_ground assert torch.allclose(joint_viscous_friction_coeff_sim, viscous_friction.cpu()) assert torch.allclose(joint_friction_coeff_sim, friction.cpu()) - # For Isaac Sim >= 5.0: also test the combined API that can set dynamic and viscous via - # write_joint_friction_coefficient_to_sim; reset the sim to isolate this path. - if has_kit() and get_isaac_sim_version().major >= 5: - # Reset simulator to ensure a clean state for the alternative API path - sim.reset() - - # Warm up a few steps to populate buffers - for _ in range(100): - sim.step() - articulation.update(sim.cfg.dt) - - # New random coefficients - dynamic_friction_2 = torch.rand(num_articulations, articulation.num_joints, device=device) - viscous_friction_2 = torch.rand(num_articulations, articulation.num_joints, device=device) - friction_2 = torch.rand(num_articulations, articulation.num_joints, device=device) - - # Guarantee that the dynamic friction is not greater than the static friction - dynamic_friction_2 = torch.min(dynamic_friction_2, friction_2) - - # Use the combined setter to write all three at once - articulation.write_joint_friction_coefficient_to_sim_index( - joint_friction_coeff=friction_2, - joint_dynamic_friction_coeff=dynamic_friction_2, - joint_viscous_friction_coeff=viscous_friction_2, - ) - articulation.write_data_to_sim() - - # Step to let sim ingest new params and refresh data buffers - for _ in range(100): - sim.step() - articulation.update(sim.cfg.dt) - - friction_props_from_sim_2 = _read_binding_to_torch(articulation, TT.DOF_FRICTION_PROPERTIES, "cpu") - joint_friction_coeff_sim_2 = friction_props_from_sim_2[:, :, 0] - friction_dynamic_coef_sim_2 = friction_props_from_sim_2[:, :, 1] - friction_viscous_coeff_sim_2 = friction_props_from_sim_2[:, :, 2] - - # Validate values propagated - assert torch.allclose(friction_viscous_coeff_sim_2, viscous_friction_2.cpu()) - assert torch.allclose(friction_dynamic_coef_sim_2, dynamic_friction_2.cpu()) - assert torch.allclose(joint_friction_coeff_sim_2, friction_2.cpu()) + # The public getters expose the same coefficients. + torch.testing.assert_close(articulation.data.joint_friction_coeff.torch, friction) + torch.testing.assert_close(articulation.data.joint_dynamic_friction_coeff.torch, dynamic_friction) + torch.testing.assert_close(articulation.data.joint_viscous_friction_coeff.torch, viscous_friction) @pytest.mark.parametrize("add_ground_plane", [True]) -@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("num_articulations", [2]) @pytest.mark.parametrize("device", test_devices()) -@pytest.mark.parametrize("articulation_type", ["panda"]) -def test_set_material_properties(sim, num_articulations, device, add_ground_plane, articulation_type): - """Test getting and setting per-shape material properties (friction/restitution). +def test_set_material_properties(sim, num_articulations, device, add_ground_plane): + """Randomize per-shape articulation materials through the OVPhysX material event. OVPhysX exposes per-collision-shape material as the ``articulation_shape_friction_and_restitution`` tensor binding (shape ``[N, S, 3]`` = static friction, dynamic friction, restitution), addressed through the :class:`~isaaclab_ov.sim.views.OvPhysxView`. The binding is CPU-native, so the - buffer lives in host memory. (The PhysX backend instead uses a dedicated - ``root_view.get_material_properties`` / ``set_material_properties`` view API.) + buffer lives in host memory. """ - if not hasattr(TT, "SHAPE_FRICTION_AND_RESTITUTION"): - pytest.skip("ovphysx wheel does not expose the shape material tensor type") + from isaaclab.envs.mdp.events import _RandomizeRigidBodyMaterialOvPhysx # noqa: PLC0415 - articulation_cfg = generate_articulation_cfg(articulation_type=articulation_type) + articulation_cfg = generate_articulation_cfg(articulation_type="panda") articulation, _ = generate_articulation( articulation_cfg=articulation_cfg, num_articulations=num_articulations, device=device ) @@ -3596,22 +2839,32 @@ def test_set_material_properties(sim, num_articulations, device, add_ground_plan # Play the simulator sim.reset() + # The ranges exclude the asset's default material, so values inside them prove the write happened. + static_range, dynamic_range, restitution_range = (1.5, 2.0), (1.2, 1.4), (0.6, 0.8) view = articulation.root_view - # Number of collision shapes per articulation, from the material binding's shape [N, S, 3]. - num_shapes = view.binding_for(TT.SHAPE_FRICTION_AND_RESTITUTION).shape[1] - - # Random material per shape: (static_friction, dynamic_friction, restitution), on the CPU. - materials = torch.empty(num_articulations, num_shapes, 3, device="cpu").uniform_(0.0, 1.0) - materials[..., 1] = torch.min(materials[..., 0], materials[..., 1]) # dynamic <= static + materials_before = wp.to_torch(view.get_attribute(TT.SHAPE_FRICTION_AND_RESTITUTION)).clone() + assert (materials_before[..., 0] < static_range[0]).all(), f"default material overlaps: {materials_before}" + params = { + "static_friction_range": static_range, + "dynamic_friction_range": dynamic_range, + "restitution_range": restitution_range, + "num_buckets": 16, + } + asset_cfg = SimpleNamespace(body_ids=slice(None)) + env = SimpleNamespace() # unused by the OVPhysX implementation + randomize = _RandomizeRigidBodyMaterialOvPhysx(SimpleNamespace(params=params), env, articulation, asset_cfg) - # Set material properties through the view, then simulate. - view.set_attribute(TT.SHAPE_FRICTION_AND_RESTITUTION, wp.from_torch(materials, dtype=wp.float32)) + # Randomize only the last environment; the others keep their materials. + randomize(env, torch.tensor([num_articulations - 1], device=device), *params.values(), asset_cfg) sim.step() articulation.update(sim.cfg.dt) - # Read back from the simulation and verify the round-trip. - materials_check = wp.to_torch(view.get_attribute(TT.SHAPE_FRICTION_AND_RESTITUTION)) - torch.testing.assert_close(materials_check, materials) + materials = wp.to_torch(view.get_attribute(TT.SHAPE_FRICTION_AND_RESTITUTION)) + torch.testing.assert_close(materials[:-1], materials_before[:-1]) + eps = 1e-5 + for component, (lo, hi) in enumerate((static_range, dynamic_range, restitution_range)): + values = materials[-1, :, component] + assert ((values >= lo - eps) & (values <= hi + eps)).all(), values @wp.kernel diff --git a/source/isaaclab_ov/test/assets/test_articulation_helpers.py b/source/isaaclab_ov/test/assets/test_articulation_helpers.py index 5346b942705a..ca43d1b1c46b 100644 --- a/source/isaaclab_ov/test/assets/test_articulation_helpers.py +++ b/source/isaaclab_ov/test/assets/test_articulation_helpers.py @@ -5,9 +5,8 @@ """OVPhysX-only unit tests for articulation helpers. -These tests cover OVPhysX-specific scaffolding (USD tendon-scope resolution, -mock binding-set shape contracts) that has no PhysX equivalent and therefore -does not appear in the PhysX-mirrored ``test_articulation.py``. +These tests cover OVPhysX-specific scaffolding (USD tendon-scope resolution) that has no +PhysX equivalent and therefore does not appear in the PhysX-mirrored ``test_articulation.py``. """ from __future__ import annotations @@ -118,36 +117,3 @@ def test_process_tendons_scopes_to_articulation_root(): # add the identically-named instance from /World/unrelated, giving two entries assert articulation.fixed_tendon_names == ["inst0"] assert articulation.spatial_tendon_names == ["spatial_joint"] - - -def test_mock_binding_set_rigid_object_shapes(): - pytest.importorskip("isaaclab_ov.tensor_types").RIGID_BODY_POSE # gates on wheel - from isaaclab_ov import tensor_types as TT - from isaaclab_ov.test.fixtures.views import MockOvPhysxBindingSet - - bindings = MockOvPhysxBindingSet( - num_instances=4, - num_joints=0, - num_bodies=1, - asset_kind="rigid_object", - ) - assert bindings.bindings[TT.RIGID_BODY_POSE].shape == (4, 7) - assert bindings.bindings[TT.RIGID_BODY_VELOCITY].shape == (4, 6) - assert bindings.bindings[TT.RIGID_BODY_WRENCH].shape == (4, 9) - assert bindings.bindings[TT.RIGID_BODY_MASS].shape == (4,) - assert bindings.bindings[TT.RIGID_BODY_INERTIA].shape == (4, 9) - # Articulation-only bindings must be absent - assert TT.DOF_POSITION not in bindings.bindings - assert TT.LINK_WRENCH not in bindings.bindings - - -def test_mock_binding_read_preserves_structured_warp_dtype(): - """Mock bindings should read flat component data into structured Warp arrays.""" - from isaaclab_ov import tensor_types as TT - - bindings = MockOvPhysxBindingSet(num_instances=4, num_joints=1, num_bodies=2) - destination = wp.empty((4, 2), dtype=wp.spatial_vectorf, device="cpu") - - bindings.bindings[TT.LINK_VELOCITY].read(destination) - - assert destination.numpy().shape == (4, 2, 6) diff --git a/source/isaaclab_ov/test/assets/test_articulation_kernels.py b/source/isaaclab_ov/test/assets/test_articulation_kernels.py index a8c6b79f8f5e..b1da63857647 100644 --- a/source/isaaclab_ov/test/assets/test_articulation_kernels.py +++ b/source/isaaclab_ov/test/assets/test_articulation_kernels.py @@ -5,9 +5,6 @@ """Tests for OVPhysX articulation Warp kernels.""" -import warnings -from types import SimpleNamespace - import numpy as np import pytest import warp as wp @@ -18,23 +15,6 @@ def _selector(values: list[int], dtype: type) -> wp.array: return wp.array(values, dtype=dtype, device="cpu") -def _articulation_class(): - with warnings.catch_warnings(): - warnings.filterwarnings( - "ignore", - message=( - "^'RigidBodyMaterialCfg' is deprecated and will be removed in 5\\.0\\. Use " - "'isaaclab_physx\\.sim\\.spawners\\.materials\\.PhysxRigidBodyMaterialCfg' for PhysX properties, " - "or 'isaaclab\\.sim\\.spawners\\.materials\\.RigidBodyMaterialBaseCfg' for solver-common properties " - "only\\.$" - ), - category=DeprecationWarning, - ) - from isaaclab_ov.assets.articulation.articulation import Articulation - - return Articulation - - @pytest.mark.parametrize("env_dtype", [wp.int32, wp.int64]) def test_root_worker_accepts_selector_widths(env_dtype: type) -> None: env_ids = _selector([1, 0], env_dtype) @@ -55,8 +35,7 @@ def test_root_worker_accepts_selector_widths(env_dtype: type) -> None: np.testing.assert_array_equal(sim_env_ids.numpy(), [1, 0]) -@pytest.mark.parametrize("env_dtype", [wp.int32, wp.int64]) -@pytest.mark.parametrize("item_dtype", [wp.int32, wp.int64]) +@pytest.mark.parametrize(("env_dtype", "item_dtype"), [(wp.int32, wp.int32), (wp.int64, wp.int64)]) def test_item_worker_accepts_selector_widths(env_dtype: type, item_dtype: type) -> None: env_ids = _selector([1, 0], env_dtype) item_ids = _selector([2, 0], item_dtype) @@ -69,18 +48,3 @@ def test_item_worker_accepts_selector_widths(env_dtype: type, item_dtype: type) wp.launch(kernel, dim=(2, 2), inputs=[data, env_ids, item_ids], outputs=[output], device="cpu") np.testing.assert_array_equal(output.numpy(), [[22.0, -1.0, 21.0], [12.0, -1.0, 11.0]]) - - -def test_cpu_env_ids_all_returns_pinned_fast_path() -> None: - Articulation = _articulation_class() - all_indices = _selector([0, 1], wp.int32) - cpu_env_ids_all = wp.clone(all_indices, device="cpu") - articulation = SimpleNamespace( - _ALL_INDICES=all_indices, - _cpu_env_ids_all=cpu_env_ids_all, - _device="cpu", - ) - - resolved = Articulation._get_cpu_env_ids(articulation, all_indices) - - assert resolved is cpu_env_ids_all diff --git a/source/isaaclab_ov/test/assets/test_deformable_object.py b/source/isaaclab_ov/test/assets/test_deformable_object.py index 4aaf827bedd7..842e0ff17af2 100644 --- a/source/isaaclab_ov/test/assets/test_deformable_object.py +++ b/source/isaaclab_ov/test/assets/test_deformable_object.py @@ -43,37 +43,6 @@ wp.init() -@configclass -class DeformableSceneCfg(InteractiveSceneCfg): - """Interactive scene configuration for cloned volume deformables.""" - - deformable: DeformableObjectCfg = DeformableObjectCfg( - prim_path="{ENV_REGEX_NS}/Object", - spawn=pre_tetrahedralized_deformable_spawn_cfg(), - init_state=DeformableObjectCfg.InitialStateCfg(pos=(0.0, 0.0, 1.0)), - ) - - -@configclass -class MixedDeformableRigidSceneCfg(InteractiveSceneCfg): - """Interactive scene configuration for cloned deformable and rigid assets.""" - - deformable: DeformableObjectCfg = DeformableObjectCfg( - prim_path="{ENV_REGEX_NS}/Object", - spawn=pre_tetrahedralized_deformable_spawn_cfg(), - init_state=DeformableObjectCfg.InitialStateCfg(pos=(0.0, 0.0, 1.0)), - ) - cube: RigidObjectCfg = RigidObjectCfg( - prim_path="{ENV_REGEX_NS}/Cube", - spawn=sim_utils.CuboidCfg( - size=(0.1, 0.1, 0.1), - rigid_props=PhysxRigidBodyCfg(disable_gravity=True), - collision_props=sim_utils.UsdPhysicsCollisionCfg(collision_enabled=True), - ), - init_state=RigidObjectCfg.InitialStateCfg(pos=(0.35, 0.0, 1.0)), - ) - - @configclass class HeterogeneousMixedDeformableRigidSceneCfg(InteractiveSceneCfg): """Interactive scene configuration with two rigid variants and a deformable.""" @@ -180,19 +149,22 @@ def _run_cpu_deformable_initialization(result_queue: Any) -> None: @pytest.mark.parametrize( "num_objects, material_path", [ - (1, "material"), (2, None), - (2, "/World/SoftMaterial"), - (2, "material"), + # A shared absolute material whose path only textually prefixes a sibling asset path. + (2, "/World/Table_0/ObjectSiblingMaterial"), ], ) @pytest.mark.skipif(not torch.cuda.is_available(), reason="OVPhysX deformables require CUDA") def test_initialization(num_objects: int, material_path: str | None): - """Test volume deformable initialization and public buffer shapes.""" + """Test volume deformable initialization, optional and shared materials, and public buffer shapes.""" with _ovphysx_sim_context(device="cuda:0") as sim: deformable = _generate_deformable_scene( pre_tetrahedralized_deformable_spawn_cfg(material_path=material_path), num_objects=num_objects ) + if material_path is not None: + # A same-named material under the other table must not be matched by prefix expansion. + distractor_cfg = PhysxDeformableBodyMaterialCfg() + distractor_cfg.func("/World/Table_1/ObjectSiblingMaterial", distractor_cfg) assert sys.getrefcount(deformable) < 10 sim.reset() @@ -203,12 +175,9 @@ def test_initialization(num_objects: int, material_path: str | None): assert deformable.root_view.count == num_objects if material_path is None: assert deformable.material_physx_view is None - elif material_path.startswith("/"): - assert deformable.material_physx_view is not None - assert deformable.material_physx_view.count == 1 else: assert deformable.material_physx_view is not None - assert deformable.material_physx_view.count == num_objects + assert deformable.material_physx_view.count == 1 assert deformable.data.nodal_state_w.torch.shape == ( num_objects, deformable.max_sim_vertices_per_body, @@ -228,53 +197,6 @@ def test_initialization(num_objects: int, material_path: str | None): assert deformable._material_physx_view is None -@pytest.mark.skipif(not torch.cuda.is_available(), reason="OVPhysX deformables require CUDA") -def test_absolute_material_sibling_prefix_is_not_expanded(): - """Keep a shared absolute material exact when its path only textually prefixes the asset path.""" - with _ovphysx_sim_context(device="cuda:0") as sim: - material_path = "/World/Table_0/ObjectSiblingMaterial" - deformable = _generate_deformable_scene( - pre_tetrahedralized_deformable_spawn_cfg(material_path=material_path), num_objects=2 - ) - distractor_cfg = PhysxDeformableBodyMaterialCfg() - distractor_cfg.func("/World/Table_1/ObjectSiblingMaterial", distractor_cfg) - - sim.reset() - - material_view = deformable.material_physx_view - assert material_view is not None - assert material_view.count == 1 - - -@pytest.mark.skipif(not torch.cuda.is_available(), reason="OVPhysX deformables require CUDA") -def test_initialization_surface_deformable(): - """Test surface deformable initialization and unsupported target writes.""" - with _ovphysx_sim_context(device="cuda:0") as sim: - num_objects = 2 - deformable = _generate_deformable_scene(pretriangulated_surface_deformable_spawn_cfg(), num_objects=num_objects) - - sim.reset() - - assert deformable.is_initialized - assert deformable._deformable_type == "surface" - assert deformable.num_instances == num_objects - assert deformable.root_view.count == num_objects - assert deformable.material_physx_view is not None - assert deformable.material_physx_view.count == num_objects - assert deformable.data.nodal_state_w.torch.shape == ( - num_objects, - deformable.max_sim_vertices_per_body, - 6, - ) - assert deformable.data.root_pos_w.torch.shape == (num_objects, 3) - assert deformable.data.root_vel_w.torch.shape == (num_objects, 3) - assert deformable.data.nodal_kinematic_target is None - - dummy_targets = torch.zeros(num_objects, deformable.max_sim_vertices_per_body, 4, device=sim.device) - with pytest.raises(ValueError, match="Kinematic targets can only be set for volume deformable bodies"): - deformable.write_nodal_kinematic_target_to_sim_index(dummy_targets) - - def test_initialization_on_device_cpu(): """Test that OVPhysX deformable initialization rejects a CPU simulation.""" context = multiprocessing.get_context("spawn") @@ -302,103 +224,25 @@ def test_initialization_on_device_cpu(): assert is_initialized is False -@pytest.mark.skipif(not torch.cuda.is_available(), reason="OVPhysX deformables require CUDA") -def test_set_nodal_state(): - """Test combined nodal state writes while independently randomizing position and velocity.""" - with _ovphysx_sim_context(device="cuda:0") as sim: - num_objects = 2 - deformable = _generate_deformable_scene(pre_tetrahedralized_deformable_spawn_cfg(), num_objects=num_objects) - sim.reset() - - for state_type_to_randomize in ["nodal_pos_w", "nodal_vel_w"]: - state_dict = { - "nodal_pos_w": torch.zeros_like(deformable.data.nodal_pos_w.torch), - "nodal_vel_w": torch.zeros_like(deformable.data.nodal_vel_w.torch), - } - - for _ in range(5): - deformable.reset() - state_dict[state_type_to_randomize] = torch.randn( - num_objects, deformable.max_sim_vertices_per_body, 3, device=sim.device - ) - - for _ in range(5): - nodal_state = torch.cat([state_dict["nodal_pos_w"], state_dict["nodal_vel_w"]], dim=-1) - deformable.write_nodal_state_to_sim_index(nodal_state) - torch.testing.assert_close(deformable.data.nodal_state_w.torch, nodal_state, rtol=1e-5, atol=1e-5) - - sim.step() - deformable.update(sim.cfg.dt) - - -@pytest.mark.parametrize( - ("property_name", "write_method_name", "tensor_type", "command_value"), - [ - ("nodal_pos_w", "write_nodal_pos_to_sim_index", "deformable_sim_nodal_position", 100.0), - ("nodal_vel_w", "write_nodal_velocity_to_sim_index", "deformable_sim_nodal_velocity", -100.0), - ], -) -@pytest.mark.skipif(not torch.cuda.is_available(), reason="OVPhysX deformables require CUDA") -def test_indexed_partial_write_preserves_retained_aliased_slice_in_simulator( - property_name: str, write_method_name: str, tensor_type: str, command_value: float -) -> None: - """Preserve a retained selected command while hydrating stale rows from OVPhysX.""" - with _ovphysx_sim_context(device="cuda:0") as sim: - deformable = _generate_deformable_scene(pre_tetrahedralized_deformable_spawn_cfg(), num_objects=2) - sim.reset() - - retained = getattr(deformable.data, property_name).torch - sim.step() - deformable.update(sim.cfg.dt) - latest = wp.to_torch(deformable.root_view.get_attribute(tensor_type)).clone() - selected = retained[1:2] - selected.fill_(command_value) - - getattr(deformable, write_method_name)(selected, env_ids=torch.tensor([1], device=sim.device)) - readback = wp.to_torch(deformable.root_view.get_attribute(tensor_type)) - - torch.testing.assert_close(readback[0], latest[0], rtol=1e-5, atol=1e-5) - torch.testing.assert_close(readback[1], selected[0], rtol=1e-5, atol=1e-5) - - -@pytest.mark.parametrize( - "num_objects, randomize_pos, randomize_rot", - [ - (1, False, False), - (1, True, False), - (1, False, True), - (2, True, True), - ], -) @flaky(max_runs=3, min_passes=1) @pytest.mark.skipif(not torch.cuda.is_available(), reason="OVPhysX deformables require CUDA") -def test_set_nodal_state_with_applied_transform(num_objects: int, randomize_pos: bool, randomize_rot: bool): +def test_set_nodal_state_with_applied_transform(): """Test combined nodal state writes after applying rigid transforms.""" with _ovphysx_sim_context(device="cuda:0", gravity_enabled=False) as sim: - deformable = _generate_deformable_scene(pre_tetrahedralized_deformable_spawn_cfg(), num_objects=num_objects) + deformable = _generate_deformable_scene(pre_tetrahedralized_deformable_spawn_cfg(), num_objects=2) sim.reset() for _ in range(5): nodal_state = deformable.data.default_nodal_state_w.torch.clone() mean_nodal_pos_default = nodal_state[..., :3].mean(dim=1) - if randomize_pos: - pos_w = 0.5 * torch.rand(deformable.num_instances, 3, device=sim.device) - pos_w[:, 2] += 0.5 - else: - pos_w = None - if randomize_rot: - quat_w = math_utils.random_orientation(deformable.num_instances, device=sim.device) - else: - quat_w = None + pos_w = 0.5 * torch.rand(deformable.num_instances, 3, device=sim.device) + pos_w[:, 2] += 0.5 + quat_w = math_utils.random_orientation(deformable.num_instances, device=sim.device) nodal_state[..., :3] = deformable.transform_nodal_pos(nodal_state[..., :3], pos_w, quat_w) mean_nodal_pos_init = nodal_state[..., :3].mean(dim=1) - - if pos_w is None: - torch.testing.assert_close(mean_nodal_pos_init, mean_nodal_pos_default, rtol=1e-5, atol=1e-5) - else: - torch.testing.assert_close(mean_nodal_pos_init, mean_nodal_pos_default + pos_w, rtol=1e-5, atol=1e-5) + torch.testing.assert_close(mean_nodal_pos_init, mean_nodal_pos_default + pos_w, rtol=1e-5, atol=1e-5) deformable.write_nodal_state_to_sim_index(nodal_state) deformable.reset() @@ -560,6 +404,20 @@ def test_volume_deformable_reads_writes_targets_materials_and_steps(): deformable.update(sim.cfg.dt) _assert_finite_deformable_state(deformable) + # A forced re-warm replaces the attached stage, so the deformable bindings are rebuilt. + original_view = deformable.root_view + original_binding = original_view.binding_for(TT.DEFORMABLE_SIM_NODAL_POSITION) + OvPhysxManager._warmup_done = False + sim.reset() + + assert deformable.is_initialized + assert deformable.root_view is not original_view + assert deformable.root_view.binding_for(TT.DEFORMABLE_SIM_NODAL_POSITION) is not original_binding + _assert_finite_deformable_state(deformable) + sim.step() + deformable.update(sim.cfg.dt) + _assert_finite_deformable_state(deformable) + @pytest.mark.skipif(not torch.cuda.is_available(), reason="OVPhysX deformables require CUDA") def test_surface_deformable_reads_writes_materials_and_steps(): @@ -575,6 +433,7 @@ def test_surface_deformable_reads_writes_materials_and_steps(): assert deformable.max_sim_vertices_per_body == 4 assert deformable.max_sim_elements_per_body == 2 assert deformable.max_collision_elements_per_body == 0 + assert deformable.max_collision_vertices_per_body == 0 assert deformable.data.nodal_state_w.torch.shape == (2, 4, 6) assert deformable.data.root_pos_w.torch.shape == (2, 3) assert deformable.data.root_vel_w.torch.shape == (2, 3) @@ -664,70 +523,6 @@ def test_surface_deformable_reads_writes_materials_and_steps(): _assert_finite_deformable_state(deformable) -@pytest.mark.skipif(not torch.cuda.is_available(), reason="OVPhysX deformables require CUDA") -def test_deformable_interactive_scene_uses_full_authored_stage(): - """Initialize cloned deformable bodies and materials from the full authored stage.""" - with _ovphysx_sim_context(device="cuda:0") as sim: - scene = InteractiveScene(DeformableSceneCfg(num_envs=3, env_spacing=0.75, lazy_sensor_update=False)) - - sim.reset() - - deformable = scene["deformable"] - assert deformable.num_instances == 3 - assert deformable.root_view.count == 3 - assert deformable.material_physx_view is not None - assert deformable.material_physx_view.count == 3 - - sim.step() - scene.update(sim.cfg.dt) - _assert_finite_deformable_state(deformable) - - -@pytest.mark.skipif(not torch.cuda.is_available(), reason="OVPhysX deformables require CUDA") -def test_forced_rewarm_rebuilds_deformable_bindings(): - """Replace deformable bindings when a forced re-warm replaces the attached stage.""" - with _ovphysx_sim_context(device="cuda:0") as sim: - deformable = _generate_deformable_scene(pre_tetrahedralized_deformable_spawn_cfg(), num_objects=2) - sim.reset() - - original_view = deformable.root_view - original_binding = original_view.binding_for(TT.DEFORMABLE_SIM_NODAL_POSITION) - - OvPhysxManager._warmup_done = False - sim.reset() - - assert deformable.is_initialized - assert deformable.root_view is not original_view - assert deformable.root_view.binding_for(TT.DEFORMABLE_SIM_NODAL_POSITION) is not original_binding - _assert_finite_deformable_state(deformable) - sim.step() - deformable.update(sim.cfg.dt) - _assert_finite_deformable_state(deformable) - - -@pytest.mark.skipif(not torch.cuda.is_available(), reason="OVPhysX deformables require CUDA") -def test_mixed_deformable_rigid_scene_does_not_duplicate_runtime_clones(): - """Keep deformable, material, and rigid clone counts aligned in a mixed scene.""" - with _ovphysx_sim_context(device="cuda:0") as sim: - scene = InteractiveScene(MixedDeformableRigidSceneCfg(num_envs=3, env_spacing=0.75, lazy_sensor_update=False)) - - sim.reset() - - deformable = scene["deformable"] - cube = scene["cube"] - assert deformable.num_instances == 3 - assert deformable.root_view.count == 3 - assert deformable.material_physx_view is not None - assert deformable.material_physx_view.count == 3 - assert cube.num_instances == 3 - assert cube.root_view.count == 3 - - sim.step() - scene.update(sim.cfg.dt) - _assert_finite_deformable_state(deformable) - assert torch.isfinite(cube.data.root_pos_w.torch).all() - - @pytest.mark.skipif(not torch.cuda.is_available(), reason="OVPhysX deformables require CUDA") def test_heterogeneous_mixed_deformable_rigid_scene_materializes_missing_targets(): """Materialize missing rigid targets beside full-stage deformable clones without duplicates.""" @@ -767,7 +562,11 @@ def test_heterogeneous_mixed_deformable_rigid_scene_materializes_missing_targets deformable = scene["deformable"] shape = scene["shape"] + assert deformable.num_instances == num_envs assert deformable.root_view.count == num_envs, deformable.root_view.prim_paths + assert deformable.material_physx_view is not None + assert deformable.material_physx_view.count == num_envs + assert shape.num_instances == num_envs assert shape.root_view.count == num_envs, shape.root_view.prim_paths runtime_paths = shape.root_view.prim_paths assert set(runtime_paths) == expected_paths diff --git a/source/isaaclab_ov/test/assets/test_deformable_object_helpers.py b/source/isaaclab_ov/test/assets/test_deformable_object_helpers.py index 42e236925d66..cd58647c8e1d 100644 --- a/source/isaaclab_ov/test/assets/test_deformable_object_helpers.py +++ b/source/isaaclab_ov/test/assets/test_deformable_object_helpers.py @@ -23,14 +23,6 @@ ) from isaaclab_ov.assets.deformable_object.kernels import vec6f # noqa: E402 -from isaaclab.assets.deformable_object import ( # noqa: E402 - BaseDeformableObject, - BaseDeformableObjectData, -) -from isaaclab.assets.deformable_object import ( # noqa: E402 - DeformableObject as DeformableObjectFactory, -) - wp.init() wp.set_device("cpu") @@ -51,9 +43,6 @@ def __init__(self, num_instances: int, num_vertices: int) -> None: self.velocity_reads = 0 self.position_write_count = 0 self.velocity_write_count = 0 - self.target_write_count = 0 - self.last_indices: torch.Tensor | None = None - self.last_values: wp.array | None = None def binding_for(self, tensor_type): if tensor_type in (TT.DEFORMABLE_SIM_ELEMENT_INDICES, TT.SURFACE_DEFORMABLE_SIM_ELEMENT_INDICES): @@ -93,24 +82,15 @@ def set_attribute( indices: wp.array(dtype=wp.int32) | None = None, mask: wp.array(dtype=wp.bool) | None = None, ) -> None: - self.last_values = values - self.last_indices = wp.to_torch(indices) if indices is not None else None + self.last_indices = indices if tensor_type in (TT.DEFORMABLE_SIM_NODAL_POSITION, TT.SURFACE_DEFORMABLE_SIM_POSITION): self.position_write_count += 1 elif tensor_type in (TT.DEFORMABLE_SIM_NODAL_VELOCITY, TT.SURFACE_DEFORMABLE_SIM_VELOCITY): self.velocity_write_count += 1 - elif tensor_type == TT.DEFORMABLE_SIM_KINEMATIC_TARGET: - self.target_write_count += 1 - else: + elif tensor_type != TT.DEFORMABLE_SIM_KINEMATIC_TARGET: raise AssertionError(f"Unexpected tensor write: {tensor_type}") -class _FakeMaterialView: - """Minimal optional plain material view.""" - - count = 1 - - class _FakeVisualizer: """Capture marker positions from the debug visualization callback.""" @@ -126,7 +106,6 @@ def _make_asset_shell( deformable_type: str, num_instances: int = 2, num_vertices: int = 4, - material_view: _FakeMaterialView | None = None, ) -> DeformableObject: asset = object.__new__(DeformableObject) asset._device = "cpu" @@ -142,7 +121,7 @@ def _make_asset_shell( asset._sim_nodal_velocity_type = TT.SURFACE_DEFORMABLE_SIM_VELOCITY asset._sim_kinematic_target_type = None asset._root_physx_view = _FakeBodyView(num_instances, num_vertices) - asset._material_physx_view = material_view + asset._material_physx_view = None asset._data = DeformableObjectData( asset._root_physx_view, asset._device, @@ -160,41 +139,6 @@ def _make_asset_shell( return asset -def test_unbound_material_is_optional(): - asset = _make_asset_shell(deformable_type="volume") - - assert asset.material_physx_view is None - - -def test_surface_kinematic_target_write_matches_physx_error(): - asset = _make_asset_shell(deformable_type="surface") - with pytest.raises(ValueError, match="Kinematic targets can only be set for volume deformable bodies"): - asset.write_nodal_kinematic_target_to_sim_index(torch.zeros((2, 4, 4), device=asset.device)) - - -def test_indexed_position_write_updates_full_internal_buffer(): - asset = _make_asset_shell(deformable_type="volume", num_instances=3, num_vertices=4) - selected = torch.ones((1, 4, 3), device=asset.device) - asset.write_nodal_pos_to_sim_index(selected, env_ids=torch.tensor([2], device=asset.device)) - assert torch.count_nonzero(asset.data.nodal_pos_w.torch[0:2]) == 0 - torch.testing.assert_close(asset.data.nodal_pos_w.torch[2], selected[0]) - assert asset.root_view.last_indices.tolist() == [2] - - -def test_indexed_state_write_updates_position_and_velocity_buffers(): - asset = _make_asset_shell(deformable_type="volume", num_instances=2, num_vertices=4) - selected = torch.cat( - (torch.full((1, 4, 3), 2.0), torch.full((1, 4, 3), -3.0)), - dim=-1, - ) - - asset.write_nodal_state_to_sim_index(selected, env_ids=[1]) - - torch.testing.assert_close(asset.data.nodal_pos_w.torch[1], selected[0, :, :3]) - torch.testing.assert_close(asset.data.nodal_vel_w.torch[1], selected[0, :, 3:]) - assert asset.data._nodal_state_w.timestamp == -1.0 - - def test_indexed_state_write_refreshes_unselected_stale_cache_rows(): asset = _make_asset_shell(deformable_type="volume", num_instances=3, num_vertices=2) initial_positions = torch.full((3, 2, 3), -1.0, device=asset.device) @@ -225,6 +169,11 @@ def test_indexed_state_write_refreshes_unselected_stale_cache_rows(): asset.root_view.velocities = wp.from_torch(latest_velocities.contiguous(), dtype=wp.float32) asset.update(0.1) + # Prime the derived buffers at the current timestamp so the write itself must invalidate them. + asset.data.nodal_state_w + asset.data.root_pos_w + asset.data.root_vel_w + selected_state = torch.cat( ( torch.full((1, 2, 3), 100.0, device=asset.device), @@ -303,6 +252,8 @@ def test_indexed_partial_write_preserves_retained_aliased_slice( selected.fill_(command_value) getattr(asset, write_method_name)(selected, env_ids=[1]) + # Only the selected environment is written back to the simulator. + assert asset.root_view.last_indices.numpy().tolist() == [1] expected = latest.clone() expected[1].fill_(command_value) @@ -327,7 +278,7 @@ def test_full_overwrite_stale_cache_does_not_read_simulator() -> None: torch.testing.assert_close(asset.data.nodal_state_w.torch, full_state) -@pytest.mark.parametrize("trailing_dimension", [4, 5, 7]) +@pytest.mark.parametrize("trailing_dimension", [7]) def test_malformed_state_write_fails_before_mutating_or_writing(trailing_dimension: int): asset = _make_asset_shell(deformable_type="volume", num_instances=2, num_vertices=4) original_positions = asset.data.nodal_pos_w.torch.clone() @@ -343,29 +294,6 @@ def test_malformed_state_write_fails_before_mutating_or_writing(trailing_dimensi assert asset.root_view.velocity_write_count == 0 -def test_volume_target_initialization_sets_free_flags_and_writes_full_buffer(): - asset = _make_asset_shell(deformable_type="volume") - - asset._create_buffers() - - assert asset.data.nodal_kinematic_target is not None - torch.testing.assert_close( - asset.data.nodal_kinematic_target.torch[..., 3], - torch.ones((asset.num_instances, asset.max_sim_vertices_per_body)), - ) - assert asset.root_view.target_write_count == 1 - assert asset.root_view.last_indices is None - - -def test_surface_buffer_initialization_has_no_kinematic_target(): - asset = _make_asset_shell(deformable_type="surface") - - asset._create_buffers() - - assert asset.data.nodal_kinematic_target is None - assert asset.root_view.target_write_count == 0 - - def test_lazy_root_means_refresh_in_place_after_update(): root_view = _FakeBodyView(num_instances=2, num_vertices=2) root_view.positions = wp.array( @@ -403,26 +331,7 @@ def test_surface_debug_visualization_uses_below_ground_sentinel(): torch.testing.assert_close(asset.target_visualizer.positions, torch.tensor([[0.0, 0.0, -10.0]])) -def test_invalidation_releases_body_and_material_views(monkeypatch: pytest.MonkeyPatch): - asset = _make_asset_shell(deformable_type="volume", material_view=_FakeMaterialView()) - monkeypatch.setattr(BaseDeformableObject, "_invalidate_initialize_callback", lambda self, event: None) - - asset._invalidate_initialize_callback(None) - - assert asset._root_physx_view is None - assert asset._material_physx_view is None - - -def test_data_implements_backend_neutral_interface(): - asset = _make_asset_shell(deformable_type="volume") - assert isinstance(asset.data, BaseDeformableObjectData) - - def test_factory_export_is_present(): - backend_module = importlib.import_module("isaaclab_ov.assets.deformable_object") assets_module = importlib.import_module("isaaclab_ov.assets") - assert backend_module.DeformableObject is DeformableObject assert assets_module.DeformableObject is DeformableObject - assert DeformableObjectFactory._get_module_name("ovphysx") == "isaaclab_ov.assets.deformable_object" - assert DeformableObject.__backend_name__ == "ovphysx" diff --git a/source/isaaclab_ov/test/assets/test_deformable_views.py b/source/isaaclab_ov/test/assets/test_deformable_views.py index e521784d303f..f3f9d1c1a98a 100644 --- a/source/isaaclab_ov/test/assets/test_deformable_views.py +++ b/source/isaaclab_ov/test/assets/test_deformable_views.py @@ -15,7 +15,6 @@ import warp as wp # noqa: E402 from isaaclab_ov.assets.deformable_object.views import OvPhysxDeformableBodyView # noqa: E402 -from isaaclab_ov.sim.views import OvPhysxView # noqa: E402 from ovphysx.types import TensorType # noqa: E402 wp.init() @@ -36,8 +35,6 @@ class _FakeDtype: TensorType.DEFORMABLE_SIM_NODAL_POSITION: ((2, 5, 3), wp.float32), TensorType.DEFORMABLE_SIM_ELEMENT_INDICES: ((2, 2, 4), wp.int32), TensorType.DEFORMABLE_COLLISION_ELEMENT_INDICES: ((2, 6, 4), wp.int32), - TensorType.SURFACE_DEFORMABLE_SIM_POSITION: ((2, 4, 3), wp.float32), - TensorType.SURFACE_DEFORMABLE_SIM_ELEMENT_INDICES: ((2, 2, 3), wp.int32), } @@ -64,16 +61,6 @@ def read(self, values: wp.array) -> None: wp.copy(values, connectivity) elif self._tensor_type == TensorType.DEFORMABLE_COLLISION_ELEMENT_INDICES: wp.copy(values, wp.full(self.shape, value=4, dtype=wp.int32, device="cpu")) - elif self._tensor_type == TensorType.SURFACE_DEFORMABLE_SIM_ELEMENT_INDICES: - connectivity = wp.array( - [ - [[0, 1, 2], [0, 2, 3]], - [[0, 1, 2], [0, 2, 3]], - ], - dtype=wp.int32, - device="cpu", - ) - wp.copy(values, connectivity) def write(self, values: wp.array, indices: wp.array | None = None, mask: wp.array | None = None) -> None: pass @@ -111,50 +98,6 @@ def create_tensor_binding(self, *, tensor_type: TensorType, pattern: str | None return _MixedTopologyBinding(tensor_type) -def test_volume_view_is_ovphysx_view_with_deformable_dimensions(): - view = OvPhysxDeformableBodyView( - _FakePhysX(), - pattern="/World/env_*/Soft", - device="cpu", - tensor_types=[ - TensorType.DEFORMABLE_SIM_NODAL_POSITION, - TensorType.DEFORMABLE_SIM_ELEMENT_INDICES, - TensorType.DEFORMABLE_COLLISION_ELEMENT_INDICES, - ], - eager=True, - simulation_nodal_position_type=TensorType.DEFORMABLE_SIM_NODAL_POSITION, - simulation_element_indices_type=TensorType.DEFORMABLE_SIM_ELEMENT_INDICES, - collision_element_indices_type=TensorType.DEFORMABLE_COLLISION_ELEMENT_INDICES, - ) - - assert isinstance(view, OvPhysxView) - assert view.count == 2 - assert view.max_simulation_nodes_per_body == 5 - assert view.max_simulation_elements_per_body == 2 - assert view.max_collision_elements_per_body == 6 - assert view.max_collision_nodes_per_body == 5 - - -def test_surface_view_reports_zero_collision_dimensions(): - view = OvPhysxDeformableBodyView( - _FakePhysX(), - pattern="/World/env_*/Cloth", - device="cpu", - tensor_types=[ - TensorType.SURFACE_DEFORMABLE_SIM_POSITION, - TensorType.SURFACE_DEFORMABLE_SIM_ELEMENT_INDICES, - ], - eager=True, - simulation_nodal_position_type=TensorType.SURFACE_DEFORMABLE_SIM_POSITION, - simulation_element_indices_type=TensorType.SURFACE_DEFORMABLE_SIM_ELEMENT_INDICES, - ) - - assert view.max_simulation_nodes_per_body == 4 - assert view.max_simulation_elements_per_body == 2 - assert view.max_collision_elements_per_body == 0 - assert view.max_collision_nodes_per_body == 0 - - def test_volume_view_rejects_mixed_simulation_node_counts(): with pytest.raises( ValueError, diff --git a/source/isaaclab_ov/test/assets/test_rigid_object.py b/source/isaaclab_ov/test/assets/test_rigid_object.py index 2aa9b771fd0f..3052256c6d56 100644 --- a/source/isaaclab_ov/test/assets/test_rigid_object.py +++ b/source/isaaclab_ov/test/assets/test_rigid_object.py @@ -32,6 +32,8 @@ import warp as wp from flaky import flaky +from pxr import UsdPhysics + from isaaclab.test.utils import test_devices # The OVPhysX runtime wheel is optional. Skip gracefully when it is not installed; @@ -46,7 +48,7 @@ from isaaclab.assets import RigidObjectCfg # noqa: E402 from isaaclab.sim import SimulationCfg, build_simulation_context # noqa: E402 from isaaclab.sim.spawners import materials # noqa: E402 -from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR, ISAACLAB_NUCLEUS_DIR # noqa: E402 +from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR # noqa: E402 from isaaclab.utils.math import ( # noqa: E402 combine_frame_transforms, default_orientation, @@ -112,7 +114,7 @@ def _ovphysx_sim_context(device: str, **kwargs): def generate_cubes_scene( num_cubes: int = 1, height=1.0, - api: Literal["none", "rigid_body", "articulation_root"] = "rigid_body", + api: Literal["none", "rigid_body"] = "rigid_body", kinematic_enabled: bool = False, device: str = "cuda:0", ) -> tuple[RigidObject, torch.Tensor]: @@ -146,12 +148,6 @@ def generate_cubes_scene( usd_path=f"{ISAAC_NUCLEUS_DIR}/Props/Blocks/DexCube/dex_cube_instanceable.usd", rigid_props=sim_utils.UsdPhysicsRigidBodyCfg(kinematic_enabled=kinematic_enabled), ) - elif api == "articulation_root": - spawn_cfg = sim_utils.UsdFileCfg( - usd_path=f"{ISAACLAB_NUCLEUS_DIR}/Tests/RigidObject/Cube/dex_cube_instanceable_with_articulation_root.usd", - # Only tune existing bodies; do not create one on this invalid articulation fixture. - rigid_props={"(/.*)?": [sim_utils.UsdPhysicsRigidBodyCfg(kinematic_enabled=kinematic_enabled)]}, - ) else: raise ValueError(f"Unknown api: {api}") @@ -189,44 +185,7 @@ def _write_shape_material(cube_object, materials_nS3: torch.Tensor) -> None: ) -def _read_shape_material(cube_object) -> torch.Tensor: - """Read the per-shape material as a torch tensor ``[N, S, 3]``.""" - return wp.to_torch(cube_object.root_view.get_attribute(TT.RIGID_BODY_SHAPE_FRICTION_AND_RESTITUTION)) - - -@pytest.mark.parametrize("num_cubes", [1, 2]) -@pytest.mark.parametrize("device", test_devices()) -def test_initialization(num_cubes, device): - """Test initialization for prim with rigid body API at the provided prim path.""" - with _ovphysx_sim_context(device=device, auto_add_lighting=True) as sim: - # Generate cubes scene - cube_object, _ = generate_cubes_scene(num_cubes=num_cubes, device=device) - - # Check that the framework doesn't hold excessive strong references. - assert sys.getrefcount(cube_object) < 10 - - # Play sim - sim.reset() - - # Check if object is initialized - assert cube_object.is_initialized - assert len(cube_object.body_names) == 1 - - # Check buffers that exists and have correct shapes - assert cube_object.data.root_pos_w.torch.shape == (num_cubes, 3) - assert cube_object.data.root_quat_w.torch.shape == (num_cubes, 4) - assert cube_object.data.body_mass.torch.shape == (num_cubes, 1) - assert cube_object.data.body_inertia.torch.shape == (num_cubes, 1, 9) - - # Simulate physics - for _ in range(2): - # perform rendering - sim.step() - # update object - cube_object.update(sim.cfg.dt) - - -@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("num_cubes", [2]) @pytest.mark.parametrize("device", test_devices()) def test_initialization_with_kinematic_enabled(num_cubes, device): """Test that initialization for prim with kinematic flag enabled.""" @@ -262,7 +221,7 @@ def test_initialization_with_kinematic_enabled(num_cubes, device): torch.testing.assert_close(cube_object.data.root_com_vel_w.torch, default_root_vel) -@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("num_cubes", [2]) @pytest.mark.parametrize("device", test_devices()) def test_initialization_with_no_rigid_body(num_cubes, device): """Test that initialization fails when no rigid body is found at the provided prim path.""" @@ -274,17 +233,25 @@ def test_initialization_with_no_rigid_body(num_cubes, device): assert sys.getrefcount(cube_object) < 10 # Play sim - with pytest.raises(RuntimeError): + with pytest.raises(RuntimeError, match="Expected 1 prims"): sim.reset() -@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("num_cubes", [2]) @pytest.mark.parametrize("device", test_devices()) +@pytest.mark.xfail( + strict=True, + raises=pytest.fail.Exception, + reason="OVPhysX RigidObject has no ArticulationRootAPI guard; the rigid body initializes normally.", +) def test_initialization_with_articulation_root(num_cubes, device): """Test that initialization fails when an articulation root is found at the provided prim path.""" with _ovphysx_sim_context(device=device, auto_add_lighting=True) as sim: - # Generate cubes scene - cube_object, _ = generate_cubes_scene(num_cubes=num_cubes, api="articulation_root", device=device) + # Generate cubes scene and mark each rigid body as an articulation root. + cube_object, _ = generate_cubes_scene(num_cubes=num_cubes, device=device) + stage = sim_utils.get_current_stage() + for i in range(num_cubes): + UsdPhysics.ArticulationRootAPI.Apply(stage.GetPrimAtPath(f"/World/Table_{i}/Object")) # Check that the framework doesn't hold excessive strong references. assert sys.getrefcount(cube_object) < 10 @@ -294,73 +261,7 @@ def test_initialization_with_articulation_root(num_cubes, device): sim.reset() -@pytest.mark.parametrize("device", test_devices()) -def test_external_force_buffer(device): - """Test if external force buffer correctly updates in the force value is zero case. - - In this test, we apply a non-zero force, then a zero force, then finally a non-zero force - to an object. We check if the force buffer is properly updated at each step. - """ - - # Generate cubes scene - with _ovphysx_sim_context(device=device, add_ground_plane=True, auto_add_lighting=True) as sim: - cube_object, origins = generate_cubes_scene(num_cubes=1, device=device) - - # play the simulator - sim.reset() - - # find bodies to apply the force - body_ids, body_names = cube_object.find_bodies(".*") - - # reset object - cube_object.reset() - - # perform simulation - for step in range(5): - # initiate force tensor - external_wrench_b = torch.zeros(cube_object.num_instances, len(body_ids), 6, device=sim.device) - - if step == 0 or step == 3: - # set a non-zero force - force = 1 - else: - # set a zero force - force = 0 - - # set force value - external_wrench_b[:, :, 0] = force - external_wrench_b[:, :, 3] = force - - # apply force - cube_object.permanent_wrench_composer.set_forces_and_torques_index( - forces=external_wrench_b[..., :3], - torques=external_wrench_b[..., 3:], - body_ids=body_ids, - ) - - # check if the cube's force and torque buffers are correctly updated - for i in range(cube_object.num_instances): - assert cube_object._permanent_wrench_composer.composed_force.torch[i, 0, 0].item() == force - assert cube_object._permanent_wrench_composer.composed_torque.torch[i, 0, 0].item() == force - - # Check if the instantaneous wrench is correctly added to the permanent wrench - cube_object.permanent_wrench_composer.add_forces_and_torques_index( - forces=external_wrench_b[..., :3], - torques=external_wrench_b[..., 3:], - body_ids=body_ids, - ) - - # apply action to the object - cube_object.write_data_to_sim() - - # perform step - sim.step() - - # update buffers - cube_object.update(sim.cfg.dt) - - -@pytest.mark.parametrize("num_cubes", [2, 4]) +@pytest.mark.parametrize("num_cubes", [2]) @pytest.mark.parametrize("device", test_devices()) def test_external_force_on_single_body(num_cubes, device): """Test application of external force on the base of the object. @@ -436,7 +337,7 @@ def test_external_force_on_single_body(num_cubes, device): assert torch.all(cube_object.data.root_pos_w.torch[1::2, 2] < 1.0) -@pytest.mark.parametrize("num_cubes", [2, 4]) +@pytest.mark.parametrize("num_cubes", [2]) @pytest.mark.parametrize("device", test_devices()) def test_external_force_on_single_body_at_position(num_cubes, device): """Test application of external force on the base of the object at a specific position. @@ -540,7 +441,7 @@ def test_external_force_on_single_body_at_position(num_cubes, device): assert torch.all(cube_object.data.root_pos_w.torch[1::2, 2] < 1.0) -@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("num_cubes", [2]) @pytest.mark.parametrize("device", test_devices()) def test_set_rigid_object_state(num_cubes, device): """Test setting the state of the rigid object. @@ -604,7 +505,7 @@ def test_set_rigid_object_state(num_cubes, device): cube_object.update(sim.cfg.dt) -@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("num_cubes", [2]) @pytest.mark.parametrize("device", test_devices()) def test_reset_rigid_object(num_cubes, device): """Test resetting the state of the rigid object.""" @@ -633,6 +534,18 @@ def test_reset_rigid_object(num_cubes, device): cube_object.write_root_velocity_to_sim_index(root_velocity=root_vel) if i % 2 == 0: + # Make both wrench composers active so the reset has something to clear. + cube_object.permanent_wrench_composer.set_forces_and_torques_index( + forces=torch.ones((num_cubes, 1, 3), device=device), + torques=torch.ones((num_cubes, 1, 3), device=device), + ) + cube_object.instantaneous_wrench_composer.add_forces_and_torques_index( + forces=torch.ones((num_cubes, 1, 3), device=device), + torques=torch.ones((num_cubes, 1, 3), device=device), + ) + assert cube_object._instantaneous_wrench_composer.active + assert cube_object._permanent_wrench_composer.active + # reset object cube_object.reset() @@ -645,58 +558,7 @@ def test_reset_rigid_object(num_cubes, device): assert torch.count_nonzero(cube_object._permanent_wrench_composer.composed_torque.torch) == 0 -@pytest.mark.parametrize("num_cubes", [1, 2]) -@pytest.mark.parametrize("device", test_devices()) -def test_rigid_body_set_material_properties(num_cubes, device): - """Test getting and setting per-shape material properties of a rigid object.""" - with _ovphysx_sim_context(device=device, add_ground_plane=True, auto_add_lighting=True) as sim: - cube_object, _ = generate_cubes_scene(num_cubes=num_cubes, device=device) - - # Play sim - sim.reset() - - # Random material per shape: (static_friction, dynamic_friction, restitution), on the CPU. - num_shapes = _num_shapes(cube_object) - static_friction = torch.empty(num_cubes, num_shapes, 1, device="cpu").uniform_(0.4, 0.8) - dynamic_friction = torch.empty(num_cubes, num_shapes, 1, device="cpu").uniform_(0.4, 0.8) - restitution = torch.empty(num_cubes, num_shapes, 1, device="cpu").uniform_(0.0, 0.2) - materials = torch.cat([static_friction, dynamic_friction, restitution], dim=-1) - - # Add friction/restitution to the cubes through the view. - _write_shape_material(cube_object, materials) - - # Simulate physics - sim.step() - cube_object.update(sim.cfg.dt) - - # Read back and verify the round-trip. - torch.testing.assert_close(_read_shape_material(cube_object), materials) - - -@pytest.mark.parametrize("num_cubes", [1, 2]) -@pytest.mark.parametrize("device", test_devices()) -def test_set_material_properties_via_view(num_cubes, device): - """Test setting per-shape material via the OvPhysxView binding API.""" - with _ovphysx_sim_context(device=device, add_ground_plane=True, auto_add_lighting=True) as sim: - cube_object, _ = generate_cubes_scene(num_cubes=num_cubes, device=device) - - # Play sim - sim.reset() - - # Generate random material properties: (static_friction, dynamic_friction, restitution). - num_shapes = _num_shapes(cube_object) - materials = torch.empty(num_cubes, num_shapes, 3, device="cpu").uniform_(0.0, 1.0) - materials[..., 1] = torch.min(materials[..., 0], materials[..., 1]) # dynamic <= static - - # Set material properties through the view, simulate, then read back. - _write_shape_material(cube_object, materials) - sim.step() - cube_object.update(sim.cfg.dt) - - torch.testing.assert_close(_read_shape_material(cube_object), materials) - - -@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("num_cubes", [2]) @pytest.mark.parametrize("device", test_devices()) def test_rigid_body_no_friction(num_cubes, device): """Test that a rigid object with no friction will maintain its velocity when sliding across a plane.""" @@ -741,7 +603,7 @@ def test_rigid_body_no_friction(num_cubes, device): ) -@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("num_cubes", [2]) @pytest.mark.parametrize("device", test_devices()) def test_rigid_body_with_static_friction(num_cubes, device): """Test that static friction applied to rigid object works as expected. @@ -805,7 +667,7 @@ def test_rigid_body_with_static_friction(num_cubes, device): assert (cube_object.data.root_pos_w.torch[..., 0] - initial_root_pos[..., 0] > 0.02).all() -@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("num_cubes", [2]) @pytest.mark.parametrize("device", test_devices()) def test_rigid_body_with_restitution(num_cubes, device): """Test that restitution when applied to rigid object works as expected. @@ -869,7 +731,7 @@ def test_rigid_body_with_restitution(num_cubes, device): assert (curr_z_velocity > 0.0).all() -@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("num_cubes", [2]) @pytest.mark.parametrize("device", test_devices()) def test_rigid_body_set_mass(num_cubes, device): """Test getting and setting mass of rigid object.""" @@ -906,21 +768,23 @@ def test_rigid_body_set_mass(num_cubes, device): # update object cube_object.update(sim.cfg.dt) - masses_to_check = cube_object.data.body_mass.torch - - # Check if mass is set correctly - torch.testing.assert_close(masses, masses_to_check) + # Check the simulation received the masses; the data cache is written by the setter itself. + masses_to_check = wp.to_torch(cube_object.root_view.get_attribute(TT.RIGID_BODY_MASS)) + torch.testing.assert_close(masses_to_check.reshape(num_cubes, 1), masses.cpu()) -@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("num_cubes", [2]) @pytest.mark.parametrize("device", test_devices()) @pytest.mark.parametrize("gravity_enabled", [True, False]) def test_gravity_vec_w(num_cubes, device, gravity_enabled): - """Test that gravity vector direction is set correctly for the rigid object.""" + """Test initialization and that gravity vector direction is set correctly for the rigid object.""" with _ovphysx_sim_context(device=device, gravity_enabled=gravity_enabled) as sim: # Create a scene with random cubes cube_object, _ = generate_cubes_scene(num_cubes=num_cubes, device=device) + # Check that the framework doesn't hold excessive strong references. + assert sys.getrefcount(cube_object) < 10 + # Obtain gravity direction if gravity_enabled: gravity_dir = (0.0, 0.0, -1.0) @@ -930,6 +794,16 @@ def test_gravity_vec_w(num_cubes, device, gravity_enabled): # Play sim sim.reset() + # Check if object is initialized + assert cube_object.is_initialized + assert len(cube_object.body_names) == 1 + + # Check buffers that exists and have correct shapes + assert cube_object.data.root_pos_w.torch.shape == (num_cubes, 3) + assert cube_object.data.root_quat_w.torch.shape == (num_cubes, 4) + assert cube_object.data.body_mass.torch.shape == (num_cubes, 1) + assert cube_object.data.body_inertia.torch.shape == (num_cubes, 1, 9) + # Check that gravity is set correctly assert cube_object.data.GRAVITY_VEC_W.torch[0, 0] == gravity_dir[0] assert cube_object.data.GRAVITY_VEC_W.torch[0, 1] == gravity_dir[1] @@ -950,11 +824,10 @@ def test_gravity_vec_w(num_cubes, device, gravity_enabled): torch.testing.assert_close(cube_object.data.body_acc_w.torch, gravity) -@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("num_cubes", [2]) @pytest.mark.parametrize("device", test_devices()) -@pytest.mark.parametrize("with_offset", [True, False]) @flaky(max_runs=3, min_passes=1) -def test_body_root_state_properties(num_cubes, device, with_offset): +def test_body_root_state_properties(num_cubes, device): """Test the root_com_state_w, root_link_state_w, body_com_state_w, and body_link_state_w properties.""" with _ovphysx_sim_context(device=device, gravity_enabled=False, auto_add_lighting=True) as sim: # Create a scene with random cubes @@ -968,10 +841,7 @@ def test_body_root_state_properties(num_cubes, device, with_offset): assert cube_object.is_initialized # change center of mass offset from link frame - if with_offset: - offset = torch.tensor([0.1, 0.0, 0.0], device=device).repeat(num_cubes, 1) - else: - offset = torch.tensor([0.0, 0.0, 0.0], device=device).repeat(num_cubes, 1) + offset = torch.tensor([0.1, 0.0, 0.0], device=device).repeat(num_cubes, 1) # Read current COMs, mutate the translation, write back via the OVPhysX # ``set_coms_index`` setter (PhysX uses ``root_view.set_coms`` for the same @@ -1010,70 +880,55 @@ def test_body_root_state_properties(num_cubes, device, with_offset): body_com_pose_w = cube_object.data.body_com_pose_w.torch body_com_vel_w = cube_object.data.body_com_vel_w.torch - # if offset is [0,0,0] all root_state_%_w will match and all body_%_w will match - if not with_offset: - torch.testing.assert_close(root_link_pose_w, root_com_pose_w) - torch.testing.assert_close(root_com_vel_w, root_link_vel_w) - torch.testing.assert_close(root_link_pose_w, root_link_pose_w) - torch.testing.assert_close(root_com_vel_w, root_link_vel_w) - torch.testing.assert_close(body_link_pose_w, body_com_pose_w) - torch.testing.assert_close(body_com_vel_w, body_link_vel_w) - torch.testing.assert_close(body_link_pose_w, body_link_pose_w) - torch.testing.assert_close(body_com_vel_w, body_link_vel_w) - else: - # cubes are spinning around center of mass - # position will not match - # center of mass position will be constant (i.e. spinning around com) - torch.testing.assert_close(env_pos + offset, root_com_pose_w[..., :3]) - torch.testing.assert_close(env_pos + offset, body_com_pose_w[..., :3].squeeze(-2)) - # link position will be moving but should stay constant away from center of mass - root_link_state_pos_rel_com = quat_apply_inverse( - root_link_pose_w[..., 3:], - root_link_pose_w[..., :3] - root_com_pose_w[..., :3], - ) - torch.testing.assert_close(-offset, root_link_state_pos_rel_com) - body_link_state_pos_rel_com = quat_apply_inverse( - body_link_pose_w[..., 3:], - body_link_pose_w[..., :3] - body_com_pose_w[..., :3], - ) - torch.testing.assert_close(-offset, body_link_state_pos_rel_com.squeeze(-2)) - - # orientation of com will be a constant rotation from link orientation - com_quat_b = cube_object.data.body_com_quat_b.torch - com_quat_w = quat_mul(body_link_pose_w[..., 3:], com_quat_b) - torch.testing.assert_close(com_quat_w, body_com_pose_w[..., 3:]) - torch.testing.assert_close(com_quat_w.squeeze(-2), root_com_pose_w[..., 3:]) - - # orientation of link will match root state will always match - torch.testing.assert_close(root_link_pose_w[..., 3:], root_link_pose_w[..., 3:]) - torch.testing.assert_close(body_link_pose_w[..., 3:], body_link_pose_w[..., 3:]) - - # lin_vel will not match - # center of mass vel will be constant (i.e. spinning around com) - torch.testing.assert_close(torch.zeros_like(root_com_vel_w[..., :3]), root_com_vel_w[..., :3]) - torch.testing.assert_close(torch.zeros_like(body_com_vel_w[..., :3]), body_com_vel_w[..., :3]) - # link frame will be moving, and should account for the reported COM velocity and offset - lin_vel_rel_root_gt = quat_apply_inverse(root_link_pose_w[..., 3:], root_link_vel_w[..., :3]) - lin_vel_rel_body_gt = quat_apply_inverse(body_link_pose_w[..., 3:], body_link_vel_w[..., :3]) - com_lin_vel_rel_gt = quat_apply_inverse(root_link_pose_w[..., 3:], root_com_vel_w[..., :3]) - com_ang_vel_rel_gt = quat_apply_inverse(root_link_pose_w[..., 3:], root_com_vel_w[..., 3:]) - lin_vel_rel_gt = com_lin_vel_rel_gt + torch.linalg.cross(com_ang_vel_rel_gt, -offset) - torch.testing.assert_close(lin_vel_rel_gt, lin_vel_rel_root_gt, atol=1e-4, rtol=1e-4) - torch.testing.assert_close(lin_vel_rel_gt, lin_vel_rel_body_gt.squeeze(-2), atol=1e-4, rtol=1e-4) - - # ang_vel will always match - torch.testing.assert_close(root_com_vel_w[..., 3:], root_com_vel_w[..., 3:]) - torch.testing.assert_close(root_com_vel_w[..., 3:], root_link_vel_w[..., 3:]) - torch.testing.assert_close(body_com_vel_w[..., 3:], body_com_vel_w[..., 3:]) - torch.testing.assert_close(body_com_vel_w[..., 3:], body_link_vel_w[..., 3:]) - - -@pytest.mark.parametrize("num_cubes", [1, 2]) + # cubes are spinning around center of mass + # position will not match + # center of mass position will be constant (i.e. spinning around com) + torch.testing.assert_close(env_pos + offset, root_com_pose_w[..., :3]) + torch.testing.assert_close(env_pos + offset, body_com_pose_w[..., :3].squeeze(-2)) + # link position will be moving but should stay constant away from center of mass + root_link_state_pos_rel_com = quat_apply_inverse( + root_link_pose_w[..., 3:], + root_link_pose_w[..., :3] - root_com_pose_w[..., :3], + ) + torch.testing.assert_close(-offset, root_link_state_pos_rel_com) + body_link_state_pos_rel_com = quat_apply_inverse( + body_link_pose_w[..., 3:], + body_link_pose_w[..., :3] - body_com_pose_w[..., :3], + ) + torch.testing.assert_close(-offset, body_link_state_pos_rel_com.squeeze(-2)) + + # orientation of com will be a constant rotation from link orientation + com_quat_b = cube_object.data.body_com_quat_b.torch + com_quat_w = quat_mul(body_link_pose_w[..., 3:], com_quat_b) + torch.testing.assert_close(com_quat_w, body_com_pose_w[..., 3:]) + torch.testing.assert_close(com_quat_w.squeeze(-2), root_com_pose_w[..., 3:]) + + # lin_vel will not match + # center of mass vel will be constant (i.e. spinning around com) + torch.testing.assert_close(torch.zeros_like(root_com_vel_w[..., :3]), root_com_vel_w[..., :3]) + torch.testing.assert_close(torch.zeros_like(body_com_vel_w[..., :3]), body_com_vel_w[..., :3]) + # link frame will be moving, and should account for the reported COM velocity and offset + lin_vel_rel_root_gt = quat_apply_inverse(root_link_pose_w[..., 3:], root_link_vel_w[..., :3]) + lin_vel_rel_body_gt = quat_apply_inverse(body_link_pose_w[..., 3:], body_link_vel_w[..., :3]) + com_lin_vel_rel_gt = quat_apply_inverse(root_link_pose_w[..., 3:], root_com_vel_w[..., :3]) + com_ang_vel_rel_gt = quat_apply_inverse(root_link_pose_w[..., 3:], root_com_vel_w[..., 3:]) + lin_vel_rel_gt = com_lin_vel_rel_gt + torch.linalg.cross(com_ang_vel_rel_gt, -offset) + torch.testing.assert_close(lin_vel_rel_gt, lin_vel_rel_root_gt, atol=1e-4, rtol=1e-4) + torch.testing.assert_close(lin_vel_rel_gt, lin_vel_rel_body_gt.squeeze(-2), atol=1e-4, rtol=1e-4) + + # ang_vel will always match + torch.testing.assert_close(root_com_vel_w[..., 3:], root_link_vel_w[..., 3:]) + torch.testing.assert_close(body_com_vel_w[..., 3:], body_link_vel_w[..., 3:]) + + +@pytest.mark.parametrize("num_cubes", [2]) @pytest.mark.parametrize("device", test_devices()) -@pytest.mark.parametrize("with_offset", [True, False]) -@pytest.mark.parametrize("state_location", ["com", "link"]) -def test_write_root_state(num_cubes, device, with_offset, state_location): - """Test the setters for root_state using both the link frame and center of mass as reference frame.""" +@pytest.mark.parametrize("state_location", ["com", "link", "root"]) +def test_write_root_state(num_cubes, device, state_location): + """Test the root-state setters in the link, center-of-mass, and root reference frames. + + The written frame reads back unchanged and the other frame follows it through the COM offset. + """ with _ovphysx_sim_context(device=device, gravity_enabled=False, auto_add_lighting=True) as sim: # Create a scene with random cubes cube_object, env_pos = generate_cubes_scene(num_cubes=num_cubes, height=0.0, device=device) @@ -1086,10 +941,7 @@ def test_write_root_state(num_cubes, device, with_offset, state_location): assert cube_object.is_initialized # change center of mass offset from link frame - if with_offset: - offset = torch.tensor([0.1, 0.0, 0.0], device=device).repeat(num_cubes, 1) - else: - offset = torch.tensor([0.0, 0.0, 0.0], device=device).repeat(num_cubes, 1) + offset = torch.tensor([0.1, 0.0, 0.0], device=device).repeat(num_cubes, 1) com = cube_object.data.body_com_pose_b.torch.clone() # shape (N, 1, 7) com[..., :3] = offset.to(com.device).unsqueeze(1) @@ -1101,8 +953,7 @@ def test_write_root_state(num_cubes, device, with_offset, state_location): # check center of mass has been set torch.testing.assert_close(cube_object.data.body_com_pose_b.torch, com) - rand_state = torch.zeros(num_cubes, 13, device=device) - rand_state[..., :7] = cube_object.data.default_root_pose.torch + rand_state = torch.rand(num_cubes, 13, device=device) rand_state[..., :3] += env_pos # make quaternion a unit vector rand_state[..., 3:7] = torch.nn.functional.normalize(rand_state[..., 3:7], dim=-1) @@ -1114,143 +965,54 @@ def test_write_root_state(num_cubes, device, with_offset, state_location): # update buffers cube_object.update(sim.cfg.dt) + write_env_ids = None if i % 2 == 0 else env_idx if state_location == "com": - if i % 2 == 0: - cube_object.write_root_com_pose_to_sim_index(root_pose=rand_state[..., :7]) - cube_object.write_root_com_velocity_to_sim_index(root_velocity=rand_state[..., 7:]) - else: - cube_object.write_root_com_pose_to_sim_index(root_pose=rand_state[..., :7], env_ids=env_idx) - cube_object.write_root_com_velocity_to_sim_index(root_velocity=rand_state[..., 7:], env_ids=env_idx) - elif state_location == "link": - if i % 2 == 0: - cube_object.write_root_link_pose_to_sim_index(root_pose=rand_state[..., :7]) - cube_object.write_root_link_velocity_to_sim_index(root_velocity=rand_state[..., 7:]) - else: - cube_object.write_root_link_pose_to_sim_index(root_pose=rand_state[..., :7], env_ids=env_idx) - cube_object.write_root_link_velocity_to_sim_index( - root_velocity=rand_state[..., 7:], env_ids=env_idx - ) - - if state_location == "com": - torch.testing.assert_close(rand_state[..., :7], cube_object.data.root_com_pose_w.torch) - torch.testing.assert_close(rand_state[..., 7:], cube_object.data.root_com_vel_w.torch) + cube_object.write_root_com_pose_to_sim_index(root_pose=rand_state[..., :7], env_ids=write_env_ids) + cube_object.write_root_com_velocity_to_sim_index( + root_velocity=rand_state[..., 7:], env_ids=write_env_ids + ) elif state_location == "link": - torch.testing.assert_close(rand_state[..., :7], cube_object.data.root_link_pose_w.torch) - torch.testing.assert_close(rand_state[..., 7:], cube_object.data.root_link_vel_w.torch) - - -@pytest.mark.parametrize("num_cubes", [1, 2]) -@pytest.mark.parametrize("device", test_devices()) -@pytest.mark.parametrize("with_offset", [True]) -@pytest.mark.parametrize("state_location", ["com", "link", "root"]) -def test_write_state_functions_data_consistency(num_cubes, device, with_offset, state_location): - """Test the setters for root_state using both the link frame and center of mass as reference frame.""" - with _ovphysx_sim_context(device=device, gravity_enabled=False, auto_add_lighting=True) as sim: - # Create a scene with random cubes - cube_object, env_pos = generate_cubes_scene(num_cubes=num_cubes, height=0.0, device=device) - env_idx = torch.tensor([x for x in range(num_cubes)], dtype=torch.int32) - - # Play sim - sim.reset() - - # Check if cube_object is initialized - assert cube_object.is_initialized - - # change center of mass offset from link frame - if with_offset: - offset = torch.tensor([0.1, 0.0, 0.0], device=device).repeat(num_cubes, 1) - else: - offset = torch.tensor([0.0, 0.0, 0.0], device=device).repeat(num_cubes, 1) - - com = cube_object.data.body_com_pose_b.torch.clone() # shape (N, 1, 7) - com[..., :3] = offset.to(com.device).unsqueeze(1) - cube_object.set_coms_index( - coms=wp.from_torch(com.contiguous(), dtype=wp.transformf), - env_ids=wp.from_torch(env_idx, dtype=wp.int32), - ) - - # check ceter of mass has been set - torch.testing.assert_close(cube_object.data.body_com_pose_b.torch, com) - - rand_state = torch.rand(num_cubes, 13, device=device) - rand_state[..., :3] += env_pos - # make quaternion a unit vector - rand_state[..., 3:7] = torch.nn.functional.normalize(rand_state[..., 3:7], dim=-1) - - env_idx = env_idx.to(device) - - # perform step - sim.step() - # update buffers - cube_object.update(sim.cfg.dt) + cube_object.write_root_link_pose_to_sim_index(root_pose=rand_state[..., :7], env_ids=write_env_ids) + cube_object.write_root_link_velocity_to_sim_index( + root_velocity=rand_state[..., 7:], env_ids=write_env_ids + ) + elif state_location == "root": + cube_object.write_root_pose_to_sim_index(root_pose=rand_state[..., :7], env_ids=write_env_ids) + cube_object.write_root_velocity_to_sim_index(root_velocity=rand_state[..., 7:], env_ids=write_env_ids) - if state_location == "com": - cube_object.write_root_com_pose_to_sim_index(root_pose=rand_state[..., :7]) - cube_object.write_root_com_velocity_to_sim_index(root_velocity=rand_state[..., 7:]) - elif state_location == "link": - cube_object.write_root_link_pose_to_sim_index(root_pose=rand_state[..., :7]) - cube_object.write_root_link_velocity_to_sim_index(root_velocity=rand_state[..., 7:]) - elif state_location == "root": - cube_object.write_root_pose_to_sim_index(root_pose=rand_state[..., :7]) - cube_object.write_root_velocity_to_sim_index(root_velocity=rand_state[..., 7:]) - - if state_location == "com": - root_com_pose_w = cube_object.data.root_com_pose_w.torch - root_com_vel_w = cube_object.data.root_com_vel_w.torch - body_com_pose_b = cube_object.data.body_com_pose_b.torch - expected_root_link_pos, expected_root_link_quat = combine_frame_transforms( - root_com_pose_w[:, :3], - root_com_pose_w[:, 3:], - quat_rotate(quat_inv(body_com_pose_b[:, 0, 3:7]), -body_com_pose_b[:, 0, :3]), - quat_inv(body_com_pose_b[:, 0, 3:7]), - ) - expected_root_link_pose = torch.cat((expected_root_link_pos, expected_root_link_quat), dim=1) root_link_pose_w = cube_object.data.root_link_pose_w.torch root_link_vel_w = cube_object.data.root_link_vel_w.torch - # test both root_pose and root_link successfully updated when root_com updates - torch.testing.assert_close(expected_root_link_pose, root_link_pose_w) - # skip lin_vel because it differs from link frame, this should be fine because we are only checking - # if velocity update is triggered, which can be determined by comparing angular velocity - torch.testing.assert_close(root_com_vel_w[:, 3:], root_link_vel_w[:, 3:]) - torch.testing.assert_close(expected_root_link_pose, root_link_pose_w) - torch.testing.assert_close(root_com_vel_w[:, 3:], cube_object.data.root_com_vel_w.torch[:, 3:]) - elif state_location == "link": - root_link_pose_w = cube_object.data.root_link_pose_w.torch - root_link_vel_w = cube_object.data.root_link_vel_w.torch - body_com_pose_b = cube_object.data.body_com_pose_b.torch - expected_com_pos, expected_com_quat = combine_frame_transforms( - root_link_pose_w[:, :3], - root_link_pose_w[:, 3:], - body_com_pose_b[:, 0, :3], - body_com_pose_b[:, 0, 3:7], - ) - expected_com_pose = torch.cat((expected_com_pos, expected_com_quat), dim=1) root_com_pose_w = cube_object.data.root_com_pose_w.torch root_com_vel_w = cube_object.data.root_com_vel_w.torch - # test both root_pose and root_com successfully updated when root_link updates - torch.testing.assert_close(expected_com_pose, root_com_pose_w) + body_com_pose_b = cube_object.data.body_com_pose_b.torch + if state_location == "com": + torch.testing.assert_close(rand_state[..., :7], root_com_pose_w) + torch.testing.assert_close(rand_state[..., 7:], root_com_vel_w) + expected_root_link_pos, expected_root_link_quat = combine_frame_transforms( + root_com_pose_w[:, :3], + root_com_pose_w[:, 3:], + quat_rotate(quat_inv(body_com_pose_b[:, 0, 3:7]), -body_com_pose_b[:, 0, :3]), + quat_inv(body_com_pose_b[:, 0, 3:7]), + ) + expected_root_link_pose = torch.cat((expected_root_link_pos, expected_root_link_quat), dim=1) + # test root_link successfully updated when root_com updates + torch.testing.assert_close(expected_root_link_pose, root_link_pose_w) + else: + # the "root" setters write the link pose and the COM velocity + torch.testing.assert_close(rand_state[..., :7], root_link_pose_w) + written_vel = root_link_vel_w if state_location == "link" else root_com_vel_w + torch.testing.assert_close(rand_state[..., 7:], written_vel) + expected_com_pos, expected_com_quat = combine_frame_transforms( + root_link_pose_w[:, :3], + root_link_pose_w[:, 3:], + body_com_pose_b[:, 0, :3], + body_com_pose_b[:, 0, 3:7], + ) + expected_com_pose = torch.cat((expected_com_pos, expected_com_quat), dim=1) + # test root_com successfully updated when root_link updates + torch.testing.assert_close(expected_com_pose, root_com_pose_w) # skip lin_vel because it differs from link frame, this should be fine because we are only checking # if velocity update is triggered, which can be determined by comparing angular velocity - torch.testing.assert_close(root_link_vel_w[:, 3:], root_com_vel_w[:, 3:]) - torch.testing.assert_close(root_link_pose_w, cube_object.data.root_link_pose_w.torch) - torch.testing.assert_close(root_link_vel_w[:, 3:], cube_object.data.root_com_vel_w.torch[:, 3:]) - elif state_location == "root": - root_link_pose_w = cube_object.data.root_link_pose_w.torch - root_com_vel_w = cube_object.data.root_com_vel_w.torch - body_com_pose_b = cube_object.data.body_com_pose_b.torch - expected_com_pos, expected_com_quat = combine_frame_transforms( - root_link_pose_w[:, :3], - root_link_pose_w[:, 3:], - body_com_pose_b[:, 0, :3], - body_com_pose_b[:, 0, 3:7], - ) - expected_com_pose = torch.cat((expected_com_pos, expected_com_quat), dim=1) - root_com_pose_w = cube_object.data.root_com_pose_w.torch - root_link_vel_w = cube_object.data.root_link_vel_w.torch - # test both root_com and root_link successfully updated when root_pose updates - torch.testing.assert_close(expected_com_pose, root_com_pose_w) - torch.testing.assert_close(root_com_vel_w, cube_object.data.root_com_vel_w.torch) - torch.testing.assert_close(root_link_pose_w, cube_object.data.root_link_pose_w.torch) torch.testing.assert_close(root_com_vel_w[:, 3:], root_link_vel_w[:, 3:]) diff --git a/source/isaaclab_ov/test/assets/test_rigid_object_collection.py b/source/isaaclab_ov/test/assets/test_rigid_object_collection.py index 3ebc5bd47f46..aca62d6becdb 100644 --- a/source/isaaclab_ov/test/assets/test_rigid_object_collection.py +++ b/source/isaaclab_ov/test/assets/test_rigid_object_collection.py @@ -160,36 +160,6 @@ def generate_cubes_scene( return cube_object_colection, origins -@pytest.mark.parametrize("num_envs", [1, 2]) -@pytest.mark.parametrize("num_cubes", [1, 3]) -@pytest.mark.parametrize("device", test_devices()) -def test_initialization(num_envs, num_cubes, device): - """Test initialization for prim with rigid body API at the provided prim path.""" - with _ovphysx_sim_context(device=device, auto_add_lighting=True) as sim: - object_collection, _ = generate_cubes_scene(num_envs=num_envs, num_cubes=num_cubes, device=device) - - # Check that the framework doesn't hold excessive strong references. - assert sys.getrefcount(object_collection) < 10 - - # Play sim - sim.reset() - - # Check if object is initialized - assert object_collection.is_initialized - assert len(object_collection.body_names) == num_cubes - - # Check buffers that exist and have correct shapes - assert object_collection.data.body_link_pos_w.torch.shape == (num_envs, num_cubes, 3) - assert object_collection.data.body_link_quat_w.torch.shape == (num_envs, num_cubes, 4) - assert object_collection.data.body_mass.torch.shape == (num_envs, num_cubes) - assert object_collection.data.body_inertia.torch.shape == (num_envs, num_cubes, 9) - - # Simulate physics - for _ in range(2): - sim.step() - object_collection.update(sim.cfg.dt) - - @pytest.mark.parametrize("device", test_devices()) def test_id_conversion(device): """Test environment and object index conversion to physics view indices.""" @@ -227,8 +197,8 @@ def test_id_conversion(device): assert (wp.to_torch(view_ids) == expected[3]).all() -@pytest.mark.parametrize("num_envs", [1, 2]) -@pytest.mark.parametrize("num_cubes", [1, 3]) +@pytest.mark.parametrize("num_envs", [2]) +@pytest.mark.parametrize("num_cubes", [3]) @pytest.mark.parametrize("device", test_devices()) def test_initialization_with_kinematic_enabled(num_envs, num_cubes, device): """Test that initialization for prim with kinematic flag enabled.""" @@ -263,7 +233,7 @@ def test_initialization_with_kinematic_enabled(num_envs, num_cubes, device): torch.testing.assert_close(object_collection.data.body_link_vel_w.torch, default_body_vel) -@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("num_cubes", [2]) @pytest.mark.parametrize("device", test_devices()) def test_initialization_with_no_rigid_body(num_cubes, device): """Test that initialization fails when no rigid body is found at the provided prim path.""" @@ -274,65 +244,12 @@ def test_initialization_with_no_rigid_body(num_cubes, device): assert sys.getrefcount(object_collection) < 10 # Play sim - with pytest.raises(RuntimeError): + with pytest.raises(RuntimeError, match="Expected 1 prims"): sim.reset() -@pytest.mark.parametrize("device", test_devices()) -def test_external_force_buffer(device): - """Test if external force buffer correctly updates in the force value is zero case.""" - num_envs = 2 - num_cubes = 1 - with _ovphysx_sim_context(device=device, auto_add_lighting=True) as sim: - object_collection, origins = generate_cubes_scene(num_envs=num_envs, num_cubes=num_cubes, device=device) - sim.reset() - - # find objects to apply the force - object_ids, object_names = object_collection.find_bodies(".*") - # reset object - object_collection.reset() - - # perform simulation - for step in range(5): - # initiate force tensor - external_wrench_b = torch.zeros(object_collection.num_instances, len(object_ids), 6, device=sim.device) - - # decide if zero or non-zero force - if step == 0 or step == 3: - force = 1.0 - else: - force = 0.0 - - # apply force to the object - external_wrench_b[:, :, 0] = force - external_wrench_b[:, :, 3] = force - - object_collection.permanent_wrench_composer.set_forces_and_torques_index( - forces=external_wrench_b[..., :3], - torques=external_wrench_b[..., 3:], - body_ids=object_ids, - env_ids=None, - ) - - # check if the object collection's force and torque buffers are correctly updated - for i in range(num_envs): - assert object_collection._permanent_wrench_composer.composed_force.torch[i, 0, 0].item() == force - assert object_collection._permanent_wrench_composer.composed_torque.torch[i, 0, 0].item() == force - - object_collection.instantaneous_wrench_composer.add_forces_and_torques_index( - body_ids=object_ids, - forces=external_wrench_b[..., :3], - torques=external_wrench_b[..., 3:], - ) - - # apply action to the object collection - object_collection.write_data_to_sim() - sim.step() - object_collection.update(sim.cfg.dt) - - -@pytest.mark.parametrize("num_envs", [1, 2]) -@pytest.mark.parametrize("num_cubes", [1, 4]) +@pytest.mark.parametrize("num_envs", [2]) +@pytest.mark.parametrize("num_cubes", [4]) @pytest.mark.parametrize("device", test_devices()) def test_external_force_on_single_body(num_envs, num_cubes, device): """Test application of external force on the base of the object.""" @@ -392,8 +309,8 @@ def test_external_force_on_single_body(num_envs, num_cubes, device): assert torch.all(object_collection.data.body_link_pos_w.torch[:, 1::2, 2] < 1.0) -@pytest.mark.parametrize("num_envs", [1, 2]) -@pytest.mark.parametrize("num_cubes", [1, 4]) +@pytest.mark.parametrize("num_envs", [2]) +@pytest.mark.parametrize("num_cubes", [4]) @pytest.mark.parametrize("device", test_devices()) def test_external_force_on_single_body_at_position(num_envs, num_cubes, device): """Test application of external force on the base of the object at a specific position. @@ -474,8 +391,8 @@ def test_external_force_on_single_body_at_position(num_envs, num_cubes, device): assert torch.all(object_collection.data.body_link_pos_w.torch[:, 1::2, 2] < 1.0) -@pytest.mark.parametrize("num_envs", [1, 3]) -@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("num_envs", [3]) +@pytest.mark.parametrize("num_cubes", [2]) @pytest.mark.parametrize("device", test_devices()) @pytest.mark.parametrize("gravity_enabled", [False]) def test_set_object_state(num_envs, num_cubes, device, gravity_enabled): @@ -544,12 +461,11 @@ def test_set_object_state(num_envs, num_cubes, device, gravity_enabled): object_collection.update(sim.cfg.dt) -@pytest.mark.parametrize("num_envs", [1, 4]) -@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("num_envs", [4]) +@pytest.mark.parametrize("num_cubes", [2]) @pytest.mark.parametrize("device", test_devices()) -@pytest.mark.parametrize("with_offset", [True, False]) @pytest.mark.parametrize("gravity_enabled", [False]) -def test_object_state_properties(num_envs, num_cubes, device, with_offset, gravity_enabled): +def test_object_state_properties(num_envs, num_cubes, device, gravity_enabled): """Test the object_com_state_w and object_link_state_w properties.""" with _ovphysx_sim_context(device=device, gravity_enabled=gravity_enabled, auto_add_lighting=True) as sim: cube_object, env_pos = generate_cubes_scene(num_envs=num_envs, num_cubes=num_cubes, height=0.0, device=device) @@ -561,11 +477,7 @@ def test_object_state_properties(num_envs, num_cubes, device, with_offset, gravi assert cube_object.is_initialized # change center of mass offset from link frame - offset = ( - torch.tensor([0.1, 0.0, 0.0], device=device).repeat(num_envs, num_cubes, 1) - if with_offset - else torch.tensor([0.0, 0.0, 0.0], device=device).repeat(num_envs, num_cubes, 1) - ) + offset = torch.tensor([0.1, 0.0, 0.0], device=device).repeat(num_envs, num_cubes, 1) # Read current COMs, mutate the translation, write back via the OVPhysX # ``set_coms_index`` setter (PhysX uses ``root_view.set_coms`` + reshape helpers @@ -600,57 +512,51 @@ def test_object_state_properties(num_envs, num_cubes, device, with_offset, gravi object_com_pose_w = cube_object.data.body_com_pose_w.torch object_com_vel_w = cube_object.data.body_com_vel_w.torch - # if offset is [0,0,0] all object_state_%_w will match and all body_%_w will match - if not with_offset: - torch.testing.assert_close(object_link_pose_w, object_com_pose_w) - torch.testing.assert_close(object_com_vel_w, object_link_vel_w) - else: - # cubes are spinning around center of mass - # position will not match - # center of mass position will be constant (i.e. spinning around com) - torch.testing.assert_close(init_com, object_com_pose_w[..., :3]) - - # link position will be moving but should stay constant away from center of mass - object_link_state_pos_rel_com = quat_apply_inverse( - object_link_pose_w[..., 3:], - object_link_pose_w[..., :3] - object_com_pose_w[..., :3], - ) + # cubes are spinning around center of mass + # position will not match + # center of mass position will be constant (i.e. spinning around com) + torch.testing.assert_close(init_com, object_com_pose_w[..., :3]) - torch.testing.assert_close(-offset, object_link_state_pos_rel_com) + # link position will be moving but should stay constant away from center of mass + object_link_state_pos_rel_com = quat_apply_inverse( + object_link_pose_w[..., 3:], + object_link_pose_w[..., :3] - object_com_pose_w[..., :3], + ) - # orientation of com will be a constant rotation from link orientation - com_quat_b = cube_object.data.body_com_quat_b.torch - com_quat_w = quat_mul(object_link_pose_w[..., 3:], com_quat_b) - torch.testing.assert_close(com_quat_w, object_com_pose_w[..., 3:]) + torch.testing.assert_close(-offset, object_link_state_pos_rel_com) - # orientation of link will match object state will always match - torch.testing.assert_close(object_link_pose_w[..., 3:], object_link_pose_w[..., 3:]) + # orientation of com will be a constant rotation from link orientation + com_quat_b = cube_object.data.body_com_quat_b.torch + com_quat_w = quat_mul(object_link_pose_w[..., 3:], com_quat_b) + torch.testing.assert_close(com_quat_w, object_com_pose_w[..., 3:]) - # lin_vel will not match - # center of mass vel will be constant (i.e. spinning around com) - torch.testing.assert_close( - torch.zeros_like(object_com_vel_w[..., :3]), - object_com_vel_w[..., :3], - ) + # lin_vel will not match + # center of mass vel will be constant (i.e. spinning around com) + torch.testing.assert_close( + torch.zeros_like(object_com_vel_w[..., :3]), + object_com_vel_w[..., :3], + ) - # link frame will be moving, and should be equal to input angular velocity cross offset - lin_vel_rel_object_gt = quat_apply_inverse(object_link_pose_w[..., 3:], object_link_vel_w[..., :3]) - lin_vel_rel_gt = torch.linalg.cross(spin_twist.repeat(num_envs, num_cubes, 1)[..., 3:], -offset) - torch.testing.assert_close(lin_vel_rel_gt, lin_vel_rel_object_gt, atol=1e-4, rtol=1e-3) + # link frame will be moving, and should be equal to input angular velocity cross offset + lin_vel_rel_object_gt = quat_apply_inverse(object_link_pose_w[..., 3:], object_link_vel_w[..., :3]) + lin_vel_rel_gt = torch.linalg.cross(spin_twist.repeat(num_envs, num_cubes, 1)[..., 3:], -offset) + torch.testing.assert_close(lin_vel_rel_gt, lin_vel_rel_object_gt, atol=1e-4, rtol=1e-3) - # ang_vel will always match - torch.testing.assert_close(object_com_vel_w[..., 3:], object_com_vel_w[..., 3:]) - torch.testing.assert_close(object_com_vel_w[..., 3:], object_link_vel_w[..., 3:]) + # ang_vel will always match + torch.testing.assert_close(object_com_vel_w[..., 3:], object_link_vel_w[..., 3:]) -@pytest.mark.parametrize("num_envs", [1, 3]) -@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("num_envs", [3]) +@pytest.mark.parametrize("num_cubes", [2]) @pytest.mark.parametrize("device", test_devices()) -@pytest.mark.parametrize("with_offset", [True, False]) -@pytest.mark.parametrize("state_location", ["com", "link"]) +@pytest.mark.parametrize("state_location", ["com", "link", "root"]) @pytest.mark.parametrize("gravity_enabled", [False]) -def test_write_object_state(num_envs, num_cubes, device, with_offset, state_location, gravity_enabled): - """Test the setters for object_state using both the link frame and center of mass as reference frame.""" +def test_write_object_state(num_envs, num_cubes, device, state_location, gravity_enabled): + """Test the object-state setters in the link, center-of-mass, and root reference frames. + + The written frame reads back unchanged and the other frame follows it through the link-to-COM + transform measured from the simulation before the write. + """ with _ovphysx_sim_context(device=device, gravity_enabled=gravity_enabled, auto_add_lighting=True) as sim: # Create a scene with random cubes cube_object, env_pos = generate_cubes_scene(num_envs=num_envs, num_cubes=num_cubes, height=0.0, device=device) @@ -663,11 +569,7 @@ def test_write_object_state(num_envs, num_cubes, device, with_offset, state_loca assert cube_object.is_initialized # change center of mass offset from link frame - offset = ( - torch.tensor([0.1, 0.0, 0.0], device=device).repeat(num_envs, num_cubes, 1) - if with_offset - else torch.tensor([0.0, 0.0, 0.0], device=device).repeat(num_envs, num_cubes, 1) - ) + offset = torch.tensor([0.1, 0.0, 0.0], device=device).repeat(num_envs, num_cubes, 1) com = cube_object.data.body_com_pose_b.torch.clone() # shape (num_envs, num_cubes, 7) com[..., :3] = offset.to(com.device) @@ -678,8 +580,7 @@ def test_write_object_state(num_envs, num_cubes, device, with_offset, state_loca # check center of mass has been set torch.testing.assert_close(cube_object.data.body_com_pose_b.torch, com) - rand_state = torch.zeros(num_envs, num_cubes, 13, device=device) - rand_state[..., :7] = cube_object.data.default_body_pose.torch + rand_state = torch.rand(num_envs, num_cubes, 13, device=device) rand_state[..., :3] += cube_object.data.body_link_pos_w.torch # make quaternion a unit vector rand_state[..., 3:7] = torch.nn.functional.normalize(rand_state[..., 3:7], dim=-1) @@ -690,39 +591,62 @@ def test_write_object_state(num_envs, num_cubes, device, with_offset, state_loca sim.step() cube_object.update(sim.cfg.dt) + body_link_pose_w = cube_object.data.body_link_pose_w.torch + body_com_pose_w = cube_object.data.body_com_pose_w.torch + object_link_to_com_pos, object_link_to_com_quat = subtract_frame_transforms( + body_link_pose_w[..., :3].view(-1, 3), + body_link_pose_w[..., 3:7].view(-1, 4), + body_com_pose_w[..., :3].view(-1, 3), + body_com_pose_w[..., 3:7].view(-1, 4), + ) + + ids = {} if i % 2 == 0 else {"env_ids": env_ids, "body_ids": object_ids} if state_location == "com": - if i % 2 == 0: - cube_object.write_body_com_pose_to_sim_index(body_poses=rand_state[..., :7]) - cube_object.write_body_com_velocity_to_sim_index(body_velocities=rand_state[..., 7:]) - else: - cube_object.write_body_com_pose_to_sim_index( - body_poses=rand_state[..., :7], env_ids=env_ids, body_ids=object_ids - ) - cube_object.write_body_com_velocity_to_sim_index( - body_velocities=rand_state[..., 7:], env_ids=env_ids, body_ids=object_ids - ) + cube_object.write_body_com_pose_to_sim_index(body_poses=rand_state[..., :7], **ids) + cube_object.write_body_com_velocity_to_sim_index(body_velocities=rand_state[..., 7:], **ids) elif state_location == "link": - if i % 2 == 0: - cube_object.write_body_link_pose_to_sim_index(body_poses=rand_state[..., :7]) - cube_object.write_body_link_velocity_to_sim_index(body_velocities=rand_state[..., 7:]) - else: - cube_object.write_body_link_pose_to_sim_index( - body_poses=rand_state[..., :7], env_ids=env_ids, body_ids=object_ids - ) - cube_object.write_body_link_velocity_to_sim_index( - body_velocities=rand_state[..., 7:], env_ids=env_ids, body_ids=object_ids - ) + cube_object.write_body_link_pose_to_sim_index(body_poses=rand_state[..., :7], **ids) + cube_object.write_body_link_velocity_to_sim_index(body_velocities=rand_state[..., 7:], **ids) + elif state_location == "root": + cube_object.write_body_link_pose_to_sim_index(body_poses=rand_state[..., :7], **ids) + cube_object.write_body_com_velocity_to_sim_index(body_velocities=rand_state[..., 7:], **ids) + link_pose_w = cube_object.data.body_link_pose_w.torch + link_vel_w = cube_object.data.body_link_vel_w.torch + com_pose_w = cube_object.data.body_com_pose_w.torch + com_vel_w = cube_object.data.body_com_vel_w.torch if state_location == "com": - torch.testing.assert_close(rand_state[..., :7], cube_object.data.body_com_pose_w.torch) - torch.testing.assert_close(rand_state[..., 7:], cube_object.data.body_com_vel_w.torch) - elif state_location == "link": - torch.testing.assert_close(rand_state[..., :7], cube_object.data.body_link_pose_w.torch) - torch.testing.assert_close(rand_state[..., 7:], cube_object.data.body_link_vel_w.torch) + torch.testing.assert_close(rand_state[..., :7], com_pose_w) + torch.testing.assert_close(rand_state[..., 7:], com_vel_w) + expected_link_pos, expected_link_quat = combine_frame_transforms( + com_pose_w[..., :3].view(-1, 3), + com_pose_w[..., 3:].view(-1, 4), + quat_rotate(quat_inv(object_link_to_com_quat), -object_link_to_com_pos), + quat_inv(object_link_to_com_quat), + ) + expected_link_pose = torch.cat((expected_link_pos, expected_link_quat), dim=1).view(num_envs, -1, 7) + # test body_link successfully updated when body_com updates + torch.testing.assert_close(expected_link_pose, link_pose_w) + else: + torch.testing.assert_close(rand_state[..., :7], link_pose_w) + written_vel = link_vel_w if state_location == "link" else com_vel_w + torch.testing.assert_close(rand_state[..., 7:], written_vel) + expected_com_pos, expected_com_quat = combine_frame_transforms( + link_pose_w[..., :3].view(-1, 3), + link_pose_w[..., 3:].view(-1, 4), + object_link_to_com_pos, + object_link_to_com_quat, + ) + expected_com_pose = torch.cat((expected_com_pos, expected_com_quat), dim=1).view(num_envs, -1, 7) + # test body_com successfully updated when body_link updates + torch.testing.assert_close(expected_com_pose, com_pose_w) + # skip lin_vel because it differs from link frame, this should be fine because we are only checking + # if velocity update is triggered, which can be determined by comparing angular velocity + torch.testing.assert_close(com_vel_w[..., 3:], link_vel_w[..., 3:]) -@pytest.mark.parametrize("num_envs", [1, 3]) -@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("num_envs", [3]) +@pytest.mark.parametrize("num_cubes", [2]) @pytest.mark.parametrize("device", test_devices()) def test_reset_object_collection(num_envs, num_cubes, device): """Test resetting the state of the rigid object.""" @@ -744,6 +668,18 @@ def test_reset_object_collection(num_envs, num_cubes, device): object_collection.write_body_com_velocity_to_sim_index(body_velocities=body_vel) if i % 2 == 0: + # Make both wrench composers active so the reset has something to clear. + object_collection.permanent_wrench_composer.set_forces_and_torques_index( + forces=torch.ones((num_envs, num_cubes, 3), device=device), + torques=torch.ones((num_envs, num_cubes, 3), device=device), + ) + object_collection.instantaneous_wrench_composer.add_forces_and_torques_index( + forces=torch.ones((num_envs, num_cubes, 3), device=device), + torques=torch.ones((num_envs, num_cubes, 3), device=device), + ) + assert object_collection._instantaneous_wrench_composer.active + assert object_collection._permanent_wrench_composer.active + object_collection.reset() # Reset should zero external forces and torques @@ -755,8 +691,8 @@ def test_reset_object_collection(num_envs, num_cubes, device): assert torch.count_nonzero(object_collection._permanent_wrench_composer.composed_torque.torch) == 0 -@pytest.mark.parametrize("num_envs", [1, 3]) -@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("num_envs", [3]) +@pytest.mark.parametrize("num_cubes", [2]) @pytest.mark.parametrize("device", test_devices()) def test_set_material_properties(num_envs, num_cubes, device): """Test getting and setting per-shape material properties of a rigid object collection. @@ -796,20 +732,33 @@ def test_set_material_properties(num_envs, num_cubes, device): torch.testing.assert_close(materials_check, materials) -@pytest.mark.parametrize("num_envs", [1, 3]) -@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("num_envs", [3]) +@pytest.mark.parametrize("num_cubes", [2]) @pytest.mark.parametrize("device", test_devices()) @pytest.mark.parametrize("gravity_enabled", [True, False]) def test_gravity_vec_w(num_envs, num_cubes, device, gravity_enabled): - """Test that gravity vector direction is set correctly for the rigid object.""" + """Test initialization and that gravity vector direction is set correctly for the rigid object.""" with _ovphysx_sim_context(device=device, gravity_enabled=gravity_enabled, auto_add_lighting=True) as sim: object_collection, _ = generate_cubes_scene(num_envs=num_envs, num_cubes=num_cubes, device=device) + # Check that the framework doesn't hold excessive strong references. + assert sys.getrefcount(object_collection) < 10 + # Obtain gravity direction gravity_dir = (0.0, 0.0, -1.0) if gravity_enabled else (0.0, 0.0, 0.0) sim.reset() + # Check if object is initialized + assert object_collection.is_initialized + assert len(object_collection.body_names) == num_cubes + + # Check buffers that exist and have correct shapes + assert object_collection.data.body_link_pos_w.torch.shape == (num_envs, num_cubes, 3) + assert object_collection.data.body_link_quat_w.torch.shape == (num_envs, num_cubes, 4) + assert object_collection.data.body_mass.torch.shape == (num_envs, num_cubes) + assert object_collection.data.body_inertia.torch.shape == (num_envs, num_cubes, 9) + # Check if gravity vector is set correctly gravity_vec = object_collection.data.GRAVITY_VEC_W.torch assert gravity_vec[0, 0, 0] == gravity_dir[0] @@ -828,145 +777,3 @@ def test_gravity_vec_w(num_envs, num_cubes, device, gravity_enabled): # Check the body accelerations are correct torch.testing.assert_close(object_collection.data.body_com_acc_w.torch, gravity) - - -@pytest.mark.parametrize("num_envs", [1, 3]) -@pytest.mark.parametrize("num_cubes", [1, 2]) -@pytest.mark.parametrize("device", test_devices()) -@pytest.mark.parametrize("with_offset", [True]) -@pytest.mark.parametrize("state_location", ["com", "link", "root"]) -@pytest.mark.parametrize("gravity_enabled", [False]) -def test_write_object_state_functions_data_consistency( - num_envs, num_cubes, device, with_offset, state_location, gravity_enabled -): - """Test the setters for object_state using both the link frame and center of mass as reference frame.""" - with _ovphysx_sim_context(device=device, gravity_enabled=gravity_enabled, auto_add_lighting=True) as sim: - # Create a scene with random cubes - cube_object, env_pos = generate_cubes_scene(num_envs=num_envs, num_cubes=num_cubes, height=0.0, device=device) - env_ids = torch.tensor([x for x in range(num_envs)], dtype=torch.int32) - object_ids = torch.tensor([x for x in range(num_cubes)], dtype=torch.int32) - - sim.reset() - - # Check if cube_object is initialized - assert cube_object.is_initialized - - # change center of mass offset from link frame - offset = ( - torch.tensor([0.1, 0.0, 0.0], device=device).repeat(num_envs, num_cubes, 1) - if with_offset - else torch.tensor([0.0, 0.0, 0.0], device=device).repeat(num_envs, num_cubes, 1) - ) - - com = cube_object.data.body_com_pose_b.torch.clone() # shape (num_envs, num_cubes, 7) - com[..., :3] = offset.to(com.device) - cube_object.set_coms_index( - coms=wp.from_torch(com.contiguous(), dtype=wp.transformf), - env_ids=wp.from_torch(env_ids, dtype=wp.int32), - ) - - # check center of mass has been set - torch.testing.assert_close(cube_object.data.body_com_pose_b.torch, com) - - rand_state = torch.rand(num_envs, num_cubes, 13, device=device) - rand_state[..., :3] += cube_object.data.body_link_pos_w.torch - # make quaternion a unit vector - rand_state[..., 3:7] = torch.nn.functional.normalize(rand_state[..., 3:7], dim=-1) - - env_ids = env_ids.to(device) - object_ids = object_ids.to(device) - sim.step() - cube_object.update(sim.cfg.dt) - - body_link_pose_w = cube_object.data.body_link_pose_w.torch - body_com_pose_w = cube_object.data.body_com_pose_w.torch - object_link_to_com_pos, object_link_to_com_quat = subtract_frame_transforms( - body_link_pose_w[..., :3].view(-1, 3), - body_link_pose_w[..., 3:7].view(-1, 4), - body_com_pose_w[..., :3].view(-1, 3), - body_com_pose_w[..., 3:7].view(-1, 4), - ) - - if state_location == "com": - cube_object.write_body_com_pose_to_sim_index( - body_poses=rand_state[..., :7], env_ids=env_ids, body_ids=object_ids - ) - cube_object.write_body_com_velocity_to_sim_index( - body_velocities=rand_state[..., 7:], env_ids=env_ids, body_ids=object_ids - ) - elif state_location == "link": - cube_object.write_body_link_pose_to_sim_index( - body_poses=rand_state[..., :7], env_ids=env_ids, body_ids=object_ids - ) - cube_object.write_body_link_velocity_to_sim_index( - body_velocities=rand_state[..., 7:], env_ids=env_ids, body_ids=object_ids - ) - elif state_location == "root": - cube_object.write_body_link_pose_to_sim_index( - body_poses=rand_state[..., :7], env_ids=env_ids, body_ids=object_ids - ) - cube_object.write_body_com_velocity_to_sim_index( - body_velocities=rand_state[..., 7:], env_ids=env_ids, body_ids=object_ids - ) - - if state_location == "com": - com_pose_w = cube_object.data.body_com_pose_w.torch - com_vel_w = cube_object.data.body_com_vel_w.torch - expected_root_link_pos, expected_root_link_quat = combine_frame_transforms( - com_pose_w[..., :3].view(-1, 3), - com_pose_w[..., 3:].view(-1, 4), - quat_rotate(quat_inv(object_link_to_com_quat), -object_link_to_com_pos), - quat_inv(object_link_to_com_quat), - ) - expected_object_link_pose = torch.cat((expected_root_link_pos, expected_root_link_quat), dim=1).view( - num_envs, -1, 7 - ) - link_pose_w = cube_object.data.body_link_pose_w.torch - link_vel_w = cube_object.data.body_link_vel_w.torch - # test both root_pose and root_link successfully updated when root_com updates - torch.testing.assert_close(expected_object_link_pose, link_pose_w) - # skip lin_vel because it differs from link frame, this should be fine because we are only checking - # if velocity update is triggered, which can be determined by comparing angular velocity - torch.testing.assert_close(com_vel_w[..., 3:], link_vel_w[..., 3:]) - torch.testing.assert_close(expected_object_link_pose, link_pose_w) - torch.testing.assert_close(com_vel_w[..., 3:], cube_object.data.body_com_vel_w.torch[..., 3:]) - elif state_location == "link": - link_pose_w = cube_object.data.body_link_pose_w.torch - link_vel_w = cube_object.data.body_link_vel_w.torch - expected_com_pos, expected_com_quat = combine_frame_transforms( - link_pose_w[..., :3].view(-1, 3), - link_pose_w[..., 3:].view(-1, 4), - object_link_to_com_pos, - object_link_to_com_quat, - ) - expected_object_com_pose = torch.cat((expected_com_pos, expected_com_quat), dim=1).view(num_envs, -1, 7) - com_pose_w = cube_object.data.body_com_pose_w.torch - com_vel_w = cube_object.data.body_com_vel_w.torch - # test both root_pose and root_com successfully updated when root_link updates - torch.testing.assert_close(expected_object_com_pose, com_pose_w) - # skip lin_vel because it differs from link frame, this should be fine because we are only checking - # if velocity update is triggered, which can be determined by comparing angular velocity - torch.testing.assert_close(link_vel_w[..., 3:], com_vel_w[..., 3:]) - torch.testing.assert_close(link_pose_w, cube_object.data.body_link_pose_w.torch) - torch.testing.assert_close(link_vel_w[..., 3:], cube_object.data.body_com_vel_w.torch[..., 3:]) - elif state_location == "root": - body_link_pose_w = cube_object.data.body_link_pose_w.torch - body_com_vel_w = cube_object.data.body_com_vel_w.torch - expected_object_com_pos, expected_object_com_quat = combine_frame_transforms( - body_link_pose_w[..., :3].view(-1, 3), - body_link_pose_w[..., 3:].view(-1, 4), - object_link_to_com_pos, - object_link_to_com_quat, - ) - expected_object_com_pose = torch.cat((expected_object_com_pos, expected_object_com_quat), dim=1).view( - num_envs, -1, 7 - ) - com_pose_w = cube_object.data.body_com_pose_w.torch - com_vel_w = cube_object.data.body_com_vel_w.torch - link_pose_w = cube_object.data.body_link_pose_w.torch - link_vel_w = cube_object.data.body_link_vel_w.torch - # test both root_com and root_link successfully updated when root_pose updates - torch.testing.assert_close(expected_object_com_pose, com_pose_w) - torch.testing.assert_close(body_com_vel_w, com_vel_w) - torch.testing.assert_close(body_link_pose_w, link_pose_w) - torch.testing.assert_close(body_com_vel_w[..., 3:], link_vel_w[..., 3:]) diff --git a/source/isaaclab_ov/test/assets/test_rigid_object_helpers.py b/source/isaaclab_ov/test/assets/test_rigid_object_helpers.py deleted file mode 100644 index 7fdb408cdd29..000000000000 --- a/source/isaaclab_ov/test/assets/test_rigid_object_helpers.py +++ /dev/null @@ -1,44 +0,0 @@ -# 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 - -"""OVPhysX-only unit tests for rigid-object helpers. - -These tests cover OVPhysX-specific scaffolding (mock binding-set shape -contracts for ``asset_kind="rigid_object"``) that has no PhysX equivalent -and therefore does not appear in the PhysX-mirrored ``test_rigid_object.py``. -""" - -from __future__ import annotations - -import pytest -import warp as wp - -# The OVPhysX runtime wheel is optional. Skip gracefully when it is not installed; -# CI jobs that need OVPhysX coverage install it explicitly. -pytest.importorskip("ovphysx.types", reason="ovphysx wheel not installed") - -from isaaclab_ov import tensor_types as TT # noqa: E402 -from isaaclab_ov.test.fixtures.views import MockOvPhysxBindingSet # noqa: E402 - -wp.init() - - -def test_mock_binding_set_rigid_object_shapes(): - pytest.importorskip("isaaclab_ov.tensor_types").RIGID_BODY_POSE # gates on wheel - - bindings = MockOvPhysxBindingSet( - num_instances=4, - num_joints=0, - num_bodies=1, - asset_kind="rigid_object", - ) - assert bindings.bindings[TT.RIGID_BODY_POSE].shape == (4, 7) - assert bindings.bindings[TT.RIGID_BODY_VELOCITY].shape == (4, 6) - assert bindings.bindings[TT.RIGID_BODY_WRENCH].shape == (4, 9) - assert bindings.bindings[TT.RIGID_BODY_MASS].shape == (4,) - assert bindings.bindings[TT.RIGID_BODY_INERTIA].shape == (4, 9) - # Articulation-only bindings must be absent - assert TT.DOF_POSITION not in bindings.bindings - assert TT.LINK_WRENCH not in bindings.bindings diff --git a/source/isaaclab_ov/test/physics/test_ovphysx_compat.py b/source/isaaclab_ov/test/physics/test_ovphysx_compat.py index b887cd59f09a..53aaef7d3e54 100644 --- a/source/isaaclab_ov/test/physics/test_ovphysx_compat.py +++ b/source/isaaclab_ov/test/physics/test_ovphysx_compat.py @@ -41,22 +41,24 @@ _CURRENT_ENTRY_POINTS = {"warmup": "warmup", "destroy": "destroy"} -def test_detect_ovphysx_version_reads_distribution_metadata(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(importlib.metadata, "version", lambda name: "0.5.11") - assert detect_ovphysx_version() == Version("0.5.11") +def _missing_distribution(name: str) -> str: + raise importlib.metadata.PackageNotFoundError(name) -def test_detect_ovphysx_version_returns_none_when_uninstalled(monkeypatch: pytest.MonkeyPatch): - def _missing(name: str) -> str: - raise importlib.metadata.PackageNotFoundError(name) - - monkeypatch.setattr(importlib.metadata, "version", _missing) - assert detect_ovphysx_version() is None - - -def test_detect_ovphysx_version_returns_none_for_unparseable_version(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(importlib.metadata, "version", lambda name: "internal-build") - assert detect_ovphysx_version() is None +@pytest.mark.parametrize( + ("metadata_version", "expected"), + [ + (lambda name: "0.5.11", Version("0.5.11")), + (_missing_distribution, None), + (lambda name: "internal-build", None), + ], + ids=["installed", "uninstalled", "unparsable"], +) +def test_detect_ovphysx_version_reads_distribution_metadata( + monkeypatch: pytest.MonkeyPatch, metadata_version, expected: Version | None +): + monkeypatch.setattr(importlib.metadata, "version", metadata_version) + assert detect_ovphysx_version() == expected @pytest.mark.parametrize( @@ -86,7 +88,6 @@ def test_published_entry_points_are_read_only(): (Version("0.5.11"), True), (Version("0.6.0.dev1+trunk.e15a64a2"), False), (Version("0.6"), False), - (Version("0.6.2"), False), (Version("1.0"), False), ], ) diff --git a/source/isaaclab_ov/test/physics/test_ovphysx_scene_data_backend.py b/source/isaaclab_ov/test/physics/test_ovphysx_scene_data_backend.py index 1787f108cb13..e338f7fcca18 100644 --- a/source/isaaclab_ov/test/physics/test_ovphysx_scene_data_backend.py +++ b/source/isaaclab_ov/test/physics/test_ovphysx_scene_data_backend.py @@ -83,40 +83,6 @@ def _fake_rigid_body_prim(path: str): ) -def test_manager_full_stage_requirement_preserves_authored_environments(): - """A full-stage request keeps every authored environment in memory.""" - from isaaclab_ov.physics import OvPhysxManager - - from pxr import Sdf, Usd - - stage = _make_two_environment_stage() - previous = OvPhysxManager._requires_full_stage - try: - OvPhysxManager._requires_full_stage = True - usda = OvPhysxManager._serialize_selected_stage(stage) - layer = Sdf.Layer.CreateAnonymous("full.usda") - assert layer.ImportFromString(usda) - exported = Usd.Stage.Open(layer) - assert exported.GetPrimAtPath("/World/envs/env_0/Cube").IsValid() - assert exported.GetPrimAtPath("/World/envs/env_1/Cube").IsValid() - finally: - OvPhysxManager._requires_full_stage = previous - - -def test_manager_full_stage_never_replays_runtime_clones(): - """A full-stage load never mutates the already loaded runtime through cloning.""" - from isaaclab_ov.physics import OvPhysxManager - - fake = SimpleNamespace(clone=lambda *args, **kwargs: pytest.fail("clone must not run")) - previous = OvPhysxManager._pending_clones - try: - OvPhysxManager._pending_clones = [("/env_0", ["/env_1"], [(1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0)])] - OvPhysxManager._replay_pending_clones(fake, requires_full_stage=True) - assert OvPhysxManager._pending_clones == [] - finally: - OvPhysxManager._pending_clones = previous - - def test_manager_full_stage_materializes_only_missing_heterogeneous_targets(): """A full-stage export copies missing heterogeneous targets without replacing authored ones.""" from isaaclab_ov.physics import OvPhysxManager @@ -318,8 +284,9 @@ def test_manager_full_stage_materialization_is_atomic_on_invalid_target(): OvPhysxManager._pending_clones = previous -def test_manager_replays_pending_runtime_clones_without_full_stage_requirement(): - """The default replay path forwards final world transforms to the runtime.""" +@pytest.mark.parametrize("requires_full_stage", [False, True]) +def test_manager_replays_pending_runtime_clones_without_full_stage_requirement(requires_full_stage): + """Only the default replay path forwards final world transforms; a full-stage load never clones.""" from isaaclab_ov.physics import OvPhysxManager class FakePhysX: @@ -337,26 +304,19 @@ def wait_op(self, operation): previous = OvPhysxManager._pending_clones try: OvPhysxManager._pending_clones = [("/env_0", ["/env_1"], [(1.0, 2.0, 3.0, 0.0, 0.0, 0.0, 1.0)])] - OvPhysxManager._replay_pending_clones(fake, requires_full_stage=False) - assert fake.calls == [ - ("clone", "/env_0", ["/env_1"], [(1.0, 2.0, 3.0, 0.0, 0.0, 0.0, 1.0)]), - ("wait_op", 19), - ] + OvPhysxManager._replay_pending_clones(fake, requires_full_stage=requires_full_stage) + if requires_full_stage: + assert fake.calls == [] + else: + assert fake.calls == [ + ("clone", "/env_0", ["/env_1"], [(1.0, 2.0, 3.0, 0.0, 0.0, 0.0, 1.0)]), + ("wait_op", 19), + ] assert OvPhysxManager._pending_clones == [] finally: OvPhysxManager._pending_clones = previous -def test_manager_resets_full_stage_requirement_between_contexts(monkeypatch): - """Closing a manager context resets the full-stage requirement.""" - from isaaclab_ov.physics import OvPhysxManager - - monkeypatch.setattr(OvPhysxManager, "backend", None) - OvPhysxManager.require_full_stage() - OvPhysxManager.close() - assert OvPhysxManager._requires_full_stage is False - - def test_manager_forced_rewarm_invalidates_bindings_before_loading(monkeypatch): """A forced re-warm invalidates views before replacing their attached stage.""" from isaaclab_ov.physics import OvPhysxManager @@ -505,11 +465,11 @@ def test_manager_serializes_env0_only_stage_in_memory(caplog): assert "stripped 1 env_ subtrees from in-memory USD" in caplog.text -def test_manager_logs_when_serialized_stage_has_no_envs(caplog): - """The in-memory serializer diagnoses stages without the standard env namespace.""" +def test_manager_serializes_stage_without_envs_as_is(): + """The in-memory serializer keeps stages without the standard env namespace intact.""" from isaaclab_ov.physics import OvPhysxManager - from pxr import Usd, UsdGeom + from pxr import Sdf, Usd, UsdGeom stage = Usd.Stage.CreateInMemory() UsdGeom.Xform.Define(stage, "/World/Ground") @@ -517,12 +477,13 @@ def test_manager_logs_when_serialized_stage_has_no_envs(caplog): previous = OvPhysxManager._requires_full_stage try: OvPhysxManager._requires_full_stage = False - with caplog.at_level(logging.DEBUG, logger=OvPhysxManager.__module__): - OvPhysxManager._serialize_selected_stage(stage) + usda = OvPhysxManager._serialize_selected_stage(stage) finally: OvPhysxManager._requires_full_stage = previous - assert "no cloned environments to strip — serialized stage as-is" in caplog.text + layer = Sdf.Layer.CreateAnonymous("no_envs.usda") + assert layer.ImportFromString(usda) + assert Usd.Stage.Open(layer).GetPrimAtPath("/World/Ground").IsValid() def test_manager_attaches_and_releases_owned_ovstage(monkeypatch): @@ -763,43 +724,6 @@ def fail_population(*args, **kwargs): assert destroyed == ["isaaclab"] -def test_manager_keeps_kit_physx_provider_and_registers_deformable_schema(monkeypatch, tmp_path): - """Keep Kit's PhysX provider while registering the wheel's deformable schema.""" - from isaaclab_ov.physics import OvPhysxManager - - class FakeRegistry: - def __init__(self): - self.get_all_calls = 0 - self.registered_paths = [] - - def GetAllPlugins(self): - self.get_all_calls += 1 - return [SimpleNamespace(name="physxSchema")] - - def RegisterPlugins(self, path): - self.registered_paths.append(path) - - registry = FakeRegistry() - fake_pxr = ModuleType("pxr") - fake_pxr.Plug = SimpleNamespace(Registry=lambda: registry) - fake_ovphysx = ModuleType("ovphysx") - deformable_schema_path = tmp_path / "ovphysx" / "plugins" / "usd" / "OmniUsdPhysicsDeformableSchema" / "resources" - deformable_schema_path.mkdir(parents=True) - fake_ovphysx.codeless_schema_paths = lambda: [deformable_schema_path] - monkeypatch.setitem(sys.modules, "pxr", fake_pxr) - monkeypatch.setitem(sys.modules, "ovphysx", fake_ovphysx) - - previous = OvPhysxManager._physx_schemas_registered - OvPhysxManager._physx_schemas_registered = False - try: - OvPhysxManager._ensure_physx_schemas_registered() - finally: - OvPhysxManager._physx_schemas_registered = previous - - assert registry.get_all_calls == 1 - assert registry.registered_paths == [[str(deformable_schema_path)]] - - def test_ovphysx_cfg_does_not_register_unselected_backend_schemas(monkeypatch): """Creating an eager preset alternative leaves global USD plugins unchanged.""" from isaaclab_ov.physics import OvPhysxCfg, OvPhysxManager @@ -816,22 +740,6 @@ def test_ovphysx_cfg_does_not_register_unselected_backend_schemas(monkeypatch): assert calls == [] -def test_ovphysx_manager_registers_schemas_during_pre_stage_setup(monkeypatch): - """The selected OvPhysX manager registers schemas in its pre-stage hook.""" - from isaaclab_ov.physics import OvPhysxManager - - calls = [] - monkeypatch.setattr( - OvPhysxManager, - "_ensure_physx_schemas_registered", - classmethod(lambda cls: calls.append(cls)), - ) - - OvPhysxManager._prepare_stage_creation() - - assert calls == [OvPhysxManager] - - def test_automatic_physx_selection_prepares_ovphysx_before_stage_creation(monkeypatch): """Automatic kitless PhysX selection prepares OvPhysX before creating the USD stage.""" from isaaclab_ov.physics import OvPhysxManager @@ -852,9 +760,10 @@ def _prepare_stage_creation(cls): events = [] monkeypatch.setattr(simulation_context_module, "has_kit", lambda: False) + # Record schema registration so the real pre-stage hook must reach it. monkeypatch.setattr( OvPhysxManager, - "_prepare_stage_creation", + "_ensure_physx_schemas_registered", classmethod(lambda cls: events.append("ovphysx")), ) diff --git a/source/isaaclab_ov/test/sensors/check_imu.py b/source/isaaclab_ov/test/sensors/check_imu.py deleted file mode 100644 index 6215be4e7458..000000000000 --- a/source/isaaclab_ov/test/sensors/check_imu.py +++ /dev/null @@ -1,79 +0,0 @@ -# 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 - -"""Standalone validation script for the OVPhysX IMU sensor. - -Run with: - uv run python source/isaaclab_ov/test/sensors/check_imu.py - -Spawns a free-falling sphere with an IMU attached, steps the simulation for -100 steps, and prints the angular velocity and linear acceleration in the -IMU body frame. In freefall the proper acceleration is zero (only gravity -acts on the body), so the IMU reading should be approximately ``[0, 0, 0]`` -— the finite-difference world-frame acceleration ``(-g)`` cancels the IMU's -gravity bias ``(+g)``. - -Runs kitless under ``uv run python`` — no AppLauncher needed. -""" - -from __future__ import annotations - -import torch -from isaaclab_ov.physics import OvPhysxCfg - -import isaaclab.sim as sim_utils -from isaaclab.assets import RigidObject, RigidObjectCfg -from isaaclab.sensors.imu import Imu, ImuCfg -from isaaclab.sim import SimulationCfg, build_simulation_context - - -def main() -> None: - sim_cfg = SimulationCfg(physics=OvPhysxCfg(), device="cpu", dt=0.005) - with build_simulation_context(sim_cfg=sim_cfg) as sim: - # Spawn one ball per env at /World/env_/ball. - spawn_cfg = sim_utils.SphereCfg( - radius=0.25, - rigid_props=sim_utils.UsdPhysicsRigidBodyCfg(), - mass_props=sim_utils.MassCfg(mass=0.5), - collision_props=sim_utils.UsdPhysicsCollisionCfg(), - visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.0, 0.0, 1.0)), - ) - # /World/env_ Xforms are siblings under /World — no envs container needed - num_envs = 2 - for i in range(num_envs): - sim_utils.create_prim(f"/World/env_{i}", "Xform", translation=(i * 5.0, 0.0, 0.0)) - spawn_cfg.func(f"/World/env_{i}/ball", spawn_cfg, translation=(0.0, 0.0, 1.0)) - - balls = RigidObject( - RigidObjectCfg( - prim_path="/World/env_[^/]+/ball", - spawn=None, - init_state=RigidObjectCfg.InitialStateCfg(pos=(0.0, 0.0, 1.0)), - ) - ) - imu = Imu(ImuCfg(prim_path="/World/env_[^/]+/ball")) - sim.reset() - - dt = sim.get_physics_dt() - for step in range(100): - balls.write_data_to_sim() - sim.step() - balls.update(dt) - imu.update(dt, force_recompute=True) - - if step in (0, 10, 50, 99): - ang = imu.data.ang_vel_b.torch - lin = imu.data.lin_acc_b.torch - print(f"step={step:3d} ang_vel_b={ang[0].cpu().numpy()} lin_acc_b={lin[0].cpu().numpy()}") - - # Final sanity check: in freefall the proper acceleration is zero - # (gravity cancels the bias). The norm should be near 0. - final_lin = imu.data.lin_acc_b.torch - magnitude = torch.linalg.norm(final_lin, dim=1).cpu().numpy() - print(f"final |lin_acc_b| per env = {magnitude}") - - -if __name__ == "__main__": - main() diff --git a/source/isaaclab_ov/test/sensors/check_pva.py b/source/isaaclab_ov/test/sensors/check_pva.py deleted file mode 100644 index 4386d45b700b..000000000000 --- a/source/isaaclab_ov/test/sensors/check_pva.py +++ /dev/null @@ -1,92 +0,0 @@ -# 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 - -"""Standalone validation script for the OVPhysX PVA sensor. - -Run with: - uv run python source/isaaclab_ov/test/sensors/check_pva.py - -Spawns a free-falling sphere with a PVA sensor attached, steps the simulation -for 100 steps, and prints the world-frame position, body-frame linear/angular -velocities, body-frame linear/angular accelerations, and projected gravity. -In freefall the coordinate linear acceleration is ``-g`` rotated into the -body frame, so its magnitude should converge to ``~9.81 m/s^2``. The -projected gravity unit vector should remain ``≈ (0, 0, -1)`` for a body that -stays upright. - -Runs kitless under ``uv run python`` — no AppLauncher needed. -""" - -from __future__ import annotations - -import torch -from isaaclab_ov.physics import OvPhysxCfg - -import isaaclab.sim as sim_utils -from isaaclab.assets import RigidObject, RigidObjectCfg -from isaaclab.sensors.pva import Pva, PvaCfg -from isaaclab.sim import SimulationCfg, build_simulation_context - - -def main() -> None: - sim_cfg = SimulationCfg(physics=OvPhysxCfg(), device="cpu", dt=0.005) - with build_simulation_context(sim_cfg=sim_cfg) as sim: - # Spawn one ball per env at /World/env_/ball. - spawn_cfg = sim_utils.SphereCfg( - radius=0.25, - rigid_props=sim_utils.UsdPhysicsRigidBodyCfg(), - mass_props=sim_utils.MassCfg(mass=0.5), - collision_props=sim_utils.UsdPhysicsCollisionCfg(), - visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.0, 0.0, 1.0)), - ) - # /World/env_ Xforms are siblings under /World — no envs container needed - num_envs = 2 - for i in range(num_envs): - sim_utils.create_prim(f"/World/env_{i}", "Xform", translation=(i * 5.0, 0.0, 0.0)) - spawn_cfg.func(f"/World/env_{i}/ball", spawn_cfg, translation=(0.0, 0.0, 1.0)) - - balls = RigidObject( - RigidObjectCfg( - prim_path="/World/env_[^/]+/ball", - spawn=None, - init_state=RigidObjectCfg.InitialStateCfg(pos=(0.0, 0.0, 1.0)), - ) - ) - pva = Pva(PvaCfg(prim_path="/World/env_[^/]+/ball")) - sim.reset() - - dt = sim.get_physics_dt() - for step in range(100): - balls.write_data_to_sim() - sim.step() - balls.update(dt) - pva.update(dt, force_recompute=True) - - if step in (0, 10, 50, 99): - data = pva.data - pos = data.pos_w.torch - lin_v = data.lin_vel_b.torch - ang_v = data.ang_vel_b.torch - lin_a = data.lin_acc_b.torch - ang_a = data.ang_acc_b.torch - pg = data.projected_gravity_b.torch - print( - f"step={step:3d} pos_w={pos[0].cpu().numpy()} lin_vel_b={lin_v[0].cpu().numpy()} " - f"ang_vel_b={ang_v[0].cpu().numpy()} lin_acc_b={lin_a[0].cpu().numpy()} " - f"ang_acc_b={ang_a[0].cpu().numpy()} proj_g={pg[0].cpu().numpy()}" - ) - - # Final sanity check: in freefall the coordinate acceleration magnitude is ~g. - final_lin = pva.data.lin_acc_b.torch - magnitude = torch.linalg.norm(final_lin, dim=1).cpu().numpy() - print(f"final |lin_acc_b| per env = {magnitude} (expected ~9.81)") - # projected gravity unit vector magnitude is 1. - final_pg = pva.data.projected_gravity_b.torch - pg_magnitude = torch.linalg.norm(final_pg, dim=1).cpu().numpy() - print(f"final |projected_gravity_b| per env = {pg_magnitude} (expected ~1.0)") - - -if __name__ == "__main__": - main() diff --git a/source/isaaclab_ov/test/sensors/test_contact_sensor.py b/source/isaaclab_ov/test/sensors/test_contact_sensor.py index 60f4dc7dc220..7d8bec585308 100644 --- a/source/isaaclab_ov/test/sensors/test_contact_sensor.py +++ b/source/isaaclab_ov/test/sensors/test_contact_sensor.py @@ -20,14 +20,9 @@ :exc:`RuntimeError` by ``pytest.skip``-ing on the unlocked device so single-device runs finish cleanly. -Two v1-unsupported feature tests are kept but marked ``@pytest.mark.skip``: - -* :func:`test_friction_reporting` — requires ``track_friction_forces``; see - issue #5325 and ``docs/superpowers/specs/2026-04-27-ovphysx-contact-api-gaps.md``. -* :func:`test_invalid_prim_paths_config` — requires ``track_friction_forces`` - (used to configure the scene); same issue. -* :func:`test_invalid_max_contact_points_config` — requires - ``track_friction_forces``; same issue. +The PhysX friction-force, contact-point, and their config-validation tests have no +counterpart here: :class:`ContactSensor` raises :exc:`NotImplementedError` for +``track_friction_forces`` and ``track_contact_points`` (issue #5325). The ``disable_contact_processing`` PhysX/Kit setting is not available in the kitless OVPhysX flow; :func:`test_cube_contact_time` and @@ -338,8 +333,7 @@ def test_sphere_contact_time(device): @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -@pytest.mark.parametrize("clock_age", [2.5, 10.0, 30.0]) -def test_first_transition_with_aged_clock(device, clock_age): +def test_first_transition_with_aged_clock(device): """Regression for #7283: transitions must still be reported once the sensor clock has aged. The sensor clock is a float32 accumulator whose rounding error grows with simulated time. On a @@ -351,6 +345,9 @@ def test_first_transition_with_aged_clock(device, clock_age): # The lazy (zero-history) cadence is covered by the kernel-level tolerance test: on GPU this # backend serves stale contact forces when buffers refresh only on data access, which is # unrelated to the tolerance under test here. + # At 2.5 s the float32 clock error already breaks a fixed 1e-8 tolerance; not every age does + # (10 s happens to round cleanly), so keep an age that fails without the adaptive tolerance. + clock_age = 2.5 history_length = 1 decimation = 1 poll_dt = decimation * _SIM_DT @@ -436,9 +433,9 @@ def _hold(pose: torch.Tensor, num_steps: int) -> None: @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -@pytest.mark.parametrize("num_envs", [1, 6, 24]) -def test_cube_stack_contact_filtering(device, num_envs): +def test_cube_stack_contact_filtering(device): """Checks contact sensor reporting for filtering stacked cube prims.""" + num_envs = 6 with _ovphysx_sim_context(device=device, dt=_SIM_DT, add_lighting=True) as sim: # Instance new scene for the current terrain and contact prim. # OVPhysX uses fnmatch globs (not regex), so ``Env_*`` rather than ``Env_.*``. @@ -498,72 +495,8 @@ def test_cube_stack_contact_filtering(device, num_envs): assert contact_sensor.data.net_normal_forces_w.torch.sum().item() > 0.0 -def test_no_contact_reporting(): - """Test that OVPhysX contact sensor returns zero forces when no filter is configured. - - Without ``filter_prim_paths_expr``, the ``normal_force_matrix_w`` buffer is not - populated (no per-partner breakdown is available), and ``net_normal_forces_w`` - should still reflect the aggregate contact force. This test verifies the - simpler "unfiltered, CPU-only" path by using CPU and letting the scene - settle: with no filter the ``normal_force_matrix_w`` sum is expected to be zero - (the buffer is not allocated). - - Note: - The PhysX variant of this test forcibly disables contact processing via - a Carbonite setting (``/physics/disableContactProcessing``). That - setting is not available in the kitless OVPhysX flow; instead we test - that a sensor with no filter has a zero ``normal_force_matrix_w``. - """ - with _ovphysx_sim_context(device="cpu", dt=_SIM_DT, add_lighting=True) as sim: - scene_cfg = ContactSensorSceneCfg(num_envs=2, env_spacing=1.0, lazy_sensor_update=False) - scene_cfg.terrain = FLAT_TERRAIN_CFG - # -- cube 1 - scene_cfg.shape = CUBE_CFG.replace(prim_path="{ENV_REGEX_NS}/Cube_1") - scene_cfg.shape.init_state.pos = (0, -1.0, 1.0) - # -- cube 2 (on top of cube 1) - scene_cfg.shape_2 = CUBE_CFG.replace(prim_path="{ENV_REGEX_NS}/Cube_2") - scene_cfg.shape_2.init_state.pos = (0, -1.0, 1.525) - # No filter paths — normal_force_matrix_w will not be allocated. - scene_cfg.contact_sensor = ContactSensorCfg( - prim_path="{ENV_REGEX_NS}/Cube_1", - track_pose=True, - debug_vis=False, - update_period=0.0, - filter_prim_paths_expr=[], - ) - scene_cfg.contact_sensor_2 = ContactSensorCfg( - prim_path="{ENV_REGEX_NS}/Cube_2", - track_pose=True, - debug_vis=False, - update_period=0.0, - filter_prim_paths_expr=[], - ) - scene = InteractiveScene(scene_cfg) - - # Play the simulation - sim.reset() - - contact_sensor: ContactSensor = scene["contact_sensor"] - contact_sensor_2: ContactSensor = scene["contact_sensor_2"] - - # Let the scene settle - scene.reset() - for _ in range(500): - _perform_sim_step(sim, scene, _SIM_DT) - - # Without filter_prim_paths_expr the normal_force_matrix_w buffer is not allocated; - # its sum should be zero (or the tensor is None). - fm1 = contact_sensor.data.normal_force_matrix_w - fm2 = contact_sensor_2.data.normal_force_matrix_w - if fm1 is not None: - assert fm1.torch.sum().item() == 0.0 - if fm2 is not None: - assert fm2.torch.sum().item() == 0.0 - - @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -@pytest.mark.parametrize("num_envs", [1, 3]) -def test_multi_body_per_sensor_indexing(device, num_envs): +def test_multi_body_per_sensor_indexing(device): """Ground-truth body-index check for a single sensor that resolves to two bodies. OVPhysX :class:`ContactBinding` returns sensors in **pattern-major** order @@ -574,8 +507,10 @@ def test_multi_body_per_sensor_indexing(device, num_envs): multi-body discovery path with one cube on the ground and one floating above it. After the scene settles, only the bottom cube should report a non-zero net force. An env-major bug would attribute that force to the - wrong (env, body) slot — caught here. + wrong (env, body) slot — caught here. A single env would make both layouts + coincide, so the test uses several. """ + num_envs = 3 with _ovphysx_sim_context(device=device, dt=_SIM_DT, add_lighting=True) as sim: scene_cfg = ContactSensorSceneCfg(num_envs=num_envs, env_spacing=2.0, lazy_sensor_update=False) scene_cfg.terrain = FLAT_TERRAIN_CFG.replace(prim_path="/World/ground") @@ -622,6 +557,8 @@ def test_multi_body_per_sensor_indexing(device, num_envs): " e.g. a Cube_low contact was attributed to Cube_high because the kernel" " assumed env-major instead of pattern-major flat-buffer layout." ) + # Without filter_prim_paths_expr there is no per-partner breakdown to report. + assert contact_sensor.data.normal_force_matrix_w is None def _author_nested_chain(prim_path: str) -> None: @@ -652,8 +589,7 @@ def _author_nested_chain(prim_path: str) -> None: @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -@pytest.mark.parametrize("num_envs", [1, 3]) -def test_nested_rigid_body_hierarchy(device, num_envs): +def test_nested_rigid_body_hierarchy(device): """Checks contact binding creation and body resolution on nested rigid-body hierarchies. Regression test for the sensor-pattern construction: patterns were built from the @@ -665,6 +601,7 @@ def test_nested_rigid_body_hierarchy(device, num_envs): clone path so the test covers both nested body resolution and multi-environment contact binding behavior. """ + num_envs = 3 with _ovphysx_sim_context(device=device, dt=_SIM_DT, add_lighting=False) as sim: stage = get_current_stage() contact_sensor_cfg = ContactSensorCfg( @@ -703,31 +640,13 @@ def test_nested_rigid_body_hierarchy(device, num_envs): assert net_forces.shape == (num_envs, 3, 3) -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -def test_sensor_print(device): - """Test sensor print is working correctly.""" - with _ovphysx_sim_context(device=device, dt=_SIM_DT, add_lighting=False) as sim: - scene_cfg = ContactSensorSceneCfg(num_envs=1, env_spacing=1.0, lazy_sensor_update=False) - scene_cfg.terrain = FLAT_TERRAIN_CFG.replace(prim_path="/World/ground") - scene_cfg.shape = CUBE_CFG - scene_cfg.contact_sensor = ContactSensorCfg( - prim_path=scene_cfg.shape.prim_path, - track_pose=True, - debug_vis=False, - update_period=0.0, - track_air_time=True, - history_length=3, - ) - scene = InteractiveScene(scene_cfg) - # Play the simulator - sim.reset() - # print info - print(scene.sensors["contact_sensor"]) - - -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +# Only USD authoring and the device-independent __str__ are checked, so one device covers them. +@pytest.mark.parametrize("device", ["cpu"]) def test_contact_sensor_threshold(device): - """Test that the contact sensor USD threshold attribute is set to 0.0.""" + """Test that the contact sensor USD threshold attribute is set to 0.0. + + Regression for #3498, where the spawner passed ``activate_contact_sensors`` (a bool) as the threshold. + """ with _ovphysx_sim_context(device=device, dt=_SIM_DT, add_lighting=False) as sim: scene_cfg = ContactSensorSceneCfg(num_envs=1, env_spacing=1.0, lazy_sensor_update=False) scene_cfg.terrain = FLAT_TERRAIN_CFG.replace(prim_path="/World/ground") @@ -751,15 +670,13 @@ def test_contact_sensor_threshold(device): # Ensure the contact sensor was created properly contact_sensor = scene["contact_sensor"] assert contact_sensor is not None, "Contact sensor was not created" + assert "Contact sensor @" in str(contact_sensor) - # Check if the prim has contact report API and verify threshold is close to 0.0 - if "PhysxContactReportAPI" in prim.GetAppliedSchemas(): - threshold_attr = prim.GetAttribute("physxContactReport:threshold") - if threshold_attr.IsValid(): - threshold_value = threshold_attr.Get() - assert pytest.approx(threshold_value, abs=1e-6) == 0.0, ( - f"Expected USD threshold to be close to 0.0, but got {threshold_value}" - ) + assert "PhysxContactReportAPI" in prim.GetAppliedSchemas() + threshold_value = prim.GetAttribute("physxContactReport:threshold").Get() + assert threshold_value == pytest.approx(0.0, abs=1e-6), ( + f"Expected USD threshold to be close to 0.0, but got {threshold_value}" + ) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @@ -812,137 +729,6 @@ def run_policy_step(root_pose: torch.Tensor) -> float: assert max(air_forces[1:]) < 0.1, f"Stale contact force reported in the air: {air_forces}" -@pytest.mark.skip( - reason=( - "ovphysx ContactSensor v1 does not support track_friction_forces; " - "see issue #5325 and docs/superpowers/specs/2026-04-27-ovphysx-contact-api-gaps.md" - ) -) -@pytest.mark.parametrize("grav_dir", [(-10.0, 0.0, -0.1), (0.0, -10.0, -0.1)]) -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -def test_friction_reporting(device, grav_dir): - """Test friction force reporting for contact sensors. - - This test places a contact sensor enabled cube onto a ground plane under different gravity directions. - It then compares the normalized friction force dir with the direction of gravity to ensure they are aligned. - """ - sim_cfg = SimulationCfg(physics=OvPhysxCfg(), dt=_SIM_DT, device=device, gravity=grav_dir) - with build_simulation_context(device=device, sim_cfg=sim_cfg, add_lighting=False) as sim: - scene_cfg = ContactSensorSceneCfg(num_envs=1, env_spacing=1.0, lazy_sensor_update=False) - scene_cfg.terrain = FLAT_TERRAIN_CFG - scene_cfg.shape = CUBE_CFG - - filter_prim_paths_expr = [scene_cfg.terrain.prim_path + "/terrain/GroundPlane/CollisionPlane"] - - scene_cfg.contact_sensor = ContactSensorCfg( - prim_path=scene_cfg.shape.prim_path, - track_pose=True, - debug_vis=False, - update_period=0.0, - track_air_time=True, - history_length=3, - track_friction_forces=True, - filter_prim_paths_expr=filter_prim_paths_expr, - ) - - scene = InteractiveScene(scene_cfg) - sim.reset() - - scene["contact_sensor"].reset() - shape: RigidObject = scene["shape"] - shape.write_root_pose_to_sim_index( - root_pose=torch.tensor([0, 0.0, CUBE_CFG.spawn.size[2] / 2.0, 1, 0, 0, 0], device=device).unsqueeze(0) - ) - - # step sim once to compute friction forces - _perform_sim_step(sim, scene, _SIM_DT) - - # check that forces are being reported match expected friction forces - expected_friction, _, _, _ = scene["contact_sensor"].contact_view.get_friction_data(dt=_SIM_DT) - expected_friction_torch = wp.to_torch(expected_friction) - reported_friction = scene["contact_sensor"].data.friction_force_matrix_w.torch[0, 0, :] - - torch.testing.assert_close(expected_friction_torch.sum(dim=0), reported_friction[0], atol=1e-6, rtol=1e-5) - - # check that friction force direction opposes gravity direction - grav = torch.tensor(grav_dir, device=device) - norm_reported_friction = reported_friction / reported_friction.norm() - norm_gravity = grav / grav.norm() - dot = torch.dot(norm_reported_friction[0], norm_gravity) - - torch.testing.assert_close(torch.abs(dot), torch.tensor(1.0, device=device), atol=1e-4, rtol=1e-3) - - -@pytest.mark.skip( - reason=( - "ovphysx ContactSensor v1 does not support track_friction_forces; " - "see issue #5325 and docs/superpowers/specs/2026-04-27-ovphysx-contact-api-gaps.md" - ) -) -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -def test_invalid_prim_paths_config(device): - """Test that a ValueError is raised when track_friction_forces=True and filter_prim_paths_expr is empty.""" - sim_cfg = SimulationCfg(physics=OvPhysxCfg(), dt=_SIM_DT, device=device) - with build_simulation_context(device=device, sim_cfg=sim_cfg, add_lighting=False) as sim: - scene_cfg = ContactSensorSceneCfg(num_envs=1, env_spacing=1.0, lazy_sensor_update=False) - scene_cfg.terrain = FLAT_TERRAIN_CFG - scene_cfg.shape = CUBE_CFG - - scene_cfg.contact_sensor = ContactSensorCfg( - prim_path=scene_cfg.shape.prim_path, - track_pose=True, - debug_vis=False, - update_period=0.0, - track_air_time=True, - history_length=3, - track_friction_forces=True, - filter_prim_paths_expr=[], - ) - - try: - _ = InteractiveScene(scene_cfg) - sim.reset() - assert False, "Expected ValueError due to invalid contact sensor configuration." - except ValueError: - pass - - -@pytest.mark.skip( - reason=( - "ovphysx ContactSensor v1 does not support track_friction_forces; " - "see issue #5325 and docs/superpowers/specs/2026-04-27-ovphysx-contact-api-gaps.md" - ) -) -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -def test_invalid_max_contact_points_config(device): - """Test that a ValueError is raised when track_friction_forces=True and max_contact_data_count_per_prim=0.""" - sim_cfg = SimulationCfg(physics=OvPhysxCfg(), dt=_SIM_DT, device=device) - with build_simulation_context(device=device, sim_cfg=sim_cfg, add_lighting=False) as sim: - scene_cfg = ContactSensorSceneCfg(num_envs=1, env_spacing=1.0, lazy_sensor_update=False) - scene_cfg.terrain = FLAT_TERRAIN_CFG - scene_cfg.shape = CUBE_CFG - filter_prim_paths_expr = [scene_cfg.terrain.prim_path + "/terrain/GroundPlane/CollisionPlane"] - - scene_cfg.contact_sensor = ContactSensorCfg( - prim_path=scene_cfg.shape.prim_path, - track_pose=True, - debug_vis=False, - update_period=0.0, - track_air_time=True, - history_length=3, - track_friction_forces=True, - filter_prim_paths_expr=filter_prim_paths_expr, - max_contact_data_count_per_prim=0, - ) - - try: - _ = InteractiveScene(scene_cfg) - sim.reset() - assert False, "Expected ValueError due to invalid contact sensor configuration." - except ValueError: - pass - - ## # Internal helpers. ## @@ -1103,78 +889,6 @@ def _test_sensor_contact( expected_last_reset_contact_time = 2 * sim_dt -def _test_friction_forces(shape: RigidObject, sensor: ContactSensor, mode: ContactTestMode) -> None: - """Verify friction force values reported by the contact sensor. - - This helper is only called from skipped tests (requires ``track_friction_forces`` - which is not supported in ovphysx v1). - - Args: - shape: The contact prim used for the contact sensor test. - sensor: The sensor reporting data to be verified. - mode: The contact test mode. - """ - if not sensor.cfg.track_friction_forces: - assert sensor._data.friction_force_matrix_w is None - return - - # check shape of the friction_force_matrix_w tensor (wp.to_torch expands vec3f -> float32 trailing dim) - num_bodies = sensor.num_bodies - friction_torch = sensor._data.friction_force_matrix_w.torch - assert friction_torch.shape == (sensor.num_instances // num_bodies, num_bodies, 1, 3) - # compare friction forces - if mode == ContactTestMode.IN_CONTACT: - assert torch.any(torch.abs(friction_torch) > 1e-5).item() - friction_forces, _, buffer_count, buffer_start_indices = sensor.contact_view.get_friction_data( - dt=sensor._sim_physics_dt - ) - friction_forces_t = wp.to_torch(friction_forces) - buffer_count_t = wp.to_torch(buffer_count).to(torch.int32) - buffer_start_t = wp.to_torch(buffer_start_indices).to(torch.int32) - for i in range(sensor.num_instances * num_bodies): - for j in range(sensor.contact_view.filter_count): - start_index_ij = buffer_start_t[i, j] - count_ij = buffer_count_t[i, j] - force = torch.sum(friction_forces_t[start_index_ij : (start_index_ij + count_ij), :], dim=0) - env_idx = i // num_bodies - body_idx = i % num_bodies - assert torch.allclose(force, friction_torch[env_idx, body_idx, j, :], atol=1e-5) - elif mode == ContactTestMode.NON_CONTACT: - assert torch.all(friction_torch == 0.0).item() - - -def _test_contact_position(shape: RigidObject, sensor: ContactSensor, mode: ContactTestMode) -> None: - """Test for the contact positions (only implemented for sphere and flat terrain). - - Checks that the contact position is radius distance away from the root of the object. - - This helper is only called from skipped tests (requires ``track_contact_points`` - which is not supported in ovphysx v1). - - Args: - shape: The contact prim used for the contact sensor test. - sensor: The sensor reporting data to be verified. - mode: The contact test mode. - """ - if not sensor.cfg.track_contact_points: - assert sensor._data.contact_pos_w is None - return - - # check shape of the contact_pos_w tensor (wp.to_torch expands vec3f -> float32 trailing dim) - num_bodies = sensor.num_bodies - contact_pos_torch = sensor._data.contact_pos_w.torch - assert contact_pos_torch.shape == (sensor.num_instances // num_bodies, num_bodies, 1, 3) - # check contact positions - if mode == ContactTestMode.IN_CONTACT: - pos_w_torch = sensor._data.pos_w.torch - contact_position = pos_w_torch + torch.tensor([[0.0, 0.0, -shape.cfg.spawn.radius]], device=pos_w_torch.device) - assert torch.all( - torch.abs(torch.linalg.norm(contact_pos_torch - contact_position.unsqueeze(1), ord=2, dim=-1)) < 1e-2 - ).item() - elif mode == ContactTestMode.NON_CONTACT: - assert torch.all(torch.isnan(contact_pos_torch)).item() - - def _check_prim_contact_state_times( sensor: ContactSensor, expected_air_time: float, diff --git a/source/isaaclab_ov/test/sensors/test_frame_transformer.py b/source/isaaclab_ov/test/sensors/test_frame_transformer.py index a6fecf3b94a2..583bf250b89a 100644 --- a/source/isaaclab_ov/test/sensors/test_frame_transformer.py +++ b/source/isaaclab_ov/test/sensors/test_frame_transformer.py @@ -156,30 +156,8 @@ class _SceneCfg(InteractiveSceneCfg): # --------------------------------------------------------------------------- -@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) -def test_frame_transformer_factory_dispatch(device): - """Smoke test: ``FrameTransformer(cfg)`` resolves to the OVPhysX backend. - - ``FrameTransformer`` is a :class:`~isaaclab.utils.backend_utils.FactoryBase`, - so the returned instance is *not* an instance of the factory class itself. - Verify via :attr:`~isaaclab.sensors.frame_transformer.BaseFrameTransformer.__backend_name__`. - """ - with _ovphysx_sim_context(device=device) as sim: - sim._app_control_on_stop_handle = None - cfg = FrameTransformerCfg( - prim_path="{ENV_REGEX_NS}/Robot/base", - target_frames=[FrameTransformerCfg.FrameCfg(prim_path="{ENV_REGEX_NS}/Robot/LF_SHANK")], - ) - scene_cfg = _SceneCfg(num_envs=2, env_spacing=2.0) - scene_cfg.frame_transformer = cfg - scene = InteractiveScene(scene_cfg) - sim.reset() - sensor = scene.sensors["frame_transformer"] - assert isinstance(sensor, BaseFrameTransformer) - assert sensor.__backend_name__ == "ovphysx" - - -@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +# The guard runs before any device work, so the CPU pass covers it. +@pytest.mark.parametrize("device", ["cpu"]) def test_frame_transformer_debug_vis_callback_noops_before_init(device): """Test debug visualization callback is safe before physics initialization.""" with _ovphysx_sim_context(device=device) as sim: @@ -196,33 +174,41 @@ def test_frame_transformer_debug_vis_callback_noops_before_init(device): @pytest.mark.parametrize("device", ["cpu", "cuda:0"]) def test_frame_transformer_feet_wrt_base(device): - """Test feet transformations w.r.t. base source frame. + """Test frame transforms for articulation, rigid-object, and offset targets in one shared scene. - In this test, the source frame is the robot base. + The scene hosts one sensor per source/target combination: + + * ``frame_transformer``: feet (articulation links with offsets) w.r.t. the robot base. + * ``ft_thigh``: feet w.r.t. a non-root link (``LF_THIGH``), checking target-name order. + * ``ft_cube``: an external RigidObject target w.r.t. the robot base (multi-binding gather). + * ``ft_cube_offsets``: a RigidObject source with offset targets on itself. + * ``ft_all``: every robot body via a regex target, checking the target reordering. """ with _ovphysx_sim_context(device=device) as sim: sim._app_control_on_stop_handle = None # Spawn things into stage scene_cfg = _SceneCfg(num_envs=2, env_spacing=5.0, lazy_sensor_update=False) + lf_foot = FrameTransformerCfg.FrameCfg( + name="LF_FOOT_USER", + prim_path="{ENV_REGEX_NS}/Robot/LF_SHANK", + offset=OffsetCfg( + pos=euler_rpy_apply(rpy=(0, 0, -math.pi / 2), xyz=(0.08795, 0.01305, -0.33797)), + rot=quat_from_euler_rpy(0, 0, -math.pi / 2), + ), + ) + rf_foot = FrameTransformerCfg.FrameCfg( + name="RF_FOOT_USER", + prim_path="{ENV_REGEX_NS}/Robot/RF_SHANK", + offset=OffsetCfg( + pos=euler_rpy_apply(rpy=(0, 0, math.pi / 2), xyz=(0.08795, -0.01305, -0.33797)), + rot=quat_from_euler_rpy(0, 0, math.pi / 2), + ), + ) scene_cfg.frame_transformer = FrameTransformerCfg( prim_path="{ENV_REGEX_NS}/Robot/base", target_frames=[ - FrameTransformerCfg.FrameCfg( - name="LF_FOOT_USER", - prim_path="{ENV_REGEX_NS}/Robot/LF_SHANK", - offset=OffsetCfg( - pos=euler_rpy_apply(rpy=(0, 0, -math.pi / 2), xyz=(0.08795, 0.01305, -0.33797)), - rot=quat_from_euler_rpy(0, 0, -math.pi / 2), - ), - ), - FrameTransformerCfg.FrameCfg( - name="RF_FOOT_USER", - prim_path="{ENV_REGEX_NS}/Robot/RF_SHANK", - offset=OffsetCfg( - pos=euler_rpy_apply(rpy=(0, 0, math.pi / 2), xyz=(0.08795, -0.01305, -0.33797)), - rot=quat_from_euler_rpy(0, 0, math.pi / 2), - ), - ), + lf_foot, + rf_foot, FrameTransformerCfg.FrameCfg( name="LH_FOOT_USER", prim_path="{ENV_REGEX_NS}/Robot/LH_SHANK", @@ -241,17 +227,55 @@ def test_frame_transformer_feet_wrt_base(device): ), ], ) + scene_cfg.ft_thigh = FrameTransformerCfg( + prim_path="{ENV_REGEX_NS}/Robot/LF_THIGH", target_frames=[lf_foot.replace(), rf_foot.replace()] + ) + scene_cfg.ft_cube = FrameTransformerCfg( + prim_path="{ENV_REGEX_NS}/Robot/base", + target_frames=[FrameTransformerCfg.FrameCfg(name="CUBE_USER", prim_path="{ENV_REGEX_NS}/cube")], + ) + scene_cfg.ft_cube_offsets = FrameTransformerCfg( + prim_path="{ENV_REGEX_NS}/cube", + target_frames=[ + FrameTransformerCfg.FrameCfg(name="CUBE_CENTER", prim_path="{ENV_REGEX_NS}/cube"), + FrameTransformerCfg.FrameCfg( + name="CUBE_TOP", + prim_path="{ENV_REGEX_NS}/cube", + offset=OffsetCfg(pos=(0.0, 0.0, 0.1), rot=(0.0, 0.0, 0.0, 1.0)), + ), + FrameTransformerCfg.FrameCfg( + name="CUBE_BOTTOM", + prim_path="{ENV_REGEX_NS}/cube", + offset=OffsetCfg(pos=(0.0, 0.0, -0.1), rot=(0.0, 0.0, 0.0, 1.0)), + ), + ], + ) + scene_cfg.ft_all = FrameTransformerCfg( + prim_path="{ENV_REGEX_NS}/Robot/base", + target_frames=[FrameTransformerCfg.FrameCfg(prim_path="{ENV_REGEX_NS}/Robot/[^/]*")], + ) scene = InteractiveScene(scene_cfg) # Play the simulator sim.reset() + robot = scene.articulations["robot"] + cube = scene.rigid_objects["cube"] + ft_base = scene.sensors["frame_transformer"] + ft_thigh = scene.sensors["ft_thigh"] + ft_cube = scene.sensors["ft_cube"] + ft_cube_offsets = scene.sensors["ft_cube_offsets"] + ft_all = scene.sensors["ft_all"] + + # The factory resolves to the OVPhysX backend. + assert isinstance(ft_base, BaseFrameTransformer) + assert ft_base.__backend_name__ == "ovphysx" + assert "FrameTransformer @" in str(ft_all) + # Acquire the index of ground truth bodies - feet_indices, feet_names = scene.articulations["robot"].find_bodies( - ["LF_FOOT", "RF_FOOT", "LH_FOOT", "RH_FOOT"] - ) + feet_indices, feet_names = robot.find_bodies(["LF_FOOT", "RF_FOOT", "LH_FOOT", "RH_FOOT"]) - target_frame_names = scene.sensors["frame_transformer"].data.target_frame_names + target_frame_names = ft_base.data.target_frame_names # Reorder the feet indices to match the order of the target frames with _USER suffix removed target_frame_names = [name.split("_USER")[0] for name in target_frame_names] @@ -260,8 +284,18 @@ def test_frame_transformer_feet_wrt_base(device): reordering_indices = [feet_names.index(name) for name in target_frame_names] feet_indices = [feet_indices[i] for i in reordering_indices] + # -- thigh source: names are parsed in the same order + thigh_index = robot.find_bodies("LF_THIGH")[0][0] + front_feet_indices, front_feet_names = robot.find_bodies(["LF_FOOT", "RF_FOOT"]) + user_feet_names = [f"{name}_USER" for name in front_feet_names] + assert ft_thigh.data.target_frame_names == user_feet_names + + # -- all bodies: reorder regex targets to the articulation body order + articulation_body_names = robot.data.body_names + all_reordering_indices = [ft_all.data.target_frame_names.index(name) for name in articulation_body_names] + # default joint targets - default_actions = scene.articulations["robot"].data.default_joint_pos.torch.clone() + default_actions = robot.data.default_joint_pos.torch.clone() # Define simulation stepping sim_dt = sim.get_physics_dt() # Simulate physics @@ -270,27 +304,27 @@ def test_frame_transformer_feet_wrt_base(device): if count % 25 == 0: # reset root state root_state = torch.cat( - ( - scene.articulations["robot"].data.default_root_pose.torch, - scene.articulations["robot"].data.default_root_vel.torch, - ), - dim=-1, + (robot.data.default_root_pose.torch, robot.data.default_root_vel.torch), dim=-1 ).clone() root_state[:, :3] += scene.env_origins - joint_pos = scene.articulations["robot"].data.default_joint_pos.torch - joint_vel = scene.articulations["robot"].data.default_joint_vel.torch - # -- set root state # -- robot - scene.articulations["robot"].write_root_pose_to_sim_index(root_pose=root_state[:, :7]) - scene.articulations["robot"].write_root_velocity_to_sim_index(root_velocity=root_state[:, 7:]) - scene.articulations["robot"].write_joint_position_to_sim_index(position=joint_pos) - scene.articulations["robot"].write_joint_velocity_to_sim_index(velocity=joint_vel) + robot.write_root_pose_to_sim_index(root_pose=root_state[:, :7]) + robot.write_root_velocity_to_sim_index(root_velocity=root_state[:, 7:]) + robot.write_joint_position_to_sim_index(position=robot.data.default_joint_pos.torch) + robot.write_joint_velocity_to_sim_index(velocity=robot.data.default_joint_vel.torch) + # -- cube + cube_state = torch.cat( + (cube.data.default_root_pose.torch, cube.data.default_root_vel.torch), dim=-1 + ).clone() + cube_state[:, :3] += scene.env_origins + cube.write_root_pose_to_sim_index(root_pose=cube_state[:, :7]) + cube.write_root_velocity_to_sim_index(root_velocity=cube_state[:, 7:]) # reset buffers scene.reset() # set joint targets robot_actions = default_actions + 0.5 * torch.randn_like(default_actions) - scene.articulations["robot"].set_joint_position_target_index(target=robot_actions) + robot.set_joint_position_target_index(target=robot_actions) # write data to sim scene.write_data_to_sim() # perform step @@ -298,462 +332,106 @@ def test_frame_transformer_feet_wrt_base(device): # read data from sim scene.update(sim_dt) - # check absolute frame transforms in world frame # -- ground-truth - root_pose_w = scene.articulations["robot"].data.root_pose_w.torch - feet_pos_w_gt = scene.articulations["robot"].data.body_pos_w.torch[:, feet_indices] - feet_quat_w_gt = scene.articulations["robot"].data.body_quat_w.torch[:, feet_indices] - # -- frame transformer - source_pos_w_tf = scene.sensors["frame_transformer"].data.source_pos_w.torch - source_quat_w_tf = scene.sensors["frame_transformer"].data.source_quat_w.torch - feet_pos_w_tf = scene.sensors["frame_transformer"].data.target_pos_w.torch - feet_quat_w_tf = scene.sensors["frame_transformer"].data.target_quat_w.torch - - # check if they are same - torch.testing.assert_close(root_pose_w[:, :3], source_pos_w_tf) - torch.testing.assert_close(root_pose_w[:, 3:], source_quat_w_tf) + root_pose_w = robot.data.root_pose_w.torch + cube_pos_w_gt = cube.data.root_pos_w.torch + cube_quat_w_gt = cube.data.root_quat_w.torch + + # feet w.r.t. base: absolute frame transforms in world frame + feet_pos_w_gt = robot.data.body_pos_w.torch[:, feet_indices] + feet_quat_w_gt = robot.data.body_quat_w.torch[:, feet_indices] + feet_pos_w_tf = ft_base.data.target_pos_w.torch + feet_quat_w_tf = ft_base.data.target_quat_w.torch + torch.testing.assert_close(root_pose_w[:, :3], ft_base.data.source_pos_w.torch) + torch.testing.assert_close(root_pose_w[:, 3:], ft_base.data.source_quat_w.torch) torch.testing.assert_close(feet_pos_w_gt, feet_pos_w_tf) torch.testing.assert_close(feet_quat_w_gt, feet_quat_w_tf) - - # check if relative transforms are same - feet_pos_source_tf = scene.sensors["frame_transformer"].data.target_pos_source.torch - feet_quat_source_tf = scene.sensors["frame_transformer"].data.target_quat_source.torch + # relative transforms + feet_pos_source_tf = ft_base.data.target_pos_source.torch + feet_quat_source_tf = ft_base.data.target_quat_source.torch for index in range(len(feet_indices)): - # ground-truth foot_pos_b, foot_quat_b = math_utils.subtract_frame_transforms( root_pose_w[:, :3], root_pose_w[:, 3:], feet_pos_w_tf[:, index], feet_quat_w_tf[:, index] ) - # check if they are same torch.testing.assert_close(feet_pos_source_tf[:, index], foot_pos_b) torch.testing.assert_close(feet_quat_source_tf[:, index], foot_quat_b) - -@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) -def test_frame_transformer_feet_wrt_thigh(device): - """Test feet transformation w.r.t. thigh source frame.""" - with _ovphysx_sim_context(device=device) as sim: - sim._app_control_on_stop_handle = None - # Spawn things into stage - scene_cfg = _SceneCfg(num_envs=2, env_spacing=5.0, lazy_sensor_update=False) - scene_cfg.frame_transformer = FrameTransformerCfg( - prim_path="{ENV_REGEX_NS}/Robot/LF_THIGH", - target_frames=[ - FrameTransformerCfg.FrameCfg( - name="LF_FOOT_USER", - prim_path="{ENV_REGEX_NS}/Robot/LF_SHANK", - offset=OffsetCfg( - pos=euler_rpy_apply(rpy=(0, 0, -math.pi / 2), xyz=(0.08795, 0.01305, -0.33797)), - rot=quat_from_euler_rpy(0, 0, -math.pi / 2), - ), - ), - FrameTransformerCfg.FrameCfg( - name="RF_FOOT_USER", - prim_path="{ENV_REGEX_NS}/Robot/RF_SHANK", - offset=OffsetCfg( - pos=euler_rpy_apply(rpy=(0, 0, math.pi / 2), xyz=(0.08795, -0.01305, -0.33797)), - rot=quat_from_euler_rpy(0, 0, math.pi / 2), - ), - ), - ], - ) - scene = InteractiveScene(scene_cfg) - - # Play the simulator - sim.reset() - - # Acquire the index of ground truth bodies - source_frame_index = scene.articulations["robot"].find_bodies("LF_THIGH")[0][0] - feet_indices, feet_names = scene.articulations["robot"].find_bodies(["LF_FOOT", "RF_FOOT"]) - # Check names are parsed the same order - user_feet_names = [f"{name}_USER" for name in feet_names] - assert scene.sensors["frame_transformer"].data.target_frame_names == user_feet_names - - # default joint targets - default_actions = scene.articulations["robot"].data.default_joint_pos.torch.clone() - # Define simulation stepping - sim_dt = sim.get_physics_dt() - # Simulate physics - for count in range(100): - # # reset - if count % 25 == 0: - # reset root state - root_state = torch.cat( - ( - scene.articulations["robot"].data.default_root_pose.torch, - scene.articulations["robot"].data.default_root_vel.torch, - ), - dim=-1, - ).clone() - root_state[:, :3] += scene.env_origins - joint_pos = scene.articulations["robot"].data.default_joint_pos.torch - joint_vel = scene.articulations["robot"].data.default_joint_vel.torch - # -- set root state - # -- robot - scene.articulations["robot"].write_root_pose_to_sim_index(root_pose=root_state[:, :7]) - scene.articulations["robot"].write_root_velocity_to_sim_index(root_velocity=root_state[:, 7:]) - scene.articulations["robot"].write_joint_position_to_sim_index(position=joint_pos) - scene.articulations["robot"].write_joint_velocity_to_sim_index(velocity=joint_vel) - # reset buffers - scene.reset() - - # set joint targets - robot_actions = default_actions + 0.5 * torch.randn_like(default_actions) - scene.articulations["robot"].set_joint_position_target_index(target=robot_actions) - # write data to sim - scene.write_data_to_sim() - # perform step - sim.step() - # read data from sim - scene.update(sim_dt) - - # check absolute frame transforms in world frame - # -- ground-truth - source_pose_w_gt = scene.articulations["robot"].data.body_state_w.torch[:, source_frame_index, :7] - feet_pos_w_gt = scene.articulations["robot"].data.body_pos_w.torch[:, feet_indices] - feet_quat_w_gt = scene.articulations["robot"].data.body_quat_w.torch[:, feet_indices] - # -- frame transformer - source_pos_w_tf = scene.sensors["frame_transformer"].data.source_pos_w.torch - source_quat_w_tf = scene.sensors["frame_transformer"].data.source_quat_w.torch - feet_pos_w_tf = scene.sensors["frame_transformer"].data.target_pos_w.torch - feet_quat_w_tf = scene.sensors["frame_transformer"].data.target_quat_w.torch - # check if they are same - torch.testing.assert_close(source_pose_w_gt[:, :3], source_pos_w_tf) - torch.testing.assert_close(source_pose_w_gt[:, 3:], source_quat_w_tf) - torch.testing.assert_close(feet_pos_w_gt, feet_pos_w_tf) - torch.testing.assert_close(feet_quat_w_gt, feet_quat_w_tf) - - # check if relative transforms are same - feet_pos_source_tf = scene.sensors["frame_transformer"].data.target_pos_source.torch - feet_quat_source_tf = scene.sensors["frame_transformer"].data.target_quat_source.torch - for index in range(len(feet_indices)): - # ground-truth + # feet w.r.t. thigh + source_pose_w_gt = robot.data.body_state_w.torch[:, thigh_index, :7] + front_feet_pos_w_gt = robot.data.body_pos_w.torch[:, front_feet_indices] + front_feet_quat_w_gt = robot.data.body_quat_w.torch[:, front_feet_indices] + front_feet_pos_w_tf = ft_thigh.data.target_pos_w.torch + front_feet_quat_w_tf = ft_thigh.data.target_quat_w.torch + torch.testing.assert_close(source_pose_w_gt[:, :3], ft_thigh.data.source_pos_w.torch) + torch.testing.assert_close(source_pose_w_gt[:, 3:], ft_thigh.data.source_quat_w.torch) + torch.testing.assert_close(front_feet_pos_w_gt, front_feet_pos_w_tf) + torch.testing.assert_close(front_feet_quat_w_gt, front_feet_quat_w_tf) + front_feet_pos_source_tf = ft_thigh.data.target_pos_source.torch + front_feet_quat_source_tf = ft_thigh.data.target_quat_source.torch + for index in range(len(front_feet_indices)): foot_pos_b, foot_quat_b = math_utils.subtract_frame_transforms( source_pose_w_gt[:, :3], source_pose_w_gt[:, 3:], - feet_pos_w_tf[:, index], - feet_quat_w_tf[:, index], + front_feet_pos_w_tf[:, index], + front_feet_quat_w_tf[:, index], ) - # check if they are same - torch.testing.assert_close(feet_pos_source_tf[:, index], foot_pos_b) - torch.testing.assert_close(feet_quat_source_tf[:, index], foot_quat_b) - - -@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) -def test_frame_transformer_robot_body_to_external_cube(device): - """Test transformation from robot body to a cube in the scene.""" - with _ovphysx_sim_context(device=device) as sim: - sim._app_control_on_stop_handle = None - # Spawn things into stage - scene_cfg = _SceneCfg(num_envs=2, env_spacing=5.0, lazy_sensor_update=False) - scene_cfg.frame_transformer = FrameTransformerCfg( - prim_path="{ENV_REGEX_NS}/Robot/base", - target_frames=[ - FrameTransformerCfg.FrameCfg( - name="CUBE_USER", - prim_path="{ENV_REGEX_NS}/cube", - ), - ], - ) - scene = InteractiveScene(scene_cfg) - - # Play the simulator - sim.reset() - - # default joint targets - default_actions = scene.articulations["robot"].data.default_joint_pos.torch.clone() - # Define simulation stepping - sim_dt = sim.get_physics_dt() - # Simulate physics - for count in range(100): - # # reset - if count % 25 == 0: - # reset root state - root_state = torch.cat( - ( - scene.articulations["robot"].data.default_root_pose.torch, - scene.articulations["robot"].data.default_root_vel.torch, - ), - dim=-1, - ).clone() - root_state[:, :3] += scene.env_origins - joint_pos = scene.articulations["robot"].data.default_joint_pos.torch - joint_vel = scene.articulations["robot"].data.default_joint_vel.torch - # -- set root state - # -- robot - scene.articulations["robot"].write_root_pose_to_sim_index(root_pose=root_state[:, :7]) - scene.articulations["robot"].write_root_velocity_to_sim_index(root_velocity=root_state[:, 7:]) - scene.articulations["robot"].write_joint_position_to_sim_index(position=joint_pos) - scene.articulations["robot"].write_joint_velocity_to_sim_index(velocity=joint_vel) - # reset buffers - scene.reset() - - # set joint targets - robot_actions = default_actions + 0.5 * torch.randn_like(default_actions) - scene.articulations["robot"].set_joint_position_target_index(target=robot_actions) - # write data to sim - scene.write_data_to_sim() - # perform step - sim.step() - # read data from sim - scene.update(sim_dt) - - # check absolute frame transforms in world frame - # -- ground-truth - root_pose_w = scene.articulations["robot"].data.root_pose_w.torch - cube_pos_w_gt = scene.rigid_objects["cube"].data.root_pos_w.torch - cube_quat_w_gt = scene.rigid_objects["cube"].data.root_quat_w.torch - # -- frame transformer - source_pos_w_tf = scene.sensors["frame_transformer"].data.source_pos_w.torch - source_quat_w_tf = scene.sensors["frame_transformer"].data.source_quat_w.torch - cube_pos_w_tf = scene.sensors["frame_transformer"].data.target_pos_w.torch.squeeze() - cube_quat_w_tf = scene.sensors["frame_transformer"].data.target_quat_w.torch.squeeze() - - # check if they are same - torch.testing.assert_close(root_pose_w[:, :3], source_pos_w_tf) - torch.testing.assert_close(root_pose_w[:, 3:], source_quat_w_tf) + torch.testing.assert_close(front_feet_pos_source_tf[:, index], foot_pos_b) + torch.testing.assert_close(front_feet_quat_source_tf[:, index], foot_quat_b) + + # external cube w.r.t. base + cube_pos_w_tf = ft_cube.data.target_pos_w.torch.squeeze() + cube_quat_w_tf = ft_cube.data.target_quat_w.torch.squeeze() + torch.testing.assert_close(root_pose_w[:, :3], ft_cube.data.source_pos_w.torch) + torch.testing.assert_close(root_pose_w[:, 3:], ft_cube.data.source_quat_w.torch) torch.testing.assert_close(cube_pos_w_gt, cube_pos_w_tf) torch.testing.assert_close(cube_quat_w_gt, cube_quat_w_tf) - - # check if relative transforms are same - cube_pos_source_tf = scene.sensors["frame_transformer"].data.target_pos_source.torch - cube_quat_source_tf = scene.sensors["frame_transformer"].data.target_quat_source.torch - # ground-truth cube_pos_b, cube_quat_b = math_utils.subtract_frame_transforms( root_pose_w[:, :3], root_pose_w[:, 3:], cube_pos_w_tf, cube_quat_w_tf ) - # check if they are same - torch.testing.assert_close(cube_pos_source_tf[:, 0], cube_pos_b) - torch.testing.assert_close(cube_quat_source_tf[:, 0], cube_quat_b) - - -@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) -def test_frame_transformer_offset_frames(device): - """Test body transformation w.r.t. base source frame. - - In this test, the source frame is the cube frame. - """ - with _ovphysx_sim_context(device=device) as sim: - sim._app_control_on_stop_handle = None - # Spawn things into stage - scene_cfg = _SceneCfg(num_envs=2, env_spacing=5.0, lazy_sensor_update=False) - scene_cfg.frame_transformer = FrameTransformerCfg( - prim_path="{ENV_REGEX_NS}/cube", - target_frames=[ - FrameTransformerCfg.FrameCfg( - name="CUBE_CENTER", - prim_path="{ENV_REGEX_NS}/cube", - ), - FrameTransformerCfg.FrameCfg( - name="CUBE_TOP", - prim_path="{ENV_REGEX_NS}/cube", - offset=OffsetCfg( - pos=(0.0, 0.0, 0.1), - rot=(0.0, 0.0, 0.0, 1.0), - ), - ), - FrameTransformerCfg.FrameCfg( - name="CUBE_BOTTOM", - prim_path="{ENV_REGEX_NS}/cube", - offset=OffsetCfg( - pos=(0.0, 0.0, -0.1), - rot=(0.0, 0.0, 0.0, 1.0), - ), - ), - ], - ) - scene = InteractiveScene(scene_cfg) - - # Play the simulator - sim.reset() - - # Define simulation stepping - sim_dt = sim.get_physics_dt() - # Simulate physics - for count in range(100): - # # reset - if count % 25 == 0: - # reset root state - root_state = torch.cat( - ( - scene["cube"].data.default_root_pose.torch, - scene["cube"].data.default_root_vel.torch, - ), - dim=-1, - ).clone() - root_state[:, :3] += scene.env_origins - # -- set root state - # -- cube - scene["cube"].write_root_pose_to_sim_index(root_pose=root_state[:, :7]) - scene["cube"].write_root_velocity_to_sim_index(root_velocity=root_state[:, 7:]) - # reset buffers - scene.reset() - - # write data to sim - scene.write_data_to_sim() - # perform step - sim.step() - # read data from sim - scene.update(sim_dt) - - # check absolute frame transforms in world frame - # -- ground-truth - cube_pos_w_gt = scene["cube"].data.root_pos_w.torch - cube_quat_w_gt = scene["cube"].data.root_quat_w.torch - # -- frame transformer - source_pos_w_tf = scene.sensors["frame_transformer"].data.source_pos_w.torch - source_quat_w_tf = scene.sensors["frame_transformer"].data.source_quat_w.torch - target_pos_w_tf = scene.sensors["frame_transformer"].data.target_pos_w.torch.squeeze() - target_quat_w_tf = scene.sensors["frame_transformer"].data.target_quat_w.torch.squeeze() - target_frame_names = scene.sensors["frame_transformer"].data.target_frame_names - - cube_center_idx = target_frame_names.index("CUBE_CENTER") - cube_bottom_idx = target_frame_names.index("CUBE_BOTTOM") - cube_top_idx = target_frame_names.index("CUBE_TOP") - - # check if they are same - torch.testing.assert_close(cube_pos_w_gt, source_pos_w_tf) - torch.testing.assert_close(cube_quat_w_gt, source_quat_w_tf) + torch.testing.assert_close(ft_cube.data.target_pos_source.torch[:, 0], cube_pos_b) + torch.testing.assert_close(ft_cube.data.target_quat_source.torch[:, 0], cube_quat_b) + + # offset frames on the cube + target_pos_w_tf = ft_cube_offsets.data.target_pos_w.torch.squeeze() + target_quat_w_tf = ft_cube_offsets.data.target_quat_w.torch.squeeze() + offset_frame_names = ft_cube_offsets.data.target_frame_names + cube_center_idx = offset_frame_names.index("CUBE_CENTER") + cube_bottom_idx = offset_frame_names.index("CUBE_BOTTOM") + cube_top_idx = offset_frame_names.index("CUBE_TOP") + torch.testing.assert_close(cube_pos_w_gt, ft_cube_offsets.data.source_pos_w.torch) + torch.testing.assert_close(cube_quat_w_gt, ft_cube_offsets.data.source_quat_w.torch) torch.testing.assert_close(cube_pos_w_gt, target_pos_w_tf[:, cube_center_idx]) torch.testing.assert_close(cube_quat_w_gt, target_quat_w_tf[:, cube_center_idx]) - - # test offsets are applied correctly # -- cube top - cube_pos_top = target_pos_w_tf[:, cube_top_idx] - cube_quat_top = target_quat_w_tf[:, cube_top_idx] - torch.testing.assert_close(cube_pos_top, cube_pos_w_gt + torch.tensor([0.0, 0.0, 0.1], device=device)) - torch.testing.assert_close(cube_quat_top, cube_quat_w_gt) - + torch.testing.assert_close( + target_pos_w_tf[:, cube_top_idx], cube_pos_w_gt + torch.tensor([0.0, 0.0, 0.1], device=device) + ) + torch.testing.assert_close(target_quat_w_tf[:, cube_top_idx], cube_quat_w_gt) # -- cube bottom - cube_pos_bottom = target_pos_w_tf[:, cube_bottom_idx] - cube_quat_bottom = target_quat_w_tf[:, cube_bottom_idx] - torch.testing.assert_close(cube_pos_bottom, cube_pos_w_gt + torch.tensor([0.0, 0.0, -0.1], device=device)) - torch.testing.assert_close(cube_quat_bottom, cube_quat_w_gt) - - -@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) -def test_frame_transformer_all_bodies(device): - """Test transformation of all bodies w.r.t. base source frame. - - In this test, the source frame is the robot base. - - The target_frames are all bodies in the robot, implemented using .* pattern. - """ - with _ovphysx_sim_context(device=device) as sim: - sim._app_control_on_stop_handle = None - # Spawn things into stage - scene_cfg = _SceneCfg(num_envs=2, env_spacing=5.0, lazy_sensor_update=False) - scene_cfg.frame_transformer = FrameTransformerCfg( - prim_path="{ENV_REGEX_NS}/Robot/base", - target_frames=[ - FrameTransformerCfg.FrameCfg( - prim_path="{ENV_REGEX_NS}/Robot/[^/]*", - ), - ], - ) - scene = InteractiveScene(scene_cfg) - - # Play the simulator - sim.reset() - - target_frame_names = scene.sensors["frame_transformer"].data.target_frame_names - articulation_body_names = scene.articulations["robot"].data.body_names - - reordering_indices = [target_frame_names.index(name) for name in articulation_body_names] - - # default joint targets - default_actions = scene.articulations["robot"].data.default_joint_pos.torch.clone() - # Define simulation stepping - sim_dt = sim.get_physics_dt() - # Simulate physics - for count in range(100): - # # reset - if count % 25 == 0: - # reset root state - root_state = torch.cat( - ( - scene.articulations["robot"].data.default_root_pose.torch, - scene.articulations["robot"].data.default_root_vel.torch, - ), - dim=-1, - ).clone() - root_state[:, :3] += scene.env_origins - joint_pos = scene.articulations["robot"].data.default_joint_pos.torch - joint_vel = scene.articulations["robot"].data.default_joint_vel.torch - # -- set root state - # -- robot - scene.articulations["robot"].write_root_pose_to_sim_index(root_pose=root_state[:, :7]) - scene.articulations["robot"].write_root_velocity_to_sim_index(root_velocity=root_state[:, 7:]) - scene.articulations["robot"].write_joint_position_to_sim_index(position=joint_pos) - scene.articulations["robot"].write_joint_velocity_to_sim_index(velocity=joint_vel) - # reset buffers - scene.reset() - - # set joint targets - robot_actions = default_actions + 0.5 * torch.randn_like(default_actions) - scene.articulations["robot"].set_joint_position_target_index(target=robot_actions) - # write data to sim - scene.write_data_to_sim() - # perform step - sim.step() - # read data from sim - scene.update(sim_dt) - - # check absolute frame transforms in world frame - # -- ground-truth - root_pose_w = scene.articulations["robot"].data.root_pose_w.torch - bodies_pos_w_gt = scene.articulations["robot"].data.body_pos_w.torch - bodies_quat_w_gt = scene.articulations["robot"].data.body_quat_w.torch - - # -- frame transformer - source_pos_w_tf = scene.sensors["frame_transformer"].data.source_pos_w.torch - source_quat_w_tf = scene.sensors["frame_transformer"].data.source_quat_w.torch - bodies_pos_w_tf = scene.sensors["frame_transformer"].data.target_pos_w.torch - bodies_quat_w_tf = scene.sensors["frame_transformer"].data.target_quat_w.torch - - # check if they are same - torch.testing.assert_close(root_pose_w[:, :3], source_pos_w_tf) - torch.testing.assert_close(root_pose_w[:, 3:], source_quat_w_tf) - torch.testing.assert_close(bodies_pos_w_gt, bodies_pos_w_tf[:, reordering_indices]) - torch.testing.assert_close(bodies_quat_w_gt, bodies_quat_w_tf[:, reordering_indices]) - - bodies_pos_source_tf = scene.sensors["frame_transformer"].data.target_pos_source.torch - bodies_quat_source_tf = scene.sensors["frame_transformer"].data.target_quat_source.torch - - # Go through each body and check if relative transforms are same + torch.testing.assert_close( + target_pos_w_tf[:, cube_bottom_idx], cube_pos_w_gt + torch.tensor([0.0, 0.0, -0.1], device=device) + ) + torch.testing.assert_close(target_quat_w_tf[:, cube_bottom_idx], cube_quat_w_gt) + + # all bodies w.r.t. base + bodies_pos_w_tf = ft_all.data.target_pos_w.torch + bodies_quat_w_tf = ft_all.data.target_quat_w.torch + torch.testing.assert_close(root_pose_w[:, :3], ft_all.data.source_pos_w.torch) + torch.testing.assert_close(root_pose_w[:, 3:], ft_all.data.source_quat_w.torch) + torch.testing.assert_close(robot.data.body_pos_w.torch, bodies_pos_w_tf[:, all_reordering_indices]) + torch.testing.assert_close(robot.data.body_quat_w.torch, bodies_quat_w_tf[:, all_reordering_indices]) + bodies_pos_source_tf = ft_all.data.target_pos_source.torch + bodies_quat_source_tf = ft_all.data.target_quat_source.torch for index in range(len(articulation_body_names)): body_pos_b, body_quat_b = math_utils.subtract_frame_transforms( root_pose_w[:, :3], root_pose_w[:, 3:], bodies_pos_w_tf[:, index], bodies_quat_w_tf[:, index] ) - torch.testing.assert_close(bodies_pos_source_tf[:, index], body_pos_b) torch.testing.assert_close(bodies_quat_source_tf[:, index], body_quat_b) -@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) -def test_sensor_print(device): - """Test sensor print is working correctly.""" - with _ovphysx_sim_context(device=device) as sim: - sim._app_control_on_stop_handle = None - # Spawn things into stage - scene_cfg = _SceneCfg(num_envs=2, env_spacing=5.0, lazy_sensor_update=False) - scene_cfg.frame_transformer = FrameTransformerCfg( - prim_path="{ENV_REGEX_NS}/Robot/base", - target_frames=[ - FrameTransformerCfg.FrameCfg( - prim_path="{ENV_REGEX_NS}/Robot/[^/]*", - ), - ], - ) - scene = InteractiveScene(scene_cfg) - - # Play the simulator - sim.reset() - # print info - print(scene.sensors["frame_transformer"]) - - -@pytest.mark.parametrize("source_robot", ["Robot", "Robot_1"]) -@pytest.mark.parametrize("path_prefix", ["{ENV_REGEX_NS}", "/World"]) +# Neither axis selects a branch the other depends on, so each value is covered once. +@pytest.mark.parametrize(("source_robot", "path_prefix"), [("Robot", "{ENV_REGEX_NS}"), ("Robot_1", "/World")]) @pytest.mark.parametrize("device", ["cpu", "cuda:0"]) def test_frame_transformer_duplicate_body_names(device, source_robot, path_prefix): """Test tracking bodies with same leaf name at different hierarchy levels. diff --git a/source/isaaclab_ov/test/sensors/test_imu.py b/source/isaaclab_ov/test/sensors/test_imu.py index fcad09040ce5..5cf67a71f314 100644 --- a/source/isaaclab_ov/test/sensors/test_imu.py +++ b/source/isaaclab_ov/test/sensors/test_imu.py @@ -20,8 +20,7 @@ Process-global wheel state: like the rigid-object test, this file mixes procedural USD assets (``test_constant_velocity``, ``test_constant_acceleration``, -``test_attachment_validity``, ``test_sensor_print``) with Nucleus assets -(``test_offset_calculation``, ``test_env_ids_propagation``). ``omni.client`` +``test_attachment_validity``) with a Nucleus asset (``test_offset_calculation``). ``omni.client`` must be loaded before the first OVPhysX scene is torn down; otherwise a later first import can fail native symbol resolution after ``ovphysx.reset()``. """ @@ -349,6 +348,12 @@ def test_constant_acceleration(sim_ctx, device): atol=1e-4, ) + s = str(imu_ball) + assert "Imu sensor @ '/World/env_[^/]+/ball'" in s + assert "binding pattern" in s + assert "/World/env_[^/]+/ball" in s + assert "number of sensors : 2" in s + # =========================================================================== # Articulation tests (anymal-C, USD asset from Nucleus) @@ -361,7 +366,8 @@ def test_offset_calculation(sim_ctx, device): Two IMUs on the anymal-C robot — one at ``base`` with a configured offset matching the location of ``imu_link``, and one directly at ``imu_link`` - — should produce identical readings. + (a non-physics child of the ``base`` rigid body) — should resolve the same + offset and produce identical readings. """ _spawn_envs(NUM_ENVS) robot = _spawn_anymal(NUM_ENVS) @@ -372,6 +378,17 @@ def test_offset_calculation(sim_ctx, device): ) sim_ctx.reset() + torch.testing.assert_close( + wp.to_torch(imu_robot_imu_link._offset_pos_b), + wp.to_torch(imu_robot_base._offset_pos_b), + ) + torch.testing.assert_close( + wp.to_torch(imu_robot_imu_link._offset_quat_b), + wp.to_torch(imu_robot_base._offset_quat_b), + rtol=1e-4, + atol=1e-4, + ) + dt = sim_ctx.get_physics_dt() for idx in range(500): @@ -404,141 +421,15 @@ def test_offset_calculation(sim_ctx, device): ) -@pytest.mark.parametrize("device", _DEVICES) -def test_env_ids_propagation(sim_ctx, device): - """Test that ``env_ids`` argument propagates through update and reset methods.""" - _spawn_envs(NUM_ENVS) - robot = _spawn_anymal(NUM_ENVS) - imu_robot_imu_link = _make_imu("/World/env_[^/]+/robot/base/imu_link") - sim_ctx.reset() - - dt = sim_ctx.get_physics_dt() - - for idx in range(10): - velocity = torch.tensor([[0.5, 0.0, 0.0, 0.0, 0.0, 0.0]], dtype=torch.float32, device=device).repeat( - NUM_ENVS, 1 - ) * (idx + 1) - robot.write_root_velocity_to_sim(velocity) - robot.write_data_to_sim() - sim_ctx.step() - robot.update(dt) - imu_robot_imu_link.update(dt, force_recompute=True) - - # reset only env 1 - imu_robot_imu_link.reset(env_ids=[1]) - imu_robot_imu_link.update(dt, force_recompute=True) - sim_ctx.step() - imu_robot_imu_link.update(dt, force_recompute=True) - - -# =========================================================================== -# Physics-correctness sanity tests (ported from Newton) -# =========================================================================== - - -@pytest.mark.parametrize("device", _DEVICES) -def test_sensor_initialization(sim_ctx, device): - """Test that the OVPhysX IMU sensor initializes correctly.""" - _spawn_envs(NUM_ENVS) - _spawn_balls(NUM_ENVS) - imu_ball = _make_imu("/World/env_[^/]+/ball") - sim_ctx.reset() - - assert imu_ball.num_instances == NUM_ENVS - # Inspect the raw warp buffers directly — accessing ``imu.data`` triggers a - # lazy FD-acceleration recompute that needs ``_dt`` (set by ``update``). - assert imu_ball._data._ang_vel_b.shape == (NUM_ENVS,) - assert imu_ball._data._lin_acc_b.shape == (NUM_ENVS,) - assert imu_ball._data._ang_vel_b.dtype == wp.vec3f - assert imu_ball._data._lin_acc_b.dtype == wp.vec3f - - -@pytest.mark.parametrize("device", _DEVICES) -def test_gravity_at_rest(sim_ctx, device): - """Test that an IMU held at rest measures gravity (~9.81 m/s^2 upward). - - The kitless scene has no ground plane, so the ball is supported by an applied force of - ``m * g`` standing in for the ground's normal force. With zero net force the ball stays - at rest, the solver reports zero acceleration, and the accelerometer reads the gravity - bias alone -- the reading a real IMU gives sitting on a table. A ball left to fall - instead reads zero (see :func:`test_freefall_acceleration`). - """ - _spawn_envs(NUM_ENVS) - balls = _spawn_balls(NUM_ENVS) - imu_ball = _make_imu("/World/env_[^/]+/ball") - sim_ctx.reset() - - dt = sim_ctx.get_physics_dt() - gravity_magnitude = abs(sim_ctx.cfg.gravity[2]) - # Support the ball against gravity. The ball never rotates, so the body-frame force - # stays aligned with world +z. - ball_mass = balls.data.body_mass.torch[:, 0] - external_wrench_b = torch.zeros((NUM_ENVS, 1, 6), device=device) - external_wrench_b[:, 0, 2] = ball_mass * gravity_magnitude - balls.permanent_wrench_composer.set_forces_and_torques_index( - forces=external_wrench_b[..., :3], - torques=external_wrench_b[..., 3:], - ) - - for _ in range(5): - balls.write_data_to_sim() - sim_ctx.step() - balls.update(dt) - imu_ball.update(dt, force_recompute=True) - - lin_acc = imu_ball.data.lin_acc_b.torch - torch.testing.assert_close( - lin_acc[:, 2], - torch.full((NUM_ENVS,), gravity_magnitude, dtype=lin_acc.dtype, device=lin_acc.device), - atol=0.5, - rtol=0.0, - ) - torch.testing.assert_close( - lin_acc[:, :2], - torch.zeros(NUM_ENVS, 2, dtype=lin_acc.dtype, device=lin_acc.device), - atol=0.5, - rtol=0.0, - ) - - -@pytest.mark.parametrize("device", _DEVICES) -def test_freefall_acceleration(sim_ctx, device): - """Test that a freefalling IMU measures near-zero proper acceleration. - - In freefall the finite-difference world-frame acceleration (~``-g``) cancels - the IMU's gravity bias (``+g``), so the reading converges to ``[0, 0, 0]``. - """ - _spawn_envs(NUM_ENVS) - balls = _spawn_balls(NUM_ENVS, height=5.0) - imu_ball = _make_imu("/World/env_[^/]+/ball") - sim_ctx.reset() - - dt = sim_ctx.get_physics_dt() - # Let physics integrate gravity for a few steps with no external velocity write. - for _ in range(10): - balls.write_data_to_sim() - sim_ctx.step() - balls.update(dt) - imu_ball.update(dt, force_recompute=True) - - lin_acc = imu_ball.data.lin_acc_b.torch - acc_magnitude = torch.linalg.norm(lin_acc, dim=-1) - torch.testing.assert_close( - acc_magnitude, - torch.zeros_like(acc_magnitude), - atol=0.5, - rtol=0.0, - ) - - @pytest.mark.parametrize("device", _DEVICES) def test_reset(sim_ctx, device): """Test that ``reset`` zeroes out the IMU output and previous-velocity buffers. Mirrors the Newton ``test_reset`` parity check: drive the IMU until its - buffers hold non-zero data, then ``reset()`` and assert the raw warp - buffers are zero. We read the raw warp arrays directly because accessing - ``imu.data`` triggers a lazy re-fill that masks reset bugs. + buffers hold non-zero data, reset one env through ``env_ids`` and then all + of them, and assert the raw warp buffers are zero where reset. We read the + raw warp arrays directly because accessing ``imu.data`` triggers a lazy + re-fill that masks reset bugs. """ _spawn_envs(NUM_ENVS) balls = _spawn_balls(NUM_ENVS) @@ -566,6 +457,19 @@ def test_reset(sim_ctx, device): # Buffers should hold non-zero state before reset. assert torch.any(wp.to_torch(imu_ball._data._lin_acc_b) != 0), "expected non-zero data before reset" assert torch.any(wp.to_torch(imu_ball._data._ang_vel_b) != 0), "expected non-zero data before reset" + assert torch.any(imu_ball.data.lin_acc_b.torch[1] != 0), "expected env 1 to have non-zero data before reset" + + # reset only env 1 + imu_ball.reset(env_ids=[1]) + torch.testing.assert_close( + wp.to_torch(imu_ball._data._lin_acc_b)[1], + torch.zeros(3, dtype=torch.float32, device=device), + ) + torch.testing.assert_close( + wp.to_torch(imu_ball._data._ang_vel_b)[1], + torch.zeros(3, dtype=torch.float32, device=device), + ) + assert torch.any(wp.to_torch(imu_ball._data._lin_acc_b)[0] != 0), "env 0 should not be reset" imu_ball.reset() @@ -609,72 +513,13 @@ def test_no_stale_data_after_scene_reset(sim_ctx, device): torch.testing.assert_close(post_reset_ang_vel, torch.zeros_like(post_reset_ang_vel)) -@pytest.mark.parametrize("device", _DEVICES) -def test_indirect_attachment_usd(sim_ctx, device): - """Test that an IMU attached to a non-physics Xform under a rigid ancestor matches a direct attachment. - - USD-only port of PhysX's ``test_indirect_attachment``: the URDF pendulum is - not available kitless, but the indirect-attachment code path is reachable - by attaching a non-physics Xform child to a rigid ball and pointing the - IMU at it. The composed offset should match the directly-configured - offset; ``ang_vel_b`` and ``lin_acc_b`` should agree. - """ - _spawn_envs(NUM_ENVS) - balls = _spawn_balls(NUM_ENVS) - # Add a non-physics Xform child under each ball at a known offset; the IMU - # must resolve the rigid-body ancestor (the ball) and recover the offset. - sub_pos = (0.4, 0.0, 0.1) - sub_rot = (0.5, 0.5, 0.5, 0.5) - for i in range(NUM_ENVS): - sim_utils.create_prim(f"/World/env_{i}/ball/imu_sub", "Xform", translation=sub_pos, orientation=sub_rot) - imu_indirect = _make_imu("/World/env_[^/]+/ball/imu_sub") - imu_direct = _make_imu("/World/env_[^/]+/ball", offset=ImuCfg.OffsetCfg(pos=sub_pos, rot=sub_rot)) - sim_ctx.reset() - - torch.testing.assert_close( - wp.to_torch(imu_indirect._offset_pos_b), - wp.to_torch(imu_direct._offset_pos_b), - ) - torch.testing.assert_close( - wp.to_torch(imu_indirect._offset_quat_b), - wp.to_torch(imu_direct._offset_quat_b), - rtol=1e-4, - atol=1e-4, - ) - - dt = sim_ctx.get_physics_dt() - drive_vel = torch.tensor([[0.05, 0.0, 0.0, 0.0, 0.0, 0.0]], dtype=torch.float32, device=device).repeat(NUM_ENVS, 1) - for idx in range(50): - balls.write_root_velocity_to_sim(drive_vel * (idx + 1)) - balls.write_data_to_sim() - sim_ctx.step() - balls.update(dt) - imu_indirect.update(dt, force_recompute=True) - imu_direct.update(dt, force_recompute=True) - - if idx < 2: - continue - - torch.testing.assert_close( - imu_indirect.data.ang_vel_b.torch, - imu_direct.data.ang_vel_b.torch, - rtol=1e-4, - atol=1e-4, - ) - torch.testing.assert_close( - imu_indirect.data.lin_acc_b.torch, - imu_direct.data.lin_acc_b.torch, - rtol=1e-4, - atol=1e-4, - ) - - # =========================================================================== # Validation tests (no asset state required) # =========================================================================== -@pytest.mark.parametrize("device", _DEVICES) +# The shared SensorBase resolver raises before any device work, so the CPU pass covers it. +@pytest.mark.parametrize("device", ["cpu"]) def test_attachment_validity(sim_ctx, device): """Test invalid IMU attachment. @@ -691,22 +536,6 @@ def test_attachment_validity(sim_ctx, device): assert exc_info.type is RuntimeError and "find a rigid body ancestor prim" in str(exc_info.value) -@pytest.mark.parametrize("device", _DEVICES) -def test_sensor_print(sim_ctx, device): - """Test ``__str__`` is implemented and exposes the prim path and binding pattern.""" - _spawn_envs(NUM_ENVS) - _spawn_balls(NUM_ENVS) - imu_ball = _make_imu("/World/env_[^/]+/ball") - sim_ctx.reset() - - s = str(imu_ball) - print(s) - assert "Imu sensor @ '/World/env_[^/]+/ball'" in s - assert "binding pattern" in s - assert "/World/env_[^/]+/ball" in s - assert "number of sensors : 2" in s - - # =========================================================================== # URDF-dependent tests — skipped pending USD pendulum asset # =========================================================================== @@ -722,6 +551,12 @@ def test_sensor_print(sim_ctx, device): ) def test_single_dof_pendulum(): """Test imu against analytical pendulum problem.""" + # If this test is ever un-skipped without porting the PhysX assertions, fail + # explicitly rather than passing vacuously. + pytest.fail( + "test_single_dof_pendulum was un-skipped without a body — port the assertions from" + " source/isaaclab_physx/test/sensors/test_imu.py::test_single_dof_pendulum." + ) @pytest.mark.skip( @@ -734,3 +569,9 @@ def test_single_dof_pendulum(): ) def test_indirect_attachment(): """Test attaching the IMU through an Xform primitive offset chain.""" + # If this test is ever un-skipped without porting the PhysX assertions, fail + # explicitly rather than passing vacuously. + pytest.fail( + "test_indirect_attachment was un-skipped without a body — port the assertions from" + " source/isaaclab_physx/test/sensors/test_imu.py::test_indirect_attachment." + ) diff --git a/source/isaaclab_ov/test/sensors/test_joint_wrench_sensor.py b/source/isaaclab_ov/test/sensors/test_joint_wrench_sensor.py index 232e731b5275..45f26fe77691 100644 --- a/source/isaaclab_ov/test/sensors/test_joint_wrench_sensor.py +++ b/source/isaaclab_ov/test/sensors/test_joint_wrench_sensor.py @@ -8,9 +8,9 @@ """Real-backend tests for the OVPhysX JointWrenchSensor. -Mirrors ``isaaclab_physx`` ``test_joint_wrench_sensor.py``; only the -fixtures and raw-tensor read helper change. Physical assertions are -kept byte-identical so the two backends report the same convention. +Wrench values and frames are checked against analytic loads by the shared +``test_joint_wrench_frame`` contract imported below; the local tests cover +initialization, body resolution, and reset behavior. The OVPhysX runtime fixes device mode (CPU vs GPU) when the process creates its first ``ovphysx.PhysX`` instance. Full coverage therefore requires two @@ -35,6 +35,7 @@ # the optional ovphysx wheel. Skip the OVPhysX tests gracefully in that case. pytest.importorskip("ovphysx.types", reason="ovphysx wheel not installed") +from isaaclab_ov import tensor_types as TT # noqa: E402 from isaaclab_ov.physics import OvPhysxCfg # noqa: E402 from isaaclab_physx.sim.schemas import PhysxJointCfg # noqa: E402 from joint_wrench_contract import test_joint_wrench_frame # noqa: E402, F401 @@ -47,7 +48,7 @@ from isaaclab.sim import SimulationCfg, build_simulation_context # noqa: E402 from isaaclab.terrains import TerrainImporterCfg # noqa: E402 from isaaclab.utils import configclass # noqa: E402 -from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR, ISAACLAB_NUCLEUS_DIR # noqa: E402 +from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR # noqa: E402 from isaaclab_assets.robots.ant import ANT_CFG # noqa: E402 @@ -55,12 +56,6 @@ pytestmark = pytest.mark.device_split -# OVPhysX/Warp and the PyTorch reference use different float32 operation order on CUDA. The -# relative gap grows with the wrench magnitude: the Ant scenes see contact wrenches on the order -# of 1e8, where the two orderings differ by a few 1e-5 relative. -_OVPHYSX_WRENCH_RTOL = 1e-4 -_OVPHYSX_WRENCH_ATOL = 1e-5 - # --------------------------------------------------------------------------- # Device-lock autouse fixture (copied from test_contact_sensor.py) # --------------------------------------------------------------------------- @@ -128,28 +123,6 @@ def _make_single_joint_articulation_cfg() -> ArticulationCfg: ) -def _make_cartpole_articulation_cfg(pole_damping: float = 0.0) -> ArticulationCfg: - """Two-joint cartpole articulation (cart + pole).""" - return ArticulationCfg( - prim_path="{ENV_REGEX_NS}/Robot", - spawn=sim_utils.UsdFileCfg( - usd_path=f"{ISAACLAB_NUCLEUS_DIR}/Robots/Classic/Cartpole/cartpole.usd", - ), - init_state=ArticulationCfg.InitialStateCfg( - pos=(0.0, 0.0, 2.0), - joint_pos={"slider_to_cart": 0.0, "cart_to_pole": 0.0}, - ), - actuators={ - "cart_actuator": ImplicitActuatorCfg( - joint_names_expr=["slider_to_cart"], joint_effort_limit=400.0, stiffness=0.0, damping=10.0 - ), - "pole_actuator": ImplicitActuatorCfg( - joint_names_expr=["cart_to_pole"], joint_effort_limit=400.0, stiffness=0.0, damping=pole_damping - ), - }, - ) - - @configclass class _SingleJointSceneCfg(InteractiveSceneCfg): """Scene with a single-joint articulation and the joint-wrench sensor.""" @@ -160,26 +133,6 @@ class _SingleJointSceneCfg(InteractiveSceneCfg): wrench = JointWrenchSensorCfg(prim_path="{ENV_REGEX_NS}/Robot") -@configclass -class _CartpoleSceneCfg(InteractiveSceneCfg): - """Scene with a cartpole (2-joint) articulation and the joint-wrench sensor.""" - - env_spacing = 4.0 - terrain = TerrainImporterCfg(prim_path="/World/ground", terrain_type="plane") - robot = _make_cartpole_articulation_cfg() - wrench = JointWrenchSensorCfg(prim_path="{ENV_REGEX_NS}/Robot") - - -@configclass -class _CartpoleDampedSceneCfg(InteractiveSceneCfg): - """Cartpole with pole damping for steady-state physics validation tests.""" - - env_spacing = 4.0 - terrain = TerrainImporterCfg(prim_path="/World/ground", terrain_type="plane") - robot = _make_cartpole_articulation_cfg(pole_damping=10.0) - wrench = JointWrenchSensorCfg(prim_path="{ENV_REGEX_NS}/Robot") - - @configclass class _NestedRootAntSceneCfg(InteractiveSceneCfg): """Ant USD asset whose articulation root is nested under the configured asset prim.""" @@ -204,44 +157,6 @@ def device(request): return request.param -# --------------------------------------------------------------------------- -# Raw-tensor helpers -# --------------------------------------------------------------------------- - - -def _ovphysx_incoming_joint_wrench(sensor: JointWrenchSensor) -> torch.Tensor: - """Read the raw OVPhysX incoming joint wrench tensor. - - OVPhysX reports spatial vectors as force followed by torque. Shape is - ``(num_envs, num_bodies, 6)``. The read targets an independent scratch - buffer so a subsequent ``sensor.data`` access (which re-reads into the - sensor's own ``_wrench_buf``) cannot retroactively change the returned - snapshot — mirrors how the PhysX helper returns an independent array from - :meth:`ArticulationView.get_link_incoming_joint_force`. - """ - scratch_buf = wp.zeros((sensor._num_envs, sensor._num_bodies), dtype=wp.spatial_vectorf, device=sensor._device) - scratch_view = wp.array( - ptr=scratch_buf.ptr, - shape=sensor._wrench_binding.shape, - dtype=wp.float32, - device=str(scratch_buf.device), - copy=False, - ) - sensor._wrench_binding.read(scratch_view) - return wp.to_torch(scratch_buf) - - -def _assert_sensor_matches_ovphysx_tensor(sensor: JointWrenchSensor) -> None: - """The sensor exposes the OVPhysX tensor's existing child-joint-frame components.""" - raw_wrench = _ovphysx_incoming_joint_wrench(sensor) - torch.testing.assert_close( - sensor.data.force.torch, raw_wrench[..., :3], rtol=_OVPHYSX_WRENCH_RTOL, atol=_OVPHYSX_WRENCH_ATOL - ) - torch.testing.assert_close( - sensor.data.torque.torch, raw_wrench[..., 3:], rtol=_OVPHYSX_WRENCH_RTOL, atol=_OVPHYSX_WRENCH_ATOL - ) - - # --------------------------------------------------------------------------- # Sensor data — pre-init contract # --------------------------------------------------------------------------- @@ -278,32 +193,14 @@ def test_initialization_and_shapes(sim, device): assert sensor.data.torque.torch.shape == (num_envs, num_bodies, 3) assert sensor.body_names == robot.body_names assert sensor.find_bodies("Arm") == ([robot.body_names.index("Arm")], ["Arm"]) - _assert_sensor_matches_ovphysx_tensor(sensor) - - -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -def test_multi_body_articulation(sim, device): - """Cartpole exposes a wrench for each link labelled by body name.""" - scene = InteractiveScene(_CartpoleSceneCfg(num_envs=2)) - sim.reset() - - robot: Articulation = scene["robot"] - sensor: JointWrenchSensor = scene["wrench"] - sim.step() - scene.update(sim.get_physics_dt()) - - num_envs = 2 - num_bodies = robot.num_bodies - assert sensor.data.force.torch.shape == (num_envs, num_bodies, 3) - assert sensor.data.torque.torch.shape == (num_envs, num_bodies, 3) - assert sensor.body_names == robot.body_names - assert len(sensor.body_names) == num_bodies - _assert_sensor_matches_ovphysx_tensor(sensor) + sensor_str = str(sensor) + assert "ovphysx" in sensor_str + assert "Joint wrench sensor" in sensor_str @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) def test_nested_articulation_root_resolution(sim, device): - """Sensor accepts an asset prim path whose articulation root is nested in the USD asset.""" + """Resolve a nested articulation root and preserve the wrench belonging to each physical link.""" scene = InteractiveScene(_NestedRootAntSceneCfg(num_envs=1)) sim.reset() @@ -315,95 +212,19 @@ def test_nested_articulation_root_resolution(sim, device): assert sensor.body_names == robot.body_names assert sensor.data.force.torch.shape == (1, robot.num_bodies, 3) assert sensor.data.torque.torch.shape == (1, robot.num_bodies, 3) - _assert_sensor_matches_ovphysx_tensor(sensor) - - -# --------------------------------------------------------------------------- -# Physical correctness -# --------------------------------------------------------------------------- - - -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -def test_force_and_torque_components_at_rest(sim, device): - """Component-level validation of force and torque against the OVPhysX tensor API.""" - scene = InteractiveScene(_SingleJointSceneCfg(num_envs=1)) - sim.reset() - - sensor: JointWrenchSensor = scene["wrench"] - robot: Articulation = scene["robot"] - for _ in range(400): - sim.step() - scene.update(sim.get_physics_dt()) - _assert_sensor_matches_ovphysx_tensor(sensor) - - arm_idx = robot.body_names.index("Arm") - raw_wrench = _ovphysx_incoming_joint_wrench(sensor) - assert torch.any(raw_wrench[:, arm_idx, :] != 0.0) - - -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -def test_wrench_with_external_force_and_torque(sim, device): - """Full wrench validation with external force and torque applied.""" - scene = InteractiveScene(_SingleJointSceneCfg(num_envs=1)) - sim.reset() - - sensor: JointWrenchSensor = scene["wrench"] - robot: Articulation = scene["robot"] - arm_idx = robot.body_names.index("Arm") - - ext_force_b = torch.zeros((1, robot.num_bodies, 3), device=sim.device) - ext_force_b[:, arm_idx, 1] = 10.0 - ext_torque_b = torch.zeros((1, robot.num_bodies, 3), device=sim.device) - ext_torque_b[:, arm_idx, 2] = 10.0 - - for _ in range(800): - robot.permanent_wrench_composer.set_forces_and_torques_index(forces=ext_force_b, torques=ext_torque_b) - robot.write_data_to_sim() - sim.step() - scene.update(sim.get_physics_dt()) - - _assert_sensor_matches_ovphysx_tensor(sensor) - - raw_wrench = _ovphysx_incoming_joint_wrench(sensor) - assert torch.any(raw_wrench[:, arm_idx, :] != 0.0) - - -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -def test_interior_joint_wrench_at_rest(sim, device): - """Interior joint wrench matches the raw OVPhysX incoming-joint tensor.""" - scene = InteractiveScene(_CartpoleDampedSceneCfg(num_envs=1)) - sim.reset() - - sensor: JointWrenchSensor = scene["wrench"] - robot: Articulation = scene["robot"] - - for _ in range(800): - sim.step() - scene.update(sim.get_physics_dt()) - - _assert_sensor_matches_ovphysx_tensor(sensor) - - cart_idx = robot.body_names.index("cart") - raw_wrench = _ovphysx_incoming_joint_wrench(sensor) - assert torch.any(raw_wrench[:, cart_idx, :] != 0.0) + # The single-joint analytic contract cannot detect a gather that duplicates one link across the others. + expected = wp.to_torch(robot.root_view.get_attribute(TT.LINK_INCOMING_JOINT_FORCE)).reshape(1, robot.num_bodies, 6) + assert not torch.allclose(expected[:, 2:], expected[:, 1:2].expand_as(expected[:, 2:])) + torch.testing.assert_close(sensor.data.force.torch, expected[..., :3], rtol=1e-4, atol=1e-5) + torch.testing.assert_close(sensor.data.torque.torch, expected[..., 3:], rtol=1e-4, atol=1e-5) # --------------------------------------------------------------------------- -# String representation +# Physical correctness # --------------------------------------------------------------------------- - -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -def test_sensor_print(sim, device): - """Test that the sensor string representation works.""" - scene = InteractiveScene(_SingleJointSceneCfg(num_envs=2)) - sim.reset() - - sensor: JointWrenchSensor = scene["wrench"] - sensor_str = str(sensor) - assert "ovphysx" in sensor_str - assert "Joint wrench sensor" in sensor_str +# Checked against analytic loads by the shared ``test_joint_wrench_frame`` imported above. # --------------------------------------------------------------------------- @@ -411,30 +232,13 @@ def test_sensor_print(sim, device): # --------------------------------------------------------------------------- -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -def test_reset_zeros_buffers(sim, device): - """Resetting the sensor clears the force / torque buffers.""" - scene = InteractiveScene(_SingleJointSceneCfg(num_envs=2)) - sim.reset() - - sensor: JointWrenchSensor = scene["wrench"] - for _ in range(100): - sim.step() - scene.update(sim.get_physics_dt()) - - assert torch.any(sensor.data.force.torch != 0), "Expected non-zero data before reset" - - sensor.reset() - - force_after = wp.to_torch(sensor._data._force) - torque_after = wp.to_torch(sensor._data._torque) - torch.testing.assert_close(force_after, torch.zeros_like(force_after)) - torch.testing.assert_close(torque_after, torch.zeros_like(torque_after)) - - @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) def test_reset_with_env_ids_only_zeros_selected_envs(sim, device): - """Partial reset via env_ids should zero the selected envs and preserve the others.""" + """Partial reset via env_ids should zero the selected envs and preserve the others; a full reset zeros all. + + The public read after the full reset is also the regression for #4970: it must not surface the + pre-reset wrenches that OVPhysX still holds. + """ scene = InteractiveScene(_SingleJointSceneCfg(num_envs=4)) sim.reset() @@ -454,25 +258,11 @@ def test_reset_with_env_ids_only_zeros_selected_envs(sim, device): torch.testing.assert_close(force_after[1], force_before[1]) torch.testing.assert_close(force_after[3], force_before[3]) - -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -def test_no_stale_data_after_scene_reset(sim, device): - """Regression for #4970: ``scene.reset(env_ids)`` must not surface pre-reset wrenches (OVPhysX).""" - scene = InteractiveScene(_SingleJointSceneCfg(num_envs=1)) - sim.reset() - - sensor: JointWrenchSensor = scene["wrench"] - for _ in range(100): - sim.step() - scene.update(sim.get_physics_dt()) - - pre_reset_force = sensor.data.force.torch.clone() - pre_reset_torque = sensor.data.torque.torch.clone() - assert torch.any(pre_reset_force != 0) or torch.any(pre_reset_torque != 0), "Expected non-zero wrench before reset" - - scene.reset(env_ids=torch.tensor([0], device=sensor.device)) - - post_reset_force = sensor.data.force.torch - post_reset_torque = sensor.data.torque.torch - torch.testing.assert_close(post_reset_force, torch.zeros_like(post_reset_force)) - torch.testing.assert_close(post_reset_torque, torch.zeros_like(post_reset_torque)) + # A reset without env_ids clears every environment. + sensor.reset() + force_after = wp.to_torch(sensor._data._force) + torque_after = wp.to_torch(sensor._data._torque) + torch.testing.assert_close(force_after, torch.zeros_like(force_after)) + torch.testing.assert_close(torque_after, torch.zeros_like(torque_after)) + torch.testing.assert_close(sensor.data.force.torch, torch.zeros_like(force_after)) + torch.testing.assert_close(sensor.data.torque.torch, torch.zeros_like(torque_after)) diff --git a/source/isaaclab_ov/test/sensors/test_pva.py b/source/isaaclab_ov/test/sensors/test_pva.py index 1ff506189659..019e4bcf7749 100644 --- a/source/isaaclab_ov/test/sensors/test_pva.py +++ b/source/isaaclab_ov/test/sensors/test_pva.py @@ -79,8 +79,7 @@ def _spawn_envs(num_envs: int) -> None: """Create per-env Xform containers at ``/World/env_``. - These match the prim-path layout the PVA's attachment-validity test - expects, and provide a parent for per-env asset spawns. + These provide a parent for per-env asset spawns. """ # /World/env_ Xforms are siblings under /World — no envs container needed for i in range(num_envs): @@ -306,6 +305,11 @@ def test_constant_velocity(sim_ctx, device): atol=1e-4, ) + # the bodies never rotate, so gravity projects to world -z in the body frame + expected_pg = torch.tensor([[0.0, 0.0, -1.0]], dtype=torch.float32, device=device).repeat(NUM_ENVS, 1) + torch.testing.assert_close(pva_ball.data.projected_gravity_b.torch, expected_pg, atol=1e-3, rtol=0.0) + torch.testing.assert_close(pva_cube.data.projected_gravity_b.torch, expected_pg, atol=1e-3, rtol=0.0) + # update previous values prev_lin_acc_ball = pva_ball.data.lin_acc_b.torch.clone() prev_ang_acc_ball = pva_ball.data.ang_acc_b.torch.clone() @@ -361,9 +365,21 @@ def test_constant_acceleration(sim_ctx, device): atol=1e-4, ) + # ``pose_w`` packs ``[pos_w | quat_w]`` + pose = pva_ball.data.pose_w.torch + assert pose.shape == (NUM_ENVS, 7) + torch.testing.assert_close(pose[:, :3], pva_ball.data.pos_w.torch) + torch.testing.assert_close(pose[:, 3:], pva_ball.data.quat_w.torch) + + s = str(pva_ball) + assert "Pva sensor @ '/World/env_[^/]+/ball'" in s + assert "binding pattern" in s + assert "/World/env_[^/]+/ball" in s + assert "number of sensors : 2" in s + # =========================================================================== -# Offset and env-id tests (procedural USD assets) +# Offset and reset tests (procedural USD assets) # =========================================================================== @@ -385,6 +401,17 @@ def test_offset_calculation(sim_ctx, device): ) sim_ctx.reset() + torch.testing.assert_close( + wp.to_torch(pva_child._offset_pos_b), + wp.to_torch(pva_direct._offset_pos_b), + ) + torch.testing.assert_close( + wp.to_torch(pva_child._offset_quat_b), + wp.to_torch(pva_direct._offset_quat_b), + rtol=1e-4, + atol=1e-4, + ) + dt = sim_ctx.get_physics_dt() for idx in range(50): @@ -450,168 +477,15 @@ def test_offset_calculation(sim_ctx, device): ) -@pytest.mark.parametrize("device", _DEVICES) -def test_env_ids_propagation(sim_ctx, device): - """Test that ``env_ids`` argument propagates through update and reset methods.""" - _spawn_envs(NUM_ENVS) - cubes = _spawn_cubes(NUM_ENVS) - pva_cube = _make_pva("/World/env_[^/]+/cube") - sim_ctx.reset() - - dt = sim_ctx.get_physics_dt() - - for idx in range(10): - velocity = torch.tensor([[0.5, 0.0, 0.0, 0.0, 0.0, 0.0]], dtype=torch.float32, device=device).repeat( - NUM_ENVS, 1 - ) * (idx + 1) - cubes.write_root_velocity_to_sim_index(root_velocity=velocity) - cubes.write_data_to_sim() - sim_ctx.step() - cubes.update(dt) - pva_cube.update(dt, force_recompute=True) - - assert torch.any(pva_cube.data.lin_vel_b.torch[1] != 0), "expected env 1 to have non-zero data before reset" - - # reset only env 1 - pva_cube.reset(env_ids=[1]) - torch.testing.assert_close( - wp.to_torch(pva_cube._data._lin_vel_b)[1], - torch.zeros(3, dtype=torch.float32, device=device), - ) - assert torch.any(wp.to_torch(pva_cube._data._lin_vel_b)[0] != 0), "env 0 should not be reset" - - pva_cube.update(dt, force_recompute=True) - sim_ctx.step() - pva_cube.update(dt, force_recompute=True) - - -# =========================================================================== -# Physics-correctness sanity tests (ported from Newton) -# =========================================================================== - - -@pytest.mark.parametrize("device", _DEVICES) -def test_sensor_initialization(sim_ctx, device): - """Test that the OVPhysX PVA sensor initializes correctly.""" - _spawn_envs(NUM_ENVS) - _spawn_balls(NUM_ENVS) - pva_ball = _make_pva("/World/env_[^/]+/ball") - sim_ctx.reset() - - assert pva_ball.num_instances == NUM_ENVS - # Inspect the raw warp buffers directly — accessing ``pva.data`` triggers a - # lazy recompute that needs ``_dt`` (set by ``update``). - for name, expected_dtype in [ - ("_pose_w", wp.transformf), - ("_pos_w", wp.vec3f), - ("_quat_w", wp.quatf), - ("_lin_vel_b", wp.vec3f), - ("_ang_vel_b", wp.vec3f), - ("_lin_acc_b", wp.vec3f), - ("_ang_acc_b", wp.vec3f), - ("_projected_gravity_b", wp.vec3f), - ]: - buf = getattr(pva_ball._data, name) - assert buf.shape == (NUM_ENVS,), f"{name} shape mismatch" - assert buf.dtype == expected_dtype, f"{name} dtype mismatch" - - -@pytest.mark.parametrize("device", _DEVICES) -def test_pose_w_packing(sim_ctx, device): - """Verify the lazy ``pose_w`` property packs ``[pos_w | quat_w]`` correctly. - - Regression guard for the ``concat_pos_and_quat_to_pose_1d_kernel`` launch in - :meth:`PvaData.pose_w` — without this test only buffer shape/dtype is - checked, and a regression that, say, swaps the kernel inputs or returns - stale data would not be caught. - """ - _spawn_envs(NUM_ENVS) - balls = _spawn_balls(NUM_ENVS) - pva_ball = _make_pva("/World/env_[^/]+/ball") - sim_ctx.reset() - - dt = sim_ctx.get_physics_dt() - # Step a few times so pos_w / quat_w are populated by the simulation. - for _ in range(3): - balls.write_data_to_sim() - sim_ctx.step() - balls.update(dt) - pva_ball.update(dt, force_recompute=True) - - pose = pva_ball.data.pose_w.torch - assert pose.shape == (NUM_ENVS, 7) - torch.testing.assert_close(pose[:, :3], pva_ball.data.pos_w.torch) - torch.testing.assert_close(pose[:, 3:], pva_ball.data.quat_w.torch) - - -@pytest.mark.parametrize("device", _DEVICES) -def test_projected_gravity_at_rest(sim_ctx, device): - """Test that a PVA at rest reports projected gravity ≈ (0, 0, -1) in body frame. - - Without InteractiveScene's terrain plumbing the ball falls forever, so we - drive it kinematically: hold zero velocity for enough steps to settle, then - check that the projected gravity unit vector points along world ``-z`` — - which for a body whose orientation is identity is also ``(0, 0, -1)`` in - the body frame. - """ - _spawn_envs(NUM_ENVS) - balls = _spawn_balls(NUM_ENVS) - pva_ball = _make_pva("/World/env_[^/]+/ball") - sim_ctx.reset() - - dt = sim_ctx.get_physics_dt() - zero_vel = torch.zeros((NUM_ENVS, 6), dtype=torch.float32, device=device) - for _ in range(5): - balls.write_root_velocity_to_sim_index(root_velocity=zero_vel) - balls.write_data_to_sim() - sim_ctx.step() - balls.update(dt) - pva_ball.update(dt, force_recompute=True) - - pg = pva_ball.data.projected_gravity_b.torch - expected = torch.tensor([[0.0, 0.0, -1.0]], dtype=pg.dtype, device=pg.device).repeat(NUM_ENVS, 1) - torch.testing.assert_close(pg, expected, atol=1e-3, rtol=0.0) - - -@pytest.mark.parametrize("device", _DEVICES) -def test_freefall_lin_acc(sim_ctx, device): - """Test that a freefalling PVA reports lin_acc_b ≈ rotated ``-g`` in body frame. - - The PVA reports the coordinate acceleration of the sensor frame. In - freefall this is ``-g`` (the body is accelerating downward at ``g``), so - the magnitude of ``lin_acc_b`` should converge to ``g ≈ 9.81 m/s^2``. - """ - _spawn_envs(NUM_ENVS) - balls = _spawn_balls(NUM_ENVS, height=5.0) - pva_ball = _make_pva("/World/env_[^/]+/ball") - sim_ctx.reset() - - dt = sim_ctx.get_physics_dt() - # Let physics integrate gravity for a few steps with no external velocity write. - for _ in range(10): - balls.write_data_to_sim() - sim_ctx.step() - balls.update(dt) - pva_ball.update(dt, force_recompute=True) - - lin_acc = pva_ball.data.lin_acc_b.torch - acc_magnitude = torch.linalg.norm(lin_acc, dim=-1) - torch.testing.assert_close( - acc_magnitude, - torch.full((NUM_ENVS,), 9.81, dtype=acc_magnitude.dtype, device=acc_magnitude.device), - atol=0.5, - rtol=0.0, - ) - - @pytest.mark.parametrize("device", _DEVICES) def test_reset(sim_ctx, device): """Test that ``reset`` zeroes out the PVA output and previous-velocity buffers. Mirrors the Newton ``test_reset`` parity check: drive the PVA until its - buffers hold non-zero data, then ``reset()`` and assert the raw warp - buffers are zero. We read the raw warp arrays directly because accessing - ``pva.data`` triggers a lazy re-fill that masks reset bugs. + buffers hold non-zero data, reset one env through ``env_ids`` and then all + of them, and assert the raw warp buffers hold their reset values where reset. + We read the raw warp arrays directly because accessing ``pva.data`` triggers + a lazy re-fill that masks reset bugs. """ _spawn_envs(NUM_ENVS) balls = _spawn_balls(NUM_ENVS) @@ -629,6 +503,15 @@ def test_reset(sim_ctx, device): # Buffers should hold non-zero state before reset. assert torch.any(wp.to_torch(pva_ball._data._lin_vel_b) != 0), "expected non-zero data before reset" + assert torch.any(pva_ball.data.lin_vel_b.torch[1] != 0), "expected env 1 to have non-zero data before reset" + + # reset only env 1 + pva_ball.reset(env_ids=[1]) + torch.testing.assert_close( + wp.to_torch(pva_ball._data._lin_vel_b)[1], + torch.zeros(3, dtype=torch.float32, device=device), + ) + assert torch.any(wp.to_torch(pva_ball._data._lin_vel_b)[0] != 0), "env 0 should not be reset" pva_ball.reset() @@ -691,134 +574,6 @@ def test_no_stale_data_after_scene_reset(sim_ctx, device): torch.testing.assert_close(post_reset_ang_vel, torch.zeros_like(post_reset_ang_vel)) -@pytest.mark.parametrize("device", _DEVICES) -def test_indirect_attachment_usd(sim_ctx, device): - """Test that a PVA attached to a non-physics Xform under a rigid ancestor matches a direct attachment. - - USD-only port of PhysX's ``test_indirect_attachment``: the URDF pendulum is - not available kitless, but the indirect-attachment code path is reachable - by attaching a non-physics Xform child to a rigid ball and pointing the - PVA at it. The composed offset should match the directly-configured - offset; all output channels should agree. - """ - _spawn_envs(NUM_ENVS) - balls = _spawn_balls(NUM_ENVS) - # Add a non-physics Xform child under each ball at a known offset; the PVA - # must resolve the rigid-body ancestor (the ball) and recover the offset. - sub_pos = (0.4, 0.0, 0.1) - sub_rot = (0.5, 0.5, 0.5, 0.5) - for i in range(NUM_ENVS): - sim_utils.create_prim(f"/World/env_{i}/ball/pva_sub", "Xform", translation=sub_pos, orientation=sub_rot) - pva_indirect = _make_pva("/World/env_[^/]+/ball/pva_sub") - pva_direct = _make_pva("/World/env_[^/]+/ball", offset=PvaCfg.OffsetCfg(pos=sub_pos, rot=sub_rot)) - sim_ctx.reset() - - torch.testing.assert_close( - wp.to_torch(pva_indirect._offset_pos_b), - wp.to_torch(pva_direct._offset_pos_b), - ) - torch.testing.assert_close( - wp.to_torch(pva_indirect._offset_quat_b), - wp.to_torch(pva_direct._offset_quat_b), - rtol=1e-4, - atol=1e-4, - ) - - dt = sim_ctx.get_physics_dt() - drive_vel = torch.tensor([[0.05, 0.0, 0.0, 0.0, 0.0, 0.0]], dtype=torch.float32, device=device).repeat(NUM_ENVS, 1) - for idx in range(50): - balls.write_root_velocity_to_sim_index(root_velocity=drive_vel * (idx + 1)) - balls.write_data_to_sim() - sim_ctx.step() - balls.update(dt) - pva_indirect.update(dt, force_recompute=True) - pva_direct.update(dt, force_recompute=True) - - if idx < 2: - continue - - torch.testing.assert_close( - pva_indirect.data.pos_w.torch, - pva_direct.data.pos_w.torch, - rtol=1e-4, - atol=1e-4, - ) - torch.testing.assert_close( - pva_indirect.data.quat_w.torch, - pva_direct.data.quat_w.torch, - rtol=1e-4, - atol=1e-4, - ) - torch.testing.assert_close( - pva_indirect.data.lin_vel_b.torch, - pva_direct.data.lin_vel_b.torch, - rtol=1e-2, - atol=5e-3, - ) - torch.testing.assert_close( - pva_indirect.data.ang_vel_b.torch, - pva_direct.data.ang_vel_b.torch, - rtol=1e-4, - atol=1e-4, - ) - torch.testing.assert_close( - pva_indirect.data.lin_acc_b.torch, - pva_direct.data.lin_acc_b.torch, - rtol=1e-4, - atol=1e-4, - ) - torch.testing.assert_close( - pva_indirect.data.ang_acc_b.torch, - pva_direct.data.ang_acc_b.torch, - rtol=1e-4, - atol=1e-4, - ) - torch.testing.assert_close( - pva_indirect.data.projected_gravity_b.torch, - pva_direct.data.projected_gravity_b.torch, - rtol=1e-4, - atol=1e-4, - ) - - -# =========================================================================== -# Validation tests (no asset state required) -# =========================================================================== - - -@pytest.mark.parametrize("device", _DEVICES) -def test_attachment_validity(sim_ctx, device): - """Test invalid PVA attachment. - - A PVA sensor cannot be attached directly to the world Xform — it must have - a rigid-body ancestor in its prim tree. - """ - _spawn_envs(NUM_ENVS) - sim_ctx.reset() - - pva_world_cfg = PvaCfg(prim_path="/World/env_0") - with pytest.raises(RuntimeError) as exc_info: - pva_world = Pva(pva_world_cfg) - pva_world._initialize_impl() - assert exc_info.type is RuntimeError and "find a rigid body ancestor prim" in str(exc_info.value) - - -@pytest.mark.parametrize("device", _DEVICES) -def test_sensor_print(sim_ctx, device): - """Test ``__str__`` is implemented and exposes the prim path and binding pattern.""" - _spawn_envs(NUM_ENVS) - _spawn_balls(NUM_ENVS) - pva_ball = _make_pva("/World/env_[^/]+/ball") - sim_ctx.reset() - - s = str(pva_ball) - print(s) - assert "Pva sensor @ '/World/env_[^/]+/ball'" in s - assert "binding pattern" in s - assert "/World/env_[^/]+/ball" in s - assert "number of sensors : 2" in s - - # =========================================================================== # URDF-dependent tests — skipped pending USD pendulum asset # =========================================================================== diff --git a/source/isaaclab_ov/test/sensors/test_ray_caster.py b/source/isaaclab_ov/test/sensors/test_ray_caster.py index a2abea3d08ac..ff504b841cd1 100644 --- a/source/isaaclab_ov/test/sensors/test_ray_caster.py +++ b/source/isaaclab_ov/test/sensors/test_ray_caster.py @@ -51,10 +51,11 @@ def _initialize_static_pose_tracking(self, prims): raise AssertionError("dynamic clone-plan sources should not fall back to static USD pose tracking") -def test_initialize_pose_tracking_uses_shared_rigid_body_resolver_without_destination_usd(monkeypatch): - """RayCaster should use SensorBase clone-plan resolution when destination USD prims are missing.""" +def test_initialize_pose_tracking_binds_body_glob_and_replicates_offset(monkeypatch): + """The resolved rigid-body expression is bound as a glob, and its fixed offset is replicated per frame.""" fake_physx = _FakePhysx() + # No destination USD prims exist: resolution must go through the shared clone-plan resolver. monkeypatch.setattr(ray_caster_module.sim_utils, "find_matching_prims", lambda _path: []) monkeypatch.setattr(ray_caster_module.OvPhysxManager, "get_physx_instance", staticmethod(lambda: fake_physx)) diff --git a/source/isaaclab_ov/test/sim/test_ovphysx_view.py b/source/isaaclab_ov/test/sim/test_ovphysx_view.py index a4bb84505ad3..422db9180513 100644 --- a/source/isaaclab_ov/test/sim/test_ovphysx_view.py +++ b/source/isaaclab_ov/test/sim/test_ovphysx_view.py @@ -227,42 +227,23 @@ def test_resolve_roundtrips_name_and_enum(): assert resolve_tensor_type("RIGID_BODY_POSE") is TensorType.RIGID_BODY_POSE # case-insensitive -def test_resolve_unknown_name_raises(): +@pytest.mark.parametrize( + "key", + ["not_a_real_attribute", "invalid", 123, TensorType.INVALID], + ids=["unknown_name", "invalid_name", "non_str", "invalid_enum"], +) +def test_resolve_unknown_name_raises(key): with pytest.raises(OvPhysxView.UnknownAttribute): - resolve_tensor_type("not_a_real_attribute") - - -def test_read_only_names_are_valid_vocabulary(): - # The expected inventory keeps the manually maintained production set complete; - # vocabulary coverage also rejects stale names after wheel enum changes. - from isaaclab_ov.sim.views import ovphysx_view as mod - - assert mod._READ_ONLY_NAMES == _EXPECTED_READ_ONLY_NAMES - assert set(attribute_vocabulary()) >= mod._READ_ONLY_NAMES - - -def test_read_only_and_cpu_only_classification(): - assert is_read_only("articulation_jacobian") - assert is_read_only("rigid_body_acceleration") - assert is_read_only("articulation_dof_drive_type") - assert not is_read_only("articulation_dof_stiffness") - assert is_cpu_only("articulation_dof_stiffness") - assert is_cpu_only("rigid_body_mass") - assert is_cpu_only("rigid_body_disable_gravity") - assert is_cpu_only("articulation_dof_drive_type") - assert not is_cpu_only("rigid_body_pose") - + _make_view().get_attribute(key) -def test_cpu_only_names_match_canonical_set(): - # Comparing the view's derived set against the canonical one cannot fail, since the - # former is built from the latter. The expected inventory is what gives this test - # teeth: it is measured residency, so it also rejects a set that is merely incomplete. - from isaaclab_ov.sim.views import ovphysx_view as mod - from isaaclab_ov.tensor_types import _CPU_ONLY_TYPES - assert frozenset(tt.name.lower() for tt in _CPU_ONLY_TYPES) == _EXPECTED_CPU_ONLY_NAMES - assert mod._CPU_ONLY_NAMES == _EXPECTED_CPU_ONLY_NAMES - assert set(attribute_vocabulary()) >= mod._CPU_ONLY_NAMES +def test_read_only_and_cpu_only_classification_match_measured_inventory(): + # The expected inventories are measured, so both directions matter: a missing name and a + # misclassified one both fail. Iterating the vocabulary also rejects expected names the + # wheel no longer exposes. + vocabulary = attribute_vocabulary() + assert {name for name in vocabulary if is_read_only(name)} == _EXPECTED_READ_ONLY_NAMES + assert {name for name in vocabulary if is_cpu_only(name)} == _EXPECTED_CPU_ONLY_NAMES # ----------------------------------------------------------------------------- @@ -270,11 +251,32 @@ def test_cpu_only_names_match_canonical_set(): # ----------------------------------------------------------------------------- -def test_requires_exactly_one_of_pattern_or_prim_paths(): - with pytest.raises(ValueError): - OvPhysxView(_FakePhysX(), pattern="/p", prim_paths=["/p"], device="cpu") - with pytest.raises(ValueError): - OvPhysxView(_FakePhysX(), device="cpu") +@pytest.mark.parametrize( + ("kwargs", "match"), + [ + ({"pattern": "/p", "prim_paths": ["/p"]}, None), + ({}, None), + ({"prim_paths": []}, None), + ({"pattern": ""}, None), + ({"pattern": "/p", "tensor_types": [TensorType.RIGID_BODY_POSE]}, "eager"), + # LINK_POSE is GPU state; RIGID_BODY_MASS is CPU-only -> the device guard would be wrong. + ( + {"pattern": "/p", "key_aliases": {TensorType.ARTICULATION_LINK_POSE: TensorType.RIGID_BODY_MASS}}, + "residency", + ), + ], + ids=[ + "pattern_and_prim_paths", + "no_target", + "empty_prim_paths", + "empty_pattern", + "types_without_eager", + "alias_residency", + ], +) +def test_requires_exactly_one_of_pattern_or_prim_paths(kwargs, match): + with pytest.raises(ValueError, match=match): + OvPhysxView(_FakePhysX(), device="cpu", **kwargs) def test_eager_creates_requested_and_exposes_metadata(): @@ -294,6 +296,16 @@ def test_eager_default_sweep_empty_view_raises(): physx = _FakePhysX(all_unavailable=True) with pytest.raises(OvPhysxViewError, match="Could not create any bindings"): OvPhysxView(physx, pattern="/no/match", device="cpu", eager=True) + # When the caller names exact types, a failing one is surfaced (not silently dropped). + physx = _FakePhysX(n=3, unavailable={TensorType.RIGID_BODY_VELOCITY}) + with pytest.raises(OvPhysxView.AttributeUnavailable): + OvPhysxView( + physx, + pattern="/World/env_*/body", + device="cpu", + tensor_types=[TensorType.RIGID_BODY_POSE, TensorType.RIGID_BODY_VELOCITY], + eager=True, + ) # ----------------------------------------------------------------------------- @@ -301,19 +313,20 @@ def test_eager_default_sweep_empty_view_raises(): # ----------------------------------------------------------------------------- -def test_get_attribute_uses_binding_reported_int32_dtype(): +@pytest.mark.parametrize( + ("tensor_type", "dtype"), + [ + (TensorType.DEFORMABLE_SIM_ELEMENT_INDICES, wp.int32), + (TensorType.RIGID_BODY_DISABLE_SIMULATION, wp.uint8), + ], +) +def test_get_attribute_uses_binding_reported_scalar_dtype(tensor_type, dtype): view = _make_view(n=2) - values = view.get_attribute(TensorType.DEFORMABLE_SIM_ELEMENT_INDICES) - assert values.dtype == wp.int32 + values = view.get_attribute(tensor_type) + assert values.dtype == dtype assert tuple(values.shape) == (2, 1) -def test_get_attribute_uses_binding_reported_uint8_dtype(): - view = _make_view(n=2) - values = view.get_attribute(TensorType.RIGID_BODY_DISABLE_SIMULATION) - assert values.dtype == wp.uint8 - - def test_int32_binding_accepts_int32_and_rejects_float32(): view = _make_view(n=2) view.read_into( @@ -335,18 +348,6 @@ def test_missing_or_unsupported_binding_dtype_raises_compatibility_error(): view.get_attribute(TensorType.RIGID_BODY_MASS) -def test_get_attribute_allocates_fresh_typed_buffer_each_call(): - view = _make_view(n=4) - buf = view.get_attribute("rigid_body_pose") - # Pose maps to a structured dtype: an [N] transformf array (== [N, 7] float32). - assert tuple(buf.shape) == (4,) and buf.dtype == wp.transformf - binding = view._bindings[TensorType.RIGID_BODY_POSE] - assert binding.read_calls == 1 - buf2 = view.get_attribute("rigid_body_pose") - assert buf2 is not buf # no aliasing of view state - assert binding.read_calls == 2 - - def test_get_attribute_no_out_does_not_grow_read_cache(): # A no-`out` get_attribute allocates a fresh buffer each call, so caching its reinterpret by # id() could never hit on a later call and would leak one entry (keeping the buffer alive) per @@ -354,8 +355,12 @@ def test_get_attribute_no_out_does_not_grow_read_cache(): # cache -- the read cache only pays off for a reused `out`/`dst`. Pose is the structured case # (transformf), the one that would have been cached before the fix. view = _make_view(n=3) + previous = None for _ in range(5): - view.get_attribute("rigid_body_pose") + buf = view.get_attribute("rigid_body_pose") + assert buf is not previous # fresh buffer each call, no aliasing of view state + previous = buf + assert view._bindings[TensorType.RIGID_BODY_POSE].read_calls == 5 assert view._read_views == {} @@ -379,6 +384,8 @@ def test_read_into_reuses_reinterpret_view_across_calls(): view.read_into("rigid_body_pose", dst) assert binding.last_read_obj is first # same object handed to the wheel both times assert first is not dst # it is the float32 reinterpret, not the transformf buffer + # The reinterpret matches the binding's flat float32 shape. + assert binding.last_read == (wp.float32, (3, 7), "cpu") def test_read_into_passthrough_reuses_dst_object(): @@ -438,22 +445,6 @@ def test_get_attribute_out_param_is_filled_and_returned(): assert view._bindings[TensorType.RIGID_BODY_POSE].read_calls == 1 -def test_read_into_reinterprets_structured_buffer(): - view = _make_view(n=3) - dst = wp.zeros((3,), dtype=wp.transformf, device="cpu") # [N] transformf == [N,7] float32 - view.read_into("rigid_body_pose", dst) - binding = view._bindings[TensorType.RIGID_BODY_POSE] - # The binding was handed a float32 view matching its flat shape, not the transformf buffer. - assert binding.last_read == (wp.float32, (3, 7), "cpu") - - -def test_read_into_passthrough_when_already_float32(): - view = _make_view(n=3) - dst = wp.zeros((3, 7), dtype=wp.float32, device="cpu") - view.read_into("rigid_body_pose", dst) - assert view._bindings[TensorType.RIGID_BODY_POSE].last_read == (wp.float32, (3, 7), "cpu") - - def test_read_into_shape_mismatch_raises(): view = _make_view(n=3) wrong = wp.zeros((3, 6), dtype=wp.float32, device="cpu") @@ -466,12 +457,19 @@ def test_read_into_shape_mismatch_raises(): # ----------------------------------------------------------------------------- -def test_cpu_array_for_device_state_on_gpu_sim_raises(): - # GPU sim, but a CPU buffer is supplied for a device-resident state attribute. +@pytest.mark.parametrize("operation", ["read_into", "get_attribute_out", "set_attribute"]) +def test_cpu_array_for_device_state_on_gpu_sim_raises(operation): + # GPU sim, but a CPU buffer is supplied for a device-resident state attribute. Each entry + # point has its own device check. view = _make_view(n=3, device="cuda:0") cpu_buf = wp.zeros((3, 7), dtype=wp.float32, device="cpu") with pytest.raises(OvPhysxView.DeviceMismatch, match="cuda:0"): - view.read_into("rigid_body_pose", cpu_buf) + if operation == "read_into": + view.read_into("rigid_body_pose", cpu_buf) + elif operation == "get_attribute_out": + view.get_attribute("rigid_body_pose", out=cpu_buf) + else: + view.set_attribute("rigid_body_pose", cpu_buf) def test_cpu_only_property_accepts_cpu_buffer_on_gpu_sim(): @@ -495,15 +493,6 @@ def test_gpu_array_for_cpu_only_property_raises(): # ----------------------------------------------------------------------------- -def test_set_attribute_forwards_indices_and_mask(): - view = _make_view(n=3) - values = wp.zeros((3, 7), dtype=wp.float32, device="cpu") - idx = wp.array([0, 2], dtype=wp.int32, device="cpu") - view.set_attribute("rigid_body_pose", values, indices=idx) - dtype, shape, indices, mask = view._bindings[TensorType.RIGID_BODY_POSE].write_calls[0] - assert (dtype, shape, indices, mask) == (wp.float32, (3, 7), idx, None) - - def test_set_attribute_reinterprets_structured_source(): view = _make_view(n=3) values = wp.zeros((3,), dtype=wp.transformf, device="cpu") @@ -512,8 +501,9 @@ def test_set_attribute_reinterprets_structured_source(): assert (dtype, shape) == (wp.float32, (3, 7)) -@pytest.mark.parametrize("name", sorted(_EXPECTED_READ_ONLY_NAMES)) -def test_set_attribute_read_only_raises_and_does_not_bind(name: str): +def test_set_attribute_read_only_raises_and_does_not_bind(): + # Read-only membership per name is owned by the classification test above. + name = "articulation_jacobian" view = _make_view(n=3) values = wp.zeros((3, 1), dtype=wp.float32, device="cpu") with pytest.raises(OvPhysxView.ReadOnlyAttribute, match="read-only"): @@ -522,20 +512,6 @@ def test_set_attribute_read_only_raises_and_does_not_bind(name: str): assert view._physx.created == [] -def test_set_attribute_shape_mismatch_raises(): - view = _make_view(n=3) - wrong = wp.zeros((3, 6), dtype=wp.float32, device="cpu") - with pytest.raises(OvPhysxView.ShapeMismatch, match="Shape mismatch"): - view.set_attribute("rigid_body_pose", wrong) - - -def test_set_attribute_cpu_array_for_state_on_gpu_sim_raises(): - view = _make_view(n=3, device="cuda:0") - values = wp.zeros((3, 7), dtype=wp.float32, device="cpu") - with pytest.raises(OvPhysxView.DeviceMismatch): - view.set_attribute("rigid_body_pose", values) - - def test_as_wp_accepts_numpy_float32_and_rejects_float64(): view = _make_view(n=2) # float32 host data is materialized on the native device and written. @@ -575,16 +551,12 @@ def test_prim_paths_with_key_alias_creates_remapped_type(): # ----------------------------------------------------------------------------- -def test_unknown_attribute_raises_on_access(): - view = _make_view() - with pytest.raises(OvPhysxView.UnknownAttribute): - view.get_attribute("totally_made_up") - - def test_unavailable_binding_reports_clear_error(): view = _make_view(n=3, unavailable={TensorType.RIGID_BODY_VELOCITY}) with pytest.raises(OvPhysxView.AttributeUnavailable, match="not available"): view.get_attribute("rigid_body_velocity") + # The probed zero-count binding is released rather than leaked. + assert view._physx.bindings[-1].destroy_calls == 1 def test_discoverability_surface(): @@ -666,37 +638,22 @@ def test_raw_binding_keeps_owning_view_registered_until_runtime_close(): assert view_ref() is None -def test_unavailable_binding_is_destroyed(): - view = _make_view(n=3, unavailable={TensorType.RIGID_BODY_POSE}) - - with pytest.raises(OvPhysxView.AttributeUnavailable): - view.binding_for("rigid_body_pose") - - assert view._physx.bindings[0].destroy_calls == 1 - - # ----------------------------------------------------------------------------- # dtype safety — the view reinterprets bits, so non-float32 scalars must be rejected # ----------------------------------------------------------------------------- -def test_set_attribute_rejects_same_byte_size_wrong_dtype(): +@pytest.mark.parametrize("dtype", [wp.int32, wp.float16]) +def test_set_attribute_rejects_non_float32_scalar_dtype(dtype): # int32 has the same 4-byte width as float32: it would pass a byte-count-only guard - # and get bit-reinterpreted into garbage. It must be rejected, not silently written. + # and get bit-reinterpreted into garbage. float16 is narrower. Both must be rejected. view = _make_view(n=3) - int_buf = wp.zeros((3, 7), dtype=wp.int32, device="cpu") + wrong_buf = wp.zeros((3, 7), dtype=dtype, device="cpu") with pytest.raises(OvPhysxView.DtypeMismatch, match="float32 scalar"): - view.set_attribute("rigid_body_pose", int_buf) + view.set_attribute("rigid_body_pose", wrong_buf) assert view._bindings[TensorType.RIGID_BODY_POSE].write_calls == [] -def test_set_attribute_rejects_sub_4byte_dtype(): - view = _make_view(n=3) - half_buf = wp.zeros((3, 7), dtype=wp.float16, device="cpu") - with pytest.raises(OvPhysxView.DtypeMismatch, match="float32 scalar"): - view.set_attribute("rigid_body_pose", half_buf) - - def test_set_attribute_forwards_both_indices_and_mask(): # The view forwards both verbatim; the wheel resolves precedence (mask wins). view = _make_view(n=3) @@ -704,24 +661,11 @@ def test_set_attribute_forwards_both_indices_and_mask(): idx = wp.array([0, 2], dtype=wp.int32, device="cpu") mask = wp.array([True, False, True], dtype=wp.bool, device="cpu") view.set_attribute("rigid_body_pose", values, indices=idx, mask=mask) - _, _, fwd_idx, fwd_mask = view._bindings[TensorType.RIGID_BODY_POSE].write_calls[0] + dtype, shape, fwd_idx, fwd_mask = view._bindings[TensorType.RIGID_BODY_POSE].write_calls[0] + assert (dtype, shape) == (wp.float32, (3, 7)) assert fwd_idx is idx and fwd_mask is mask -def test_get_attribute_out_on_wrong_device_raises(): - # `get_attribute(out=)` has its own device check distinct from read_into's. - view = _make_view(n=3, device="cuda:0") - cpu_out = wp.zeros((3, 7), dtype=wp.float32, device="cpu") - with pytest.raises(OvPhysxView.DeviceMismatch): - view.get_attribute("rigid_body_pose", out=cpu_out) - - -def test_resolve_rejects_non_str_non_tensortype(): - view = _make_view() - with pytest.raises(OvPhysxView.UnknownAttribute): - view.get_attribute(123) - - def test_set_attribute_rejects_non_contiguous_source(): # A strided slice has the right element count but non-contiguous memory; the ptr-based # reinterpret would read the wrong elements, so it must be rejected. @@ -738,12 +682,6 @@ def test_set_attribute_rejects_non_contiguous_source(): # ----------------------------------------------------------------------------- -def test_invalid_tensortype_member_rejected(): - view = _make_view() - with pytest.raises(OvPhysxView.UnknownAttribute): - view.get_attribute(TensorType.INVALID) - - def test_string_keyed_aliases_are_honored(): # Passing string alias keys/values must be normalized to TensorType, not silently dropped. physx = _FakePhysX(n=6) @@ -757,42 +695,6 @@ def test_string_keyed_aliases_are_honored(): assert physx.created[0][0] is TensorType.RIGID_BODY_POSE # alias applied -def test_key_alias_crossing_residency_is_rejected(): - # LINK_POSE is GPU state; RIGID_BODY_MASS is CPU-only -> the device guard would be wrong. - with pytest.raises(ValueError, match="residency"): - OvPhysxView( - _FakePhysX(), - pattern="/p", - device="cpu", - key_aliases={TensorType.ARTICULATION_LINK_POSE: TensorType.RIGID_BODY_MASS}, - ) - - -def test_tensor_types_without_eager_raises(): - with pytest.raises(ValueError, match="eager"): - OvPhysxView(_FakePhysX(), pattern="/p", device="cpu", tensor_types=[TensorType.RIGID_BODY_POSE]) - - -def test_empty_target_is_rejected(): - with pytest.raises(ValueError): - OvPhysxView(_FakePhysX(), prim_paths=[], device="cpu") - with pytest.raises(ValueError): - OvPhysxView(_FakePhysX(), pattern="", device="cpu") - - -def test_eager_explicit_unavailable_type_raises_loud(): - # When the caller names exact types, a failing one is surfaced (not silently dropped). - physx = _FakePhysX(n=3, unavailable={TensorType.RIGID_BODY_VELOCITY}) - with pytest.raises(OvPhysxView.AttributeUnavailable): - OvPhysxView( - physx, - pattern="/World/env_*/body", - device="cpu", - tensor_types=[TensorType.RIGID_BODY_POSE, TensorType.RIGID_BODY_VELOCITY], - eager=True, - ) - - def test_get_attribute_cpu_only_property_returns_cpu_buffer_on_gpu_sim(): # No-out allocation path must use the native device: CPU for a CPU-only property even # though the sim device is a GPU. (CPU allocation -> runs without a GPU.) @@ -801,23 +703,6 @@ def test_get_attribute_cpu_only_property_returns_cpu_buffer_on_gpu_sim(): assert str(buf.device) == "cpu" -@pytest.mark.parametrize( - "name", - [ - name - for name in ("articulation_shape_friction_and_restitution", "rigid_body_shape_friction_and_restitution") - if hasattr(TensorType, name.upper()) - ], -) -def test_shape_material_property_returns_cpu_buffer_on_gpu_sim(name: str): - """Shape material properties must use their CPU-native bindings on GPU simulations.""" - view = _make_view(n=3, device="cuda:0") - - buf = view.get_attribute(name) - - assert str(buf.device) == "cpu" - - def test_binding_for_is_idempotent_and_unguarded(): view = _make_view(n=3) # Returns a binding even for a read-only attribute (raw access bypasses the write guard)... diff --git a/source/isaaclab_ov/test/sim/test_views_xform_prim_ovphysx.py b/source/isaaclab_ov/test/sim/test_views_xform_prim_ovphysx.py index 7daba7203801..0174788e039c 100644 --- a/source/isaaclab_ov/test/sim/test_views_xform_prim_ovphysx.py +++ b/source/isaaclab_ov/test/sim/test_views_xform_prim_ovphysx.py @@ -29,22 +29,6 @@ pytestmark = pytest.mark.device_split -@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) -def test_factory_dispatches_to_ovphysx_frame_view(device): - """``FrameView(...)`` under an OVPhysX ``SimulationContext`` returns an ``OvPhysxFrameView``.""" - OVPHYSX_SIM_CFG.device = device - with build_simulation_context(device=device, sim_cfg=OVPHYSX_SIM_CFG, add_ground_plane=True): - # Define a plain Xform prim so the pattern matches at least one prim. - stage = sim_utils.get_current_stage() - prim = stage.DefinePrim("/World/marker", "Xform") - sim_utils.standardize_xform_ops(prim) - - from isaaclab_ov.sim.views import OvPhysxFrameView - - view = FrameView("/World/marker", device=device) - assert isinstance(view, OvPhysxFrameView), f"Expected OvPhysxFrameView, got {type(view).__name__}" - - def test_view_raises_before_physics_ready(): """A view constructed before PHYSICS_READY raises a clear error on pose-method calls.""" device = "cpu" @@ -54,14 +38,16 @@ def test_view_raises_before_physics_ready(): prim = stage.DefinePrim("/World/marker_pre", "Xform") sim_utils.standardize_xform_ops(prim) view = FrameView("/World/marker_pre", device=device) - if hasattr(view, "_site_body"): - pytest.skip("PHYSICS_READY already fired; cannot exercise the deferred-init path here.") + # Nothing has played the simulation yet, so the view must still be uninitialized. + assert not hasattr(view, "_site_body") with pytest.raises(RuntimeError, match="used before initialization"): view.get_world_poses() def test_world_attached_source_prim_expands_from_clone_plan(): """A source-only world frame expands across cloned environments without USD replication.""" + from isaaclab_ov.sim.views import OvPhysxFrameView + device = "cpu" OVPHYSX_SIM_CFG.device = device with build_simulation_context( @@ -86,6 +72,7 @@ def test_world_attached_source_prim_expands_from_clone_plan(): view = FrameView("/World/envs/env_[^/]+/WorldCamera", device=device) + assert isinstance(view, OvPhysxFrameView) assert not stage.GetPrimAtPath(f"/World/envs/env_{target_env_ids[1]}").IsValid() assert not stage.GetPrimAtPath(f"/World/envs/env_{target_env_ids[1]}/WorldCamera").IsValid() assert view.count == scene.num_envs diff --git a/source/isaaclab_ov/test/test_cloner.py b/source/isaaclab_ov/test/test_cloner.py index 9439ec7566e4..e6ccf74a086a 100644 --- a/source/isaaclab_ov/test/test_cloner.py +++ b/source/isaaclab_ov/test/test_cloner.py @@ -153,29 +153,16 @@ def GetPrimAtPath(self, path): @pytest.mark.parametrize( ("name", "value"), - [("positions", np.zeros((1, 3), dtype=np.float32)), ("quaternions", np.zeros((1, 4), dtype=np.float32))], -) -def test_raw_replicate_rejects_pose_array_missing_selected_environment(name, value): - """Provided pose arrays include every selected environment.""" - stage = Usd.Stage.CreateInMemory() - UsdGeom.Xform.Define(stage, "/World/envs/env_0/Robot") - with pytest.raises(ValueError, match=name): - ovphysx_replicate( - stage, - sources=["/World/envs/env_0/Robot"], - destinations=["/World/envs/env_{}/Robot"], - env_ids=np.array([0, 1], dtype=np.int64), - mapping=np.array([[True, True]], dtype=np.bool_), - **{name: value}, - ) - - -@pytest.mark.parametrize( - ("name", "value"), - [("positions", np.zeros((2, 2), dtype=np.float32)), ("quaternions", np.zeros((2, 3), dtype=np.float32))], + [ + ("positions", np.zeros((2, 2), dtype=np.float32)), + ("quaternions", np.zeros((2, 3), dtype=np.float32)), + # Missing the second selected environment. + ("positions", np.zeros((1, 3), dtype=np.float32)), + ("quaternions", np.zeros((1, 4), dtype=np.float32)), + ], ) def test_raw_replicate_rejects_malformed_pose_array(name, value): - """Provided pose arrays use the documented component counts.""" + """Provided pose arrays use the documented component counts and include every selected environment.""" stage = Usd.Stage.CreateInMemory() UsdGeom.Xform.Define(stage, "/World/envs/env_0/Robot") with pytest.raises(ValueError, match=rf"{name} must have shape"): diff --git a/source/isaaclab_ov/test/test_ovrtx_annotator_utils.py b/source/isaaclab_ov/test/test_ovrtx_annotator_utils.py index d84ff523b139..7cf343b56485 100644 --- a/source/isaaclab_ov/test/test_ovrtx_annotator_utils.py +++ b/source/isaaclab_ov/test/test_ovrtx_annotator_utils.py @@ -34,19 +34,19 @@ ) -def _encode_semantic_id_map(entries: list[tuple[int, str]]) -> "np.ndarray": - """Encode ``(semantic_id, raw_label)`` pairs into a SemanticIdMap byte buffer for decode tests. +def _encode_identifier_map(entries: "list[StableIdLabelPair]") -> "np.ndarray": + """Encode ``(id_words, raw_label)`` pairs into an IdentifierMap byte buffer (SemanticIdMap / StableIdMap). - Layout mirrors the OVRTX render var: packed ``SemanticIdentifierMap`` entries, the UTF-8 label blob, - then a trailing little-endian ``uint32`` entry count. + Layout mirrors the OVRTX render var: packed ``IdentifierMap`` entries, the UTF-8 label blob, then a + trailing little-endian ``uint32`` entry count. """ entry_dtype = np.dtype([("id", " "np.ndarray": - """Encode ``(id_words, raw_label)`` pairs into an IdentifierMap byte buffer (SemanticIdMap / StableIdMap). - - Layout mirrors the OVRTX render var: packed ``IdentifierMap`` entries, the UTF-8 label blob, then a - trailing little-endian ``uint32`` entry count. - """ - entry_dtype = np.dtype([("id", " "np.ndarray": """Encode ``(stable_id_words, semantic_id)`` pairs into a StableIdSemanticIdMap byte buffer. diff --git a/source/isaaclab_ov/test/test_ovrtx_deformable_bindings.py b/source/isaaclab_ov/test/test_ovrtx_deformable_bindings.py index 941767ebfae9..e8aa63dc7217 100644 --- a/source/isaaclab_ov/test/test_ovrtx_deformable_bindings.py +++ b/source/isaaclab_ov/test/test_ovrtx_deformable_bindings.py @@ -419,7 +419,6 @@ def reject_newton_access(*args, **kwargs): monkeypatch.setattr(NewtonManager, "get_model", reject_newton_access) monkeypatch.setattr(NewtonManager, "get_state", reject_newton_access) - assert not hasattr(ovrtx_renderer_module, "sync_newton_transforms_kernel") renderer, _ = _make_renderer_without_backend() paths = ["/World/Shared", "/World/envs/env_1/Object"] poses = np.array([[1, 2, 3, 0, 0, 0, 1], [4, 5, 6, 0, 0, 0, 1]], dtype=np.float32) diff --git a/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py b/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py index 12e1d319237c..402680a47048 100644 --- a/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py +++ b/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py @@ -115,7 +115,6 @@ def __init__(self, **kwargs): renderer = OVRTXRenderer(OVRTXRendererCfg()) shared = OVRTXRenderer(renderer.cfg) - assert not {"_backend", "_renderer", "_stage", "_stage_paths", "_ovstage_exit_stack"}.intersection(vars(renderer)) assert shared.backend is renderer.backend assert renderer.backend.renderer is not None assert config_kwargs["suppress_deprecation_warnings"] is True @@ -164,9 +163,18 @@ def test_ovrtx_supported_output_types_key_set(): assert specs[RenderBufferKind.MOTION_VECTORS] == RenderBufferSpec(2, wp.float32) -@pytest.mark.parametrize("use_ovstage", [False, True]) -@pytest.mark.parametrize("missing_output", [None, "product", "frame"]) -@pytest.mark.parametrize("batch", [False, True]) +# Each missing output runs with and without batching; ``use_ovstage`` only changes the ordinal bookkeeping. +@pytest.mark.parametrize( + ("missing_output", "batch", "use_ovstage"), + [ + (None, False, False), + (None, True, True), + ("product", False, True), + ("product", True, False), + ("frame", False, False), + ("frame", True, True), + ], +) def test_ovrtx_render_submits_requested_products_and_routes_outputs(monkeypatch, use_ovstage, missing_output, batch): """A submission fills each camera from its product and rejects incomplete results.""" renderer = _make_ovrtx_renderer_without_backend() @@ -382,7 +390,7 @@ def test_ovrtx_set_outputs_wraps_caller_torch_zero_copy(): pytest.skip("OVRTX zero-copy wrapping requires a CUDA device") device = "cuda" - cfg = _make_camera_cfg(["rgb", "rgba", "depth"]) + cfg = _make_camera_cfg(["rgb", "rgba", "depth", "rgb_hdr"]) data = CameraData.allocate( data_types=cfg.data_types, height=8, @@ -397,30 +405,8 @@ def test_ovrtx_set_outputs_wraps_caller_torch_zero_copy(): assert set(render_data.warp_buffers.keys()) >= {"rgba", "depth"} assert render_data.warp_buffers["rgba"].ptr == data.output["rgba"].warp.ptr assert render_data.warp_buffers["depth"].ptr == data.output["depth"].warp.ptr - assert "rgb" not in render_data.warp_buffers - - -def test_ovrtx_set_outputs_wraps_requested_rgb_hdr_output(): - """OVRTXRenderer.set_outputs publishes a zero-copy view for requested RGB_HDR.""" - renderer = _make_ovrtx_renderer_without_backend() - - if not torch.cuda.is_available(): - pytest.skip("OVRTX zero-copy wrapping requires a CUDA device") - device = "cuda" - - cfg = _make_camera_cfg(["rgb_hdr"]) - data = CameraData.allocate( - data_types=cfg.data_types, - height=8, - width=16, - num_views=2, - device=device, - supported_specs=renderer.supported_output_types(), - ) - render_data = _make_ovrtx_camera_render_data() - renderer.set_outputs(render_data, data.output) - assert render_data.warp_buffers["rgb_hdr"].ptr == data.output["rgb_hdr"].warp.ptr + assert "rgb" not in render_data.warp_buffers def test_ovrtx_set_outputs_routes_ppisp_buffers_through_warp_buffers(): @@ -464,8 +450,8 @@ def map(self, *args, **kwargs): renderer._process_render_frame(render_data, frame, {"rgba": object()}) -@pytest.mark.parametrize("use_ovstage", [False, True]) -@pytest.mark.parametrize("version", ["0.4", "0.5"]) +# Render-var keys depend only on the OVRTX version; ``use_ovstage`` only selects the registration path. +@pytest.mark.parametrize(("use_ovstage", "version"), [(False, "0.4"), (True, "0.5")]) def test_ovrtx_process_frame_reads_authored_camera_render_vars(monkeypatch, use_ovstage, version): """Both OVRTX APIs extract each camera's outputs using keys from its authored USD.""" from packaging.version import Version @@ -664,10 +650,9 @@ def test_ovrtx_use_ovstage_rejects_non_boolean_values(monkeypatch): ovrtx_use_ovstage_enabled() -@pytest.mark.parametrize("platform", ["win32", "darwin"]) -def test_ovrtx_render_var_sync_is_gpu_side_off_linux(monkeypatch, platform): +def test_ovrtx_render_var_sync_is_gpu_side_off_linux(monkeypatch): """Everywhere but Linux the mapping is ordered by a GPU-side wait on the Warp stream.""" - monkeypatch.setattr(sys, "platform", platform) + monkeypatch.setattr(sys, "platform", "win32") monkeypatch.delenv(_DISABLE_LINUX_CUDA_CPU_SYNC_ENV, raising=False) assert _gpu_side_render_var_sync_enabled() is True @@ -860,9 +845,6 @@ def test_intrinsic_updates_target_the_given_camera(monkeypatch, use_ovstage): monkeypatch.setattr(wp, "get_stream", lambda device: types.SimpleNamespace(cuda_stream=99)) parameters = wp.zeros((5, 2), dtype=wp.float32, device="cpu") renderer.update_camera_intrinsics(cameras[1], wp.zeros(2, dtype=wp.mat33f, device="cpu"), parameters) - # Keep native resources on the backend and reuse the camera-owned query for calibration. - assert not {"_renderer", "_stage", "_stage_paths", "_camera_intrinsic_bindings"}.intersection(vars(renderer)) - assert all(not {"intrinsic_query", "intrinsic_paths"}.intersection(vars(camera)) for camera in cameras) if use_ovstage: assert renderer.backend.stage.write_attributes.call_args.args[0] is cameras[1].camera_xform_query bound_paths = [call.args[0] for call in renderer.backend.paths.create_path_list_from_strings.call_args_list] diff --git a/source/isaaclab_ov/test/test_ovrtx_renderer_kernels.py b/source/isaaclab_ov/test/test_ovrtx_renderer_kernels.py index 9aa08b50361b..09d5dcb5e52c 100644 --- a/source/isaaclab_ov/test/test_ovrtx_renderer_kernels.py +++ b/source/isaaclab_ov/test/test_ovrtx_renderer_kernels.py @@ -18,112 +18,22 @@ DEVICE = "cuda:0" -def _reference_extract_all_depth_tiles( - tiled_np: np.ndarray, - num_envs: int, - num_cols: int, - tile_width: int, - tile_height: int, -) -> np.ndarray: - """NumPy reference for ``extract_all_depth_tiles_kernel``.""" - out = np.zeros((num_envs, tile_height, tile_width, 1), dtype=np.float32) - for env_idx in range(num_envs): - tile_x = env_idx % num_cols - tile_y = env_idx // num_cols - for y in range(tile_height): - for x in range(tile_width): - src_y = tile_y * tile_height + y - src_x = tile_x * tile_width + x - out[env_idx, y, x, 0] = tiled_np[src_y, src_x, 0] - return out - - -def _reference_extract_all_uint32_tiles( - tiled_np: np.ndarray, - num_envs: int, - num_cols: int, - tile_width: int, - tile_height: int, -) -> np.ndarray: - """NumPy reference for the uint32 (e.g. raw instance segmentation ID) case of ``extract_all_tiles_kernel``.""" - out = np.zeros((num_envs, tile_height, tile_width, 1), dtype=np.uint32) - for env_idx in range(num_envs): - tile_x = env_idx % num_cols - tile_y = env_idx // num_cols - for y in range(tile_height): - for x in range(tile_width): - src_y = tile_y * tile_height + y - src_x = tile_x * tile_width + x - out[env_idx, y, x, 0] = tiled_np[src_y, src_x, 0] - return out - - -def _reference_extract_all_rgba_tiles( +def _reference_extract_all_tiles( tiled_np: np.ndarray, num_envs: int, num_cols: int, tile_width: int, tile_height: int, num_channels: int, + dtype: np.dtype, ) -> np.ndarray: - """NumPy reference for ``extract_all_rgba_tiles_kernel``.""" - out_c = 4 if num_channels == 4 else 3 - out = np.zeros((num_envs, tile_height, tile_width, out_c), dtype=np.uint8) + """NumPy reference for ``extract_all_tiles_kernel``: slice each env's tile and keep the first channels.""" + out = np.zeros((num_envs, tile_height, tile_width, num_channels), dtype=dtype) for env_idx in range(num_envs): - tile_x = env_idx % num_cols - tile_y = env_idx // num_cols - for y in range(tile_height): - for x in range(tile_width): - src_y = tile_y * tile_height + y - src_x = tile_x * tile_width + x - out[env_idx, y, x, 0] = tiled_np[src_y, src_x, 0] - out[env_idx, y, x, 1] = tiled_np[src_y, src_x, 1] - out[env_idx, y, x, 2] = tiled_np[src_y, src_x, 2] - if num_channels == 4: - out[env_idx, y, x, 3] = tiled_np[src_y, src_x, 3] - return out - - -def _reference_extract_all_rgb_float_tiles( - tiled_np: np.ndarray, - num_envs: int, - num_cols: int, - tile_width: int, - tile_height: int, -) -> np.ndarray: - """NumPy reference for ``extract_all_rgb_float_tiles_kernel``.""" - out = np.zeros((num_envs, tile_height, tile_width, 3), dtype=np.float32) - for env_idx in range(num_envs): - tile_x = env_idx % num_cols - tile_y = env_idx // num_cols - for y in range(tile_height): - for x in range(tile_width): - src_y = tile_y * tile_height + y - src_x = tile_x * tile_width + x - out[env_idx, y, x, 0] = tiled_np[src_y, src_x, 0] - out[env_idx, y, x, 1] = tiled_np[src_y, src_x, 1] - out[env_idx, y, x, 2] = tiled_np[src_y, src_x, 2] - return out - - -def _reference_extract_all_motion_vector_tiles( - tiled_np: np.ndarray, - num_envs: int, - num_cols: int, - tile_width: int, - tile_height: int, -) -> np.ndarray: - """NumPy reference for the motion-vector case of ``extract_all_tiles_kernel``.""" - out = np.zeros((num_envs, tile_height, tile_width, 2), dtype=np.float32) - for env_idx in range(num_envs): - tile_x = env_idx % num_cols - tile_y = env_idx // num_cols - for y in range(tile_height): - for x in range(tile_width): - src_y = tile_y * tile_height + y - src_x = tile_x * tile_width + x - out[env_idx, y, x, 0] = tiled_np[src_y, src_x, 0] - out[env_idx, y, x, 1] = tiled_np[src_y, src_x, 1] + tile_y, tile_x = divmod(env_idx, num_cols) + rows = slice(tile_y * tile_height, (tile_y + 1) * tile_height) + cols = slice(tile_x * tile_width, (tile_x + 1) * tile_width) + out[env_idx] = tiled_np[rows, cols, :num_channels] return out @@ -157,69 +67,25 @@ def test_two_by_two_tile_grid_drops_extra_channels(self): ) wp.synchronize() - expected = _reference_extract_all_motion_vector_tiles(tiled_np, num_envs, num_cols, tile_width, tile_height) + expected = _reference_extract_all_tiles(tiled_np, num_envs, num_cols, tile_width, tile_height, 2, np.float32) np.testing.assert_allclose(output_wp.numpy(), expected, rtol=0, atol=0) class TestExtractAllDepthTilesKernel: """Tests for the depth case of ``extract_all_tiles_kernel``.""" - def test_two_by_two_tile_grid(self): - num_cols = 2 - num_envs = 4 - tile_width = 2 - tile_height = 3 - tiled_h = (num_envs // num_cols) * tile_height - tiled_w = num_cols * tile_width - tiled_np = np.zeros((tiled_h, tiled_w, 1), dtype=np.float32) - for h in range(tiled_h): - for w in range(tiled_w): - tiled_np[h, w, 0] = float(h * 1000 + w) - - tiled_wp = wp.array(tiled_np, dtype=wp.float32, ndim=3, device=DEVICE) - output_wp = wp.zeros(shape=(num_envs, tile_height, tile_width, 1), dtype=wp.float32, device=DEVICE) - - wp.launch( - kernel=extract_all_tiles_kernel, - dim=(num_envs, tile_height, tile_width), - inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height], - device=DEVICE, - ) - wp.synchronize() - - expected = _reference_extract_all_depth_tiles(tiled_np, num_envs, num_cols, tile_width, tile_height) - np.testing.assert_allclose(output_wp.numpy(), expected, rtol=0, atol=0) - - def test_single_tile(self): - num_cols = 1 - num_envs = 1 - tile_width = 4 - tile_height = 4 - tiled_np = np.arange(tile_height * tile_width, dtype=np.float32).reshape(tile_height, tile_width, 1) - - tiled_wp = wp.array(tiled_np, dtype=wp.float32, ndim=3, device=DEVICE) - output_wp = wp.zeros(shape=(num_envs, tile_height, tile_width, 1), dtype=wp.float32, device=DEVICE) - - wp.launch( - kernel=extract_all_tiles_kernel, - dim=(num_envs, tile_height, tile_width), - inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height], - device=DEVICE, - ) - wp.synchronize() - - expected = _reference_extract_all_depth_tiles(tiled_np, num_envs, num_cols, tile_width, tile_height) - np.testing.assert_array_equal(output_wp.numpy(), expected) - @pytest.mark.parametrize( ("num_cols", "num_envs", "tile_width", "tile_height"), [ + (2, 4, 2, 3), + (1, 1, 4, 4), (3, 6, 2, 2), (1, 3, 5, 1), (4, 8, 1, 1), ], ) def test_various_layouts(self, num_cols, num_envs, tile_width, tile_height): + """Tile layout math is dtype independent, so one float32 table covers every overload.""" num_rows = (num_envs + num_cols - 1) // num_cols tiled_h = num_rows * tile_height tiled_w = num_cols * tile_width @@ -237,8 +103,8 @@ def test_various_layouts(self, num_cols, num_envs, tile_width, tile_height): ) wp.synchronize() - expected = _reference_extract_all_depth_tiles(tiled_np, num_envs, num_cols, tile_width, tile_height) - np.testing.assert_allclose(output_wp.numpy(), expected, rtol=1e-6, atol=1e-6) + expected = _reference_extract_all_tiles(tiled_np, num_envs, num_cols, tile_width, tile_height, 1, np.float32) + np.testing.assert_array_equal(output_wp.numpy(), expected) class TestExtractAllUint32TilesKernel: @@ -265,57 +131,7 @@ def test_two_by_two_tile_grid(self): ) wp.synchronize() - expected = _reference_extract_all_uint32_tiles(tiled_np, num_envs, num_cols, tile_width, tile_height) - np.testing.assert_array_equal(output_wp.numpy(), expected) - - def test_single_tile(self): - num_cols = 1 - num_envs = 1 - tile_width = 4 - tile_height = 4 - tiled_np = np.arange(tile_height * tile_width, dtype=np.uint32).reshape(tile_height, tile_width, 1) - - tiled_wp = wp.array(tiled_np, dtype=wp.uint32, ndim=3, device=DEVICE) - output_wp = wp.zeros(shape=(num_envs, tile_height, tile_width, 1), dtype=wp.uint32, device=DEVICE) - - wp.launch( - kernel=extract_all_tiles_kernel, - dim=(num_envs, tile_height, tile_width), - inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height], - device=DEVICE, - ) - wp.synchronize() - - expected = _reference_extract_all_uint32_tiles(tiled_np, num_envs, num_cols, tile_width, tile_height) - np.testing.assert_array_equal(output_wp.numpy(), expected) - - @pytest.mark.parametrize( - ("num_cols", "num_envs", "tile_width", "tile_height"), - [ - (3, 6, 2, 2), - (1, 3, 5, 1), - (4, 8, 1, 1), - ], - ) - def test_various_layouts(self, num_cols, num_envs, tile_width, tile_height): - num_rows = (num_envs + num_cols - 1) // num_cols - tiled_h = num_rows * tile_height - tiled_w = num_cols * tile_width - rng = np.random.default_rng(13579) - tiled_np = rng.integers(0, 2**31, size=(tiled_h, tiled_w, 1), dtype=np.uint32) - - tiled_wp = wp.array(tiled_np, dtype=wp.uint32, ndim=3, device=DEVICE) - output_wp = wp.zeros(shape=(num_envs, tile_height, tile_width, 1), dtype=wp.uint32, device=DEVICE) - - wp.launch( - kernel=extract_all_tiles_kernel, - dim=(num_envs, tile_height, tile_width), - inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height], - device=DEVICE, - ) - wp.synchronize() - - expected = _reference_extract_all_uint32_tiles(tiled_np, num_envs, num_cols, tile_width, tile_height) + expected = _reference_extract_all_tiles(tiled_np, num_envs, num_cols, tile_width, tile_height, 1, np.uint32) np.testing.assert_array_equal(output_wp.numpy(), expected) @@ -349,32 +165,8 @@ def test_two_by_two_tile_grid_rgba(self): ) wp.synchronize() - expected = _reference_extract_all_rgba_tiles( - tiled_np, num_envs, num_cols, tile_width, tile_height, num_channels - ) - np.testing.assert_array_equal(output_wp.numpy(), expected) - - def test_single_tile_rgb(self): - num_cols = 1 - num_envs = 1 - tile_width = 4 - tile_height = 4 - num_channels = 3 - tiled_np = np.arange(tile_height * tile_width * 3, dtype=np.uint8).reshape(tile_height, tile_width, 3) - - tiled_wp = wp.array(tiled_np, dtype=wp.uint8, ndim=3, device=DEVICE) - output_wp = wp.zeros(shape=(num_envs, tile_height, tile_width, num_channels), dtype=wp.uint8, device=DEVICE) - - wp.launch( - kernel=extract_all_tiles_kernel, - dim=(num_envs, tile_height, tile_width), - inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height], - device=DEVICE, - ) - wp.synchronize() - - expected = _reference_extract_all_rgba_tiles( - tiled_np, num_envs, num_cols, tile_width, tile_height, num_channels + expected = _reference_extract_all_tiles( + tiled_np, num_envs, num_cols, tile_width, tile_height, num_channels, np.uint8 ) np.testing.assert_array_equal(output_wp.numpy(), expected) @@ -403,78 +195,13 @@ def test_three_channel_output_skips_alpha(self): ) wp.synchronize() - expected = _reference_extract_all_rgba_tiles(tiled_np, num_envs, num_cols, tile_width, tile_height, 3) - np.testing.assert_array_equal(output_wp.numpy(), expected) - - @pytest.mark.parametrize( - ("num_cols", "num_envs", "tile_width", "tile_height", "num_channels"), - [ - (3, 6, 2, 2, 3), - (3, 6, 2, 2, 4), - (1, 3, 5, 1, 3), - (4, 8, 1, 1, 4), - ], - ) - def test_various_layouts(self, num_cols, num_envs, tile_width, tile_height, num_channels): - num_rows = (num_envs + num_cols - 1) // num_cols - tiled_h = num_rows * tile_height - tiled_w = num_cols * tile_width - c_in = 4 if num_channels == 4 else 3 - rng = np.random.default_rng(24680) - tiled_np = rng.integers(0, 256, size=(tiled_h, tiled_w, c_in), dtype=np.uint8) - - tiled_wp = wp.array(tiled_np, dtype=wp.uint8, ndim=3, device=DEVICE) - output_wp = wp.zeros( - shape=(num_envs, tile_height, tile_width, num_channels), - dtype=wp.uint8, - device=DEVICE, - ) - - wp.launch( - kernel=extract_all_tiles_kernel, - dim=(num_envs, tile_height, tile_width), - inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height], - device=DEVICE, - ) - wp.synchronize() - - expected = _reference_extract_all_rgba_tiles( - tiled_np, num_envs, num_cols, tile_width, tile_height, num_channels - ) + expected = _reference_extract_all_tiles(tiled_np, num_envs, num_cols, tile_width, tile_height, 3, np.uint8) np.testing.assert_array_equal(output_wp.numpy(), expected) class TestExtractAllRgbFloatTilesKernel: """Tests for the HdrColor (float32/float16) case of ``extract_all_tiles_kernel``.""" - def test_two_by_two_tile_grid(self): - num_cols = 2 - num_envs = 4 - tile_width = 2 - tile_height = 3 - tiled_h = (num_envs // num_cols) * tile_height - tiled_w = num_cols * tile_width - tiled_np = np.zeros((tiled_h, tiled_w, 3), dtype=np.float32) - for h in range(tiled_h): - for w in range(tiled_w): - tiled_np[h, w, 0] = float(h * 1000 + w) - tiled_np[h, w, 1] = float(h * 1000 + w + 100) - tiled_np[h, w, 2] = float(h * 1000 + w + 200) - - tiled_wp = wp.array(tiled_np, dtype=wp.float32, ndim=3, device=DEVICE) - output_wp = wp.zeros(shape=(num_envs, tile_height, tile_width, 3), dtype=wp.float32, device=DEVICE) - - wp.launch( - kernel=extract_all_tiles_kernel, - dim=(num_envs, tile_height, tile_width), - inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height], - device=DEVICE, - ) - wp.synchronize() - - expected = _reference_extract_all_rgb_float_tiles(tiled_np, num_envs, num_cols, tile_width, tile_height) - np.testing.assert_allclose(output_wp.numpy(), expected, rtol=0, atol=0) - def test_half_input_writes_float_output(self): num_cols = 2 num_envs = 4 @@ -500,8 +227,8 @@ def test_half_input_writes_float_output(self): ) wp.synchronize() - expected = _reference_extract_all_rgb_float_tiles( - tiled_np.astype(np.float32), num_envs, num_cols, tile_width, tile_height + expected = _reference_extract_all_tiles( + tiled_np.astype(np.float32), num_envs, num_cols, tile_width, tile_height, 3, np.float32 ) np.testing.assert_allclose(output_wp.numpy(), expected, rtol=0, atol=0) @@ -519,28 +246,10 @@ def _launch(self, ids_np: np.ndarray) -> np.ndarray: def test_matches_host_reference(self): """Output matches pack_rgba(random_color_from_id(id)) for a small grid of ids.""" - ids_np = np.array([[[0], [1]], [[2], [3]]], dtype=np.uint32) + ids_np = np.array([0, 1, 2, 3, 100], dtype=np.uint32).reshape(5, 1, 1) out_np = self._launch(ids_np) for (i, j, k), input_id in np.ndenumerate(ids_np): expected = pack_rgba(random_color_from_id(int(np.uint32(input_id)))) assert int(out_np[i, j, k]) == expected, ( f"At ({i},{j},{k}) id={input_id}: expected 0x{expected:08x}, got 0x{int(out_np[i, j, k]):08x}" ) - - def test_deterministic_across_launches(self): - """Two launches with the same input produce identical output.""" - rng = np.random.default_rng(42) - ids_np = rng.integers(0, 2**31, size=(4, 4, 1), dtype=np.uint32) - first = self._launch(ids_np).copy() - second = self._launch(ids_np) - np.testing.assert_array_equal(first, second) - - @pytest.mark.parametrize("input_value", [0, 1, 2, 3, 100]) - def test_single_value(self, input_value): - ids_np = np.array([[[input_value]]], dtype=np.uint32) - out_np = self._launch(ids_np) - expected = pack_rgba(random_color_from_id(int(np.uint32(input_value)))) - out_color = int(out_np[0, 0, 0]) - assert out_color == expected, ( - f"id=0x{int(np.uint32(input_value)):08x}: expected 0x{expected:08x}, got 0x{out_color:08x}" - ) diff --git a/source/isaaclab_ov/test/test_ovrtx_shader_cache_redirect.py b/source/isaaclab_ov/test/test_ovrtx_shader_cache_redirect.py index 7bc4c945a310..dda1a37cba0a 100644 --- a/source/isaaclab_ov/test/test_ovrtx_shader_cache_redirect.py +++ b/source/isaaclab_ov/test/test_ovrtx_shader_cache_redirect.py @@ -37,18 +37,6 @@ def __call__(self, setting: str) -> bool: return self._reject is None or self._reject not in setting -def test_apply_settings_redirects_both_driver_caches(): - """Both driver cache settings must be pointed at the requested directory.""" - applier = _RecordingApplier() - - apply_shader_cache_settings(applier, _CACHE_PATH) - - assert applier.applied == [ - f"--/rtx/shaderDb/driverShaderCachePath={_CACHE_PATH}", - f"--/rtx/shaderDb/driverAppShaderCachePath={_CACHE_PATH}", - ] - - @pytest.mark.parametrize("rejected", ["driverShaderCachePath", "driverAppShaderCachePath"]) def test_apply_settings_raises_when_a_setting_is_rejected(rejected, caplog): """A rejected setting must fail loudly, and must not be reported as a working redirect.""" @@ -92,7 +80,10 @@ def test_redirect_applies_settings_with_the_renderer_config(monkeypatch): redirect_shader_cache(_CONFIG) assert seen == [_CONFIG] - assert [setting.split("=", 1)[1] for setting in applier.applied] == [_CACHE_PATH, _CACHE_PATH] + assert applier.applied == [ + f"--/rtx/shaderDb/driverShaderCachePath={_CACHE_PATH}", + f"--/rtx/shaderDb/driverAppShaderCachePath={_CACHE_PATH}", + ] def test_redirect_raises_when_settings_extension_is_unavailable(monkeypatch): diff --git a/source/isaaclab_ov/test/test_ovrtx_usd.py b/source/isaaclab_ov/test/test_ovrtx_usd.py index bd04c7b0f50e..5c4c46232f66 100644 --- a/source/isaaclab_ov/test/test_ovrtx_usd.py +++ b/source/isaaclab_ov/test/test_ovrtx_usd.py @@ -126,23 +126,15 @@ def test_render_product_solid_background_color(camera_spec, render_data): assert 'token omni:rtx:background:source:type = "domeLight"' not in render_scope -def test_ovrtx_rgb_hdr_uses_hdr_color_render_var(): - """Requesting RGB_HDR from OVRTX selects the HdrColor render variable.""" - assert get_render_var_config(["rgb_hdr"], render_scope_name="RenderCamera_0") == ( - "/RenderCamera_0/Vars/HdrColor", - "HdrColor", - "HdrColor", - ) - - def test_render_var_prim_names_are_read_only(): with pytest.raises(TypeError): render_var_prim_names_by_source()["LdrColor"] = "mutated" # type: ignore[index] -@pytest.mark.parametrize("render_scope_name", ["RenderCamera_0", "RenderCamera_1"]) -def test_render_var_prim_paths_cover_every_authored_render_var(render_scope_name: str, camera_spec, render_data): +def test_render_var_prim_paths_cover_every_authored_render_var(camera_spec, render_data): """Every output and metadata path resolves to its authored prim under the requested scope.""" + # A non-default scope name also covers the default one: only the path prefix changes. + render_scope_name = "RenderCamera_1" data_types = [ "rgb", "rgb_hdr", @@ -178,24 +170,6 @@ def test_render_var_prim_paths_cover_every_authored_render_var(render_scope_name assert prim_paths[source] == path -def test_ovrtx_instance_segmentation_uses_non_stable_instance_segmentation_render_var(): - """Requesting instance_segmentation from OVRTX selects the NonStableInstanceSegmentation render var.""" - assert get_render_var_config(["instance_segmentation"], render_scope_name="RenderCamera_0") == ( - "/RenderCamera_0/Vars/NonStableInstanceSegmentation", - "NonStableInstanceSegmentation", - "NonStableInstanceSegmentation", - ) - - -def test_ovrtx_motion_vectors_uses_target_motion_render_var(): - """Requesting motion vectors from OVRTX selects the TargetMotionSD render variable.""" - assert get_render_var_config(["motion_vectors"], render_scope_name="RenderCamera_0") == ( - "/RenderCamera_0/Vars/TargetMotionSD", - "TargetMotionSD", - "TargetMotionSD", - ) - - def test_ovrtx_primary_render_var_follows_the_first_requested_data_type(): """The primary render var is the first supported configuration in the requested data types.""" assert get_render_var_config(["rgb", "motion_vectors"], render_scope_name="RenderCamera_0") == ( @@ -252,16 +226,6 @@ def test_ovrtx_data_types_sharing_a_source_author_one_render_var(): ] -def test_ovrtx_depth_and_distance_to_camera_author_distinct_render_vars(camera_spec, render_data): - """Image-plane depth and distance-to-camera are different sources and get separate prims.""" - camera_spec.cfg.data_types = ["depth", "distance_to_camera"] - render_scope = build_render_scope_usd(camera_spec, render_data) - - assert "rel orderedVars = [, ]" in render_scope - assert 'uniform string sourceName = "DistanceToImagePlaneSD"' in render_scope - assert 'uniform string sourceName = "DistanceToCameraSD"' in render_scope - - def test_ovrtx_unsupported_data_type_is_skipped_and_falls_back_to_ldr_color(): """Unsupported data types author no render var; an otherwise empty product keeps LdrColor.""" assert get_render_var_configs(["instance_id_segmentation_fast"], render_scope_name="RenderCamera_0") == [ @@ -425,18 +389,6 @@ def test_ovrtx_instance_segmentation_authors_pixel_and_map_render_vars(camera_sp assert 'uniform string sourceName = "StableIdMap"' in render_scope -def test_ovrtx_semantic_and_instance_segmentation_share_a_single_semantic_id_map(): - """Requesting both segmentation outputs authors ``SemanticIdMap`` exactly once (it is shared).""" - render_var_configs = get_render_var_configs( - ["semantic_segmentation", "instance_segmentation"], render_scope_name="RenderCamera_0" - ) - - sources = [source for _, _, source in render_var_configs] - assert sources.count("SemanticIdMap") == 1 - # Both segmentations' map render vars are authored regardless of which AOV get_render_var_config resolves. - assert {"SemanticIdMap", "StableIdSemanticIdMap", "StableIdMap"} <= set(sources) - - def test_export_stage_keeps_all_env_content_when_all_roots_are_sources(): """Listing every env root as a source preserves the full stage content.""" num_envs = 4 diff --git a/source/isaaclab_ov/test/test_ovrtx_visual_material_sync.py b/source/isaaclab_ov/test/test_ovrtx_visual_material_sync.py index 80dc51074e5a..82c8eb2cb1d4 100644 --- a/source/isaaclab_ov/test/test_ovrtx_visual_material_sync.py +++ b/source/isaaclab_ov/test/test_ovrtx_visual_material_sync.py @@ -6,7 +6,6 @@ """Unit tests for typed visual-material writes into OVRTX-owned scenes.""" import importlib.util -import inspect from types import SimpleNamespace import pytest @@ -21,7 +20,6 @@ if not _MISSING_MODULES: from isaaclab_ov.renderers.ovrtx_renderer import OVRTXRenderer - from isaaclab_ov.renderers.visual_materials import OVRTXVisualMaterialWriter from ovrtx import DataAccess from isaaclab.renderers.base_renderer import VisualMaterialBatch @@ -269,8 +267,6 @@ def test_writer_close_drains_and_unbinds_compiled_legacy_addresses(): assert all(binding.unbound for binding in renderer.backend.renderer.bindings) assert all(write[3].wait_count == 1 for write in renderer.backend.renderer.writes) - assert writer._addresses == [] - assert writer._buffers == {} @pytest.mark.parametrize("values", [torch.zeros(1, dtype=torch.float64), torch.zeros(1, 4)]) @@ -301,9 +297,3 @@ def test_writer_factory_requires_ingested_detached_scene(): renderer._initialized_scene = False with pytest.raises(RuntimeError, match="ingest its detached scene"): renderer.visual_material_writer((_batch("color", ("diffuseColor",), torch.zeros(1, 3)),)) - - -def test_material_runtime_has_no_host_or_usd_path(): - source = inspect.getsource(OVRTXVisualMaterialWriter) - for forbidden in (".cpu(", ".numpy(", ".tolist(", "pxr", "Usd", "Sdf"): - assert forbidden not in source diff --git a/source/isaaclab_ov/test/test_randomize_rigid_body_material_mdp.py b/source/isaaclab_ov/test/test_randomize_rigid_body_material_mdp.py index 419bad5ba894..32061ee27a19 100644 --- a/source/isaaclab_ov/test/test_randomize_rigid_body_material_mdp.py +++ b/source/isaaclab_ov/test/test_randomize_rigid_body_material_mdp.py @@ -5,11 +5,11 @@ """Real-backend test for the OVPhysX branch of the ``randomize_rigid_body_material`` MDP term. -Exercises :class:`isaaclab.envs.mdp.events._RandomizeRigidBodyMaterialOvPhysx` against a real -OVPhysX :class:`~isaaclab_ov.assets.RigidObject`, verifying that it writes per-shape -friction/restitution through the asset's ``OvPhysxView``. The ``cfg`` / ``env`` / ``asset_cfg`` -inputs are stubbed (the OVPhysX implementation only reads ``cfg.params`` and -``asset_cfg.body_ids`` and operates on ``asset.root_view``). +Drives the public :class:`isaaclab.envs.mdp.events.randomize_rigid_body_material` term against a real +OVPhysX :class:`~isaaclab_ov.assets.RigidObject`, so the backend dispatch itself is exercised (the +``ovphysxmanager`` name also contains ``physx``), and verifies that it writes per-shape +friction/restitution through the asset's ``OvPhysxView``. The ``cfg`` / ``env`` / ``asset_cfg`` inputs +are stubbed: the term only reads ``cfg.params``, ``env.scene[...]`` and ``env.sim.physics_manager``. Kitless; run once per device (``-k cpu`` / ``-k 'cuda:0'``) -- the ovphysx runtime binds the device mode process-globally (see the asset tests' module docstring). @@ -30,7 +30,7 @@ import isaaclab.sim as sim_utils # noqa: E402 from isaaclab.assets import RigidObjectCfg # noqa: E402 -from isaaclab.envs.mdp.events import _RandomizeRigidBodyMaterialOvPhysx # noqa: E402 +from isaaclab.envs.mdp.events import randomize_rigid_body_material # noqa: E402 from isaaclab.sim import SimulationCfg, build_simulation_context # noqa: E402 from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR # noqa: E402 @@ -77,28 +77,34 @@ def _make_cubes(num_cubes: int, device: str) -> RigidObject: return RigidObject(cfg=cfg) -@pytest.mark.parametrize("num_cubes", [1, 2]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -def test_randomize_material_writes_friction_within_range(num_cubes, device): - """The OVPhysX impl should write per-shape friction/restitution sampled within the given ranges.""" +def test_randomize_material_writes_friction_within_range(device): + """The term should dispatch to OVPhysX and write per-shape friction/restitution within the given ranges. + + A per-body selection on a standalone rigid object must fail loud (no per-body shape counts). + """ + num_cubes = 2 with _ovphysx_sim_context(device=device, auto_add_lighting=True) as sim: cube_object = _make_cubes(num_cubes, device) sim.reset() - static_range, dynamic_range, restitution_range = (0.4, 0.8), (0.2, 0.6), (0.0, 0.3) - cfg = SimpleNamespace( - params={ - "static_friction_range": static_range, - "dynamic_friction_range": dynamic_range, - "restitution_range": restitution_range, - "num_buckets": 16, - } - ) - asset_cfg = SimpleNamespace(body_ids=[0]) - env = SimpleNamespace() # unused by the OVPhysX implementation - - impl = _RandomizeRigidBodyMaterialOvPhysx(cfg, env, cube_object, asset_cfg) - impl(env, None, static_range, dynamic_range, restitution_range, 16, asset_cfg) + # The ranges exclude the asset's default material, so values inside them prove the write happened. + static_range, dynamic_range, restitution_range = (0.9, 1.2), (0.7, 0.9), (0.4, 0.6) + materials_before = wp.to_torch( + cube_object.root_view.get_attribute(TT.RIGID_BODY_SHAPE_FRICTION_AND_RESTITUTION) + ).clone() + assert (materials_before[..., 0] < static_range[0]).all(), f"default material overlaps: {materials_before}" + params = { + "static_friction_range": static_range, + "dynamic_friction_range": dynamic_range, + "restitution_range": restitution_range, + "num_buckets": 16, + } + asset_cfg = SimpleNamespace(name="cube", body_ids=[0]) + env = SimpleNamespace(sim=sim, scene={"cube": cube_object}) + + term = randomize_rigid_body_material(SimpleNamespace(params={**params, "asset_cfg": asset_cfg}), env) + term(env, None, static_range, dynamic_range, restitution_range, 16, asset_cfg) materials = wp.to_torch(cube_object.root_view.get_attribute(TT.RIGID_BODY_SHAPE_FRICTION_AND_RESTITUTION)) assert materials.shape[0] == num_cubes and materials.shape[-1] == 3 @@ -107,15 +113,6 @@ def test_randomize_material_writes_friction_within_range(num_cubes, device): values = materials[..., component] assert (values >= lo - eps).all() and (values <= hi + eps).all() - -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -def test_randomize_material_body_subset_unsupported(device): - """A per-body selection must fail loud on OVPhysX (no per-body shape counts).""" - with _ovphysx_sim_context(device=device, auto_add_lighting=True) as sim: - cube_object = _make_cubes(1, device) - sim.reset() - - cfg = SimpleNamespace(params={"static_friction_range": (0.4, 0.8), "num_buckets": 4}) - asset_cfg = SimpleNamespace(body_ids=[]) # proper subset of the rigid object's single body + subset_cfg = SimpleNamespace(name="cube", body_ids=[]) # proper subset of the rigid object's single body with pytest.raises(NotImplementedError, match="per-body"): - _RandomizeRigidBodyMaterialOvPhysx(cfg, SimpleNamespace(), cube_object, asset_cfg) + randomize_rigid_body_material(SimpleNamespace(params={**params, "asset_cfg": subset_cfg}), env) From 0173b0436801a854223a27439d5f2921acf495c9 Mon Sep 17 00:00:00 2001 From: Mustafa H <34825877+StafaH@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:40:45 -0700 Subject: [PATCH 03/10] [Tests] Prune PhysX tests with the test-audit skill (#8009) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # PhysX test suite: 12:54 → 7:09 (−45%) The `isaaclab_physx` suite runs through `pytest tools` on the same machine with a warm Warp cache. 26/27 files pass. `test_spawn_meshes.py` needs the `tetrahedralization` extra, which is missing locally and fails identically on `develop`; it passes with the extra. | | `develop` | this PR | |---|---:|---:| | Suite wall time | 12:54 | **7:09** | | Test functions | 272 | 186 | | Test lines | 15,744 | 12,350 | ## What changed The whole test surface was pruned with the `isaaclab-auditing-tests` skill (#8007). **Method.** Every test was marked keep, fix, merge or delete. Whenever a deletion's keeper does not carry the assertions verbatim, a deliberate production mutation proves the keeper fails. An independent review then restored every gap it found. Only test files changed. - **Merged:** - frame transformer: one scene - IMU/PVA: one driven loop each (the analytic checks are kept) - Jacobian and mass-matrix tests into the link-origin contract - init smoke tests into their siblings - consistency tests into the write tests, which now use random states - **Deleted:** - joint-wrench tests that compared the sensor to the raw tensor it copies; the shared analytic test is the keeper - Fabric tests that restated the shared FrameView contract - WrenchComposer replays - OSC tests covered by the controller suite - cloner tests that are always-xfail or always-skipped - **Un-shadowed:** the Fabric view defined local tests with the same names as the shared contract tests, which hid them. The shared tests now run and catch a per-row scale bug the local copies missed. - **Fixed tests that could not fail:** - external-force tests, where the robot was in free fall - `apply_joint_command` (inverted assertion) - reset tests - IMU stale-data (#4970) - `physx_replicate`, which was a non-strict xfail - contact threshold (#3498) - friction sign - **Kept:** the RTX writer and visual-material tests, and IK tracking. Their would-be keepers (the Kuka golden-image test and `test_differential_ik.py`) never run in CI. ## Product defects found (not fixed here) - `RigidObjectCollection.set_coms_index` raises `KeyError`. - A full collection `reset()` leaves the wrench composers active. - `FrameTransformer.__str__` swaps the frame count and the frame names. - The Kuka visual-material golden-image test is excluded from CI. ## Release backport - [x] Backport this pull request to the active release branch after it merges into `develop` ## Checklist - [x] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html) - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [x] My changes generate no new warnings - [x] I have added a changelog fragment under `source//changelog.d/` for every touched package - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Signed-off-by: Octi Zhang Co-authored-by: Octi Zhang --- .../changelog.d/test-audit-physx-tests.skip | 1 + .../test/assets/test_articulation.py | 1234 +++-------------- .../assets/test_newton_actuators_physx.py | 120 +- .../test/assets/test_rigid_object.py | 580 +------- .../assets/test_rigid_object_collection.py | 413 ++---- .../test/assets/test_surface_gripper.py | 58 +- .../renderers/test_isaac_rtx_renderer_cfg.py | 7 +- .../test_isaac_rtx_renderer_contract.py | 42 +- ...aac_rtx_renderer_rigid_object_rendering.py | 4 +- ...t_isaac_rtx_renderer_scene_partitioning.py | 48 +- .../test/renderers/test_kit_viewport_utils.py | 69 - .../test/sensors/test_contact_sensor.py | 404 ++---- .../test/sensors/test_frame_transformer.py | 606 +++----- .../isaaclab_physx/test/sensors/test_imu.py | 342 ++--- .../sensors/test_imu_pva_recorded_launch.py | 88 +- .../test/sensors/test_joint_wrench_sensor.py | 221 +-- .../isaaclab_physx/test/sensors/test_pva.py | 527 ++----- .../test/sensors/test_ray_caster_graph.py | 11 - source/isaaclab_physx/test/sim/test_cloner.py | 177 +-- .../test/sim/test_spawn_materials.py | 15 +- .../test/sim/test_spawn_meshes.py | 49 +- .../test/sim/test_views_xform_prim_fabric.py | 474 +------ 22 files changed, 1032 insertions(+), 4458 deletions(-) create mode 100644 source/isaaclab_physx/changelog.d/test-audit-physx-tests.skip diff --git a/source/isaaclab_physx/changelog.d/test-audit-physx-tests.skip b/source/isaaclab_physx/changelog.d/test-audit-physx-tests.skip new file mode 100644 index 000000000000..2d3040008dbc --- /dev/null +++ b/source/isaaclab_physx/changelog.d/test-audit-physx-tests.skip @@ -0,0 +1 @@ +Consolidated duplicated PhysX tests and repaired regression tests that could not fail. diff --git a/source/isaaclab_physx/test/assets/test_articulation.py b/source/isaaclab_physx/test/assets/test_articulation.py index c05957b87549..70bae44f451d 100644 --- a/source/isaaclab_physx/test/assets/test_articulation.py +++ b/source/isaaclab_physx/test/assets/test_articulation.py @@ -43,12 +43,7 @@ import isaaclab.utils.string as string_utils from isaaclab.actuators import IdealPDActuatorCfg, ImplicitActuatorCfg from isaaclab.assets import ArticulationCfg, get_articulation_name_ordering -from isaaclab.controllers import ( - DifferentialIKController, - DifferentialIKControllerCfg, - OperationalSpaceController, - OperationalSpaceControllerCfg, -) +from isaaclab.controllers import DifferentialIKController, DifferentialIKControllerCfg from isaaclab.envs.mdp.terminations import joint_effort_out_of_limit from isaaclab.managers import SceneEntityCfg from isaaclab.sim import build_simulation_context @@ -208,12 +203,12 @@ def generate_articulation( # --------------------------------------------------------------------------- -# Franka task-space tracking helpers (shared between IK and OSC tests). +# Franka task-space tracking helpers for the IK test. # Mirrors the helpers in ``isaaclab_newton/test/assets/test_articulation.py``. # --------------------------------------------------------------------------- -def _setup_franka_at_home_pose(sim, *, zero_actuator_pd: bool = False, enable_rigid_body_gravity: bool = False): +def _setup_franka_at_home_pose(sim): """Build a Franka articulation at its configured home pose. See the Newton-side mirror for full docs. Standalone tests skip the @@ -223,29 +218,11 @@ def _setup_franka_at_home_pose(sim, *, zero_actuator_pd: bool = False, enable_ri Args: sim: The simulation context to use. - zero_actuator_pd: If True, sets the panda_shoulder/panda_forearm - actuator stiffness and damping to zero. - enable_rigid_body_gravity: If True, override - ``FRANKA_PANDA_HIGH_PD_CFG.spawn.rigid_props.disable_gravity`` - (which defaults to True) so gravity actually loads the arm. Required - for any test that wants to exercise gravity-related dynamics - (e.g. gravity-compensation accuracy tests). Returns: Tuple of ``(robot, ee_frame_idx, ee_jacobi_idx, arm_joint_ids)``. """ cfg = FRANKA_PANDA_HIGH_PD_CFG.copy().replace(prim_path="/World/Env_[^/]*/Robot") - if zero_actuator_pd: - cfg.actuators["panda_shoulder"].stiffness = 0.0 - cfg.actuators["panda_shoulder"].damping = 0.0 - cfg.actuators["panda_forearm"].stiffness = 0.0 - cfg.actuators["panda_forearm"].damping = 0.0 - if enable_rigid_body_gravity: - cfg = cfg.replace( - spawn=cfg.spawn.replace( - rigid_props=cfg.spawn.rigid_props.replace(disable_gravity=False), - ), - ) sim_utils.create_prim("/World/Env_0", "Xform", translation=(0.0, 0.0, 0.0)) robot = Articulation(cfg) sim.reset() @@ -281,20 +258,6 @@ def _compute_jacobian_root_frame(robot, ee_jacobi_idx, arm_joint_ids): return jacobian -def _compute_ee_vel_root(jacobian_b, joint_vel): - """Return the EE 6D velocity in the root frame as ``J · q_dot``. - - Required to make OSC's ``kd * ee_vel_b`` damping term meaningful. - Passing zero EE velocity (the convenient hack) leaves the impedance - undamped and the EE oscillates around the target. ``J · q_dot`` - avoids relying on ``data.body_vel_w`` (Newton's lazy velocity - buffers can return stale/zero values until forced materialization), - keeping the helper backend-symmetric. ``J`` correctness is pinned - independently by ``test_get_jacobians_link_origin_contract``. - """ - return torch.bmm(jacobian_b, joint_vel.unsqueeze(-1)).squeeze(-1) - - def _build_relative_pose_target(robot, ee_frame_idx, delta_xyz, device): """Build a target pose = (current EE pose) + ``delta_xyz``, preserving orientation.""" initial_ee_pos_b, initial_ee_quat_b, _ = _compute_ee_pose_root(robot, ee_frame_idx) @@ -346,33 +309,7 @@ def sim(request): yield sim -@pytest.mark.parametrize("device", test_devices(DeviceScope.CUDA)) -@pytest.mark.parametrize("gravity_enabled", [False]) -def test_write_joint_state_accepts_int64_selector(sim, device, gravity_enabled): - """Write joint state with int64 selectors.""" - articulation_cfg = generate_articulation_cfg(articulation_type="spatial_tendon_test_asset") - articulation, _ = generate_articulation(articulation_cfg, 2, device=device) - sim.reset() - assert articulation.num_joints >= 2 - - env_ids = torch.tensor([1, 0], dtype=torch.int64, device=device) - joint_ids = torch.tensor([articulation.num_joints - 1, 0], dtype=torch.int64, device=device) - position = torch.tensor([[0.21, 0.11], [0.22, 0.12]], device=device) - velocity = torch.tensor([[1.21, 1.11], [1.22, 1.12]], device=device) - - expected_position = articulation.data.joint_pos.torch.clone() - expected_velocity = articulation.data.joint_vel.torch.clone() - - articulation.write_joint_state_to_sim_index( - position=position, velocity=velocity, env_ids=env_ids, joint_ids=joint_ids - ) - expected_position[env_ids[:, None], joint_ids[None, :]] = position - expected_velocity[env_ids[:, None], joint_ids[None, :]] = velocity - torch.testing.assert_close(articulation.data.joint_pos.torch, expected_position) - torch.testing.assert_close(articulation.data.joint_vel.torch, expected_velocity) - - -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("device", test_devices()) @pytest.mark.parametrize("gravity_enabled", [False]) def test_live_manual_root_preserving_ordering_reorders_backend_reads_and_writes(sim, device, gravity_enabled): """Smoke-test non-identity joint/body ordering through a live PhysX articulation.""" @@ -444,6 +381,7 @@ def test_live_manual_root_preserving_ordering_reorders_backend_reads_and_writes( body_user_to_backend, ) + # The split accessors must be sliced from the reordered public poses, not from a stale backend-order cache. torch.testing.assert_close(articulation.data.body_com_pos_b.torch, articulation.data.body_com_pose_b.torch[..., :3]) torch.testing.assert_close( articulation.data.body_com_quat_b.torch, articulation.data.body_com_pose_b.torch[..., 3:] @@ -495,7 +433,7 @@ def test_reversed_joint_dynamics_use_public_joint_basis(sim, device, gravity_ena torch.testing.assert_close(generalized_energy, body_energy, atol=1e-5, rtol=1e-5) -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("device", test_devices()) @pytest.mark.parametrize("gravity_enabled", [False]) def test_live_floating_root_writers_match_identity_after_body_reordering(sim, device, gravity_enabled): """Keep floating-base root writes invariant when public body order moves the root.""" @@ -560,7 +498,7 @@ def test_live_floating_root_writers_match_identity_after_body_reordering(sim, de torch.testing.assert_close(articulation.data.root_link_vel_w.torch, root_link_velocity) -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("device", test_devices()) @pytest.mark.parametrize("gravity_enabled", [False]) @pytest.mark.parametrize("body_ordering", ["identity", "reversed"]) def test_live_direct_view_mass_inertia_writes_become_visible(sim, device, gravity_enabled, body_ordering): @@ -573,10 +511,11 @@ def test_live_direct_view_mass_inertia_writes_become_visible(sim, device, gravit - Case A: after a primed read and a subsequent simulation update (the lazy gate opens once per step). - - Case B: on the very first read of a cold buffer (initial timestamp -1.0). + - Case B: on the very first read of a cold buffer. - Both identity and non-identity (reversed) body ordering are covered; under ordering the public - buffers must equal the backend-order view gathered through ``user_to_backend``. + Case B runs first so that the buffers are still cold. Both identity and non-identity (reversed) body + ordering are covered; under ordering the public buffers must equal the backend-order view gathered through + ``user_to_backend``. """ body_ordering_arg = None if body_ordering == "identity" else PANDA_ROOT_PRESERVING_REVERSED_BODY_NAMES articulation = Articulation(FRANKA_PANDA_CFG.replace(prim_path="/World/Robot", body_ordering=body_ordering_arg)) @@ -604,23 +543,19 @@ def write_backend_mass_inertia(delta_mass: float, delta_inertia: float) -> tuple ) return backend_masses, backend_inertias - # Case A: prime the buffers, write through the view, then advance the sim so the lazy gate opens. - _ = articulation.data.body_mass.torch - _ = articulation.data.body_inertia.torch - backend_masses, backend_inertias = write_backend_mass_inertia(0.137, 0.011) - articulation.update(sim.cfg.dt) + # Case B: the buffers have not been read since reset, so the first read must reflect the write. + backend_masses, backend_inertias = write_backend_mass_inertia(0.293, 0.023) _assert_backend_to_user(articulation.data.body_mass.torch, backend_masses, body_user_to_backend) _assert_backend_to_user(articulation.data.body_inertia.torch, backend_inertias, body_user_to_backend) - # Case B: a cold buffer (initial timestamp -1.0) must reflect a write on its first read. - articulation.data._body_mass.timestamp = -1.0 - articulation.data._body_inertia.timestamp = -1.0 - backend_masses, backend_inertias = write_backend_mass_inertia(0.293, 0.023) + # Case A: the buffers are now primed; a new write becomes visible after the sim update opens the lazy gate. + backend_masses, backend_inertias = write_backend_mass_inertia(0.137, 0.011) + articulation.update(sim.cfg.dt) _assert_backend_to_user(articulation.data.body_mass.torch, backend_masses, body_user_to_backend) _assert_backend_to_user(articulation.data.body_inertia.torch, backend_inertias, body_user_to_backend) -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("device", test_devices(DeviceScope.CUDA)) @pytest.mark.parametrize("gravity_enabled", [False]) def test_branching_fixture_resolves_distinct_conventions(sim, device, gravity_enabled): """Resolve concrete breadth-first PhysX and depth-first MJWarp name orders.""" @@ -647,93 +582,7 @@ def test_branching_fixture_resolves_distinct_conventions(sim, device, gravity_en assert articulation.body_ordering is not None -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -@pytest.mark.parametrize("gravity_enabled", [False]) -@pytest.mark.parametrize("ordering_axis", ["joint", "body"]) -def test_unused_jacobian_ordering_map_is_none(sim, device, gravity_enabled, ordering_axis): - """Keep the inactive Jacobian-axis map unset under single-axis ordering.""" - fixture_path = Path(__file__).parent / "data" / "articulation_ordering_branching.usda" - articulation = Articulation( - ArticulationCfg( - prim_path="/World/Robot", - spawn=sim_utils.UsdFileCfg(usd_path=str(fixture_path)), - actuators={}, - joint_ordering="mjwarp" if ordering_axis == "joint" else None, - body_ordering="mjwarp" if ordering_axis == "body" else None, - ) - ) - sim.reset() - assert articulation.is_initialized - - if ordering_axis == "joint": - assert articulation.joint_ordering is not None - assert articulation.body_ordering is None - assert articulation.data._jacobian_joint_user_to_backend is not None - assert articulation.data._jacobian_body_user_to_backend is None - else: - assert articulation.joint_ordering is None - assert articulation.body_ordering is not None - assert articulation.data._jacobian_joint_user_to_backend is None - assert articulation.data._jacobian_body_user_to_backend is not None - - -@pytest.mark.parametrize("num_articulations", [1, 2]) -@pytest.mark.parametrize("device", test_devices()) -@pytest.mark.parametrize("add_ground_plane", [True]) -def test_initialization_floating_base_non_root(sim, num_articulations, device, add_ground_plane): - """Test initialization for a floating-base with articulation root on a rigid body. - - This test verifies that: - 1. The articulation is properly initialized - 2. The articulation is not fixed base - 3. All buffers have correct shapes - 4. The articulation can be simulated - - Args: - sim: The simulation fixture - num_articulations: Number of articulations to test - device: The device to run the simulation on - """ - articulation_cfg = generate_articulation_cfg(articulation_type="humanoid", stiffness=0.0, damping=0.0) - articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=sim.device) - - # Check that the framework doesn't hold excessive strong references. - assert sys.getrefcount(articulation) < 10 - - # Play sim - sim.reset() - - # Check if articulation is initialized - assert articulation.is_initialized - # Check that is fixed base - assert not articulation.is_fixed_base - # Check buffers that exists and have correct shapes - assert articulation.data.root_pos_w.torch.shape == (num_articulations, 3) - assert articulation.data.root_quat_w.torch.shape == (num_articulations, 4) - assert articulation.data.joint_pos.torch.shape == (num_articulations, 21) - - # Check some internal physx data for debugging - # -- joint related - assert articulation.root_view.max_dofs == articulation.root_view.shared_metatype.dof_count - # -- link related - assert articulation.root_view.max_links == articulation.root_view.shared_metatype.link_count - # -- link names (check within articulation ordering is correct) - prim_path_body_names = [path.split("/")[-1] for path in articulation.root_view.link_paths[0]] - assert prim_path_body_names == articulation.body_names - # -- actuator type - for actuator_name, actuator in articulation.actuators.items(): - is_implicit_model_cfg = isinstance(articulation_cfg.actuators[actuator_name], ImplicitActuatorCfg) - assert getattr(actuator, "is_implicit_model", False) == is_implicit_model_cfg - - # Simulate physics - for _ in range(10): - # perform rendering - sim.step() - # update articulation - articulation.update(sim.cfg.dt) - - -@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("num_articulations", [2]) @pytest.mark.parametrize("device", test_devices()) @pytest.mark.parametrize("add_ground_plane", [True]) def test_initialization_floating_base(sim, num_articulations, device, add_ground_plane): @@ -743,7 +592,6 @@ def test_initialization_floating_base(sim, num_articulations, device, add_ground 1. The articulation is properly initialized 2. The articulation is not fixed base 3. All buffers have correct shapes - 4. The articulation can be simulated Args: sim: The simulation fixture @@ -769,11 +617,6 @@ def test_initialization_floating_base(sim, num_articulations, device, add_ground assert articulation.data.body_mass.torch.shape == (num_articulations, articulation.num_bodies) assert articulation.data.body_inertia.torch.shape == (num_articulations, articulation.num_bodies, 9) - # Check some internal physx data for debugging - # -- joint related - assert articulation.root_view.max_dofs == articulation.root_view.shared_metatype.dof_count - # -- link related - assert articulation.root_view.max_links == articulation.root_view.shared_metatype.link_count # -- link names (check within articulation ordering is correct) prim_path_body_names = [path.split("/")[-1] for path in articulation.root_view.link_paths[0]] assert prim_path_body_names == articulation.body_names @@ -782,13 +625,6 @@ def test_initialization_floating_base(sim, num_articulations, device, add_ground is_implicit_model_cfg = isinstance(articulation_cfg.actuators[actuator_name], ImplicitActuatorCfg) assert getattr(actuator, "is_implicit_model", False) == is_implicit_model_cfg - # Simulate physics - for _ in range(10): - # perform rendering - sim.step() - # update articulation - articulation.update(sim.cfg.dt) - @pytest.mark.parametrize("num_articulations", [1, 2]) @pytest.mark.parametrize("device", test_devices()) @@ -825,76 +661,6 @@ def test_initialization_fixed_base(sim, num_articulations, device): assert articulation.data.body_mass.torch.shape == (num_articulations, articulation.num_bodies) assert articulation.data.body_inertia.torch.shape == (num_articulations, articulation.num_bodies, 9) - # Check some internal physx data for debugging - # -- joint related - assert articulation.root_view.max_dofs == articulation.root_view.shared_metatype.dof_count - # -- link related - assert articulation.root_view.max_links == articulation.root_view.shared_metatype.link_count - # -- link names (check within articulation ordering is correct) - prim_path_body_names = [path.split("/")[-1] for path in articulation.root_view.link_paths[0]] - assert prim_path_body_names == articulation.body_names - # -- actuator type - for actuator_name, actuator in articulation.actuators.items(): - is_implicit_model_cfg = isinstance(articulation_cfg.actuators[actuator_name], ImplicitActuatorCfg) - assert getattr(actuator, "is_implicit_model", False) == is_implicit_model_cfg - - # Simulate physics - for _ in range(10): - # perform rendering - sim.step() - # update articulation - articulation.update(sim.cfg.dt) - - # check that the root is at the correct state - its default state as it is fixed base - default_root_pose = articulation.data.default_root_pose.torch.clone() - default_root_vel = articulation.data.default_root_vel.torch.clone() - default_root_pose[:, :3] = default_root_pose[:, :3] + translations - - torch.testing.assert_close(articulation.data.root_link_pose_w.torch, default_root_pose) - torch.testing.assert_close(articulation.data.root_com_vel_w.torch, default_root_vel) - - -@pytest.mark.parametrize("num_articulations", [1, 2]) -@pytest.mark.parametrize("device", test_devices()) -@pytest.mark.parametrize("add_ground_plane", [True]) -def test_initialization_fixed_base_single_joint(sim, num_articulations, device, add_ground_plane): - """Test initialization for fixed base articulation with a single joint. - - This test verifies that: - 1. The articulation is properly initialized - 2. The articulation is fixed base - 3. All buffers have correct shapes - 4. The articulation maintains its default state - - Args: - sim: The simulation fixture - num_articulations: Number of articulations to test - device: The device to run the simulation on - """ - articulation_cfg = generate_articulation_cfg(articulation_type="single_joint_implicit") - articulation, translations = generate_articulation(articulation_cfg, num_articulations, device=device) - - # Check that the framework doesn't hold excessive strong references. - assert sys.getrefcount(articulation) < 10 - - # Play sim - sim.reset() - # Check if articulation is initialized - assert articulation.is_initialized - # Check that fixed base - assert articulation.is_fixed_base - # Check buffers that exists and have correct shapes - assert articulation.data.root_pos_w.torch.shape == (num_articulations, 3) - assert articulation.data.root_quat_w.torch.shape == (num_articulations, 4) - assert articulation.data.joint_pos.torch.shape == (num_articulations, 1) - assert articulation.data.body_mass.torch.shape == (num_articulations, articulation.num_bodies) - assert articulation.data.body_inertia.torch.shape == (num_articulations, articulation.num_bodies, 9) - - # Check some internal physx data for debugging - # -- joint related - assert articulation.root_view.max_dofs == articulation.root_view.shared_metatype.dof_count - # -- link related - assert articulation.root_view.max_links == articulation.root_view.shared_metatype.link_count # -- link names (check within articulation ordering is correct) prim_path_body_names = [path.split("/")[-1] for path in articulation.root_view.link_paths[0]] assert prim_path_body_names == articulation.body_names @@ -919,72 +685,26 @@ def test_initialization_fixed_base_single_joint(sim, num_articulations, device, torch.testing.assert_close(articulation.data.root_com_vel_w.torch, default_root_vel) -@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("num_articulations", [2]) @pytest.mark.parametrize("device", test_devices()) -def test_initialization_hand_with_tendons(sim, num_articulations, device): - """Test initialization for fixed base articulated hand with tendons. - - This test verifies that: - 1. The articulation is properly initialized - 2. The articulation is fixed base - 3. All buffers have correct shapes - 4. The articulation can be simulated +def test_fixed_tendon_position_target_writes_offset(sim, num_articulations, device): + """A tendon length target lands in the simulation as ``rest_length - target`` on the selected cells only. - Args: - sim: The simulation fixture - num_articulations: Number of articulations to test - device: The device to run the simulation on + The index form commands every tendon of environment 0; the mask form commands tendon 0 of + environment 1. Every other cell must keep its initial offset. """ articulation_cfg = generate_articulation_cfg(articulation_type="shadow_hand") articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=device) - # Check that the framework doesn't hold excessive strong references. - assert sys.getrefcount(articulation) < 10 - - # Play sim sim.reset() - # Check if articulation is initialized assert articulation.is_initialized - # Check that fixed base assert articulation.is_fixed_base - # Check buffers that exists and have correct shapes - assert articulation.data.root_pos_w.torch.shape == (num_articulations, 3) - assert articulation.data.root_quat_w.torch.shape == (num_articulations, 4) assert articulation.data.joint_pos.torch.shape == (num_articulations, 24) assert articulation.data.body_mass.torch.shape == (num_articulations, articulation.num_bodies) assert articulation.data.body_inertia.torch.shape == (num_articulations, articulation.num_bodies, 9) - - # Check some internal physx data for debugging - # -- joint related - assert articulation.root_view.max_dofs == articulation.root_view.shared_metatype.dof_count - # -- link related - assert articulation.root_view.max_links == articulation.root_view.shared_metatype.link_count - # -- actuator type for actuator_name, actuator in articulation.actuators.items(): is_implicit_model_cfg = isinstance(articulation_cfg.actuators[actuator_name], ImplicitActuatorCfg) assert getattr(actuator, "is_implicit_model", False) == is_implicit_model_cfg - - # Simulate physics - for _ in range(10): - # perform rendering - sim.step() - # update articulation - articulation.update(sim.cfg.dt) - - -@pytest.mark.parametrize("num_articulations", [2]) -@pytest.mark.parametrize("device", test_devices()) -def test_fixed_tendon_position_target_writes_offset(sim, num_articulations, device): - """A tendon length target lands in the simulation as ``rest_length - target`` on the selected cells only. - - The index form commands every tendon of environment 0; the mask form commands tendon 0 of - environment 1. Every other cell must keep its initial offset. - """ - articulation_cfg = generate_articulation_cfg(articulation_type="shadow_hand") - articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=device) - - sim.reset() - assert articulation.is_initialized num_tendons = articulation.num_fixed_tendons assert num_tendons > 0 rest_length = articulation.data.fixed_tendon_rest_length.torch.clone() @@ -1015,7 +735,7 @@ def test_fixed_tendon_position_target_writes_offset(sim, num_articulations, devi torch.testing.assert_close(articulation.data.fixed_tendon_offset.torch, expected) -@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("num_articulations", [2]) @pytest.mark.parametrize("device", test_devices()) @pytest.mark.parametrize("add_ground_plane", [True]) def test_initialization_floating_base_made_fixed_base(sim, num_articulations, device, add_ground_plane): @@ -1050,11 +770,6 @@ def test_initialization_floating_base_made_fixed_base(sim, num_articulations, de assert articulation.data.root_quat_w.torch.shape == (num_articulations, 4) assert articulation.data.joint_pos.torch.shape == (num_articulations, 12) - # Check some internal physx data for debugging - # -- joint related - assert articulation.root_view.max_dofs == articulation.root_view.shared_metatype.dof_count - # -- link related - assert articulation.root_view.max_links == articulation.root_view.shared_metatype.link_count # -- link names (check within articulation ordering is correct) prim_path_body_names = [path.split("/")[-1] for path in articulation.root_view.link_paths[0]] assert prim_path_body_names == articulation.body_names @@ -1075,7 +790,7 @@ def test_initialization_floating_base_made_fixed_base(sim, num_articulations, de torch.testing.assert_close(articulation.data.root_com_vel_w.torch, default_root_vel) -@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("num_articulations", [2]) @pytest.mark.parametrize("device", test_devices()) @pytest.mark.parametrize("add_ground_plane", [True]) def test_initialization_fixed_base_made_floating_base(sim, num_articulations, device, add_ground_plane): @@ -1085,7 +800,6 @@ def test_initialization_fixed_base_made_floating_base(sim, num_articulations, de 1. The articulation is properly initialized 2. The articulation is floating base after modification 3. All buffers have correct shapes - 4. The articulation can be simulated Args: sim: The simulation fixture @@ -1110,25 +824,13 @@ def test_initialization_fixed_base_made_floating_base(sim, num_articulations, de assert articulation.data.root_quat_w.torch.shape == (num_articulations, 4) assert articulation.data.joint_pos.torch.shape == (num_articulations, 9) - # Check some internal physx data for debugging - # -- joint related - assert articulation.root_view.max_dofs == articulation.root_view.shared_metatype.dof_count - # -- link related - assert articulation.root_view.max_links == articulation.root_view.shared_metatype.link_count # -- link names (check within articulation ordering is correct) prim_path_body_names = [path.split("/")[-1] for path in articulation.root_view.link_paths[0]] assert prim_path_body_names == articulation.body_names - # Simulate physics - for _ in range(10): - # perform rendering - sim.step() - # update articulation - articulation.update(sim.cfg.dt) - -@pytest.mark.parametrize("num_articulations", [1, 2]) -@pytest.mark.parametrize("device", test_devices()) +@pytest.mark.parametrize("num_articulations", [2]) +@pytest.mark.parametrize("device", test_devices(DeviceScope.CUDA)) @pytest.mark.parametrize("add_ground_plane", [True]) def test_out_of_range_default_joint_pos(sim, num_articulations, device, add_ground_plane): """Test that the default joint position from configuration is out of range. @@ -1158,7 +860,7 @@ def test_out_of_range_default_joint_pos(sim, num_articulations, device, add_grou sim.reset() -@pytest.mark.parametrize("device", test_devices()) +@pytest.mark.parametrize("device", test_devices(DeviceScope.CUDA)) def test_out_of_range_default_joint_vel(sim, device): """Test that the default joint velocity from configuration is out of range. @@ -1181,7 +883,7 @@ def test_out_of_range_default_joint_vel(sim, device): sim.reset() -@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("num_articulations", [2]) @pytest.mark.parametrize("device", test_devices()) @pytest.mark.parametrize("add_ground_plane", [True]) def test_joint_pos_limits(sim, num_articulations, device, add_ground_plane): @@ -1256,8 +958,8 @@ def test_joint_pos_limits(sim, num_articulations, device, add_ground_plane): assert torch.all(within_bounds) -@pytest.mark.parametrize("num_articulations", [1, 2]) -@pytest.mark.parametrize("device", test_devices()) +@pytest.mark.parametrize("num_articulations", [2]) +@pytest.mark.parametrize("device", test_devices(DeviceScope.CUDA)) @pytest.mark.parametrize("add_ground_plane", [True]) def test_joint_effort_limits(sim, num_articulations, device, add_ground_plane): """Validate joint effort limits via joint_effort_out_of_limit().""" @@ -1289,119 +991,38 @@ def __init__(self, art): assert torch.all(out) -@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("num_articulations", [2]) @pytest.mark.parametrize("device", test_devices()) -def test_external_force_buffer(sim, num_articulations, device): - """Test if external force buffer correctly updates in the force value is zero case. +@pytest.mark.parametrize("add_ground_plane", [True]) +def test_external_force_on_single_body(sim, num_articulations, device, add_ground_plane): + """Test application of external force on the base of the articulation. This test verifies that: - 1. External forces can be applied correctly - 2. Force buffers are updated properly - 3. Zero forces are handled correctly + 1. External forces can be applied to specific bodies + 2. The forces affect the articulation's motion correctly + 3. The articulation responds to the forces as expected Args: sim: The simulation fixture num_articulations: Number of articulations to test """ articulation_cfg = generate_articulation_cfg(articulation_type="anymal") - articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=sim.device) - - # play the simulator + articulation, translations = generate_articulation(articulation_cfg, num_articulations, device=sim.device) + # Play the simulator sim.reset() - # find bodies to apply the force + # Find bodies to apply the force body_ids, _ = articulation.find_bodies("base") - - # reset root state - articulation.write_root_pose_to_sim_index(root_pose=articulation.data.default_root_pose.torch.clone()) - articulation.write_root_velocity_to_sim_index(root_velocity=articulation.data.default_root_vel.torch.clone()) - - # reset dof state - joint_pos, joint_vel = ( - articulation.data.default_joint_pos.torch, - articulation.data.default_joint_vel.torch, - ) - articulation.write_joint_position_to_sim_index(position=joint_pos) - articulation.write_joint_velocity_to_sim_index(velocity=joint_vel) - - # reset articulation - articulation.reset() - - # perform simulation - for step in range(5): - # initiate force tensor - external_wrench_b = torch.zeros(articulation.num_instances, len(body_ids), 6, device=sim.device) - - if step == 0 or step == 3: - # set a non-zero force - force = 1 - else: - # set a zero force - force = 0 - - # set force value - external_wrench_b[:, :, 0] = force - external_wrench_b[:, :, 3] = force - - # apply force - articulation.permanent_wrench_composer.set_forces_and_torques_index( - forces=external_wrench_b[..., :3], - torques=external_wrench_b[..., 3:], - body_ids=body_ids, - ) - - # check if the articulation's force and torque buffers are correctly updated - for i in range(num_articulations): - assert articulation.permanent_wrench_composer.out_force_b.torch[i, 0, 0].item() == force - assert articulation.permanent_wrench_composer.out_torque_b.torch[i, 0, 0].item() == force - - # Check if the instantaneous wrench is correctly added to the permanent wrench - articulation.instantaneous_wrench_composer.add_forces_and_torques_index( - forces=external_wrench_b[..., :3], - torques=external_wrench_b[..., 3:], - body_ids=body_ids, - ) - - # apply action to the articulation - articulation.set_joint_position_target_index(target=articulation.data.default_joint_pos.torch.clone()) - articulation.write_data_to_sim() - - # perform step - sim.step() - - # update buffers - articulation.update(sim.cfg.dt) - - -@pytest.mark.parametrize("num_articulations", [1, 2]) -@pytest.mark.parametrize("device", test_devices()) -def test_external_force_on_single_body(sim, num_articulations, device): - """Test application of external force on the base of the articulation. - - This test verifies that: - 1. External forces can be applied to specific bodies - 2. The forces affect the articulation's motion correctly - 3. The articulation responds to the forces as expected - - Args: - sim: The simulation fixture - num_articulations: Number of articulations to test - """ - articulation_cfg = generate_articulation_cfg(articulation_type="anymal") - articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=sim.device) - # Play the simulator - sim.reset() - - # Find bodies to apply the force - body_ids, _ = articulation.find_bodies("base") - # Sample a large force - external_wrench_b = torch.zeros(articulation.num_instances, len(body_ids), 6, device=sim.device) - external_wrench_b[..., 1] = 1000.0 + # Sample a large force + external_wrench_b = torch.zeros(articulation.num_instances, len(body_ids), 6, device=sim.device) + external_wrench_b[..., 1] = 1000.0 # Now we are ready! for _ in range(5): # reset root state - articulation.write_root_pose_to_sim_index(root_pose=articulation.data.default_root_pose.torch.clone()) + root_pose = articulation.data.default_root_pose.torch.clone() + root_pose[:, :3] += translations + articulation.write_root_pose_to_sim_index(root_pose=root_pose) articulation.write_root_velocity_to_sim_index(root_velocity=articulation.data.default_root_vel.torch.clone()) # reset dof state joint_pos, joint_vel = ( @@ -1425,14 +1046,15 @@ def test_external_force_on_single_body(sim, num_articulations, device): sim.step() # update buffers articulation.update(sim.cfg.dt) - # check condition that the articulations have fallen down - for i in range(num_articulations): - assert articulation.data.root_pos_w.torch[i, 2].item() < 0.2 + # the lateral force along the base y-axis must push the standing robot sideways + lateral_displacement = articulation.data.root_pos_w.torch[:, 1] - translations[:, 1] + assert torch.all(lateral_displacement > 0.5), f"lateral displacement {lateral_displacement.tolist()}" -@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("num_articulations", [2]) @pytest.mark.parametrize("device", test_devices()) -def test_external_force_on_single_body_at_position(sim, num_articulations, device): +@pytest.mark.parametrize("add_ground_plane", [True]) +def test_external_force_on_single_body_at_position(sim, num_articulations, device, add_ground_plane): """Test application of external force on the base of the articulation at a given position. This test verifies that: @@ -1447,7 +1069,7 @@ def test_external_force_on_single_body_at_position(sim, num_articulations, devic num_articulations: Number of articulations to test """ articulation_cfg = generate_articulation_cfg(articulation_type="anymal") - articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=sim.device) + articulation, translations = generate_articulation(articulation_cfg, num_articulations, device=sim.device) # Play the simulator sim.reset() @@ -1459,16 +1081,11 @@ def test_external_force_on_single_body_at_position(sim, num_articulations, devic external_wrench_positions_b = torch.zeros(articulation.num_instances, len(body_ids), 3, device=sim.device) external_wrench_positions_b[..., 1] = 1.0 - desired_force = torch.zeros(articulation.num_instances, len(body_ids), 3, device=sim.device) - desired_force[..., 2] = 1000.0 - desired_torque = torch.zeros(articulation.num_instances, len(body_ids), 3, device=sim.device) - desired_torque[..., 0] = 1000.0 - # Now we are ready! for i in range(5): # reset root state root_pose = articulation.data.default_root_pose.torch.clone() - root_pose[0, 0] = 2.5 # space them apart by 2.5m + root_pose[:, :3] += translations articulation.write_root_pose_to_sim_index(root_pose=root_pose) articulation.write_root_velocity_to_sim_index(root_velocity=articulation.data.default_root_vel.torch.clone()) @@ -1486,7 +1103,7 @@ def test_external_force_on_single_body_at_position(sim, num_articulations, devic if i % 2 == 0: body_com_pos_w = articulation.data.body_com_pos_w.torch[:, body_ids, :3] - # is_global = True + is_global = True external_wrench_positions_b[..., 0] = 0.0 external_wrench_positions_b[..., 1] = 1.0 external_wrench_positions_b[..., 2] = 0.0 @@ -1519,12 +1136,12 @@ def test_external_force_on_single_body_at_position(sim, num_articulations, devic sim.step() # update buffers articulation.update(sim.cfg.dt) - # check condition that the articulations have fallen down - for i in range(num_articulations): - assert articulation.data.root_pos_w.torch[i, 2].item() < 0.2 + # the upward force applied 1 m along the base y-axis must roll the robot about its x-axis + roll_rate = articulation.data.root_ang_vel_b.torch[:, 0] + assert torch.all(roll_rate > 0.1), f"roll rate {roll_rate.tolist()} (is_global={is_global})" -@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("num_articulations", [2]) @pytest.mark.parametrize("device", test_devices()) def test_external_force_on_multiple_bodies(sim, num_articulations, device): """Test application of external force on the legs of the articulation. @@ -1583,7 +1200,7 @@ def test_external_force_on_multiple_bodies(sim, num_articulations, device): assert articulation.data.root_ang_vel_w.torch[i, 2].item() > 0.1 -@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("num_articulations", [2]) @pytest.mark.parametrize("device", test_devices()) def test_external_force_on_multiple_bodies_at_position(sim, num_articulations, device): """Test application of external force on the legs of the articulation at a given position. @@ -1613,11 +1230,6 @@ def test_external_force_on_multiple_bodies_at_position(sim, num_articulations, d external_wrench_positions_b = torch.zeros(articulation.num_instances, len(body_ids), 3, device=sim.device) external_wrench_positions_b[..., 1] = 1.0 - desired_force = torch.zeros(articulation.num_instances, len(body_ids), 3, device=sim.device) - desired_force[..., 2] = 1000.0 - desired_torque = torch.zeros(articulation.num_instances, len(body_ids), 3, device=sim.device) - desired_torque[..., 0] = 1000.0 - # Now we are ready! for i in range(5): # reset root state @@ -1676,8 +1288,8 @@ def test_external_force_on_multiple_bodies_at_position(sim, num_articulations, d assert torch.linalg.vector_norm(articulation.data.root_ang_vel_w.torch[i]).item() > 0.1 -@pytest.mark.parametrize("num_articulations", [1, 2]) -@pytest.mark.parametrize("device", test_devices()) +@pytest.mark.parametrize("num_articulations", [2]) +@pytest.mark.parametrize("device", test_devices(DeviceScope.CUDA)) def test_loading_gains_from_usd(sim, num_articulations, device): """Test that gains are loaded from USD file if actuator model has them as None. @@ -1737,22 +1349,26 @@ def test_loading_gains_from_usd(sim, num_articulations, device): torch.testing.assert_close(articulation.actuators["body"].damping, expected_damping) -@pytest.mark.parametrize("num_articulations", [1, 2]) -@pytest.mark.parametrize("device", test_devices()) +@pytest.mark.parametrize("num_articulations", [2]) +@pytest.mark.parametrize("device", test_devices(DeviceScope.CUDA)) @pytest.mark.parametrize("add_ground_plane", [True]) -def test_setting_gains_from_cfg(sim, num_articulations, device, add_ground_plane): +@pytest.mark.parametrize(("stiffness", "damping"), [(10.0, 2.0), ({".*": 10.0}, {".*": 2.0})]) +def test_setting_gains_from_cfg(sim, num_articulations, device, add_ground_plane, stiffness, damping): """Test that gains are loaded from the configuration correctly. This test verifies that: - 1. Gains are loaded correctly from configuration + 1. Gains are loaded correctly from a scalar or a per-joint dictionary configuration 2. The gains match the expected values 3. The gains are applied correctly to the actuators + The humanoid has its articulation root on a rigid body below the spawned prim, so this test also checks + that the floating-base root is discovered there. + Args: sim: The simulation fixture num_articulations: Number of articulations to test """ - articulation_cfg = generate_articulation_cfg(articulation_type="humanoid") + articulation_cfg = generate_articulation_cfg(articulation_type="humanoid", stiffness=stiffness, damping=damping) articulation, _ = generate_articulation( articulation_cfg=articulation_cfg, num_articulations=num_articulations, device=sim.device ) @@ -1760,37 +1376,12 @@ def test_setting_gains_from_cfg(sim, num_articulations, device, add_ground_plane # Play sim sim.reset() - # Expected gains - expected_stiffness = torch.full( - (articulation.num_instances, articulation.num_joints), 10.0, device=articulation.device - ) - expected_damping = torch.full_like(expected_stiffness, 2.0) - - # Check that gains are loaded from USD file - torch.testing.assert_close(articulation.actuators["body"].stiffness, expected_stiffness) - torch.testing.assert_close(articulation.actuators["body"].damping, expected_damping) - - -@pytest.mark.parametrize("num_articulations", [1, 2]) -@pytest.mark.parametrize("device", test_devices()) -def test_setting_gains_from_cfg_dict(sim, num_articulations, device): - """Test that gains are loaded from the configuration dictionary correctly. - - This test verifies that: - 1. Gains are loaded correctly from configuration dictionary - 2. The gains match the expected values - 3. The gains are applied correctly to the actuators - - Args: - sim: The simulation fixture - num_articulations: Number of articulations to test - """ - articulation_cfg = generate_articulation_cfg(articulation_type="humanoid") - articulation, _ = generate_articulation( - articulation_cfg=articulation_cfg, num_articulations=num_articulations, device=sim.device - ) - # Play sim - sim.reset() + # Floating-base root discovered on a rigid body below the spawned prim + assert articulation.is_initialized + assert not articulation.is_fixed_base + assert articulation.data.joint_pos.torch.shape == (num_articulations, 21) + prim_path_body_names = [path.split("/")[-1] for path in articulation.root_view.link_paths[0]] + assert prim_path_body_names == articulation.body_names # Expected gains expected_stiffness = torch.full( @@ -1898,7 +1489,7 @@ def test_setting_effort_limit_writes_to_solver(sim, device, joint_effort_limit, ) -@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("num_articulations", [2]) @pytest.mark.parametrize("device", test_devices()) def test_reset(sim, num_articulations, device, monkeypatch): """Test that reset method works properly.""" @@ -1920,37 +1511,32 @@ def record_actuator_reset(env_ids=None): actuator_reset(env_ids) monkeypatch.setattr(actuator, "reset", record_actuator_reset) + + num_bodies = articulation.num_bodies + composers = (articulation.instantaneous_wrench_composer, articulation.permanent_wrench_composer) + ones = torch.ones((num_articulations, num_bodies, 3), device=device) + articulation.permanent_wrench_composer.set_forces_and_torques_index(forces=ones, torques=ones) + articulation.instantaneous_wrench_composer.add_forces_and_torques_index(forces=ones, torques=ones) + + # A partial reset clears only the selected environment + articulation.reset(env_ids=torch.tensor([0], device=device)) + for composer in composers: + assert composer.active + for buffer in (composer.out_force_b.torch, composer.out_torque_b.torch): + assert torch.count_nonzero(buffer[0]) == 0 + assert torch.count_nonzero(buffer[1:]) == buffer[1:].numel() + + # A full reset resets every actuator environment and clears all external forces and torques + reset_env_ids.clear() articulation.reset() assert reset_env_ids == [None] + for composer in composers: + assert not composer.active + assert torch.count_nonzero(composer.out_force_b.torch) == 0 + assert torch.count_nonzero(composer.out_torque_b.torch) == 0 - # Reset should zero external forces and torques - assert not articulation._instantaneous_wrench_composer.active - assert not articulation._permanent_wrench_composer.active - assert torch.count_nonzero(articulation._instantaneous_wrench_composer.out_force_b.torch) == 0 - assert torch.count_nonzero(articulation._instantaneous_wrench_composer.out_torque_b.torch) == 0 - assert torch.count_nonzero(articulation._permanent_wrench_composer.out_force_b.torch) == 0 - assert torch.count_nonzero(articulation._permanent_wrench_composer.out_torque_b.torch) == 0 - - if num_articulations > 1: - num_bodies = articulation.num_bodies - articulation.permanent_wrench_composer.set_forces_and_torques_index( - forces=torch.ones((num_articulations, num_bodies, 3), device=device), - torques=torch.ones((num_articulations, num_bodies, 3), device=device), - ) - articulation.instantaneous_wrench_composer.add_forces_and_torques_index( - forces=torch.ones((num_articulations, num_bodies, 3), device=device), - torques=torch.ones((num_articulations, num_bodies, 3), device=device), - ) - articulation.reset(env_ids=torch.tensor([0], device=device)) - assert articulation._instantaneous_wrench_composer.active - assert articulation._permanent_wrench_composer.active - assert torch.count_nonzero(articulation._instantaneous_wrench_composer.out_force_b.torch) == num_bodies * 3 - assert torch.count_nonzero(articulation._instantaneous_wrench_composer.out_torque_b.torch) == num_bodies * 3 - assert torch.count_nonzero(articulation._permanent_wrench_composer.out_force_b.torch) == num_bodies * 3 - assert torch.count_nonzero(articulation._permanent_wrench_composer.out_torque_b.torch) == num_bodies * 3 - -@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("num_articulations", [2]) @pytest.mark.parametrize("device", test_devices()) @pytest.mark.parametrize("add_ground_plane", [True]) def test_apply_joint_command(sim, num_articulations, device, add_ground_plane): @@ -1983,13 +1569,13 @@ def test_apply_joint_command(sim, num_articulations, device, add_ground_plane): # update buffers articulation.update(sim.cfg.dt) - # Check that current joint position is not the same as default joint position, meaning - # the articulation moved. We can't check that it reached its desired joint position as the gains + # The commanded joint must move toward its target. We can't check that it reached the target as the gains # are not properly tuned - assert not torch.allclose(articulation.data.joint_pos.torch, joint_pos) + default_joint_pos = articulation.data.default_joint_pos.torch + assert torch.all(articulation.data.joint_pos.torch[:, 3].abs() < default_joint_pos[:, 3].abs() - 0.5) -@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("num_articulations", [2]) @pytest.mark.parametrize("device", test_devices()) @pytest.mark.parametrize("with_offset", [True, False]) def test_body_root_state(sim, num_articulations, device, with_offset): @@ -2018,6 +1604,9 @@ def test_body_root_state(sim, num_articulations, device, with_offset): assert articulation.is_initialized, "Articulation is not initialized" # Check that fixed base assert articulation.is_fixed_base, "Articulation is not a fixed base" + assert articulation.data.joint_pos.torch.shape == (num_articulations, 1) + prim_path_body_names = [path.split("/")[-1] for path in articulation.root_view.link_paths[0]] + assert prim_path_body_names == articulation.body_names # Resolve body indices by name (ordering may differ across physics backends) root_idx = articulation.body_names.index("CenterPivot") @@ -2059,6 +1648,12 @@ def test_body_root_state(sim, num_articulations, device, with_offset): body_com_pose_w = articulation.data.body_com_pose_w.torch body_com_vel_w = articulation.data.body_com_vel_w.torch + # the fixed root stays at its default state + default_root_pose = articulation.data.default_root_pose.torch.clone() + default_root_pose[:, :3] += env_pos + torch.testing.assert_close(root_link_pose_w, default_root_pose) + torch.testing.assert_close(root_com_vel_w, articulation.data.default_root_vel.torch) + if with_offset: # get joint state joint_pos = articulation.data.joint_pos.torch.unsqueeze(-1) @@ -2113,7 +1708,7 @@ def test_body_root_state(sim, num_articulations, device, with_offset): torch.testing.assert_close(body_com_vel_w, body_link_vel_w) -@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("num_articulations", [2]) @pytest.mark.parametrize("device", test_devices()) @pytest.mark.parametrize("with_offset", [True, False]) @pytest.mark.parametrize("state_location", ["com", "link"]) @@ -2196,7 +1791,7 @@ def test_write_root_state(sim, num_articulations, device, with_offset, state_loc torch.testing.assert_close(rand_state[..., 7:], articulation.data.root_link_vel_w.torch) -@pytest.mark.parametrize("device", test_devices()) +@pytest.mark.parametrize("device", test_devices(DeviceScope.CUDA)) def test_setting_articulation_root_prim_path(sim, device): """Test that the articulation root prim path can be set explicitly.""" sim._app_control_on_stop_handle = None @@ -2214,7 +1809,7 @@ def test_setting_articulation_root_prim_path(sim, device): assert articulation._is_initialized -@pytest.mark.parametrize("device", test_devices()) +@pytest.mark.parametrize("device", test_devices(DeviceScope.CUDA)) def test_setting_invalid_articulation_root_prim_path(sim, device): """Test that the articulation root prim path can be set explicitly.""" sim._app_control_on_stop_handle = None @@ -2231,7 +1826,7 @@ def test_setting_invalid_articulation_root_prim_path(sim, device): sim.reset() -@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("num_articulations", [2]) @pytest.mark.parametrize("device", test_devices()) @pytest.mark.parametrize("gravity_enabled", [False]) def test_write_joint_state_data_consistency(sim, num_articulations, device, gravity_enabled): @@ -2278,12 +1873,10 @@ def test_write_joint_state_data_consistency(sim, num_articulations, device, grav body_com_vel_w = articulation.data.body_com_vel_w.torch original_body_states = torch.cat([original_body_link_pose_w, original_body_com_vel_w], dim=-1) body_state_w = torch.cat([body_link_pose_w, body_com_vel_w], dim=-1) - assert torch.count_nonzero(original_body_states[:, 1:] != body_state_w[:, 1:]) > ( - len(original_body_states[:, 1:]) / 2 - ) + # every non-root body must move with the joint write + assert torch.all((original_body_states[:, 1:] != body_state_w[:, 1:]).any(dim=-1)) # validate body - link consistency body_link_vel_w = articulation.data.body_link_vel_w.torch - torch.testing.assert_close(body_link_pose_w, articulation.data.body_link_pose_w.torch) # skip lin_vel because it differs from link frame, this should be fine because we are only checking # if velocity update is triggered, which can be determined by comparing angular velocity torch.testing.assert_close(body_com_vel_w[..., 3:], body_link_vel_w[..., 3:]) @@ -2308,43 +1901,19 @@ def test_write_joint_state_data_consistency(sim, num_articulations, device, grav torch.testing.assert_close(body_com_vel_w[..., :3], body_com_lin_vel_w) torch.testing.assert_close(body_com_vel_w[..., 3:], body_com_ang_vel_w) - # validate pos_w, quat_w, pos_b, quat_b is consistent with pose_w and pose_b - expected_com_pose_w = torch.cat((body_com_pos_w, body_com_quat_w), dim=2) - expected_com_pose_b = torch.cat((body_com_pos_b, body_com_quat_b), dim=2) - body_pos_w = articulation.data.body_pos_w.torch - body_quat_w = articulation.data.body_quat_w.torch - expected_body_pose_w = torch.cat((body_pos_w, body_quat_w), dim=2) - body_link_pos_w = articulation.data.body_link_pos_w.torch - body_link_quat_w = articulation.data.body_link_quat_w.torch - expected_body_link_pose_w = torch.cat((body_link_pos_w, body_link_quat_w), dim=2) - body_com_pose_w = articulation.data.body_com_pose_w.torch - body_com_pose_b = articulation.data.body_com_pose_b.torch - body_pose_w = articulation.data.body_pose_w.torch - body_link_pose_w_fresh = articulation.data.body_link_pose_w.torch - torch.testing.assert_close(body_com_pose_w, expected_com_pose_w) - torch.testing.assert_close(body_com_pose_b, expected_com_pose_b) - torch.testing.assert_close(body_pose_w, expected_body_pose_w) - torch.testing.assert_close(body_link_pose_w_fresh, expected_body_link_pose_w) - - # validate pose_w is consistent with individual properties - body_vel_w = articulation.data.body_vel_w.torch - body_com_vel_w_fresh = articulation.data.body_com_vel_w.torch - torch.testing.assert_close(body_pose_w, body_link_pose_w) - torch.testing.assert_close(body_vel_w, body_com_vel_w) - torch.testing.assert_close(body_link_pose_w_fresh, body_link_pose_w) - torch.testing.assert_close(body_com_pose_w, articulation.data.body_com_pose_w.torch) - torch.testing.assert_close(body_vel_w, body_com_vel_w_fresh) - -@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("num_articulations", [2]) @pytest.mark.parametrize("device", test_devices()) def test_spatial_tendons(sim, num_articulations, device): """Test spatial tendons apis. + This test verifies that: 1. The articulation is properly initialized 2. The articulation has spatial tendons 3. All buffers have correct shapes - 4. The articulation can be simulated + 4. Spatial tendon properties reach the simulation for the right environments + 5. Joint state writes accept int64 environment and joint selectors + Args: sim: The simulation fixture num_articulations: Number of articulations to test @@ -2374,24 +1943,46 @@ def test_spatial_tendons(sim, num_articulations, device): assert articulation.data.body_inertia.torch.shape == (num_articulations, articulation.num_bodies, 9) assert articulation.num_spatial_tendons == 1 - articulation.set_spatial_tendon_stiffness_index(stiffness=10.0) - articulation.set_spatial_tendon_limit_stiffness_index(limit_stiffness=10.0) - articulation.set_spatial_tendon_damping_index(damping=10.0) - articulation.set_spatial_tendon_offset_index(offset=10.0) + # Distinct per-environment values so that an environment mix-up cannot pass + env_offset = torch.arange(num_articulations, dtype=torch.float32, device=device).unsqueeze(1) + stiffness, limit_stiffness, damping, offset = (10.0 + env_offset, 20.0 + env_offset, 3.0 + env_offset, env_offset) + articulation.set_spatial_tendon_stiffness_index(stiffness=stiffness) + articulation.set_spatial_tendon_limit_stiffness_index(limit_stiffness=limit_stiffness) + articulation.set_spatial_tendon_damping_index(damping=damping) + articulation.set_spatial_tendon_offset_index(offset=offset) + articulation.write_spatial_tendon_properties_to_sim_index() + + root_view = articulation.root_view + torch.testing.assert_close(_to_device_tensor(root_view.get_spatial_tendon_stiffnesses(), device), stiffness) + torch.testing.assert_close( + _to_device_tensor(root_view.get_spatial_tendon_limit_stiffnesses(), device), limit_stiffness + ) + torch.testing.assert_close(_to_device_tensor(root_view.get_spatial_tendon_dampings(), device), damping) + torch.testing.assert_close(_to_device_tensor(root_view.get_spatial_tendon_offsets(), device), offset) - # Simulate physics - for _ in range(10): - # perform rendering - sim.step() - # update articulation - articulation.update(sim.cfg.dt) + # Joint state writes accept int64 selectors (regression) + env_ids = torch.tensor([1, 0], dtype=torch.int64, device=device) + joint_ids = torch.tensor([articulation.num_joints - 1, 0], dtype=torch.int64, device=device) + position = torch.tensor([[0.21, 0.11], [0.22, 0.12]], device=device) + velocity = torch.tensor([[1.21, 1.11], [1.22, 1.12]], device=device) + + expected_position = articulation.data.joint_pos.torch.clone() + expected_velocity = articulation.data.joint_vel.torch.clone() + + articulation.write_joint_state_to_sim_index( + position=position, velocity=velocity, env_ids=env_ids, joint_ids=joint_ids + ) + expected_position[env_ids[:, None], joint_ids[None, :]] = position + expected_velocity[env_ids[:, None], joint_ids[None, :]] = velocity + torch.testing.assert_close(articulation.data.joint_pos.torch, expected_position) + torch.testing.assert_close(articulation.data.joint_vel.torch, expected_velocity) @pytest.mark.parametrize("add_ground_plane", [True]) -@pytest.mark.parametrize("num_articulations", [1, 2]) +@pytest.mark.parametrize("num_articulations", [2]) @pytest.mark.parametrize("device", test_devices()) def test_write_joint_frictions_to_sim(sim, num_articulations, device, add_ground_plane): - """Test applying of joint position target functions correctly for a robotic arm.""" + """Test that joint friction coefficients written through the combined API reach the simulation.""" articulation_cfg = generate_articulation_cfg(articulation_type="panda") articulation, _ = generate_articulation( articulation_cfg=articulation_cfg, num_articulations=num_articulations, device=device @@ -2400,12 +1991,6 @@ def test_write_joint_frictions_to_sim(sim, num_articulations, device, add_ground # Play the simulator sim.reset() - for _ in range(100): - # perform step - sim.step() - # update buffers - articulation.update(sim.cfg.dt) - # apply action to the articulation dynamic_friction = torch.rand(num_articulations, articulation.num_joints, device=device) viscous_friction = torch.rand(num_articulations, articulation.num_joints, device=device) @@ -2437,87 +2022,6 @@ def test_write_joint_frictions_to_sim(sim, num_articulations, device, add_ground assert torch.allclose(joint_viscous_friction_coeff_sim, viscous_friction.cpu()) assert torch.allclose(joint_friction_coeff_sim, friction.cpu()) - # For Isaac Sim >= 5.0: also test the combined API that can set dynamic and viscous via - # write_joint_friction_coefficient_to_sim; reset the sim to isolate this path. - if has_kit() and get_isaac_sim_version().major >= 5: - # Reset simulator to ensure a clean state for the alternative API path - sim.reset() - - # Warm up a few steps to populate buffers - for _ in range(100): - sim.step() - articulation.update(sim.cfg.dt) - - # New random coefficients - dynamic_friction_2 = torch.rand(num_articulations, articulation.num_joints, device=device) - viscous_friction_2 = torch.rand(num_articulations, articulation.num_joints, device=device) - friction_2 = torch.rand(num_articulations, articulation.num_joints, device=device) - - # Guarantee that the dynamic friction is not greater than the static friction - dynamic_friction_2 = torch.min(dynamic_friction_2, friction_2) - - # Use the combined setter to write all three at once - articulation.write_joint_friction_coefficient_to_sim_index( - joint_friction_coeff=friction_2, - joint_dynamic_friction_coeff=dynamic_friction_2, - joint_viscous_friction_coeff=viscous_friction_2, - ) - articulation.write_data_to_sim() - - # Step to let sim ingest new params and refresh data buffers - for _ in range(100): - sim.step() - articulation.update(sim.cfg.dt) - - friction_props_from_sim_2 = wp.to_torch(articulation.root_view.get_dof_friction_properties()) - joint_friction_coeff_sim_2 = friction_props_from_sim_2[:, :, 0] - friction_dynamic_coef_sim_2 = friction_props_from_sim_2[:, :, 1] - friction_viscous_coeff_sim_2 = friction_props_from_sim_2[:, :, 2] - - # Validate values propagated - assert torch.allclose(friction_viscous_coeff_sim_2, viscous_friction_2.cpu()) - assert torch.allclose(friction_dynamic_coef_sim_2, dynamic_friction_2.cpu()) - assert torch.allclose(joint_friction_coeff_sim_2, friction_2.cpu()) - - -@pytest.mark.parametrize("add_ground_plane", [True]) -@pytest.mark.parametrize("num_articulations", [1, 2]) -@pytest.mark.parametrize("device", test_devices()) -@pytest.mark.parametrize("articulation_type", ["panda"]) -def test_set_material_properties(sim, num_articulations, device, add_ground_plane, articulation_type): - """Test getting and setting material properties (friction/restitution) of articulation shapes.""" - articulation_cfg = generate_articulation_cfg(articulation_type=articulation_type) - articulation, _ = generate_articulation( - articulation_cfg=articulation_cfg, num_articulations=num_articulations, device=device - ) - - # Play the simulator - sim.reset() - - # Get number of shapes from the articulation - max_shapes = articulation.root_view.max_shapes - - # Generate random material properties: (static_friction, dynamic_friction, restitution) - materials = torch.empty(num_articulations, max_shapes, 3, device="cpu").uniform_(0.0, 1.0) - # Ensure dynamic friction <= static friction - materials[..., 1] = torch.min(materials[..., 0], materials[..., 1]) - - # Set material properties via the PhysX view-level API - env_ids = torch.arange(num_articulations, dtype=torch.int32) - articulation.root_view.set_material_properties( - wp.from_torch(materials, dtype=wp.float32), wp.from_torch(env_ids, dtype=wp.int32) - ) - - # Simulate physics - sim.step() - articulation.update(sim.cfg.dt) - - # Get material properties from simulation - materials_check = wp.to_torch(articulation.root_view.get_material_properties()) - - # Check if material properties are set correctly - torch.testing.assert_close(materials_check, materials) - ## # Shape-contract regression tests for the new BaseArticulation accessors. @@ -2528,75 +2032,6 @@ def test_set_material_properties(sim, num_articulations, device, add_ground_plan ## -@pytest.mark.parametrize("num_articulations", [1, 4]) -@pytest.mark.parametrize("device", test_devices(DeviceScope.CUDA)) -@pytest.mark.parametrize("articulation_type", ["panda"]) -@pytest.mark.isaacsim_ci -def test_get_jacobians_shape_fixed_base(sim, num_articulations, device, articulation_type): - """PhysX reference: fixed-base ``body_link_jacobian_w`` is ``(N, num_bodies-1, 6, num_joints)``.""" - articulation_cfg = generate_articulation_cfg(articulation_type=articulation_type) - articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=device) - sim.reset() - assert articulation.is_initialized - assert articulation.is_fixed_base - - J = articulation.data.body_link_jacobian_w.torch - expected = (num_articulations, articulation.num_bodies - 1, 6, articulation.num_joints) - assert J.shape == torch.Size(expected), f"expected {expected}, got {tuple(J.shape)}" - - -@pytest.mark.parametrize("num_articulations", [1, 4]) -@pytest.mark.parametrize("device", test_devices(DeviceScope.CUDA)) -@pytest.mark.parametrize("articulation_type", ["panda"]) -@pytest.mark.isaacsim_ci -def test_get_mass_matrix_shape_and_nonsingular_fixed_base(sim, num_articulations, device, articulation_type): - """PhysX reference: fixed-base ``mass_matrix`` shape + non-singular.""" - articulation_cfg = generate_articulation_cfg(articulation_type=articulation_type) - articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=device) - sim.reset() - assert articulation.is_initialized - - sim.step() - articulation.update(sim.cfg.dt) - - M = articulation.data.mass_matrix.torch - expected = (num_articulations, articulation.num_joints, articulation.num_joints) - assert M.shape == torch.Size(expected), f"expected {expected}, got {tuple(M.shape)}" - - # Each diagonal entry is the joint's effective inertia and must be positive - # for any physical articulation. Padded zero rows/cols (the bug) would show - # up here as zero diagonal entries — much more sensitive than checking the - # determinant, which can be small for a well-conditioned 9x9 just from - # numerical cancellation. - diag = M.diagonal(dim1=-2, dim2=-1) - assert (diag > 1e-6).all(), f"mass matrix has non-positive diagonal entries: min={diag.min()}" - - -@pytest.mark.parametrize("num_articulations", [1, 4]) -@pytest.mark.parametrize("device", test_devices(DeviceScope.CUDA)) -@pytest.mark.parametrize("add_ground_plane", [True]) -@pytest.mark.parametrize("articulation_type", ["anymal"]) -@pytest.mark.isaacsim_ci -def test_get_jacobians_shape_floating_base(sim, num_articulations, device, add_ground_plane, articulation_type): - """PhysX reference: floating-base ``body_link_jacobian_w``. - - Floating-base articulations include the 6 floating-base spatial-velocity columns - at the front of the DoF axis, so the shape is - ``(N, num_bodies, 6, num_joints + num_base_dofs)`` — matching Newton and the - cross-library industry convention (Pinocchio, Drake, MuJoCo, RBDL, OCS2, - iDynTree). - """ - articulation_cfg = generate_articulation_cfg(articulation_type=articulation_type) - articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=device) - sim.reset() - assert articulation.is_initialized - assert not articulation.is_fixed_base - - J = articulation.data.body_link_jacobian_w.torch - expected = (num_articulations, articulation.num_bodies, 6, articulation.num_joints + articulation.num_base_dofs) - assert J.shape == torch.Size(expected), f"expected {expected}, got {tuple(J.shape)}" - - @pytest.mark.parametrize("num_articulations", [4]) @pytest.mark.parametrize("device", test_devices(DeviceScope.CUDA)) @pytest.mark.parametrize("articulation_type", ["panda", "anymal"]) @@ -2648,39 +2083,18 @@ def test_get_jacobians_link_origin_contract(sim, num_articulations, device, arti torch.testing.assert_close(v_pred[..., 3:6], body_ang_w, atol=1.5e-1, rtol=5e-2) torch.testing.assert_close(v_pred[..., 0:3], body_lin_w, atol=1.5e-1, rtol=5e-2) + # Shape contract: fixed-base Jacobians omit the root body; floating-base ones prepend the base DoFs. + num_dofs = articulation.num_joints + articulation.num_base_dofs + num_jacobian_bodies = articulation.num_bodies - 1 if articulation.is_fixed_base else articulation.num_bodies + J_full = articulation.data.body_link_jacobian_w.torch + assert J_full.shape == (num_articulations, num_jacobian_bodies, 6, num_dofs) -@pytest.mark.parametrize("num_articulations", [4]) -@pytest.mark.parametrize("device", test_devices(DeviceScope.CUDA)) -@pytest.mark.parametrize("articulation_type", ["panda", "anymal"]) -@pytest.mark.parametrize("gravity_enabled", [False]) -@pytest.mark.isaacsim_ci -def test_get_mass_matrix_symmetry_pd(sim, num_articulations, device, articulation_type, gravity_enabled): - """The joint-space mass matrix ``M(q)`` must be square, symmetric, and positive-definite. - - Mirrors the Newton-side test in - ``source/isaaclab_newton/test/assets/test_articulation.py``. Pins - three structural properties of :attr:`~isaaclab.assets.BaseArticulationData.mass_matrix` - that every backend must satisfy. Both backends include the 6 floating-base - rows/cols on floating-base assets (matching the cross-library industry - convention); this test cares about square + symmetric + PD across both - fixed- and floating-base, not the absolute column count. - """ - articulation_cfg = generate_articulation_cfg(articulation_type=articulation_type) - articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=device) - sim.reset() - assert articulation.is_initialized - - sim.step() - articulation.update(sim.cfg.dt) - - M = articulation.data.mass_matrix.torch # (N, J, J) - assert M.dim() == 3, f"expected 3-D mass matrix, got shape {tuple(M.shape)}" - assert M.shape[0] == num_articulations - assert M.shape[1] == M.shape[2], f"mass matrix is not square: {tuple(M.shape)}" - + # The joint-space mass matrix is square over all DoFs, symmetric, and positive definite. + M = articulation.data.mass_matrix.torch + assert M.shape == (num_articulations, num_dofs, num_dofs) + assert (M.diagonal(dim1=-2, dim2=-1) > 1e-6).all() asym = (M - M.transpose(-1, -2)).abs().max().item() assert asym < 1e-4, f"|M - M^T|_max = {asym:.3e} — mass matrix is not symmetric" - eye = torch.eye(M.shape[-1], device=M.device, dtype=M.dtype).expand_as(M) torch.linalg.cholesky(M + 1e-6 * eye) @@ -2690,84 +2104,23 @@ def test_get_mass_matrix_symmetry_pd(sim, num_articulations, device, articulatio @pytest.mark.parametrize("articulation_type", ["panda", "anymal"]) @pytest.mark.parametrize("gravity_enabled", [False]) @pytest.mark.isaacsim_ci -def test_jacobian_refreshes_after_manual_joint_write( - sim, num_articulations, device, articulation_type, gravity_enabled -): - """After ``write_joint_position_to_sim_index`` (no sim step), the Jacobian read - must reflect the new joint state — not the previous one. - - PhysX-side counterpart to the Newton test of the same name. PhysX's - :attr:`body_link_jacobian_w` triggers FK indirectly through - :attr:`body_link_pose_w` (used by the shift kernel); :attr:`body_com_jacobian_w` is - a passthrough to ``_root_view.get_jacobians()``. This test confirms that PhysX's - tensor view returns up-to-date Jacobians after a manual joint write — i.e., that - PhysX internally refreshes FK on ``get_jacobians`` (or that our property does). - Failure means we need to add ``update_articulations_kinematic()`` before the - passthrough. - """ +def test_dynamics_refresh_after_manual_joint_write(sim, num_articulations, device, articulation_type, gravity_enabled): + """Each Jacobian and mass-matrix read independently reflects a manual joint write without stepping.""" articulation_cfg = generate_articulation_cfg(articulation_type=articulation_type) articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=device) sim.reset() sim.step() articulation.update(sim.cfg.dt) - # Read J at the baseline joint state. - J_link_0 = articulation.data.body_link_jacobian_w.torch.clone() - J_com_0 = articulation.data.body_com_jacobian_w.torch.clone() - - # Manually write a different joint state — large delta to make the change visible. - # No sim.step / update — FK becomes stale. - q_target = articulation.data.joint_pos.torch.clone() + 0.5 + q_initial = articulation.data.joint_pos.torch.clone() env_ids = wp.array([0], dtype=wp.int32, device=device) - articulation.write_joint_position_to_sim_index(position=q_target, env_ids=env_ids) - - # Read J again. With the FK trigger, J reflects q_target and differs from J at baseline. - # Without the trigger, body_q stays at baseline, J unchanged. - J_link_1 = articulation.data.body_link_jacobian_w.torch.clone() - J_com_1 = articulation.data.body_com_jacobian_w.torch.clone() - - assert not torch.allclose(J_link_0, J_link_1, atol=1e-3), ( - "body_link_jacobian_w did not change after manual joint write — " - "FK trigger likely missing (eval_jacobian / shift kernel reading stale state.body_q)." - ) - assert not torch.allclose(J_com_0, J_com_1, atol=1e-3), ( - "body_com_jacobian_w did not change after manual joint write — " - "PhysX get_jacobians may not auto-refresh FK; consider adding update_articulations_kinematic()." - ) - - -@pytest.mark.parametrize("num_articulations", [1]) -@pytest.mark.parametrize("device", test_devices(DeviceScope.CUDA)) -@pytest.mark.parametrize("articulation_type", ["panda", "anymal"]) -@pytest.mark.parametrize("gravity_enabled", [False]) -@pytest.mark.isaacsim_ci -def test_mass_matrix_refreshes_after_manual_joint_write( - sim, num_articulations, device, articulation_type, gravity_enabled -): - """After ``write_joint_position_to_sim_index`` (no sim step), the mass matrix read - must reflect the new joint state. - - PhysX-side counterpart. :attr:`mass_matrix` is a passthrough to - ``_root_view.get_generalized_mass_matrices()``. Failure means PhysX's tensor view - does not auto-refresh FK on this getter, and we need to add - ``update_articulations_kinematic()`` before the passthrough. - """ - articulation_cfg = generate_articulation_cfg(articulation_type=articulation_type) - articulation, _ = generate_articulation(articulation_cfg, num_articulations, device=device) - sim.reset() - sim.step() - articulation.update(sim.cfg.dt) - - M_0 = articulation.data.mass_matrix.torch.clone() - q_target = articulation.data.joint_pos.torch.clone() + 0.5 - env_ids = wp.array([0], dtype=wp.int32, device=device) - articulation.write_joint_position_to_sim_index(position=q_target, env_ids=env_ids) - M_1 = articulation.data.mass_matrix.torch.clone() - - assert not torch.allclose(M_0, M_1, atol=1e-3), ( - "mass_matrix did not change after manual joint write — " - "PhysX get_generalized_mass_matrices may not auto-refresh FK." - ) + for property_name in ("body_link_jacobian_w", "body_com_jacobian_w", "mass_matrix"): + articulation.write_joint_position_to_sim_index(position=q_initial, env_ids=env_ids) + before = getattr(articulation.data, property_name).torch.clone() + # A separate write for each getter prevents another getter from refreshing FK on its behalf. + articulation.write_joint_position_to_sim_index(position=q_initial + 0.5, env_ids=env_ids) + after = getattr(articulation.data, property_name).torch.clone() + assert not torch.allclose(before, after, atol=1e-3), f"{property_name} stayed stale after a joint write" @pytest.mark.parametrize("num_articulations", [1]) @@ -2914,236 +2267,5 @@ def test_franka_ik_tracking_accuracy(sim, device, articulation_type, gravity_ena assert rot_mean < 5e-2, f"IK rot_mean {rot_mean:.5f} > 0.05 rad — bridge regression?" -@pytest.mark.parametrize("device", test_devices(DeviceScope.CUDA)) -@pytest.mark.parametrize("articulation_type", ["panda"]) -@pytest.mark.parametrize("gravity_enabled", [False]) -@pytest.mark.isaacsim_ci -def test_franka_osc_tracking_accuracy(sim, device, articulation_type, gravity_enabled): - """PhysX-side OSC pose tracking sentinel — backend parity with Newton. - - Mirrors :func:`isaaclab_newton.test.assets.test_articulation.test_franka_osc_tracking_accuracy`. - Zero out the actuator's PD gains so OSC's joint-effort output is - not opposed by the implicit-PD term, matching the Newton test setup. - """ - robot, ee_frame_idx, ee_jacobi_idx, arm_joint_ids = _setup_franka_at_home_pose(sim, zero_actuator_pd=True) - - osc = OperationalSpaceController( - OperationalSpaceControllerCfg( - target_types=["pose_abs"], - impedance_mode="fixed", - inertial_dynamics_decoupling=True, - partial_inertial_dynamics_decoupling=False, - gravity_compensation=False, - motion_stiffness_task=500.0, - motion_damping_ratio_task=1.0, - ), - num_envs=1, - device=device, - ) - - sim.step() - robot.update(sim.cfg.dt) - target_pose_b = _build_relative_pose_target(robot, ee_frame_idx, (0.05, 0.0, 0.0), device) - - pos_history: list[float] = [] - rot_history: list[float] = [] - for _ in range(800): - jacobian_b = _compute_jacobian_root_frame(robot, ee_jacobi_idx, arm_joint_ids) - mass_matrix = robot.data.mass_matrix.torch[:, arm_joint_ids, :][:, :, arm_joint_ids] - ee_pos_b, ee_quat_b, _ = _compute_ee_pose_root(robot, ee_frame_idx) - ee_pose_b = torch.cat([ee_pos_b, ee_quat_b], dim=-1) - joint_vel = robot.data.joint_vel.torch[:, arm_joint_ids] - ee_vel_b = _compute_ee_vel_root(jacobian_b, joint_vel) - - osc.set_command(target_pose_b, current_ee_pose_b=ee_pose_b) - joint_efforts = osc.compute( - jacobian_b=jacobian_b, - current_ee_pose_b=ee_pose_b, - current_ee_vel_b=ee_vel_b, - mass_matrix=mass_matrix, - gravity=None, - ) - - robot.set_joint_effort_target(joint_efforts, joint_ids=arm_joint_ids) - robot.write_data_to_sim() - sim.step() - robot.update(sim.cfg.dt) - - pos_error, rot_error = compute_pose_error(ee_pos_b, ee_quat_b, target_pose_b[:, 0:3], target_pose_b[:, 3:7]) - pos_history.append(pos_error.norm(dim=-1).max().item()) - rot_history.append(rot_error.norm(dim=-1).max().item()) - - pos_min, pos_mean = _summarize_history(pos_history) - rot_min, rot_mean = _summarize_history(rot_history) - - print(f"OSC_METRIC pos_min={pos_min:.5f} pos_mean={pos_mean:.5f} rot_min={rot_min:.5f} rot_mean={rot_mean:.5f}") - - # Assert on tail mean. Threshold matched to the Newton-side test - # (5 mm / 0.05 rad). Both backends converge to machine precision - # with proper ee-velocity feedback (``J · q_dot``). - assert pos_mean < 5e-3, f"OSC pos_mean {pos_mean:.5f} > 5 mm — bridge regression?" - assert rot_mean < 5e-2, f"OSC rot_mean {rot_mean:.5f} > 0.05 rad — bridge regression?" - - -def _run_osc_stay_still_under_gravity( - sim, - device: str, - *, - gravity_compensation_enabled: bool, - num_steps: int = 100, -): - """Run OSC with a stay-still target on Franka under gravity, return EE drift summary. - - Shared helper for the gravity-comp tests. Setup mirrors - :func:`test_franka_osc_tracking_accuracy` (zero actuator PD so OSC's joint-effort - output is not opposed by an implicit-PD spring), but with scene gravity ON and the - target = the EE pose captured after the first sim step (which already includes a - fraction-of-a-mm of gravity-induced motion; that's the baseline drift starts from). - - Args: - gravity_compensation_enabled: If ``True``, the OSC controller cfg has - ``gravity_compensation=True`` and ``osc.compute(gravity=g(q))`` receives - the data-layer ``gravity_compensation_forces`` slice. If ``False``, - ``gravity_compensation=False`` and ``gravity=None``. - - Returns: - Tuple ``((pos_min, pos_mean), (rot_min, rot_mean))`` over the last 20% of - steps (per :func:`_summarize_history`), where ``pos`` is in meters and - ``rot`` in radians. - """ - # Enable rigid-body gravity so the arm actually feels weight. - # ``FRANKA_PANDA_HIGH_PD_CFG`` defaults ``disable_gravity=True`` for IK/OSC tests. - robot, ee_frame_idx, ee_jacobi_idx, arm_joint_ids = _setup_franka_at_home_pose( - sim, zero_actuator_pd=True, enable_rigid_body_gravity=True - ) - - osc = OperationalSpaceController( - OperationalSpaceControllerCfg( - target_types=["pose_abs"], - impedance_mode="fixed", - inertial_dynamics_decoupling=True, - partial_inertial_dynamics_decoupling=False, - gravity_compensation=gravity_compensation_enabled, - motion_stiffness_task=500.0, - motion_damping_ratio_task=1.0, - ), - num_envs=1, - device=device, - ) - - sim.step() - robot.update(sim.cfg.dt) - - # Stay-still target = current EE pose in root frame, captured right after the - # first step. The OSC loop must hold this pose under gravity. - initial_ee_pos_b, initial_ee_quat_b, _ = _compute_ee_pose_root(robot, ee_frame_idx) - target_pose_b = torch.cat([initial_ee_pos_b, initial_ee_quat_b], dim=-1) - - pos_history: list[float] = [] - rot_history: list[float] = [] - for _ in range(num_steps): - jacobian_b = _compute_jacobian_root_frame(robot, ee_jacobi_idx, arm_joint_ids) - mass_matrix = robot.data.mass_matrix.torch[:, arm_joint_ids, :][:, :, arm_joint_ids] - ee_pos_b, ee_quat_b, _ = _compute_ee_pose_root(robot, ee_frame_idx) - ee_pose_b = torch.cat([ee_pos_b, ee_quat_b], dim=-1) - joint_vel = robot.data.joint_vel.torch[:, arm_joint_ids] - ee_vel_b = _compute_ee_vel_root(jacobian_b, joint_vel) - - # ``gravity_compensation_forces`` shape is ``(N, num_joints + num_base_dofs)``; - # slice past the leading floating-base columns (0 for fixed-base Franka, so a - # no-op here, but the pattern matches the action-term convention). - gravity = ( - robot.data.gravity_compensation_forces.torch[:, [j + robot.num_base_dofs for j in arm_joint_ids]] - if gravity_compensation_enabled - else None - ) - - osc.set_command(target_pose_b, current_ee_pose_b=ee_pose_b) - joint_efforts = osc.compute( - jacobian_b=jacobian_b, - current_ee_pose_b=ee_pose_b, - current_ee_vel_b=ee_vel_b, - mass_matrix=mass_matrix, - gravity=gravity, - ) - robot.set_joint_effort_target(joint_efforts, joint_ids=arm_joint_ids) - robot.write_data_to_sim() - sim.step() - robot.update(sim.cfg.dt) - - pos_error, rot_error = compute_pose_error(ee_pos_b, ee_quat_b, target_pose_b[:, 0:3], target_pose_b[:, 3:7]) - pos_history.append(pos_error.norm(dim=-1).max().item()) - rot_history.append(rot_error.norm(dim=-1).max().item()) - - return _summarize_history(pos_history), _summarize_history(rot_history) - - -@pytest.mark.parametrize("device", test_devices(DeviceScope.CUDA)) -@pytest.mark.parametrize("articulation_type", ["panda"]) -@pytest.mark.parametrize("gravity_enabled", [True]) -@pytest.mark.isaacsim_ci -def test_franka_osc_gravity_compensation_holds_under_gravity(sim, device, articulation_type, gravity_enabled): - """OSC with ``gravity_compensation=True`` must hold the EE pose under gravity. - - With scene gravity ON and zero actuator PD (so OSC torques are not opposed by an - implicit-PD spring), passing - :attr:`~isaaclab.assets.BaseArticulationData.gravity_compensation_forces` through - ``osc.compute(gravity=...)`` should keep the arm at the initial pose. - - Pins three things that the existing direct-primitive - :func:`test_get_gravity_compensation_forces_static_equilibrium` does not: - 1. OSC's ``_jacobi_joint_idx`` indexing — the ``+ num_base_dofs`` shift. - 2. OSC's :meth:`OperationalSpaceController.compute` correctly adds ``g(q)`` to - its torque output. - 3. The data-property ``gravity_compensation_forces`` is reachable from the OSC - pipeline (catches gating regressions in - :meth:`OperationalSpaceControllerAction._compute_dynamic_quantities`). - - Companion test :func:`test_franka_osc_no_gravity_compensation_sags_under_gravity` - runs the same setup with ``gravity_compensation=False`` and reports the - uncompensated drift magnitude — a sanity check that gravity is loading the arm. - """ - (pos_min, pos_mean), (rot_min, rot_mean) = _run_osc_stay_still_under_gravity( - sim, device, gravity_compensation_enabled=True - ) - print(f"OSC_GC_ON pos_min={pos_min:.5f} pos_mean={pos_mean:.5f} rot_min={rot_min:.5f} rot_mean={rot_mean:.5f}") - - assert pos_mean < 5e-3, f"OSC + gravity_compensation pos_mean {pos_mean:.5f} > 5 mm — regression?" - assert rot_mean < 5e-2, f"OSC + gravity_compensation rot_mean {rot_mean:.5f} > 0.05 rad — regression?" - - -@pytest.mark.parametrize("device", test_devices(DeviceScope.CUDA)) -@pytest.mark.parametrize("articulation_type", ["panda"]) -@pytest.mark.parametrize("gravity_enabled", [True]) -@pytest.mark.isaacsim_ci -def test_franka_osc_no_gravity_compensation_sags_under_gravity(sim, device, articulation_type, gravity_enabled): - """OSC without ``gravity_compensation`` under gravity: sanity check that the arm sags. - - Companion to :func:`test_franka_osc_gravity_compensation_holds_under_gravity`. - Same setup, but ``gravity_compensation=False`` and ``osc.compute(gravity=None)``. - With zero actuator PD, OSC's task-space impedance is the only restoring force — - the steady-state solution is whatever pose error the impedance produces enough - joint torque to balance ``g(q)``. - - Asserts the drift is **non-trivially larger** than the with-comp threshold (5 mm). - Without this check, a regression that broke ``gravity_compensation_forces`` by - returning zeros (or a no-op `g(q)`) would pass the with-comp test silently. The - bound here proves gravity is actually loading the arm and the with-comp pass is - meaningful. - """ - (pos_min, pos_mean), (rot_min, rot_mean) = _run_osc_stay_still_under_gravity( - sim, device, gravity_compensation_enabled=False - ) - print(f"OSC_GC_OFF pos_min={pos_min:.5f} pos_mean={pos_mean:.5f} rot_min={rot_min:.5f} rot_mean={rot_mean:.5f}") - - # Sanity: with gravity on and no comp, OSC's task-space spring vs gravity-load - # equilibrium produces a non-zero pose error. If this asserts fails, the test - # setup itself is broken (e.g., gravity is not on, or the home pose has no - # gravity load), which would invalidate the with-comp test as well. - assert pos_mean > 5e-3, ( - f"OSC + no gravity_compensation pos_mean {pos_mean:.5f} ≤ 5 mm — gravity not loading the arm?" - ) - - if __name__ == "__main__": pytest.main([__file__, "-v", "--maxfail=1"]) diff --git a/source/isaaclab_physx/test/assets/test_newton_actuators_physx.py b/source/isaaclab_physx/test/assets/test_newton_actuators_physx.py index 0eb73dfc2c20..bf5555d5e7bc 100644 --- a/source/isaaclab_physx/test/assets/test_newton_actuators_physx.py +++ b/source/isaaclab_physx/test/assets/test_newton_actuators_physx.py @@ -262,12 +262,6 @@ def _run_simulation( } -def test_graphable_newton_actuators_capture_ping_pong_graphs() -> None: - result = _run_simulation(DELAYED_PD_ACTUATORS, use_newton_actuators=True, num_steps=2) - - assert result["native_actuator_graph_count"] == 2 - - def test_newton_actuator_graph_capture_failure_falls_back_to_eager(monkeypatch: pytest.MonkeyPatch) -> None: class FailingCapture: def __init__(self, *args, **kwargs): @@ -409,11 +403,6 @@ def to_user_order(raw_backend: wp.array) -> torch.Tensor: articulation.update(DT) -def test_newton_actuator_identity_ordering_uses_current_joint_state() -> None: - """Sanity check: with identity joint ordering, ``applied_effort`` always reflects this step's state.""" - _assert_newton_actuator_uses_current_joint_state(None) - - def test_newton_actuator_reversed_ordering_uses_current_joint_state() -> None: """Regression test: non-identity joint ordering must preserve current-state torque evaluation. @@ -440,20 +429,11 @@ class _EquivalenceTestBase(EquivalenceAssertionsMixin, unittest.TestCase): __test__ = False actuators: dict = {} - feedforward: float | None = None @classmethod def setUpClass(cls): - cls.lab_result = _run_simulation( - cls.actuators, - use_newton_actuators=False, - feedforward=cls.feedforward, - ) - cls.newton_result = _run_simulation( - cls.actuators, - use_newton_actuators=True, - feedforward=cls.feedforward, - ) + cls.lab_result = _run_simulation(cls.actuators, use_newton_actuators=False) + cls.newton_result = _run_simulation(cls.actuators, use_newton_actuators=True) # --------------------------------------------------------------------------- @@ -485,6 +465,10 @@ class TestDelayedPDEquivalence(_EquivalenceTestBase): __test__ = True actuators = DELAYED_PD_ACTUATORS + def test_native_graph_count(self): + """Graphable Newton actuators capture the two ping-pong graphs.""" + self.assertEqual(self.newton_result["native_actuator_graph_count"], 2) + class TestMixedWithImplicitEquivalence(_EquivalenceTestBase): """Implicit HAA + IdealPD HFE + DCMotor KFE: Lab vs Newton (PhysX). @@ -497,19 +481,6 @@ class TestMixedWithImplicitEquivalence(_EquivalenceTestBase): actuators = MIXED_WITH_IMPLICIT_ACTUATORS -# --------------------------------------------------------------------------- -# Implicit + non-zero feedforward effort target on PhysX -# --------------------------------------------------------------------------- - - -class TestImplicitWithFeedforwardEquivalencePhysx(_EquivalenceTestBase): - """Implicit-only actuators with a non-zero feedforward effort target on PhysX.""" - - __test__ = True - actuators = IMPLICIT_ONLY_ACTUATORS - feedforward = 5.0 - - # --------------------------------------------------------------------------- # Heterogeneous multi-articulation (ANYmal floating-base + Cartpole fixed-base) # --------------------------------------------------------------------------- @@ -670,55 +641,6 @@ def test_implicit_storage_reuses_randomized_payload(self): torch.testing.assert_close(actuator.stiffness[1:], stiffness_before[1:]) torch.testing.assert_close(actuator.damping[1:], damping_before[1:]) - def test_single_articulation(self): - sim_cfg = SimulationCfg(dt=DT, physics=PhysxCfg(), use_newton_actuators=True) - with build_simulation_context( - device="cuda:0", - gravity_enabled=True, - add_ground_plane=True, - sim_cfg=sim_cfg, - ) as sim: - sim._app_control_on_stop_handle = None - for i in range(NUM_ENVS): - sim_utils.create_prim(f"/World/Env_{i}", "Xform", translation=(i * 3.0, 0, 0)) - art_cfg = ANYMAL_C_CFG.replace( - actuators=IDEAL_PD_ACTUATORS, - prim_path="/World/Env_[^/]*/Robot", - ) - anymal = Articulation(art_cfg) - sim.reset() - - adapter = anymal.newton_actuator_adapter - self.assertIsNotNone(adapter, "PhysX per-articulation adapter should exist") - read = functools.partial(read_group_parameter, anymal.actuators) - n = anymal.num_joints - kp_before = read("legs", "controller", "kp").clone() - kd_before = read("legs", "controller", "kd").clone() - - env = MockEnv({"robot": anymal}, NUM_ENVS, anymal.device) - term, asset_cfg = build_dr_term(env, "robot") - env_ids = torch.tensor([0], device=anymal.device, dtype=torch.long) - - term( - env, - env_ids=env_ids, - asset_cfg=asset_cfg, - stiffness_distribution_params=(100.0, 100.0), - damping_distribution_params=(5.0, 5.0), - operation="abs", - distribution="uniform", - ) - - # Named native-group reads project the controller values immediately. - torch.testing.assert_close( - read("legs", "controller", "kp")[0], torch.full((n,), 100.0, device=anymal.device) - ) - torch.testing.assert_close(read("legs", "controller", "kd")[0], torch.full((n,), 5.0, device=anymal.device)) - # Other envs untouched. - for env_idx in range(1, NUM_ENVS): - torch.testing.assert_close(read("legs", "controller", "kp")[env_idx], kp_before[env_idx]) - torch.testing.assert_close(read("legs", "controller", "kd")[env_idx], kd_before[env_idx]) - def test_two_articulations(self): from isaaclab_assets import CARTPOLE_CFG # noqa: PLC0415 @@ -851,6 +773,20 @@ def setUpClass(cls): # --------------------------------------------------------------------------- +def _assert_network_drives_haa_joints(test: unittest.TestCase, result: dict) -> None: + """Assert that the network actuator produces finite, non-zero HAA efforts that follow the joint state.""" + haa_ids = [i for i, name in enumerate(result["joint_names"]) if name.endswith("HAA")] + test.assertEqual(len(haa_ids), 4) + for step_i, (pos, effort) in enumerate(zip(result["joint_pos"], result["applied_effort"])): + test.assertTrue(torch.isfinite(pos).all(), f"Non-finite positions at step {step_i}") + haa_effort = effort[:, haa_ids] + test.assertTrue(torch.all(haa_effort != 0.0), f"Network produced zero HAA effort at step {step_i}") + # A constant output (e.g. only the output-layer bias) would mean the network is not fed the moving joint state. + first_effort = result["applied_effort"][0][:, haa_ids] + last_effort = result["applied_effort"][-1][:, haa_ids] + test.assertFalse(torch.allclose(first_effort, last_effort), "Network HAA effort did not follow the joint state") + + class TestNeuralMLPFunctional(unittest.TestCase): """Verify ActuatorNetMLPCfg runs on PhysX with Newton actuators.""" @@ -887,12 +823,8 @@ def setUpClass(cls): def tearDownClass(cls): os.unlink(cls.mlp_path) - def test_positions_finite(self): - for step_i, pos in enumerate(self.result["joint_pos"]): - self.assertTrue( - torch.isfinite(pos).all(), - f"Non-finite positions at step {step_i}", - ) + def test_network_drives_haa_joints(self): + _assert_network_drives_haa_joints(self, self.result) class TestNeuralLSTMFunctional(unittest.TestCase): @@ -926,12 +858,8 @@ def setUpClass(cls): def tearDownClass(cls): os.unlink(cls.lstm_path) - def test_positions_finite(self): - for step_i, pos in enumerate(self.result["joint_pos"]): - self.assertTrue( - torch.isfinite(pos).all(), - f"Non-finite positions at step {step_i}", - ) + def test_network_drives_haa_joints(self): + _assert_network_drives_haa_joints(self, self.result) if __name__ == "__main__": diff --git a/source/isaaclab_physx/test/assets/test_rigid_object.py b/source/isaaclab_physx/test/assets/test_rigid_object.py index 9c4257de4584..2322f6f59c27 100644 --- a/source/isaaclab_physx/test/assets/test_rigid_object.py +++ b/source/isaaclab_physx/test/assets/test_rigid_object.py @@ -35,9 +35,7 @@ combine_frame_transforms, default_orientation, quat_apply_inverse, - quat_inv, quat_mul, - quat_rotate, random_orientation, ) @@ -133,7 +131,7 @@ def test_initialization(num_cubes, device): cube_object.update(sim.cfg.dt) -@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("num_cubes", [2]) @pytest.mark.parametrize("device", test_devices()) @pytest.mark.isaacsim_ci def test_initialization_with_kinematic_enabled(num_cubes, device): @@ -171,7 +169,7 @@ def test_initialization_with_kinematic_enabled(num_cubes, device): torch.testing.assert_close(cube_object.data.root_com_vel_w.torch, default_root_vel) -@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("num_cubes", [2]) @pytest.mark.parametrize("device", test_devices()) @pytest.mark.isaacsim_ci def test_initialization_with_no_rigid_body(num_cubes, device): @@ -189,7 +187,7 @@ def test_initialization_with_no_rigid_body(num_cubes, device): sim.reset() -@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("num_cubes", [2]) @pytest.mark.parametrize("device", test_devices()) @pytest.mark.isaacsim_ci def test_initialization_with_articulation_root(num_cubes, device): @@ -207,256 +205,7 @@ def test_initialization_with_articulation_root(num_cubes, device): sim.reset() -@pytest.mark.parametrize("device", test_devices()) -@pytest.mark.isaacsim_ci -def test_external_force_buffer(device): - """Test if external force buffer correctly updates in the force value is zero case. - - In this test, we apply a non-zero force, then a zero force, then finally a non-zero force - to an object. We check if the force buffer is properly updated at each step. - """ - - # Generate cubes scene - with build_simulation_context(device=device, add_ground_plane=True, auto_add_lighting=True) as sim: - sim._app_control_on_stop_handle = None - cube_object, origins = generate_cubes_scene(num_cubes=1, device=device) - - # play the simulator - sim.reset() - - # find bodies to apply the force - body_ids, body_names = cube_object.find_bodies(".*") - - # reset object - cube_object.reset() - - # perform simulation - for step in range(5): - # initiate force tensor - external_wrench_b = torch.zeros(cube_object.num_instances, len(body_ids), 6, device=sim.device) - - if step == 0 or step == 3: - # set a non-zero force - force = 1 - else: - # set a zero force - force = 0 - - # set force value - external_wrench_b[:, :, 0] = force - external_wrench_b[:, :, 3] = force - - # apply force - cube_object.permanent_wrench_composer.set_forces_and_torques_index( - forces=external_wrench_b[..., :3], - torques=external_wrench_b[..., 3:], - body_ids=body_ids, - ) - - # check if the cube's force and torque buffers are correctly updated - for i in range(cube_object.num_instances): - assert cube_object._permanent_wrench_composer.out_force_b.torch[i, 0, 0].item() == force - assert cube_object._permanent_wrench_composer.out_torque_b.torch[i, 0, 0].item() == force - - # Check if the instantaneous wrench is correctly added to the permanent wrench - cube_object.permanent_wrench_composer.add_forces_and_torques_index( - forces=external_wrench_b[..., :3], - torques=external_wrench_b[..., 3:], - body_ids=body_ids, - ) - - # apply action to the object - cube_object.write_data_to_sim() - - # perform step - sim.step() - - # update buffers - cube_object.update(sim.cfg.dt) - - -@pytest.mark.parametrize("num_cubes", [2, 4]) -@pytest.mark.parametrize("device", test_devices()) -@pytest.mark.isaacsim_ci -def test_external_force_on_single_body(num_cubes, device): - """Test application of external force on the base of the object. - - In this test, we apply a force equal to the weight of an object on the base of - one of the objects. We check that the object does not move. For the other object, - we do not apply any force and check that it falls down. - - We validate that this works when we apply the force in the global frame and in the local frame. - """ - # Generate cubes scene - with build_simulation_context(device=device, add_ground_plane=True, auto_add_lighting=True) as sim: - sim._app_control_on_stop_handle = None - cube_object, origins = generate_cubes_scene(num_cubes=num_cubes, device=device) - - # Play the simulator - sim.reset() - - # Find bodies to apply the force - body_ids, body_names = cube_object.find_bodies(".*") - - # Sample a force equal to the weight of the object - external_wrench_b = torch.zeros(cube_object.num_instances, len(body_ids), 6, device=sim.device) - # Every 2nd cube should have a force applied to it - external_wrench_b[0::2, :, 2] = 9.81 * wp.to_torch(cube_object.root_view.get_masses())[0] - - # Now we are ready! - for i in range(5): - # reset root state - root_pose = cube_object.data.default_root_pose.torch.clone() - root_vel = cube_object.data.default_root_vel.torch.clone() - - # need to shift the position of the cubes otherwise they will be on top of each other - root_pose[:, :3] = origins - cube_object.write_root_pose_to_sim_index(root_pose=root_pose) - cube_object.write_root_velocity_to_sim_index(root_velocity=root_vel) - - # reset object - cube_object.reset() - - is_global = False - if i % 2 == 0: - is_global = True - positions = cube_object.data.body_com_pos_w.torch[:, body_ids, :3] - else: - positions = None - - # apply force - cube_object.permanent_wrench_composer.set_forces_and_torques_index( - forces=external_wrench_b[..., :3], - torques=external_wrench_b[..., 3:], - positions=positions, - body_ids=body_ids, - is_global=is_global, - ) - # perform simulation - for _ in range(5): - # apply action to the object - cube_object.write_data_to_sim() - - # perform step - sim.step() - - # update buffers - cube_object.update(sim.cfg.dt) - - # First object should still be at the same Z position (1.0) - torch.testing.assert_close( - cube_object.data.root_pos_w.torch[0::2, 2], torch.ones(num_cubes // 2, device=sim.device) - ) - # Second object should have fallen, so it's Z height should be less than initial height of 1.0 - assert torch.all(cube_object.data.root_pos_w.torch[1::2, 2] < 1.0) - - -@pytest.mark.parametrize("num_cubes", [2, 4]) -@pytest.mark.parametrize("device", test_devices()) -def test_external_force_on_single_body_at_position(num_cubes, device): - """Test application of external force on the base of the object at a specific position. - - In this test, we apply a force equal to the weight of an object on the base of - one of the objects at 1m in the Y direction, we check that the object rotates around it's X axis. - For the other object, we do not apply any force and check that it falls down. - - We validate that this works when we apply the force in the global frame and in the local frame. - """ - # Generate cubes scene - with build_simulation_context(device=device, add_ground_plane=True, auto_add_lighting=True) as sim: - sim._app_control_on_stop_handle = None - cube_object, origins = generate_cubes_scene(num_cubes=num_cubes, device=device) - - # Play the simulator - sim.reset() - - # Find bodies to apply the force - body_ids, body_names = cube_object.find_bodies(".*") - - # Sample a force equal to the weight of the object - external_wrench_b = torch.zeros(cube_object.num_instances, len(body_ids), 6, device=sim.device) - external_wrench_positions_b = torch.zeros(cube_object.num_instances, len(body_ids), 3, device=sim.device) - # Every 2nd cube should have a force applied to it - external_wrench_b[0::2, :, 2] = 500.0 - external_wrench_positions_b[0::2, :, 1] = 1.0 - - # Desired force and torque - desired_force = torch.zeros(cube_object.num_instances, len(body_ids), 3, device=sim.device) - desired_force[0::2, :, 2] = 1000.0 - desired_torque = torch.zeros(cube_object.num_instances, len(body_ids), 3, device=sim.device) - desired_torque[0::2, :, 0] = 1000.0 - # Now we are ready! - for i in range(5): - # reset root state - root_pose = cube_object.data.default_root_pose.torch.clone() - root_vel = cube_object.data.default_root_vel.torch.clone() - - # need to shift the position of the cubes otherwise they will be on top of each other - root_pose[:, :3] = origins - cube_object.write_root_pose_to_sim_index(root_pose=root_pose) - cube_object.write_root_velocity_to_sim_index(root_velocity=root_vel) - - # reset object - cube_object.reset() - - is_global = False - if i % 2 == 0: - is_global = True - body_com_pos_w = cube_object.data.body_com_pos_w.torch[:, body_ids, :3] - external_wrench_positions_b[..., 0] = 0.0 - external_wrench_positions_b[..., 1] = 1.0 - external_wrench_positions_b[..., 2] = 0.0 - external_wrench_positions_b += body_com_pos_w - else: - external_wrench_positions_b[..., 0] = 0.0 - external_wrench_positions_b[..., 1] = 1.0 - external_wrench_positions_b[..., 2] = 0.0 - - # apply force - cube_object.permanent_wrench_composer.set_forces_and_torques_index( - forces=external_wrench_b[..., :3], - torques=external_wrench_b[..., 3:], - positions=external_wrench_positions_b, - body_ids=body_ids, - is_global=is_global, - ) - cube_object.permanent_wrench_composer.add_forces_and_torques_index( - forces=external_wrench_b[..., :3], - torques=external_wrench_b[..., 3:], - positions=external_wrench_positions_b, - body_ids=body_ids, - is_global=is_global, - ) - torch.testing.assert_close( - cube_object._permanent_wrench_composer.out_force_b.torch[:, 0, :], - desired_force[:, 0, :], - rtol=1e-6, - atol=1e-7, - ) - torch.testing.assert_close( - cube_object._permanent_wrench_composer.out_torque_b.torch[:, 0, :], - desired_torque[:, 0, :], - rtol=1e-6, - atol=1e-7, - ) - # perform simulation - for _ in range(5): - # apply action to the object - cube_object.write_data_to_sim() - - # perform step - sim.step() - - # update buffers - cube_object.update(sim.cfg.dt) - - # The first object should be rotating around it's X axis - assert torch.all(torch.abs(cube_object.data.root_ang_vel_b.torch[0::2, 0]) > 0.1) - # Second object should have fallen, so it's Z height should be less than initial height of 1.0 - assert torch.all(cube_object.data.root_pos_w.torch[1::2, 2] < 1.0) - - -@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("num_cubes", [2]) @pytest.mark.parametrize("device", test_devices()) @pytest.mark.isaacsim_ci def test_set_rigid_object_state(num_cubes, device): @@ -522,7 +271,7 @@ def test_set_rigid_object_state(num_cubes, device): cube_object.update(sim.cfg.dt) -@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("num_cubes", [2]) @pytest.mark.parametrize("device", test_devices()) @pytest.mark.isaacsim_ci def test_reset_rigid_object(num_cubes, device): @@ -534,119 +283,31 @@ def test_reset_rigid_object(num_cubes, device): # Play the simulator sim.reset() - - for i in range(5): - # perform rendering - sim.step() - - # update object - cube_object.update(sim.cfg.dt) - - # Move the object to a random position - root_pose = cube_object.data.default_root_pose.torch.clone() - root_pose[:, :3] = torch.randn(num_cubes, 3, device=sim.device) - - # Random orientation - root_pose[:, 3:7] = random_orientation(num=num_cubes, device=sim.device) - cube_object.write_root_pose_to_sim_index(root_pose=root_pose) - root_vel = cube_object.data.default_root_vel.torch.clone() - cube_object.write_root_velocity_to_sim_index(root_velocity=root_vel) - - if i % 2 == 0: - # reset object - cube_object.reset() - - # Reset should zero external forces and torques - assert not cube_object._instantaneous_wrench_composer.active - assert not cube_object._permanent_wrench_composer.active - assert torch.count_nonzero(cube_object._instantaneous_wrench_composer.out_force_b.torch) == 0 - assert torch.count_nonzero(cube_object._instantaneous_wrench_composer.out_torque_b.torch) == 0 - assert torch.count_nonzero(cube_object._permanent_wrench_composer.out_force_b.torch) == 0 - assert torch.count_nonzero(cube_object._permanent_wrench_composer.out_torque_b.torch) == 0 - - -@pytest.mark.parametrize("num_cubes", [1, 2]) -@pytest.mark.parametrize("device", test_devices()) -@pytest.mark.isaacsim_ci -def test_rigid_body_set_material_properties(num_cubes, device): - """Test getting and setting material properties of rigid object.""" - with build_simulation_context( - device=device, gravity_enabled=True, add_ground_plane=True, auto_add_lighting=True - ) as sim: - sim._app_control_on_stop_handle = None - # Generate cubes scene - cube_object, _ = generate_cubes_scene(num_cubes=num_cubes, device=device) - - # Play sim - sim.reset() - - # Set material properties - static_friction = torch.FloatTensor(num_cubes, 1).uniform_(0.4, 0.8) - dynamic_friction = torch.FloatTensor(num_cubes, 1).uniform_(0.4, 0.8) - restitution = torch.FloatTensor(num_cubes, 1).uniform_(0.0, 0.2) - - materials = torch.cat([static_friction, dynamic_friction, restitution], dim=-1) - - indices = torch.tensor(range(num_cubes), dtype=torch.int32) - # Add friction to cube - cube_object.root_view.set_material_properties( - wp.from_torch(materials, dtype=wp.float32), wp.from_torch(indices, dtype=wp.int32) - ) - - # Simulate physics - # perform rendering sim.step() - # update object cube_object.update(sim.cfg.dt) - # Get material properties - materials_to_check = wp.to_torch(cube_object.root_view.get_material_properties()) - - # Check if material properties are set correctly - torch.testing.assert_close(materials_to_check.reshape(num_cubes, 3), materials) + composers = (cube_object.instantaneous_wrench_composer, cube_object.permanent_wrench_composer) + ones = torch.ones((num_cubes, 1, 3), device=device) + cube_object.permanent_wrench_composer.set_forces_and_torques_index(forces=ones, torques=ones) + cube_object.instantaneous_wrench_composer.add_forces_and_torques_index(forces=ones, torques=ones) + # A partial reset clears only the selected environment + cube_object.reset(env_ids=torch.tensor([0], device=device)) + for composer in composers: + assert composer.active + for buffer in (composer.out_force_b.torch, composer.out_torque_b.torch): + assert torch.count_nonzero(buffer[0]) == 0 + assert torch.count_nonzero(buffer[1:]) == buffer[1:].numel() -@pytest.mark.parametrize("num_cubes", [1, 2]) -@pytest.mark.parametrize("device", test_devices()) -@pytest.mark.isaacsim_ci -def test_set_material_properties_via_view(num_cubes, device): - """Test setting material properties via the PhysX view-level API.""" - with build_simulation_context( - device=device, gravity_enabled=True, add_ground_plane=True, auto_add_lighting=True - ) as sim: - sim._app_control_on_stop_handle = None - # Generate cubes scene - cube_object, _ = generate_cubes_scene(num_cubes=num_cubes, device=device) - - # Play sim - sim.reset() - - # Get number of shapes - max_shapes = cube_object.root_view.max_shapes - - # Generate random material properties: (static_friction, dynamic_friction, restitution) - materials = torch.empty(num_cubes, max_shapes, 3, device="cpu").uniform_(0.0, 1.0) - # Ensure dynamic friction <= static friction - materials[..., 1] = torch.min(materials[..., 0], materials[..., 1]) - - # Set material properties via the PhysX view-level API - env_ids = torch.arange(num_cubes, dtype=torch.int32) - cube_object.root_view.set_material_properties( - wp.from_torch(materials, dtype=wp.float32), wp.from_torch(env_ids, dtype=wp.int32) - ) - - # Simulate physics - sim.step() - cube_object.update(sim.cfg.dt) - - # Get material properties from simulation - materials_check = wp.to_torch(cube_object.root_view.get_material_properties()) - - # Check if material properties are set correctly - torch.testing.assert_close(materials_check, materials) + # A full reset clears every environment and deactivates the composers + cube_object.reset() + for composer in composers: + assert not composer.active + assert torch.count_nonzero(composer.out_force_b.torch) == 0 + assert torch.count_nonzero(composer.out_torque_b.torch) == 0 -@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("num_cubes", [2]) @pytest.mark.parametrize("device", test_devices()) @pytest.mark.isaacsim_ci def test_rigid_body_no_friction(num_cubes, device): @@ -706,7 +367,7 @@ def test_rigid_body_no_friction(num_cubes, device): ) -@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("num_cubes", [2]) @pytest.mark.parametrize("device", test_devices()) @pytest.mark.isaacsim_ci def test_rigid_body_with_static_friction(num_cubes, device): @@ -792,7 +453,7 @@ def test_rigid_body_with_static_friction(num_cubes, device): assert (cube_object.data.root_pos_w.torch[..., 0] - initial_root_pos[..., 0] > 0.02).all() -@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("num_cubes", [2]) @pytest.mark.parametrize("device", test_devices()) @pytest.mark.isaacsim_ci def test_rigid_body_with_restitution(num_cubes, device): @@ -875,7 +536,7 @@ def test_rigid_body_with_restitution(num_cubes, device): assert (curr_z_velocity > 0.0).all() -@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("num_cubes", [2]) @pytest.mark.parametrize("device", test_devices()) @pytest.mark.isaacsim_ci def test_rigid_body_set_mass(num_cubes, device): @@ -895,31 +556,22 @@ def test_rigid_body_set_mass(num_cubes, device): assert original_masses.shape == (num_cubes, 1) - # Randomize mass of the object + # Randomize mass of the object through the public writer masses = original_masses + torch.FloatTensor(num_cubes, 1).uniform_(4, 8) + cube_object.set_masses_index(masses=masses.to(device)) - indices = torch.tensor(range(num_cubes), dtype=torch.int32) - - # Add friction to cube - cube_object.root_view.set_masses( - wp.from_torch(masses, dtype=wp.float32), wp.from_torch(indices, dtype=wp.int32) - ) - + torch.testing.assert_close(cube_object.data.body_mass.torch, masses.to(device)) torch.testing.assert_close(wp.to_torch(cube_object.root_view.get_masses()), masses) - # Simulate physics - # perform rendering + # The written mass must survive a simulation step sim.step() - # update object cube_object.update(sim.cfg.dt) - masses_to_check = wp.to_torch(cube_object.root_view.get_masses()) - - # Check if mass is set correctly - torch.testing.assert_close(masses, masses_to_check) + torch.testing.assert_close(cube_object.data.body_mass.torch, masses.to(device)) + torch.testing.assert_close(wp.to_torch(cube_object.root_view.get_masses()), masses) -@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("num_cubes", [2]) @pytest.mark.parametrize("device", test_devices()) @pytest.mark.parametrize("gravity_enabled", [True, False]) @pytest.mark.isaacsim_ci @@ -959,7 +611,7 @@ def test_gravity_vec_w(num_cubes, device, gravity_enabled): torch.testing.assert_close(cube_object.data.body_acc_w.torch, gravity) -@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("num_cubes", [2]) @pytest.mark.parametrize("device", test_devices()) @pytest.mark.parametrize("with_offset", [True, False]) @pytest.mark.isaacsim_ci @@ -970,7 +622,6 @@ def test_body_root_state_properties(num_cubes, device, with_offset): sim._app_control_on_stop_handle = None # Create a scene with random cubes cube_object, env_pos = generate_cubes_scene(num_cubes=num_cubes, height=0.0, device=device) - env_idx = torch.tensor([x for x in range(num_cubes)], dtype=torch.int32) # Play sim sim.reset() @@ -986,9 +637,9 @@ def test_body_root_state_properties(num_cubes, device, with_offset): com = wp.to_torch(cube_object.root_view.get_coms()) com[..., :3] = offset.to("cpu") - cube_object.root_view.set_coms(wp.from_torch(com, dtype=wp.float32), wp.from_torch(env_idx, dtype=wp.int32)) + cube_object.set_coms_index(coms=wp.from_torch(com.unsqueeze(1).to(device).contiguous(), dtype=wp.transformf)) - # check ceter of mass has been set + # check center of mass has been set torch.testing.assert_close(wp.to_torch(cube_object.root_view.get_coms()), com) # random z spin velocity @@ -1018,12 +669,8 @@ def test_body_root_state_properties(num_cubes, device, with_offset): if not with_offset: torch.testing.assert_close(root_link_pose_w, root_com_pose_w) torch.testing.assert_close(root_com_vel_w, root_link_vel_w) - torch.testing.assert_close(root_link_pose_w, root_link_pose_w) - torch.testing.assert_close(root_com_vel_w, root_link_vel_w) torch.testing.assert_close(body_link_pose_w, body_com_pose_w) torch.testing.assert_close(body_com_vel_w, body_link_vel_w) - torch.testing.assert_close(body_link_pose_w, body_link_pose_w) - torch.testing.assert_close(body_com_vel_w, body_link_vel_w) else: # cubes are spinning around center of mass # position will not match @@ -1048,10 +695,6 @@ def test_body_root_state_properties(num_cubes, device, with_offset): torch.testing.assert_close(com_quat_w, body_com_pose_w[..., 3:]) torch.testing.assert_close(com_quat_w.squeeze(-2), root_com_pose_w[..., 3:]) - # orientation of link will match root state will always match - torch.testing.assert_close(root_link_pose_w[..., 3:], root_link_pose_w[..., 3:]) - torch.testing.assert_close(body_link_pose_w[..., 3:], body_link_pose_w[..., 3:]) - # lin_vel will not match # center of mass vel will be constant (i.e. spinning around com) torch.testing.assert_close(torch.zeros_like(root_com_vel_w[..., :3]), root_com_vel_w[..., :3]) @@ -1064,19 +707,21 @@ def test_body_root_state_properties(num_cubes, device, with_offset): torch.testing.assert_close(lin_vel_rel_gt, lin_vel_rel_body_gt.squeeze(-2), atol=1e-4, rtol=1e-4) # ang_vel will always match - torch.testing.assert_close(root_com_vel_w[..., 3:], root_com_vel_w[..., 3:]) torch.testing.assert_close(root_com_vel_w[..., 3:], root_link_vel_w[..., 3:]) - torch.testing.assert_close(body_com_vel_w[..., 3:], body_com_vel_w[..., 3:]) torch.testing.assert_close(body_com_vel_w[..., 3:], body_link_vel_w[..., 3:]) -@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("num_cubes", [2]) @pytest.mark.parametrize("device", test_devices()) @pytest.mark.parametrize("with_offset", [True, False]) -@pytest.mark.parametrize("state_location", ["com", "link"]) +@pytest.mark.parametrize("state_location", ["com", "link", "root"]) @pytest.mark.isaacsim_ci def test_write_root_state(num_cubes, device, with_offset, state_location): - """Test the setters for root_state using both the link frame and center of mass as reference frame.""" + """Test the root state setters in the link, center-of-mass, and mixed root frames. + + Every write must round-trip through the written frame and keep the other frame consistent with the + center-of-mass offset. + """ with build_simulation_context(device=device, gravity_enabled=False, auto_add_lighting=True) as sim: sim._app_control_on_stop_handle = None # Create a scene with random cubes @@ -1102,8 +747,8 @@ def test_write_root_state(num_cubes, device, with_offset, state_location): # check center of mass has been set torch.testing.assert_close(wp.to_torch(cube_object.root_view.get_coms()), com) - rand_state = torch.zeros(num_cubes, 13, device=device) - rand_state[..., :7] = cube_object.data.default_root_pose.torch + # random pose and velocity so frame conversions see a non-trivial rotation + rand_state = torch.rand(num_cubes, 13, device=device) rand_state[..., :3] += env_pos # make quaternion a unit vector rand_state[..., 3:7] = torch.nn.functional.normalize(rand_state[..., 3:7], dim=-1) @@ -1115,22 +760,16 @@ def test_write_root_state(num_cubes, device, with_offset, state_location): # update buffers cube_object.update(sim.cfg.dt) + env_ids = None if i % 2 == 0 else env_idx if state_location == "com": - if i % 2 == 0: - cube_object.write_root_com_pose_to_sim_index(root_pose=rand_state[..., :7]) - cube_object.write_root_com_velocity_to_sim_index(root_velocity=rand_state[..., 7:]) - else: - cube_object.write_root_com_pose_to_sim_index(root_pose=rand_state[..., :7], env_ids=env_idx) - cube_object.write_root_com_velocity_to_sim_index(root_velocity=rand_state[..., 7:], env_ids=env_idx) + cube_object.write_root_com_pose_to_sim_index(root_pose=rand_state[..., :7], env_ids=env_ids) + cube_object.write_root_com_velocity_to_sim_index(root_velocity=rand_state[..., 7:], env_ids=env_ids) elif state_location == "link": - if i % 2 == 0: - cube_object.write_root_link_pose_to_sim_index(root_pose=rand_state[..., :7]) - cube_object.write_root_link_velocity_to_sim_index(root_velocity=rand_state[..., 7:]) - else: - cube_object.write_root_link_pose_to_sim_index(root_pose=rand_state[..., :7], env_ids=env_idx) - cube_object.write_root_link_velocity_to_sim_index( - root_velocity=rand_state[..., 7:], env_ids=env_idx - ) + cube_object.write_root_link_pose_to_sim_index(root_pose=rand_state[..., :7], env_ids=env_ids) + cube_object.write_root_link_velocity_to_sim_index(root_velocity=rand_state[..., 7:], env_ids=env_ids) + elif state_location == "root": + cube_object.write_root_pose_to_sim_index(root_pose=rand_state[..., :7], env_ids=env_ids) + cube_object.write_root_velocity_to_sim_index(root_velocity=rand_state[..., 7:], env_ids=env_ids) if state_location == "com": torch.testing.assert_close(rand_state[..., :7], cube_object.data.root_com_pose_w.torch) @@ -1138,85 +777,12 @@ def test_write_root_state(num_cubes, device, with_offset, state_location): elif state_location == "link": torch.testing.assert_close(rand_state[..., :7], cube_object.data.root_link_pose_w.torch) torch.testing.assert_close(rand_state[..., 7:], cube_object.data.root_link_vel_w.torch) + elif state_location == "root": + torch.testing.assert_close(rand_state[..., :7], cube_object.data.root_link_pose_w.torch) + torch.testing.assert_close(rand_state[..., 7:], cube_object.data.root_com_vel_w.torch) - -@pytest.mark.parametrize("num_cubes", [1, 2]) -@pytest.mark.parametrize("device", test_devices()) -@pytest.mark.parametrize("with_offset", [True]) -@pytest.mark.parametrize("state_location", ["com", "link", "root"]) -@pytest.mark.isaacsim_ci -def test_write_state_functions_data_consistency(num_cubes, device, with_offset, state_location): - """Test the setters for root_state using both the link frame and center of mass as reference frame.""" - with build_simulation_context(device=device, gravity_enabled=False, auto_add_lighting=True) as sim: - sim._app_control_on_stop_handle = None - # Create a scene with random cubes - cube_object, env_pos = generate_cubes_scene(num_cubes=num_cubes, height=0.0, device=device) - env_idx = torch.tensor([x for x in range(num_cubes)], dtype=torch.int32) - - # Play sim - sim.reset() - - # Check if cube_object is initialized - assert cube_object.is_initialized - - # change center of mass offset from link frame - if with_offset: - offset = torch.tensor([0.1, 0.0, 0.0], device=device).repeat(num_cubes, 1) - else: - offset = torch.tensor([0.0, 0.0, 0.0], device=device).repeat(num_cubes, 1) - - com = wp.to_torch(cube_object.root_view.get_coms()) - com[..., :3] = offset.to("cpu") - cube_object.root_view.set_coms(wp.from_torch(com, dtype=wp.float32), wp.from_torch(env_idx, dtype=wp.int32)) - - # check ceter of mass has been set - torch.testing.assert_close(wp.to_torch(cube_object.root_view.get_coms()), com) - - rand_state = torch.rand(num_cubes, 13, device=device) - rand_state[..., :3] += env_pos - # make quaternion a unit vector - rand_state[..., 3:7] = torch.nn.functional.normalize(rand_state[..., 3:7], dim=-1) - - env_idx = env_idx.to(device) - - # perform step - sim.step() - # update buffers - cube_object.update(sim.cfg.dt) - - if state_location == "com": - cube_object.write_root_com_pose_to_sim_index(root_pose=rand_state[..., :7]) - cube_object.write_root_com_velocity_to_sim_index(root_velocity=rand_state[..., 7:]) - elif state_location == "link": - cube_object.write_root_link_pose_to_sim_index(root_pose=rand_state[..., :7]) - cube_object.write_root_link_velocity_to_sim_index(root_velocity=rand_state[..., 7:]) - elif state_location == "root": - cube_object.write_root_pose_to_sim_index(root_pose=rand_state[..., :7]) - cube_object.write_root_velocity_to_sim_index(root_velocity=rand_state[..., 7:]) - - if state_location == "com": - root_com_pose_w = cube_object.data.root_com_pose_w.torch - root_com_vel_w = cube_object.data.root_com_vel_w.torch - body_com_pose_b = cube_object.data.body_com_pose_b.torch - expected_root_link_pos, expected_root_link_quat = combine_frame_transforms( - root_com_pose_w[:, :3], - root_com_pose_w[:, 3:], - quat_rotate(quat_inv(body_com_pose_b[:, 0, 3:7]), -body_com_pose_b[:, 0, :3]), - quat_inv(body_com_pose_b[:, 0, 3:7]), - ) - expected_root_link_pose = torch.cat((expected_root_link_pos, expected_root_link_quat), dim=1) - root_link_pose_w = cube_object.data.root_link_pose_w.torch - root_link_vel_w = cube_object.data.root_link_vel_w.torch - # test both root_pose and root_link successfully updated when root_com updates - torch.testing.assert_close(expected_root_link_pose, root_link_pose_w) - # skip lin_vel because it differs from link frame, this should be fine because we are only checking - # if velocity update is triggered, which can be determined by comparing angular velocity - torch.testing.assert_close(root_com_vel_w[:, 3:], root_link_vel_w[:, 3:]) - torch.testing.assert_close(expected_root_link_pose, root_link_pose_w) - torch.testing.assert_close(root_com_vel_w[:, 3:], cube_object.data.root_com_vel_w.torch[:, 3:]) - elif state_location == "link": + # the frame that was not written must follow through the center-of-mass offset root_link_pose_w = cube_object.data.root_link_pose_w.torch - root_link_vel_w = cube_object.data.root_link_vel_w.torch body_com_pose_b = cube_object.data.body_com_pose_b.torch expected_com_pos, expected_com_quat = combine_frame_transforms( root_link_pose_w[:, :3], @@ -1224,34 +790,12 @@ def test_write_state_functions_data_consistency(num_cubes, device, with_offset, body_com_pose_b[:, 0, :3], body_com_pose_b[:, 0, 3:7], ) - expected_com_pose = torch.cat((expected_com_pos, expected_com_quat), dim=1) - root_com_pose_w = cube_object.data.root_com_pose_w.torch - root_com_vel_w = cube_object.data.root_com_vel_w.torch - # test both root_pose and root_com successfully updated when root_link updates - torch.testing.assert_close(expected_com_pose, root_com_pose_w) - # skip lin_vel because it differs from link frame, this should be fine because we are only checking - # if velocity update is triggered, which can be determined by comparing angular velocity - torch.testing.assert_close(root_link_vel_w[:, 3:], root_com_vel_w[:, 3:]) - torch.testing.assert_close(root_link_pose_w, cube_object.data.root_link_pose_w.torch) - torch.testing.assert_close(root_link_vel_w[:, 3:], cube_object.data.root_com_vel_w.torch[:, 3:]) - elif state_location == "root": - root_link_pose_w = cube_object.data.root_link_pose_w.torch - root_com_vel_w = cube_object.data.root_com_vel_w.torch - body_com_pose_b = cube_object.data.body_com_pose_b.torch - expected_com_pos, expected_com_quat = combine_frame_transforms( - root_link_pose_w[:, :3], - root_link_pose_w[:, 3:], - body_com_pose_b[:, 0, :3], - body_com_pose_b[:, 0, 3:7], + torch.testing.assert_close( + torch.cat((expected_com_pos, expected_com_quat), dim=1), cube_object.data.root_com_pose_w.torch + ) + torch.testing.assert_close( + cube_object.data.root_com_vel_w.torch[:, 3:], cube_object.data.root_link_vel_w.torch[:, 3:] ) - expected_com_pose = torch.cat((expected_com_pos, expected_com_quat), dim=1) - root_com_pose_w = cube_object.data.root_com_pose_w.torch - root_link_vel_w = cube_object.data.root_link_vel_w.torch - # test both root_com and root_link successfully updated when root_pose updates - torch.testing.assert_close(expected_com_pose, root_com_pose_w) - torch.testing.assert_close(root_com_vel_w, cube_object.data.root_com_vel_w.torch) - torch.testing.assert_close(root_link_pose_w, cube_object.data.root_link_pose_w.torch) - torch.testing.assert_close(root_com_vel_w[:, 3:], root_link_vel_w[:, 3:]) @pytest.mark.isaacsim_ci diff --git a/source/isaaclab_physx/test/assets/test_rigid_object_collection.py b/source/isaaclab_physx/test/assets/test_rigid_object_collection.py index aed435fbea8c..d304881d41ea 100644 --- a/source/isaaclab_physx/test/assets/test_rigid_object_collection.py +++ b/source/isaaclab_physx/test/assets/test_rigid_object_collection.py @@ -32,11 +32,8 @@ combine_frame_transforms, default_orientation, quat_apply_inverse, - quat_inv, quat_mul, - quat_rotate, random_orientation, - subtract_frame_transforms, ) @@ -109,8 +106,7 @@ def sim(request): yield sim -@pytest.mark.parametrize("num_envs", [1, 2]) -@pytest.mark.parametrize("num_cubes", [1, 3]) +@pytest.mark.parametrize(("num_envs", "num_cubes"), [(1, 1), (2, 3)]) @pytest.mark.parametrize("device", test_devices()) def test_initialization(sim, num_envs, num_cubes, device): """Test initialization for prim with rigid body API at the provided prim path.""" @@ -139,43 +135,32 @@ def test_initialization(sim, num_envs, num_cubes, device): @pytest.mark.parametrize("device", test_devices()) -def test_id_conversion(sim, device): - """Test environment and object index conversion to physics view indices.""" +@pytest.mark.parametrize("gravity_enabled", [False]) +def test_subset_write_reaches_selected_view_entry(sim, device, gravity_enabled): + """A write to one (env, body) cell must move only that object in the simulation.""" object_collection, _ = generate_cubes_scene(num_envs=2, num_cubes=3, device=device) # Play sim sim.reset() + sim.step() + object_collection.update(sim.cfg.dt) - expected = [ - torch.tensor([4, 5], device=device, dtype=torch.int32), - torch.tensor([4], device=device, dtype=torch.int32), - torch.tensor([0, 2, 4], device=device, dtype=torch.int32), - torch.tensor([1, 3, 5], device=device, dtype=torch.int32), - ] + initial_pose = object_collection.data.body_link_pose_w.torch.clone() + new_pose = initial_pose[1:2, 2:3].clone() + new_pose[..., 2] += 0.5 + object_collection.write_body_link_pose_to_sim_index(body_poses=new_pose, env_ids=[1], body_ids=[2]) - torch_all_env_indices = wp.to_torch(object_collection._ALL_ENV_INDICES) - torch_all_body_indices = wp.to_torch(object_collection._ALL_BODY_INDICES) + # Read the pose back from the simulation so that a wrong view index cannot hide in the data buffer + sim.step() + object_collection.update(sim.cfg.dt) - view_ids = object_collection._env_body_ids_to_view_ids( - torch_all_env_indices, torch_all_body_indices[None, 2], device=device - ) - assert (wp.to_torch(view_ids) == expected[0]).all() - view_ids = object_collection._env_body_ids_to_view_ids( - torch_all_env_indices[None, 0], torch_all_body_indices[None, 2], device=device - ) - assert (wp.to_torch(view_ids) == expected[1]).all() - view_ids = object_collection._env_body_ids_to_view_ids( - torch_all_env_indices[None, 0], torch_all_body_indices, device=device - ) - assert (wp.to_torch(view_ids) == expected[2]).all() - view_ids = object_collection._env_body_ids_to_view_ids( - torch_all_env_indices[None, 1], torch_all_body_indices, device=device - ) - assert (wp.to_torch(view_ids) == expected[3]).all() + expected_pose = initial_pose.clone() + expected_pose[1, 2] = new_pose[0, 0] + torch.testing.assert_close(object_collection.data.body_link_pose_w.torch, expected_pose) -@pytest.mark.parametrize("num_envs", [1, 2]) -@pytest.mark.parametrize("num_cubes", [1, 3]) +@pytest.mark.parametrize("num_envs", [2]) +@pytest.mark.parametrize("num_cubes", [3]) @pytest.mark.parametrize("device", test_devices()) def test_initialization_with_kinematic_enabled(sim, num_envs, num_cubes, device): """Test that initialization for prim with kinematic flag enabled.""" @@ -209,7 +194,7 @@ def test_initialization_with_kinematic_enabled(sim, num_envs, num_cubes, device) torch.testing.assert_close(object_collection.data.body_link_vel_w.torch, default_body_vel) -@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("num_cubes", [2]) @pytest.mark.parametrize("device", test_devices()) def test_initialization_with_no_rigid_body(sim, num_cubes, device): """Test that initialization fails when no rigid body is found at the provided prim path.""" @@ -223,60 +208,8 @@ def test_initialization_with_no_rigid_body(sim, num_cubes, device): sim.reset() -@pytest.mark.parametrize("device", test_devices()) -def test_external_force_buffer(sim, device): - """Test if external force buffer correctly updates in the force value is zero case.""" - num_envs = 2 - num_cubes = 1 - object_collection, origins = generate_cubes_scene(num_envs=num_envs, num_cubes=num_cubes, device=device) - sim.reset() - - # find objects to apply the force - object_ids, object_names = object_collection.find_bodies(".*") - # reset object - object_collection.reset() - - # perform simulation - for step in range(5): - # initiate force tensor - external_wrench_b = torch.zeros(object_collection.num_instances, len(object_ids), 6, device=sim.device) - - # decide if zero or non-zero force - if step == 0 or step == 3: - force = 1.0 - else: - force = 0.0 - - # apply force to the object - external_wrench_b[:, :, 0] = force - external_wrench_b[:, :, 3] = force - - object_collection.permanent_wrench_composer.set_forces_and_torques_index( - forces=external_wrench_b[..., :3], - torques=external_wrench_b[..., 3:], - body_ids=object_ids, - env_ids=None, - ) - - # check if the object collection's force and torque buffers are correctly updated - for i in range(num_envs): - assert object_collection._permanent_wrench_composer.out_force_b.torch[i, 0, 0].item() == force - assert object_collection._permanent_wrench_composer.out_torque_b.torch[i, 0, 0].item() == force - - object_collection.instantaneous_wrench_composer.add_forces_and_torques_index( - body_ids=object_ids, - forces=external_wrench_b[..., :3], - torques=external_wrench_b[..., 3:], - ) - - # apply action to the object collection - object_collection.write_data_to_sim() - sim.step() - object_collection.update(sim.cfg.dt) - - -@pytest.mark.parametrize("num_envs", [1, 2]) -@pytest.mark.parametrize("num_cubes", [1, 4]) +@pytest.mark.parametrize("num_envs", [2]) +@pytest.mark.parametrize("num_cubes", [4]) @pytest.mark.parametrize("device", test_devices()) def test_external_force_on_single_body(sim, num_envs, num_cubes, device): """Test application of external force on the base of the object.""" @@ -335,8 +268,8 @@ def test_external_force_on_single_body(sim, num_envs, num_cubes, device): assert torch.all(object_collection.data.body_link_pos_w.torch[:, 1::2, 2] < 1.0) -@pytest.mark.parametrize("num_envs", [1, 2]) -@pytest.mark.parametrize("num_cubes", [1, 4]) +@pytest.mark.parametrize("num_envs", [2]) +@pytest.mark.parametrize("num_cubes", [4]) @pytest.mark.parametrize("device", test_devices()) def test_external_force_on_single_body_at_position(sim, num_envs, num_cubes, device): """Test application of external force on the base of the object at a specific position. @@ -414,8 +347,8 @@ def test_external_force_on_single_body_at_position(sim, num_envs, num_cubes, dev assert torch.all(object_collection.data.body_link_pos_w.torch[:, 1::2, 2] < 1.0) -@pytest.mark.parametrize("num_envs", [1, 3]) -@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("num_envs", [3]) +@pytest.mark.parametrize("num_cubes", [2]) @pytest.mark.parametrize("device", test_devices()) @pytest.mark.parametrize("gravity_enabled", [False]) def test_set_object_state(sim, num_envs, num_cubes, device, gravity_enabled): @@ -479,8 +412,8 @@ def test_set_object_state(sim, num_envs, num_cubes, device, gravity_enabled): object_collection.update(sim.cfg.dt) -@pytest.mark.parametrize("num_envs", [1, 4]) -@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("num_envs", [4]) +@pytest.mark.parametrize("num_cubes", [2]) @pytest.mark.parametrize("device", test_devices()) @pytest.mark.parametrize("with_offset", [True, False]) @pytest.mark.parametrize("gravity_enabled", [False]) @@ -555,9 +488,6 @@ def test_object_state_properties(sim, num_envs, num_cubes, device, with_offset, com_quat_w = quat_mul(object_link_pose_w[..., 3:], com_quat_b) torch.testing.assert_close(com_quat_w, object_com_pose_w[..., 3:]) - # orientation of link will match object state will always match - torch.testing.assert_close(object_link_pose_w[..., 3:], object_link_pose_w[..., 3:]) - # lin_vel will not match # center of mass vel will be constant (i.e. spinning around com) torch.testing.assert_close( @@ -571,18 +501,21 @@ def test_object_state_properties(sim, num_envs, num_cubes, device, with_offset, torch.testing.assert_close(lin_vel_rel_gt, lin_vel_rel_object_gt, atol=1e-4, rtol=1e-3) # ang_vel will always match - torch.testing.assert_close(object_com_vel_w[..., 3:], object_com_vel_w[..., 3:]) torch.testing.assert_close(object_com_vel_w[..., 3:], object_link_vel_w[..., 3:]) -@pytest.mark.parametrize("num_envs", [1, 3]) -@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("num_envs", [3]) +@pytest.mark.parametrize("num_cubes", [2]) @pytest.mark.parametrize("device", test_devices()) @pytest.mark.parametrize("with_offset", [True, False]) -@pytest.mark.parametrize("state_location", ["com", "link"]) +@pytest.mark.parametrize("state_location", ["com", "link", "root"]) @pytest.mark.parametrize("gravity_enabled", [False]) def test_write_object_state(sim, num_envs, num_cubes, device, with_offset, state_location, gravity_enabled): - """Test the setters for object_state using both the link frame and center of mass as reference frame.""" + """Test the body state setters in the link, center-of-mass, and mixed root frames. + + Every write must round-trip through the written frame and keep the other frame consistent with the + center-of-mass offset. + """ # Create a scene with random cubes cube_object, env_pos = generate_cubes_scene(num_envs=num_envs, num_cubes=num_cubes, height=0.0, device=device) view_ids = torch.tensor([x for x in range(num_cubes * num_envs)], dtype=torch.int32) @@ -612,8 +545,8 @@ def test_write_object_state(sim, num_envs, num_cubes, device, with_offset, state wp.to_torch(cube_object.reshape_view_to_data_2d(cube_object.root_view.get_coms().view(wp.transformf))), com ) - rand_state = torch.zeros(num_envs, num_cubes, 13, device=device) - rand_state[..., :7] = cube_object.data.default_body_pose.torch + # random pose and velocity so frame conversions see a non-trivial rotation + rand_state = torch.rand(num_envs, num_cubes, 13, device=device) rand_state[..., :3] += cube_object.data.body_link_pos_w.torch # make quaternion a unit vector rand_state[..., 3:7] = torch.nn.functional.normalize(rand_state[..., 3:7], dim=-1) @@ -624,28 +557,16 @@ def test_write_object_state(sim, num_envs, num_cubes, device, with_offset, state sim.step() cube_object.update(sim.cfg.dt) + ids = {} if i % 2 == 0 else {"env_ids": env_ids, "body_ids": object_ids} if state_location == "com": - if i % 2 == 0: - cube_object.write_body_com_pose_to_sim_index(body_poses=rand_state[..., :7]) - cube_object.write_body_com_velocity_to_sim_index(body_velocities=rand_state[..., 7:]) - else: - cube_object.write_body_com_pose_to_sim_index( - body_poses=rand_state[..., :7], env_ids=env_ids, body_ids=object_ids - ) - cube_object.write_body_com_velocity_to_sim_index( - body_velocities=rand_state[..., 7:], env_ids=env_ids, body_ids=object_ids - ) + cube_object.write_body_com_pose_to_sim_index(body_poses=rand_state[..., :7], **ids) + cube_object.write_body_com_velocity_to_sim_index(body_velocities=rand_state[..., 7:], **ids) elif state_location == "link": - if i % 2 == 0: - cube_object.write_body_link_pose_to_sim_index(body_poses=rand_state[..., :7]) - cube_object.write_body_link_velocity_to_sim_index(body_velocities=rand_state[..., 7:]) - else: - cube_object.write_body_link_pose_to_sim_index( - body_poses=rand_state[..., :7], env_ids=env_ids, body_ids=object_ids - ) - cube_object.write_body_link_velocity_to_sim_index( - body_velocities=rand_state[..., 7:], env_ids=env_ids, body_ids=object_ids - ) + cube_object.write_body_link_pose_to_sim_index(body_poses=rand_state[..., :7], **ids) + cube_object.write_body_link_velocity_to_sim_index(body_velocities=rand_state[..., 7:], **ids) + elif state_location == "root": + cube_object.write_body_link_pose_to_sim_index(body_poses=rand_state[..., :7], **ids) + cube_object.write_body_com_velocity_to_sim_index(body_velocities=rand_state[..., 7:], **ids) if state_location == "com": torch.testing.assert_close(rand_state[..., :7], cube_object.data.body_com_pose_w.torch) @@ -653,78 +574,58 @@ def test_write_object_state(sim, num_envs, num_cubes, device, with_offset, state elif state_location == "link": torch.testing.assert_close(rand_state[..., :7], cube_object.data.body_link_pose_w.torch) torch.testing.assert_close(rand_state[..., 7:], cube_object.data.body_link_vel_w.torch) + elif state_location == "root": + torch.testing.assert_close(rand_state[..., :7], cube_object.data.body_link_pose_w.torch) + torch.testing.assert_close(rand_state[..., 7:], cube_object.data.body_com_vel_w.torch) - -@pytest.mark.parametrize("num_envs", [1, 3]) -@pytest.mark.parametrize("num_cubes", [1, 2]) -@pytest.mark.parametrize("device", test_devices()) -def test_reset_object_collection(sim, num_envs, num_cubes, device): - """Test resetting the state of the rigid object.""" - object_collection, _ = generate_cubes_scene(num_envs=num_envs, num_cubes=num_cubes, device=device) - sim.reset() - - for i in range(5): - sim.step() - object_collection.update(sim.cfg.dt) - - # Move the object to a random position - body_pose = object_collection.data.default_body_pose.torch.clone() - body_pose[..., :3] = torch.randn(num_envs, num_cubes, 3, device=sim.device) - # Random orientation - body_pose[..., 3:7] = random_orientation(num=num_cubes, device=sim.device) - object_collection.write_body_link_pose_to_sim_index(body_poses=body_pose) - body_vel = object_collection.data.default_body_vel.torch.clone() - object_collection.write_body_com_velocity_to_sim_index(body_velocities=body_vel) - - if i % 2 == 0: - object_collection.reset() - - # Reset should zero external forces and torques - assert not object_collection._instantaneous_wrench_composer.active - assert not object_collection._permanent_wrench_composer.active - assert torch.count_nonzero(object_collection._instantaneous_wrench_composer.out_force_b.torch) == 0 - assert torch.count_nonzero(object_collection._instantaneous_wrench_composer.out_torque_b.torch) == 0 - assert torch.count_nonzero(object_collection._permanent_wrench_composer.out_force_b.torch) == 0 - assert torch.count_nonzero(object_collection._permanent_wrench_composer.out_torque_b.torch) == 0 + # the frame that was not written must follow through the center-of-mass offset + link_pose_w = cube_object.data.body_link_pose_w.torch + body_com_pose_b = cube_object.data.body_com_pose_b.torch + expected_com_pos, expected_com_quat = combine_frame_transforms( + link_pose_w[..., :3].reshape(-1, 3), + link_pose_w[..., 3:].reshape(-1, 4), + body_com_pose_b[..., :3].reshape(-1, 3), + body_com_pose_b[..., 3:].reshape(-1, 4), + ) + expected_com_pose = torch.cat((expected_com_pos, expected_com_quat), dim=1).view(num_envs, num_cubes, 7) + torch.testing.assert_close(expected_com_pose, cube_object.data.body_com_pose_w.torch) + torch.testing.assert_close( + cube_object.data.body_com_vel_w.torch[..., 3:], cube_object.data.body_link_vel_w.torch[..., 3:] + ) -@pytest.mark.parametrize("num_envs", [1, 3]) -@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("num_envs", [3]) +@pytest.mark.parametrize("num_cubes", [2]) @pytest.mark.parametrize("device", test_devices()) -def test_set_material_properties(sim, num_envs, num_cubes, device): - """Test getting and setting material properties of rigid object.""" +def test_reset_object_collection(sim, num_envs, num_cubes, device): + """Test that reset clears the external wrenches of the selected environments only.""" object_collection, _ = generate_cubes_scene(num_envs=num_envs, num_cubes=num_cubes, device=device) sim.reset() - - # Set material properties - static_friction = torch.FloatTensor(num_envs, num_cubes, 1).uniform_(0.4, 0.8) - dynamic_friction = torch.FloatTensor(num_envs, num_cubes, 1).uniform_(0.4, 0.8) - restitution = torch.FloatTensor(num_envs, num_cubes, 1).uniform_(0.0, 0.2) - - materials = torch.cat([static_friction, dynamic_friction, restitution], dim=-1) - - # Add friction to cube - indices = torch.tensor(range(num_cubes * num_envs), dtype=torch.int) - object_collection.root_view.set_material_properties( - object_collection.reshape_data_to_view_3d(wp.from_torch(materials, dtype=wp.float32), 3, device="cpu"), - wp.from_torch(indices, dtype=wp.int32), - ) - - # Perform simulation sim.step() object_collection.update(sim.cfg.dt) - # Get material properties - materials_to_check = object_collection.root_view.get_material_properties() + composers = (object_collection.instantaneous_wrench_composer, object_collection.permanent_wrench_composer) + ones = torch.ones((num_envs, num_cubes, 3), device=device) + object_collection.permanent_wrench_composer.set_forces_and_torques_index(forces=ones, torques=ones) + object_collection.instantaneous_wrench_composer.add_forces_and_torques_index(forces=ones, torques=ones) - # Check if material properties are set correctly - torch.testing.assert_close( - wp.to_torch(object_collection.reshape_view_to_data_3d(materials_to_check, 3, device="cpu")), materials - ) + # A partial reset clears only the selected environment + object_collection.reset(env_ids=torch.tensor([0], device=device)) + for composer in composers: + assert composer.active + for buffer in (composer.out_force_b.torch, composer.out_torque_b.torch): + assert torch.count_nonzero(buffer[0]) == 0 + assert torch.count_nonzero(buffer[1:]) == buffer[1:].numel() + + # A full reset clears every environment + object_collection.reset() + for composer in composers: + assert torch.count_nonzero(composer.out_force_b.torch) == 0 + assert torch.count_nonzero(composer.out_torque_b.torch) == 0 -@pytest.mark.parametrize("num_envs", [1, 3]) -@pytest.mark.parametrize("num_cubes", [1, 2]) +@pytest.mark.parametrize("num_envs", [3]) +@pytest.mark.parametrize("num_cubes", [2]) @pytest.mark.parametrize("device", test_devices()) @pytest.mark.parametrize("gravity_enabled", [True, False]) def test_gravity_vec_w(sim, num_envs, num_cubes, device, gravity_enabled): @@ -754,147 +655,3 @@ def test_gravity_vec_w(sim, num_envs, num_cubes, device, gravity_enabled): # Check the body accelerations are correct torch.testing.assert_close(object_collection.data.body_com_acc_w.torch, gravity) - - -@pytest.mark.parametrize("num_envs", [1, 3]) -@pytest.mark.parametrize("num_cubes", [1, 2]) -@pytest.mark.parametrize("device", test_devices()) -@pytest.mark.parametrize("with_offset", [True]) -@pytest.mark.parametrize("state_location", ["com", "link", "root"]) -@pytest.mark.parametrize("gravity_enabled", [False]) -def test_write_object_state_functions_data_consistency( - sim, num_envs, num_cubes, device, with_offset, state_location, gravity_enabled -): - """Test the setters for object_state using both the link frame and center of mass as reference frame.""" - # Create a scene with random cubes - cube_object, env_pos = generate_cubes_scene(num_envs=num_envs, num_cubes=num_cubes, height=0.0, device=device) - view_ids = torch.tensor([x for x in range(num_cubes * num_envs)], dtype=torch.int32) - env_ids = torch.tensor([x for x in range(num_envs)], dtype=torch.int32) - object_ids = torch.tensor([x for x in range(num_cubes)], dtype=torch.int32) - - sim.reset() - - # Check if cube_object is initialized - assert cube_object.is_initialized - - # change center of mass offset from link frame - offset = ( - torch.tensor([0.1, 0.0, 0.0], device=device).repeat(num_envs, num_cubes, 1) - if with_offset - else torch.tensor([0.0, 0.0, 0.0], device=device).repeat(num_envs, num_cubes, 1) - ) - - com = wp.to_torch(cube_object.reshape_view_to_data_2d(cube_object.root_view.get_coms().view(wp.transformf))) - com[..., :3] = offset.to("cpu") - cube_object.root_view.set_coms( - cube_object.reshape_data_to_view_2d(wp.from_torch(com.clone(), dtype=wp.transformf)).view(wp.float32), - wp.from_torch(view_ids, dtype=wp.int32), - ) - - # check center of mass has been set - torch.testing.assert_close( - wp.to_torch(cube_object.reshape_view_to_data_2d(cube_object.root_view.get_coms().view(wp.transformf))), com - ) - - rand_state = torch.rand(num_envs, num_cubes, 13, device=device) - rand_state[..., :3] += cube_object.data.body_link_pos_w.torch - # make quaternion a unit vector - rand_state[..., 3:7] = torch.nn.functional.normalize(rand_state[..., 3:7], dim=-1) - - env_ids = env_ids.to(device) - object_ids = object_ids.to(device) - sim.step() - cube_object.update(sim.cfg.dt) - - body_link_pose_w = cube_object.data.body_link_pose_w.torch - body_com_pose_w = cube_object.data.body_com_pose_w.torch - object_link_to_com_pos, object_link_to_com_quat = subtract_frame_transforms( - body_link_pose_w[..., :3].view(-1, 3), - body_link_pose_w[..., 3:7].view(-1, 4), - body_com_pose_w[..., :3].view(-1, 3), - body_com_pose_w[..., 3:7].view(-1, 4), - ) - - if state_location == "com": - cube_object.write_body_com_pose_to_sim_index( - body_poses=rand_state[..., :7], env_ids=env_ids, body_ids=object_ids - ) - cube_object.write_body_com_velocity_to_sim_index( - body_velocities=rand_state[..., 7:], env_ids=env_ids, body_ids=object_ids - ) - elif state_location == "link": - cube_object.write_body_link_pose_to_sim_index( - body_poses=rand_state[..., :7], env_ids=env_ids, body_ids=object_ids - ) - cube_object.write_body_link_velocity_to_sim_index( - body_velocities=rand_state[..., 7:], env_ids=env_ids, body_ids=object_ids - ) - elif state_location == "root": - cube_object.write_body_link_pose_to_sim_index( - body_poses=rand_state[..., :7], env_ids=env_ids, body_ids=object_ids - ) - cube_object.write_body_com_velocity_to_sim_index( - body_velocities=rand_state[..., 7:], env_ids=env_ids, body_ids=object_ids - ) - - if state_location == "com": - com_pose_w = cube_object.data.body_com_pose_w.torch - com_vel_w = cube_object.data.body_com_vel_w.torch - expected_root_link_pos, expected_root_link_quat = combine_frame_transforms( - com_pose_w[..., :3].view(-1, 3), - com_pose_w[..., 3:].view(-1, 4), - quat_rotate(quat_inv(object_link_to_com_quat), -object_link_to_com_pos), - quat_inv(object_link_to_com_quat), - ) - expected_object_link_pose = torch.cat((expected_root_link_pos, expected_root_link_quat), dim=1).view( - num_envs, -1, 7 - ) - link_pose_w = cube_object.data.body_link_pose_w.torch - link_vel_w = cube_object.data.body_link_vel_w.torch - # test both root_pose and root_link successfully updated when root_com updates - torch.testing.assert_close(expected_object_link_pose, link_pose_w) - # skip lin_vel because it differs from link frame, this should be fine because we are only checking - # if velocity update is triggered, which can be determined by comparing angular velocity - torch.testing.assert_close(com_vel_w[..., 3:], link_vel_w[..., 3:]) - torch.testing.assert_close(expected_object_link_pose, link_pose_w) - torch.testing.assert_close(com_vel_w[..., 3:], cube_object.data.body_com_vel_w.torch[..., 3:]) - elif state_location == "link": - link_pose_w = cube_object.data.body_link_pose_w.torch - link_vel_w = cube_object.data.body_link_vel_w.torch - expected_com_pos, expected_com_quat = combine_frame_transforms( - link_pose_w[..., :3].view(-1, 3), - link_pose_w[..., 3:].view(-1, 4), - object_link_to_com_pos, - object_link_to_com_quat, - ) - expected_object_com_pose = torch.cat((expected_com_pos, expected_com_quat), dim=1).view(num_envs, -1, 7) - com_pose_w = cube_object.data.body_com_pose_w.torch - com_vel_w = cube_object.data.body_com_vel_w.torch - # test both root_pose and root_com successfully updated when root_link updates - torch.testing.assert_close(expected_object_com_pose, com_pose_w) - # skip lin_vel because it differs from link frame, this should be fine because we are only checking - # if velocity update is triggered, which can be determined by comparing angular velocity - torch.testing.assert_close(link_vel_w[..., 3:], com_vel_w[..., 3:]) - torch.testing.assert_close(link_pose_w, cube_object.data.body_link_pose_w.torch) - torch.testing.assert_close(link_vel_w[..., 3:], cube_object.data.body_com_vel_w.torch[..., 3:]) - elif state_location == "root": - body_link_pose_w = cube_object.data.body_link_pose_w.torch - body_com_vel_w = cube_object.data.body_com_vel_w.torch - expected_object_com_pos, expected_object_com_quat = combine_frame_transforms( - body_link_pose_w[..., :3].view(-1, 3), - body_link_pose_w[..., 3:].view(-1, 4), - object_link_to_com_pos, - object_link_to_com_quat, - ) - expected_object_com_pose = torch.cat((expected_object_com_pos, expected_object_com_quat), dim=1).view( - num_envs, -1, 7 - ) - com_pose_w = cube_object.data.body_com_pose_w.torch - com_vel_w = cube_object.data.body_com_vel_w.torch - link_pose_w = cube_object.data.body_link_pose_w.torch - link_vel_w = cube_object.data.body_link_vel_w.torch - # test both root_com and root_link successfully updated when root_pose updates - torch.testing.assert_close(expected_object_com_pose, com_pose_w) - torch.testing.assert_close(body_com_vel_w, com_vel_w) - torch.testing.assert_close(body_link_pose_w, link_pose_w) - torch.testing.assert_close(body_com_vel_w[..., 3:], link_vel_w[..., 3:]) diff --git a/source/isaaclab_physx/test/assets/test_surface_gripper.py b/source/isaaclab_physx/test/assets/test_surface_gripper.py index 21adb913484a..951cb9d10d57 100644 --- a/source/isaaclab_physx/test/assets/test_surface_gripper.py +++ b/source/isaaclab_physx/test/assets/test_surface_gripper.py @@ -160,56 +160,6 @@ def sim(request): yield sim -@pytest.mark.parametrize("num_articulations", [1]) -@pytest.mark.parametrize("device", ["cpu"]) -@pytest.mark.parametrize("add_ground_plane", [True]) -@pytest.mark.isaacsim_ci -@pytest.mark.skipif( - _RUNNING_CI, - reason="Isaac Sim SurfaceGripperView initialization can deadlock in CI; keep CUDA fail-fast coverage only.", -) -def test_initialization(sim, num_articulations, device, add_ground_plane) -> None: - """Test initialization for articulation with a surface gripper. - - This test verifies that: - 1. The surface gripper is initialized correctly. - 2. The command and state buffers have the correct shapes. - 3. The command and state are initialized to the correct values. - - Args: - num_articulations: The number of articulations to initialize. - device: The device to run the test on. - add_ground_plane: Whether to add a ground plane to the simulation. - """ - if has_kit() and get_isaac_sim_version().major < 5: - return - surface_gripper_cfg, articulation_cfg = generate_surface_gripper_cfgs(kinematic_enabled=False) - surface_gripper, articulation, _ = generate_surface_gripper( - surface_gripper_cfg, articulation_cfg, num_articulations, device - ) - - sim.reset() - - assert articulation.is_initialized - assert surface_gripper.is_initialized - - # Check that the command and state buffers have the correct shapes - assert surface_gripper.command.shape == (num_articulations,) - assert surface_gripper.state.shape == (num_articulations,) - - # Check that the command and state are initialized to the correct values - assert wp.to_torch(surface_gripper.command).item() == 0.0 # Idle command after a reset - assert wp.to_torch(surface_gripper.state).item() == -1.0 # Open state after a reset - - # Simulate physics - for _ in range(10): - # perform rendering - sim.step() - # update articulation - articulation.update(sim.cfg.dt) - surface_gripper.update(sim.cfg.dt) - - @pytest.mark.parametrize("num_articulations", [1]) @pytest.mark.parametrize("device", ["cpu"]) @pytest.mark.parametrize("add_ground_plane", [True]) @@ -245,8 +195,12 @@ def test_close_and_open_command(sim, num_articulations, device, add_ground_plane sim.reset() + assert articulation.is_initialized assert surface_gripper.is_initialized - # after a reset the gripper is open (-1.0) + assert surface_gripper.command.shape == (num_articulations,) + assert surface_gripper.state.shape == (num_articulations,) + # after a reset the gripper is idle (0.0) and open (-1.0) + assert torch.all(wp.to_torch(surface_gripper.command) == 0.0) assert torch.all(wp.to_torch(surface_gripper.state) == -1.0) # send a single close command (the action term is edge-triggered, so commands are sent once) @@ -288,7 +242,7 @@ def test_raise_error_if_not_cpu(sim, device, add_ground_plane) -> None: surface_gripper_cfg, articulation_cfg, num_articulations, device ) - with pytest.raises(Exception): + with pytest.raises(Exception, match="only supported on CPU"): sim.reset() diff --git a/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_cfg.py b/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_cfg.py index b85171d60301..9401aeb3b065 100644 --- a/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_cfg.py +++ b/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_cfg.py @@ -6,15 +6,10 @@ """Tests for Isaac RTX renderer configuration.""" import isaaclab_physx.renderers as renderers -from isaaclab_physx.renderers.isaac_rtx_renderer_cfg import IsaacRtxRendererCfg, IsaacRtxRendererGlobalSettingsCfg +from isaaclab_physx.renderers.isaac_rtx_renderer_cfg import IsaacRtxRendererGlobalSettingsCfg def test_global_settings_cfg_is_publicly_exported(): """Test that the global settings config is exported from the renderer package.""" assert renderers.IsaacRtxRendererGlobalSettingsCfg is IsaacRtxRendererGlobalSettingsCfg assert "IsaacRtxRendererGlobalSettingsCfg" in renderers.__all__ - - -def test_global_view_preserves_launch_setting_by_default(): - """Renderer construction should preserve AppLauncher's spectator-view choice.""" - assert IsaacRtxRendererCfg().global_settings.show_all_partitions_by_default is None diff --git a/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_contract.py b/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_contract.py index 5f05e23f8420..91b8fcdc2814 100644 --- a/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_contract.py +++ b/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_contract.py @@ -46,19 +46,30 @@ def _install_omni_stubs(monkeypatch): return replicator_core_module, syntheticdata_module -def test_isaac_rtx_supported_output_types_include_rgb_hdr(monkeypatch): - """Isaac RTX advertises RGB_HDR as a 3-channel float renderer output.""" +@pytest.mark.parametrize("isaac_sim_version", ["5.1", "6.0"]) +def test_isaac_rtx_supported_output_types_include_rgb_hdr(monkeypatch, isaac_sim_version): + """Isaac RTX advertises RGB_HDR as a 3-channel float output; ALBEDO and simple shading need Isaac Sim 6.0+.""" _install_omni_stubs(monkeypatch) from isaaclab_physx.renderers.isaac_rtx_renderer import IsaacRtxRenderer from isaaclab_physx.renderers.isaac_rtx_renderer_cfg import IsaacRtxRendererCfg renderer = IsaacRtxRenderer.__new__(IsaacRtxRenderer) renderer.cfg = IsaacRtxRendererCfg() - with patch("isaaclab_physx.renderers.isaac_rtx_renderer.get_isaac_sim_version", return_value=version.parse("6.0")): + with patch( + "isaaclab_physx.renderers.isaac_rtx_renderer.get_isaac_sim_version", + return_value=version.parse(isaac_sim_version), + ): specs = renderer.supported_output_types() - assert specs == renderer.cfg.supported_output_types() assert specs[RenderBufferKind.RGB_HDR] == RenderBufferSpec(3, wp.float32) + requires_6_0 = [ + RenderBufferKind.ALBEDO, + RenderBufferKind.SIMPLE_SHADING_CONSTANT_DIFFUSE, + RenderBufferKind.SIMPLE_SHADING_DIFFUSE_MDL, + RenderBufferKind.SIMPLE_SHADING_FULL_MDL, + ] + is_supported = isaac_sim_version == "6.0" + assert all((kind in specs) is is_supported for kind in requires_6_0) def test_create_render_data_uses_unique_sdf_safe_render_product_name(monkeypatch): @@ -150,7 +161,6 @@ def test_create_render_data_uses_unique_sdf_safe_render_product_name(monkeypatch pytest.param(["simple_shading_constant_diffuse"], 1, True, id="constant_diffuse"), pytest.param(["simple_shading_diffuse_mdl"], 2, True, id="diffuse_mdl"), pytest.param(["simple_shading_full_mdl"], 3, True, id="full_mdl"), - pytest.param(["simple_shading_full_mdl", "simple_shading_full_mdl"], 3, True, id="duplicate_full_mdl"), pytest.param( ["simple_shading_constant_diffuse", "simple_shading_full_mdl"], 1, @@ -363,18 +373,19 @@ def test_init_applies_only_explicit_global_spectator_view_setting(monkeypatch, c settings = MagicMock() settings.get.return_value = False + # The unset case uses the default renderer config, which must preserve the launch setting. + if configured_value is None: + cfg = IsaacRtxRendererCfg() + else: + cfg = IsaacRtxRendererCfg( + global_settings=IsaacRtxRendererGlobalSettingsCfg(show_all_partitions_by_default=configured_value) + ) with ( patch.object(rtx_renderer, "get_settings_manager", return_value=settings), patch.object(rtx_renderer, "enable_extension"), patch.object(rtx_renderer, "ensure_rtx_hydra_engine_attached"), ): - rtx_renderer.IsaacRtxRenderer( - IsaacRtxRendererCfg( - global_settings=IsaacRtxRendererGlobalSettingsCfg( - show_all_partitions_by_default=configured_value, - ) - ) - ) + rtx_renderer.IsaacRtxRenderer(cfg) spectator_calls = [ setting_call @@ -424,8 +435,9 @@ def test_deterministic_flag_gates_rtx_determinism_settings(monkeypatch, stored, determinism_mock.assert_called_once_with(settings) -@pytest.mark.parametrize("data_type", ["rgba", "normals"]) -@pytest.mark.parametrize("batched", [False, True], ids=["single", "batch"]) +@pytest.mark.parametrize( + ("data_type", "batched"), [("rgba", False), ("normals", True)], ids=["rgba-single", "normals-batch"] +) def test_render_treats_empty_annotator_frame_as_not_ready(monkeypatch, data_type, batched): """An empty warm-up frame should clear its output without slicing or launching a reshape.""" _install_omni_stubs(monkeypatch) @@ -467,9 +479,7 @@ def test_render_treats_empty_annotator_frame_as_not_ready(monkeypatch, data_type @pytest.mark.parametrize( "states", [ - pytest.param((), id="empty"), pytest.param(("no_spec", "no_output"), id="uninitialized"), - pytest.param(("ready", "ready"), id="ready"), pytest.param(("no_spec", "ready", "no_output", "ready"), id="mixed"), ], ) diff --git a/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_rigid_object_rendering.py b/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_rigid_object_rendering.py index a89f5d43e271..fe21e53e3e64 100644 --- a/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_rigid_object_rendering.py +++ b/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_rigid_object_rendering.py @@ -31,8 +31,8 @@ pytestmark = [pytest.mark.integration, pytest.mark.rendering, pytest.mark.isaacsim_ci] -@pytest.mark.parametrize("with_articulation", [False, True]) -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +# The articulation scene is a superset of the plain scene, so each device builds one of them. +@pytest.mark.parametrize(("device", "with_articulation"), [("cuda:0", True), ("cpu", False)]) def test_kinematic_rigid_object_scale_and_pose_are_rendered(device: str, with_articulation: bool) -> None: """Kinematic PhysX transforms and root scale must reach Isaac RTX.""" run_rigid_object_scale_and_pose_rendering_contract( diff --git a/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_scene_partitioning.py b/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_scene_partitioning.py index decb4b92e5f6..bae8d73f28f6 100644 --- a/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_scene_partitioning.py +++ b/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_scene_partitioning.py @@ -62,44 +62,38 @@ def _isolation_renderer_cfg() -> IsaacRtxRendererCfg: @pytest.mark.isaacsim_ci -def test_partitioning_enabled_by_default(monkeypatch): - """``primvars:omni:scenePartition`` must be authored when the environment variable is absent.""" - from pxr import Usd - - monkeypatch.delenv(_ENV_VAR, raising=False) - - stage = Usd.Stage.CreateInMemory() - world = stage.DefinePrim("/World", "Xform") # noqa: F841 - env0 = stage.DefinePrim("/World/envs/env_0", "Xform") # noqa: F841 - - renderer = object.__new__(IsaacRtxRenderer) - renderer.cfg = IsaacRtxRendererCfg() - renderer.prepare_stage(stage, num_envs=1) - - prim = stage.GetPrimAtPath("/World/envs/env_0") - assert prim.HasAttribute("primvars:omni:scenePartition"), ( - "primvars:omni:scenePartition must be authored when partitioning uses its default." - ) - - -@pytest.mark.isaacsim_ci -@pytest.mark.parametrize(("cfg_enabled", "environment_value"), [(True, "0"), (False, "1")]) -def test_partitioning_cfg_overrides_legacy_environment_variable(monkeypatch, cfg_enabled: bool, environment_value: str): - """The renderer configuration should take precedence over the legacy environment variable.""" +@pytest.mark.parametrize( + ("cfg_enabled", "environment_value", "expected"), + [(True, "0", True), (False, "1", False), (None, None, True)], + ids=["cfg-enabled", "cfg-disabled", "default"], +) +def test_partitioning_cfg_overrides_legacy_environment_variable( + monkeypatch, cfg_enabled: bool | None, environment_value: str | None, expected: bool +): + """The renderer configuration should take precedence over the legacy environment variable. + + Without either, ``primvars:omni:scenePartition`` is authored by default. + """ from pxr import Usd - monkeypatch.setenv(_ENV_VAR, environment_value) + if environment_value is None: + monkeypatch.delenv(_ENV_VAR, raising=False) + else: + monkeypatch.setenv(_ENV_VAR, environment_value) stage = Usd.Stage.CreateInMemory() world = stage.DefinePrim("/World", "Xform") # noqa: F841 env0 = stage.DefinePrim("/World/envs/env_0", "Xform") # noqa: F841 renderer = object.__new__(IsaacRtxRenderer) - renderer.cfg = IsaacRtxRendererCfg(enable_scene_partitioning=cfg_enabled) + if cfg_enabled is None: + renderer.cfg = IsaacRtxRendererCfg() + else: + renderer.cfg = IsaacRtxRendererCfg(enable_scene_partitioning=cfg_enabled) renderer.prepare_stage(stage, num_envs=1) prim = stage.GetPrimAtPath("/World/envs/env_0") - assert prim.HasAttribute("primvars:omni:scenePartition") is cfg_enabled + assert prim.HasAttribute("primvars:omni:scenePartition") is expected @pytest.mark.isaacsim_ci diff --git a/source/isaaclab_physx/test/renderers/test_kit_viewport_utils.py b/source/isaaclab_physx/test/renderers/test_kit_viewport_utils.py index 487e2c2a8109..f8fa029acaa2 100644 --- a/source/isaaclab_physx/test/renderers/test_kit_viewport_utils.py +++ b/source/isaaclab_physx/test/renderers/test_kit_viewport_utils.py @@ -37,75 +37,6 @@ def test_set_kit_camera_view_uses_viewport_utility(monkeypatch): camera_state.set_target_world.assert_called_once_with((4.0, 5.0, 6.0), True) -def test_set_kit_camera_view_does_not_require_authored_center_of_interest(monkeypatch): - """Set the camera position without rotating around an unauthored center of interest.""" - - class _CameraState: - def __init__(self, camera_path, viewport_api): - self.position_calls = [] - self.target_calls = [] - camera_state_holder["state"] = self - - def set_position_world(self, world_position, rotate): - if rotate: - raise TypeError("Matrix4d.Transform(Matrix4d, NoneType)") - self.position_calls.append((world_position, rotate)) - - def set_target_world(self, world_target, rotate): - self.target_calls.append((world_target, rotate)) - - camera_state_holder = {} - utility_module = types.ModuleType("omni.kit.viewport.utility") - utility_module.get_active_viewport = MagicMock(return_value=object()) - camera_state_module = types.ModuleType("omni.kit.viewport.utility.camera_state") - camera_state_module.ViewportCameraState = _CameraState - gf_module = MagicMock() - gf_module.Vec3d.side_effect = lambda *values: values - pxr_module = types.ModuleType("pxr") - pxr_module.Gf = gf_module - - monkeypatch.setitem(sys.modules, "omni.kit.viewport.utility", utility_module) - monkeypatch.setitem(sys.modules, "omni.kit.viewport.utility.camera_state", camera_state_module) - monkeypatch.setitem(sys.modules, "pxr", pxr_module) - - _set_kit_camera_view((1.0, 2.0, 3.0), (4.0, 5.0, 6.0), "/OmniverseKit_Persp") - - state = camera_state_holder["state"] - assert state.position_calls == [((1.0, 2.0, 3.0), False)] - assert state.target_calls == [((4.0, 5.0, 6.0), True)] - - -def test_set_kit_camera_view_accepts_vertical_eye_target(monkeypatch): - """Accept cameras directly above or below their target.""" - viewport_api = object() - camera_state = MagicMock() - camera_state_type = MagicMock(return_value=camera_state) - utility_module = types.ModuleType("omni.kit.viewport.utility") - utility_module.get_active_viewport = MagicMock(return_value=viewport_api) - camera_state_module = types.ModuleType("omni.kit.viewport.utility.camera_state") - camera_state_module.ViewportCameraState = camera_state_type - gf_module = MagicMock() - gf_module.Vec3d.side_effect = lambda *values: values - pxr_module = types.ModuleType("pxr") - pxr_module.Gf = gf_module - - monkeypatch.setitem(sys.modules, "omni.kit.viewport.utility", utility_module) - monkeypatch.setitem(sys.modules, "omni.kit.viewport.utility.camera_state", camera_state_module) - monkeypatch.setitem(sys.modules, "pxr", pxr_module) - - _set_kit_camera_view((0.0, 0.0, 5.0), (0.0, 0.0, 0.0), "/Camera") - _set_kit_camera_view((0.0, 0.0, -5.0), (0.0, 0.0, 0.0), "/Camera") - - assert camera_state.set_position_world.call_args_list == [ - (((0.0, 0.0, 5.0), False),), - (((0.0, 0.0, -5.0), False),), - ] - assert camera_state.set_target_world.call_args_list == [ - (((0.0, 0.0, 0.0), True),), - (((0.0, 0.0, 0.0), True),), - ] - - def test_set_kit_camera_view_requires_active_viewport(monkeypatch): """Raise a clear error when Kit has no active viewport.""" utility_module = types.ModuleType("omni.kit.viewport.utility") diff --git a/source/isaaclab_physx/test/sensors/test_contact_sensor.py b/source/isaaclab_physx/test/sensors/test_contact_sensor.py index 381d314cf4b3..2adb804509e1 100644 --- a/source/isaaclab_physx/test/sensors/test_contact_sensor.py +++ b/source/isaaclab_physx/test/sensors/test_contact_sensor.py @@ -8,6 +8,7 @@ """Launch Isaac Sim Simulator first.""" from isaaclab.app import AppLauncher +from isaaclab.test.utils import DeviceScope, test_devices # launch omniverse app simulation_app = AppLauncher(headless=True).app @@ -206,10 +207,8 @@ def setup_simulation(): """Fixture to set up simulation parameters.""" sim_dt = 0.0025 durations = [sim_dt, sim_dt * 2, sim_dt * 32, sim_dt * 128] - terrains = [FLAT_TERRAIN_CFG, COBBLESTONE_TERRAIN_CFG] - devices = ["cuda:0", "cpu"] settings = get_settings_manager() - return sim_dt, durations, terrains, devices, settings + return sim_dt, durations, settings def _make_graph_test_sensor(device: str = "cuda:0") -> PhysxContactSensor: @@ -352,39 +351,49 @@ def test_contact_sensor_invalidation_drops_cached_graph_state(monkeypatch): assert getattr(sensor, attr) is None, attr -@pytest.mark.parametrize("disable_contact_processing", [True, False]) +# Each terrain and device is covered by one of the two shapes; the CPU and GPU contact pipelines differ. +@pytest.mark.parametrize( + ("device", "terrain"), + [("cuda:0", FLAT_TERRAIN_CFG), ("cpu", COBBLESTONE_TERRAIN_CFG)], + ids=["cuda-plane", "cpu-mesh"], +) @flaky(max_runs=5, min_passes=1) -def test_cube_contact_time(setup_simulation, disable_contact_processing): +def test_cube_contact_time(setup_simulation, device, terrain): """Checks contact sensor values for contact time and air time for a cube collision primitive.""" - # check for both contact processing enabled and disabled - # internally, the contact sensor should enable contact processing so it should always work. - sim_dt, durations, terrains, devices, settings = setup_simulation - settings.set_bool("/physics/disableContactProcessing", disable_contact_processing) - _run_contact_sensor_test(CUBE_CFG, sim_dt, devices, terrains, settings, durations) + sim_dt, durations, _ = setup_simulation + _run_contact_sensor_test(CUBE_CFG, sim_dt, device, terrain, False, durations) -@pytest.mark.parametrize("disable_contact_processing", [True, False]) +# Contact points and friction forces are only checked for the sphere on the plane (analytic contact point). +@pytest.mark.parametrize( + ("device", "terrain", "track_contact_data"), + [ + ("cuda:0", COBBLESTONE_TERRAIN_CFG, False), + ("cpu", FLAT_TERRAIN_CFG, False), + ("cpu", FLAT_TERRAIN_CFG, True), + ("cuda:0", FLAT_TERRAIN_CFG, True), + ], + ids=["cuda-mesh", "cpu-plane", "cpu-plane-contact-data", "cuda-plane-contact-data"], +) @flaky(max_runs=5, min_passes=1) -def test_sphere_contact_time(setup_simulation, disable_contact_processing): +def test_sphere_contact_time(setup_simulation, device, terrain, track_contact_data): """Checks contact sensor values for contact time and air time for a sphere collision primitive.""" - # check for both contact processing enabled and disabled - # internally, the contact sensor should enable contact processing so it should always work. - sim_dt, durations, terrains, devices, settings = setup_simulation - settings.set_bool("/physics/disableContactProcessing", disable_contact_processing) - _run_contact_sensor_test(SPHERE_CFG, sim_dt, devices, terrains, settings, durations) + sim_dt, durations, _ = setup_simulation + _run_contact_sensor_test(SPHERE_CFG, sim_dt, device, terrain, track_contact_data, durations) -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -@pytest.mark.parametrize("clock_age", [2.5, 10.0, 30.0]) -def test_first_transition_with_aged_clock(setup_simulation, device, clock_age): +@pytest.mark.parametrize("device", test_devices(DeviceScope.DEFAULT_CUDA)) +def test_first_transition_with_aged_clock(setup_simulation, device): """Regression for #7283: transitions must still be reported once the sensor clock has aged. The sensor clock is a float32 accumulator whose rounding error grows with simulated time. On a transition step the contact (resp. air) timer is exactly one polling period, so the default tolerance of :meth:`ContactSensor.compute_first_contact` has to absorb that error. A fixed 1e-8 - tolerance is orders of magnitude too small after a few seconds of simulated time. + tolerance is orders of magnitude too small after a few seconds of simulated time. The drift grows with + the clock, so a single long age covers the shorter ones. """ sim_dt = setup_simulation[0] + clock_age = 30.0 # The sensor keeps history, so it refreshes every physics step and is polled at that same rate. # The lazy (zero-history) cadence is covered by the kernel-level tolerance test: on GPU this # backend serves stale contact forces when buffers refresh only on data access, which is @@ -473,10 +482,10 @@ def _hold(pose: torch.Tensor, num_steps: int) -> None: @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -@pytest.mark.parametrize("num_envs", [1, 6, 24]) -def test_cube_stack_contact_filtering(setup_simulation, device, num_envs): +def test_cube_stack_contact_filtering(setup_simulation, device): """Checks contact sensor reporting for filtering stacked cube prims.""" - sim_dt, durations, terrains, devices, settings = setup_simulation + sim_dt, _, settings = setup_simulation + num_envs = 6 with build_simulation_context(device=device, dt=sim_dt, add_lighting=True) as sim: sim._app_control_on_stop_handle = None # Instance new scene for the current terrain and contact prim. @@ -568,9 +577,8 @@ def _author_nested_chain(prim_path: str): @pytest.mark.isaacsim_ci -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -@pytest.mark.parametrize("num_envs", [1, 6]) -def test_nested_rigid_body_hierarchy(setup_simulation, device, num_envs): +@pytest.mark.parametrize("device", test_devices(DeviceScope.DEFAULT_CUDA)) +def test_nested_rigid_body_hierarchy(setup_simulation, device): """Checks contact sensor creation and per-body data ordering on nested rigid-body hierarchies. Regression test for issue #5126: the sensor views were created from a single @@ -580,7 +588,9 @@ def test_nested_rigid_body_hierarchy(setup_simulation, device, num_envs): The chains are authored directly under each environment prim (no scene/cloner): the sensor path under test only depends on the prims existing on the stage. """ - sim_dt, durations, terrains, devices, settings = setup_simulation + sim_dt = setup_simulation[0] + # several envs are needed to distinguish body-major from env-major ordering + num_envs = 6 with build_simulation_context(device=device, dt=sim_dt, add_lighting=False) as sim: sim._app_control_on_stop_handle = None stage = get_current_stage() @@ -619,69 +629,6 @@ def test_nested_rigid_body_hierarchy(setup_simulation, device, num_envs): torch.testing.assert_close(pos_w[env_id, body_id], expected, atol=1e-4, rtol=0.0) -def test_no_contact_reporting(setup_simulation): - """Test that forcing the disable of contact processing results in no contact reporting. - - We borrow the test :func:`test_cube_stack_contact_filtering` to test this and force disable contact processing. - """ - # TODO: This test only works on CPU. For GPU, it seems the contact processing is not disabled. - sim_dt, durations, terrains, devices, settings = setup_simulation - with build_simulation_context(device="cpu", dt=sim_dt, add_lighting=True) as sim: - sim._app_control_on_stop_handle = None - # Instance new scene for the current terrain and contact prim. - scene_cfg = ContactSensorSceneCfg(num_envs=2, env_spacing=1.0, lazy_sensor_update=False) - scene_cfg.terrain = FLAT_TERRAIN_CFG - # -- cube 1 - scene_cfg.shape = CUBE_CFG.replace(prim_path="{ENV_REGEX_NS}/Cube_1") - scene_cfg.shape.init_state.pos = (0, -1.0, 1.0) - # -- cube 2 (on top of cube 1) - scene_cfg.shape_2 = CUBE_CFG.replace(prim_path="{ENV_REGEX_NS}/Cube_2") - scene_cfg.shape_2.init_state.pos = (0, -1.0, 1.525) - # -- contact sensor 1 - scene_cfg.contact_sensor = ContactSensorCfg( - prim_path="{ENV_REGEX_NS}/Cube_1", - track_pose=True, - debug_vis=False, - update_period=0.0, - filter_prim_paths_expr=["{ENV_REGEX_NS}/Cube_2"], - ) - # -- contact sensor 2 - scene_cfg.contact_sensor_2 = ContactSensorCfg( - prim_path="{ENV_REGEX_NS}/Cube_2", - track_pose=True, - debug_vis=False, - update_period=0.0, - filter_prim_paths_expr=["{ENV_REGEX_NS}/Cube_1"], - ) - scene = InteractiveScene(scene_cfg) - - # Force disable contact processing - settings.set_bool("/physics/disableContactProcessing", True) - - # Set variables internally for reference - sim.reset() - - # Extract from scene for type hinting - contact_sensor: ContactSensor = scene["contact_sensor"] - contact_sensor_2: ContactSensor = scene["contact_sensor_2"] - - # Check buffers have the right size - assert contact_sensor.contact_view.filter_count == 1 - assert contact_sensor_2.contact_view.filter_count == 1 - - # Reset the contact sensors - scene.reset() - # Let the scene come to a rest - for _ in range(500): - _perform_sim_step(sim, scene, sim_dt) - - # check values are zero (contacts are happening but not reported) - assert contact_sensor.data.net_normal_forces_w.torch.sum().item() == 0.0 - assert contact_sensor.data.normal_force_matrix_w.torch.sum().item() == 0.0 - assert contact_sensor_2.data.net_normal_forces_w.torch.sum().item() == 0.0 - assert contact_sensor_2.data.normal_force_matrix_w.torch.sum().item() == 0.0 - - @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) def test_contact_sensor_no_stale_data_after_reset(setup_simulation, device): """Regression for issue #4970: ``scene.reset(env_ids)`` must not surface pre-reset contact data. @@ -691,7 +638,7 @@ def test_contact_sensor_no_stale_data_after_reset(setup_simulation, device): happens before any further physics step. The contact sensor's lazy refetch must not return PhysX's stale post-step buffer for the reset env — it must reflect the freshly reset state. """ - sim_dt, _, _, _, settings = setup_simulation + sim_dt, _, settings = setup_simulation settings.set_bool("/physics/disableContactProcessing", False) with build_simulation_context(device=device, dt=sim_dt, add_lighting=True) as sim: sim._app_control_on_stop_handle = None @@ -736,7 +683,7 @@ def test_contact_sensor_no_stale_data_after_reset(setup_simulation, device): ) -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("device", test_devices(DeviceScope.DEFAULT_CUDA)) @pytest.mark.parametrize( ("history_length", "update_period_steps", "expected_fetches", "expected_last_update"), [(0, 1, 4, 0.0), (3, 1, 4, 0.01), (3, 2, 4, 0.0075)], @@ -750,7 +697,7 @@ def test_contact_history_updates_at_sensor_period( issue #7613), but a history-less sensor only recomputes its data (and therefore its timestamps) on data access. """ - sim_dt, _, _, _, settings = setup_simulation + sim_dt, _, settings = setup_simulation settings.set_bool("/physics/disableContactProcessing", False) with build_simulation_context(device=device, dt=sim_dt, add_lighting=False) as sim: sim._app_control_on_stop_handle = None @@ -797,7 +744,7 @@ def count_fetches(**kwargs) -> None: assert fetch_count == expected_fetches_after_read -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("device", [pytest.param("cuda:0", marks=pytest.mark.isaacsim_ci), "cpu"]) def test_lazy_sensor_reports_contact_loss(setup_simulation, device): """Regression for issue #7613: a lazily read sensor must report the loss of contact. @@ -806,8 +753,11 @@ def test_lazy_sensor_reports_contact_loss(setup_simulation, device): its PhysX getters only when :attr:`ContactSensor.data` was accessed, so a policy-rate reader (one read per four physics steps) skipped the contact-loss step and kept reporting the last in-contact force until the next touchdown. + + Also checks that the USD contact-report threshold is 0.0 and that the sensor prints its summary + (regression for #3498, where the spawner passed ``activate_contact_sensors`` as the threshold). """ - sim_dt, _, _, _, settings = setup_simulation + sim_dt, _, settings = setup_simulation settings.set_bool("/physics/disableContactProcessing", False) with build_simulation_context(device=device, dt=sim_dt, add_lighting=False) as sim: sim._app_control_on_stop_handle = None @@ -826,6 +776,14 @@ def test_lazy_sensor_reports_contact_loss(setup_simulation, device): sensor: ContactSensor = scene["contact_sensor"] shape: RigidObject = scene["shape"] + assert "number of bodies : 1" in str(sensor) + prim = get_current_stage().GetPrimAtPath(CUBE_CFG.prim_path) + assert "PhysxContactReportAPI" in prim.GetAppliedSchemas() + threshold_value = prim.GetAttribute("physxContactReport:threshold").Get() + assert threshold_value == pytest.approx(0.0, abs=1e-6), ( + f"Expected USD threshold to be close to 0.0, but got {threshold_value}" + ) + contact_pose = CUBE_CFG.contact_pose.to(device=shape.device).unsqueeze(0) non_contact_pose = CUBE_CFG.non_contact_pose.to(device=shape.device).unsqueeze(0) @@ -852,81 +810,16 @@ def run_policy_step(root_pose: torch.Tensor) -> float: @pytest.mark.isaacsim_ci -def test_sensor_print(setup_simulation): - """Test sensor print is working correctly.""" - sim_dt, durations, terrains, devices, settings = setup_simulation - with build_simulation_context(device="cuda:0", dt=sim_dt, add_lighting=False) as sim: - sim._app_control_on_stop_handle = None - # Spawn things into stage - scene_cfg = ContactSensorSceneCfg(num_envs=1, env_spacing=1.0, lazy_sensor_update=False) - scene_cfg.terrain = FLAT_TERRAIN_CFG.replace(prim_path="/World/ground") - scene_cfg.shape = CUBE_CFG - scene_cfg.contact_sensor = ContactSensorCfg( - prim_path=scene_cfg.shape.prim_path, - track_pose=True, - debug_vis=False, - update_period=0.0, - track_air_time=True, - history_length=3, - ) - scene = InteractiveScene(scene_cfg) - # Play the simulator - sim.reset() - # print info - print(scene.sensors["contact_sensor"]) - - -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -def test_contact_sensor_threshold(setup_simulation, device): - """Test that the contact sensor USD threshold attribute is set to 0.0.""" - sim_dt, durations, terrains, devices, settings = setup_simulation - with build_simulation_context(device=device, dt=sim_dt, add_lighting=False) as sim: - sim._app_control_on_stop_handle = None - # Spawn things into stage - scene_cfg = ContactSensorSceneCfg(num_envs=1, env_spacing=1.0, lazy_sensor_update=False) - scene_cfg.terrain = FLAT_TERRAIN_CFG.replace(prim_path="/World/ground") - scene_cfg.shape = CUBE_CFG - scene_cfg.contact_sensor = ContactSensorCfg( - prim_path=scene_cfg.shape.prim_path, - track_pose=True, - debug_vis=False, - update_period=0.0, - track_air_time=True, - history_length=3, - ) - scene = InteractiveScene(scene_cfg) - # Play the simulator - sim.reset() - - stage = get_current_stage() - prim_path = scene_cfg.shape.prim_path - prim = stage.GetPrimAtPath(prim_path) - - # Ensure the contact sensor was created properly - contact_sensor = scene["contact_sensor"] - assert contact_sensor is not None, "Contact sensor was not created" - - # Check if the prim has contact report API and verify threshold is close to 0.0 - if "PhysxContactReportAPI" in prim.GetAppliedSchemas(): - threshold_attr = prim.GetAttribute("physxContactReport:threshold") - if threshold_attr.IsValid(): - threshold_value = threshold_attr.Get() - assert pytest.approx(threshold_value, abs=1e-6) == 0.0, ( - f"Expected USD threshold to be close to 0.0, but got {threshold_value}" - ) - - -# minor gravity force in -z to ensure object stays on ground plane -@pytest.mark.parametrize("grav_dir", [(-10.0, 0.0, -0.1), (0.0, -10.0, -0.1)]) -@pytest.mark.isaacsim_ci -def test_friction_reporting(setup_simulation, grav_dir): +def test_friction_reporting(setup_simulation): """ Test friction force reporting for contact sensors. - This test places a contact sensor enabled cube onto a ground plane under different gravity directions. - It then compares the normalized friction force dir with the direction of gravity to ensure they are aligned. + This test places a contact sensor enabled cube onto a ground plane under a mostly horizontal gravity. + The friction force on the cube must oppose the gravity-driven slide. """ - sim_dt, _, _, _, settings = setup_simulation + # minor gravity force in -z to ensure object stays on ground plane + grav_dir = (-10.0, 0.0, -0.1) + sim_dt, _, settings = setup_simulation settings.set_bool("/physics/disableContactProcessing", True) device = "cuda:0" sim_cfg = SimulationCfg(dt=sim_dt, device=device, gravity=grav_dir) @@ -962,25 +855,28 @@ def test_friction_reporting(setup_simulation, grav_dir): # step sim once to compute friction forces _perform_sim_step(sim, scene, sim_dt) - # check that forces are being reported match expected friction forces - expected_friction, _, _, _ = scene["contact_sensor"].contact_view.get_friction_data(dt=sim_dt) - expected_friction_torch = wp.to_torch(expected_friction) + # check that the friction force on the cube opposes the gravity direction (signed, so a flip is caught) reported_friction = scene["contact_sensor"].data.friction_force_matrix_w.torch[0, 0, :] - - torch.testing.assert_close(expected_friction_torch.sum(dim=0), reported_friction[0], atol=1e-6, rtol=1e-5) - - # check that friction force direction opposes gravity direction grav = torch.tensor(grav_dir, device=device) norm_reported_friction = reported_friction / reported_friction.norm() norm_gravity = grav / grav.norm() dot = torch.dot(norm_reported_friction[0], norm_gravity) - torch.testing.assert_close(torch.abs(dot), torch.tensor(1.0, device=device), atol=1e-4, rtol=1e-3) + torch.testing.assert_close(dot, torch.tensor(-1.0, device=device), atol=1e-4, rtol=1e-3) @pytest.mark.isaacsim_ci -def test_invalid_prim_paths_config(setup_simulation): - sim_dt, _, _, _, settings = setup_simulation +@pytest.mark.parametrize( + ("use_filter", "max_contact_data_count_per_prim", "match"), + [ + (False, None, "'filter_prim_paths_expr' is empty"), + (True, 0, "'max_contact_data_count_per_prim' is 0"), + ], + ids=["empty_filter", "zero_max_contact_data"], +) +def test_invalid_contact_data_config(setup_simulation, use_filter, max_contact_data_count_per_prim, match): + """Tracking friction forces requires a filter pattern and a positive contact data budget.""" + sim_dt, _, settings = setup_simulation settings.set_bool("/physics/disableContactProcessing", True) device = "cuda:0" sim_cfg = SimulationCfg(dt=sim_dt, device=device) @@ -990,6 +886,7 @@ def test_invalid_prim_paths_config(setup_simulation): scene_cfg = ContactSensorSceneCfg(num_envs=1, env_spacing=1.0, lazy_sensor_update=False) scene_cfg.terrain = FLAT_TERRAIN_CFG scene_cfg.shape = CUBE_CFG + filter_prim_paths_expr = [scene_cfg.terrain.prim_path + "/terrain/GroundPlane/CollisionPlane"] scene_cfg.contact_sensor = ContactSensorCfg( prim_path=scene_cfg.shape.prim_path, @@ -999,134 +896,73 @@ def test_invalid_prim_paths_config(setup_simulation): track_air_time=True, history_length=3, track_friction_forces=True, - filter_prim_paths_expr=[], + filter_prim_paths_expr=filter_prim_paths_expr if use_filter else [], + max_contact_data_count_per_prim=max_contact_data_count_per_prim, ) - try: + with pytest.raises(ValueError, match=match): _ = InteractiveScene(scene_cfg) - sim.reset() - assert False, "Expected ValueError due to invalid contact sensor configuration." - except ValueError: - pass +""" +Internal helpers. +""" -@pytest.mark.isaacsim_ci -def test_invalid_max_contact_points_config(setup_simulation): - sim_dt, _, _, _, settings = setup_simulation - settings.set_bool("/physics/disableContactProcessing", True) - device = "cuda:0" - sim_cfg = SimulationCfg(dt=sim_dt, device=device) - with build_simulation_context(sim_cfg=sim_cfg, add_lighting=False) as sim: + +def _run_contact_sensor_test( + shape_cfg: ContactSensorRigidObjectCfg, + sim_dt: float, + device: str, + terrain: TerrainImporterCfg, + track_contact_data: bool, + durations: list[float], +): + """Runs the contact and air time tests for a given contact primitive, device, and terrain. + + Contact points and friction forces are only checked for the sphere on the flat plane, where the + expected contact point is known analytically. + """ + with build_simulation_context(device=device, dt=sim_dt, add_lighting=True) as sim: sim._app_control_on_stop_handle = None scene_cfg = ContactSensorSceneCfg(num_envs=1, env_spacing=1.0, lazy_sensor_update=False) - scene_cfg.terrain = FLAT_TERRAIN_CFG - scene_cfg.shape = CUBE_CFG - filter_prim_paths_expr = [scene_cfg.terrain.prim_path + "/terrain/GroundPlane/CollisionPlane"] + scene_cfg.terrain = terrain + scene_cfg.shape = shape_cfg + test_contact_data = type(shape_cfg.spawn) is sim_utils.SphereCfg and terrain.terrain_type == "plane" + + filter_prim_paths_expr = [] + if track_contact_data: + filter_prim_paths_expr = [terrain.prim_path + "/terrain/GroundPlane/CollisionPlane"] scene_cfg.contact_sensor = ContactSensorCfg( - prim_path=scene_cfg.shape.prim_path, + prim_path=shape_cfg.prim_path, track_pose=True, debug_vis=False, update_period=0.0, track_air_time=True, history_length=3, - track_friction_forces=True, + track_contact_points=track_contact_data, + track_friction_forces=track_contact_data, filter_prim_paths_expr=filter_prim_paths_expr, - max_contact_data_count_per_prim=0, ) + scene = InteractiveScene(scene_cfg) - try: - _ = InteractiveScene(scene_cfg) - - sim.reset() - - assert False, "Expected ValueError due to invalid contact sensor configuration." - except ValueError: - pass - - -""" -Internal helpers. -""" - - -def _run_contact_sensor_test( - shape_cfg: ContactSensorRigidObjectCfg, - sim_dt: float, - devices: list[str], - terrains: list[TerrainImporterCfg], - settings, - durations: list[float], -): - """ - Runs a rigid body test for a given contact primitive configuration. + # Play the simulation + sim.reset() - This method iterates through each device and terrain combination in the simulation environment, - running tests for contact sensors. - """ - for device in devices: - for terrain in terrains: - for track_contact_data in [True, False]: - with build_simulation_context(device=device, dt=sim_dt, add_lighting=True) as sim: - sim._app_control_on_stop_handle = None - - scene_cfg = ContactSensorSceneCfg(num_envs=1, env_spacing=1.0, lazy_sensor_update=False) - scene_cfg.terrain = terrain - scene_cfg.shape = shape_cfg - test_contact_data = False - if (type(shape_cfg.spawn) is sim_utils.SphereCfg) and (terrain.terrain_type == "plane"): - test_contact_data = True - elif track_contact_data: - continue - - if track_contact_data: - if terrain.terrain_type == "plane": - filter_prim_paths_expr = [terrain.prim_path + "/terrain/GroundPlane/CollisionPlane"] - elif terrain.terrain_type == "generator": - filter_prim_paths_expr = [terrain.prim_path + "/terrain/mesh"] - else: - filter_prim_paths_expr = [] - - scene_cfg.contact_sensor = ContactSensorCfg( - prim_path=shape_cfg.prim_path, - track_pose=True, - debug_vis=False, - update_period=0.0, - track_air_time=True, - history_length=3, - track_contact_points=track_contact_data, - track_friction_forces=track_contact_data, - filter_prim_paths_expr=filter_prim_paths_expr, - ) - scene = InteractiveScene(scene_cfg) - - # Play the simulation - sim.reset() - - # Run contact time and air time tests. - _test_sensor_contact( - shape=scene["shape"], - sensor=scene["contact_sensor"], - mode=ContactTestMode.IN_CONTACT, - sim=sim, - scene=scene, - sim_dt=sim_dt, - durations=durations, - test_contact_data=test_contact_data, - ) - _test_sensor_contact( - shape=scene["shape"], - sensor=scene["contact_sensor"], - mode=ContactTestMode.NON_CONTACT, - sim=sim, - scene=scene, - sim_dt=sim_dt, - durations=durations, - test_contact_data=test_contact_data, - ) + # Run contact time and air time tests. + for mode in (ContactTestMode.IN_CONTACT, ContactTestMode.NON_CONTACT): + _test_sensor_contact( + shape=scene["shape"], + sensor=scene["contact_sensor"], + mode=mode, + sim=sim, + scene=scene, + sim_dt=sim_dt, + durations=durations, + test_contact_data=test_contact_data, + ) def _test_sensor_contact( diff --git a/source/isaaclab_physx/test/sensors/test_frame_transformer.py b/source/isaaclab_physx/test/sensors/test_frame_transformer.py index 21e497cf8f08..73d63ac5fdf6 100644 --- a/source/isaaclab_physx/test/sensors/test_frame_transformer.py +++ b/source/isaaclab_physx/test/sensors/test_frame_transformer.py @@ -91,6 +91,7 @@ def sim(request): # Cleanup is handled by build_simulation_context +@pytest.mark.isaacsim_ci @pytest.mark.parametrize( "sim", [ @@ -100,9 +101,15 @@ def sim(request): indirect=True, ) def test_frame_transformer_feet_wrt_base(sim): - """Test feet transformations w.r.t. base source frame. + """Test frame transformers with different source and target frames in one scene. + + The sensors share one robot and cube scene: - In this test, the source frame is the robot base. + * ``frame_transformer``: feet (rotated offsets) w.r.t. the robot base. + * ``ft_thigh``: feet w.r.t. a non-root thigh source, preserving the target name order. + * ``ft_cube``: a target on another asset (the cube) w.r.t. the robot base. + * ``ft_cube_offsets``: source equal to target with pure-translation offsets on one body. + * ``ft_all``: all robot bodies through a regex target. """ # Spawn things into stage scene_cfg = MySceneCfg(num_envs=2, env_spacing=5.0, lazy_sensor_update=False) @@ -143,96 +150,7 @@ def test_frame_transformer_feet_wrt_base(sim): ), ], ) - scene = InteractiveScene(scene_cfg) - - # Play the simulator - sim.reset() - - # Acquire the index of ground truth bodies - feet_indices, feet_names = scene.articulations["robot"].find_bodies(["LF_FOOT", "RF_FOOT", "LH_FOOT", "RH_FOOT"]) - - target_frame_names = scene.sensors["frame_transformer"].data.target_frame_names - - # Reorder the feet indices to match the order of the target frames with _USER suffix removed - target_frame_names = [name.split("_USER")[0] for name in target_frame_names] - - # Find the indices of the feet in the order of the target frames - reordering_indices = [feet_names.index(name) for name in target_frame_names] - feet_indices = [feet_indices[i] for i in reordering_indices] - - # default joint targets - default_actions = scene.articulations["robot"].data.default_joint_pos.torch.clone() - # Define simulation stepping - sim_dt = sim.get_physics_dt() - # Simulate physics - for count in range(100): - # # reset - if count % 25 == 0: - # reset root state - root_state = torch.cat( - ( - scene.articulations["robot"].data.default_root_pose.torch, - scene.articulations["robot"].data.default_root_vel.torch, - ), - dim=-1, - ).clone() - root_state[:, :3] += scene.env_origins - joint_pos = scene.articulations["robot"].data.default_joint_pos.torch - joint_vel = scene.articulations["robot"].data.default_joint_vel.torch - # -- set root state - # -- robot - scene.articulations["robot"].write_root_pose_to_sim_index(root_pose=root_state[:, :7]) - scene.articulations["robot"].write_root_velocity_to_sim_index(root_velocity=root_state[:, 7:]) - scene.articulations["robot"].write_joint_position_to_sim_index(position=joint_pos) - scene.articulations["robot"].write_joint_velocity_to_sim_index(velocity=joint_vel) - # reset buffers - scene.reset() - - # set joint targets - robot_actions = default_actions + 0.5 * torch.randn_like(default_actions) - scene.articulations["robot"].set_joint_position_target_index(target=robot_actions) - # write data to sim - scene.write_data_to_sim() - # perform step - sim.step() - # read data from sim - scene.update(sim_dt) - - # check absolute frame transforms in world frame - # -- ground-truth - root_pose_w = scene.articulations["robot"].data.root_pose_w.torch - feet_pos_w_gt = scene.articulations["robot"].data.body_pos_w.torch[:, feet_indices] - feet_quat_w_gt = scene.articulations["robot"].data.body_quat_w.torch[:, feet_indices] - # -- frame transformer - source_pos_w_tf = scene.sensors["frame_transformer"].data.source_pos_w.torch - source_quat_w_tf = scene.sensors["frame_transformer"].data.source_quat_w.torch - feet_pos_w_tf = scene.sensors["frame_transformer"].data.target_pos_w.torch - feet_quat_w_tf = scene.sensors["frame_transformer"].data.target_quat_w.torch - - # check if they are same - torch.testing.assert_close(root_pose_w[:, :3], source_pos_w_tf) - torch.testing.assert_close(root_pose_w[:, 3:], source_quat_w_tf) - torch.testing.assert_close(feet_pos_w_gt, feet_pos_w_tf) - torch.testing.assert_close(feet_quat_w_gt, feet_quat_w_tf) - - # check if relative transforms are same - feet_pos_source_tf = scene.sensors["frame_transformer"].data.target_pos_source.torch - feet_quat_source_tf = scene.sensors["frame_transformer"].data.target_quat_source.torch - for index in range(len(feet_indices)): - # ground-truth - foot_pos_b, foot_quat_b = math_utils.subtract_frame_transforms( - root_pose_w[:, :3], root_pose_w[:, 3:], feet_pos_w_tf[:, index], feet_quat_w_tf[:, index] - ) - # check if they are same - torch.testing.assert_close(feet_pos_source_tf[:, index], foot_pos_b) - torch.testing.assert_close(feet_quat_source_tf[:, index], foot_quat_b) - - -def test_frame_transformer_feet_wrt_thigh(sim): - """Test feet transformation w.r.t. thigh source frame.""" - # Spawn things into stage - scene_cfg = MySceneCfg(num_envs=2, env_spacing=5.0, lazy_sensor_update=False) - scene_cfg.frame_transformer = FrameTransformerCfg( + scene_cfg.ft_thigh = FrameTransformerCfg( prim_path="{ENV_REGEX_NS}/Robot/LF_THIGH", target_frames=[ FrameTransformerCfg.FrameCfg( @@ -253,90 +171,7 @@ def test_frame_transformer_feet_wrt_thigh(sim): ), ], ) - scene = InteractiveScene(scene_cfg) - - # Play the simulator - sim.reset() - - # Acquire the index of ground truth bodies - source_frame_index = scene.articulations["robot"].find_bodies("LF_THIGH")[0][0] - feet_indices, feet_names = scene.articulations["robot"].find_bodies(["LF_FOOT", "RF_FOOT"]) - # Check names are parsed the same order - user_feet_names = [f"{name}_USER" for name in feet_names] - assert scene.sensors["frame_transformer"].data.target_frame_names == user_feet_names - - # default joint targets - default_actions = scene.articulations["robot"].data.default_joint_pos.torch.clone() - # Define simulation stepping - sim_dt = sim.get_physics_dt() - # Simulate physics - for count in range(100): - # # reset - if count % 25 == 0: - # reset root state - root_state = torch.cat( - ( - scene.articulations["robot"].data.default_root_pose.torch, - scene.articulations["robot"].data.default_root_vel.torch, - ), - dim=-1, - ).clone() - root_state[:, :3] += scene.env_origins - joint_pos = scene.articulations["robot"].data.default_joint_pos.torch - joint_vel = scene.articulations["robot"].data.default_joint_vel.torch - # -- set root state - # -- robot - scene.articulations["robot"].write_root_pose_to_sim_index(root_pose=root_state[:, :7]) - scene.articulations["robot"].write_root_velocity_to_sim_index(root_velocity=root_state[:, 7:]) - scene.articulations["robot"].write_joint_position_to_sim_index(position=joint_pos) - scene.articulations["robot"].write_joint_velocity_to_sim_index(velocity=joint_vel) - # reset buffers - scene.reset() - - # set joint targets - robot_actions = default_actions + 0.5 * torch.randn_like(default_actions) - scene.articulations["robot"].set_joint_position_target_index(target=robot_actions) - # write data to sim - scene.write_data_to_sim() - # perform step - sim.step() - # read data from sim - scene.update(sim_dt) - - # check absolute frame transforms in world frame - # -- ground-truth - source_pose_w_gt = scene.articulations["robot"].data.body_state_w.torch[:, source_frame_index, :7] - feet_pos_w_gt = scene.articulations["robot"].data.body_pos_w.torch[:, feet_indices] - feet_quat_w_gt = scene.articulations["robot"].data.body_quat_w.torch[:, feet_indices] - # -- frame transformer - source_pos_w_tf = scene.sensors["frame_transformer"].data.source_pos_w.torch - source_quat_w_tf = scene.sensors["frame_transformer"].data.source_quat_w.torch - feet_pos_w_tf = scene.sensors["frame_transformer"].data.target_pos_w.torch - feet_quat_w_tf = scene.sensors["frame_transformer"].data.target_quat_w.torch - # check if they are same - torch.testing.assert_close(source_pose_w_gt[:, :3], source_pos_w_tf) - torch.testing.assert_close(source_pose_w_gt[:, 3:], source_quat_w_tf) - torch.testing.assert_close(feet_pos_w_gt, feet_pos_w_tf) - torch.testing.assert_close(feet_quat_w_gt, feet_quat_w_tf) - - # check if relative transforms are same - feet_pos_source_tf = scene.sensors["frame_transformer"].data.target_pos_source.torch - feet_quat_source_tf = scene.sensors["frame_transformer"].data.target_quat_source.torch - for index in range(len(feet_indices)): - # ground-truth - foot_pos_b, foot_quat_b = math_utils.subtract_frame_transforms( - source_pose_w_gt[:, :3], source_pose_w_gt[:, 3:], feet_pos_w_tf[:, index], feet_quat_w_tf[:, index] - ) - # check if they are same - torch.testing.assert_close(feet_pos_source_tf[:, index], foot_pos_b) - torch.testing.assert_close(feet_quat_source_tf[:, index], foot_quat_b) - - -def test_frame_transformer_robot_body_to_external_cube(sim): - """Test transformation from robot body to a cube in the scene.""" - # Spawn things into stage - scene_cfg = MySceneCfg(num_envs=2, env_spacing=5.0, lazy_sensor_update=False) - scene_cfg.frame_transformer = FrameTransformerCfg( + scene_cfg.ft_cube = FrameTransformerCfg( prim_path="{ENV_REGEX_NS}/Robot/base", target_frames=[ FrameTransformerCfg.FrameCfg( @@ -345,87 +180,7 @@ def test_frame_transformer_robot_body_to_external_cube(sim): ), ], ) - scene = InteractiveScene(scene_cfg) - - # Play the simulator - sim.reset() - - # default joint targets - default_actions = scene.articulations["robot"].data.default_joint_pos.torch.clone() - # Define simulation stepping - sim_dt = sim.get_physics_dt() - # Simulate physics - for count in range(100): - # # reset - if count % 25 == 0: - # reset root state - root_state = torch.cat( - ( - scene.articulations["robot"].data.default_root_pose.torch, - scene.articulations["robot"].data.default_root_vel.torch, - ), - dim=-1, - ).clone() - root_state[:, :3] += scene.env_origins - joint_pos = scene.articulations["robot"].data.default_joint_pos.torch - joint_vel = scene.articulations["robot"].data.default_joint_vel.torch - # -- set root state - # -- robot - scene.articulations["robot"].write_root_pose_to_sim_index(root_pose=root_state[:, :7]) - scene.articulations["robot"].write_root_velocity_to_sim_index(root_velocity=root_state[:, 7:]) - scene.articulations["robot"].write_joint_position_to_sim_index(position=joint_pos) - scene.articulations["robot"].write_joint_velocity_to_sim_index(velocity=joint_vel) - # reset buffers - scene.reset() - - # set joint targets - robot_actions = default_actions + 0.5 * torch.randn_like(default_actions) - scene.articulations["robot"].set_joint_position_target_index(target=robot_actions) - # write data to sim - scene.write_data_to_sim() - # perform step - sim.step() - # read data from sim - scene.update(sim_dt) - - # check absolute frame transforms in world frame - # -- ground-truth - root_pose_w = scene.articulations["robot"].data.root_pose_w.torch - cube_pos_w_gt = scene.rigid_objects["cube"].data.root_pos_w.torch - cube_quat_w_gt = scene.rigid_objects["cube"].data.root_quat_w.torch - # -- frame transformer - source_pos_w_tf = scene.sensors["frame_transformer"].data.source_pos_w.torch - source_quat_w_tf = scene.sensors["frame_transformer"].data.source_quat_w.torch - cube_pos_w_tf = scene.sensors["frame_transformer"].data.target_pos_w.torch.squeeze() - cube_quat_w_tf = scene.sensors["frame_transformer"].data.target_quat_w.torch.squeeze() - - # check if they are same - torch.testing.assert_close(root_pose_w[:, :3], source_pos_w_tf) - torch.testing.assert_close(root_pose_w[:, 3:], source_quat_w_tf) - torch.testing.assert_close(cube_pos_w_gt, cube_pos_w_tf) - torch.testing.assert_close(cube_quat_w_gt, cube_quat_w_tf) - - # check if relative transforms are same - cube_pos_source_tf = scene.sensors["frame_transformer"].data.target_pos_source.torch - cube_quat_source_tf = scene.sensors["frame_transformer"].data.target_quat_source.torch - # ground-truth - cube_pos_b, cube_quat_b = math_utils.subtract_frame_transforms( - root_pose_w[:, :3], root_pose_w[:, 3:], cube_pos_w_tf, cube_quat_w_tf - ) - # check if they are same - torch.testing.assert_close(cube_pos_source_tf[:, 0], cube_pos_b) - torch.testing.assert_close(cube_quat_source_tf[:, 0], cube_quat_b) - - -@pytest.mark.isaacsim_ci -def test_frame_transformer_offset_frames(sim): - """Test body transformation w.r.t. base source frame. - - In this test, the source frame is the cube frame. - """ - # Spawn things into stage - scene_cfg = MySceneCfg(num_envs=2, env_spacing=5.0, lazy_sensor_update=False) - scene_cfg.frame_transformer = FrameTransformerCfg( + scene_cfg.ft_cube_offsets = FrameTransformerCfg( prim_path="{ENV_REGEX_NS}/cube", target_frames=[ FrameTransformerCfg.FrameCfg( @@ -450,86 +205,7 @@ def test_frame_transformer_offset_frames(sim): ), ], ) - scene = InteractiveScene(scene_cfg) - - # Play the simulator - sim.reset() - - # Define simulation stepping - sim_dt = sim.get_physics_dt() - # Simulate physics - for count in range(100): - # # reset - if count % 25 == 0: - # reset root state - root_state = torch.cat( - ( - scene["cube"].data.default_root_pose.torch, - scene["cube"].data.default_root_vel.torch, - ), - dim=-1, - ).clone() - root_state[:, :3] += scene.env_origins - # -- set root state - # -- cube - scene["cube"].write_root_pose_to_sim_index(root_pose=root_state[:, :7]) - scene["cube"].write_root_velocity_to_sim_index(root_velocity=root_state[:, 7:]) - # reset buffers - scene.reset() - - # write data to sim - scene.write_data_to_sim() - # perform step - sim.step() - # read data from sim - scene.update(sim_dt) - - # check absolute frame transforms in world frame - # -- ground-truth - cube_pos_w_gt = scene["cube"].data.root_pos_w.torch - cube_quat_w_gt = scene["cube"].data.root_quat_w.torch - # -- frame transformer - source_pos_w_tf = scene.sensors["frame_transformer"].data.source_pos_w.torch - source_quat_w_tf = scene.sensors["frame_transformer"].data.source_quat_w.torch - target_pos_w_tf = scene.sensors["frame_transformer"].data.target_pos_w.torch.squeeze() - target_quat_w_tf = scene.sensors["frame_transformer"].data.target_quat_w.torch.squeeze() - target_frame_names = scene.sensors["frame_transformer"].data.target_frame_names - - cube_center_idx = target_frame_names.index("CUBE_CENTER") - cube_bottom_idx = target_frame_names.index("CUBE_BOTTOM") - cube_top_idx = target_frame_names.index("CUBE_TOP") - - # check if they are same - torch.testing.assert_close(cube_pos_w_gt, source_pos_w_tf) - torch.testing.assert_close(cube_quat_w_gt, source_quat_w_tf) - torch.testing.assert_close(cube_pos_w_gt, target_pos_w_tf[:, cube_center_idx]) - torch.testing.assert_close(cube_quat_w_gt, target_quat_w_tf[:, cube_center_idx]) - - # test offsets are applied correctly - # -- cube top - cube_pos_top = target_pos_w_tf[:, cube_top_idx] - cube_quat_top = target_quat_w_tf[:, cube_top_idx] - torch.testing.assert_close(cube_pos_top, cube_pos_w_gt + torch.tensor([0.0, 0.0, 0.1])) - torch.testing.assert_close(cube_quat_top, cube_quat_w_gt) - - # -- cube bottom - cube_pos_bottom = target_pos_w_tf[:, cube_bottom_idx] - cube_quat_bottom = target_quat_w_tf[:, cube_bottom_idx] - torch.testing.assert_close(cube_pos_bottom, cube_pos_w_gt + torch.tensor([0.0, 0.0, -0.1])) - torch.testing.assert_close(cube_quat_bottom, cube_quat_w_gt) - - -@pytest.mark.isaacsim_ci -def test_frame_transformer_all_bodies(sim): - """Test transformation of all bodies w.r.t. base source frame. - - In this test, the source frame is the robot base. - - The target_frames are all bodies in the robot, implemented using .* pattern. - """ - # Spawn things into stage - scene_cfg = MySceneCfg(num_envs=2, env_spacing=5.0, lazy_sensor_update=False) - scene_cfg.frame_transformer = FrameTransformerCfg( + scene_cfg.ft_all = FrameTransformerCfg( prim_path="{ENV_REGEX_NS}/Robot/base", target_frames=[ FrameTransformerCfg.FrameCfg( @@ -542,13 +218,40 @@ def test_frame_transformer_all_bodies(sim): # Play the simulator sim.reset() - target_frame_names = scene.sensors["frame_transformer"].data.target_frame_names - articulation_body_names = scene.articulations["robot"].data.body_names + robot = scene.articulations["robot"] + cube = scene.rigid_objects["cube"] + ft = scene.sensors["frame_transformer"] + ft_thigh = scene.sensors["ft_thigh"] + ft_cube = scene.sensors["ft_cube"] + ft_cube_offsets = scene.sensors["ft_cube_offsets"] + ft_all = scene.sensors["ft_all"] + assert "FrameTransformer @" in str(ft_all) - reordering_indices = [target_frame_names.index(name) for name in articulation_body_names] + # Acquire the index of ground truth bodies + feet_indices, feet_names = robot.find_bodies(["LF_FOOT", "RF_FOOT", "LH_FOOT", "RH_FOOT"]) + + # Reorder the feet indices to match the order of the target frames with _USER suffix removed + target_frame_names = [name.split("_USER")[0] for name in ft.data.target_frame_names] + reordering_indices = [feet_names.index(name) for name in target_frame_names] + feet_indices = [feet_indices[i] for i in reordering_indices] + + # A non-root source frame keeps the target frames in body order + thigh_index = robot.find_bodies("LF_THIGH")[0][0] + thigh_feet_indices, thigh_feet_names = robot.find_bodies(["LF_FOOT", "RF_FOOT"]) + assert ft_thigh.data.target_frame_names == [f"{name}_USER" for name in thigh_feet_names] + + # Offset frames on the cube + offset_frame_names = ft_cube_offsets.data.target_frame_names + cube_center_idx = offset_frame_names.index("CUBE_CENTER") + cube_bottom_idx = offset_frame_names.index("CUBE_BOTTOM") + cube_top_idx = offset_frame_names.index("CUBE_TOP") + + # All bodies, reordered to the articulation body order + articulation_body_names = robot.data.body_names + all_reordering_indices = [ft_all.data.target_frame_names.index(name) for name in articulation_body_names] # default joint targets - default_actions = scene.articulations["robot"].data.default_joint_pos.torch.clone() + default_actions = robot.data.default_joint_pos.torch.clone() # Define simulation stepping sim_dt = sim.get_physics_dt() # Simulate physics @@ -556,28 +259,26 @@ def test_frame_transformer_all_bodies(sim): # # reset if count % 25 == 0: # reset root state - root_state = torch.cat( - ( - scene.articulations["robot"].data.default_root_pose.torch, - scene.articulations["robot"].data.default_root_vel.torch, - ), - dim=-1, - ).clone() + root_state = torch.cat((robot.data.default_root_pose.torch, robot.data.default_root_vel.torch), dim=-1) + root_state = root_state.clone() root_state[:, :3] += scene.env_origins - joint_pos = scene.articulations["robot"].data.default_joint_pos.torch - joint_vel = scene.articulations["robot"].data.default_joint_vel.torch - # -- set root state # -- robot - scene.articulations["robot"].write_root_pose_to_sim_index(root_pose=root_state[:, :7]) - scene.articulations["robot"].write_root_velocity_to_sim_index(root_velocity=root_state[:, 7:]) - scene.articulations["robot"].write_joint_position_to_sim_index(position=joint_pos) - scene.articulations["robot"].write_joint_velocity_to_sim_index(velocity=joint_vel) + robot.write_root_pose_to_sim_index(root_pose=root_state[:, :7]) + robot.write_root_velocity_to_sim_index(root_velocity=root_state[:, 7:]) + robot.write_joint_position_to_sim_index(position=robot.data.default_joint_pos.torch) + robot.write_joint_velocity_to_sim_index(velocity=robot.data.default_joint_vel.torch) + # -- cube + cube_state = torch.cat((cube.data.default_root_pose.torch, cube.data.default_root_vel.torch), dim=-1) + cube_state = cube_state.clone() + cube_state[:, :3] += scene.env_origins + cube.write_root_pose_to_sim_index(root_pose=cube_state[:, :7]) + cube.write_root_velocity_to_sim_index(root_velocity=cube_state[:, 7:]) # reset buffers scene.reset() # set joint targets robot_actions = default_actions + 0.5 * torch.randn_like(default_actions) - scene.articulations["robot"].set_joint_position_target_index(target=robot_actions) + robot.set_joint_position_target_index(target=robot_actions) # write data to sim scene.write_data_to_sim() # perform step @@ -585,61 +286,106 @@ def test_frame_transformer_all_bodies(sim): # read data from sim scene.update(sim_dt) - # check absolute frame transforms in world frame # -- ground-truth - root_pose_w = scene.articulations["robot"].data.root_pose_w.torch - bodies_pos_w_gt = scene.articulations["robot"].data.body_pos_w.torch - bodies_quat_w_gt = scene.articulations["robot"].data.body_quat_w.torch - - # -- frame transformer - source_pos_w_tf = scene.sensors["frame_transformer"].data.source_pos_w.torch - source_quat_w_tf = scene.sensors["frame_transformer"].data.source_quat_w.torch - bodies_pos_w_tf = scene.sensors["frame_transformer"].data.target_pos_w.torch - bodies_quat_w_tf = scene.sensors["frame_transformer"].data.target_quat_w.torch - - # check if they are same - torch.testing.assert_close(root_pose_w[:, :3], source_pos_w_tf) - torch.testing.assert_close(root_pose_w[:, 3:], source_quat_w_tf) - torch.testing.assert_close(bodies_pos_w_gt, bodies_pos_w_tf[:, reordering_indices]) - torch.testing.assert_close(bodies_quat_w_gt, bodies_quat_w_tf[:, reordering_indices]) - - bodies_pos_source_tf = scene.sensors["frame_transformer"].data.target_pos_source.torch - bodies_quat_source_tf = scene.sensors["frame_transformer"].data.target_quat_source.torch - - # Go through each body and check if relative transforms are same + root_pose_w = robot.data.root_pose_w.torch + feet_pos_w_gt = robot.data.body_pos_w.torch[:, feet_indices] + feet_quat_w_gt = robot.data.body_quat_w.torch[:, feet_indices] + cube_pos_w_gt = cube.data.root_pos_w.torch + cube_quat_w_gt = cube.data.root_quat_w.torch + + # feet w.r.t. base: world frame transforms + feet_pos_w_tf = ft.data.target_pos_w.torch + feet_quat_w_tf = ft.data.target_quat_w.torch + torch.testing.assert_close(root_pose_w[:, :3], ft.data.source_pos_w.torch) + torch.testing.assert_close(root_pose_w[:, 3:], ft.data.source_quat_w.torch) + torch.testing.assert_close(feet_pos_w_gt, feet_pos_w_tf) + torch.testing.assert_close(feet_quat_w_gt, feet_quat_w_tf) + # feet w.r.t. base: relative transforms + for index in range(len(feet_indices)): + foot_pos_b, foot_quat_b = math_utils.subtract_frame_transforms( + root_pose_w[:, :3], root_pose_w[:, 3:], feet_pos_w_tf[:, index], feet_quat_w_tf[:, index] + ) + torch.testing.assert_close(ft.data.target_pos_source.torch[:, index], foot_pos_b) + torch.testing.assert_close(ft.data.target_quat_source.torch[:, index], foot_quat_b) + + # feet w.r.t. thigh + thigh_pose_w_gt = robot.data.body_state_w.torch[:, thigh_index, :7] + thigh_feet_pos_w_tf = ft_thigh.data.target_pos_w.torch + thigh_feet_quat_w_tf = ft_thigh.data.target_quat_w.torch + torch.testing.assert_close(thigh_pose_w_gt[:, :3], ft_thigh.data.source_pos_w.torch) + torch.testing.assert_close(thigh_pose_w_gt[:, 3:], ft_thigh.data.source_quat_w.torch) + torch.testing.assert_close(robot.data.body_pos_w.torch[:, thigh_feet_indices], thigh_feet_pos_w_tf) + torch.testing.assert_close(robot.data.body_quat_w.torch[:, thigh_feet_indices], thigh_feet_quat_w_tf) + for index in range(len(thigh_feet_indices)): + foot_pos_b, foot_quat_b = math_utils.subtract_frame_transforms( + thigh_pose_w_gt[:, :3], + thigh_pose_w_gt[:, 3:], + thigh_feet_pos_w_tf[:, index], + thigh_feet_quat_w_tf[:, index], + ) + torch.testing.assert_close(ft_thigh.data.target_pos_source.torch[:, index], foot_pos_b) + torch.testing.assert_close(ft_thigh.data.target_quat_source.torch[:, index], foot_quat_b) + + # cube w.r.t. base + cube_pos_w_tf = ft_cube.data.target_pos_w.torch.squeeze() + cube_quat_w_tf = ft_cube.data.target_quat_w.torch.squeeze() + torch.testing.assert_close(root_pose_w[:, :3], ft_cube.data.source_pos_w.torch) + torch.testing.assert_close(root_pose_w[:, 3:], ft_cube.data.source_quat_w.torch) + torch.testing.assert_close(cube_pos_w_gt, cube_pos_w_tf) + torch.testing.assert_close(cube_quat_w_gt, cube_quat_w_tf) + cube_pos_b, cube_quat_b = math_utils.subtract_frame_transforms( + root_pose_w[:, :3], root_pose_w[:, 3:], cube_pos_w_tf, cube_quat_w_tf + ) + torch.testing.assert_close(ft_cube.data.target_pos_source.torch[:, 0], cube_pos_b) + torch.testing.assert_close(ft_cube.data.target_quat_source.torch[:, 0], cube_quat_b) + + # cube offset frames + target_pos_w_tf = ft_cube_offsets.data.target_pos_w.torch.squeeze() + target_quat_w_tf = ft_cube_offsets.data.target_quat_w.torch.squeeze() + torch.testing.assert_close(cube_pos_w_gt, ft_cube_offsets.data.source_pos_w.torch) + torch.testing.assert_close(cube_quat_w_gt, ft_cube_offsets.data.source_quat_w.torch) + torch.testing.assert_close(cube_pos_w_gt, target_pos_w_tf[:, cube_center_idx]) + torch.testing.assert_close(cube_quat_w_gt, target_quat_w_tf[:, cube_center_idx]) + torch.testing.assert_close( + target_pos_w_tf[:, cube_top_idx], cube_pos_w_gt + torch.tensor([0.0, 0.0, 0.1], device=sim.device) + ) + torch.testing.assert_close(target_quat_w_tf[:, cube_top_idx], cube_quat_w_gt) + torch.testing.assert_close( + target_pos_w_tf[:, cube_bottom_idx], cube_pos_w_gt + torch.tensor([0.0, 0.0, -0.1], device=sim.device) + ) + torch.testing.assert_close(target_quat_w_tf[:, cube_bottom_idx], cube_quat_w_gt) + + # all bodies w.r.t. base + bodies_pos_w_tf = ft_all.data.target_pos_w.torch + bodies_quat_w_tf = ft_all.data.target_quat_w.torch + torch.testing.assert_close(root_pose_w[:, :3], ft_all.data.source_pos_w.torch) + torch.testing.assert_close(root_pose_w[:, 3:], ft_all.data.source_quat_w.torch) + torch.testing.assert_close(robot.data.body_pos_w.torch, bodies_pos_w_tf[:, all_reordering_indices]) + torch.testing.assert_close(robot.data.body_quat_w.torch, bodies_quat_w_tf[:, all_reordering_indices]) for index in range(len(articulation_body_names)): body_pos_b, body_quat_b = math_utils.subtract_frame_transforms( root_pose_w[:, :3], root_pose_w[:, 3:], bodies_pos_w_tf[:, index], bodies_quat_w_tf[:, index] ) - - torch.testing.assert_close(bodies_pos_source_tf[:, index], body_pos_b) - torch.testing.assert_close(bodies_quat_source_tf[:, index], body_quat_b) - - -@pytest.mark.isaacsim_ci -def test_sensor_print(sim): - """Test sensor print is working correctly.""" - # Spawn things into stage - scene_cfg = MySceneCfg(num_envs=2, env_spacing=5.0, lazy_sensor_update=False) - scene_cfg.frame_transformer = FrameTransformerCfg( - prim_path="{ENV_REGEX_NS}/Robot/base", - target_frames=[ - FrameTransformerCfg.FrameCfg( - prim_path="{ENV_REGEX_NS}/Robot/[^/]*", - ), - ], - ) - scene = InteractiveScene(scene_cfg) - - # Play the simulator - sim.reset() - # print info - print(scene.sensors["frame_transformer"]) + torch.testing.assert_close(ft_all.data.target_pos_source.torch[:, index], body_pos_b) + torch.testing.assert_close(ft_all.data.target_quat_source.torch[:, index], body_quat_b) + + # the recorded-launch optimization must be active on CUDA; a recording failure would only + # warn and silently fall back to eager launches, defeating the optimization. + if "cuda" in str(sim.device): + sensors = (ft, ft_thigh, ft_cube, ft_cube_offsets, ft_all) + for sensor in sensors: + assert sensor._update_cmd is not None + # The recorded launch is reused across updates rather than re-recorded every step. + recorded = [sensor._update_cmd for sensor in sensors] + scene.write_data_to_sim() + sim.step() + scene.update(sim_dt) + for sensor, update_cmd in zip(sensors, recorded): + assert sensor._update_cmd is update_cmd @pytest.mark.isaacsim_ci -@pytest.mark.parametrize("source_robot", ["Robot", "Robot_1"]) -@pytest.mark.parametrize("path_prefix", ["{ENV_REGEX_NS}", "/World"]) +@pytest.mark.parametrize(("source_robot", "path_prefix"), [("Robot_1", "{ENV_REGEX_NS}"), ("Robot", "/World")]) def test_frame_transformer_duplicate_body_names(sim, source_robot, path_prefix): """Test tracking bodies with same leaf name at different hierarchy levels. @@ -932,52 +678,14 @@ def _make_frame_transformer(use_recorded_launch: bool = True): return sensor, transform_view, transforms_torch, env_mask -@pytest.mark.isaacsim_ci -@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA is not available") -def test_frame_transformer_caches_physx_transform_view(): - """Repeated eager updates should reuse one typed view over the refreshed PhysX buffer.""" - sensor, transform_view, _, env_mask = _make_frame_transformer(use_recorded_launch=False) - - sensor._update_buffers_impl(env_mask) - sensor._update_buffers_impl(env_mask) - wp.synchronize_device(sensor.device) - - assert transform_view.get_count == 2 - assert transform_view.view_count == 1 - - -@pytest.mark.isaacsim_ci -@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA is not available") -def test_frame_transformer_records_and_replays_update_launch(): - """The first recorded update should execute, and later updates should replay refreshed data.""" - sensor, _, transforms_torch, env_mask = _make_frame_transformer() - - sensor._update_buffers_impl(env_mask) - wp.synchronize_device(sensor.device) - update_cmd = sensor._update_cmd - - assert update_cmd is not None - torch.testing.assert_close( - wp.to_torch(sensor._data._target_pos_source)[0, 0], - torch.tensor([1.0, 0.0, 0.0], device=sensor.device), - ) - - transforms_torch[1, 0] = 2.0 - sensor._update_buffers_impl(env_mask) - wp.synchronize_device(sensor.device) - - assert sensor._update_cmd is update_cmd - torch.testing.assert_close( - wp.to_torch(sensor._data._target_pos_source)[0, 0], - torch.tensor([2.0, 0.0, 0.0], device=sensor.device), - ) - - @pytest.mark.isaacsim_ci @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA is not available") def test_frame_transformer_falls_back_when_recording_fails(monkeypatch): - """A recording failure should disable recording and execute the current update eagerly.""" - sensor, _, _, env_mask = _make_frame_transformer() + """A recording failure should disable recording and execute the current update eagerly. + + Later eager updates refresh the PhysX buffer but reuse one typed view over it. + """ + sensor, transform_view, _, env_mask = _make_frame_transformer() original_launch = frame_transformer_module.wp.launch def launch_with_recording_failure(*args, record_cmd=False, **kwargs): @@ -995,6 +703,12 @@ def launch_with_recording_failure(*args, record_cmd=False, **kwargs): torch.tensor([1.0, 0.0, 0.0], device=sensor.device), ) + sensor._update_buffers_impl(env_mask) + wp.synchronize_device(sensor.device) + + assert transform_view.get_count == 2 + assert transform_view.view_count == 1 + @pytest.mark.isaacsim_ci @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA is not available") diff --git a/source/isaaclab_physx/test/sensors/test_imu.py b/source/isaaclab_physx/test/sensors/test_imu.py index 30fdc7bf4b97..391c662f21e0 100644 --- a/source/isaaclab_physx/test/sensors/test_imu.py +++ b/source/isaaclab_physx/test/sensors/test_imu.py @@ -17,7 +17,6 @@ import pytest import torch -import warp as wp from isaaclab_newton.sim.schemas import NewtonArticulationCfg from isaaclab_physx.physics import PhysxCfg from isaaclab_physx.sim.schemas import PhysxArticulationCfg @@ -84,7 +83,7 @@ class MySceneCfg(InteractiveSceneCfg): # articulations - robot robot = ANYMAL_C_CFG.replace(prim_path="{ENV_REGEX_NS}/robot") - # pendulum1 - uses merge_fixed_joints=True (same as pendulum2) so that fixed-joint + # pendulum - uses merge_fixed_joints=True so that fixed-joint # child links (base, imu_link) are merged into their parents during URDF XML # pre-processing. This avoids fixed-joint constraint violations at velocity level # (the solver uses velocity_iteration_count=0). A non-physics imu_link Xform is @@ -111,31 +110,6 @@ class MySceneCfg(InteractiveSceneCfg): "joint_1_act": ImplicitActuatorCfg(joint_names_expr=["joint_.*"], stiffness=0.0, damping=0.3), }, ) - # pendulum2 - uses merge_fixed_joints=True so that the fixed-joint child links (base, imu_link) - # are merged into their parents during URDF XML pre-processing. A non-physics imu_link Xform - # is created programmatically in the test fixture to test indirect IMU attachment (see setup_sim). - pendulum2 = ArticulationCfg( - prim_path="{ENV_REGEX_NS}/pendulum2", - spawn=sim_utils.UrdfFileCfg( - fix_base=True, - merge_fixed_joints=True, - make_instanceable=False, - asset_path=f"{pathlib.Path(__file__).parent.resolve()}/urdfs/simple_2_link.urdf", - articulation_props=[ - PhysxArticulationCfg( - enabled_self_collisions=True, solver_position_iteration_count=4, solver_velocity_iteration_count=0 - ), - NewtonArticulationCfg(self_collision_enabled=True), - ], - joint_drive=sim_utils.UrdfConverterCfg.JointDriveCfg( - gains=sim_utils.UrdfConverterCfg.JointDriveCfg.PDGainsCfg(stiffness=None, damping=None) - ), - ), - init_state=ArticulationCfg.InitialStateCfg(), - actuators={ - "joint_1_act": ImplicitActuatorCfg(joint_names_expr=["joint_.*"], stiffness=0.0, damping=0.3), - }, - ) # sensors - imu (filled inside unit test) imu_ball: ImuCfg = ImuCfg(prim_path="{ENV_REGEX_NS}/ball") @@ -159,16 +133,6 @@ class MySceneCfg(InteractiveSceneCfg): # kinematic tree. With merge_fixed_joints=True the hierarchy for simple_2_link.urdf is: # Geometry/world/link_1 (base merged into world, imu_link merged into link_1) # A non-physics imu_link Xform is recreated in the test fixture (see setup_sim). - imu_indirect_pendulum_link: ImuCfg = ImuCfg( - prim_path="{ENV_REGEX_NS}/pendulum2/Geometry/world/link_1/imu_link", - ) - imu_indirect_pendulum_base: ImuCfg = ImuCfg( - prim_path="{ENV_REGEX_NS}/pendulum2/Geometry/world/link_1", - offset=ImuCfg.OffsetCfg( - pos=PEND_POS_OFFSET, - rot=PEND_ROT_OFFSET, - ), - ) imu_pendulum_imu_link: ImuCfg = ImuCfg( prim_path="{ENV_REGEX_NS}/pendulum/Geometry/world/link_1/imu_link", ) @@ -185,7 +149,6 @@ def __post_init__(self): # change position of the robot self.robot.init_state.pos = (0.0, 2.0, 1.0) self.pendulum.init_state.pos = (-2.0, 1.0, 0.5) - self.pendulum2.init_state.pos = (2.0, 1.0, 0.5) # change asset self.robot.spawn.usd_path = f"{ISAAC_NUCLEUS_DIR}/Robots/ANYbotics/anymal_c/anymal_c.usd" @@ -208,80 +171,20 @@ def setup_sim(): # construct scene scene_cfg = MySceneCfg(num_envs=2, env_spacing=5.0, lazy_sensor_update=False, replicate_physics=False) scene = InteractiveScene(scene_cfg) - # Both pendulum and pendulum2 use merge_fixed_joints=True, so the - # fixed-joint child link imu_link is removed from the URDF before USD - # conversion. Recreate it as a plain Xform (no RigidBodyAPI) under each - # pendulum's link_1 for every environment. The IMU sensor must then - # resolve the rigid-body ancestor (link_1) and cache the fixed offset -- - # exercising the "indirect attachment" code path. + # The pendulum uses merge_fixed_joints=True, so the fixed-joint child link + # imu_link is removed from the URDF before USD conversion. Recreate it as a + # plain Xform (no RigidBodyAPI) under link_1 for every environment. The IMU + # sensor must then resolve the rigid-body ancestor (link_1) and cache the + # fixed offset -- exercising the "indirect attachment" code path. for i in range(scene_cfg.num_envs): - for art_name in ("pendulum", "pendulum2"): - prim_path = f"/World/envs/env_{i}/{art_name}/Geometry/world/link_1/imu_link" - sim_utils.create_prim(prim_path, "Xform", translation=PEND_POS_OFFSET, orientation=PEND_ROT_OFFSET) + prim_path = f"/World/envs/env_{i}/pendulum/Geometry/world/link_1/imu_link" + sim_utils.create_prim(prim_path, "Xform", translation=PEND_POS_OFFSET, orientation=PEND_ROT_OFFSET) # Play the simulator sim.reset() yield sim, scene # Cleanup is handled by build_simulation_context -@pytest.mark.isaacsim_ci -def test_constant_velocity(setup_sim): - """Test the Imu sensor with a constant velocity. - - Expected behavior is that the linear acceleration is approx the same at every time step as in each step we set - the same velocity and therefore reset the physx buffers. - """ - sim, scene = setup_sim - prev_lin_acc_ball = torch.zeros((scene.num_envs, 3), dtype=torch.float32, device=scene.device) - prev_lin_acc_cube = torch.zeros((scene.num_envs, 3), dtype=torch.float32, device=scene.device) - - for idx in range(200): - # set velocity - scene.rigid_objects["balls"].write_root_velocity_to_sim( - torch.tensor([[1.0, 0.0, 0.0, 0.0, 0.0, 0.0]], dtype=torch.float32, device=scene.device).repeat( - scene.num_envs, 1 - ) - ) - scene.rigid_objects["cube"].write_root_velocity_to_sim( - torch.tensor([[1.0, 0.0, 0.0, 0.0, 0.0, 0.0]], dtype=torch.float32, device=scene.device).repeat( - scene.num_envs, 1 - ) - ) - # write data to sim - scene.write_data_to_sim() - - # perform step - sim.step() - # read data from sim - scene.update(sim.get_physics_dt()) - - if idx > 1: - # check the imu accelerations - torch.testing.assert_close( - scene.sensors["imu_ball"].data.lin_acc_b.torch, - prev_lin_acc_ball, - rtol=1e-3, - atol=1e-3, - ) - - torch.testing.assert_close( - scene.sensors["imu_cube"].data.lin_acc_b.torch, - prev_lin_acc_cube, - rtol=1e-3, - atol=1e-3, - ) - - # update previous values - prev_lin_acc_ball = scene.sensors["imu_ball"].data.lin_acc_b.torch.clone() - prev_lin_acc_cube = scene.sensors["imu_cube"].data.lin_acc_b.torch.clone() - - # the recorded-launch optimization must be active on CUDA; a recording failure would only - # warn and silently fall back to eager launches, defeating the optimization. - if "cuda" in str(scene.device): - assert scene.sensors["imu_ball"]._update_cmd is not None - assert scene.sensors["imu_cube"]._update_cmd is not None - - @pytest.mark.isaacsim_ci def test_constant_acceleration(setup_sim): """A constant applied force yields the solver acceleration F/m plus the gravity bias.""" @@ -329,11 +232,30 @@ def test_constant_acceleration(setup_sim): @pytest.mark.isaacsim_ci -def test_single_dof_pendulum(setup_sim): - """Test imu against analytical pendulum problem.""" +def test_offset_calculation(setup_sim): + """Test the Imu sensor on independently driven bodies that share one scene. + + * Robot: a configured offset on the base matches a sensor on the ``imu_link`` child prim. + * Pendulum: a configured offset on ``link_1`` matches a sensor on a non-rigid ``imu_link`` Xform child + (indirect attachment through the rigid-body ancestor). + * Ball and cube: writing the same velocity every step yields a constant linear acceleration. + """ sim, scene = setup_sim + prev_lin_acc_ball = torch.zeros((scene.num_envs, 3), dtype=torch.float32, device=scene.device) + prev_lin_acc_cube = torch.zeros((scene.num_envs, 3), dtype=torch.float32, device=scene.device) + constant_velocity = torch.tensor([[1.0, 0.0, 0.0, 0.0, 0.0, 0.0]], dtype=torch.float32, device=scene.device) for idx in range(500): + # set acceleration + scene.articulations["robot"].write_root_velocity_to_sim( + torch.tensor([[0.05, 0.0, 0.0, 0.0, 0.0, 0.0]], dtype=torch.float32, device=scene.device).repeat( + scene.num_envs, 1 + ) + * (idx + 1) + ) + # set constant velocity + scene.rigid_objects["balls"].write_root_velocity_to_sim(constant_velocity.repeat(scene.num_envs, 1)) + scene.rigid_objects["cube"].write_root_velocity_to_sim(constant_velocity.repeat(scene.num_envs, 1)) # write data to sim scene.write_data_to_sim() # perform step @@ -341,75 +263,54 @@ def test_single_dof_pendulum(setup_sim): # read data from sim scene.update(sim.get_physics_dt()) - # IMU data - imu_data = scene.sensors["imu_pendulum_imu_link"].data - base_data = scene.sensors["imu_pendulum_base"].data + # constant velocity: the linear acceleration is the same at every step + if idx > 1: + torch.testing.assert_close( + scene.sensors["imu_ball"].data.lin_acc_b.torch, + prev_lin_acc_ball, + rtol=1e-3, + atol=1e-3, + ) + torch.testing.assert_close( + scene.sensors["imu_cube"].data.lin_acc_b.torch, + prev_lin_acc_cube, + rtol=1e-3, + atol=1e-3, + ) + prev_lin_acc_ball = scene.sensors["imu_ball"].data.lin_acc_b.torch.clone() + prev_lin_acc_cube = scene.sensors["imu_cube"].data.lin_acc_b.torch.clone() # skip first step where initial velocity is zero - if idx < 2: + if idx < 1: continue - # check the angular velocities of the imus between offset and direct definition + # robot: offset vs imu_link definition torch.testing.assert_close( - base_data.ang_vel_b.torch, - imu_data.ang_vel_b.torch, + scene.sensors["imu_robot_base"].data.lin_acc_b.torch, + scene.sensors["imu_robot_imu_link"].data.lin_acc_b.torch, rtol=1e-4, atol=1e-4, ) - - # check the linear acceleration of the imus between offset and direct definition - torch.testing.assert_close( - base_data.lin_acc_b.torch, - imu_data.lin_acc_b.torch, - rtol=1e-1, - atol=1e-1, - ) - - -@pytest.mark.isaacsim_ci -def test_indirect_attachment(setup_sim): - """Test attaching the imu through an xForm primitive configuration argument.""" - sim, scene = setup_sim - - for idx in range(500): - # write data to sim - scene.write_data_to_sim() - # perform step - sim.step() - # read data from sim - scene.update(sim.get_physics_dt()) - - imu = scene.sensors["imu_indirect_pendulum_link"] - imu_base = scene.sensors["imu_indirect_pendulum_base"] - - torch.testing.assert_close( - wp.to_torch(imu._offset_pos_b), - wp.to_torch(imu_base._offset_pos_b), - ) torch.testing.assert_close( - wp.to_torch(imu._offset_quat_b), - wp.to_torch(imu_base._offset_quat_b), + scene.sensors["imu_robot_base"].data.ang_vel_b.torch, + scene.sensors["imu_robot_imu_link"].data.ang_vel_b.torch, rtol=1e-4, atol=1e-4, ) - # IMU data - imu_data = scene.sensors["imu_indirect_pendulum_link"].data - base_data = scene.sensors["imu_indirect_pendulum_base"].data - - # skip first step where initial velocity is zero + # skip the second pendulum step where the initial velocity is still zero if idx < 2: continue - # check the angular velocities of the imus between offset and direct definition + # pendulum: offset vs indirect imu_link definition + imu_data = scene.sensors["imu_pendulum_imu_link"].data + base_data = scene.sensors["imu_pendulum_base"].data torch.testing.assert_close( base_data.ang_vel_b.torch, imu_data.ang_vel_b.torch, rtol=1e-4, atol=1e-4, ) - - # check the linear acceleration of the imus between offset and direct definition torch.testing.assert_close( base_data.lin_acc_b.torch, imu_data.lin_acc_b.torch, @@ -417,90 +318,17 @@ def test_indirect_attachment(setup_sim): atol=1e-1, ) + # the recorded-launch optimization must be active on CUDA; a recording failure would only + # warn and silently fall back to eager launches, defeating the optimization. + if "cuda" in str(scene.device): + assert scene.sensors["imu_ball"]._update_cmd is not None + assert scene.sensors["imu_cube"]._update_cmd is not None -@pytest.mark.isaacsim_ci -def test_offset_calculation(setup_sim): - """Test offset configuration argument.""" - sim, scene = setup_sim - - # should achieve same results between the two imu sensors on the robot - for idx in range(500): - # set acceleration - scene.articulations["robot"].write_root_velocity_to_sim( - torch.tensor([[0.05, 0.0, 0.0, 0.0, 0.0, 0.0]], dtype=torch.float32, device=scene.device).repeat( - scene.num_envs, 1 - ) - * (idx + 1) - ) - # write data to sim - scene.write_data_to_sim() - # perform step - sim.step() - # read data from sim - scene.update(sim.get_physics_dt()) - - # skip first step where initial velocity is zero - if idx < 1: - continue - - # check the linear accelerations - torch.testing.assert_close( - scene.sensors["imu_robot_base"].data.lin_acc_b.torch, - scene.sensors["imu_robot_imu_link"].data.lin_acc_b.torch, - rtol=1e-4, - atol=1e-4, - ) - - # check the angular velocities - torch.testing.assert_close( - scene.sensors["imu_robot_base"].data.ang_vel_b.torch, - scene.sensors["imu_robot_imu_link"].data.ang_vel_b.torch, - rtol=1e-4, - atol=1e-4, - ) - - -@pytest.mark.isaacsim_ci -def test_attachment_validity(setup_sim): - """Test invalid imu attachment. An imu cannot be attached directly to the world. It must be somehow attached to - something implementing physics.""" - sim, scene = setup_sim - imu_world_cfg = ImuCfg(prim_path="/World/envs/env_0") - with pytest.raises(RuntimeError) as exc_info: - imu_world = Imu(imu_world_cfg) - imu_world._initialize_impl() - assert exc_info.type is RuntimeError and "find a rigid body ancestor prim" in str(exc_info.value) - - -@pytest.mark.isaacsim_ci -def test_env_ids_propagation(setup_sim): - """Test that env_ids argument propagates through update and reset methods""" - sim, scene = setup_sim - scene.reset() - - for idx in range(10): - # set acceleration - scene.articulations["robot"].write_root_velocity_to_sim( - torch.tensor([[0.5, 0.0, 0.0, 0.0, 0.0, 0.0]], dtype=torch.float32, device=scene.device).repeat( - scene.num_envs, 1 - ) - * (idx + 1) - ) - # write data to sim - scene.write_data_to_sim() - # perform step - sim.step() - # read data from sim - scene.update(sim.get_physics_dt()) + assert "number of sensors : 2" in str(scene.sensors["imu_ball"]) - # reset scene for env 1 - scene.reset(env_ids=[1]) - # read data from sim - scene.update(sim.get_physics_dt()) - # perform step - sim.step() - # read data from sim - scene.update(sim.get_physics_dt()) + # An imu cannot be attached directly to the world: it must have a rigid-body ancestor. + with pytest.raises(RuntimeError, match="find a rigid body ancestor prim"): + Imu(ImuCfg(prim_path="/World/envs/env_0"))._initialize_impl() @configclass @@ -525,46 +353,38 @@ def test_no_stale_data_after_scene_reset(): """Regression for #4970: ``scene.reset(env_ids)`` must not surface pre-reset IMU values. Mirrors the ``ManagerBasedRLEnv._reset_idx`` flow where reset runs inside a step - without a subsequent physics step. The IMU sensor's lazy ``data`` accessor must not - refetch from the PhysX rigid-body view here (the velocity buffer reflects the previous - physics step and would produce a spurious finite-difference acceleration). + without a subsequent physics step: the PhysX buffers still hold the pre-reset state, so + the lazy ``data`` accessor must return the zeroed buffers instead of refetching them. The + cube rests on the ground first so the stale reading is distinguishable from a reset one. """ sim_cfg = sim_utils.SimulationCfg(dt=0.01, physics=PhysxCfg(solver_type=0)) with sim_utils.build_simulation_context(sim_cfg=sim_cfg) as sim: sim._app_control_on_stop_handle = None - scene_cfg = _StaleResetSceneCfg(num_envs=1, env_spacing=2.0, lazy_sensor_update=False) + scene_cfg = _StaleResetSceneCfg(num_envs=2, env_spacing=2.0, lazy_sensor_update=False) scene = InteractiveScene(scene_cfg) sim.reset() scene.reset() sensor: Imu = scene["imu_cube"] - # Let the cube fall so PhysX accumulates a non-zero rigid-body velocity. - for _ in range(30): + # The cube falls from z=2.0 and lands in ~60 steps at 100 Hz; 150 steps let it settle. + for _ in range(150): scene.write_data_to_sim() sim.step(render=False) scene.update(dt=sim.get_physics_dt()) - # Reset the scene without writing fresh velocity/transform. The PhysX velocity - # buffer therefore still holds the pre-reset (falling) value. - scene.reset(env_ids=torch.tensor([0], device=sensor.device)) - - # The public ``data`` accessor must not refetch a stale PhysX buffer; ``reset()`` - # zeroes ``_ang_vel_b`` and ``_lin_acc_b`` and those must be what comes out here. - post_reset_lin_acc = sensor.data.lin_acc_b.torch - post_reset_ang_vel = sensor.data.ang_vel_b.torch - torch.testing.assert_close(post_reset_lin_acc, torch.zeros_like(post_reset_lin_acc)) - torch.testing.assert_close(post_reset_ang_vel, torch.zeros_like(post_reset_ang_vel)) - + pre_reset_lin_acc = sensor.data.lin_acc_b.torch.clone() + assert (torch.linalg.norm(pre_reset_lin_acc, dim=-1) > 5.0).all(), ( + f"Expected a settled gravity reading, got {pre_reset_lin_acc}" + ) -@pytest.mark.isaacsim_ci -def test_sensor_print(setup_sim): - """Test sensor print is working correctly.""" - sim, scene = setup_sim - # Create sensor - sensor = scene.sensors["imu_ball"] - # print info - print(sensor) + # Reset env 0 without a physics step: it reads zeros while env 1 keeps its measurement. + scene.reset(env_ids=torch.tensor([0], device=sensor.device)) + lin_acc = sensor.data.lin_acc_b.torch + ang_vel = sensor.data.ang_vel_b.torch + torch.testing.assert_close(lin_acc[0], torch.zeros_like(lin_acc[0])) + torch.testing.assert_close(ang_vel[0], torch.zeros_like(ang_vel[0])) + torch.testing.assert_close(lin_acc[1], pre_reset_lin_acc[1]) @pytest.mark.parametrize("access_mode", ("lazy_read", "update_period")) diff --git a/source/isaaclab_physx/test/sensors/test_imu_pva_recorded_launch.py b/source/isaaclab_physx/test/sensors/test_imu_pva_recorded_launch.py index 0f9c87b4c86e..c4a6d7c1eaa6 100644 --- a/source/isaaclab_physx/test/sensors/test_imu_pva_recorded_launch.py +++ b/source/isaaclab_physx/test/sensors/test_imu_pva_recorded_launch.py @@ -157,22 +157,6 @@ def _make_sensor(sensor_type: str, use_recorded_launch: bool = True): return sensor, rigid_view, accelerations_torch, env_mask, gravity_sink -@pytest.mark.parametrize("sensor_type", ["imu", "pva"]) -def test_sensor_caches_physx_typed_views(sensor_type): - """Repeated eager updates should reuse typed views over refreshed PhysX buffers.""" - sensor, rigid_view, _, env_mask, _ = _make_sensor(sensor_type, use_recorded_launch=False) - - sensor._update_buffers_impl(env_mask) - sensor._update_buffers_impl(env_mask) - wp.synchronize_device(sensor.device) - - assert rigid_view.get_counts == {"transforms": 2, "velocities": 2, "accelerations": 2, "coms": 2} - assert rigid_view.transforms.view_count == 1 - assert rigid_view.velocities.view_count == 1 - assert rigid_view.accelerations.view_count == 1 - assert rigid_view.coms.view_count == 1 - - @pytest.mark.parametrize("sensor_type", ["imu", "pva"]) def test_sensor_records_and_replays_changed_runtime_inputs(sensor_type): """Replay should observe refreshed buffers and a new mask.""" @@ -205,8 +189,11 @@ def test_sensor_records_and_replays_changed_runtime_inputs(sensor_type): @pytest.mark.parametrize("sensor_type", ["imu", "pva"]) def test_sensor_falls_back_when_recording_fails(monkeypatch, sensor_type): - """A recording failure should disable recording and execute the update eagerly.""" - sensor, _, _, env_mask, _ = _make_sensor(sensor_type) + """A recording failure should disable recording and execute the update eagerly. + + Later eager updates refresh the PhysX buffers but reuse one typed view over each of them. + """ + sensor, rigid_view, _, env_mask, _ = _make_sensor(sensor_type) sensor_module = imu_module if sensor_type == "imu" else pva_module original_launch = sensor_module.wp.launch @@ -226,6 +213,15 @@ def launch_with_recording_failure(*args, record_cmd=False, **kwargs): torch.tensor([2.0, 2.0], device=sensor.device), ) + sensor._update_buffers_impl(env_mask) + wp.synchronize_device(sensor.device) + + assert rigid_view.get_counts == {"transforms": 2, "velocities": 2, "accelerations": 2, "coms": 2} + assert rigid_view.transforms.view_count == 1 + assert rigid_view.velocities.view_count == 1 + assert rigid_view.accelerations.view_count == 1 + assert rigid_view.coms.view_count == 1 + @pytest.mark.parametrize("sensor_type", ["imu", "pva"]) def test_sensor_invalidation_drops_cached_launch_state(monkeypatch, sensor_type): @@ -252,29 +248,10 @@ def test_sensor_invalidation_drops_cached_launch_state(monkeypatch, sensor_type) @pytest.mark.parametrize("sensor_type", ["imu", "pva"]) -def test_sensor_tracks_runtime_gravity_changes(sensor_type): - """Scene gravity randomized after initialization must reach the sensor's replayed launch.""" +def test_sensor_replayed_launch_applies_new_gravity(sensor_type): + """Scene gravity changed after initialization must alter the output of the replayed recorded launch.""" sensor, _, _, env_mask, gravity_sink = _make_sensor(sensor_type) - sensor._update_buffers_impl(env_mask) - wp.synchronize_device(sensor.device) - - gravity_sink["value"] = (0.0, 3.72, 0.0) - sensor._update_buffers_impl(env_mask) - wp.synchronize_device(sensor.device) - - # IMU carries the accelerometer bias (-g); PVA carries the unit gravity direction. - expected = (0.0, -3.72, 0.0) if sensor_type == "imu" else (0.0, 1.0, 0.0) - resolved = sensor._gravity_bias_w if sensor_type == "imu" else sensor._gravity_vec_w - assert tuple(resolved) == pytest.approx(expected) - # The recorded command must have been re-bound, not left holding the old value. - assert sensor._recorded_gravity_w == (0.0, 3.72, 0.0) - - -def test_imu_replayed_launch_applies_new_gravity_bias(): - """A gravity change must alter the IMU output through the replayed recorded launch.""" - sensor, _, _, env_mask, gravity_sink = _make_sensor("imu") - sensor._update_buffers_impl(env_mask) wp.synchronize_device(sensor.device) # Guard the point of the test: the eager path would not exercise set_param_by_name. @@ -282,26 +259,21 @@ def test_imu_replayed_launch_applies_new_gravity_bias(): before = wp.to_torch(sensor._data._lin_acc_b).clone() # Solver accelerations are constant across updates in this harness, so the gravity - # bias is the only term that changes. + # term is the only one that changes. gravity_sink["value"] = (0.0, 3.72, 0.0) sensor._update_buffers_impl(env_mask) wp.synchronize_device(sensor.device) - after = wp.to_torch(sensor._data._lin_acc_b) - # Sensor frames are identity in this harness, so the delta is exactly the bias delta (-dg). - delta_g = torch.tensor((0.0, 3.72, 0.0), device=sensor.device) - torch.tensor( - (0.0, 0.0, -9.81), device=sensor.device - ) - torch.testing.assert_close(after - before, (-delta_g).repeat(2, 1)) - - -@pytest.mark.parametrize("sensor_type", ["imu", "pva"]) -def test_sensor_skips_gravity_work_when_unchanged(sensor_type): - """An unchanged scene gravity must not re-do the per-update gravity work.""" - sensor, _, _, env_mask, _ = _make_sensor(sensor_type) - - sensor._update_buffers_impl(env_mask) - wp.synchronize_device(sensor.device) - - # Still the harness value: no re-bind happened, so the recorded command is untouched. - assert sensor._recorded_gravity_w == (0.0, 0.0, -9.81) + if sensor_type == "imu": + # Sensor frames are identity in this harness, so the delta is exactly the bias delta (-dg). + after = wp.to_torch(sensor._data._lin_acc_b) + delta_g = torch.tensor((0.0, 3.72, 0.0), device=sensor.device) - torch.tensor( + (0.0, 0.0, -9.81), device=sensor.device + ) + torch.testing.assert_close(after - before, (-delta_g).repeat(2, 1)) + else: + # PVA projects the unit gravity direction into the (identity) sensor frame. + torch.testing.assert_close( + wp.to_torch(sensor._data._projected_gravity_b), + torch.tensor((0.0, 1.0, 0.0), device=sensor.device).repeat(2, 1), + ) diff --git a/source/isaaclab_physx/test/sensors/test_joint_wrench_sensor.py b/source/isaaclab_physx/test/sensors/test_joint_wrench_sensor.py index 19595b3ad56e..5f3a240b8d7a 100644 --- a/source/isaaclab_physx/test/sensors/test_joint_wrench_sensor.py +++ b/source/isaaclab_physx/test/sensors/test_joint_wrench_sensor.py @@ -38,7 +38,7 @@ from isaaclab.sim import SimulationCfg from isaaclab.terrains import TerrainImporterCfg from isaaclab.utils import configclass -from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR, ISAACLAB_NUCLEUS_DIR +from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR from isaaclab_assets.robots.ant import ANT_CFG @@ -62,32 +62,6 @@ def _make_single_joint_articulation_cfg() -> ArticulationCfg: ) -def _make_cartpole_articulation_cfg(pole_damping: float = 0.0) -> ArticulationCfg: - """Two-joint cartpole articulation (cart + pole). - - Args: - pole_damping: Damping for the cart-to-pole revolute joint. - """ - return ArticulationCfg( - prim_path="{ENV_REGEX_NS}/Robot", - spawn=sim_utils.UsdFileCfg( - usd_path=f"{ISAACLAB_NUCLEUS_DIR}/Robots/Classic/Cartpole/cartpole.usd", - ), - init_state=ArticulationCfg.InitialStateCfg( - pos=(0.0, 0.0, 2.0), - joint_pos={"slider_to_cart": 0.0, "cart_to_pole": 0.0}, - ), - actuators={ - "cart_actuator": ImplicitActuatorCfg( - joint_names_expr=["slider_to_cart"], joint_effort_limit=400.0, stiffness=0.0, damping=10.0 - ), - "pole_actuator": ImplicitActuatorCfg( - joint_names_expr=["cart_to_pole"], joint_effort_limit=400.0, stiffness=0.0, damping=pole_damping - ), - }, - ) - - @configclass class _SingleJointSceneCfg(InteractiveSceneCfg): """Scene with a single-joint articulation and the joint-wrench sensor.""" @@ -98,26 +72,6 @@ class _SingleJointSceneCfg(InteractiveSceneCfg): wrench = JointWrenchSensorCfg(prim_path="{ENV_REGEX_NS}/Robot") -@configclass -class _CartpoleSceneCfg(InteractiveSceneCfg): - """Scene with a cartpole (2-joint) articulation and the joint-wrench sensor.""" - - env_spacing = 4.0 - terrain = TerrainImporterCfg(prim_path="/World/ground", terrain_type="plane") - robot = _make_cartpole_articulation_cfg() - wrench = JointWrenchSensorCfg(prim_path="{ENV_REGEX_NS}/Robot") - - -@configclass -class _CartpoleDampedSceneCfg(InteractiveSceneCfg): - """Cartpole with pole damping for steady-state physics validation tests.""" - - env_spacing = 4.0 - terrain = TerrainImporterCfg(prim_path="/World/ground", terrain_type="plane") - robot = _make_cartpole_articulation_cfg(pole_damping=10.0) - wrench = JointWrenchSensorCfg(prim_path="{ENV_REGEX_NS}/Robot") - - @configclass class _NestedRootAntSceneCfg(InteractiveSceneCfg): """Ant USD asset whose articulation root is nested under the configured asset prim.""" @@ -195,25 +149,9 @@ def test_initialization_and_shapes(sim): assert sensor.find_bodies("Arm") == ([robot.body_names.index("Arm")], ["Arm"]) assert sensor._root_view is robot.root_view # noqa: SLF001 _assert_sensor_matches_physx_tensor(sensor) - - -def test_multi_body_articulation(sim): - """Cartpole exposes a wrench for each link labelled by body name.""" - scene = InteractiveScene(_CartpoleSceneCfg(num_envs=2)) - sim.reset() - - robot: Articulation = scene["robot"] - sensor: JointWrenchSensor = scene["wrench"] - sim.step() - scene.update(sim.get_physics_dt()) - - num_envs = 2 - num_bodies = robot.num_bodies - assert sensor.data.force.torch.shape == (num_envs, num_bodies, 3) - assert sensor.data.torque.torch.shape == (num_envs, num_bodies, 3) - assert sensor.body_names == robot.body_names - assert len(sensor.body_names) == num_bodies - _assert_sensor_matches_physx_tensor(sensor) + sensor_str = str(sensor) + assert "physx" in sensor_str + assert "Joint wrench sensor" in sensor_str def test_nested_articulation_root_resolution(sim): @@ -232,125 +170,13 @@ def test_nested_articulation_root_resolution(sim): _assert_sensor_matches_physx_tensor(sensor) -# --------------------------------------------------------------------------- -# Physical correctness -# --------------------------------------------------------------------------- - - -def test_force_and_torque_components_at_rest(sim): - """Component-level validation of force and torque against the PhysX tensor API.""" - scene = InteractiveScene(_SingleJointSceneCfg(num_envs=1)) - sim.reset() - - sensor: JointWrenchSensor = scene["wrench"] - robot: Articulation = scene["robot"] - for _ in range(400): - sim.step() - scene.update(sim.get_physics_dt()) - - _assert_sensor_matches_physx_tensor(sensor) - - arm_idx = robot.body_names.index("Arm") - raw_wrench = _physx_incoming_joint_wrench(sensor) - assert torch.any(raw_wrench[:, arm_idx, :] != 0.0) - - -def test_wrench_with_external_force_and_torque(sim): - """Full wrench validation with external force and torque applied.""" - scene = InteractiveScene(_SingleJointSceneCfg(num_envs=1)) - sim.reset() - - sensor: JointWrenchSensor = scene["wrench"] - robot: Articulation = scene["robot"] - arm_idx = robot.body_names.index("Arm") - - # Apply 10 N in body-Y and 10 N·m in body-Z on the arm (matches Newton test). - ext_force_b = torch.zeros((1, robot.num_bodies, 3), device=sim.device) - ext_force_b[:, arm_idx, 1] = 10.0 - ext_torque_b = torch.zeros((1, robot.num_bodies, 3), device=sim.device) - ext_torque_b[:, arm_idx, 2] = 10.0 - - for _ in range(800): - robot.permanent_wrench_composer.set_forces_and_torques_index(forces=ext_force_b, torques=ext_torque_b) - robot.write_data_to_sim() - sim.step() - scene.update(sim.get_physics_dt()) - - _assert_sensor_matches_physx_tensor(sensor) - - raw_wrench = _physx_incoming_joint_wrench(sensor) - assert torch.any(raw_wrench[:, arm_idx, :] != 0.0) - - -def test_interior_joint_wrench_at_rest(sim): - """Interior joint wrench matches the raw PhysX incoming-joint tensor. - - The cartpole has an interior joint (``slider_to_cart``) and a terminal - joint (``cart_to_pole``). PhysX reports one entry for every link, so this - test compares all link entries, including the cart link controlled by the - interior joint, against the underlying tensor API. - """ - scene = InteractiveScene(_CartpoleDampedSceneCfg(num_envs=1)) - sim.reset() - - sensor: JointWrenchSensor = scene["wrench"] - robot: Articulation = scene["robot"] - - for _ in range(800): - sim.step() - scene.update(sim.get_physics_dt()) - - _assert_sensor_matches_physx_tensor(sensor) - - cart_idx = robot.body_names.index("cart") - raw_wrench = _physx_incoming_joint_wrench(sensor) - assert torch.any(raw_wrench[:, cart_idx, :] != 0.0) - - -# --------------------------------------------------------------------------- -# String representation -# --------------------------------------------------------------------------- - - -def test_sensor_print(sim): - """Test that the sensor string representation works.""" - scene = InteractiveScene(_SingleJointSceneCfg(num_envs=2)) - sim.reset() - - sensor: JointWrenchSensor = scene["wrench"] - sensor_str = str(sensor) - assert "physx" in sensor_str - assert "Joint wrench sensor" in sensor_str - - # --------------------------------------------------------------------------- # Reset behavior # --------------------------------------------------------------------------- -def test_reset_zeros_buffers(sim): - """Resetting the sensor clears the force / torque buffers.""" - scene = InteractiveScene(_SingleJointSceneCfg(num_envs=2)) - sim.reset() - - sensor: JointWrenchSensor = scene["wrench"] - for _ in range(100): - sim.step() - scene.update(sim.get_physics_dt()) - - assert torch.any(sensor.data.force.torch != 0), "Expected non-zero data before reset" - - sensor.reset() - - # Access raw buffers to skip lazy re-population from the PhysX view on the next data read. - force_after = wp.to_torch(sensor._data._force) - torque_after = wp.to_torch(sensor._data._torque) - torch.testing.assert_close(force_after, torch.zeros_like(force_after)) - torch.testing.assert_close(torque_after, torch.zeros_like(torque_after)) - - def test_reset_with_env_ids_only_zeros_selected_envs(sim): - """Partial reset via env_ids should zero the selected envs and preserve the others.""" + """Partial reset via env_ids should zero the selected envs and preserve the others; a full reset zeros all.""" scene = InteractiveScene(_SingleJointSceneCfg(num_envs=4)) sim.reset() @@ -370,6 +196,14 @@ def test_reset_with_env_ids_only_zeros_selected_envs(sim): torch.testing.assert_close(force_after[1], force_before[1]) torch.testing.assert_close(force_after[3], force_before[3]) + sensor.reset() + + # Access raw buffers to skip lazy re-population from the PhysX view on the next data read. + force_after = wp.to_torch(sensor._data._force) + torque_after = wp.to_torch(sensor._data._torque) + torch.testing.assert_close(force_after, torch.zeros_like(force_after)) + torch.testing.assert_close(torque_after, torch.zeros_like(torque_after)) + def test_no_stale_data_after_scene_reset(sim): """Regression for #4970: ``scene.reset(env_ids)`` must not surface pre-reset wrenches. @@ -452,22 +286,6 @@ def _make_joint_wrench_sensor(use_recorded_launch: bool = True, num_envs: int = return sensor, root_view, wrenches_torch, env_mask -@pytest.mark.isaacsim_ci -@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA is not available") -def test_joint_wrench_caches_physx_wrench_view(): - """Repeated eager updates should reuse one typed view over the refreshed PhysX buffer.""" - # The native tensor already owns the frame; do not reconstruct it from USD. - assert not hasattr(PhysxJointWrenchSensor, "_create_joint_frame_buffers") - sensor, root_view, _, env_mask = _make_joint_wrench_sensor(use_recorded_launch=False) - - sensor._update_buffers_impl(env_mask) - sensor._update_buffers_impl(env_mask) - wp.synchronize_device(sensor.device) - - assert root_view.get_count == 2 - assert root_view.view_count == 1 - - @pytest.mark.isaacsim_ci @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA is not available") def test_joint_wrench_records_and_replays_update_launch(): @@ -514,8 +332,11 @@ def test_joint_wrench_records_and_replays_update_launch(): @pytest.mark.isaacsim_ci @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA is not available") def test_joint_wrench_falls_back_when_recording_fails(monkeypatch): - """A recording failure should disable recording and execute the current update eagerly.""" - sensor, _, _, env_mask = _make_joint_wrench_sensor() + """A recording failure should disable recording and execute the current update eagerly. + + Later eager updates refresh the PhysX buffer but reuse one typed view over it. + """ + sensor, root_view, _, env_mask = _make_joint_wrench_sensor() original_launch = joint_wrench_module.wp.launch def launch_with_recording_failure(*args, record_cmd=False, **kwargs): @@ -533,6 +354,12 @@ def launch_with_recording_failure(*args, record_cmd=False, **kwargs): torch.tensor([1.0, 2.0, 3.0], device=sensor.device), ) + sensor._update_buffers_impl(env_mask) + wp.synchronize_device(sensor.device) + + assert root_view.get_count == 2 + assert root_view.view_count == 1 + @pytest.mark.isaacsim_ci @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA is not available") diff --git a/source/isaaclab_physx/test/sensors/test_pva.py b/source/isaaclab_physx/test/sensors/test_pva.py index c9a46345b97c..de571515230d 100644 --- a/source/isaaclab_physx/test/sensors/test_pva.py +++ b/source/isaaclab_physx/test/sensors/test_pva.py @@ -17,7 +17,6 @@ import pytest import torch -import warp as wp from isaaclab_newton.sim.schemas import NewtonArticulationCfg from isaaclab_physx.physics import PhysxCfg from isaaclab_physx.sim.schemas import PhysxArticulationCfg @@ -85,7 +84,7 @@ class MySceneCfg(InteractiveSceneCfg): # articulations - robot robot = ANYMAL_C_CFG.replace(prim_path="{ENV_REGEX_NS}/robot") - # pendulum1 - uses merge_fixed_joints=True (same as pendulum2) so that fixed-joint + # pendulum - uses merge_fixed_joints=True so that fixed-joint # child links (base, imu_link) are merged into their parents during URDF XML # pre-processing. This avoids fixed-joint constraint violations at velocity level # (the solver uses velocity_iteration_count=0). A non-physics imu_link Xform is @@ -112,31 +111,6 @@ class MySceneCfg(InteractiveSceneCfg): "joint_1_act": ImplicitActuatorCfg(joint_names_expr=["joint_.*"], stiffness=0.0, damping=0.3), }, ) - # pendulum2 - uses merge_fixed_joints=True so that the fixed-joint child links (base, imu_link) - # are merged into their parents during URDF XML pre-processing. A non-physics imu_link Xform - # is created programmatically in the test fixture to test indirect PVA attachment (see setup_sim). - pendulum2 = ArticulationCfg( - prim_path="{ENV_REGEX_NS}/pendulum2", - spawn=sim_utils.UrdfFileCfg( - fix_base=True, - merge_fixed_joints=True, - make_instanceable=False, - asset_path=f"{pathlib.Path(__file__).parent.resolve()}/urdfs/simple_2_link.urdf", - articulation_props=[ - PhysxArticulationCfg( - enabled_self_collisions=True, solver_position_iteration_count=4, solver_velocity_iteration_count=0 - ), - NewtonArticulationCfg(self_collision_enabled=True), - ], - joint_drive=sim_utils.UrdfConverterCfg.JointDriveCfg( - gains=sim_utils.UrdfConverterCfg.JointDriveCfg.PDGainsCfg(stiffness=None, damping=None) - ), - ), - init_state=ArticulationCfg.InitialStateCfg(), - actuators={ - "joint_1_act": ImplicitActuatorCfg(joint_names_expr=["joint_.*"], stiffness=0.0, damping=0.3), - }, - ) # sensors - pva (filled inside unit test) pva_ball: PvaCfg = PvaCfg(prim_path="{ENV_REGEX_NS}/ball") @@ -160,20 +134,6 @@ class MySceneCfg(InteractiveSceneCfg): # kinematic tree. With merge_fixed_joints=True the hierarchy for simple_2_link.urdf is: # Geometry/world/link_1 (base merged into world, imu_link merged into link_1) # A non-physics imu_link Xform is recreated in the test fixture (see setup_sim). - pva_indirect_pendulum_link: PvaCfg = PvaCfg( - prim_path="{ENV_REGEX_NS}/pendulum2/Geometry/world/link_1/imu_link", - debug_vis=not app_launcher._headless, - visualizer_cfg=RED_ARROW_X_MARKER_CFG.replace(prim_path="/Visuals/Acceleration/imu_link"), - ) - pva_indirect_pendulum_base: PvaCfg = PvaCfg( - prim_path="{ENV_REGEX_NS}/pendulum2/Geometry/world/link_1", - offset=PvaCfg.OffsetCfg( - pos=PEND_POS_OFFSET, - rot=PEND_ROT_OFFSET, - ), - debug_vis=not app_launcher._headless, - visualizer_cfg=GREEN_ARROW_X_MARKER_CFG.replace(prim_path="/Visuals/Acceleration/base"), - ) pva_pendulum_imu_link: PvaCfg = PvaCfg( prim_path="{ENV_REGEX_NS}/pendulum/Geometry/world/link_1/imu_link", debug_vis=not app_launcher._headless, @@ -194,7 +154,6 @@ def __post_init__(self): # change position of the robot self.robot.init_state.pos = (0.0, 2.0, 1.0) self.pendulum.init_state.pos = (-2.0, 1.0, 0.5) - self.pendulum2.init_state.pos = (2.0, 1.0, 0.5) # change asset self.robot.spawn.usd_path = f"{ISAAC_NUCLEUS_DIR}/Robots/ANYbotics/anymal_c/anymal_c.usd" @@ -217,16 +176,14 @@ def setup_sim(): # construct scene scene_cfg = MySceneCfg(num_envs=2, env_spacing=5.0, lazy_sensor_update=False, replicate_physics=False) scene = InteractiveScene(scene_cfg) - # Both pendulum and pendulum2 use merge_fixed_joints=True, so the - # fixed-joint child link imu_link is removed from the URDF before USD - # conversion. Recreate it as a plain Xform (no RigidBodyAPI) under each - # pendulum's link_1 for every environment. The PVA sensor must then - # resolve the rigid-body ancestor (link_1) and cache the fixed offset — - # exercising the "indirect attachment" code path. + # The pendulum uses merge_fixed_joints=True, so the fixed-joint child link + # imu_link is removed from the URDF before USD conversion. Recreate it as a + # plain Xform (no RigidBodyAPI) under link_1 for every environment. The PVA + # sensor must then resolve the rigid-body ancestor (link_1) and cache the + # fixed offset — exercising the "indirect attachment" code path. for i in range(scene_cfg.num_envs): - for art_name in ("pendulum", "pendulum2"): - prim_path = f"/World/envs/env_{i}/{art_name}/Geometry/world/link_1/imu_link" - sim_utils.create_prim(prim_path, "Xform", translation=PEND_POS_OFFSET, orientation=PEND_ROT_OFFSET) + prim_path = f"/World/envs/env_{i}/pendulum/Geometry/world/link_1/imu_link" + sim_utils.create_prim(prim_path, "Xform", translation=PEND_POS_OFFSET, orientation=PEND_ROT_OFFSET) # Play the simulator sim.reset() yield sim, scene @@ -234,40 +191,92 @@ def setup_sim(): @pytest.mark.isaacsim_ci -def test_constant_velocity(setup_sim): - """Test the PVA sensor with a constant velocity. +def test_constant_acceleration(setup_sim): + """A constant applied force yields the solver acceleration F/m on top of free fall.""" + sim, scene = setup_sim + balls = scene.rigid_objects["balls"] + force = 0.25 # [N] on a 0.5 kg ball -> 0.5 m/s^2 + expected_acc = force / 0.5 + forces = torch.zeros((scene.num_envs, 1, 3), dtype=torch.float32, device=scene.device) + forces[..., 0] = force + # keep the window short so the ball stays airborne: PVA reports kinematic acceleration, + # so the vertical component is the free-fall -g + for idx in range(10): + balls.set_external_force_and_torque(forces, torch.zeros_like(forces)) + # write data to sim + scene.write_data_to_sim() + # perform step + sim.step() + # read data from sim + scene.update(sim.get_physics_dt()) - Expected behavior is that the linear and angular are approx the same at every time step as in each step we set - the same velocity and therefore reset the physx buffers. + # skip first step where the solver has not integrated the force yet + if idx < 1: + continue + + # check the pva data + torch.testing.assert_close( + scene.sensors["pva_ball"].data.lin_acc_b.torch, + math_utils.quat_apply_inverse( + scene.rigid_objects["balls"].data.root_quat_w.torch, + torch.tensor([[expected_acc, 0.0, -9.81]], dtype=torch.float32, device=scene.device).repeat( + scene.num_envs, 1 + ), + ), + rtol=1e-4, + atol=1e-4, + ) + + # check the angular velocity + torch.testing.assert_close( + scene.sensors["pva_ball"].data.ang_vel_b.torch, + scene.rigid_objects["balls"].data.root_ang_vel_b.torch, + rtol=1e-4, + atol=1e-4, + ) + + +@pytest.mark.isaacsim_ci +def test_offset_calculation(setup_sim): + """Test the PVA sensor on independently driven bodies that share one scene. + + * Robot: a configured offset on the base matches a sensor on the ``imu_link`` child prim. + * Pendulum: the sensor on a non-rigid ``imu_link`` Xform child (indirect attachment through the + rigid-body ancestor) matches the analytic pendulum and a configured offset on ``link_1``. + * Ball and cube: writing the same velocity every step yields constant accelerations and the + written velocity. """ sim, scene = setup_sim prev_lin_acc_ball = torch.zeros((scene.num_envs, 3), dtype=torch.float32, device=scene.device) prev_ang_acc_ball = torch.zeros((scene.num_envs, 3), dtype=torch.float32, device=scene.device) prev_lin_acc_cube = torch.zeros((scene.num_envs, 3), dtype=torch.float32, device=scene.device) prev_ang_acc_cube = torch.zeros((scene.num_envs, 3), dtype=torch.float32, device=scene.device) + constant_velocity = torch.tensor([[1.0, 0.0, 0.0, 0.0, 0.0, 0.0]], dtype=torch.float32, device=scene.device) + # pendulum length + pend_length = PEND_POS_OFFSET[0] + pendulum = scene.articulations["pendulum"] + pendulum_link_id = pendulum.find_bodies("link_1")[0][0] - for idx in range(200): - # set velocity - scene.rigid_objects["balls"].write_root_velocity_to_sim( - torch.tensor([[1.0, 0.0, 0.0, 0.0, 0.0, 0.0]], dtype=torch.float32, device=scene.device).repeat( - scene.num_envs, 1 - ) - ) - scene.rigid_objects["cube"].write_root_velocity_to_sim( - torch.tensor([[1.0, 0.0, 0.0, 0.0, 0.0, 0.0]], dtype=torch.float32, device=scene.device).repeat( + for idx in range(500): + # set acceleration + scene.articulations["robot"].write_root_velocity_to_sim( + torch.tensor([[0.05, 0.0, 0.0, 0.0, 0.0, 0.0]], dtype=torch.float32, device=scene.device).repeat( scene.num_envs, 1 ) + * (idx + 1) ) + # set constant velocity + scene.rigid_objects["balls"].write_root_velocity_to_sim(constant_velocity.repeat(scene.num_envs, 1)) + scene.rigid_objects["cube"].write_root_velocity_to_sim(constant_velocity.repeat(scene.num_envs, 1)) # write data to sim scene.write_data_to_sim() - # perform step sim.step() # read data from sim scene.update(sim.get_physics_dt()) + # constant velocity: the accelerations are the same at every step if idx > 1: - # check the pva accelerations torch.testing.assert_close( scene.sensors["pva_ball"].data.lin_acc_b.torch, prev_lin_acc_ball, @@ -280,7 +289,6 @@ def test_constant_velocity(setup_sim): rtol=1e-3, atol=1e-3, ) - torch.testing.assert_close( scene.sensors["pva_cube"].data.lin_acc_b.torch, prev_lin_acc_cube, @@ -294,7 +302,6 @@ def test_constant_velocity(setup_sim): atol=1e-3, ) - # check the pva velocities # NOTE: the expected lin_vel_b is the same as the set velocity, as write_root_velocity_to_sim is # setting v_0 (initial velocity) and then a calculation step of v_i = v_0 + a*dt. Consequently, # the data.lin_vel_b is returning approx. v_i. @@ -314,220 +321,62 @@ def test_constant_velocity(setup_sim): rtol=1e-4, atol=1e-4, ) - - # update previous values prev_lin_acc_ball = scene.sensors["pva_ball"].data.lin_acc_b.torch.clone() prev_ang_acc_ball = scene.sensors["pva_ball"].data.ang_acc_b.torch.clone() prev_lin_acc_cube = scene.sensors["pva_cube"].data.lin_acc_b.torch.clone() prev_ang_acc_cube = scene.sensors["pva_cube"].data.ang_acc_b.torch.clone() - # the recorded-launch optimization must be active on CUDA; a recording failure would only - # warn and silently fall back to eager launches, defeating the optimization. - if "cuda" in str(scene.device): - assert scene.sensors["pva_ball"]._update_cmd is not None - assert scene.sensors["pva_cube"]._update_cmd is not None - - -@pytest.mark.isaacsim_ci -def test_constant_acceleration(setup_sim): - """A constant applied force yields the solver acceleration F/m on top of free fall.""" - sim, scene = setup_sim - balls = scene.rigid_objects["balls"] - force = 0.25 # [N] on a 0.5 kg ball -> 0.5 m/s^2 - expected_acc = force / 0.5 - forces = torch.zeros((scene.num_envs, 1, 3), dtype=torch.float32, device=scene.device) - forces[..., 0] = force - # keep the window short so the ball stays airborne: PVA reports kinematic acceleration, - # so the vertical component is the free-fall -g - for idx in range(10): - balls.set_external_force_and_torque(forces, torch.zeros_like(forces)) - # write data to sim - scene.write_data_to_sim() - # perform step - sim.step() - # read data from sim - scene.update(sim.get_physics_dt()) - - # skip first step where the solver has not integrated the force yet + # skip first step where initial velocity is zero if idx < 1: continue - # check the pva data + # robot: offset vs imu_link definition torch.testing.assert_close( - scene.sensors["pva_ball"].data.lin_acc_b.torch, - math_utils.quat_apply_inverse( - scene.rigid_objects["balls"].data.root_quat_w.torch, - torch.tensor([[expected_acc, 0.0, -9.81]], dtype=torch.float32, device=scene.device).repeat( - scene.num_envs, 1 - ), - ), + scene.sensors["pva_robot_base"].data.lin_acc_b.torch, + scene.sensors["pva_robot_imu_link"].data.lin_acc_b.torch, rtol=1e-4, atol=1e-4, ) - - # check the angular velocity torch.testing.assert_close( - scene.sensors["pva_ball"].data.ang_vel_b.torch, - scene.rigid_objects["balls"].data.root_ang_vel_b.torch, + scene.sensors["pva_robot_base"].data.ang_acc_b.torch, + scene.sensors["pva_robot_imu_link"].data.ang_acc_b.torch, rtol=1e-4, atol=1e-4, ) - - -@pytest.mark.isaacsim_ci -def test_single_dof_pendulum(setup_sim): - """Test PVA against analytical pendulum problem.""" - sim, scene = setup_sim - # pendulum length - pend_length = PEND_POS_OFFSET[0] - pendulum = scene.articulations["pendulum"] - pendulum_link_id = pendulum.find_bodies("link_1")[0][0] - - # should achieve same results between the two pva sensors on the robot - for idx in range(500): - # write data to sim - scene.write_data_to_sim() - # perform step - sim.step() - # read data from sim - scene.update(sim.get_physics_dt()) - - # get pendulum joint state - joint_pos = pendulum.data.joint_pos.torch - joint_vel = pendulum.data.joint_vel.torch - # Use the solver-reported link acceleration as the reference. The public joint - # acceleration is finite-differenced and intentionally differs from PVA semantics. - joint_ang_acc_w_y = pendulum.data.body_com_acc_w.torch[:, pendulum_link_id, 4].unsqueeze(-1) - - # PVA and base data - pva_data = scene.sensors["pva_pendulum_imu_link"].data - base_data = scene.sensors["pva_pendulum_base"].data - - # extract imu_link pva_sensor dynamics - lin_vel_w_imu_link = math_utils.quat_apply(pva_data.quat_w.torch, pva_data.lin_vel_b.torch) - lin_acc_w_imu_link = math_utils.quat_apply(pva_data.quat_w.torch, pva_data.lin_acc_b.torch) - - # calculate the joint dynamics from the pva_sensor (y axis of imu_link is parallel to joint axis of pendulum) - joint_vel_pva = math_utils.quat_apply(pva_data.quat_w.torch, pva_data.ang_vel_b.torch)[..., 1].unsqueeze(-1) - joint_acc_pva = math_utils.quat_apply(pva_data.quat_w.torch, pva_data.ang_acc_b.torch)[..., 1].unsqueeze(-1) - - # calculate analytical solution - vx = -joint_vel * pend_length * torch.sin(joint_pos) - vy = torch.zeros(2, 1, device=scene.device) - vz = -joint_vel * pend_length * torch.cos(joint_pos) - gt_linear_vel_w = torch.cat([vx, vy, vz], dim=-1) - - ax = -joint_ang_acc_w_y * pend_length * torch.sin(joint_pos) - joint_vel**2 * pend_length * torch.cos(joint_pos) - ay = torch.zeros(2, 1, device=scene.device) - az = -joint_ang_acc_w_y * pend_length * torch.cos(joint_pos) + joint_vel**2 * pend_length * torch.sin(joint_pos) - gt_linear_acc_w = torch.cat([ax, ay, az], dim=-1) - - # skip first step where initial velocity is zero - if idx < 2: - continue - - # compare pva projected gravity - gravity_dir_w = torch.tensor((0.0, 0.0, -1.0), device=scene.device).repeat(2, 1) - gravity_dir_b = math_utils.quat_apply_inverse(pva_data.quat_w.torch, gravity_dir_w) - torch.testing.assert_close( - pva_data.projected_gravity_b.torch, - gravity_dir_b, - ) - - # compare pva angular velocity with joint velocity torch.testing.assert_close( - joint_vel, - joint_vel_pva, - rtol=1e-1, - atol=1e-3, - ) - # compare pva angular acceleration with solver-reported link acceleration - torch.testing.assert_close( - joint_ang_acc_w_y, - joint_acc_pva, - rtol=1e-1, - atol=1e-3, - ) - # compare pva linear velocity with simple pendulum calculation - torch.testing.assert_close( - gt_linear_vel_w, - lin_vel_w_imu_link, - rtol=1e-1, - atol=1e-3, - ) - # compare pva linear acceleration with simple pendulum calculation - torch.testing.assert_close( - gt_linear_acc_w, - lin_acc_w_imu_link, - rtol=1e-1, - atol=1e0, - ) - - # check the position between offset and pva definition - torch.testing.assert_close( - base_data.pos_w.torch, - pva_data.pos_w.torch, - rtol=1e-5, - atol=1e-5, - ) - - # check the orientation between offset and pva definition - torch.testing.assert_close( - base_data.quat_w.torch, - pva_data.quat_w.torch, + scene.sensors["pva_robot_base"].data.ang_vel_b.torch, + scene.sensors["pva_robot_imu_link"].data.ang_vel_b.torch, rtol=1e-4, atol=1e-4, ) - - # check the angular velocities of the pvas between offset and pva definition torch.testing.assert_close( - base_data.ang_vel_b.torch, - pva_data.ang_vel_b.torch, + scene.sensors["pva_robot_base"].data.lin_vel_b.torch, + scene.sensors["pva_robot_imu_link"].data.lin_vel_b.torch, rtol=1e-4, atol=1e-4, ) - # check the angular acceleration of the pvas between offset and pva definition torch.testing.assert_close( - base_data.ang_acc_b.torch, - pva_data.ang_acc_b.torch, + scene.sensors["pva_robot_base"].data.quat_w.torch, + scene.sensors["pva_robot_imu_link"].data.quat_w.torch, rtol=1e-4, atol=1e-4, ) - - # check the linear velocity of the pvas between offset and pva definition torch.testing.assert_close( - base_data.lin_vel_b.torch, - pva_data.lin_vel_b.torch, - rtol=1e-2, - atol=5e-3, + scene.sensors["pva_robot_base"].data.pos_w.torch, + scene.sensors["pva_robot_imu_link"].data.pos_w.torch, + rtol=1e-4, + atol=1e-4, ) - - # check the linear acceleration of the pvas between offset and pva definition torch.testing.assert_close( - base_data.lin_acc_b.torch, - pva_data.lin_acc_b.torch, - rtol=1e-1, - atol=1e-1, + scene.sensors["pva_robot_base"].data.projected_gravity_b.torch, + scene.sensors["pva_robot_imu_link"].data.projected_gravity_b.torch, + rtol=1e-4, + atol=1e-4, ) - -@pytest.mark.isaacsim_ci -def test_indirect_attachment(setup_sim): - """Test attaching the PVA sensor through an xForm primitive configuration argument.""" - sim, scene = setup_sim - # pendulum length - pend_length = PEND_POS_OFFSET[0] - pendulum = scene.articulations["pendulum2"] - pendulum_link_id = pendulum.find_bodies("link_1")[0][0] - - # should achieve same results between the two pva sensors on the robot - for idx in range(500): - # write data to sim - scene.write_data_to_sim() - # perform step - sim.step() - # read data from sim - scene.update(sim.get_physics_dt()) + # skip the second pendulum step where the initial velocity is still zero + if idx < 2: + continue # get pendulum joint state joint_pos = pendulum.data.joint_pos.torch @@ -536,23 +385,10 @@ def test_indirect_attachment(setup_sim): # acceleration is finite-differenced and intentionally differs from PVA semantics. joint_ang_acc_w_y = pendulum.data.body_com_acc_w.torch[:, pendulum_link_id, 4].unsqueeze(-1) - pva = scene.sensors["pva_indirect_pendulum_link"] - pva_base = scene.sensors["pva_indirect_pendulum_base"] - - torch.testing.assert_close( - wp.to_torch(pva._offset_pos_b), - wp.to_torch(pva_base._offset_pos_b), - ) - torch.testing.assert_close( - wp.to_torch(pva._offset_quat_b), - wp.to_torch(pva_base._offset_quat_b), - rtol=1e-4, - atol=1e-4, - ) - # PVA and base data - pva_data = scene.sensors["pva_indirect_pendulum_link"].data - base_data = scene.sensors["pva_indirect_pendulum_base"].data + pva_data = scene.sensors["pva_pendulum_imu_link"].data + base_data = scene.sensors["pva_pendulum_base"].data + # extract imu_link pva_sensor dynamics lin_vel_w_imu_link = math_utils.quat_apply(pva_data.quat_w.torch, pva_data.lin_vel_b.torch) lin_acc_w_imu_link = math_utils.quat_apply(pva_data.quat_w.torch, pva_data.lin_acc_b.torch) @@ -572,10 +408,6 @@ def test_indirect_attachment(setup_sim): az = -joint_ang_acc_w_y * pend_length * torch.cos(joint_pos) + joint_vel**2 * pend_length * torch.sin(joint_pos) gt_linear_acc_w = torch.cat([ax, ay, az], dim=-1) - # skip first step where initial velocity is zero - if idx < 2: - continue - # compare pva projected gravity gravity_dir_w = torch.tensor((0.0, 0.0, -1.0), device=scene.device).repeat(2, 1) gravity_dir_b = math_utils.quat_apply_inverse(pva_data.quat_w.torch, gravity_dir_w) @@ -660,126 +492,17 @@ def test_indirect_attachment(setup_sim): atol=1e-1, ) + # the recorded-launch optimization must be active on CUDA; a recording failure would only + # warn and silently fall back to eager launches, defeating the optimization. + if "cuda" in str(scene.device): + assert scene.sensors["pva_ball"]._update_cmd is not None + assert scene.sensors["pva_cube"]._update_cmd is not None -@pytest.mark.isaacsim_ci -def test_offset_calculation(setup_sim): - """Test offset configuration argument.""" - sim, scene = setup_sim - - # should achieve same results between the two pva sensors on the robot - for idx in range(500): - # set acceleration - scene.articulations["robot"].write_root_velocity_to_sim( - torch.tensor([[0.05, 0.0, 0.0, 0.0, 0.0, 0.0]], dtype=torch.float32, device=scene.device).repeat( - scene.num_envs, 1 - ) - * (idx + 1) - ) - # write data to sim - scene.write_data_to_sim() - # perform step - sim.step() - # read data from sim - scene.update(sim.get_physics_dt()) - - # skip first step where initial velocity is zero - if idx < 1: - continue - - # check the accelerations - torch.testing.assert_close( - scene.sensors["pva_robot_base"].data.lin_acc_b.torch, - scene.sensors["pva_robot_imu_link"].data.lin_acc_b.torch, - rtol=1e-4, - atol=1e-4, - ) - torch.testing.assert_close( - scene.sensors["pva_robot_base"].data.ang_acc_b.torch, - scene.sensors["pva_robot_imu_link"].data.ang_acc_b.torch, - rtol=1e-4, - atol=1e-4, - ) - - # check the velocities - torch.testing.assert_close( - scene.sensors["pva_robot_base"].data.ang_vel_b.torch, - scene.sensors["pva_robot_imu_link"].data.ang_vel_b.torch, - rtol=1e-4, - atol=1e-4, - ) - torch.testing.assert_close( - scene.sensors["pva_robot_base"].data.lin_vel_b.torch, - scene.sensors["pva_robot_imu_link"].data.lin_vel_b.torch, - rtol=1e-4, - atol=1e-4, - ) - - # check the orientation - torch.testing.assert_close( - scene.sensors["pva_robot_base"].data.quat_w.torch, - scene.sensors["pva_robot_imu_link"].data.quat_w.torch, - rtol=1e-4, - atol=1e-4, - ) - # check the position - torch.testing.assert_close( - scene.sensors["pva_robot_base"].data.pos_w.torch, - scene.sensors["pva_robot_imu_link"].data.pos_w.torch, - rtol=1e-4, - atol=1e-4, - ) - # check the projected gravity - torch.testing.assert_close( - scene.sensors["pva_robot_base"].data.projected_gravity_b.torch, - scene.sensors["pva_robot_imu_link"].data.projected_gravity_b.torch, - rtol=1e-4, - atol=1e-4, - ) - - -@pytest.mark.isaacsim_ci -def test_attachment_validity(setup_sim): - """Test invalid PVA attachment. A PVA sensor cannot be attached directly to the world. - - It must be somehow attached to something implementing physics. - """ - sim, scene = setup_sim - pva_world_cfg = PvaCfg(prim_path="/World/envs/env_0") - with pytest.raises(RuntimeError) as exc_info: - pva_world = Pva(pva_world_cfg) - pva_world._initialize_impl() - assert exc_info.type is RuntimeError and "find a rigid body ancestor prim" in str(exc_info.value) - - -@pytest.mark.isaacsim_ci -def test_env_ids_propagation(setup_sim): - """Test that env_ids argument propagates through update and reset methods""" - sim, scene = setup_sim - scene.reset() - - for idx in range(10): - # set acceleration - scene.articulations["robot"].write_root_velocity_to_sim( - torch.tensor([[0.5, 0.0, 0.0, 0.0, 0.0, 0.0]], dtype=torch.float32, device=scene.device).repeat( - scene.num_envs, 1 - ) - * (idx + 1) - ) - # write data to sim - scene.write_data_to_sim() - # perform step - sim.step() - # read data from sim - scene.update(sim.get_physics_dt()) + assert "number of sensors : 2" in str(scene.sensors["pva_ball"]) - # reset scene for env 1 - scene.reset(env_ids=[1]) - # read data from sim - scene.update(sim.get_physics_dt()) - # perform step - sim.step() - # read data from sim - scene.update(sim.get_physics_dt()) + # A PVA sensor cannot be attached directly to the world: it must have a rigid-body ancestor. + with pytest.raises(RuntimeError, match="find a rigid body ancestor prim"): + Pva(PvaCfg(prim_path="/World/envs/env_0"))._initialize_impl() @configclass @@ -811,7 +534,7 @@ def test_no_stale_data_after_scene_reset(): sim_cfg = sim_utils.SimulationCfg(dt=0.01, physics=PhysxCfg(solver_type=0)) with sim_utils.build_simulation_context(sim_cfg=sim_cfg) as sim: sim._app_control_on_stop_handle = None - scene_cfg = _StaleResetSceneCfg(num_envs=1, env_spacing=2.0, lazy_sensor_update=False) + scene_cfg = _StaleResetSceneCfg(num_envs=2, env_spacing=2.0, lazy_sensor_update=False) scene = InteractiveScene(scene_cfg) sim.reset() scene.reset() @@ -824,29 +547,23 @@ def test_no_stale_data_after_scene_reset(): sim.step(render=False) scene.update(dt=sim.get_physics_dt()) - # Sanity: cube has gained downward velocity. - pre_reset_vel_mag = torch.linalg.norm(sensor.data.lin_vel_b.torch, dim=-1).item() - assert pre_reset_vel_mag > 0.05, f"Expected non-zero velocity before reset; got {pre_reset_vel_mag!r}" + # Sanity: the cubes have gained downward velocity. + pre_reset_lin_vel = sensor.data.lin_vel_b.torch.clone() + assert (torch.linalg.norm(pre_reset_lin_vel, dim=-1) > 0.05).all(), ( + f"Expected non-zero velocity before reset; got {pre_reset_lin_vel}" + ) - # Reset the scene without writing fresh velocity/transform. The PhysX velocity + # Reset env 0 without writing fresh velocity/transform. The PhysX velocity # buffer therefore still holds the pre-reset (falling) value. scene.reset(env_ids=torch.tensor([0], device=sensor.device)) - # The public ``data`` accessor must not refetch a stale PhysX buffer. + # The public ``data`` accessor must not refetch a stale PhysX buffer for the reset env, + # while env 1 keeps its measurement. post_reset_vel = sensor.data.lin_vel_b.torch post_reset_acc = sensor.data.lin_acc_b.torch - torch.testing.assert_close(post_reset_vel, torch.zeros_like(post_reset_vel)) - torch.testing.assert_close(post_reset_acc, torch.zeros_like(post_reset_acc)) - - -@pytest.mark.isaacsim_ci -def test_sensor_print(setup_sim): - """Test sensor print is working correctly.""" - sim, scene = setup_sim - # Create sensor - sensor = scene.sensors["pva_ball"] - # print info - print(sensor) + torch.testing.assert_close(post_reset_vel[0], torch.zeros_like(post_reset_vel[0])) + torch.testing.assert_close(post_reset_acc[0], torch.zeros_like(post_reset_acc[0])) + torch.testing.assert_close(post_reset_vel[1], pre_reset_lin_vel[1]) @pytest.mark.parametrize("access_mode", ("lazy_read", "update_period")) diff --git a/source/isaaclab_physx/test/sensors/test_ray_caster_graph.py b/source/isaaclab_physx/test/sensors/test_ray_caster_graph.py index 4986531de18b..b613d2a784b4 100644 --- a/source/isaaclab_physx/test/sensors/test_ray_caster_graph.py +++ b/source/isaaclab_physx/test/sensors/test_ray_caster_graph.py @@ -101,17 +101,6 @@ def compute(sensor, env_mask): monkeypatch.setattr(BaseRayCaster, "_update_buffers_impl", compute) -def test_ray_caster_caches_physx_transform_view(): - """Repeated eager reads should reuse one typed view over the refreshed PhysX buffer.""" - sensor, transform_view, _, _ = _make_ray_caster(use_graph=False, cache_raw_transforms=False) - - sensor._get_view_transforms_wp() - sensor._get_view_transforms_wp() - - assert transform_view.get_count == 2 - assert transform_view.conversion_count == 1 - - def test_ray_caster_updates_eagerly_when_graphs_are_disabled(monkeypatch): """Graph-disabled updates should stay eager while reusing the cached transform view.""" _replace_base_update_with_test_kernel(monkeypatch) diff --git a/source/isaaclab_physx/test/sim/test_cloner.py b/source/isaaclab_physx/test/sim/test_cloner.py index fe37c8c52e87..393c23eb5759 100644 --- a/source/isaaclab_physx/test/sim/test_cloner.py +++ b/source/isaaclab_physx/test/sim/test_cloner.py @@ -8,6 +8,7 @@ """Launch Isaac Sim Simulator first.""" from isaaclab.app import AppLauncher +from isaaclab.test.utils import DeviceScope, test_devices # launch omniverse app simulation_app = AppLauncher(headless=True).app @@ -60,28 +61,12 @@ def sim(request): yield sim -def test_physx_replicate_no_error(sim): - """PhysX replicator call runs without raising exceptions for simple mapping.""" - sim_utils.create_prim("/World/envs", "Xform") - sim_utils.create_prim("/World/template", "Xform") - sim_utils.create_prim("/World/template/A", "Xform") - - num_envs = 2 - env_ids = np.arange(num_envs, dtype=np.int64) - for i in range(num_envs): - sim_utils.create_prim(f"/World/envs/env_{i}", "Xform") - - mapping = np.ones((1, num_envs), dtype=np.bool_) - - physx_replicate( - sim_utils.get_current_stage(), - sources=["/World/template/A"], - destinations=["/World/envs/env_{}/A"], - env_ids=env_ids, - mapping=mapping, - ) +# Replicator bookkeeping (mapping checks, world lists, Fabric notices) does not depend on the +# simulation device, so those tests run on CUDA only. +cuda_only = pytest.mark.parametrize("sim", test_devices(DeviceScope.DEFAULT_CUDA), indirect=True) +@cuda_only def test_physx_replicate_validates_mapping_shape(sim): """Mapping rows and columns match the declared sources and environments.""" env_ids = np.arange(2, dtype=np.int64) @@ -139,6 +124,7 @@ def _fake_register(_stage_id, attach_fn, attach_end_fn, rename_fn): return mock_rep, replicate_calls, attach_excluded +@cuda_only def test_physx_replicate_context_consumes_plan(sim): """PhysxReplicateContext reads its mapping from the shared clone plan.""" from unittest.mock import patch @@ -163,6 +149,7 @@ def test_physx_replicate_context_consumes_plan(sim): assert replicate_calls == [2] +@cuda_only @pytest.mark.parametrize( "num_envs,src,expected_worlds", [ @@ -242,6 +229,7 @@ def test_physx_replicate_isolated_source_loaded_without_replication(sim): ) +@cuda_only def test_physx_replicate_heterogeneous_isolated_sources(sim): """physx_replicate handles heterogeneous sources excluding self from world lists. @@ -351,7 +339,7 @@ def test_direct_clone_plan_multi_asset(sim): sim.reset() physics_sim_view = sim.physics_manager.get_physics_sim_view() physx_view = physics_sim_view.create_rigid_body_view("/World/envs/env_*/Object") - assert physx_view is not None + assert physx_view.count == num_clones def _run_colocation_collision_filter(sim, asset_cfg, expected_types, assert_count=False): @@ -421,41 +409,6 @@ def test_colocation_collision_filter_homogeneous(sim): ) -@pytest.mark.xfail(reason="Heterogeneous cloning with collision filtering not yet fully supported") -def test_colocation_collision_filter_heterogeneous(sim): - """Verify colocated clones of multiple prototypes retain modulo ordering and remain stable. - - The cone, cube, and sphere are all spawned in the identical pose for every clone; an incorrect - collision filter would blow up the simulation on reset. This guards both modulo ordering and - that the colocated set stays stable through PhysX steps across CPU and CUDA. - """ - _run_colocation_collision_filter( - sim, - sim_utils.MultiAssetSpawnerCfg( - assets_cfg=[ - sim_utils.ConeCfg( - radius=0.3, - height=0.6, - visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.0, 1.0, 0.0), metallic=0.2), - mass_props=sim_utils.MassCfg(mass=100.0), - ), - sim_utils.CuboidCfg( - size=(0.3, 0.3, 0.3), - visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(1.0, 0.0, 0.0), metallic=0.2), - ), - sim_utils.SphereCfg( - radius=0.3, - visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.0, 0.0, 1.0), metallic=0.2), - ), - ], - rigid_props=PhysxRigidBodyCfg(solver_position_iteration_count=4, solver_velocity_iteration_count=0), - mass_props=sim_utils.MassCfg(mass=1.0), - collision_props=sim_utils.UsdPhysicsCollisionCfg(), - ), - expected_types=["Cone", "Cube", "Sphere"], - ) - - def _run_sphere_velocity_sim(sim, use_physx_replicate: bool, num_steps: int = 10) -> torch.Tensor: """Run a 2-env sphere simulation and return the full velocity trajectory. @@ -518,25 +471,14 @@ def _run_sphere_velocity_sim(sim, use_physx_replicate: bool, num_steps: int = 10 return torch.stack(velocities) -def test_physx_replicate_env_consistency(sim): - """Test that env_0 and env_1 produce matching velocities when using physx_replicate.""" - trajectory = _run_sphere_velocity_sim(sim, use_physx_replicate=True) - - for idx in range(trajectory.shape[0]): - v0 = trajectory[idx, 0] - v1 = trajectory[idx, 1] - diff = (v0 - v1).abs().max().item() - assert diff < 1e-3, f"step {idx}: env_0 and env_1 diverge, max_diff={diff}" - - -@pytest.mark.xfail(reason="Source env gets physics from replicator, not USD parsing; may diverge from baseline.") -@pytest.mark.parametrize("device", ["cpu", "cuda"]) +@pytest.mark.parametrize("device", test_devices(DeviceScope.CPU_AND_DEFAULT_CUDA)) def test_physx_replicate_vs_no_replicate(device): """Test that physx_replicate does not change the physics behavior of env_0. With ``attach_fn`` excluding ``/World/envs``, env_0 receives its physics body from the replicator (as the source of ``rep.replicate()``) rather than from - normal USD parsing, which may produce subtly different behaviour. + normal USD parsing; the resulting trajectory must match the USD-parsed baseline, + and the replicated env_1 must match env_0. """ with build_simulation_context(device=device, dt=0.01, add_lighting=False) as sim_no_rep: baseline = _run_sphere_velocity_sim(sim_no_rep, use_physx_replicate=False) @@ -547,8 +489,11 @@ def test_physx_replicate_vs_no_replicate(device): for idx in range(baseline.shape[0]): diff = (with_rep[idx, 0] - baseline[idx, 0]).abs().max().item() assert diff < 1e-3, f"step {idx}: replicate vs no-replicate diverge, max_diff={diff}" + diff = (with_rep[idx, 0] - with_rep[idx, 1]).abs().max().item() + assert diff < 1e-3, f"step {idx}: env_0 and env_1 diverge, max_diff={diff}" +@cuda_only def test_disabled_fabric_change_notifies_toggles_ifabricusd_flag(sim): """Regression: ``disabled_fabric_change_notifies`` actually toggles the IFabricUsd flag. @@ -599,95 +544,3 @@ def test_disabled_fabric_change_notifies_toggles_ifabricusd_flag(sim): assert not bindings.is_enabled(fabric_id) assert not bindings.is_enabled(fabric_id), "inner exit must not re-enable while outer is active" assert bindings.is_enabled(fabric_id), "outer exit should restore the flag" - - -@pytest.mark.skip( - reason=( - "Local-only perf regression; correctness is covered by" - " test_disabled_fabric_change_notifies_toggles_ifabricusd_flag." - " Re-enable manually when touching listener suspension." - ) -) -def test_disabled_fabric_change_notifies_speedup_regression(): - """Local-only perf regression: listener suspension speeds up clone+reset by >= 1.2x. - - Skipped unconditionally — the suspension mechanism's correctness is covered by - :func:`test_disabled_fabric_change_notifies_toggles_ifabricusd_flag`; the wall-clock - win is platform-sensitive (deferred Fabric resync in ``sim.reset`` can offset the - scene-time savings on some hardware). Re-verify locally when touching the suspension. - - Scene knobs from bisection: ``rigid_props`` is required (plain Xforms give ~1.0x), - ``replicate_physics=True`` is required (drops to ~1.19x without), and 16 bodies x - 4096 envs ≈ 64K firings keeps listener cost above noise. See PR #5432. - """ - import time - - import isaaclab.cloner._fabric_notices as fabric_notices_mod - import isaaclab.sim as sim_utils - from isaaclab.assets import RigidObjectCfg - from isaaclab.scene import InteractiveScene, InteractiveSceneCfg - from isaaclab.utils import configclass - - if fabric_notices_mod.get_bindings() is None: - pytest.skip("omni::fabric::IFabricUsd unavailable") - - def _body(i: int) -> RigidObjectCfg: - return RigidObjectCfg( - prim_path=f"/World/envs/env_[^/]+/Body_{i}", - spawn=sim_utils.SphereCfg(radius=0.1, rigid_props=sim_utils.UsdPhysicsRigidBodyCfg()), - init_state=RigidObjectCfg.InitialStateCfg(pos=(0.3 * (i % 4), 0.3 * (i // 4), 0.5)), - ) - - @configclass - class _SceneCfg(InteractiveSceneCfg): - pass - - for _i in range(16): - setattr(_SceneCfg, f"body_{_i}", _body(_i)) - - def _probe_flag(stage) -> bool | None: - try: - import usdrt - from pxr import UsdUtils - - cid = UsdUtils.StageCache.Get().GetId(stage) - sid = cid.ToLongInt() if cid.IsValid() else UsdUtils.StageCache.Get().Insert(stage).ToLongInt() - fid = usdrt.Usd.Stage.Attach(sid).GetFabricId().id - b = fabric_notices_mod.get_bindings() - return None if b is None else bool(b.is_enabled(fid)) - except Exception: - return None - - def _measure(simulate_pre_pr: bool) -> tuple[float, float, bool | None]: - original = fabric_notices_mod.get_bindings - if simulate_pre_pr: - fabric_notices_mod.get_bindings = lambda: None - assert fabric_notices_mod.get_bindings() is None, "monkey-patch did not take effect" - try: - with build_simulation_context(device="cpu", dt=0.01, add_lighting=False) as sim: - t0 = time.perf_counter() - scene = InteractiveScene(_SceneCfg(num_envs=4096, env_spacing=4.0, replicate_physics=True)) - scene_dt = time.perf_counter() - t0 - # Probe before reset so we see whether suspension actually engaged. - fabric_notices_mod.get_bindings = original - flag = _probe_flag(scene.stage) - if simulate_pre_pr: - fabric_notices_mod.get_bindings = lambda: None - t0 = time.perf_counter() - sim.reset() - return scene_dt, time.perf_counter() - t0, flag - finally: - fabric_notices_mod.get_bindings = original - - _measure(simulate_pre_pr=False) # warmup - s_scene, s_reset, s_flag = _measure(simulate_pre_pr=False) - a_scene, a_reset, a_flag = _measure(simulate_pre_pr=True) - - suspended = s_scene + s_reset - active = a_scene + a_reset - speedup = active / suspended - print( - f"\n[fabric-notice perf] active={active:.2f}s (flag={a_flag})" - f" suspended={suspended:.2f}s (flag={s_flag}) speedup={speedup:.2f}x" - ) - assert speedup >= 1.2, f"expected >= 1.2x, got {speedup:.2f}x (active={active:.2f}s suspended={suspended:.2f}s)" diff --git a/source/isaaclab_physx/test/sim/test_spawn_materials.py b/source/isaaclab_physx/test/sim/test_spawn_materials.py index bdd39a2d242e..ca5e790504ef 100644 --- a/source/isaaclab_physx/test/sim/test_spawn_materials.py +++ b/source/isaaclab_physx/test/sim/test_spawn_materials.py @@ -38,7 +38,7 @@ def sim(): def test_spawn_deformable_body_material(sim): - """Test spawning a deformable body material.""" + """Test spawning volume and surface deformable body materials.""" cfg = PhysxDeformableBodyMaterialCfg( density=1.0, dynamic_friction=0.25, @@ -57,21 +57,20 @@ def test_spawn_deformable_body_material(sim): assert prim.GetAttribute("omniphysics:poissonsRatio").Get() == cfg.poissons_ratio assert prim.GetAttribute("physxDeformableMaterial:elasticityDamping").Get() == pytest.approx(cfg.elasticity_damping) - -def test_spawn_surface_deformable_body_material(sim): - """Test spawning a surface deformable body material.""" - cfg = PhysxSurfaceDeformableBodyMaterialCfg( + surface_cfg = PhysxSurfaceDeformableBodyMaterialCfg( density=1.0, youngs_modulus=50000000.0, poissons_ratio=0.5, elasticity_damping=0.005, bend_damping=0.01, ) - prim = cfg.func("/Looks/SurfaceDeformableBodyMaterial", cfg) + prim = surface_cfg.func("/Looks/SurfaceDeformableBodyMaterial", surface_cfg) # Check validity assert prim.IsValid() assert sim.stage.GetPrimAtPath("/Looks/SurfaceDeformableBodyMaterial").IsValid() # Check PhysX properties assert "PhysxSurfaceDeformableMaterialAPI" in prim.GetAppliedSchemas() - assert prim.GetAttribute("physxDeformableMaterial:elasticityDamping").Get() == pytest.approx(cfg.elasticity_damping) - assert prim.GetAttribute("physxDeformableMaterial:bendDamping").Get() == pytest.approx(cfg.bend_damping) + assert prim.GetAttribute("physxDeformableMaterial:elasticityDamping").Get() == pytest.approx( + surface_cfg.elasticity_damping + ) + assert prim.GetAttribute("physxDeformableMaterial:bendDamping").Get() == pytest.approx(surface_cfg.bend_damping) diff --git a/source/isaaclab_physx/test/sim/test_spawn_meshes.py b/source/isaaclab_physx/test/sim/test_spawn_meshes.py index d8dd2c5e84b6..bcfbc7880ffb 100644 --- a/source/isaaclab_physx/test/sim/test_spawn_meshes.py +++ b/source/isaaclab_physx/test/sim/test_spawn_meshes.py @@ -47,7 +47,9 @@ def sim(): def test_spawn_cone_with_deformable_and_mass_props(sim): - """Test spawning of UsdGeomMesh prim for a cone with deformable body and mass API.""" + """Test spawning of UsdGeomMesh prims for cones with deformable body and mass API, + specifying either the mass or the material density. + """ # Spawn cone cfg = sim_utils.MeshConeCfg( radius=1.0, @@ -64,10 +66,21 @@ def test_spawn_cone_with_deformable_and_mass_props(sim): assert prim.GetAttribute("omniphysics:deformableBodyEnabled").Get() == cfg.deformable_props.deformable_body_enabled assert prim.GetAttribute("omniphysics:mass").Get() == cfg.deformable_props.mass - # check sim playing - sim.play() - for _ in range(10): - sim.step() + # Spawn cone with density instead of mass + cfg = sim_utils.MeshConeCfg( + radius=1.0, + height=2.0, + deformable_props=PhysxDeformableBodyPropertiesCfg(deformable_body_enabled=True), + physics_material=PhysxDeformableBodyMaterialCfg(density=10.0), + ) + prim = cfg.func("/World/ConeDensity", cfg) + + # Check validity + assert prim.IsValid() + assert sim.stage.GetPrimAtPath("/World/ConeDensity/geometry/material").IsValid() + # Check properties + prim = sim.stage.GetPrimAtPath("/World/ConeDensity/geometry/material") + assert prim.GetAttribute("omniphysics:density").Get() == cfg.physics_material.density def test_spawn_cone_with_deformable_and_collision_fragment_mapping(sim): @@ -90,29 +103,3 @@ def test_spawn_cone_with_deformable_and_collision_fragment_mapping(sim): assert sim_mesh.IsValid() assert abs(sim_mesh.GetAttribute("physxCollision:contactOffset").Get() - 0.02) < 1e-6 assert abs(sim_mesh.GetAttribute("physxCollision:restOffset").Get() - 0.001) < 1e-6 - - -def test_spawn_cone_with_deformable_and_density_props(sim): - """Test spawning of UsdGeomMesh prim for a cone with deformable body and mass API, - specifying density instead of mass. - """ - # Spawn cone - cfg = sim_utils.MeshConeCfg( - radius=1.0, - height=2.0, - deformable_props=PhysxDeformableBodyPropertiesCfg(deformable_body_enabled=True), - physics_material=PhysxDeformableBodyMaterialCfg(density=10.0), - ) - prim = cfg.func("/World/Cone", cfg) - - # Check validity - assert prim.IsValid() - assert sim.stage.GetPrimAtPath("/World/Cone").IsValid() - assert sim.stage.GetPrimAtPath("/World/Cone/geometry/material").IsValid() - # Check properties - prim = sim.stage.GetPrimAtPath("/World/Cone/geometry/material") - assert prim.GetAttribute("omniphysics:density").Get() == cfg.physics_material.density - # check sim playing - sim.play() - for _ in range(10): - sim.step() diff --git a/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py b/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py index 2eb6583faac8..6102126ba8ca 100644 --- a/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py +++ b/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py @@ -181,36 +181,6 @@ def test_sdp_native_gpu_fabric_binding_preserves_live_physx_pose(device, request assert sim.get_physics_step_count() == step_count -@pytest.mark.parametrize("device", test_devices()) -def test_float_scale_initializes_fabric(device): - """A legal float3 scale initializes Fabric without changing the FP32 view contract.""" - _skip_if_unavailable(device) - - stage = sim_utils.get_current_stage() - prim = stage.DefinePrim("/World/SiteGuide", "Sphere") - xformable = UsdGeom.Xformable(prim) - xformable.AddTranslateOp(UsdGeom.XformOp.PrecisionFloat).Set(Gf.Vec3f(0.1, -0.2, 0.3)) - xformable.AddOrientOp(UsdGeom.XformOp.PrecisionFloat).Set(Gf.Quatf(1.0, Gf.Vec3f(0.0))) - xformable.AddScaleOp(UsdGeom.XformOp.PrecisionFloat).Set(Gf.Vec3f(0.01, 0.02, 0.03)) - - sim_utils.SimulationContext(sim_utils.SimulationCfg(dt=0.01, device=device, use_fabric=True)) - view = FrameView("/World/SiteGuide", device=device) - try: - assert isinstance(prim.GetAttribute("xformOp:scale").Get(), Gf.Vec3f) - - world_positions, _ = view.get_world_poses() - expected_position = torch.tensor([[0.1, -0.2, 0.3]], dtype=torch.float32, device=device) - torch.testing.assert_close(world_positions.torch, expected_position, atol=1e-6, rtol=0) - - expected_scale = torch.tensor([[0.01, 0.02, 0.03]], dtype=torch.float32, device=device) - scales = view.get_local_scales() - assert scales.warp.dtype == wp.float32 - assert scales.torch.dtype == torch.float32 - torch.testing.assert_close(scales.torch, expected_scale, atol=1e-6, rtol=0) - finally: - view.close() - - @wp.kernel def _fill_position(out: wp.array(dtype=wp.float32, ndim=2), x: float, y: float, z: float): i = wp.tid() @@ -264,46 +234,6 @@ def test_fabric_set_world_does_not_write_back_to_usd(device, view_factory): ) -@pytest.mark.parametrize("device", test_devices()) -def test_fabric_rebuild_after_topology_change(device, view_factory): - """Refreshing every selection mid-use leaves writes and reads correct. - - ``PrepareForReuse`` only reports a topology change when Fabric reallocates - internally, which this test does not provoke, so this is a smoke test of the - refresh paths rather than true topology-recovery coverage. - """ - bundle = view_factory(2, device) - view = bundle.view - - # First write -- initializes Fabric. - initial = wp.zeros((2, 3), dtype=wp.float32, device=device) - wp.launch(kernel=_fill_position, dim=2, inputs=[initial, 1.0, 2.0, 3.0], device=device) - with view.xform_world_space_writer() as w: - w.set_poses(positions=initial) - - # Simulate topology change: refresh both child selections and the parent - # selection, mirroring the accessor paths. - view._fabric_sel.refresh_child_selection() # RO (steady state) - view._fabric_sel.read_write = True - try: - view._fabric_sel.refresh_child_selection() # RW (writer scope) - finally: - view._fabric_sel.read_write = False - view._fabric_sel.refresh_parent_selection() - - # Trigger another write through the rebuilt arrays. - new = wp.zeros((2, 3), dtype=wp.float32, device=device) - wp.launch(kernel=_fill_position, dim=2, inputs=[new, 4.0, 5.0, 6.0], device=device) - with view.xform_world_space_writer() as w: - w.set_poses(positions=new) - - ret_pos, _ = view.get_world_poses() - pos_torch = torch.as_tensor(ret_pos, device=device) - expected = torch.tensor([[4.0, 5.0, 6.0], [4.0, 5.0, 6.0]], device=device) - # 1e-5 ≈ 20 ULP at magnitudes ~4-6; absorbs float32 SRT compose/decompose drift. - assert torch.allclose(pos_torch, expected, atol=1e-5), f"Read after rebuild failed on {device}: {pos_torch}" - - @pytest.mark.parametrize("device", ["cpu", "cuda:0"]) def test_writer_scope_exception_recovers_state(device, view_factory): """An exception raised inside a writer scope must still: @@ -314,7 +244,9 @@ def test_writer_scope_exception_recovers_state(device, view_factory): on whatever partial-write state Fabric currently holds (best-effort). Simulates an interactive-notebook scenario where a user-code exception - fires after a ``set_poses`` call but before the scope closes. + fires after a ``set_poses`` call but before the scope closes. A regular + scope exit also restores the prior tracking state rather than re-enabling + a paused listener. """ bundle = view_factory(2, device) view = bundle.view @@ -362,29 +294,19 @@ def test_writer_scope_exception_recovers_state(device, view_factory): follow_up_t = torch.as_tensor(follow_up, device=device) assert torch.allclose(follow_up_t, torch.tensor([[10.0, 11.0, 12.0]] * 2, device=device), atol=1e-5) + if h is not None: + # A pre-paused local listener stays paused after exit. + h.track_local_xform_changes(False) + assert not h.tracking_local_xform_changes + with view.xform_world_space_writer(): + pass + assert not h.tracking_local_xform_changes, "writer must not re-enable a pre-paused local listener" -@pytest.mark.parametrize("device", test_devices()) -def test_selections_match_only_the_view_prims(device, view_factory): - """Selections contain only the managed child prims and their unique parents. - - Without the per-view index attribute in the selection predicate the child - selections also pick up the parents (and, on a real stage, every other - xformable), so this fails with "matched 8 prims, expected 4". - """ - num_envs = 4 - bundle = view_factory(num_envs, device) - view = bundle.view - view.get_world_poses() # trigger Fabric init - - for name in ("sel_ro", "sel_rw"): - count = getattr(view._fabric_sel, name).GetCount() - assert count == view.count, ( - f"{name} matched {count} prims but the view manages {view.count}. " - "The selection is not scoped by the per-view index attribute, so it is " - "picking up unrelated prims from the stage." - ) - parent_count = view._fabric_sel.sel_parent.GetCount() - assert parent_count == num_envs, f"parent selection matched {parent_count} prims, expected {num_envs}" + # A pre-enabled local listener stays enabled after exit. + h.track_local_xform_changes(True) + with view.xform_world_space_writer(): + pass + assert h.tracking_local_xform_changes, "writer must restore the pre-enabled local listener" def _count_prims_with_tag(view, attr: str) -> int: @@ -447,219 +369,6 @@ def test_garbage_collection_removes_index_attributes_and_warns(device, caplog): assert any("without close()" in r.message for r in caplog.records), "expected a close() warning" -def _read_fabric_world_matrix_translation(view, prim_index=0): - """Read cached Fabric worldMatrix directly, without FrameView getter sync.""" - rt_prim = view._fabric_sel.stage.GetPrimAtPath(view.prim_paths[prim_index]) - world_attr = rt_prim.GetAttribute(view._WORLD_MATRIX_NAME) - matrix = world_attr.Get() - translation = matrix.ExtractTranslation() - return torch.tensor( - [[float(translation[0]), float(translation[1]), float(translation[2])]], - dtype=torch.float32, - device=view._device, - ) - - -def _read_fabric_world_matrix_scale(view, prim_index=0): - """Read cached Fabric worldMatrix scale directly, without FrameView getter sync.""" - import usdrt # noqa: PLC0415 - - rt_prim = view._fabric_sel.stage.GetPrimAtPath(view.prim_paths[prim_index]) - world_attr = rt_prim.GetAttribute(view._WORLD_MATRIX_NAME) - matrix = world_attr.Get() - scale = usdrt.Gf.Transform(matrix).GetScale() - return torch.tensor( - [[float(scale[0]), float(scale[1]), float(scale[2])]], - dtype=torch.float32, - device=view._device, - ) - - -def _read_fabric_local_matrix_translation(view, prim_index=0): - """Read cached Fabric localMatrix directly, without FrameView getter sync.""" - rt_prim = view._fabric_sel.stage.GetPrimAtPath(view.prim_paths[prim_index]) - local_attr = rt_prim.GetAttribute(view._LOCAL_MATRIX_NAME) - matrix = local_attr.Get() - translation = matrix.ExtractTranslation() - return torch.tensor( - [[float(translation[0]), float(translation[1]), float(translation[2])]], - dtype=torch.float32, - device=view._device, - ) - - -@pytest.mark.parametrize("device", ["cuda:0"]) -def test_set_local_via_fabric_path(device, view_factory): - """Exercise the Fabric-native set_local_poses path. - - Ensures set_local_poses computes child_world = parent_world * local - entirely within Fabric (not falling back to USD) by first triggering - the Fabric sync via get_world_poses. - """ - bundle = view_factory(num_envs=1, device=device) - view = bundle.view - - # Trigger lazy `_initialize_fabric()` so subsequent calls take the Fabric path. - view.get_world_poses() - - # Now write via the writer scope (Fabric path). - new_local_pos = wp.zeros((1, 3), dtype=wp.float32, device=device) - wp.launch(kernel=_fill_position, dim=1, inputs=[new_local_pos, 1.0, 2.0, 3.0], device=device) - ori = torch.tensor([[0.0, 0.0, 0.0, 1.0]], dtype=torch.float32, device=device) - new_local_ori = wp.from_torch(ori) - - with view.xform_local_space_writer() as w: - w.set_poses(positions=new_local_pos, orientations=new_local_ori) - - # Verify: world = parent(0,0,1) + local(1,2,3) = (1,2,4) - world_pos, _ = view.get_world_poses() - expected = torch.tensor([[1.0, 2.0, 4.0]], dtype=torch.float32, device=device) - torch.testing.assert_close(torch.as_tensor(world_pos, device=device), expected, atol=1e-4, rtol=0) - - # Verify get_local_poses returns the local offset - local_pos, _ = view.get_local_poses() - expected_local = torch.tensor([[1.0, 2.0, 3.0]], dtype=torch.float32, device=device) - torch.testing.assert_close(torch.as_tensor(local_pos, device=device), expected_local, atol=1e-4, rtol=0) - - -@pytest.mark.parametrize("device", ["cuda:0"]) -def test_get_scales_fabric_path(device, view_factory): - """Exercise the Fabric-native get_world_scales path.""" - bundle = view_factory(num_envs=1, device=device) - view = bundle.view - - # Trigger lazy `_initialize_fabric()` so the get_world_scales call below uses Fabric. - view.get_world_poses() - - scales = view.get_world_scales() - scales_t = scales.torch - # Default scale should be (1, 1, 1) - expected = torch.tensor([[1.0, 1.0, 1.0]], dtype=torch.float32, device=device) - torch.testing.assert_close(scales_t, expected, atol=1e-4, rtol=0) - - -@pytest.mark.parametrize("device", ["cuda:0"]) -def test_local_scales_roundtrip(device, view_factory): - """Writing scales through the local-space writer roundtrips via ``localMatrix``.""" - bundle = view_factory(num_envs=2, device=device) - view = bundle.view - - # Force Fabric init - view.get_world_poses() - - new_scales = wp.zeros((2, 3), dtype=wp.float32, device=device) - wp.launch(kernel=_fill_position, dim=2, inputs=[new_scales, 2.0, 3.0, 4.0], device=device) - with view.xform_local_space_writer() as w: - w.set_scales(new_scales) - - ret_scales = view.get_local_scales() - scales_torch = ret_scales.torch - expected = torch.tensor([[2.0, 3.0, 4.0], [2.0, 3.0, 4.0]], device=device) - torch.testing.assert_close(scales_torch, expected, atol=1e-5, rtol=0) - - -@pytest.mark.parametrize("device", ["cuda:0"]) -def test_world_scales_roundtrip(device, view_factory): - """Writing scales through the world-space writer roundtrips via ``worldMatrix``.""" - bundle = view_factory(num_envs=2, device=device) - view = bundle.view - - # Force Fabric init - view.get_world_poses() - - new_scales = wp.zeros((2, 3), dtype=wp.float32, device=device) - wp.launch(kernel=_fill_position, dim=2, inputs=[new_scales, 5.0, 6.0, 7.0], device=device) - with view.xform_world_space_writer() as w: - w.set_scales(new_scales) - - ret_scales = view.get_world_scales() - scales_torch = ret_scales.torch - expected = torch.tensor([[5.0, 6.0, 7.0], [5.0, 6.0, 7.0]], device=device) - torch.testing.assert_close(scales_torch, expected, atol=1e-5, rtol=0) - - -# ------------------------------------------------------------------ -# Transpose-convention verification: world ↔ local kernels rely on the -# identity ``(A·B)ᵀ = Bᵀ·Aᵀ`` to drop explicit transposes when operating -# on Fabric's column-transposed matrix storage. The translation-only -# parents used by the standard fixture cannot distinguish the right -# convention from the wrong one -- the rotation block is identity and -# equals its own transpose. These tests use a parent rotated 90° around -# Z so that an incorrect storage convention would produce a clearly -# wrong child pose. -# ------------------------------------------------------------------ - - -# Parent at (0, 0, 1) rotated +90° around Z (so the parent X axis points -# along world +Y). Quaternion components in (x, y, z, w) order. -_ROTATED_PARENT_POS = (0.0, 0.0, 1.0) -_ROTATED_PARENT_QUAT_XYZW = (0.0, 0.0, 0.70710678, 0.70710678) - - -def _build_rotated_parent_view(device: str) -> "FrameView": - """Build a 1-env FabricFrameView whose parent is rotated 90° around Z.""" - stage = sim_utils.get_current_stage() - sim_utils.create_prim( - "/World/Parent_0", - "Xform", - translation=_ROTATED_PARENT_POS, - orientation=_ROTATED_PARENT_QUAT_XYZW, - stage=stage, - ) - sim_utils.create_prim("/World/Parent_0/Child", "Camera", translation=(0.0, 0.0, 0.0), stage=stage) - sim_utils.SimulationContext(sim_utils.SimulationCfg(dt=0.01, device=device, use_fabric=True)) - view = FrameView("/World/Parent_[^/]*/Child", device=device) - view.get_world_poses() # force Fabric init and USD→Fabric seed - return view - - -@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) -def test_set_local_then_get_world_with_rotated_parent(device): - """Verify ``update_indexed_world_matrix_from_local`` under non-identity parent rotation. - - With parent rotated +90° around Z, a child local translation of (1, 0, 0) - must produce world translation (0, 1, 1) -- parent_pos + R · local. If the - transpose convention in the kernel were wrong, the rotation would flip - direction and the world position would land at (0, -1, 1) instead. - """ - _skip_if_unavailable(device) - view = _build_rotated_parent_view(device) - - new_local = wp.zeros((1, 3), dtype=wp.float32, device=device) - wp.launch(kernel=_fill_position, dim=1, inputs=[new_local, 1.0, 0.0, 0.0], device=device) - identity_quat = wp.from_torch(torch.tensor([[0.0, 0.0, 0.0, 1.0]], dtype=torch.float32, device=device)) - with view.xform_local_space_writer() as w: - w.set_poses(positions=new_local, orientations=identity_quat) - - world_pos, _ = view.get_world_poses() - expected = torch.tensor([[0.0, 1.0, 1.0]], dtype=torch.float32, device=device) - torch.testing.assert_close(torch.as_tensor(world_pos, device=device), expected, atol=1e-5, rtol=0) - view.close() - - -@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) -def test_set_world_then_get_local_with_rotated_parent(device): - """Verify ``update_indexed_local_matrix_from_world`` under non-identity parent rotation. - - With parent rotated +90° around Z and at (0, 0, 1), writing child world - translation (5, 0, 2) must yield child local translation Rᵀ · (5, 0, 1) = - (0, -5, 1). A wrong transpose convention would invert the rotation in the - wrong direction and produce (0, 5, 1) instead. - """ - _skip_if_unavailable(device) - view = _build_rotated_parent_view(device) - - new_world = wp.zeros((1, 3), dtype=wp.float32, device=device) - wp.launch(kernel=_fill_position, dim=1, inputs=[new_world, 5.0, 0.0, 2.0], device=device) - with view.xform_world_space_writer() as w: - w.set_poses(positions=new_world) - - local_pos, _ = view.get_local_poses() - expected = torch.tensor([[0.0, -5.0, 1.0]], dtype=torch.float32, device=device) - torch.testing.assert_close(torch.as_tensor(local_pos, device=device), expected, atol=1e-5, rtol=0) - view.close() - - @pytest.mark.parametrize("device", ["cpu", "cuda:0"]) def test_initial_seed_with_scaled_parent(device): """Verify the initial USD→Fabric seed handles non-unit scales correctly. @@ -866,6 +575,24 @@ def test_fabric_cuda1_scales_roundtrip(device, view_factory): assert torch.allclose(scales_torch, expected, atol=1e-7), f"Scales roundtrip failed on {device}: {scales_torch}" +# ------------------------------------------------------------------ +# Transpose-convention verification: world ↔ local kernels rely on the +# identity ``(A·B)ᵀ = Bᵀ·Aᵀ`` to drop explicit transposes when operating +# on Fabric's column-transposed matrix storage. The translation-only +# parents used by the standard fixture cannot distinguish the right +# convention from the wrong one -- the rotation block is identity and +# equals its own transpose. The sequential-scope tests below use parents +# rotated 90° around Z so that an incorrect storage convention would +# produce a clearly wrong child pose. +# ------------------------------------------------------------------ + + +# Parent at (0, 0, 1) rotated +90° around Z (so the parent X axis points +# along world +Y). Quaternion components in (x, y, z, w) order. +_ROTATED_PARENT_POS = (0.0, 0.0, 1.0) +_ROTATED_PARENT_QUAT_XYZW = (0.0, 0.0, 0.70710678, 0.70710678) + + # ------------------------------------------------------------------ # Sequential writer scopes (interleaved world / local writes via two scopes) # ------------------------------------------------------------------ @@ -894,8 +621,13 @@ def _build_two_child_view(device: str) -> "FrameView": @pytest.mark.parametrize("device", ["cpu", "cuda:0"]) -def test_sequential_world_then_local_scopes_partial_indices(device): - """A world writer scope (idx 0), then a local writer scope (idx 1). Both correct.""" +def test_sequential_scopes_partial_indices(device): + """Interleaved indexed writer scopes on rotated parents. All results correct. + + First a world scope (idx 0) then a local scope (idx 1); then, on the same view, + a local scope (idx 0) then a world scope (idx 1). Each write fully determines + the asserted poses, so the first half does not affect the second. + """ view = _build_two_child_view(device) new_world_pos = wp.zeros((1, 3), dtype=wp.float32, device=device) @@ -944,24 +676,15 @@ def test_sequential_world_then_local_scopes_partial_indices(device): atol=1e-5, rtol=0, ) - view.close() - - -@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) -def test_sequential_local_then_world_scopes_partial_indices(device): - """A local writer scope (idx 0), then a world writer scope (idx 1). Both correct.""" - view = _build_two_child_view(device) + # Reverse order on the same view: a local writer scope (idx 0), then a world writer scope (idx 1). new_local_pos = wp.zeros((1, 3), dtype=wp.float32, device=device) wp.launch(kernel=_fill_position, dim=1, inputs=[new_local_pos, 2.0, 3.0, 0.0], device=device) - identity_quat = wp.from_torch(torch.tensor([[0.0, 0.0, 0.0, 1.0]], dtype=torch.float32, device=device)) - idx0 = wp.from_torch(torch.tensor([0], dtype=torch.int32, device=device)) with view.xform_local_space_writer() as w: w.set_poses(positions=new_local_pos, orientations=identity_quat, indices=idx0) new_world_pos = wp.zeros((1, 3), dtype=wp.float32, device=device) wp.launch(kernel=_fill_position, dim=1, inputs=[new_world_pos, 10.0, 20.0, 30.0], device=device) - idx1 = wp.from_torch(torch.tensor([1], dtype=torch.int32, device=device)) with view.xform_world_space_writer() as w: w.set_poses(positions=new_world_pos, indices=idx1) @@ -1001,66 +724,9 @@ def test_sequential_local_then_world_scopes_partial_indices(device): # ------------------------------------------------------------------ -@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) -def test_world_writer_writes_world_and_derives_local(device, view_factory): - """A world writer's set_poses + set_scales updates cached Fabric world AND derives local on exit.""" - bundle = view_factory(num_envs=1, device=device) - view = bundle.view - view.get_world_poses() # trigger Fabric init - - new_pos = wp.zeros((1, 3), dtype=wp.float32, device=device) - wp.launch(kernel=_fill_position, dim=1, inputs=[new_pos, 1.0, 2.0, 4.0], device=device) - new_scales = wp.zeros((1, 3), dtype=wp.float32, device=device) - wp.launch(kernel=_fill_position, dim=1, inputs=[new_scales, 2.0, 3.0, 4.0], device=device) - - with view.xform_world_space_writer() as w: - w.set_poses(positions=new_pos) - w.set_scales(new_scales) - - # Cached world reflects the writes (parent is at (0, 0, 1) so child world pos - # is whatever we wrote). - expected_world = torch.tensor([[1.0, 2.0, 4.0]], dtype=torch.float32, device=device) - cached_world = _read_fabric_world_matrix_translation(view) - torch.testing.assert_close(cached_world, expected_world, atol=1e-5, rtol=0) - - expected_scale = torch.tensor([[2.0, 3.0, 4.0]], dtype=torch.float32, device=device) - cached_scale = _read_fabric_world_matrix_scale(view) - torch.testing.assert_close(cached_scale, expected_scale, atol=1e-5, rtol=0) - - # Local derived: with parent at (0, 0, 1) (identity rotation, unit scale), - # local translation = world - parent = (1, 2, 3). - expected_local = torch.tensor([[1.0, 2.0, 3.0]], dtype=torch.float32, device=device) - cached_local = _read_fabric_local_matrix_translation(view) - torch.testing.assert_close(cached_local, expected_local, atol=1e-5, rtol=0) - - -@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) -def test_local_writer_writes_local_and_derives_world(device, view_factory): - """A local writer's set_poses updates cached Fabric local AND derives world on exit.""" - bundle = view_factory(num_envs=1, device=device) - view = bundle.view - view.get_world_poses() - - new_pos = wp.zeros((1, 3), dtype=wp.float32, device=device) - wp.launch(kernel=_fill_position, dim=1, inputs=[new_pos, 1.0, 2.0, 3.0], device=device) - identity_quat = wp.from_torch(torch.tensor([[0.0, 0.0, 0.0, 1.0]], dtype=torch.float32, device=device)) - - with view.xform_local_space_writer() as w: - w.set_poses(positions=new_pos, orientations=identity_quat) - - expected_local = torch.tensor([[1.0, 2.0, 3.0]], dtype=torch.float32, device=device) - cached_local = _read_fabric_local_matrix_translation(view) - torch.testing.assert_close(cached_local, expected_local, atol=1e-5, rtol=0) - - # World derived: with parent at (0, 0, 1), world = parent + local = (1, 2, 4). - expected_world = torch.tensor([[1.0, 2.0, 4.0]], dtype=torch.float32, device=device) - cached_world = _read_fabric_world_matrix_translation(view) - torch.testing.assert_close(cached_world, expected_world, atol=1e-5, rtol=0) - - @pytest.mark.parametrize("device", ["cuda:0"]) def test_writer_single_derivation_per_scope(device, view_factory, monkeypatch): - """Multiple set_* calls inside one scope produce exactly one derive-kernel launch.""" + """An empty scope launches no derive kernel; multiple set_* calls inside one scope launch exactly one.""" bundle = view_factory(num_envs=1, device=device) view = bundle.view view.get_world_poses() @@ -1075,6 +741,11 @@ def counted(): monkeypatch.setattr(view, "_recompute_local_from_world_all", counted) + with view.xform_world_space_writer(): + pass + + assert calls == 0 + new_pos = wp.zeros((1, 3), dtype=wp.float32, device=device) wp.launch(kernel=_fill_position, dim=1, inputs=[new_pos, 1.0, 2.0, 4.0], device=device) new_scales = wp.zeros((1, 3), dtype=wp.float32, device=device) @@ -1088,7 +759,7 @@ def counted(): assert calls == 1 # exactly one derive on exit -@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +@pytest.mark.parametrize("device", test_devices(DeviceScope.CUDA)) def test_writer_single_active_invariant(device, view_factory): """Only one writer scope may be active per view at a time.""" bundle = view_factory(num_envs=1, device=device) @@ -1105,54 +776,7 @@ def test_writer_single_active_invariant(device, view_factory): pass -@pytest.mark.parametrize("device", ["cuda:0"]) -def test_writer_restores_hierarchy_change_tracking(device, view_factory): - """``__exit__`` restores the prior ``track_*_xform_changes`` state (don't re-enable paused listeners).""" - bundle = view_factory(num_envs=1, device=device) - view = bundle.view - view.get_world_poses() - h = view._fabric_sel.fabric_hierarchy - if h is None: - pytest.skip("Fabric hierarchy bindings are unavailable in this headless experience") - - # Case 1: pre-paused local stays paused after exit. - h.track_local_xform_changes(False) - assert not h.tracking_local_xform_changes - with view.xform_world_space_writer(): - pass - assert not h.tracking_local_xform_changes, "writer must not re-enable a pre-paused local listener" - - # Case 2: pre-enabled local stays enabled after exit. - h.track_local_xform_changes(True) - with view.xform_world_space_writer(): - pass - assert h.tracking_local_xform_changes, "writer must restore the pre-enabled local listener" - - -@pytest.mark.parametrize("device", ["cuda:0"]) -def test_writer_empty_scope_does_no_derivation(device, view_factory, monkeypatch): - """Entering and exiting a writer scope without any ``set_*`` call must not launch the derive kernel.""" - bundle = view_factory(num_envs=1, device=device) - view = bundle.view - view.get_world_poses() - - calls = 0 - original = view._recompute_local_from_world_all - - def counted(): - nonlocal calls - calls += 1 - original() - - monkeypatch.setattr(view, "_recompute_local_from_world_all", counted) - - with view.xform_world_space_writer(): - pass - - assert calls == 0 - - -@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +@pytest.mark.parametrize("device", test_devices(DeviceScope.CUDA)) def test_view_getter_inside_scope_raises(device, view_factory): """View-level getters raise ``RuntimeError`` while a writer scope is active.""" bundle = view_factory(num_envs=1, device=device) From 8b94e78f9d4f31f1ccbeaaf3d00938a83f262c6b Mon Sep 17 00:00:00 2001 From: ooctipus Date: Fri, 25 Sep 2026 00:01:44 -0700 Subject: [PATCH 04/10] [PR5B] Route deformable, particle, and cable geometry through SDP (#7995) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary PR5B follows merged #8005 and is rebased directly onto develop `0173b0436`. This PR now contains only the geometry changes. Complete SDP geometry transport for deformables, MPM particles and cables: - `ClonePlan` stays generic. Backend imports read declared prototypes and retain native geometry mappings; no geometry fields or point-cloud assets are added to the plan. - Physics publishes native pointers and exact visual-path ranges. SDP either borrows native point views or converts/interpolates directly into the requested destination, once per publication timestamp. - OVRTX no longer calls Newton for geometry. Shared Fabric rendering and foreign Newton rendering also consume SDP publications. - Fabric meshes stay on GPU. Kit's Points/Curves sink requires CPU Fabric writes; SDP owns the transfer. Removed per-frame USD `attr.Set()`, renderer-side host packing, duplicate remapping and obsolete internal point APIs. - PR5A removes implicit Newton construction, not native-only startup without a plan. PhysX/OVPhysX can initialize without geometry declarations; SDP geometry requests require an initialized declaration and distinguish it from an explicitly empty scene. Relative to develop: **production −1,708 LOC; tests −991 LOC; overall −2,602 LOC.** ## Performance Pre-rebase matched warm sample per workload after cache priming, physical GPU 1 (RTX 5090), 128 environments, Newton MJWarp+VBD proxy physics, Newton renderer, no visualizer, seed 42. Baseline: PR5A `efc70de0b` (develop `1e520e9d5` plus lifecycle enforcement); candidate: `e4b01c453`. Both used the same harness and dependencies, with checkout imports verified. Startup includes imports through environment construction, excluding first step. Runtime excludes 20 warmup steps and measures 100 steps with GPU synchronization at window boundaries. Timings have not been rerun for this rebase. | Camera workload | Startup PR5B / PR5A | Step PR5B / PR5A | Aggregate FPS PR5B / PR5A | |---|---:|---:|---:| | Franka cloth lift | 8.82 / 8.85 s | 29.21 / 29.27 ms | 4,382 / 4,373 | | Franka cable lift | 7.44 / 7.46 s | 19.73 / 19.95 ms | 6,488 / 6,417 | These samples are performance-neutral; no broad speedup is claimed. Image means and standard deviations matched for both workloads. A focused 1,024-world × 1,024-vertex GPU measurement compares the former interpolation-then-scatter path with direct SDP-to-Fabric writes: **0.0294–0.0299 ms → 0.0217–0.0223 ms** across two paired orders, with identical results. This is a geometry-kernel saving, not an end-to-end FPS claim. ## Validation - Rebased onto develop `0173b0436`, preserving upstream PhysX/OVPhysX test pruning. The conflict resolutions changed no production logic. Geometry cache naming now uses `geometry_timestamp`, with its relation to articulation and rigid-object buffer timestamps documented; cache behavior is unchanged. - Current focused validation: 32 SDP/Newton geometry checks passed (1 skipped), 42 OVPhysX checks passed, and 79 renderer checks passed. These groups ran in separate processes to respect schema-registration ordering. Formatting and changelog checks passed. No PR5A example or `AGENTS.md` changes remain; documentation builds stay in CI. - On the preceding stack revision: 116 focused CPU checks, 12 native Kit/PhysX lifecycle and publication checks, and all 10 GPU geometry checks passed. Formatting and changelog checks passed. Preserved upstream test consolidation and the global native-deformable import regression. - Fixed the native-Fabric request shared by 77 failing CI cases: the preceding transform request already updates the whole native stage, so rendering no longer requests geometry a second time or requires a dummy plan. The regression failed before the fix and passed afterward. 57 CPU contract checks and 36 Kit checks passed, covering cameras, environment render scheduling, XR feeds, all visuotactile tests, and foreign Newton geometry/cables. Included PR5A's stale documentation-snippet fix; full documentation validation remains in CI. - Earlier validation on the pre-rebase revision: - 176 focused CPU tests and four subtests passed; 10 CUDA/native cases were skipped in that CPU run. - All 10 geometry tests passed with GPU 1 enabled, including CPU↔GPU and native Fabric destinations, pointer ownership and version caching. - Native Fabric storage-reallocation regression failed before the fix and passed afterward. Pixel probes verified moving meshes, points and curves without modifying authored USD point values. - Formatting and changelog checks passed. Documentation builds are left to CI. ## Checklist - [x] Followed contribution guidelines and ran formatting checks - [x] Updated documentation and package changelog fragments - [x] Consolidated existing tests and removed obsolete coverage - [x] Backport to the active release branch --- .../developer-tools/scene_data_providers.rst | 87 +- docs/source/how-to/cloning.rst | 14 +- source/isaaclab/changelog.d/plan-geometry.rst | 11 + .../isaaclab/isaaclab/envs/direct_marl_env.py | 2 - .../isaaclab/isaaclab/envs/direct_rl_env.py | 2 - .../isaaclab/envs/manager_based_env.py | 2 - .../isaaclab/envs/manager_based_rl_env.py | 2 - .../isaaclab/renderers/render_context.py | 14 +- .../scene_data/deformable_discovery.py | 424 ++++----- .../isaaclab/scene_data/geometry_points.py | 193 +---- .../isaaclab/scene_data/scene_data_backend.py | 76 +- .../scene_data/scene_data_provider.py | 225 +++-- .../test/cloner/test_replicate_session.py | 10 +- ..._object_rendering_contract_architecture.py | 16 + .../test_simulation_render_context.py | 6 +- .../scene_data/test_deformable_discovery.py | 154 ++-- .../test_deformable_discovery_helpers.py | 128 --- .../scene_data/test_scene_data_geometry.py | 283 +++--- ...test_newton_manager_visualization_state.py | 440 +--------- .../plan-owned-deformable-bindings.rst | 6 + .../deformable/deformable_object.py | 92 +- .../test_deformable_builder_hooks.py | 173 ++-- .../changelog.d/plan-geometry.rst | 7 + .../assets/mpm_object/mpm_object.py | 68 +- .../cloner/newton_clone_utils.py | 1 + .../isaaclab_newton/cloner/replicate.py | 54 +- .../isaaclab_newton/physics/newton_manager.py | 808 +++--------------- .../isaaclab_newton/physics/vbd_manager.py | 9 +- .../physics/visualization_deformables.py | 374 +------- .../isaaclab_newton/sim/spawners/mpm/mpm.py | 23 +- .../sim/spawners/mpm/visualization.py | 3 +- .../test/assets/test_mpm_object.py | 87 +- .../test/cloner/test_rename_builder_labels.py | 354 +++----- .../physics/test_newton_fabric_body_sync.py | 172 ++-- .../test_newton_manager_abstraction.py | 11 +- .../test/physics/test_vbd_core.py | 2 - .../test/sim/test_mpm_spawners.py | 59 +- .../isaaclab_ov/changelog.d/plan-geometry.rst | 8 + .../deformable_object/deformable_object.py | 1 + .../isaaclab_ov/physics/ovphysx_manager.py | 239 ++---- .../isaaclab_ov/renderers/ovrtx_renderer.py | 704 ++------------- .../renderers/ovrtx_renderer_cfg.py | 3 - .../renderers/ovrtx_renderer_kernels.py | 85 -- .../assets/test_deformable_object_helpers.py | 6 + .../test_ovphysx_scene_data_backend.py | 149 ++-- .../isaaclab_ov/test/test_ovrtx_clone_plan.py | 171 +--- .../test/test_ovrtx_deformable_bindings.py | 495 ++--------- .../test/test_ovrtx_renderer_contract.py | 210 ++--- ...t_ovrtx_renderer_rigid_object_rendering.py | 6 +- .../changelog.d/plan-geometry.rst | 10 + .../deformable_object/deformable_object.py | 1 + .../isaaclab_physx/physics/physx_manager.py | 229 +++-- .../isaaclab_physx/renderers/fabric.py | 87 +- .../renderers/isaac_rtx_renderer.py | 6 +- .../test_isaac_rtx_renderer_contract.py | 14 + .../test/sim/test_physx_scene_data_backend.py | 154 ++-- .../changelog.d/sdp-geometry-reset.skip | 1 + .../render_benchmark/render_benchmark_env.py | 2 - .../multitask_manipulation/multitask_env.py | 1 - .../benchmark/test_render_benchmark_cfg.py | 1 - .../test/rendering_test_utils.py | 3 +- .../changelog.d/sdp-geometry-publication.rst | 5 + .../kit/kit_visualizer.py | 2 + .../test_kit_visualizer_scene_partitioning.py | 9 +- 64 files changed, 2196 insertions(+), 4798 deletions(-) create mode 100644 source/isaaclab/changelog.d/plan-geometry.rst delete mode 100644 source/isaaclab/test/scene_data/test_deformable_discovery_helpers.py create mode 100644 source/isaaclab_contrib/changelog.d/plan-owned-deformable-bindings.rst create mode 100644 source/isaaclab_newton/changelog.d/plan-geometry.rst create mode 100644 source/isaaclab_ov/changelog.d/plan-geometry.rst create mode 100644 source/isaaclab_physx/changelog.d/plan-geometry.rst create mode 100644 source/isaaclab_tasks/changelog.d/sdp-geometry-reset.skip create mode 100644 source/isaaclab_visualizers/changelog.d/sdp-geometry-publication.rst diff --git a/docs/source/developer-tools/scene_data_providers.rst b/docs/source/developer-tools/scene_data_providers.rst index ae20df95dc06..29b9a1e0e426 100644 --- a/docs/source/developer-tools/scene_data_providers.rst +++ b/docs/source/developer-tools/scene_data_providers.rst @@ -3,7 +3,7 @@ Scene Data Provider :class:`~isaaclab.scene_data.SceneDataProvider` bridges physics simulation backends and the visualizers/renderers that consume scene data. It exposes a single Warp-native read path for -body transforms regardless of which physics backend (PhysX or Newton) is active, so renderers +body transforms and visual geometry regardless of which physics backend is active, so renderers and visualizers can stay backend-agnostic. Overview @@ -44,11 +44,14 @@ The system has three layers: - :attr:`SceneDataBackend.transform_paths`: list of USD prim paths, one per transform. - :attr:`SceneDataBackend.native_transform_formats`: formats published without conversion. PhysX publishes either packed poses or Fabric matrices and refreshes only the requested representation. - - :attr:`SceneDataBackend.points`: flattened deformable nodal positions as - :class:`SceneDataFormat.Points` (optional; rigid-only backends return an empty buffer). - - :attr:`SceneDataBackend.point_count`: total number of geometry points. - - :attr:`SceneDataBackend.geometry_paths`: one USD prim path per deformable body instance. - - :attr:`SceneDataBackend.geometry_counts`: unpadded nodal count per geometry entity. + - :meth:`SceneDataBackend.get_geometry_batches`: native point arrays or interpolation inputs, + paired with exact visual prim paths and ranges compiled during backend construction. It returns + the requested native representation when available, otherwise the primary representations for + SDP to convert. The return type is always a list of batches, including native Fabric. + - :attr:`SceneDataBackend.geometry_timestamp`: logical update timestamp, advanced for same-step + writes and native pointer swaps. Cached outputs record the timestamp they contain, like asset + data buffers. This is not elapsed simulation time or a shared dirty flag that a reader clears. + - :attr:`SceneDataBackend.native_geometry_formats`: geometry formats available without conversion. 2. :class:`~isaaclab.scene_data.SceneDataProvider`: wraps a backend and offers format conversion plus index re-mapping. @@ -60,11 +63,12 @@ The system has three layers: - :meth:`SceneDataProvider.create_mapping`: builds a remap array from the backend's prim paths to a consumer's desired ordering. Used when a renderer or visualizer wants transforms indexed by its own body list rather than by the physics view order. - - :meth:`SceneDataProvider.get_points`: copies backend deformable nodal positions into a - consumer buffer, optionally remapping entity slices via - :meth:`SceneDataProvider.create_geometry_mapping`. - - :meth:`SceneDataProvider.create_geometry_mapping`: maps backend deformable entities to - consumer particle offsets in a shadow Newton ``particle_q`` buffer. + - :meth:`SceneDataProvider.get_geometry_points`: read-only world-space point views keyed by + exact visual prim path. Native point ranges alias the producer; interpolation and destination + reordering are fused into one cached conversion. Consumers with fixed native storage pass + that array or ``FabricPoints`` as ``output`` and visual-path offsets as ``offsets``. These + calls return the supplied destination. Destination caches retain indexing metadata, not the + consumer's buffers, and expire with the destination. - :meth:`SceneDataProvider.get_camera_transforms`: discovers per-camera, per-env world transforms from the USD stage. - :attr:`SceneDataProvider.usd_stage`: USD stage handle for stage-walking consumers. @@ -96,17 +100,14 @@ the shared clone plan before initialization. Its rigid ``body_q`` binds to SDP's ``Transform`` array; no intermediate per-frame copy into a second state buffer is required. OVRTX requests ``TransposedMatrix44d`` directly from SDP, including destination ordering and static scale in the same conversion. It no longer reads Newton state for rigid transforms. -When the scene has PhysX or OVPhysX deformables, the shadow model also allocates -``particle_q`` render slots for soft/cloth meshes, syncs simulation nodal positions through -:meth:`SceneDataProvider.get_points` with ``allow_passthrough=False`` into a separate -sim-sized buffer, and remaps or copies those positions into the render-sized ``particle_q`` -buffer each frame. Volume deformables with mismatched sim and visual vertex counts use a -barycentric sim-to-visual remap so Newton Warp and OVRTX render the paired visual mesh rather -than tet simulation topology. The shadow deformable registry exposes render-slot offsets and -``particles_per_body`` counts for OVRTX point bindings. - -The deformable and cable geometry bridge remains separate from this rigid-transform path. -OVRTX still uses Newton geometry metadata for those features. +For deformables with different simulation and visual meshes, the producer compiles barycentric +indices and weights from the declared prototype once. SDP applies that interpolation directly into +the Newton representation's final ``particle_q`` slots. There is no intermediate simulation-sized +buffer and no consumer-owned remap. Native PhysX padded nodal arrays are borrowed without packing. + +OVRTX receives the same exact visual-path publications through SDP. It neither imports Newton +managers nor requests a Newton model. Meshes, particle clouds, and cable curves use one point-binding +path. PhysX owns its native Fabric refresh and publishes the resulting matrices through SDP without fetching packed poses. ``isaaclab_physx.renderers.fabric.FabricBackend`` owns the shared native stage @@ -124,7 +125,13 @@ bound once. Fabric's selection reuse API reports scene-wide structural changes; array views without repeating path matching or scale capture. Otherwise GPU propagation reuses the hierarchy topology. Clean requests never acquire writable Fabric arrays. Renderers do not select a physics-specific synchronization path. -``FabricMatrix44`` contains only matrix storage, not bindings or native engine handles. +The same Fabric resource receives geometry through ``update_geometries(provider, frame)``. +PhysX publishes its native ``FabricPoints`` without a conversion or rewrite. Foreign mesh points +are interpolated directly into GPU Fabric storage. The current Kit Hydra path requires CPU Fabric +destinations for ``Points`` and ``BasisCurves``; SDP handles their device transfer without USD +attribute writes. Only destinations whose update interval has elapsed are transferred. World-space +point destinations reset their transform stack to avoid applying the environment or body pose twice. +``FabricMatrix44`` and ``FabricPoints`` contain only array storage, not bindings or native engine handles. Newton backend -------------- @@ -142,6 +149,38 @@ Externally replayed CUDA graphs do not call Python write hooks. After writes hav Newton conservatively republishes transforms when read so an unannounced replay cannot leave rendering stale. Those reads do not benefit from clean-publication caching. +Geometry publication +-------------------- + +Newton deformable and MPM positions are direct views of native ``particle_q`` ranges. Cable +publications borrow native body poses and capsule parameters; SDP derives curve endpoints once +per update timestamp. Both camera renderers and viewers consume the same cached result. + +``ClonePlan`` remains a generic replication and routing description. Asset construction authors +prototype geometry; native import combines those prototypes with the plan and records native +ranges. Consumers bind to those completed resources, never rediscovering the completed stage. + +.. code-block:: python + + # Default: read-only native or converted views, cached by producer timestamp. + points_by_path = provider.get_geometry_points() + + # A consumer with fixed native storage receives the conversion directly. + provider.get_geometry_points(output=state.particle_q, offsets=visual_path_offsets) + + # A Fabric consumer supplies native storage and its exact visual-path row indices. + provider.get_geometry_points(output=fabric_points, offsets=visual_path_rows) + +The internal flat-node queries and physics-owned geometry sync methods were removed. Rendering +consumers use ``get_geometry_points``; physics managers no longer run geometry writers from ``pre_render``. + +As in articulation and rigid-object data, the current timestamp and a cached buffer's timestamp +serve different purposes: one identifies current state; the other identifies the state in that buffer. +Asset data advances ``_sim_timestamp`` with time and invalidates dependent buffers on same-step writes. +SDP instead advances ``geometry_timestamp`` on those writes, so independently updated consumers all see +the change. The output's cache owns its freshness check; a downstream upload or BVH may need its own +invalidation, but should not repeat the conversion's cache bookkeeping. + Data requirements ------------------ @@ -173,7 +212,7 @@ consumer construction time, before the shared clone plan is built: - Yes - No * - OVRTX renderer - - Yes + - No - Yes See Also diff --git a/docs/source/how-to/cloning.rst b/docs/source/how-to/cloning.rst index 330065897549..f4250713f1ec 100644 --- a/docs/source/how-to/cloning.rst +++ b/docs/source/how-to/cloning.rst @@ -156,11 +156,21 @@ fields listed below are that table's columns: - Optional per-env world positions [m], shape ``[num_envs, 3]``. * - ``global_paths`` - Unique prim paths for scene assets shared by every env and therefore not replicated. + * - ``cfg_rows`` + - Asset configuration identities mapped to the rows they own. * - ``context_rows`` - Clone-context types mapped to the rows they consume. -The plan does not own a stage. Simulation-owned contexts supply their own runtime -when they consume it. +The plan describes replication and routing, not asset geometry or native state. Asset +construction authors the prototypes. Each backend imports its declared roots and records +the geometry-to-native mappings needed by its consumers; consumers bind after native +initialization. Clone contexts apply the plan but do not own native runtime resources. + +Deformable imports read prototype meshes and expand their paths through the plan without +copying vertex arrays per environment. Newton cable imports retain ordered native segment +bindings. MPM spawners author render points under the asset before cloning, then bind the +importer's particle ranges. None requires geometry-specific fields on ``ClonePlan`` or +discovery of the completed replicated scene. When every env is a copy of env_0: diff --git a/source/isaaclab/changelog.d/plan-geometry.rst b/source/isaaclab/changelog.d/plan-geometry.rst new file mode 100644 index 000000000000..7cd809a018cb --- /dev/null +++ b/source/isaaclab/changelog.d/plan-geometry.rst @@ -0,0 +1,11 @@ +Fixed +^^^^^ + +* Resolved deformable geometry from declared prototypes during backend import, preserving + custom namespaces and partial environment coverage without storing geometry on ``ClonePlan``. +* Added versioned SDP geometry publications for deformables, particles, and cables, with native + point views or one fused interpolation and layout conversion. Replaced internal flat-node queries + with ``get_geometry_points`` using visual prim paths. +* Removed the renderer's geometry step gate so native position writes between renders remain visible. +* Released cached destination bindings with their consumer-owned buffers instead of retaining them + for the simulation's lifetime. diff --git a/source/isaaclab/isaaclab/envs/direct_marl_env.py b/source/isaaclab/isaaclab/envs/direct_marl_env.py index e8d861304962..40ff5a928c3e 100644 --- a/source/isaaclab/isaaclab/envs/direct_marl_env.py +++ b/source/isaaclab/isaaclab/envs/direct_marl_env.py @@ -710,8 +710,6 @@ def _reset_idx(self, env_ids: Sequence[int]): self.episode_length_buf[env_ids] = 0 - self.sim.render_context.reset_scene_state_cadence() - """ Implementation-specific functions. """ diff --git a/source/isaaclab/isaaclab/envs/direct_rl_env.py b/source/isaaclab/isaaclab/envs/direct_rl_env.py index 2c80377e956b..d64bc4e8e629 100644 --- a/source/isaaclab/isaaclab/envs/direct_rl_env.py +++ b/source/isaaclab/isaaclab/envs/direct_rl_env.py @@ -725,8 +725,6 @@ def _reset_idx(self, env_ids: Sequence[int]): self.episode_length_buf[env_ids] = 0 - self.sim.render_context.reset_scene_state_cadence() - """ Implementation-specific functions. """ diff --git a/source/isaaclab/isaaclab/envs/manager_based_env.py b/source/isaaclab/isaaclab/envs/manager_based_env.py index bb9d1d282eb1..236911a542dc 100644 --- a/source/isaaclab/isaaclab/envs/manager_based_env.py +++ b/source/isaaclab/isaaclab/envs/manager_based_env.py @@ -672,5 +672,3 @@ def _reset_idx(self, env_ids: Sequence[int]): # -- recorder manager info = self.recorder_manager.reset(env_ids) self.extras["log"].update(info) - - self.sim.render_context.reset_scene_state_cadence() diff --git a/source/isaaclab/isaaclab/envs/manager_based_rl_env.py b/source/isaaclab/isaaclab/envs/manager_based_rl_env.py index 794254a43e15..f594dc068398 100644 --- a/source/isaaclab/isaaclab/envs/manager_based_rl_env.py +++ b/source/isaaclab/isaaclab/envs/manager_based_rl_env.py @@ -437,5 +437,3 @@ def _reset_idx(self, env_ids: Sequence[int]): self.extras["log"].update(info) self.episode_length_buf[env_ids] = 0 - - self.sim.render_context.reset_scene_state_cadence() diff --git a/source/isaaclab/isaaclab/renderers/render_context.py b/source/isaaclab/isaaclab/renderers/render_context.py index 9d3e7ac55310..135c8e4ba8ee 100644 --- a/source/isaaclab/isaaclab/renderers/render_context.py +++ b/source/isaaclab/isaaclab/renderers/render_context.py @@ -71,7 +71,6 @@ class RenderContext: "_physics_initialized", "_prepared_renderer_ids", "_prepared_num_envs", - "_last_geometry_update_step", "_visual_materials", "_visual_material_batches", "_visual_material_batches_by_channel", @@ -89,7 +88,6 @@ def __init__(self, backend_registry: list[tuple[BackendCfg, Any]]) -> None: self._physics_initialized: bool = False # Set to True after the first PHYSICS_READY callback fires. self._prepared_renderer_ids: set[int] = set() self._prepared_num_envs: int | None = None - self._last_geometry_update_step: int | None = None # Physics step of the last renderer geometry update. self._visual_materials: list[Any] = [] self._visual_material_batches: tuple[VisualMaterialBatch, ...] = () self._visual_material_batches_by_channel: dict[str, VisualMaterialBatch] = {} @@ -127,7 +125,6 @@ def validate_renderer_cfg(self, cfg: RendererCfg) -> None: def register_renderer(self, cfg: RendererCfg, renderer: BaseRenderer) -> None: """Include a newly registry-owned renderer in cloning and post-physics initialization.""" self.clone_contexts.update(cfg.cloning_contexts) - self._last_geometry_update_step = None if self._physics_initialized: renderer.initialize() @@ -320,13 +317,11 @@ def ensure_prepare_stage(self, stage: Any, num_envs: int) -> None: def update_scene_state(self, physics_step_count: int) -> None: """Publish physics state and refresh renderers through SDP's producer versions. - Transforms follow SDP freshness; geometry updates retain their once-per-step cadence. + Producer versions also cover geometry writes between physics steps. """ for _cfg, renderer in self._renderer_entries: renderer.update_transforms() - if self._last_geometry_update_step != physics_step_count: - renderer.update_geometries() - self._last_geometry_update_step = physics_step_count + renderer.update_geometries() def render_into_camera( self, @@ -372,10 +367,6 @@ def reset_stage_prepare_flag(self) -> None: self._prepared_renderer_ids.clear() self._prepared_num_envs = None - def reset_scene_state_cadence(self) -> None: - """Invalidate geometry updates after resets that do not advance the physics step.""" - self._last_geometry_update_step = None - def close(self) -> None: """Release material writers and lifecycle bookkeeping, not registry-owned renderers. @@ -392,7 +383,6 @@ def close(self) -> None: self.clone_contexts.clear() self._prepared_renderer_ids.clear() self._prepared_num_envs = None - self._last_geometry_update_step = None self._physics_initialized = False self._visual_materials.clear() self._visual_material_batches = () diff --git a/source/isaaclab/isaaclab/scene_data/deformable_discovery.py b/source/isaaclab/isaaclab/scene_data/deformable_discovery.py index dff12b51b03d..2684f4b9da54 100644 --- a/source/isaaclab/isaaclab/scene_data/deformable_discovery.py +++ b/source/isaaclab/isaaclab/scene_data/deformable_discovery.py @@ -3,28 +3,36 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""USD discovery helpers for PhysX/OVPhysX deformable geometry.""" +"""Deformable prototype geometry and clone-plan expansion.""" from __future__ import annotations import logging -import re +from collections import defaultdict from collections.abc import Sequence -from dataclasses import dataclass, field -from itertools import chain +from dataclasses import dataclass, field, replace +from typing import TYPE_CHECKING import numpy as np +import warp as wp from pxr import Gf, Sdf, Usd, UsdGeom from .. import sim as sim_utils +from ..cloner.path import match, rebase +from ..sim.utils.queries import has_deformable_body_api +from .deformable_vis_remap import build_volume_vis_barycentric_remap +from .scene_data_backend import SceneDataFormat + +if TYPE_CHECKING: + from ..cloner.clone_plan import ClonePlan logger = logging.getLogger(__name__) @dataclass class DeformableStageEntry: - """One deformable asset instance discovered on the USD stage.""" + """Geometry and parent pose of one deformable prototype or instance.""" root_path: str sim_mesh_path: str @@ -37,7 +45,61 @@ class DeformableStageEntry: vis_vertices: np.ndarray = field(default_factory=lambda: np.empty((0, 3), dtype=np.float32)) vis_indices: np.ndarray = field(default_factory=lambda: np.empty(0, dtype=np.int32)) init_pos: tuple[float, float, float] = (0.0, 0.0, 0.0) + """Parent-frame world position [m].""" init_rot: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 1.0) + """Parent-frame world orientation as an xyzw quaternion.""" + + +def deformable_geometry_batches( + entries: Sequence[DeformableStageEntry], points: wp.array, offsets: Sequence[int] +) -> list[tuple[SceneDataFormat.Points | SceneDataFormat.WeightedPoints, dict[str, tuple[int, int]]]]: + """Bind visual mesh paths to native nodal ranges or static barycentric interpolation tables. + + Args: + entries: Declared deformable instances in native body order. + points: Flat native nodal positions [m], including any body padding. + offsets: Native point offset for each entry. + + Returns: + Native-format publications and exact visual mesh paths with their output ranges. + Interpolation tables are computed once per shared prototype, without packing native points. + """ + direct, weighted = {}, {} + indices, weights = [], [] + prototype_remaps = {} + output_offset = 0 + for entry, offset in zip(entries, offsets, strict=True): + key = (id(entry.vertices), id(entry.indices), id(entry.vis_vertices)) + if key not in prototype_remaps: + if entry.vertex_count == entry.vis_vertex_count and np.array_equal(entry.vertices, entry.vis_vertices): + prototype_remaps[key] = None + else: + if entry.deformable_type != "volume": + raise ValueError(f"Surface visual topology differs from its native nodes: {entry.root_path}") + remap = build_volume_vis_barycentric_remap(entry.vertices, entry.indices, entry.vis_vertices) + if remap is None: + raise ValueError(f"Cannot bind visual vertices to deformable tetrahedra: {entry.root_path}") + prototype_remaps[key] = (remap.tet_vertex_indices.numpy(), remap.bary_weights.numpy()) + if prototype_remaps[key] is None: + direct[entry.vis_mesh_path] = (int(offset), entry.vis_vertex_count) + continue + prototype_indices, prototype_weights = prototype_remaps[key] + indices.append(prototype_indices + int(offset)) + weights.append(prototype_weights) + weighted[entry.vis_mesh_path] = (output_offset, entry.vis_vertex_count) + output_offset += entry.vis_vertex_count + batches = [] + if direct: + publication = SceneDataFormat.Points() + publication.points = points + batches.append((publication, direct)) + if weighted: + publication = SceneDataFormat.WeightedPoints() + publication.points = points + publication.indices = wp.array(np.concatenate(indices), dtype=wp.int32, device=points.device) + publication.weights = wp.array(np.concatenate(weights), dtype=wp.float32, device=points.device) + batches.append((publication, weighted)) + return batches def _matrix4d_to_numpy(matrix: Gf.Matrix4d) -> np.ndarray: @@ -64,7 +126,7 @@ def _usd_points_to_numpy(points) -> np.ndarray: """Convert USD point arrays to ``(N, 3)`` float32.""" if not points: return np.empty((0, 3), dtype=np.float32) - return np.array([[float(point[0]), float(point[1]), float(point[2])] for point in points], dtype=np.float32) + return np.asarray(points, dtype=np.float32).reshape(-1, 3) def _get_applied_schema_names(prim) -> set[str]: @@ -122,37 +184,23 @@ def _score(prim) -> tuple: return max(vis_candidates, key=_score) -def _classify_deformable_meshes( - root_prim, -) -> tuple[str, object, object, int, int, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: - """Classify the simulation and visual meshes under a deformable root prim. +def deformable_entry(root_prim: Usd.Prim) -> DeformableStageEntry | None: + """Read simulation and visual geometry from one declared deformable prototype. Args: - root_prim: Prim that carries ``OmniPhysicsDeformableBodyAPI``. + root_prim: Prim that carries a deformable-body API schema. Returns: - A 9-tuple: - deformable_type: ``"volume"`` for a tet sim mesh, else ``"surface"``. - sim_mesh_prim: Simulation mesh prim (``TetMesh`` or ``Mesh``). - vis_mesh_prim: Selected visual mesh prim (may equal the sim mesh). - vertex_count: Number of simulation mesh vertices. - vis_vertex_count: Number of visual mesh vertices. - vertices: Sim vertices baked into the deformable parent frame [m]. - indices: Flattened sim topology (4 ints per tet, or face-vertex indices). - vis_vertices: Visual vertices baked into the same parent frame [m]. - vis_indices: Flattened visual triangle indices (empty when not a ``Mesh``). - - Raises: - ValueError: If no simulation mesh is found under ``root_prim``. + Geometry with vertices [m] baked into the parent frame and its world pose [m, xyzw], + or ``None`` when the prototype has no simulation mesh. """ stage = root_prim.GetStage() root_path = root_prim.GetPath() - tet_prims = sim_utils.get_all_matching_child_prims( - prim_path=root_path, predicate=lambda p: p.GetTypeName() == "TetMesh", stage=stage - ) mesh_prims = sim_utils.get_all_matching_child_prims( - prim_path=root_path, predicate=lambda p: p.GetTypeName() == "Mesh", stage=stage + prim_path=root_path, predicate=lambda p: p.GetTypeName() in ("TetMesh", "Mesh"), stage=stage ) + tet_prims = [prim for prim in mesh_prims if prim.GetTypeName() == "TetMesh"] + mesh_prims = [prim for prim in mesh_prims if prim.GetTypeName() == "Mesh"] if tet_prims: if len(tet_prims) > 1: @@ -186,7 +234,8 @@ def _classify_deformable_meshes( pts = usd_mesh.GetPointsAttr().Get() or [] indices = np.asarray(usd_mesh.GetFaceVertexIndicesAttr().Get() or [], dtype=np.int32) else: - raise ValueError(f"No simulation mesh found under deformable root '{root_path}'.") + logger.warning("Skipping deformable prim '%s': no simulation mesh found.", root_path) + return None vis_mesh_prim = _select_visual_mesh(vis_candidates, sim_mesh_prim, len(pts)) vis_pts = ( @@ -213,253 +262,106 @@ def _classify_deformable_meshes( if vis_mesh_prim.GetTypeName() == "Mesh": vis_indices = np.asarray(UsdGeom.Mesh(vis_mesh_prim).GetFaceVertexIndicesAttr().Get() or [], dtype=np.int32) - return ( - deformable_type, - sim_mesh_prim, - vis_mesh_prim, - len(pts), - vis_count, - vertices, - indices, - vis_vertices, - vis_indices, + parent_transform = xform_cache.GetLocalToWorldTransform(root_prim.GetParent()) + rotation = parent_transform.ExtractRotationQuat() + return DeformableStageEntry( + root_path=str(root_path), + sim_mesh_path=str(sim_mesh_prim.GetPath()), + vis_mesh_path=str(vis_mesh_prim.GetPath()), + deformable_type=deformable_type, + vertex_count=len(pts), + vis_vertex_count=vis_count, + vertices=vertices, + indices=indices, + vis_vertices=vis_vertices, + vis_indices=vis_indices, + init_pos=tuple(parent_transform.ExtractTranslation()), + init_rot=(*rotation.GetImaginary(), rotation.GetReal()), ) -def discover_deformables_on_stage( - stage: Usd.Stage, *, root_paths: Sequence[str] | None = None +def deformable_prototypes( + stage: Usd.Stage, plan: ClonePlan, rows: Sequence[int] | None = None ) -> list[DeformableStageEntry]: - """Discover PhysX/OVPhysX deformable bodies under ``stage``. - - Callers that need the result in more than one place should keep the returned - list and pass it explicitly (for example via ``entries=``) rather than relying - on a process-global stage cache. - - Args: - stage: USD stage to traverse. - root_paths: Declared asset roots to inspect. None selects the complete stage. - - Returns: - One :class:`DeformableStageEntry` per prim with ``OmniPhysicsDeformableBodyAPI``. - """ - entries: list[DeformableStageEntry] = [] - prims = ( - stage.Traverse() - if root_paths is None - else chain.from_iterable( - Usd.PrimRange(stage.GetPrimAtPath(path)) for path in Sdf.Path.RemoveDescendentPaths(root_paths) - ) - ) - for prim in prims: - if not _prim_has_schema(prim, "OmniPhysicsDeformableBodyAPI"): - continue - - try: - ( - deformable_type, - sim_mesh_prim, - vis_mesh_prim, - vertex_count, - vis_vertex_count, - vertices, - indices, - vis_vertices, - vis_indices, - ) = _classify_deformable_meshes(prim) - except ValueError as exc: - logger.warning("Skipping deformable prim '%s': %s", prim.GetPath(), exc) - continue - - entries.append( - DeformableStageEntry( - root_path=prim.GetPath().pathString, - sim_mesh_path=sim_mesh_prim.GetPath().pathString, - vis_mesh_path=vis_mesh_prim.GetPath().pathString, - deformable_type=deformable_type, - vertex_count=vertex_count, - vis_vertex_count=vis_vertex_count, - vertices=vertices, - indices=indices, - vis_vertices=vis_vertices, - vis_indices=vis_indices, - ) - ) - return entries - - -def group_deformable_root_paths_for_views( - root_paths: list[str], - path_to_type: dict[str, str], -) -> dict[str, tuple[list[str], list[str]]]: - """Group deformable root paths into PhysX/OVPhysX view patterns and exact paths. - - Replicated env assets with the same trailing prim name collapse to - ``/World/envs/env_*`` wildcard patterns; singleton paths stay exact. - - Args: - root_paths: Discovered deformable root prim paths. - path_to_type: Map from root path to ``volume`` or ``surface``. - - Returns: - Dict keyed by deformable type with ``(wildcard_patterns, exact_paths)`` lists. - Wildcard patterns are sorted; exact paths preserve discovery order within type. - """ - non_rigid_names: set[str] = set() - for path in root_paths: - if re.search(r"/World/envs/env_\d+/", path): - non_rigid_names.add(path.rsplit("/", 1)[-1]) - - typed_patterns: dict[str, set[str]] = {"volume": set(), "surface": set()} - typed_exact: dict[str, list[str]] = {"volume": [], "surface": []} - for path in root_paths: - body_name = path.rsplit("/", 1)[-1] - wildcard = path_to_env_wildcard(path) - deformable_type = path_to_type[path] - if body_name in non_rigid_names and wildcard != path: - typed_patterns[deformable_type].add(wildcard) - else: - typed_exact[deformable_type].append(path) - - return { - deformable_type: ([*sorted(typed_patterns[deformable_type])], typed_exact[deformable_type]) - for deformable_type in ("volume", "surface") - } - - -def sort_deformable_entries_for_geometry_sync(entries: list[DeformableStageEntry]) -> list[DeformableStageEntry]: - """Return deformable entries in SceneData geometry path order (volume, then surface).""" - type_rank = {"volume": 0, "surface": 1} - return sorted(entries, key=lambda entry: (type_rank.get(entry.deformable_type, 2), entry.root_path)) - - -def path_to_env_wildcard(path: str) -> str: - """Rewrite ``env_`` segments to ``env_*`` for PhysX tensor patterns.""" - return re.sub(r"/World/envs/env_\d+", "/World/envs/env_*", path) - - -def path_to_env_regex(path: str) -> str: - """Rewrite ``env_`` segments to ``env_[^/]+`` for Isaac Lab asset regex paths.""" - return re.sub(r"/World/envs/env_\d+", "/World/envs/env_[^/]+", path) - - -def build_deformable_vertex_count_lookup(entries: list[DeformableStageEntry]) -> dict[str, int]: - """Map deformable root, simulation-mesh, and visual-mesh paths to unpadded vertex counts.""" - path_to_count: dict[str, int] = {} - for entry in entries: - path_to_count[entry.root_path] = entry.vertex_count - path_to_count[entry.sim_mesh_path] = entry.vertex_count - path_to_count[entry.vis_mesh_path] = entry.vis_vertex_count - return path_to_count - - -def build_deformable_root_path_lookup(entries: list[DeformableStageEntry]) -> dict[str, str]: - """Map deformable root, simulation-mesh, and visual-mesh paths to discovered roots.""" - path_to_root: dict[str, str] = {} - for entry in entries: - path_to_root[entry.root_path] = entry.root_path - path_to_root[entry.sim_mesh_path] = entry.root_path - path_to_root[entry.vis_mesh_path] = entry.root_path - return path_to_root - - -def _env_relative_suffix(path: str) -> str | None: - """Return the path suffix after ``/World/envs/env_/``, or ``None`` if absent.""" - match = re.search(r"/World/envs/env_\d+/(.*)", path) - return match.group(1) if match else None - - -def _rewrite_env_prefix(template_path: str, source_path: str) -> str: - """Copy the ``env_`` segment from ``source_path`` into ``template_path``.""" - match = re.search(r"/World/envs/(env_\d+)", source_path) - if match is None: - return template_path - return re.sub(r"/World/envs/env_\d+", f"/World/envs/{match.group(1)}", template_path) - - -def resolve_deformable_vertex_count(path: str, path_to_count: dict[str, int], *, fallback: int) -> int: - """Resolve an unpadded vertex count for a deformable-related prim path. - - Tries the exact path, ancestor/descendant relationships, and env-relative - suffixes (views may report a different child under the same env asset). - Falls back only when no discovered count matches. + """Read deformable geometry beneath the prototypes imported by one backend. Args: - path: Deformable root or mesh prim path reported by a physics view. - path_to_count: Lookup from :func:`build_deformable_vertex_count_lookup`. - fallback: Value returned when no discovered count matches ``path``. + stage: Stage containing the authored asset prototypes. + plan: Generic replication layout; it is not modified. + rows: Rows imported by this backend; ``None`` selects all active rows. Returns: - Unpadded vertex count for ``path``, or ``fallback``. + Prototype geometry owned by the caller, including shared assets once. """ - if path in path_to_count: - return int(path_to_count[path]) - - normalized = path.rstrip("/") - parts = normalized.split("/") - for end in range(len(parts) - 1, 0, -1): - candidate = "/".join(parts[:end]) - if candidate in path_to_count: - return int(path_to_count[candidate]) - - for key, count in path_to_count.items(): - key_normalized = key.rstrip("/") - if key_normalized.startswith(normalized + "/") or normalized.startswith(key_normalized + "/"): - return int(count) - - suffix = _env_relative_suffix(normalized) - if suffix is not None: - for key, count in path_to_count.items(): - key_suffix = _env_relative_suffix(key) - if key_suffix is None: + selected = set(range(len(plan.sources)) if rows is None else rows) + selected.intersection_update(np.flatnonzero(plan.clone_mask.any(axis=1))) + sources = {Sdf.Path(source) for source in plan.sources} + selected_sources = {Sdf.Path(plan.sources[row]) for row in selected} + roots = Sdf.Path.RemoveDescendentPaths([plan.sources[row] for row in selected] + list(plan.global_paths)) + entries = [] + for root in roots: + prims = iter(Usd.PrimRange(stage.GetPrimAtPath(root), Usd.TraverseInstanceProxies())) + for prim in prims: + path = prim.GetPath() + owner = path + while owner != Sdf.Path.absoluteRootPath and owner not in sources: + owner = owner.GetParentPath() + if owner in sources and owner not in selected_sources: + if not any(source.HasPrefix(path) for source in selected_sources): + prims.PruneChildren() continue - if suffix == key_suffix or suffix.startswith(key_suffix + "/") or key_suffix.startswith(suffix + "/"): - return int(count) - - return int(fallback) - + # Shared roots may contain a replicated namespace: never inspect its generated clones. + if owner not in sources and any(match(str(path), template) for template in plan.destinations): + if not any(source.HasPrefix(path) for source in selected_sources): + prims.PruneChildren() + continue + if prim.IsA(UsdGeom.Points) or prim.IsA(UsdGeom.BasisCurves) or not has_deformable_body_api(prim): + continue + if (entry := deformable_entry(prim)) is not None: + entries.append(entry) + return entries -def resolve_deformable_root_path(path: str, path_to_root: dict[str, str], *, fallback: str | None = None) -> str: - """Resolve a view-reported deformable path to its discovered root prim path. - Uses the same matching strategy as :func:`resolve_deformable_vertex_count`. - Env-relative suffix matches rewrite the matched root's ``env_`` to the - reported path's environment so SceneData geometry mapping stays 1:1 with - shadow entity ``root_path`` values. +def expand_deformable_entries( + plan: ClonePlan, prototypes: Sequence[DeformableStageEntry], rows: Sequence[int] | None = None +) -> list[DeformableStageEntry]: + """Expand backend-owned prototype geometry without copying its vertex arrays or reading USD. Args: - path: Deformable root or mesh prim path reported by a physics view. - path_to_root: Lookup from :func:`build_deformable_root_path_lookup`. - fallback: Value returned when no discovered root matches ``path``. - Defaults to ``path``. + plan: Generic source-to-destination mapping. + prototypes: Geometry captured during this backend's prototype import. + rows: Source rows consumed by this backend; ``None`` selects all rows. Returns: - Discovered deformable root path, or ``fallback`` / ``path``. + Destination geometry records, including shared geometry once. Parent-frame world poses [m, xyzw] + include the clone translation; topology and vertex arrays remain shared with the prototype. """ - if fallback is None: - fallback = path - - if path in path_to_root: - return path_to_root[path] - - normalized = path.rstrip("/") - parts = normalized.split("/") - for end in range(len(parts) - 1, 0, -1): - candidate = "/".join(parts[:end]) - if candidate in path_to_root: - return path_to_root[candidate] - - for key, root in path_to_root.items(): - key_normalized = key.rstrip("/") - if key_normalized.startswith(normalized + "/") or normalized.startswith(key_normalized + "/"): - return root - - suffix = _env_relative_suffix(normalized) - if suffix is not None: - for key, root in path_to_root.items(): - key_suffix = _env_relative_suffix(key) - if key_suffix is None: + entries: dict[str, tuple[str, DeformableStageEntry]] = {} + selected = set(range(len(plan.sources)) if rows is None else rows) + source_rows = defaultdict(list) + for row, source in enumerate(plan.sources): + source_rows[Sdf.Path(source)].append(row) + for entry in prototypes: + owner = Sdf.Path(entry.root_path) + while owner != Sdf.Path.absoluteRootPath and owner not in source_rows: + owner = owner.GetParentPath() + if owner not in source_rows: + entries[entry.root_path] = (entry.root_path, entry) + continue + for row in source_rows[owner]: + if row not in selected: continue - if suffix == key_suffix or suffix.startswith(key_suffix + "/") or key_suffix.startswith(suffix + "/"): - return _rewrite_env_prefix(root, normalized) - - return fallback + columns = np.flatnonzero(plan.clone_mask[row]) + for column in columns: + target = plan.destinations[row].format(int(plan.env_ids[column])) + offset = 0 if plan.positions is None else plan.positions[column] - plan.positions[columns[0]] + cloned = replace( + entry, + root_path=rebase(entry.root_path, plan.sources[row], target), + sim_mesh_path=rebase(entry.sim_mesh_path, plan.sources[row], target), + vis_mesh_path=rebase(entry.vis_mesh_path, plan.sources[row], target), + init_pos=tuple(np.asarray(entry.init_pos) + offset), + ) + if cloned.root_path not in entries or len(target) > len(entries[cloned.root_path][0]): + entries[cloned.root_path] = (target, cloned) + return [entry for _, entry in entries.values()] diff --git a/source/isaaclab/isaaclab/scene_data/geometry_points.py b/source/isaaclab/isaaclab/scene_data/geometry_points.py index 8f5fd088ac66..a4efb5c07340 100644 --- a/source/isaaclab/isaaclab/scene_data/geometry_points.py +++ b/source/isaaclab/isaaclab/scene_data/geometry_points.py @@ -3,166 +3,61 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Device-side geometry point scatter helpers for SceneData.""" +"""Fused geometry interpolation and native destination writes for SDP.""" from __future__ import annotations -import logging +from typing import Any -import numpy as np import warp as wp -logger = logging.getLogger(__name__) +from .scene_data_backend import SceneDataFormat -@wp.kernel -def scatter_geometry_points_kernel( - src: wp.array(dtype=wp.vec3f), - dst: wp.array(dtype=wp.vec3f), - src_offsets: wp.array(dtype=wp.int32), - dest_offsets: wp.array(dtype=wp.int32), - counts: wp.array(dtype=wp.int32), -): - """Copy contiguous geometry slices from ``src`` into ``dst`` for each entity.""" - entity_id = wp.tid() - dest_offset = dest_offsets[entity_id] - if dest_offset < 0: - return - src_offset = src_offsets[entity_id] - count = counts[entity_id] - for i in range(count): - dst[dest_offset + i] = src[src_offset + i] - - -@wp.kernel -def pack_body_slices_kernel( - src: wp.array2d(dtype=wp.vec3f), - dst: wp.array(dtype=wp.vec3f), - body_counts: wp.array(dtype=wp.int32), - write_offsets: wp.array(dtype=wp.int32), - dest_base_offset: int, -): - """Pack per-body nodal slices from a ``(body_count, max_nodes)`` view into a flat buffer.""" - body_idx = wp.tid() - count = body_counts[body_idx] - write_offset = dest_base_offset + write_offsets[body_idx] - for i in range(count): - dst[write_offset + i] = src[body_idx, i] - - -def scatter_geometry_points( - src: wp.array(dtype=wp.vec3f), - dst: wp.array(dtype=wp.vec3f), - entity_counts: list[int], - mapping: wp.array(dtype=wp.int32) | None, - *, - device: str, -) -> None: - """Scatter backend geometry entities into a consumer points buffer on device. +@wp.func +def geometry_point(source: SceneDataFormat.Points, index: int) -> wp.vec3f: + return source.points[index] - Args: - src: Flattened source geometry points [m], shape ``[src_count]``, ``wp.vec3f``. - dst: Pre-allocated destination buffer [m], shape ``[dst_count]``, ``wp.vec3f``. - entity_counts: Unpadded point count for each backend geometry entity. - mapping: Optional destination particle offsets per entity (``-1`` skips copy). - device: Warp device for launch metadata. - """ - if not entity_counts: - wp.copy(dst, src) - return - num_entities = len(entity_counts) - src_offsets = np.zeros(num_entities, dtype=np.int32) - flat = 0 - for index, count in enumerate(entity_counts): - src_offsets[index] = flat - flat += int(count) +@wp.func +def geometry_point(source: SceneDataFormat.WeightedPoints, index: int) -> wp.vec3f: # noqa: F811 + value = wp.vec3f() + for corner in range(4): + value += source.points[source.indices[index, corner]] * source.weights[index, corner] + return value - if mapping is None: - dest_offsets = src_offsets.copy() - else: - dest_offsets = mapping.numpy().astype(np.int32, copy=True) - # Clamp per-entity copies so oversized backend counts cannot overflow ``dst``/``src`` - # or bleed into the next mapped destination slot (shadow sim layouts may use a smaller - # per-entity stride than the backend when USD discovery and PhysX nodal counts diverge). - dest_size = int(dst.shape[0]) - src_size = int(src.shape[0]) - positive_dests = sorted(int(offset) for offset in dest_offsets if int(offset) >= 0) - counts = np.zeros(num_entities, dtype=np.int32) - for entity_id, count in enumerate(entity_counts): - count = int(count) - dest_offset = int(dest_offsets[entity_id]) - src_offset = int(src_offsets[entity_id]) - if dest_offset < 0 or count <= 0: - counts[entity_id] = 0 - continue - # Space until the next destination slot (or end of buffer), not merely dest_size. - next_dest = dest_size - for offset in positive_dests: - if offset > dest_offset: - next_dest = offset - break - dest_slot = max(0, next_dest - dest_offset) - copy_count = min(count, dest_slot, dest_size - dest_offset, src_size - src_offset) - if copy_count < 0: - copy_count = 0 - if copy_count < count: - logger.warning( - "Clamping geometry point copy for entity %d from %d to %d " - "(dest_offset=%d dest_slot=%d dest_size=%d src_offset=%d src_size=%d).", - entity_id, - count, - copy_count, - dest_offset, - dest_slot, - dest_size, - src_offset, - src_size, - ) - counts[entity_id] = int(copy_count) +@wp.func +def geometry_point(source: SceneDataFormat.CapsuleEndpoints, index: int) -> wp.vec3f: # noqa: F811 + ends = source.endpoints[index] + left = wp.transform_multiply(source.transforms[source.shape_body[ends[0]]], source.shape_transform[ends[0]]) + right = wp.transform_multiply(source.transforms[source.shape_body[ends[2]]], source.shape_transform[ends[2]]) + a = wp.transform_point(left, wp.vec3f(0.0, 0.0, float(ends[1]) * source.shape_scale[ends[0]][1])) + b = wp.transform_point(right, wp.vec3f(0.0, 0.0, float(ends[3]) * source.shape_scale[ends[2]][1])) + return (a + b) * 0.5 - wp.launch( - scatter_geometry_points_kernel, - dim=num_entities, - inputs=[ - src, - dst, - wp.array(src_offsets, dtype=wp.int32, device=src.device), - wp.array(dest_offsets, dtype=wp.int32, device=src.device), - wp.array(counts, dtype=wp.int32, device=src.device), - ], - device=device, - ) - -def pack_body_nodal_slices( - nodal: wp.array2d, - dst: wp.array(dtype=wp.vec3f), - body_counts: list[int], - *, - device: str, - dest_base_offset: int = 0, -) -> None: - """Pack per-body nodal positions from a 2D view into ``dst``.""" - if not body_counts: - return - - write_offsets = np.zeros(len(body_counts), dtype=np.int32) - flat = 0 - for index, count in enumerate(body_counts): - write_offsets[index] = flat - flat += int(count) - - wp.launch( - pack_body_slices_kernel, - dim=len(body_counts), - inputs=[ - nodal, - dst, - wp.array(np.asarray(body_counts, dtype=np.int32), dtype=wp.int32, device=nodal.device), - wp.array(write_offsets, dtype=wp.int32, device=nodal.device), - dest_base_offset, - ], - device=device, - ) +@wp.kernel(enable_backward=False) +def convert_geometry_points_kernel( + source: Any, + source_indices: wp.array(dtype=wp.int32), + destination_indices: wp.array(dtype=wp.int32), + output: wp.array(dtype=wp.vec3f), +): + index = wp.tid() + source_index = source_indices[index] if source_indices.shape[0] else index + destination_index = destination_indices[index] if destination_indices.shape[0] else index + output[destination_index] = geometry_point(source, source_index) + + +@wp.kernel(enable_backward=False) +def convert_geometry_fabric_kernel( + source: Any, + source_indices: wp.array(dtype=wp.int32), + destination_indices: wp.array(dtype=wp.vec2i), + output: wp.fabricarrayarray(dtype=wp.vec3f), +): + index = wp.tid() + destination = destination_indices[index] + source_index = source_indices[index] if source_indices.shape[0] else index + output[destination[0]][destination[1]] = geometry_point(source, source_index) diff --git a/source/isaaclab/isaaclab/scene_data/scene_data_backend.py b/source/isaaclab/isaaclab/scene_data/scene_data_backend.py index 6bbef2b4ccc8..6425fd124d60 100644 --- a/source/isaaclab/isaaclab/scene_data/scene_data_backend.py +++ b/source/isaaclab/isaaclab/scene_data/scene_data_backend.py @@ -92,8 +92,64 @@ class Points: points: wp.array(dtype=wp.vec3f) = None """World-space positions [m], shape [point_count].""" + @wp_struct + class WeightedPoints: + """Visual vertices interpolated from four native simulation nodes.""" + + points: wp.array(dtype=wp.vec3f) + """Native world-space simulation nodes [m].""" + indices: wp.array2d(dtype=wp.int32) + """Four native node indices per visual vertex.""" + weights: wp.array2d(dtype=wp.float32) + """Four barycentric weights per visual vertex.""" + + @wp_struct + class CapsuleEndpoints: + """Polyline vertices derived from native capsule poses.""" + + transforms: wp.array(dtype=wp.transformf) + """World-space body poses [m, quaternion].""" + shape_body: wp.array(dtype=wp.int32) + shape_transform: wp.array(dtype=wp.transformf) + """Capsule poses relative to their bodies [m, quaternion].""" + shape_scale: wp.array(dtype=wp.vec3f) + """Capsule scales [m]; the second component is the half-length.""" + endpoints: wp.array(dtype=wp.vec4i) + """Two (capsule index, endpoint sign) pairs per output vertex; their positions are averaged.""" + + @wp_struct + class FabricPoints: + """Native Fabric point arrays, already owned and updated by physics.""" + + points: wp.fabricarrayarray(dtype=wp.vec3f) + """Per-prim native point storage [m].""" + class SceneDataBackend: + geometry_timestamp: int = 0 + """Logical update timestamp, advanced after native writes or buffer swaps, including within one step. + + This is not elapsed simulation time. Cached outputs record the timestamp they contain; + reading one output never clears another output's pending update. + """ + + @property + def native_geometry_formats(self) -> tuple[Any, ...]: + """Geometry formats the producer can publish without conversion.""" + return (SceneDataFormat.Points,) + + def get_geometry_batches( + self, output_format: Any = SceneDataFormat.Points + ) -> list[tuple[Any, dict[str, tuple[int, int]]]]: + """Publish native arrays and exact visual-prim ranges established during construction. + + Each batch pairs a native format with ``path: (offset, count)`` ranges. Offsets index + native points for ``Points`` or output vertices for an interpolated format. Publish the + requested native format when available, otherwise the primary formats for SDP to convert. + Native ``FabricPoints`` uses an empty range dictionary because Fabric owns the indexing. + """ + return [] + transforms_version: int """Monotonic producer version, incremented after native writes or buffer swaps; never reset by readers.""" @@ -124,23 +180,3 @@ def transform_count(self) -> int: def transform_paths(self) -> list[str]: """Return the paths for each transform.""" raise NotImplementedError - - @property - def points(self) -> SceneDataFormat.Points: - """Return deformable or particle geometry as flat world-space positions.""" - return SceneDataFormat.Points() - - @property - def point_count(self) -> int: - """Return the number of points in :attr:`points`.""" - return 0 - - @property - def geometry_paths(self) -> list[str]: - """Return one USD prim path per geometry entity (deformable body instance).""" - return [] - - @property - def geometry_counts(self) -> list[int]: - """Return the unpadded point count for each geometry entity.""" - return [] diff --git a/source/isaaclab/isaaclab/scene_data/scene_data_provider.py b/source/isaaclab/isaaclab/scene_data/scene_data_provider.py index 8ceb1cf95c7c..65e69928979c 100644 --- a/source/isaaclab/isaaclab/scene_data/scene_data_provider.py +++ b/source/isaaclab/isaaclab/scene_data/scene_data_provider.py @@ -5,19 +5,18 @@ from __future__ import annotations -import logging import re from collections import deque from typing import TYPE_CHECKING, Any +from weakref import WeakKeyDictionary import numpy as np import warp as wp from .. import sim as sim_utils +from .geometry_points import convert_geometry_fabric_kernel, convert_geometry_points_kernel from .scene_data_backend import SceneDataBackend, SceneDataFormat -logger = logging.getLogger(__name__) - if TYPE_CHECKING: from pxr import Usd @@ -30,7 +29,7 @@ "viser": (False, True), "isaac_rtx": (True, False), "newton_warp": (False, True), - "ovrtx": (True, True), + "ovrtx": (True, False), } @@ -66,6 +65,8 @@ def __init__(self, backend: SceneDataBackend): self._num_envs_cache: int | None = None self._interactive_scene: Any | None = None self._transform_cache: dict[tuple, tuple[int, Any]] = {} + self._geometry_view_cache: tuple | None = None + self._geometry_destination_cache = WeakKeyDictionary() def get_transforms( self, @@ -298,103 +299,139 @@ def create_mapping(self, paths: list[str | None]) -> wp.array(dtype=wp.int32) | return wp.array(mapping, dtype=wp.int32, device=_publication_device(input)) return None - def create_geometry_mapping( + def get_geometry_points( self, - paths: list[str | None], - particle_offsets: list[int], - ) -> wp.array(dtype=wp.int32) | None: - """Create a mapping from backend geometry entities to consumer particle offsets. - - For each geometry entity in the sim backend, the resulting array stores the - destination particle offset in the consumer buffer. Entities whose path does not - appear in ``paths`` receive ``-1`` and are skipped during copy. - - Args: - paths: Desired consumer entity paths in particle-offset order. - particle_offsets: Particle offset in the consumer buffer for each ``paths`` entry. - - Returns: - A Warp int32 array of length ``len(geometry_paths)`` containing destination - particle offsets, or ``None`` when no geometry is available or every entity - maps identically in order. - """ - input_paths = self.backend.geometry_paths - input_counts = self.backend.geometry_counts - if not input_paths or not input_counts: - return None - - path_to_offset = { - path: offset for path, offset in zip(paths, particle_offsets, strict=True) if path is not None - } - mapping = [-1] * len(input_paths) - identity = True - flat_offset = 0 - for index, path in enumerate(input_paths): - dest_offset = path_to_offset.get(path, -1) - mapping[index] = dest_offset - if dest_offset != flat_offset: - identity = False - flat_offset += int(input_counts[index]) - - if identity and all(value >= 0 for value in mapping): - return None - points = self.backend.points - return wp.array(mapping, dtype=wp.int32, device=_publication_device(points)) + *, + output: wp.array | SceneDataFormat.FabricPoints | None = None, + offsets: dict[str, int] | None = None, + ) -> dict[str, wp.array] | wp.array | SceneDataFormat.FabricPoints: + """Borrow visual point views or convert directly into the requested native destination. - def get_points( - self, - output: SceneDataFormat.Points, - mapping: wp.array(dtype=wp.int32) | None = None, - allow_passthrough: bool = True, - ) -> bool: - """Copy sim backend geometry points into ``output``. + Producers supply exact visual prim paths, native pointers and immutable interpolation + metadata. SDP performs interpolation and destination reordering together, once per + update timestamp and output layout. Only cross-device destinations require staging. Args: - output: Pre-allocated :class:`SceneDataFormat.Points` buffer (typically aliased - to shadow ``particle_q``). - mapping: Optional destination particle-offset array from - :meth:`create_geometry_mapping`. - allow_passthrough: When ``True`` and no mapping is needed, alias ``output.points`` - directly to the backend buffer. + output: Consumer-owned world-space point buffer [m] or native Fabric destination. + Omit to borrow shared point views. ``FabricPoints`` without offsets borrows + native Fabric storage when the producer publishes it. + offsets: Visual prim paths mapped to flat-buffer offsets or Fabric array indices. + Keep this mapping immutable for the destination's lifetime. Returns: - ``True`` when points were copied or passed through, ``False`` when the backend - exposes no geometry. + Read-only world-space views by visual prim path when no output is supplied; + otherwise the supplied destination, populated directly on the same device. """ - if self.point_count == 0: - return False - - input_points = self.backend.points - if input_points.points is None: - return False - - if mapping is None and allow_passthrough: - output.points = input_points.points - return True - - if output.points is None: - output.points = wp.empty(self.point_count, dtype=wp.vec3f, device=input_points.points.device) - - entity_counts = self.backend.geometry_counts - if not entity_counts: - wp.copy(output.points, input_points.points) - return True - - from isaaclab.scene_data.geometry_points import scatter_geometry_points - - scatter_geometry_points( - input_points.points, - output.points, - entity_counts, - mapping, - device=str(output.points.device), - ) - return True - - @property - def point_count(self) -> int: - """Number of geometry points available from the sim backend.""" - return self.backend.point_count + fabric = output is not None and not isinstance(output, wp.array) + requested_format = SceneDataFormat.FabricPoints if fabric and offsets is None else SceneDataFormat.Points + batches = self.backend.get_geometry_batches(requested_format) + if fabric and len(batches) == 1 and batches[0][0]._cls is SceneDataFormat.FabricPoints: + output.points = batches[0][0].points + return output + timestamp = self.backend.geometry_timestamp + if output is not None: + if offsets is None: + raise ValueError("A geometry destination requires its visual-path offsets.") + destination = output.points if fabric else output + cached = self._geometry_destination_cache.get(output) + if cached is None or cached[2] is not offsets: + jobs, bound = [], set() + for source, ranges in batches: + selected = {path: bounds for path, bounds in ranges.items() if path in offsets} + count = sum(count for _, count in selected.values()) + indices = np.empty((3 if fabric else 2, count), dtype=np.int32) + cursor = 0 + for path, (start, count) in selected.items(): + vertices = np.arange(count) + indices[0, cursor : cursor + count] = start + vertices + indices[1, cursor : cursor + count] = offsets[path] if fabric else offsets[path] + vertices + if fabric: + indices[2, cursor : cursor + count] = vertices + elif offsets[path] < 0 or offsets[path] + count > len(output): + raise ValueError("Geometry destination range exceeds its output buffer.") + cursor += count + device = _publication_device(source) + source_indices = wp.array(indices[0], device=device) + destination_indices = wp.array( + indices[1:].T if fabric else indices[1], + dtype=wp.vec2i if fabric else wp.int32, + device=destination.device, + ) + transfer = None + if cursor and device != destination.device: + staging = SceneDataFormat.Points() + staging.points = wp.empty( + cursor, wp.vec3f, device=destination.device, pinned=destination.device.is_cpu + ) + transfer = (wp.empty(cursor, wp.vec3f, device=device), staging, wp.array(dtype=wp.int32)) + jobs.append((source_indices, destination_indices, transfer)) + bound.update(selected) + if bound != offsets.keys(): + raise KeyError(f"Geometry destinations have no native publication: {offsets.keys() - bound}") + else: + timestamp_last_update, jobs, _ = cached + if timestamp_last_update == timestamp: + return output + for (source, _), (source_indices, destination_indices, transfer) in zip(batches, jobs, strict=True): + if len(source_indices): + device = _publication_device(source) + count = len(source_indices) + if transfer is not None: + packed, staging, identity = transfer + wp.launch( + convert_geometry_points_kernel, + dim=count, + inputs=[source, source_indices, identity, packed], + device=device, + ) + wp.copy(staging.points, packed) + if destination.device.is_cpu: + wp.synchronize_stream(device) + source, source_indices = staging, identity + wp.launch( + convert_geometry_fabric_kernel if fabric else convert_geometry_points_kernel, + dim=count, + inputs=[source, source_indices, destination_indices, destination], + device=destination.device, + ) + # Retain conversion buffers, never the consumer's destination or slices of it. + self._geometry_destination_cache[output] = (timestamp, jobs, offsets) + return output + if offsets is not None: + raise ValueError("Geometry offsets require a destination.") + cached = self._geometry_view_cache + if cached is None: + views, jobs = {}, [] + for source, ranges in batches: + device = _publication_device(source) + count = max((start + count for start, count in ranges.values()), default=0) + buffer = ( + source.points if source._cls is SceneDataFormat.Points else wp.empty(count, wp.vec3f, device=device) + ) + indices = wp.array(dtype=wp.int32, device=device) + jobs.append((indices, buffer, ranges)) + views.update((path, buffer[start : start + count]) for path, (start, count) in ranges.items()) + else: + timestamp_last_update, views, jobs = cached + if timestamp_last_update == timestamp: + return views + + for index, ((source, _), (indices, buffer, ranges)) in enumerate(zip(batches, jobs, strict=True)): + if source._cls is SceneDataFormat.Points: + if buffer is not source.points: + buffer = source.points + jobs[index] = (indices, buffer, ranges) + views.update((path, buffer[start : start + count]) for path, (start, count) in ranges.items()) + continue + if len(buffer): + wp.launch( + convert_geometry_points_kernel, + dim=len(buffer), + inputs=[source, indices, indices, buffer], + device=buffer.device, + ) + self._geometry_view_cache = (timestamp, views, jobs) + return views class ConversionKernels: diff --git a/source/isaaclab/test/cloner/test_replicate_session.py b/source/isaaclab/test/cloner/test_replicate_session.py index 77b6717102c7..3c576e8660d2 100644 --- a/source/isaaclab/test/cloner/test_replicate_session.py +++ b/source/isaaclab/test/cloner/test_replicate_session.py @@ -10,6 +10,8 @@ import numpy as np import pytest +from pxr import Usd, UsdGeom + import isaaclab.cloner.clone_plan as clone_plan import isaaclab.cloner.replicate_session as replicate_session from isaaclab.assets import AssetBaseCfg @@ -41,11 +43,13 @@ def simulation(monkeypatch): clone_contexts={}, _backend_registry=registry, _render_context=RenderContext(registry), - stage=object(), + stage=Usd.Stage.CreateInMemory(), plan=None, calls=[], ) sim.render_context = sim._render_context + for path in ("/World/envs/env_0", "/World/Ground", "/Lab/Cell0", "/Lab/Ground"): + UsdGeom.Xform.Define(sim.stage, path) sim.get_or_create_backend = lambda cfg: SimulationContext.get_or_create_backend(sim, cfg) sim.clone_contexts[_Context] = _Context(sim) sim.get_clone_plan = lambda: sim.plan @@ -164,7 +168,7 @@ def test_grid_transforms_always_returns_float32(): def test_replicate_dispatches_the_same_plan_in_priority_order(simulation): - """Registered contexts receive one shared plan in backend priority order.""" + """Generic dispatch forwards one layout without interpreting asset geometry.""" class Late(_Context): replicate_priority = 1 @@ -173,6 +177,7 @@ class Early(_Context): replicate_priority = -1 plan = _plan(Late, Early) + simulation.stage = None simulation.physics_manager.clone_context_type = Late simulation.clone_contexts = {Late: Late(simulation), Early: Early(simulation)} simulation.plan = plan @@ -180,6 +185,7 @@ class Early(_Context): replicate_session.replicate(plan) assert simulation.calls == [(Early, plan), (Late, plan)] + assert not {"deformables", "cables", "point_clouds"}.intersection(vars(plan)) def test_replicate_physics_false_preserves_rendering_and_usd(simulation): diff --git a/source/isaaclab/test/renderers/test_rigid_object_rendering_contract_architecture.py b/source/isaaclab/test/renderers/test_rigid_object_rendering_contract_architecture.py index 631f31570377..48f1cb757202 100644 --- a/source/isaaclab/test/renderers/test_rigid_object_rendering_contract_architecture.py +++ b/source/isaaclab/test/renderers/test_rigid_object_rendering_contract_architecture.py @@ -66,3 +66,19 @@ def test_backend_adapters_do_not_duplicate_scene_ownership() -> None: assert not any(isinstance(node, ast.ClassDef) for node in ast.walk(tree)), ( f"{adapter.relative_to(_REPO_ROOT)} must not define backend-local scene classes" ) + + +def test_geometry_transport_does_not_cross_backend_ownership() -> None: + """Renderers consume SDP; physics does not own Fabric geometry destinations or shadow remapping.""" + for path in (_REPO_ROOT / "source/isaaclab_ov/isaaclab_ov/renderers").glob("*.py"): + modules = _imported_modules(ast.parse(path.read_text())) + assert not {module.split(".", 1)[0] for module in modules} & {"newton", "isaaclab_newton"}, path + for path in (_REPO_ROOT / "source/isaaclab_newton/isaaclab_newton/physics").glob("*.py"): + for node in ast.walk(ast.parse(path.read_text())): + if isinstance(node, ast.ClassDef): + assert node.name not in {"ShadowDeformableEntity", "ShadowDeformableRegistryGroup"}, path + if isinstance(node, ast.FunctionDef): + assert not any( + argument.annotation is not None and "fabricarray" in ast.unparse(argument.annotation) + for argument in node.args.args + ), (path, node.name) diff --git a/source/isaaclab/test/renderers/test_simulation_render_context.py b/source/isaaclab/test/renderers/test_simulation_render_context.py index 678a2cb557ed..bafdc4d216ec 100644 --- a/source/isaaclab/test/renderers/test_simulation_render_context.py +++ b/source/isaaclab/test/renderers/test_simulation_render_context.py @@ -172,12 +172,11 @@ def test_scene_state_does_not_skip_writes_within_a_physics_step(sim): for step in (1, 1, 2): sim.render_context.update_scene_state(step) assert renderer.update_transforms.call_count == 3 - assert renderer.update_geometries.call_count == 2 + assert renderer.update_geometries.call_count == 3 - sim.render_context.reset_scene_state_cadence() sim.render_context.update_scene_state(2) assert renderer.update_transforms.call_count == 4 - assert renderer.update_geometries.call_count == 3 + assert renderer.update_geometries.call_count == 4 @pytest.mark.parametrize("profile", [False, True]) @@ -196,6 +195,7 @@ def test_render_into_camera_call_order_and_profile_output(sim, capsys, profile): call.render_batch([data]), call.read_output(data, camera), call.update_transforms(), + call.update_geometries(), call.render_batch([data]), call.read_output(data, camera), ] diff --git a/source/isaaclab/test/scene_data/test_deformable_discovery.py b/source/isaaclab/test/scene_data/test_deformable_discovery.py index 07042fdef721..9f817becef77 100644 --- a/source/isaaclab/test/scene_data/test_deformable_discovery.py +++ b/source/isaaclab/test/scene_data/test_deformable_discovery.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""USD discovery tests for PhysX/OVPhysX deformable classification.""" +"""Declared deformable prototype classification and clone-plan expansion.""" from __future__ import annotations @@ -11,10 +11,13 @@ from pxr import Gf, Sdf, Usd, UsdGeom +from isaaclab.cloner import ClonePlan from isaaclab.scene_data.deformable_discovery import ( _matrix4d_to_numpy, _transform_points, - discover_deformables_on_stage, + deformable_entry, + deformable_prototypes, + expand_deformable_entries, ) @@ -46,7 +49,8 @@ def test_transform_points_matches_usd_matrix4d_transform(): assert np.allclose(baked, expected, atol=1e-6) -def test_discover_volume_tet_mesh_deformable(): +def test_deformable_entry_volume_tet_mesh(): + """Classify tetrahedra and prefer the named visual over unrelated child meshes.""" stage = Usd.Stage.CreateInMemory() root = UsdGeom.Xform.Define(stage, "/World/envs/env_0/SoftBody").GetPrim() _add_api_schemas(root, ["OmniPhysicsDeformableBodyAPI"]) @@ -55,14 +59,13 @@ def test_discover_volume_tet_mesh_deformable(): points = [Gf.Vec3f(0.0, 0.0, 0.0), Gf.Vec3f(1.0, 0.0, 0.0), Gf.Vec3f(0.0, 1.0, 0.0), Gf.Vec3f(0.0, 0.0, 1.0)] tet.CreatePointsAttr(points) tet.CreateTetVertexIndicesAttr([Gf.Vec4i(0, 1, 2, 3)]) - visual = UsdGeom.Mesh.Define(stage, "/World/envs/env_0/SoftBody/visual") - visual.CreatePointsAttr(points) - visual.CreateFaceVertexCountsAttr([3]) - visual.CreateFaceVertexIndicesAttr([0, 1, 2]) - - entries = discover_deformables_on_stage(stage) - assert len(entries) == 1 - entry = entries[0] + for name in ("decoration", "visual", "props/unrelated"): + mesh = UsdGeom.Mesh.Define(stage, f"/World/envs/env_0/SoftBody/{name}") + mesh.CreatePointsAttr(points) + mesh.CreateFaceVertexCountsAttr([3]) + mesh.CreateFaceVertexIndicesAttr([0, 1, 2]) + + entry = deformable_entry(root) assert entry.deformable_type == "volume" assert entry.vertex_count == 4 assert entry.vis_vertex_count == 4 @@ -71,7 +74,7 @@ def test_discover_volume_tet_mesh_deformable(): assert entry.vis_mesh_path.endswith("/visual") -def test_discover_surface_mesh_deformable(): +def test_deformable_entry_surface_mesh(): stage = Usd.Stage.CreateInMemory() sim_mesh = UsdGeom.Mesh.Define(stage, "/World/envs/env_0/Cloth") _add_api_schemas( @@ -96,27 +99,25 @@ def test_discover_surface_mesh_deformable(): vis_mesh.CreateFaceVertexCountsAttr([3, 3]) vis_mesh.CreateFaceVertexIndicesAttr([0, 1, 2, 0, 2, 3]) - entries = discover_deformables_on_stage(stage) - assert len(entries) == 1 - entry = entries[0] + entry = deformable_entry(sim_mesh.GetPrim()) assert entry.deformable_type == "surface" assert entry.vertex_count == 4 assert entry.vis_vertex_count == 5 -def test_discover_skips_deformable_without_mesh(caplog): +def test_deformable_entry_skips_missing_mesh(caplog): stage = Usd.Stage.CreateInMemory() root = UsdGeom.Xform.Define(stage, "/World/envs/env_0/EmptyDeformable").GetPrim() _add_api_schemas(root, ["OmniPhysicsDeformableBodyAPI"]) with caplog.at_level("WARNING", logger="isaaclab.scene_data.deformable_discovery"): - entries = discover_deformables_on_stage(stage) + entry = deformable_entry(root) - assert entries == [] + assert entry is None assert any("Skipping deformable prim" in record.message for record in caplog.records) -def test_discover_surface_without_sim_api_uses_first_mesh(): +def test_deformable_entry_surface_without_sim_api_uses_first_mesh(): stage = Usd.Stage.CreateInMemory() mesh = UsdGeom.Mesh.Define(stage, "/World/envs/env_0/Cloth") _add_api_schemas(mesh.GetPrim(), ["OmniPhysicsDeformableBodyAPI"]) @@ -125,58 +126,81 @@ def test_discover_surface_without_sim_api_uses_first_mesh(): mesh.CreateFaceVertexCountsAttr([3]) mesh.CreateFaceVertexIndicesAttr([0, 1, 2]) - entries = discover_deformables_on_stage(stage) - assert len(entries) == 1 - assert entries[0].deformable_type == "surface" - assert entries[0].vertex_count == 3 - assert entries[0].vis_vertex_count == 3 + entry = deformable_entry(mesh.GetPrim()) + assert entry.deformable_type == "surface" + assert entry.vertex_count == 3 + assert entry.vis_vertex_count == 3 -def test_discover_declared_roots_once_without_undeclared_siblings(): +def test_backend_geometry_nearest_owner_partial_rows_and_shared_roots(): + """Import declared prototypes once; bind exact paths without retaining geometry on the plan.""" stage = Usd.Stage.CreateInMemory() - for path in ("/Scene/Declared/Cloth", "/Scene/Undeclared/Cloth"): + parent = UsdGeom.Xform.Define(stage, "/Lab/Cell3") + parent.AddTranslateOp().Set((10.0, 0.0, 0.0)) + parent.AddRotateZOp().Set(30.0) + for path, schema in ( + ("/Lab/Cell3/Cloth", "OmniPhysicsDeformableBodyAPI"), + ("/Lab/Cell3/Nested/Cloth", "PhysicsDeformableBodyAPI"), + ("/Lab/Cell3/Dormant/Cloth", "OmniPhysicsDeformableBodyAPI"), + ("/Lab/Cell7/Cloth", "OmniPhysicsDeformableBodyAPI"), + ("/Shared/Cloth", "OmniPhysicsDeformableBodyAPI"), + ("/Undeclared/Cloth", "OmniPhysicsDeformableBodyAPI"), + ): mesh = UsdGeom.Mesh.Define(stage, path) - _add_api_schemas(mesh.GetPrim(), ["OmniPhysicsDeformableBodyAPI"]) + _add_api_schemas(mesh.GetPrim(), [schema]) mesh.CreatePointsAttr([(0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0, 1.0, 0.0)]) mesh.CreateFaceVertexCountsAttr([3]) mesh.CreateFaceVertexIndicesAttr([0, 1, 2]) - - entries = discover_deformables_on_stage( - stage, root_paths=("/Scene/Declared/Cloth", "/Scene/Declared", "/Scene/Declared/Cloth") + UsdGeom.Points.Define(stage, "/Shared/Particles").CreatePointsAttr([(1.0, 2.0, 3.0)]) + mpm_points = UsdGeom.Points.Define(stage, "/Lab/Cell3/SimulationPoints") + mpm_points.CreatePointsAttr([(1.0, 2.0, 3.0), (0.0, 0.0, 0.0)]) + _add_api_schemas(mpm_points.GetPrim(), ["PhysicsDeformableBodyAPI"]) + plan = ClonePlan( + sources=("/Lab/Cell3", "/Lab/Cell3/Nested", "/Missing", "/Lab/Cell3/Dormant"), + destinations=("/Lab/Cell{}", "/Lab/Cell{}/Nested", "/Other/{}", "/Lab/Cell{}/Dormant"), + clone_mask=np.asarray([[1, 1, 0], [1, 0, 1], [0, 0, 0], [0, 0, 0]], dtype=np.bool_), + env_ids=np.asarray([3, 7, 11]), + positions=np.asarray([[10.0, 0.0, 0.0], [20.0, 0.0, 0.0], [35.0, 0.0, 0.0]]), + global_paths=("/Shared", "/Shared/Cloth", "/Lab"), ) - assert [entry.root_path for entry in entries] == ["/Scene/Declared/Cloth"] - - -def test_discover_volume_prefers_named_visual_over_unrelated_child_mesh(): - """When several child meshes exist under the BodyAPI root, select the visual mesh.""" - stage = Usd.Stage.CreateInMemory() - root = UsdGeom.Xform.Define(stage, "/World/envs/env_0/SoftBody").GetPrim() - _add_api_schemas(root, ["OmniPhysicsDeformableBodyAPI"]) - tet = UsdGeom.TetMesh.Define(stage, "/World/envs/env_0/SoftBody/simulation") - _add_api_schemas(tet.GetPrim(), ["OmniPhysicsVolumeDeformableSimAPI"]) - points = [Gf.Vec3f(0.0, 0.0, 0.0), Gf.Vec3f(1.0, 0.0, 0.0), Gf.Vec3f(0.0, 1.0, 0.0), Gf.Vec3f(0.0, 0.0, 1.0)] - tet.CreatePointsAttr(points) - tet.CreateTetVertexIndicesAttr([Gf.Vec4i(0, 1, 2, 3)]) - - # Lexicographically first child mesh (must not win over the named visual). - deco = UsdGeom.Mesh.Define(stage, "/World/envs/env_0/SoftBody/decoration") - deco.CreatePointsAttr([Gf.Vec3f(0.0, 0.0, 0.0), Gf.Vec3f(1.0, 0.0, 0.0), Gf.Vec3f(0.0, 1.0, 0.0)]) - deco.CreateFaceVertexCountsAttr([3]) - deco.CreateFaceVertexIndicesAttr([0, 1, 2]) - - visual = UsdGeom.Mesh.Define(stage, "/World/envs/env_0/SoftBody/visual") - visual.CreatePointsAttr(points) - visual.CreateFaceVertexCountsAttr([3]) - visual.CreateFaceVertexIndicesAttr([0, 1, 2]) - - # Nested under an unrelated child branch — scoring must prefer the named visual. - nested = UsdGeom.Mesh.Define(stage, "/World/envs/env_0/SoftBody/props/unrelated") - nested.CreatePointsAttr(points) - nested.CreateFaceVertexCountsAttr([3]) - nested.CreateFaceVertexIndicesAttr([0, 1, 2]) - - entries = discover_deformables_on_stage(stage) - assert len(entries) == 1 - assert entries[0].vis_mesh_path.endswith("/visual") - assert entries[0].vis_vertex_count == 4 + prototypes = deformable_prototypes(stage, plan) + assert len(prototypes) == 3 + assert {entry.root_path for entry in prototypes} == {"/Lab/Cell3/Cloth", "/Lab/Cell3/Nested/Cloth", "/Shared/Cloth"} + assert {entry.root_path for entry in deformable_prototypes(stage, plan, (1,))} == { + "/Lab/Cell3/Nested/Cloth", + "/Shared/Cloth", + } + prototype = next(entry for entry in prototypes if entry.root_path == "/Lab/Cell3/Cloth") + stage.RemovePrim("/Lab") + + expanded = {entry.root_path: entry for entry in expand_deformable_entries(plan, prototypes)} + assert set(expanded) == { + "/Lab/Cell3/Cloth", + "/Lab/Cell7/Cloth", + "/Lab/Cell3/Nested/Cloth", + "/Lab/Cell11/Nested/Cloth", + "/Shared/Cloth", + } + assert {entry.root_path for entry in expand_deformable_entries(plan, prototypes, (1,))} == { + "/Lab/Cell3/Nested/Cloth", + "/Lab/Cell11/Nested/Cloth", + "/Shared/Cloth", + } + clone = expanded["/Lab/Cell7/Cloth"] + assert clone.sim_mesh_path == clone.vis_mesh_path == clone.root_path + assert clone.vertices is prototype.vertices and clone.indices is prototype.indices + np.testing.assert_allclose(clone.init_pos, (20.0, 0.0, 0.0)) + np.testing.assert_allclose(clone.init_rot, (0.0, 0.0, np.sin(np.pi / 12), np.cos(np.pi / 12))) + + # Distinct source roots can target the same subtree; its nearest destination owner wins. + override_plan = ClonePlan( + sources=("/Lab/Cell3", "/Shared"), + destinations=("/Lab/Cell{}", "/Lab/Cell{}/Nested"), + clone_mask=np.ones((2, 1), dtype=np.bool_), + env_ids=np.asarray([3]), + ) + shared = next(entry for entry in prototypes if entry.root_path == "/Shared/Cloth") + for ordered in (prototypes, prototypes[::-1]): + expanded = {entry.root_path: entry for entry in expand_deformable_entries(override_plan, ordered)} + assert expanded["/Lab/Cell3/Nested/Cloth"].vertices is shared.vertices diff --git a/source/isaaclab/test/scene_data/test_deformable_discovery_helpers.py b/source/isaaclab/test/scene_data/test_deformable_discovery_helpers.py deleted file mode 100644 index ec7de5898f52..000000000000 --- a/source/isaaclab/test/scene_data/test_deformable_discovery_helpers.py +++ /dev/null @@ -1,128 +0,0 @@ -# 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 deformable discovery path/count helpers.""" - -from __future__ import annotations - -from isaaclab.scene_data.deformable_discovery import ( - DeformableStageEntry, - build_deformable_root_path_lookup, - build_deformable_vertex_count_lookup, - group_deformable_root_paths_for_views, - resolve_deformable_root_path, - resolve_deformable_vertex_count, - sort_deformable_entries_for_geometry_sync, -) - - -def test_deformable_vertex_count_lookup_and_resolve(): - """Vertex-count lookup indexes root/sim/vis paths and resolves ancestors and env clones.""" - entries = [ - DeformableStageEntry( - root_path="/World/envs/env_0/Deformable", - sim_mesh_path="/World/envs/env_0/Deformable/geometry/mesh", - vis_mesh_path="/World/envs/env_0/Deformable/geometry/vis_mesh", - deformable_type="volume", - vertex_count=69, - vis_vertex_count=72, - ) - ] - lookup = build_deformable_vertex_count_lookup(entries) - assert lookup["/World/envs/env_0/Deformable"] == 69 - assert lookup["/World/envs/env_0/Deformable/geometry/mesh"] == 69 - assert lookup["/World/envs/env_0/Deformable/geometry/vis_mesh"] == 72 - assert ( - resolve_deformable_vertex_count( - "/World/envs/env_0/Deformable/geometry/mesh/points", - lookup, - fallback=128, - ) - == 69 - ) - assert ( - resolve_deformable_vertex_count( - "/World/envs/env_2/Deformable/geometry/mesh", - lookup, - fallback=128, - ) - == 69 - ) - assert resolve_deformable_vertex_count("/World/other", lookup, fallback=128) == 128 - - -def test_deformable_root_path_lookup_and_resolve(): - """Root-path lookup rewrites env-relative matches onto the reported environment.""" - entries = [ - DeformableStageEntry( - root_path="/World/envs/env_0/Deformable", - sim_mesh_path="/World/envs/env_0/Deformable/geometry/mesh", - vis_mesh_path="/World/envs/env_0/Deformable/geometry/vis_mesh", - deformable_type="volume", - vertex_count=69, - vis_vertex_count=72, - ) - ] - lookup = build_deformable_root_path_lookup(entries) - assert lookup["/World/envs/env_0/Deformable"] == "/World/envs/env_0/Deformable" - assert lookup["/World/envs/env_0/Deformable/geometry/mesh"] == "/World/envs/env_0/Deformable" - assert lookup["/World/envs/env_0/Deformable/geometry/vis_mesh"] == "/World/envs/env_0/Deformable" - assert ( - resolve_deformable_root_path( - "/World/envs/env_0/Deformable/geometry/mesh/points", - lookup, - ) - == "/World/envs/env_0/Deformable" - ) - assert ( - resolve_deformable_root_path( - "/World/envs/env_2/Deformable/geometry/mesh", - lookup, - ) - == "/World/envs/env_2/Deformable" - ) - assert resolve_deformable_root_path("/World/other", lookup) == "/World/other" - - -def test_group_deformable_root_paths_for_views_collapses_replicated_env_assets(): - root_paths = [ - "/World/envs/env_0/ClothA", - "/World/envs/env_1/ClothA", - "/World/envs/env_0/SoftB", - ] - path_to_type = { - "/World/envs/env_0/ClothA": "surface", - "/World/envs/env_1/ClothA": "surface", - "/World/envs/env_0/SoftB": "volume", - } - grouped = group_deformable_root_paths_for_views(root_paths, path_to_type) - assert grouped["surface"] == (["/World/envs/env_*/ClothA"], []) - assert grouped["volume"] == (["/World/envs/env_*/SoftB"], []) - - -def test_sort_deformable_entries_for_geometry_sync_orders_volume_before_surface(): - entries = [ - DeformableStageEntry( - root_path="/World/envs/env_0/Cloth", - sim_mesh_path="/World/envs/env_0/Cloth/sim", - vis_mesh_path="/World/envs/env_0/Cloth/vis", - deformable_type="surface", - vertex_count=4, - vis_vertex_count=4, - ), - DeformableStageEntry( - root_path="/World/envs/env_0/Soft", - sim_mesh_path="/World/envs/env_0/Soft/sim", - vis_mesh_path="/World/envs/env_0/Soft/vis", - deformable_type="volume", - vertex_count=5, - vis_vertex_count=5, - ), - ] - ordered = sort_deformable_entries_for_geometry_sync(entries) - assert [entry.root_path for entry in ordered] == [ - "/World/envs/env_0/Soft", - "/World/envs/env_0/Cloth", - ] diff --git a/source/isaaclab/test/scene_data/test_scene_data_geometry.py b/source/isaaclab/test/scene_data/test_scene_data_geometry.py index 7f13816a1d5f..efa71831542e 100644 --- a/source/isaaclab/test/scene_data/test_scene_data_geometry.py +++ b/source/isaaclab/test/scene_data/test_scene_data_geometry.py @@ -3,170 +3,151 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Unit tests for SceneData geometry (points) mapping and copy.""" +"""Native geometry publication, interpolation, and direct destination writes.""" from __future__ import annotations +import gc +import weakref +from types import SimpleNamespace +from unittest.mock import Mock + import numpy as np import pytest import warp as wp -from isaaclab.scene_data.scene_data_backend import SceneDataBackend, SceneDataFormat +from isaaclab.scene_data.scene_data_backend import SceneDataFormat from isaaclab.scene_data.scene_data_provider import SceneDataProvider -class _PointsBackend(SceneDataBackend): - def __init__( - self, - points: np.ndarray | None = None, - geometry_paths: list[str] | None = None, - geometry_counts: list[int] | None = None, - device: str | None = None, - ): - if points is None: - points = np.array( - [ - [0.0, 0.0, 0.0], - [1.0, 0.0, 0.0], - [2.0, 0.0, 0.0], - [0.0, 1.0, 0.0], - [1.0, 1.0, 0.0], - ], - dtype=np.float32, - ) - self._points = wp.array(points, dtype=wp.vec3f, device=device) - self._geometry_paths = geometry_paths or ["/World/envs/env_0/A", "/World/envs/env_1/A"] - self._geometry_counts = geometry_counts or [2, 3] - self._scene_data = SceneDataFormat.Points() - self._scene_data.points = self._points - - @property - def transforms(self) -> SceneDataFormat.Transform: - return SceneDataFormat.Transform() - - @property - def transform_count(self) -> int: - return 0 - - @property - def transform_paths(self) -> list[str]: - return [] - - @property - def points(self) -> SceneDataFormat.Points: - return self._scene_data - - @property - def point_count(self) -> int: - return int(self._points.shape[0]) - - @property - def geometry_paths(self) -> list[str]: - return self._geometry_paths - - @property - def geometry_counts(self) -> list[int]: - return self._geometry_counts - - -def test_create_geometry_mapping_returns_none_for_identity_order(): - backend = _PointsBackend() +def test_geometry_views_alias_native_ranges_and_follow_pointer_swaps(monkeypatch): + """Native geometry skips copies, including padded ranges and published pointer swaps.""" + source = SceneDataFormat.Points() + source.points = wp.array(np.arange(18).reshape(6, 3), dtype=wp.vec3f, device="cpu") + replacement = wp.array(np.arange(18, 36).reshape(6, 3), dtype=wp.vec3f, device="cpu") + ranges = {"/Cloth/visual": (1, 2), "/Particles/points": (4, 2)} + backend = SimpleNamespace(geometry_timestamp=0, get_geometry_batches=lambda _: [(source, ranges)]) provider = SceneDataProvider(backend) - mapping = provider.create_geometry_mapping( - ["/World/envs/env_0/A", "/World/envs/env_1/A"], - [0, 2], - ) - assert mapping is None - - -def test_create_geometry_mapping_remaps_out_of_order_entities(): - backend = _PointsBackend() + launch, copy = Mock(wraps=wp.launch), Mock(wraps=wp.copy) + monkeypatch.setattr(wp, "launch", launch) + monkeypatch.setattr(wp, "copy", copy) + views = provider.get_geometry_points() + assert provider.get_geometry_points() is views + for path, (offset, count) in ranges.items(): + assert views[path].ptr == source.points.ptr + offset * wp.types.type_size_in_bytes(wp.vec3f) + np.testing.assert_array_equal(views[path].numpy(), source.points.numpy()[offset : offset + count]) + previous = views["/Cloth/visual"] + source.points = replacement + backend.geometry_timestamp += 1 + updated = provider.get_geometry_points() + assert updated["/Cloth/visual"].ptr != previous.ptr + np.testing.assert_array_equal(updated["/Cloth/visual"].numpy(), [[21, 22, 23], [24, 25, 26]]) + launch.assert_not_called() + copy.assert_not_called() + output = wp.zeros(4, dtype=wp.vec3f, device="cpu") + offsets = {"/Particles/points": 0, "/Cloth/visual": 2} + reordered = provider.get_geometry_points(output=output, offsets=offsets) + assert reordered is output + assert provider.get_geometry_points(output=output, offsets=offsets) is reordered + assert launch.call_count == 1 + np.testing.assert_array_equal(output.numpy(), replacement.numpy()[[4, 5, 1, 2]]) + released = weakref.ref(output) + launch.reset_mock() + del reordered, output + gc.collect() + assert released() is None + + +@pytest.mark.parametrize("fabric", [False, True]) +@pytest.mark.parametrize( + "source_device,destination_device", [("cpu", "cpu"), ("cpu", "cuda:0"), ("cuda:0", "cpu"), ("cuda:0", "cuda:0")] +) +def test_geometry_interpolation_and_reordering_write_once_per_timestamp( + monkeypatch, fabric, source_device, destination_device +): + """One conversion writes barycentric vertices directly into the consumer's reordered layout.""" + if "cuda:0" in (source_device, destination_device) and not wp.is_cuda_available(): + pytest.skip("CUDA is unavailable") + source = SceneDataFormat.WeightedPoints() + nodes = np.arange(18, dtype=np.float32).reshape(6, 3) + indices = np.array([[0, 1, 2, 3], [1, 2, 3, 4], [2, 3, 4, 5]], dtype=np.int32) + weights = np.array([[0.25] * 4, [1, 0, 0, 0], [0.5, 0.25, 0.25, 0]], dtype=np.float32) + source.points = wp.array(nodes, dtype=wp.vec3f, device=source_device) + source.indices = wp.array(indices, dtype=wp.int32, device=source_device) + source.weights = wp.array(weights, dtype=wp.float32, device=source_device) + ranges = {"/First/visual": (0, 2), "/Second/visual": (2, 1)} + backend = SimpleNamespace(geometry_timestamp=0, get_geometry_batches=lambda _: [(source, ranges)]) provider = SceneDataProvider(backend) - mapping = provider.create_geometry_mapping( - ["/World/envs/env_1/A", "/World/envs/env_0/A"], - [0, 3], - ) - assert mapping is not None - assert mapping.numpy().tolist() == [3, 0] - - -@pytest.mark.skipif(wp.get_cuda_device_count() == 0, reason="requires CUDA") -def test_create_geometry_mapping_uses_points_device_with_empty_transforms(): - """Geometry mapping follows its point publication, independently of transforms.""" - with wp.ScopedDevice("cpu"): - provider = SceneDataProvider(_PointsBackend(device="cuda:0")) - mapping = provider.create_geometry_mapping( - ["/World/envs/env_1/A", "/World/envs/env_0/A"], - [0, 3], + output = wp.full(6, wp.vec3f(-7), device=destination_device) + offsets = {"/Second/visual": 0, "/First/visual": 3} + destination = output + if fabric: + pointers = wp.array([output.ptr, output[3:].ptr], dtype=wp.uint64, device=destination_device) + lengths = wp.array([1, 2], dtype=wp.uint64, device=destination_device) + storage = SimpleNamespace( + __fabric_arrays_interface__={ + "version": 1, + "device": destination_device, + "attribs": { + "points": { + "type": (True, "f4", 3, 1, "vector"), + "access": 2, + "pointers": [pointers.ptr], + "counts": [2], + "array_lengths": [lengths.ptr], + } + }, + } ) - - assert mapping is not None - assert mapping.device == wp.get_device("cuda:0") - - -def test_create_geometry_mapping_rejects_empty_points_publication(): - backend = _PointsBackend() - backend._scene_data.points = None - - with pytest.raises(ValueError, match="Points contains no published arrays"): - SceneDataProvider(backend).create_geometry_mapping( - ["/World/envs/env_1/A", "/World/envs/env_0/A"], - [0, 3], + destination = SceneDataFormat.FabricPoints() + destination.points = wp.fabricarrayarray(data=storage, attrib="points", dtype=wp.vec3f) + offsets = {"/Second/visual": 0, "/First/visual": 1} + launch = Mock(wraps=wp.launch) + monkeypatch.setattr(wp, "launch", launch) + assert provider.get_geometry_points(output=destination, offsets=offsets) is destination + assert provider.get_geometry_points(output=destination, offsets=offsets) is destination + destination_launches = 1 + (source_device != destination_device) + assert launch.call_count == destination_launches + expected = np.full((6, 3), -7, dtype=np.float32) + interpolated = (nodes[indices] * weights[..., None]).sum(axis=1) + expected[0], expected[3:5] = interpolated[2], interpolated[:2] + np.testing.assert_allclose(output.numpy(), expected) + shared = provider.get_geometry_points() + assert provider.get_geometry_points() is shared + assert launch.call_count == destination_launches + 1 + np.testing.assert_allclose(shared["/First/visual"].numpy(), interpolated[:2]) + source.points.assign(nodes + 10) + backend.geometry_timestamp += 1 + assert provider.get_geometry_points(output=destination, offsets=offsets) is destination + assert provider.get_geometry_points() is shared + assert launch.call_count == 2 * (destination_launches + 1) + expected[[0, 3, 4]] += 10 + np.testing.assert_allclose(output.numpy(), expected) + np.testing.assert_allclose(shared["/Second/visual"].numpy(), interpolated[2:] + 10) + if fabric: + provider = SceneDataProvider( + SimpleNamespace(geometry_timestamp=0, get_geometry_batches=lambda _: [(destination, {})]) ) - - -def test_get_points_copies_unpadded_entity_slices(): - backend = _PointsBackend() - provider = SceneDataProvider(backend) - output = SceneDataFormat.Points() - output.points = wp.empty(5, dtype=wp.vec3f) - mapping = provider.create_geometry_mapping( - ["/World/envs/env_1/A", "/World/envs/env_0/A"], - [0, 3], + native = SceneDataFormat.FabricPoints() + launch.reset_mock() + assert provider.get_geometry_points(output=native) is native + assert native.points is destination.points + launch.assert_not_called() + + +def test_cable_endpoint_conversion_composes_shape_poses_and_averages_joints(): + """Two differently oriented capsules produce the two ends and averaged connecting vertex.""" + source = SceneDataFormat.CapsuleEndpoints() + sine = np.sqrt(0.5) + source.transforms = wp.array( + [[2, 3, 3, sine, 0, 0, sine], [1, 2, 3, 0, sine, 0, sine]], dtype=wp.transformf, device="cpu" ) - assert provider.get_points(output, mapping=mapping, allow_passthrough=False) - - copied = output.points.numpy() - # Backend order is env_0 (2 pts) then env_1 (3 pts). Mapping writes env_1 to - # offset 0 and env_0 to offset 3. - assert np.allclose(copied[0:3, 0], [2.0, 0.0, 1.0]) - assert np.allclose(copied[3:5, 0], [0.0, 1.0]) - - -def test_get_points_clamps_copy_to_destination_capacity(caplog): - """Oversized backend entity counts must not overflow the consumer buffer.""" - backend = _PointsBackend() - provider = SceneDataProvider(backend) - output = SceneDataFormat.Points() - # Second backend entity has 3 points; destination only has room for 2. - output.points = wp.zeros(2, dtype=wp.vec3f) - mapping = wp.array([-1, 0], dtype=wp.int32) - - with caplog.at_level("WARNING"): - assert provider.get_points(output, mapping=mapping, allow_passthrough=False) - - copied = output.points.numpy() - assert np.allclose(copied[:, 0], [2.0, 0.0]) - assert any("Clamping geometry point copy" in record.message for record in caplog.records) - - -def test_get_points_clamps_copy_to_next_destination_slot(caplog): - """Backend strides larger than shadow slots must not bleed into the next entity.""" - # Backend: two entities with 3 points each (flat src stride 3). - # Shadow mapping: dest slots of size 2 at offsets 0 and 2 (flat dest size 4). - backend = _PointsBackend( - points=np.array([[float(i), 0.0, 0.0] for i in range(6)], dtype=np.float32), - geometry_counts=[3, 3], + source.shape_body = wp.array([1, 0], dtype=wp.int32, device="cpu") + source.shape_transform = wp.array( + [[0, 0, 1, 0, 0, 0, 1], [0, 0, 0.5, 0, 0, 0, 1]], dtype=wp.transformf, device="cpu" ) - provider = SceneDataProvider(backend) - output = SceneDataFormat.Points() - output.points = wp.zeros(4, dtype=wp.vec3f) - mapping = wp.array([0, 2], dtype=wp.int32) - - with caplog.at_level("WARNING"): - assert provider.get_points(output, mapping=mapping, allow_passthrough=False) - - copied = output.points.numpy() - # Each shadow slot receives only the first 2 points of its backend entity. - assert np.allclose(copied[:, 0], [0.0, 1.0, 3.0, 4.0]) - assert any("Clamping geometry point copy" in record.message for record in caplog.records) + source.shape_scale = wp.array([[0.1, 1, 0.1], [0.1, 2, 0.1]], dtype=wp.vec3f, device="cpu") + source.endpoints = wp.array([[0, -1, 0, -1], [0, 1, 1, -1], [1, 1, 1, 1]], dtype=wp.vec4i, device="cpu") + backend = SimpleNamespace(geometry_timestamp=0, get_geometry_batches=lambda _: [(source, {"/Cable/curve": (0, 3)})]) + points = SceneDataProvider(backend).get_geometry_points()["/Cable/curve"] + np.testing.assert_allclose(points.numpy(), [[1, 2, 3], [2.5, 3.25, 3], [2, 0.5, 3]], atol=1e-6) diff --git a/source/isaaclab/test/sim/test_newton_manager_visualization_state.py b/source/isaaclab/test/sim/test_newton_manager_visualization_state.py index 9b5be2fa473a..bfff638c8f60 100644 --- a/source/isaaclab/test/sim/test_newton_manager_visualization_state.py +++ b/source/isaaclab/test/sim/test_newton_manager_visualization_state.py @@ -14,18 +14,9 @@ import pytest from isaaclab.cloner import ClonePlan -from isaaclab.scene_data.deformable_discovery import discover_deformables_on_stage pytestmark = pytest.mark.integration -_SINGLE_ENV_PLAN = ClonePlan( - sources=("/World/envs/env_0",), - destinations=("/World/envs/env_{}",), - clone_mask=np.ones((1, 1), dtype=np.bool_), - env_ids=np.arange(1), - positions=np.zeros((1, 3), dtype=np.float32), -) - def _reset_newton_manager_state(): from isaaclab_newton.physics import NewtonManager @@ -41,152 +32,6 @@ def _add_api_schemas(prim, schemas: list[str]) -> None: prim.SetMetadata("apiSchemas", api_schemas) -def _make_points_backend(points, geometry_paths: list[str], geometry_counts: list[int]): - """Build a minimal SceneData backend that exposes flattened geometry points.""" - import warp as wp - - from isaaclab.scene_data.scene_data_backend import SceneDataBackend, SceneDataFormat - - class _PointsBackend(SceneDataBackend): - def __init__(self): - self._points = wp.array(points, dtype=wp.vec3f, device="cpu") - self._points_data = SceneDataFormat.Points() - self._points_data.points = self._points - self._geometry_paths = list(geometry_paths) - self._geometry_counts = list(geometry_counts) - - @property - def transforms(self) -> SceneDataFormat.Transform: - return SceneDataFormat.Transform() - - @property - def transform_count(self) -> int: - return 0 - - @property - def transform_paths(self) -> list[str]: - return [] - - @property - def points(self) -> SceneDataFormat.Points: - return self._points_data - - @property - def point_count(self) -> int: - return int(self._points.shape[0]) - - @property - def geometry_paths(self) -> list[str]: - return self._geometry_paths - - @property - def geometry_counts(self) -> list[int]: - return self._geometry_counts - - return _PointsBackend() - - -def _make_shadow_entity( - root_path: str, - *, - sim_particle_offset: int = 0, - sim_particle_count: int, - vis_particle_offset: int | None = None, - vis_particle_count: int | None = None, - volume_vis_remap=None, -): - from isaaclab_newton.physics.visualization_deformables import ShadowDeformableEntity - - if vis_particle_offset is None: - vis_particle_offset = sim_particle_offset - if vis_particle_count is None: - vis_particle_count = sim_particle_count - return ShadowDeformableEntity( - root_path=root_path, - sim_particle_offset=sim_particle_offset, - sim_particle_count=sim_particle_count, - vis_particle_offset=vis_particle_offset, - vis_particle_count=vis_particle_count, - volume_vis_remap=volume_vis_remap, - ) - - -def _prepare_physx_shadow_sync(monkeypatch, provider, *, model, state_0, entities, sim_particle_count: int): - """Wire NewtonManager for a PhysX-backed visualization particle sync test.""" - import warp as wp - from isaaclab_newton.physics import NewtonManager - - _reset_newton_manager_state() - monkeypatch.setattr(NewtonManager, "_backend_is_newton", classmethod(lambda cls, scene_data_provider=None: False)) - monkeypatch.setattr(NewtonManager, "backend", SimpleNamespace(model=model, state_0=state_0)) - NewtonManager._shadow_deformable_entities = list(entities) - if sim_particle_count > 0: - NewtonManager._sim_particle_q = wp.zeros(sim_particle_count, dtype=wp.vec3f, device="cpu") - return NewtonManager - - -class _FakeShadowBuilder: - """ModelBuilder stub for :func:`add_shadow_deformables_to_builder` tests.""" - - def __init__( - self, - *, - particle_count: int = 0, - cloth_delta: int = 1, - soft_delta: int = 4, - reject_soft: bool = False, - ): - self.particle_count = particle_count - self.cloth_calls = 0 - self.soft_calls = 0 - self._cloth_delta = cloth_delta - self._soft_delta = soft_delta - self._reject_soft = reject_soft - self._captured: dict = {} - - def add_cloth_mesh(self, **kwargs): - self.cloth_calls += 1 - self.particle_count += self._cloth_delta - self._captured.update(kwargs) - - def add_soft_mesh(self, **kwargs): - if self._reject_soft: - raise AssertionError("surface cloth should not call add_soft_mesh") - self.soft_calls += 1 - self.particle_count += self._soft_delta - self._captured.update(kwargs) - - @property - def captured(self) -> dict: - return self._captured - - -def _make_volume_soft_stage(*, env_ids: tuple[int, ...] = (0,), root_name: str = "Soft"): - """Author a volume deformable with tet sim mesh + single-vertex visual mesh.""" - from pxr import Gf, Usd, UsdGeom - - stage = Usd.Stage.CreateInMemory() - for env_id in env_ids: - UsdGeom.Xform.Define(stage, f"/World/envs/env_{env_id}") - root = UsdGeom.Xform.Define(stage, f"/World/envs/env_{env_id}/{root_name}").GetPrim() - _add_api_schemas(root, ["OmniPhysicsDeformableBodyAPI"]) - tet = UsdGeom.TetMesh.Define(stage, f"/World/envs/env_{env_id}/{root_name}/simulation") - _add_api_schemas(tet.GetPrim(), ["OmniPhysicsVolumeDeformableSimAPI"]) - points = [ - Gf.Vec3f(0.0, 0.0, 0.0), - Gf.Vec3f(1.0, 0.0, 0.0), - Gf.Vec3f(0.0, 1.0, 0.0), - Gf.Vec3f(0.0, 0.0, 1.0), - ] - tet.CreatePointsAttr(points) - tet.CreateTetVertexIndicesAttr([Gf.Vec4i(0, 1, 2, 3)]) - vis = UsdGeom.Mesh.Define(stage, f"/World/envs/env_{env_id}/{root_name}/visual") - vis.CreatePointsAttr([Gf.Vec3f(0.25, 0.25, 0.25)]) - vis.CreateFaceVertexCountsAttr([3]) - vis.CreateFaceVertexIndicesAttr([0, 0, 0]) - return stage - - def _make_surface_cloth_stage(path: str = "/World/envs/env_0/Cloth"): """Author a surface deformable mesh prim at ``path``.""" from pxr import Gf, Usd, UsdGeom @@ -277,7 +122,6 @@ class ForeignPhysicsManager(PhysicsManager): transforms_version=0, transform_paths=body_paths, transform_count=body_count, - point_count=0, ) ) monkeypatch.setattr(SimulationContext, "_instance", sim) @@ -445,7 +289,6 @@ def test_update_visualization_state_shares_sdp_transforms(monkeypatch, layout): transforms_version=0, transform_paths=body_paths, transform_count=len(body_paths), - point_count=0, ) ) monkeypatch.setattr(SceneDataProvider, "usd_stage", property(lambda self: None)) @@ -480,255 +323,42 @@ def test_update_visualization_state_shares_sdp_transforms(monkeypatch, layout): ) -def test_update_visualization_state_syncs_shadow_particle_q(monkeypatch): - """PhysX/OVPhysX shadow sync copies backend points into ``state.particle_q``. - - Uses a real :class:`~isaaclab.scene_data.SceneDataProvider` with an identity - geometry mapping (``None``). Passthrough must not rebind away from the shadow - ``particle_q`` buffer — that left OVRTX rendering rest-pose cloth under OVPhysX. - """ +def test_update_visualization_state_writes_final_geometry_once_per_timestamp(monkeypatch): + """SDP writes directly into native render slots, preserving gaps and the destination allocation.""" import warp as wp + from isaaclab_newton.physics import NewtonManager - from isaaclab.scene_data.scene_data_provider import SceneDataProvider - - cloth_path = "/World/envs/env_0/Cloth" - provider = SceneDataProvider( - _make_points_backend( - [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], - [cloth_path], - [2], - ) - ) - - particle_q = wp.zeros(2, dtype=wp.vec3f, device="cpu") - NewtonManager = _prepare_physx_shadow_sync( - monkeypatch, - provider, - model=SimpleNamespace(body_label=["/World/envs/env_0/Robot"]), - state_0=SimpleNamespace(body_q=None, particle_q=particle_q), - entities=[_make_shadow_entity(cloth_path, sim_particle_count=2)], - sim_particle_count=2, - ) - - NewtonManager.update_visualization_state(provider) - - # Shadow buffer must remain the same object and receive a copy of live points. - assert NewtonManager.backend.state_0.particle_q is particle_q - copied = particle_q.numpy() - assert copied[0].tolist() == [1.0, 2.0, 3.0] - assert copied[1].tolist() == [4.0, 5.0, 6.0] - - -def test_update_visualization_state_remaps_volume_vis_positions(monkeypatch): - """Volume shadow sync barycentrically remaps sim nodes into vis-sized render slots.""" - import numpy as np - import warp as wp - - from isaaclab.scene_data.deformable_vis_remap import build_volume_vis_barycentric_remap - from isaaclab.scene_data.scene_data_provider import SceneDataProvider - - soft_path = "/World/envs/env_0/Soft" - remap = build_volume_vis_barycentric_remap( - np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], dtype=np.float32), - np.array([0, 1, 2, 3], dtype=np.int32), - np.array([[0.25, 0.25, 0.25]], dtype=np.float32), - ) - assert remap is not None - - provider = SceneDataProvider( - _make_points_backend( - [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], - [soft_path], - [4], - ) - ) - - particle_q = wp.zeros(1, dtype=wp.vec3f, device="cpu") - NewtonManager = _prepare_physx_shadow_sync( - monkeypatch, - provider, - model=SimpleNamespace(body_label=["/World/envs/env_0/Robot"]), - state_0=SimpleNamespace(body_q=None, particle_q=particle_q), - entities=[ - _make_shadow_entity( - soft_path, - sim_particle_count=4, - vis_particle_count=1, - volume_vis_remap=remap, - ) - ], - sim_particle_count=4, - ) - - NewtonManager.update_visualization_state(provider) - - np.testing.assert_allclose(particle_q.numpy()[0], [0.25, 0.25, 0.25], atol=1e-4) - - -def test_sync_skips_unmapped_deformable_rest_pose(monkeypatch): - """Shadow entities with no SceneData mapping must keep rest pose, not copy zeros.""" - import numpy as np - import warp as wp - - from isaaclab.scene_data.scene_data_provider import SceneDataProvider - - provider = SceneDataProvider( - _make_points_backend( - [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], - ["/World/envs/env_0/ClothA"], - [2], - ) - ) - - particle_q = wp.array( - [ - wp.vec3(0.0, 0.0, 0.0), - wp.vec3(0.0, 0.0, 0.0), - wp.vec3(9.0, 9.0, 9.0), - wp.vec3(8.0, 8.0, 8.0), - ], - dtype=wp.vec3f, - device="cpu", - ) - NewtonManager = _prepare_physx_shadow_sync( - monkeypatch, - provider, - model=SimpleNamespace(body_label=["/World/envs/env_0/Robot"]), - state_0=SimpleNamespace(body_q=None, particle_q=particle_q), - entities=[ - _make_shadow_entity("/World/envs/env_0/ClothA", sim_particle_count=2), - _make_shadow_entity( - "/World/envs/env_0/ClothB", - sim_particle_offset=2, - sim_particle_count=2, - vis_particle_offset=2, - ), - ], - sim_particle_count=4, - ) - - NewtonManager.update_visualization_state(provider) - - copied = particle_q.numpy() - assert copied[0].tolist() == [1.0, 2.0, 3.0] - assert copied[1].tolist() == [4.0, 5.0, 6.0] - # Unmapped ClothB must keep rest pose rather than receiving zeroed sim slices. - np.testing.assert_allclose(copied[2], [9.0, 9.0, 9.0], atol=1e-6) - np.testing.assert_allclose(copied[3], [8.0, 8.0, 8.0], atol=1e-6) - - -def test_sync_skips_mismatched_volume_without_remap(monkeypatch): - """Count-mismatched volume entities without a remap must not over-read sim particles.""" - import numpy as np - import warp as wp - - from isaaclab.scene_data.scene_data_provider import SceneDataProvider - - soft_path = "/World/envs/env_0/Soft" - provider = SceneDataProvider( - _make_points_backend( - [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [1.0, 1.0, 1.0]], - [soft_path], - [4], - ) - ) - - # Vis-sized render buffer initialized to a sentinel rest pose. - particle_q = wp.array([wp.vec3(9.0, 9.0, 9.0)], dtype=wp.vec3f, device="cpu") - NewtonManager = _prepare_physx_shadow_sync( - monkeypatch, - provider, - model=SimpleNamespace(body_label=["/World/envs/env_0/Robot"]), - state_0=SimpleNamespace(body_q=None, particle_q=particle_q), - entities=[ - _make_shadow_entity( - soft_path, - sim_particle_count=4, - vis_particle_count=1, - volume_vis_remap=None, - ) - ], - sim_particle_count=4, - ) - - NewtonManager.update_visualization_state(provider) - - np.testing.assert_allclose(particle_q.numpy()[0], [9.0, 9.0, 9.0], atol=1e-6) - - -def test_shadow_deformable_volume_remap_registers_ovrtx_with_vis_mesh(monkeypatch): - """Volume bodies with barycentric remap use visual mesh slots and register for OVRTX.""" - from isaaclab_newton.physics.visualization_deformables import add_shadow_deformables_to_builder - - stage = _make_volume_soft_stage() - builder = _FakeShadowBuilder(cloth_delta=1, soft_delta=4) - entries = discover_deformables_on_stage(stage, root_paths=_SINGLE_ENV_PLAN.sources) - flat_entities, registry_groups = add_shadow_deformables_to_builder(builder, stage, entries, _SINGLE_ENV_PLAN, (0,)) - - assert builder.cloth_calls == 1 - assert builder.soft_calls == 0 - assert len(flat_entities) == 1 - assert flat_entities[0].sim_particle_count == 4 - assert flat_entities[0].vis_particle_count == 1 - assert flat_entities[0].volume_vis_remap is not None - assert len(registry_groups) == 1 - assert registry_groups[0].register_usd_vis_point_bindings is True - assert registry_groups[0].particles_per_body == 1 - - -def test_shadow_deformable_volume_remap_failure_falls_back_to_soft_mesh(monkeypatch): - """Failed volume remap must allocate sim tet slots, not mismatched vis cloth slots.""" - from isaaclab_newton.physics import visualization_deformables as vd - - stage = _make_volume_soft_stage() - monkeypatch.setattr(vd, "_build_volume_vis_remap", lambda entry, device: None) - builder = _FakeShadowBuilder(cloth_delta=1, soft_delta=4) - entries = discover_deformables_on_stage(stage, root_paths=_SINGLE_ENV_PLAN.sources) - flat_entities, registry_groups = vd.add_shadow_deformables_to_builder( - builder, stage, entries, _SINGLE_ENV_PLAN, (0,) - ) - - assert builder.cloth_calls == 0 - assert builder.soft_calls == 1 - assert len(flat_entities) == 1 - assert flat_entities[0].volume_vis_remap is None - assert flat_entities[0].sim_particle_count == 4 - assert flat_entities[0].vis_particle_count == 4 - assert registry_groups[0].register_usd_vis_point_bindings is False - - -def test_shadow_deformable_placement_uses_parent_pose_not_root(monkeypatch): - """Parent-frame baked verts must be placed with the parent world pose.""" - from isaaclab_newton.physics import visualization_deformables as vd - - from pxr import Gf, Usd, UsdGeom - - from isaaclab.scene_data.deformable_discovery import DeformableStageEntry + from isaaclab.scene_data import SceneDataFormat, SceneDataProvider - stage = Usd.Stage.CreateInMemory() - parent = UsdGeom.Xform.Define(stage, "/World/envs/env_0") - parent.AddTranslateOp().Set(Gf.Vec3d(10.0, 0.0, 0.0)) - root = UsdGeom.Xform.Define(stage, "/World/envs/env_0/ClothRoot") - root.AddTranslateOp().Set(Gf.Vec3d(2.0, 0.0, 0.0)) - UsdGeom.Mesh.Define(stage, "/World/envs/env_0/ClothRoot/mesh") - - entry = DeformableStageEntry( - root_path="/World/envs/env_0/ClothRoot", - sim_mesh_path="/World/envs/env_0/ClothRoot/mesh", - vis_mesh_path="/World/envs/env_0/ClothRoot/mesh", - deformable_type="surface", - vertex_count=3, - vis_vertex_count=3, - vertices=[(0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0, 1.0, 0.0)], - indices=[0, 1, 2], - vis_vertices=[(0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0, 1.0, 0.0)], - vis_indices=[0, 1, 2], + _reset_newton_manager_state() + data = SceneDataFormat.Points() + data.points = wp.array(np.arange(18, dtype=np.float32).reshape(6, 3), dtype=wp.vec3f, device="cpu") + ranges = {"/Cells/Cloth/mesh": (1, 2), "/Shared/Particles": (4, 1)} + backend = SimpleNamespace(geometry_timestamp=0, get_geometry_batches=lambda _format: [(data, ranges)]) + provider = SceneDataProvider(backend) + particle_q = wp.zeros(6, dtype=wp.vec3f, device="cpu") + monkeypatch.setattr( + NewtonManager, + "backend", + SimpleNamespace(model=SimpleNamespace(), state_0=SimpleNamespace(body_q=None, particle_q=particle_q)), ) - builder = _FakeShadowBuilder(cloth_delta=3, reject_soft=True) - vd.add_shadow_deformables_to_builder(builder, stage, [entry], _SINGLE_ENV_PLAN, (0,)) + monkeypatch.setattr(NewtonManager, "_geometry_offsets", {"/Shared/Particles": 1, "/Cells/Cloth/mesh": 3}) + monkeypatch.setattr(NewtonManager, "_mark_sensor_state_dirty", Mock()) + monkeypatch.setattr(wp, "launch", Mock(wraps=wp.launch)) - # Parent world translation is (10,0,0); root's extra (2,0,0) must not be used as placement. - assert tuple(float(v) for v in builder.captured["pos"]) == (10.0, 0.0, 0.0) + for timestamp in (0, 1): + if timestamp: + data.points = wp.array(data.points.numpy() + 10.0, dtype=wp.vec3f, device="cpu") + backend.geometry_timestamp += 1 + NewtonManager.update_visualization_state(provider) + NewtonManager.update_visualization_state(provider) + expected = np.zeros((6, 3), dtype=np.float32) + expected[1] = data.points.numpy()[4] + expected[3:5] = data.points.numpy()[1:3] + assert NewtonManager.backend.state_0.particle_q is particle_q + np.testing.assert_array_equal(particle_q.numpy(), expected) + assert wp.launch.call_count == timestamp + 1 + assert NewtonManager._mark_sensor_state_dirty.call_count == timestamp + 1 @pytest.mark.parametrize("global_path", ["/World/Assets/Cloth", "/World/Assets"]) @@ -771,14 +401,14 @@ def import_usd(builder, *args, **kwargs): builder, _, _ = NewtonReplicateContext(sim).replicate(clone_plan) callback = sim.physics_manager.register_callback.call_args.args[0] - shadow_entities, registry_groups = callback.args[1] + geometry = callback.args[1] assert [kwargs["root_path"] for kwargs in usd_imports] == [global_path, sources[0]] assert {"/World/Assets/Cloth", *sources} <= set(usd_imports[0]["ignore_paths"]) - assert {entity.root_path for entity in shadow_entities} == { + assert set(geometry) == { "/World/Assets/Cloth", "/Copies/env_0/Selected/Cloth", "/Copies/env_1/Selected/Cloth", } - assert {group.prim_path for group in registry_groups} == {"/World/Assets/Cloth", "/Copies/env_[^/]+/Selected/Cloth"} - assert builder.particle_count == sum(entity.vis_particle_count for entity in shadow_entities) + assert sorted(geometry.values()) == [0, 3, 6] + assert builder.particle_count == 9 diff --git a/source/isaaclab_contrib/changelog.d/plan-owned-deformable-bindings.rst b/source/isaaclab_contrib/changelog.d/plan-owned-deformable-bindings.rst new file mode 100644 index 000000000000..c43f9798df01 --- /dev/null +++ b/source/isaaclab_contrib/changelog.d/plan-owned-deformable-bindings.rst @@ -0,0 +1,6 @@ +Fixed +^^^^^ + +* Published deformable geometry through SDP using clone-plan paths and native particle ranges, + preserving nonconsecutive environment IDs, custom paths, and position writes between renders. + Removed the internal Fabric-sync helper; rendering consumers now bind through SDP. diff --git a/source/isaaclab_contrib/isaaclab_contrib/deformable/deformable_object.py b/source/isaaclab_contrib/isaaclab_contrib/deformable/deformable_object.py index 05f6725e344b..8a4d272eddec 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/deformable/deformable_object.py +++ b/source/isaaclab_contrib/isaaclab_contrib/deformable/deformable_object.py @@ -19,6 +19,7 @@ from isaaclab.assets.deformable_object.base_deformable_object import BaseDeformableObject from isaaclab.markers import VisualizationMarkers from isaaclab.physics import PhysicsEvent +from isaaclab.scene_data.deformable_vis_remap import VolumeVisRemap, build_volume_vis_barycentric_remap from isaaclab.utils.warp import ProxyArray from .deformable_object_data import DeformableObjectData @@ -68,6 +69,8 @@ class DeformableRegistryEntry: # Filled by the Newton clone context: particle_offsets: list[int] = field(default_factory=list) particles_per_body: int = 0 + volume_vis_remap: VolumeVisRemap | None = None + """Prototype interpolation tables when visual vertices differ from native simulation nodes.""" if TYPE_CHECKING: @@ -181,63 +184,6 @@ def add_registered_deformables_to_builder( add_deformable_entry_to_builder(builder, entry, world_idx, env_position, env_rotation) -def setup_registered_deformable_fabric_sync(manager_cls: type[SimulationManager]) -> None: - """Bind registered deformable visual meshes to their Newton particle slices in Fabric.""" - if manager_cls._clone_physics_only or not manager_cls._deformable_registry: - return - - import re - - import usdrt - - from isaaclab.sim.utils.stage import get_current_stage - - if SimulationManager._usdrt_stage is None: - SimulationManager._usdrt_stage = get_current_stage(fabric=True) - fabric_stage = SimulationManager._usdrt_stage - if fabric_stage is None: - logger.warning("[setup_fabric_particle_sync] Fabric stage is unavailable.") - return - - stage = get_current_stage() - synced_any = False - for entry in manager_cls._deformable_registry: - for inst_idx, offset in enumerate(entry.particle_offsets): - resolved_vis = re.sub(r"(?<=[Ee]nv_)(?:\[\^/\][*+]|\.\*)", str(inst_idx), entry.vis_mesh_prim_path) - # any wildcard left over stands for the instance too, in whichever way it is spelled - resolved_vis = re.sub(r"\[\^/\][*+]|\.\*", str(inst_idx), resolved_vis) - vis_prim = stage.GetPrimAtPath(resolved_vis) - - if not vis_prim or not vis_prim.IsValid(): - logger.warning("[setup_fabric_particle_sync] vis prim not found at %s", resolved_vis) - continue - - fab_prim = fabric_stage.GetPrimAtPath(vis_prim.GetPath().pathString) - if not fab_prim or not fab_prim.IsValid(): - logger.warning("[setup_fabric_particle_sync] Fabric prim not found at %s", resolved_vis) - continue - - offset_attr = fab_prim.CreateAttribute( - SimulationManager._newton_particle_offset_attr, usdrt.Sdf.ValueTypeNames.UInt, True - ) - count_attr = fab_prim.CreateAttribute( - SimulationManager._newton_particle_count_attr, usdrt.Sdf.ValueTypeNames.UInt, True - ) - if not offset_attr.IsValid() or not count_attr.IsValid(): - logger.warning( - "[setup_fabric_particle_sync] Fabric particle attributes not created at %s", resolved_vis - ) - continue - - offset_attr.Set(int(offset)) - count_attr.Set(int(entry.particles_per_body)) - synced_any = True - - if synced_any: - manager_cls._mark_particles_dirty() - manager_cls.sync_particles_to_usd() - - def install_deformable_builder_hooks() -> None: """Install deformable builder hooks without removing hooks owned by other extensions.""" SimulationManager._deformable_registry = [] @@ -419,6 +365,7 @@ def write_nodal_pos_to_sim_index( device=self.device, ) + SimulationManager._mark_particles_dirty() self._invalidate_nodal_pos_cache() def write_nodal_velocity_to_sim_index( @@ -533,6 +480,7 @@ def write_nodal_state_to_sim_mask( device=self.device, ) + SimulationManager._mark_particles_dirty() self._invalidate_nodal_state_cache() def write_nodal_pos_to_sim_mask( @@ -564,6 +512,7 @@ def write_nodal_pos_to_sim_mask( device=self.device, ) + SimulationManager._mark_particles_dirty() self._invalidate_nodal_pos_cache() def write_nodal_velocity_to_sim_mask( @@ -763,12 +712,12 @@ def _is_sim_mesh(prim) -> bool: # Bake the template prim's xform directly into the vertex positions. xform_cache = UsdGeom.XformCache() - mesh_to_parent_frame = ( - xform_cache.GetLocalToWorldTransform(mesh_prim) - * xform_cache.GetLocalToWorldTransform(template_prim.GetParent()).GetInverse() - ) - def _bake_points(raw_pts) -> list[wp.vec3]: + def _bake_points(raw_pts, prim) -> list[wp.vec3]: + mesh_to_parent_frame = ( + xform_cache.GetLocalToWorldTransform(prim) + * xform_cache.GetLocalToWorldTransform(template_prim.GetParent()).GetInverse() + ) out = [] for p in raw_pts: q = mesh_to_parent_frame.Transform(Gf.Vec3d(float(p[0]), float(p[1]), float(p[2]))) @@ -778,7 +727,7 @@ def _bake_points(raw_pts) -> list[wp.vec3]: if deformable_type == "volume": tet_mesh = UsdGeom.TetMesh(mesh_prim) pts = tet_mesh.GetPointsAttr().Get() - vertices = _bake_points(pts) + vertices = _bake_points(pts, mesh_prim) raw_tet_indices = tet_mesh.GetTetVertexIndicesAttr().Get() indices = [] for vec4i in raw_tet_indices: @@ -787,10 +736,24 @@ def _bake_points(raw_pts) -> list[wp.vec3]: else: # surface usd_mesh = UsdGeom.Mesh(mesh_prim) pts = usd_mesh.GetPointsAttr().Get() - vertices = _bake_points(pts) + vertices = _bake_points(pts, mesh_prim) indices = list(usd_mesh.GetFaceVertexIndicesAttr().Get()) logger.info("Registered UsdGeom.Mesh: %d vertices.", len(pts)) + volume_vis_remap = None + if vis_mesh_prim != mesh_prim: + vis_points = UsdGeom.PointBased(vis_mesh_prim).GetPointsAttr().Get() + vis_vertices = np.asarray(_bake_points(vis_points, vis_mesh_prim), dtype=np.float32) + sim_vertices = np.asarray(vertices, dtype=np.float32) + if not np.array_equal(sim_vertices, vis_vertices): + if deformable_type != "volume": + raise ValueError(f"Surface visual topology differs from its native nodes: {template_prim_path}") + volume_vis_remap = build_volume_vis_barycentric_remap( + sim_vertices, np.asarray(indices, dtype=np.int32), vis_vertices + ) + if volume_vis_remap is None: + raise ValueError(f"Cannot bind visual vertices to deformable tetrahedra: {template_prim_path}") + # init_pos/init_rot are already baked into the vertices by the Xform # transform above. Setting them to identity prevents add_cloth_mesh/add_soft_mesh # from applying them a second time. @@ -842,6 +805,7 @@ def _get_material_attr(name: str, default): vertices=vertices, indices=indices, deformable_type=deformable_type, + volume_vis_remap=volume_vis_remap, init_pos=init_pos, init_rot=init_rot, density=density, diff --git a/source/isaaclab_contrib/test/deformable/test_deformable_builder_hooks.py b/source/isaaclab_contrib/test/deformable/test_deformable_builder_hooks.py index a0d4800c95af..0929dc6a569e 100644 --- a/source/isaaclab_contrib/test/deformable/test_deformable_builder_hooks.py +++ b/source/isaaclab_contrib/test/deformable/test_deformable_builder_hooks.py @@ -4,19 +4,21 @@ # SPDX-License-Identifier: BSD-3-Clause import math -import sys from types import SimpleNamespace +from unittest.mock import Mock +import numpy as np import pytest import warp as wp from isaaclab_newton.physics import NewtonManager from isaaclab_newton.sim.spawners.materials import NewtonDeformableMaterialCfg +from isaaclab.cloner import ClonePlan + from isaaclab_contrib.deformable import DeformableObject from isaaclab_contrib.deformable.deformable_object import ( DeformableRegistryEntry, add_deformable_entry_to_builder, - setup_registered_deformable_fabric_sync, ) @@ -30,31 +32,6 @@ def add_cloth_mesh(self, **kwargs) -> None: self.particle_count += len(kwargs["vertices"]) -class _FakePath: - def __init__(self, path: str): - self.pathString = path - - -class _FakePrim: - def __init__(self, path: str, *, valid: bool = True): - self._path = path - self._valid = valid - - def IsValid(self) -> bool: - return self._valid - - def GetPath(self) -> _FakePath: - return _FakePath(self._path) - - -class _FakeStage: - def __init__(self, prims: dict[str, _FakePrim]): - self._prims = prims - - def GetPrimAtPath(self, path: str) -> _FakePrim: - return self._prims.get(path, _FakePrim(path, valid=False)) - - def _make_surface_entry() -> DeformableRegistryEntry: half_sqrt = math.sqrt(0.5) return DeformableRegistryEntry( @@ -73,25 +50,8 @@ def _make_surface_entry() -> DeformableRegistryEntry: ) -def _vec3_as_tuple(value) -> tuple[float, float, float]: - return (float(value[0]), float(value[1]), float(value[2])) - - -def test_deformable_package_exports_public_symbols(): - """Test that deformable symbols are exported from the package root.""" - assert DeformableObject.__name__ == "DeformableObject" - - -def test_newton_material_defaults_match_registry_defaults(): - """Test that Newton material cfg defaults match the deformable registry defaults.""" - material_cfg = NewtonDeformableMaterialCfg() - - assert material_cfg.density == DeformableRegistryEntry.density - assert material_cfg.particle_radius == DeformableRegistryEntry.particle_radius - - -def test_builder_hook_applies_env_quaternion_to_deformable_entry(): - """Test that deformable builder placement honors the environment quaternion.""" +def test_builder_hook_preserves_placement_materials_and_rebuild_offsets(): + """Clone placement preserves material defaults and rebuilding replaces particle offsets.""" entry = _make_surface_entry() builder = _FakeBuilder() half_sqrt = math.sqrt(0.5) @@ -107,63 +67,84 @@ def test_builder_hook_applies_env_quaternion_to_deformable_entry(): mesh = builder.cloth_meshes[0] rotated_x_axis = wp.quat_rotate(mesh["rot"], wp.vec3(1.0, 0.0, 0.0)) - assert _vec3_as_tuple(mesh["pos"]) == pytest.approx((10.0, 21.0, 30.0)) - assert _vec3_as_tuple(rotated_x_axis) == pytest.approx((-1.0, 0.0, 0.0), abs=1e-6) - assert entry.particle_offsets == [0] - assert entry.particles_per_body == 3 - - -def test_builder_hook_resets_entry_offsets_on_first_environment(): - """Test that repeated model rebuilds do not accumulate stale particle offsets.""" - entry = _make_surface_entry() - builder = _FakeBuilder() + assert tuple(mesh["pos"]) == pytest.approx((10.0, 21.0, 30.0)) + assert tuple(rotated_x_axis) == pytest.approx((-1.0, 0.0, 0.0), abs=1e-6) + material = NewtonDeformableMaterialCfg() + assert mesh["density"] == material.density + assert mesh["particle_radius"] == material.particle_radius identity = [0.0, 0.0, 0.0, 1.0] - - add_deformable_entry_to_builder(builder, entry, 0, [0.0, 0.0, 0.0], identity) add_deformable_entry_to_builder(builder, entry, 1, [1.0, 0.0, 0.0], identity) - assert entry.particle_offsets == [0, 3] - rebuilt_builder = _FakeBuilder() - add_deformable_entry_to_builder(rebuilt_builder, entry, 0, [0.0, 0.0, 0.0], identity) - + add_deformable_entry_to_builder(_FakeBuilder(), entry, 0, [0.0, 0.0, 0.0], identity) assert entry.particle_offsets == [0] assert entry.particles_per_body == 3 -def test_fabric_particle_sync_skips_missing_fabric_prim(monkeypatch): - """Test that missing Fabric prims are skipped before attributes are authored.""" - entry = _make_surface_entry() - entry.particle_offsets = [7] - entry.particles_per_body = 3 - resolved_path = "/World/envs/env_0/cloth/mesh" - - class _FakeManager: - _clone_physics_only = False - _deformable_registry = [entry] - marked = False - synced = False - - @classmethod - def _mark_particles_dirty(cls): - cls.marked = True - - @classmethod - def sync_particles_to_usd(cls): - cls.synced = True - - usd_stage = _FakeStage({resolved_path: _FakePrim(resolved_path)}) - fabric_stage = _FakeStage({}) - - monkeypatch.setattr( - "isaaclab.sim.utils.stage.get_current_stage", lambda fabric=False: fabric_stage if fabric else usd_stage +def test_planned_geometry_aliases_surface_nodes_and_interpolates_volume_once(monkeypatch): + """Sparse planned instances publish exact visual paths without requiring cloned USD meshes.""" + from isaaclab_newton.physics.newton_manager import NewtonSceneDataBackend + + from pxr import Sdf, Usd, UsdGeom, UsdShade + + import isaaclab.sim.utils.queries as queries + from isaaclab.scene_data import SceneDataProvider + + import isaaclab_contrib.deformable.deformable_object as deformable + + stage = Usd.Stage.CreateInMemory() + root = UsdGeom.Xform.Define(stage, "/Scene/copy_0/Volume") + root.AddTranslateOp().Set((10, 20, 30)) + tet = UsdGeom.TetMesh.Define(stage, "/Scene/copy_0/Volume/sim") + tet.CreatePointsAttr([(0, 0, 0), (1, 0, 0), (0, 1, 0), (0, 0, 1)]) + tet.CreateTetVertexIndicesAttr([(0, 1, 2, 3)]) + UsdGeom.Xformable(tet).AddTranslateOp().Set((1, 0, 0)) + visual = UsdGeom.Mesh.Define(stage, "/Scene/copy_0/Volume/vis") + visual.CreatePointsAttr([(0.5, 0, 0), (0, 0.5, 0), (0, 0, 0.5)]) + visual.CreateFaceVertexCountsAttr([3]) + visual.CreateFaceVertexIndicesAttr([0, 1, 2]) + UsdGeom.Xformable(visual).AddTranslateOp().Set((1, 0, 0)) + material = UsdShade.Material.Define(stage, "/Material") + material.GetPrim().CreateAttribute("newton:density", Sdf.ValueTypeNames.Float).Set(1000.0) + UsdShade.MaterialBindingAPI.Apply(root.GetPrim()).Bind(material, materialPurpose="physics") + monkeypatch.setattr(queries, "get_current_stage", lambda: stage) + monkeypatch.setattr(NewtonManager, "_deformable_registry", []) + remap = Mock(wraps=deformable.build_volume_vis_barycentric_remap) + monkeypatch.setattr(deformable, "build_volume_vis_barycentric_remap", remap) + asset = SimpleNamespace( + cfg=SimpleNamespace( + prim_path="/Scene/copy_[^/]+/Volume", spawn=SimpleNamespace(spawn_path="/Scene/copy_0/Volume") + ) ) - monkeypatch.setattr(NewtonManager, "_usdrt_stage", fabric_stage) - monkeypatch.setitem( - sys.modules, "usdrt", SimpleNamespace(Sdf=SimpleNamespace(ValueTypeNames=SimpleNamespace(UInt=object()))) + entry = DeformableObject._register_deformable(asset) + entry.particle_offsets, entry.particles_per_body = [7, 19], 4 + surface = _make_surface_entry() + surface.prim_path = "/Scene/copy_[^/]+/cloth" + surface.vis_mesh_prim_path = surface.prim_path + "/mesh" + surface.particle_offsets, surface.particles_per_body = [31, 37], 3 + NewtonManager._deformable_registry.append(surface) + plan = ClonePlan( + sources=("/Scene/copy_0",), + destinations=("/Scene/copy_{}",), + clone_mask=np.array([[True, False, True]]), + env_ids=np.array([7, 12, 42]), + global_paths=("/Scene",), ) - - setup_registered_deformable_fabric_sync(_FakeManager) - - assert not _FakeManager.marked - assert not _FakeManager.synced + nodes = np.zeros((40, 3), dtype=np.float32) + nodes[7:11], nodes[19:23] = np.asarray(entry.vertices), np.asarray(entry.vertices) + [100, 0, 0] + state = SimpleNamespace(particle_q=wp.array(nodes, dtype=wp.vec3f, device="cpu"), body_q=None) + monkeypatch.setattr(NewtonManager, "_cable_bindings", {}) + monkeypatch.setattr(NewtonSceneDataBackend, "state", property(lambda self: state)) + backend = NewtonSceneDataBackend() + backend.initialize_geometry(plan) + stage.RemovePrim("/Scene") + points = SceneDataProvider(backend).get_geometry_points() + assert set(points) == {f"/Scene/copy_{index}/{mesh}" for index in (7, 42) for mesh in ("Volume/vis", "cloth/mesh")} + for index, offset in zip((7, 42), surface.particle_offsets, strict=True): + view = points[f"/Scene/copy_{index}/cloth/mesh"] + assert view.ptr == state.particle_q.ptr + offset * wp.types.type_size_in_bytes(wp.vec3f) + assert len(view) == surface.particles_per_body + expected = np.array([[11.5, 20, 30], [11, 20.5, 30], [11, 20, 30.5]]) + np.testing.assert_allclose(points["/Scene/copy_7/Volume/vis"].numpy(), expected) + np.testing.assert_allclose(points["/Scene/copy_42/Volume/vis"].numpy(), expected + [100, 0, 0]) + remap.assert_called_once() diff --git a/source/isaaclab_newton/changelog.d/plan-geometry.rst b/source/isaaclab_newton/changelog.d/plan-geometry.rst new file mode 100644 index 000000000000..ca22887e76f6 --- /dev/null +++ b/source/isaaclab_newton/changelog.d/plan-geometry.rst @@ -0,0 +1,7 @@ +Fixed +^^^^^ + +* Bound Newton rendering geometry from clone-plan prototypes and native ranges, preserving + heterogeneous deformables and authoring MPM point clouds before cloning. +* Published native particle and cable data through SDP, replacing Newton-owned Fabric writers and + intermediate deformable buffers. Removed internal USD geometry sync calls; renderers request SDP data. diff --git a/source/isaaclab_newton/isaaclab_newton/assets/mpm_object/mpm_object.py b/source/isaaclab_newton/isaaclab_newton/assets/mpm_object/mpm_object.py index 7d873c64c328..301c67b39417 100644 --- a/source/isaaclab_newton/isaaclab_newton/assets/mpm_object/mpm_object.py +++ b/source/isaaclab_newton/isaaclab_newton/assets/mpm_object/mpm_object.py @@ -15,11 +15,13 @@ import warp as wp from isaaclab.assets.deformable_object.base_deformable_object import BaseDeformableObject +from isaaclab.cloner import path as cloner_path +from isaaclab.cloner.query import iter_sources from isaaclab.physics import PhysicsEvent +from isaaclab.sim import SimulationContext from isaaclab.utils.warp import ProxyArray from isaaclab_newton.physics import NewtonManager as SimulationManager -from isaaclab_newton.sim.spawners.mpm import create_mpm_particle_visualization from isaaclab_newton.sim.spawners.mpm.mpm import _SIMULATION_POINTS_SUFFIX from .kernels import ( @@ -408,34 +410,28 @@ def _create_buffers(self): ) self._data.default_nodal_state_w = ProxyArray(default_state) self._data.default_particle_state_w = self._data.default_nodal_state_w - self._create_particle_visualization() + self._bind_particle_visualization() - def _create_particle_visualization(self) -> None: - """Create renderer-agnostic ``UsdGeom.Points`` prims for visible particles.""" - # TODO: Remove this duplicate render path once https://github.com/isaac-sim/IsaacLab/issues/7758 lands. + def _bind_particle_visualization(self) -> None: + """Bind the plan's pre-authored render clouds to native particle ranges.""" if not self.cfg.spawn.visible: return - first_offset = self._recorded_particle_offsets[0] - radii = ( - SimulationManager.get_model() - .particle_radius[first_offset : first_offset + self._particles_per_object] - .numpy() - ) - asset_prim_paths = _resolve_particle_asset_paths(self.cfg.prim_path, self._num_instances) - prim_paths = create_mpm_particle_visualization( - prim_paths=[f"{path}/Particles" for path in asset_prim_paths], - positions=self.data.particle_pos_w.warp.numpy(), - widths=2.0 * radii, - color=self.cfg.spawn.visual_color, - visual_material=self.cfg.spawn.visual_material, - ) - for env_idx, prim_path in enumerate(prim_paths): + plan = SimulationContext.instance().get_clone_plan() + source = self.cfg.spawn.spawn_path + asset_prim_paths = [ + cloner_path.rebase(source, root, template.format(env_id)) + for root, template, source_path, env_ids in iter_sources(plan, self.cfg.prim_path) + if source_path == source + for env_id in env_ids + ] + if not asset_prim_paths and any(cloner_path.under(source, root) for root in plan.global_paths): + asset_prim_paths.append(source) + for prim_path, offset in zip(asset_prim_paths, self._recorded_particle_offsets, strict=True): SimulationManager.register_particle_visual_prim( - prim_path, - particle_offset=self._recorded_particle_offsets[env_idx], + f"{prim_path}/Particles", + particle_offset=offset, particle_count=self._particles_per_object, - sync_frequency=self.cfg.spawn.visual_update_frequency, ) logger.info("MPM particle visualization initialized for: %s", self.cfg.prim_path) @@ -490,29 +486,3 @@ def _clear_callbacks(self) -> None: registry = SimulationManager._mpm_object_registry if self._registry_entry in registry: registry.remove(self._registry_entry) - - -def _resolve_particle_asset_paths(prim_path: str, num_instances: int) -> list[str]: - """Resolve one concrete MPM asset path per Newton world.""" - import isaaclab.sim as sim_utils # noqa: PLC0415 - from isaaclab.cloner import path as cloner_path # noqa: PLC0415 - from isaaclab.cloner.query import iter_sources # noqa: PLC0415 - - sim = sim_utils.SimulationContext.instance() - clone_plan = sim.get_clone_plan() if sim is not None else None - if clone_plan is not None and clone_plan.env_ids is not None: - paths_by_env_id: dict[int, str] = {} - for _, template, _, env_ids in iter_sources(clone_plan, prim_path): - matched = cloner_path.match(prim_path, template) - if matched is not None: - paths_by_env_id.update((env_id, f"{template.format(env_id)}{matched.suffix}") for env_id in env_ids) - env_ids = [int(env_id) for env_id in clone_plan.env_ids] - if len(env_ids) == num_instances and all(env_id in paths_by_env_id for env_id in env_ids): - return [paths_by_env_id[env_id] for env_id in env_ids] - - matched_paths = sim_utils.find_matching_prim_paths(prim_path) - if len(matched_paths) != num_instances: - raise RuntimeError( - f"Expected {num_instances} MPM asset prims matching '{prim_path}', found {len(matched_paths)}." - ) - return matched_paths diff --git a/source/isaaclab_newton/isaaclab_newton/cloner/newton_clone_utils.py b/source/isaaclab_newton/isaaclab_newton/cloner/newton_clone_utils.py index 3b3754e741e4..3e0fbb0b9f8f 100644 --- a/source/isaaclab_newton/isaaclab_newton/cloner/newton_clone_utils.py +++ b/source/isaaclab_newton/isaaclab_newton/cloner/newton_clone_utils.py @@ -175,6 +175,7 @@ def _build_source_builder( skip_mesh_approximation=skip_mesh_approximation, schema_resolvers=schema_resolvers, ignore_paths=ignore_paths, + return_deformable_results=True, ) _restore_visible_colliders_without_visual_shapes( builder, stage, import_result["path_shape_map"], load_visual_shapes diff --git a/source/isaaclab_newton/isaaclab_newton/cloner/replicate.py b/source/isaaclab_newton/isaaclab_newton/cloner/replicate.py index 87f9f3ebb820..ca32e5ddbace 100644 --- a/source/isaaclab_newton/isaaclab_newton/cloner/replicate.py +++ b/source/isaaclab_newton/isaaclab_newton/cloner/replicate.py @@ -7,7 +7,6 @@ import contextlib import copy -import re from collections.abc import Callable, Iterator, Sequence from functools import partial from typing import TYPE_CHECKING, Any @@ -16,11 +15,13 @@ import warp as wp from newton import ModelBuilder -from pxr import Usd +from pxr import Usd, UsdGeom from isaaclab.cloner import ClonePlan +from isaaclab.cloner.path import rebase +from isaaclab.cloner.query import iter_sources from isaaclab.physics import PhysicsEvent, PhysicsManager -from isaaclab.scene_data.deformable_discovery import discover_deformables_on_stage +from isaaclab.scene_data.deformable_discovery import deformable_prototypes, expand_deformable_entries from isaaclab.sim.utils.newton_model_utils import replace_newton_builder_shape_colors from isaaclab_newton.cloner.newton_clone_utils import ( @@ -132,19 +133,15 @@ def _replicate_newton( if simulation: global_ignore_paths = manager_cls._inject_terrain_heightfields(stage, builder, root_paths=import_paths) # Native deformables are appended by per-world hooks, not the USD importer. - patterns = tuple( - re.compile(entry.prim_path.replace(".*", "[^/]*")) for entry in NewtonManager._deformable_registry - ) - ignore_paths = [] - if patterns: - for root_path in (*sources, *plan.global_paths): - for prim in Usd.PrimRange(stage.GetPrimAtPath(root_path)): - path = str(prim.GetPath()) - if any(pattern.fullmatch(path) for pattern in patterns): - ignore_paths.append(path) + ignore_paths = [ + path + for entry in NewtonManager._deformable_registry + for _, _, path, _ in iter_sources(plan, entry.prim_path) + ] global_ignore_paths.extend(ignore_paths) + global_ignore_paths.extend(entry.prim_path for entry in NewtonManager._deformable_registry) else: - entries = discover_deformables_on_stage(stage, root_paths=(*sources, *plan.global_paths)) + entries = deformable_prototypes(stage, plan, rows) ignore_paths = list( dict.fromkeys( path for entry in entries for path in (entry.root_path, entry.sim_mesh_path, entry.vis_mesh_path) @@ -161,6 +158,7 @@ def _replicate_newton( schema_resolvers=schema_resolvers, load_visual_shapes=load_visual_shapes, skip_mesh_approximation=not simulation, + return_deformable_results=True, ) _restore_visible_colliders_without_visual_shapes( builder, stage, import_result["path_shape_map"], load_visual_shapes @@ -185,6 +183,23 @@ def _replicate_newton( import_results_out=source_import_results, ) + # Keep only renderable cables from this representation's actual imports. + cable_counts = {} + for source, imported in [(None, result) for result in import_results] + list(source_import_results.items()): + for path, (bodies, _) in imported["path_cable_map"].items(): + if imported["path_cable_attrs"][path]["closed"]: + continue + if len(UsdGeom.BasisCurves(stage.GetPrimAtPath(path)).GetCurveVertexCountsAttr().Get()) != 1: + continue + if source is None: + cable_counts[path] = len(bodies) + else: + for row in rows: + if plan.sources[row] == source: + for column in np.flatnonzero(plan.clone_mask[row]): + destination = plan.destinations[row].format(int(plan.env_ids[column])) + cable_counts[rebase(path, source, destination)] = len(bodies) + if simulation: global_sites, source_sites, root_sites = NewtonManager._cl_inject_sites(builder, source_builders) else: @@ -229,6 +244,13 @@ def record_source_particle_ranges( ) site_index_map = {label: (idx, None) for label, idx in global_sites.items()} site_index_map.update((label, (None, per_world)) for label, per_world in local_site_map.items()) + NewtonManager._cable_bindings = {} + if cable_counts: + shape_ids = {label: index for index, label in enumerate(builder.shape_label)} + NewtonManager._cable_bindings = { + path: [shape_ids[f"{path}_edge_capsule_{segment}"] for segment in range(count)] + for path, count in cable_counts.items() + } if simulation: NewtonManager._cl_site_index_map = site_index_map NewtonManager._cl_fabric_body_bindings = fabric_body_bindings @@ -237,10 +259,10 @@ def record_source_particle_ranges( NewtonManager.set_builder(builder) NewtonManager._num_envs = len(plan.env_ids) else: - geometry = add_shadow_deformables_to_builder(builder, stage, entries, plan, rows, device=sim.device) + geometry_offsets = add_shadow_deformables_to_builder(builder, expand_deformable_entries(plan, entries, rows)) backend_cfg = NewtonBackendCfg(builder=builder, device=sim.device, num_envs=len(plan.env_ids), simulation=False) sim.physics_manager.register_callback( - partial(NewtonManager._initialize_visualization_model, backend_cfg, geometry), + partial(NewtonManager._initialize_visualization_model, backend_cfg, geometry_offsets), PhysicsEvent.PHYSICS_READY, name="newton_visualization_model", wrap_weak_ref=False, diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index 447631773830..25cb56d5dca0 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -14,8 +14,7 @@ import logging import re from abc import abstractmethod -from collections.abc import Callable, Iterable, Sequence -from dataclasses import dataclass +from collections.abc import Callable, Sequence from typing import TYPE_CHECKING, Any, ClassVar import numpy as np @@ -78,16 +77,12 @@ def _paused_gc(): from pxr import Usd, UsdGeom +from isaaclab.cloner.path import rebase, under +from isaaclab.cloner.query import iter_sources from isaaclab.physics import CallbackHandle, PhysicsEvent, PhysicsManager from isaaclab.scene_data import SceneDataBackend, SceneDataFormat, SceneDataProvider -from isaaclab.scene_data.deformable_vis_remap import ( - VolumeVisRemap, - launch_batch_particle_slice_copy, - launch_batch_volume_vis_remap, -) from isaaclab.sim import SimulationContext from isaaclab.sim.utils.newton_model_utils import replace_newton_builder_shape_colors -from isaaclab.sim.utils.queries import has_deformable_curve_api from isaaclab.sim.utils.stage import get_current_stage from isaaclab.utils import checked_apply from isaaclab.utils.string import resolve_matching_names @@ -102,7 +97,6 @@ def _paused_gc(): from isaaclab_newton.physics.featherstone_manager_cfg import FeatherstoneSolverCfg from isaaclab_newton.physics.mjwarp_manager_cfg import MJWarpSolverCfg from isaaclab_newton.physics.newton_manager_cfg import NewtonBackendCfg, NewtonCfg, NewtonShapeCfg, NewtonSolverCfg -from isaaclab_newton.physics.visualization_deformables import populate_shadow_deformable_registry from isaaclab_newton.physics.xpbd_manager_cfg import XPBDSolverCfg from isaaclab_newton.renderers.visual_material import ( VisualMaterialWriter, @@ -113,6 +107,7 @@ def _paused_gc(): if TYPE_CHECKING: from isaaclab.actuators.newton import NewtonActuatorAdapter from isaaclab.assets import BaseArticulation + from isaaclab.cloner import ClonePlan from isaaclab.renderers.base_renderer import VisualMaterialBatch from isaaclab_newton.physics.newton_collision_cfg import NewtonCollisionPipelineCfg @@ -142,86 +137,6 @@ def _compile_label_pattern(expr: str | list[str] | None) -> re.Pattern[str] | No # _LocalSite: (None, [[env0_idx, ...], ...]) — per-world site indices -@wp.kernel(enable_backward=False) -def _sync_particle_points( - fabric_points: wp.fabricarrayarray(dtype=wp.vec3f), - fabric_world_matrices: wp.fabricarray(dtype=wp.mat44d), - offsets: wp.fabricarray(dtype=wp.uint32), - counts: wp.fabricarray(dtype=wp.uint32), - particle_q: wp.array(dtype=wp.vec3f), -): - """Write Newton particle positions into Fabric mesh point arrays as local-frame points. - - Newton stores particle positions in world space in ``state.particle_q``. The Fabric - ``points`` attribute on a ``UsdGeom.Mesh`` is local-space -- Kit multiplies by the - mesh prim's resolved ``omni:fabric:worldMatrix`` at render time. - - This kernel inverts the mesh prim's world matrix to convert each world-space particle - position into local-space before writing. - """ - i = wp.tid() - offset = int(offsets[i]) - num_points = int(counts[i]) - - # Un-transpose Fabric's stored matrix to get the standard homogeneous form - world_matrix = wp.transpose(wp.mat44f(fabric_world_matrices[i])) - inv_world_matrix = wp.inverse(world_matrix) - - for j in range(num_points): - fabric_points[i][j] = wp.transform_point(inv_world_matrix, particle_q[offset + j]) - - -@wp.kernel(enable_backward=False) -def _sync_cable_points( - fabric_points: wp.fabricarrayarray(dtype=wp.vec3f), - fabric_world_matrices: wp.fabricarray(dtype=wp.mat44d), - offsets: wp.fabricarray(dtype=wp.uint32), - counts: wp.fabricarray(dtype=wp.uint32), - shape_ids: wp.array(dtype=wp.int32), - shape_body: wp.array(dtype=wp.int32), - body_q: wp.array(dtype=wp.transformf), - shape_transform: wp.array(dtype=wp.transformf), - shape_scale: wp.array(dtype=wp.vec3f), -): - """Write Newton cable segment endpoints into Fabric curve points.""" - curve = wp.tid() - offset = int(offsets[curve]) - segment_count = int(counts[curve]) - world_matrix = wp.transpose(wp.mat44f(fabric_world_matrices[curve])) - world_to_curve = wp.inverse(world_matrix) - - for point in range(segment_count + 1): - endpoint_w = wp.vec3f() - if point == 0: - shape = shape_ids[offset] - shape_q = wp.transform_multiply(body_q[shape_body[shape]], shape_transform[shape]) - endpoint_w = wp.transform_point(shape_q, wp.vec3f(0.0, 0.0, -shape_scale[shape][1])) - elif point == segment_count: - shape = shape_ids[offset + segment_count - 1] - shape_q = wp.transform_multiply(body_q[shape_body[shape]], shape_transform[shape]) - endpoint_w = wp.transform_point(shape_q, wp.vec3f(0.0, 0.0, shape_scale[shape][1])) - else: - left_shape = shape_ids[offset + point - 1] - left_q = wp.transform_multiply(body_q[shape_body[left_shape]], shape_transform[left_shape]) - left_w = wp.transform_point(left_q, wp.vec3f(0.0, 0.0, shape_scale[left_shape][1])) - right_shape = shape_ids[offset + point] - right_q = wp.transform_multiply(body_q[shape_body[right_shape]], shape_transform[right_shape]) - right_w = wp.transform_point(right_q, wp.vec3f(0.0, 0.0, -shape_scale[right_shape][1])) - endpoint_w = 0.5 * (left_w + right_w) - fabric_points[curve][point] = wp.transform_point(world_to_curve, endpoint_w) - - -@dataclass -class _ParticleVisualPrim: - """A ``UsdGeom.Points`` prim mirroring a slice of Newton's particle state.""" - - points_attr: Usd.Attribute - offset: int - count: int - sync_frequency: int - frames_since_sync: int - - @wp.kernel(enable_backward=False) def _or_reset_masks_from_mask( env_mask: wp.array(dtype=wp.bool), @@ -306,6 +221,81 @@ class NewtonSceneDataBackend(SceneDataBackend): def __init__(self): self._transforms = SceneDataFormat.Transform() self.transforms_version = 0 + self.geometry_timestamp = 0 + self._geometry_batches = [] + + def initialize_geometry(self, plan: ClonePlan) -> None: + """Bind imported geometry paths to native particle ranges and capsule endpoints.""" + ranges, visual_ranges = {}, {} + indices, weights = [], [] + visual_offset = 0 + for entry in NewtonManager._deformable_registry: + paths = [ + rebase(path, source, template.format(env_id)) + for source, template, path, env_ids in iter_sources(plan, entry.vis_mesh_prim_path) + for env_id in env_ids + ] + if not paths and any(under(entry.vis_mesh_prim_path, root) for root in plan.global_paths): + paths.append(entry.vis_mesh_prim_path) + if entry.volume_vis_remap is None: + ranges.update( + (path, (offset, entry.particles_per_body)) + for path, offset in zip(paths, entry.particle_offsets, strict=True) + ) + else: + prototype_indices = entry.volume_vis_remap.tet_vertex_indices.numpy() + prototype_weights = entry.volume_vis_remap.bary_weights.numpy() + count = len(prototype_indices) + for path, offset in zip(paths, entry.particle_offsets, strict=True): + indices.append(prototype_indices + offset) + weights.append(prototype_weights) + visual_ranges[path] = (visual_offset, count) + visual_offset += count + source = SceneDataFormat.Points() + source.points = self.state.particle_q + self._geometry_batches = [(source, ranges)] + if visual_ranges: + source = SceneDataFormat.WeightedPoints() + source.points = self.state.particle_q + source.indices = wp.array(np.concatenate(indices), dtype=wp.int32, device=source.points.device) + source.weights = wp.array(np.concatenate(weights), dtype=wp.float32, device=source.points.device) + self._geometry_batches.append((source, visual_ranges)) + endpoints, ranges = [], {} + offset = 0 + for path, shapes in NewtonManager._cable_bindings.items(): + ids = np.asarray(shapes, dtype=np.int32) + left = np.concatenate((ids[:1], ids)) + right = np.concatenate((ids, ids[-1:])) + left_sign = np.ones(len(left), dtype=np.int32) + right_sign = -np.ones(len(right), dtype=np.int32) + left_sign[0], right_sign[-1] = -1, 1 + endpoints.append(np.column_stack((left, left_sign, right, right_sign))) + ranges[path] = (offset, len(left)) + offset += len(left) + if endpoints: + model = self.model + source = SceneDataFormat.CapsuleEndpoints() + source.transforms = self.state.body_q + source.shape_body = model.shape_body + source.shape_transform = model.shape_transform + source.shape_scale = model.shape_scale + source.endpoints = wp.array(np.concatenate(endpoints), dtype=wp.vec4i, device=model.device) + self._geometry_batches.append((source, ranges)) + + @property + def native_geometry_formats(self) -> tuple[type, ...]: + return (SceneDataFormat.Points, SceneDataFormat.WeightedPoints, SceneDataFormat.CapsuleEndpoints) + + def get_geometry_batches(self, output_format=SceneDataFormat.Points): + """Publish native arrays; SDP derives cable endpoints and applies destination layouts.""" + state = self.state + for source, _ in self._geometry_batches: + attribute = "transforms" if source._cls is SceneDataFormat.CapsuleEndpoints else "points" + data = state.particle_q if attribute == "points" else state.body_q + if getattr(source, attribute) is not data: + setattr(source, attribute, data) + self.geometry_timestamp += 1 + return [(source, ranges) for source, ranges in self._geometry_batches if ranges] @property def transforms(self) -> SceneDataFormat.Transform: @@ -338,6 +328,7 @@ def state(self) -> State: if NewtonManager._transforms_may_change_on_graph_replay: # Raw external graph replays bypass Python invalidation, so these reads must stay conservative. self.transforms_version += 1 + self.geometry_timestamp += 1 if NewtonManager._eval_fk is not _eval_fk_unbound: NewtonManager.forward() return NewtonManager.get_state_0() @@ -466,15 +457,7 @@ class NewtonManager(PhysicsManager): _usdrt_stage = None _clone_physics_only = False _transforms_may_change_on_graph_replay: bool = False - _particles_dirty: bool = False - _cables_dirty: bool = False - _newton_cable_offset_attr = "newton:cableOffset" - _newton_cable_count_attr = "newton:cableSegmentCount" - _cable_shape_ids: wp.array | None = None - _cable_sync_cpu_buffers: tuple[wp.array, ...] | None = None - _newton_particle_offset_attr = "newton:particleOffset" - _newton_particle_count_attr = "newton:particleCount" - _particle_visual_prims: dict[str, _ParticleVisualPrim] = {} + _cable_bindings: dict[str, list[int]] = {} # Model changes (callbacks use unified system from PhysicsManager) _model_changes: set[int] = set() @@ -485,17 +468,10 @@ class NewtonManager(PhysicsManager): # Visualization-only state used when the sim backend is PhysX. Populated # from the clone plan in :meth:`_initialize_visualization_model` and updated each render # frame in :meth:`update_visualization_state`. - _scene_data_mapping: wp.array | None = None - _scene_data_version: int | None = None - _scene_data_points: SceneDataFormat.Points | None = None - _scene_data_geometry_mapping: wp.array | None = None - _shadow_deformable_entities: list | None = None - _sim_particle_q: wp.array | None = None - _mapped_sim_particle_offsets: set[int] | None = None - _shadow_deformable_sync_skip_warned: set[str] = set() - _shadow_deformable_remap_batches: list | None = None - _shadow_deformable_copy_batch: tuple | None = None - _shadow_deformable_batch_sync_key: tuple | None = None + _transform_mapping: wp.array | None = None + _transforms_version_last_update: int | None = None + _geometry_offsets: dict[str, int] = {} + _geometry_timestamp_last_update: int | None = None _visualization_stop_callback: CallbackHandle | None = None _builder_attribute_solvers: tuple[type[SolverBase], ...] = () @@ -644,12 +620,6 @@ def video_capture_backend(cls) -> str: """Newton GL headless perspective video capture.""" return "newton_gl" - @classmethod - def pre_render(cls) -> None: - """Refresh legacy cable and particle geometry; rigid transforms are requested through SDP.""" - cls.sync_cables_to_usd() - cls.sync_particles_to_usd() - @classmethod def sync_transforms_to_fabric(cls) -> None: """Publish rigid-body poses through SDP to Fabric, leaving authored USD untouched.""" @@ -672,130 +642,12 @@ def sync_transforms_to_usd(cls) -> None: ) cls.sync_transforms_to_fabric() - @classmethod - def sync_cables_to_usd(cls) -> None: - """Write Newton cable segment endpoints to Fabric curve points.""" - if not (cls._cables_dirty or cls._transforms_may_change_on_graph_replay): - return - if cls._usdrt_stage is None or cls._cable_shape_ids is None: - NewtonManager._cables_dirty = False - return - try: - import usdrt # noqa: PLC0415 - - selection = cls._usdrt_stage.SelectPrims( - require_attrs=[ - (usdrt.Sdf.ValueTypeNames.Point3fArray, "points", usdrt.Usd.Access.ReadWrite), - (usdrt.Sdf.ValueTypeNames.UInt, cls._newton_cable_offset_attr, usdrt.Usd.Access.Read), - (usdrt.Sdf.ValueTypeNames.UInt, cls._newton_cable_count_attr, usdrt.Usd.Access.Read), - (usdrt.Sdf.ValueTypeNames.Matrix4d, "omni:fabric:worldMatrix", usdrt.Usd.Access.Read), - ], - device="cpu", - ) - if selection.GetCount() == 0: - NewtonManager._cables_dirty = False - return - _, _, body_q, _, _ = cls._cable_sync_cpu_buffers - wp.copy(body_q, cls._scene_data_backend.state.body_q) - wp.launch( - _sync_cable_points, - dim=selection.GetCount(), - inputs=[ - wp.fabricarrayarray(data=selection, attrib="points", dtype=wp.vec3f), - wp.fabricarray(data=selection, attrib="omni:fabric:worldMatrix"), - wp.fabricarray(data=selection, attrib=cls._newton_cable_offset_attr), - wp.fabricarray(data=selection, attrib=cls._newton_cable_count_attr), - *cls._cable_sync_cpu_buffers, - ], - device="cpu", - ) - NewtonManager._cables_dirty = False - except Exception: - logger.exception("[NewtonManager] sync_cables_to_usd FAILED") - - @classmethod - def sync_particles_to_usd(cls) -> None: - """Write Newton particle positions to USD/Fabric for USD-stage rendering. - - Two prim families are synced from ``state_0.particle_q``: - - * Fabric mesh prims tagged with ``newton:particleOffset`` / - ``newton:particleCount`` (deformable visual meshes) receive - local-frame points on the GPU via :meth:`_sync_fabric_mesh_particles`. - * ``UsdGeom.Points`` prims registered through - :meth:`register_particle_visual_prim` (MPM particle clouds) receive - world-frame points via :meth:`_sync_particle_points_prims`. - - No-op when there is no particle state or nothing changed since the - last sync. - """ - if not cls._particles_dirty or cls.backend is None or cls.backend.state_0.particle_q is None: - return - try: - cls._sync_fabric_mesh_particles() - NewtonManager._particles_dirty = cls._sync_particle_points_prims() - except Exception: - logger.exception("[NewtonManager] sync_particles_to_usd FAILED") - - @classmethod - def _sync_fabric_mesh_particles(cls) -> None: - """Write ``state_0.particle_q`` into Fabric mesh point arrays as local-frame points.""" - if cls._usdrt_stage is None: - return - import usdrt # noqa: PLC0415 - - selection = cls._usdrt_stage.SelectPrims( - require_attrs=[ - (usdrt.Sdf.ValueTypeNames.Point3fArray, "points", usdrt.Usd.Access.ReadWrite), - (usdrt.Sdf.ValueTypeNames.UInt, cls._newton_particle_offset_attr, usdrt.Usd.Access.Read), - (usdrt.Sdf.ValueTypeNames.UInt, cls._newton_particle_count_attr, usdrt.Usd.Access.Read), - (usdrt.Sdf.ValueTypeNames.Matrix4d, "omni:fabric:worldMatrix", usdrt.Usd.Access.Read), - ], - device=str(PhysicsManager._device), - ) - if selection.GetCount() == 0: - return - wp.launch( - _sync_particle_points, - dim=selection.GetCount(), - inputs=[ - wp.fabricarrayarray(data=selection, attrib="points", dtype=wp.vec3f), - wp.fabricarray(data=selection, attrib="omni:fabric:worldMatrix"), - wp.fabricarray(data=selection, attrib=cls._newton_particle_offset_attr), - wp.fabricarray(data=selection, attrib=cls._newton_particle_count_attr), - cls.backend.state_0.particle_q, - ], - device=PhysicsManager._device, - ) - - @classmethod - def _sync_particle_points_prims(cls) -> bool: - """Write registered ``UsdGeom.Points`` prims; return ``True`` while throttled prims remain.""" - if not cls._particle_visual_prims: - return False - - due = [] - for record in cls._particle_visual_prims.values(): - record.frames_since_sync += 1 - if record.frames_since_sync >= record.sync_frequency: - record.frames_since_sync = 0 - due.append(record) - if due: - from pxr import Sdf, Vt # noqa: PLC0415 - - particle_q = cls.backend.state_0.particle_q.numpy() - with Sdf.ChangeBlock(): - for record in due: - points = particle_q[record.offset : record.offset + record.count] - record.points_attr.Set(Vt.Vec3fArray.FromNumpy(points)) - return len(due) < len(cls._particle_visual_prims) - @classmethod def _mark_transforms_changed(cls) -> None: """Publish authored rigid-body changes and invalidate cable geometry.""" if NewtonManager._scene_data_backend is not None: NewtonManager._scene_data_backend.transforms_version += 1 - NewtonManager._cables_dirty = True + NewtonManager._scene_data_backend.geometry_timestamp += 1 device = PhysicsManager._device if device is not None: device = wp.get_device(device) @@ -804,35 +656,22 @@ def _mark_transforms_changed(cls) -> None: @classmethod def _mark_particles_dirty(cls) -> None: - """Flag that particle positions have changed and Fabric needs re-sync. - - The actual sync is deferred to the particle sync callback (if registered), - which runs at render cadence via :meth:`pre_render`. - """ - NewtonManager._particles_dirty = True + """Invalidate SDP geometry after native particle writes.""" + NewtonManager._scene_data_backend.geometry_timestamp += 1 + device = wp.get_device(PhysicsManager._device) + if device.is_cuda and device.stream.is_capturing: + NewtonManager._transforms_may_change_on_graph_replay = True @classmethod - def register_particle_visual_prim( - cls, prim_path: str, particle_offset: int, particle_count: int, sync_frequency: int = 1 - ) -> None: - """Register a ``UsdGeom.Points`` prim whose points mirror a slice of Newton's particle state. + def register_particle_visual_prim(cls, prim_path: str, particle_offset: int, particle_count: int) -> None: + """Publish a declared point prim's native particle range. Args: - prim_path: Stage path of an existing ``UsdGeom.Points`` prim. - particle_offset: First index of the prim's slice in ``state.particle_q``. - particle_count: Number of particles in the slice. - sync_frequency: Sync the prim every N dirty render frames. + prim_path: Planned point-geometry destination. + particle_offset: First native particle index. + particle_count: Number of particles. """ - from pxr import UsdGeom # noqa: PLC0415 - - prim = get_current_stage().GetPrimAtPath(prim_path) - NewtonManager._particle_visual_prims[prim_path] = _ParticleVisualPrim( - points_attr=UsdGeom.Points(prim).GetPointsAttr(), - offset=int(particle_offset), - count=int(particle_count), - sync_frequency=int(sync_frequency), - frames_since_sync=int(sync_frequency), - ) + NewtonManager._scene_data_backend._geometry_batches[0][1][prim_path] = (particle_offset, particle_count) @classmethod def step(cls) -> None: @@ -935,8 +774,6 @@ def step(cls) -> None: PhysicsManager._sim_time += physics_dt cls._mark_transforms_changed() - if cls._usdrt_stage is not None or cls._particle_visual_prims: - cls._mark_particles_dirty() cls._mark_sensor_state_dirty() cls._check_solver_status() @@ -1025,26 +862,15 @@ def clear(cls): NewtonManager._sensor_bvh_shape_flags = ShapeFlags.VISIBLE NewtonManager._usdrt_stage = None NewtonManager._transforms_may_change_on_graph_replay = False - NewtonManager._particles_dirty = False - NewtonManager._cables_dirty = False - NewtonManager._cable_shape_ids = None - NewtonManager._cable_sync_cpu_buffers = None - NewtonManager._particle_visual_prims = {} + NewtonManager._cable_bindings = {} NewtonManager._mpm_object_registry = [] NewtonManager._deformable_registry = [] NewtonManager._per_world_builder_hooks = [] NewtonManager._up_axis = "Z" - NewtonManager._scene_data_mapping = None - NewtonManager._scene_data_version = None - NewtonManager._scene_data_points = None - NewtonManager._scene_data_geometry_mapping = None - NewtonManager._shadow_deformable_entities = None - NewtonManager._sim_particle_q = None - NewtonManager._mapped_sim_particle_offsets = None - NewtonManager._shadow_deformable_sync_skip_warned = set() - NewtonManager._shadow_deformable_remap_batches = None - NewtonManager._shadow_deformable_copy_batch = None - NewtonManager._shadow_deformable_batch_sync_key = None + NewtonManager._transform_mapping = None + NewtonManager._transforms_version_last_update = None + NewtonManager._geometry_offsets = {} + NewtonManager._geometry_timestamp_last_update = None NewtonManager._model_changes = set() NewtonManager._scene_data_backend = None NewtonManager._cl_pending_sites = {} @@ -1518,25 +1344,11 @@ def start_simulation(cls) -> None: ) NewtonManager._initialize_fabric_body_prims(cls._usdrt_stage, fabric_hierarchy, usdrt, body_bindings) - NewtonManager._initialize_fabric_cable_prims(cls._usdrt_stage, fabric_hierarchy, usdrt) + cls._scene_data_backend.initialize_geometry(PhysicsManager._sim.get_clone_plan()) logger.info("Dispatching PHYSICS_READY callbacks") cls.dispatch_event(PhysicsEvent.PHYSICS_READY) - # MPM assets register their particle visualizations during PHYSICS_READY. - if not cls._clone_physics_only: - NewtonManager._initialize_fabric_particle_prims( - cls._usdrt_stage, - fabric_hierarchy, - usdrt, - NewtonManager._particle_visual_prims, - ) - - cls._mark_transforms_changed() - cls._mark_particles_dirty() - cls.sync_cables_to_usd() - cls.sync_particles_to_usd() - @staticmethod def _initialize_fabric_body_prims(stage, fabric_hierarchy, usdrt, body_bindings: Sequence[tuple[str, int]]) -> None: """Initialize Fabric body prims used by Newton transform sync.""" @@ -1555,40 +1367,6 @@ def _initialize_fabric_body_prims(stage, fabric_hierarchy, usdrt, body_bindings: fabric_hierarchy.update_world_xforms() - @classmethod - def _initialize_fabric_cable_prims(cls, stage, fabric_hierarchy, usdrt) -> None: - """Initialize Fabric curve tags and packed Newton segment mappings.""" - cable_shapes = cls.collect_cable_segment_shape_ids() - - shape_ids: list[int] = [] - for prim_path, segment_shape_ids in cable_shapes.items(): - segment_count = len(segment_shape_ids) - prim = stage.GetPrimAtPath(prim_path) - prim.GetAttribute("points").Set(usdrt.Vt.Vec3fArray(segment_count + 1)) - usdrt.Rt.Xformable(prim).SetWorldXformFromUsd() - offset = len(shape_ids) - prim.CreateAttribute(cls._newton_cable_offset_attr, usdrt.Sdf.ValueTypeNames.UInt, custom=True).Set(offset) - prim.CreateAttribute(cls._newton_cable_count_attr, usdrt.Sdf.ValueTypeNames.UInt, custom=True).Set( - segment_count - ) - shape_ids.extend(segment_shape_ids) - - if not shape_ids: - NewtonManager._cable_shape_ids = None - NewtonManager._cable_sync_cpu_buffers = None - return - NewtonManager._cable_shape_ids = wp.array(shape_ids, dtype=wp.int32, device=PhysicsManager._device) - # TODO: CPU mirror only needed because RTX Hydra ignores GPU Fabric BasisCurves points. - # Drop these buffers and sync on device once NVBug 6502662 is fixed. - NewtonManager._cable_sync_cpu_buffers = ( - NewtonManager._cable_shape_ids.to("cpu"), - cls.backend.model.shape_body.to("cpu"), - wp.empty_like(cls.backend.state_0.body_q, device="cpu"), - cls.backend.model.shape_transform.to("cpu"), - cls.backend.model.shape_scale.to("cpu"), - ) - fabric_hierarchy.update_world_xforms() - @classmethod def collect_cable_segment_shape_ids(cls) -> dict[str, list[int]]: """Map each renderable cable prim path to its ordered Newton segment shape ids. @@ -1601,102 +1379,13 @@ def collect_cable_segment_shape_ids(cls) -> dict[str, list[int]]: from labels ``.../mesh_edge_capsule_0``, ``_1``, ``_2``, and Newton assigned those shapes indices ``42..44`` after earlier scene shapes. - When the labeled prim exists on the host USD stage, topology is validated against that - ``BasisCurves`` prim. Kit-less replicated destinations often exist only in the render - backend; for those paths topology is validated against a source prototype via the active - clone plan. + Bindings are recorded during native import and cloning, including destinations that + exist only in a native backend. No completed-stage lookup is needed. Returns: Concrete cable prim paths mapped to ordered Newton segment shape ids. """ - if cls.backend is None: - return {} - - cable_shapes: dict[str, dict[int, int]] = {} - for shape_id, label in enumerate(cls.backend.model.shape_label): - if label is None: - continue - prim_path, separator, suffix = label.rpartition("_edge_capsule_") - if not separator or not suffix.isdigit(): - continue - segment = int(suffix) - segments = cable_shapes.setdefault(prim_path, {}) - if segment in segments: - raise RuntimeError(f"Cable visualization requires one Newton shape labeled {label}.") - segments[segment] = shape_id - - stage = get_current_stage() - - sim = SimulationContext.instance() - clone_plan = sim.get_clone_plan() if sim is not None else None - - # Filter label groups into renderable ordered shape-id lists. Validate topology on the host - # destination when present; otherwise use the clone-plan source prototype. Skip unsupported - # topology (non-linear / periodic / bad counts); require segment labels to match the curve. - ordered: dict[str, list[int]] = {} - for prim_path, segments in cable_shapes.items(): - prim = stage.GetPrimAtPath(prim_path) - validation_prim = prim - - # If destination is missing on host USD: validate topology against the clone source prototype. - if not prim.IsValid() and clone_plan is not None: - from isaaclab.cloner.query import path_to_source # noqa: PLC0415 - - resolved = path_to_source(clone_plan, prim_path) - if resolved is not None: - source_path, _, asset_suffix = resolved - validation_prim = stage.GetPrimAtPath(source_path + asset_suffix) - - if not ( - validation_prim.IsValid() - and validation_prim.IsA(UsdGeom.BasisCurves) - and has_deformable_curve_api(validation_prim) - ): - logger.debug( - "Skipping cable '%s': validation prim '%s' is missing, not BasisCurves, or lacks" - " DeformableCurveAPI.", - prim_path, - validation_prim.GetPath().pathString if validation_prim.IsValid() else "", - ) - continue - - curve = UsdGeom.BasisCurves(validation_prim) - counts = curve.GetCurveVertexCountsAttr().Get() - if ( - len(counts) != 1 - or int(counts[0]) < 2 - or curve.GetTypeAttr().Get() != UsdGeom.Tokens.linear - or curve.GetWrapAttr().Get() == UsdGeom.Tokens.periodic - ): - logger.debug( - "Skipping cable '%s': unsupported BasisCurves topology (vertex_counts=%s, type=%s, wrap=%s).", - prim_path, - counts, - curve.GetTypeAttr().Get(), - curve.GetWrapAttr().Get(), - ) - continue - - segment_count = int(counts[0]) - 1 - if set(segments) != set(range(segment_count)): - raise RuntimeError( - f"Cable visualization for '{prim_path}' requires {segment_count} ordered segment shapes." - ) - ordered[prim_path] = [segments[segment] for segment in range(segment_count)] - - return ordered - - @staticmethod - def _initialize_fabric_particle_prims(stage, fabric_hierarchy, usdrt, prim_paths: Iterable[str]) -> None: - """Initialize Fabric world matrices for point prims used by particle sync.""" - prim_paths = tuple(prim_paths) - for prim_path in prim_paths: - prim = stage.GetPrimAtPath(prim_path) - if prim.IsValid(): - usdrt.Rt.Xformable(prim).SetWorldXformFromUsd() - - if prim_paths: - fabric_hierarchy.update_world_xforms() + return cls._cable_bindings @classmethod def _inject_terrain_heightfields( @@ -2745,27 +2434,19 @@ def _backend_is_newton(cls, scene_data_provider: SceneDataProvider | None = None return isinstance(cls.get_scene_data_provider().backend, NewtonSceneDataBackend) @classmethod - def _initialize_visualization_model(cls, cfg: NewtonBackendCfg, geometry: tuple[list, list], _event: Any) -> None: + def _initialize_visualization_model( + cls, cfg: NewtonBackendCfg, geometry_offsets: dict[str, int], _event: Any + ) -> None: """Acquire the completed clone representation when foreign physics becomes ready.""" if cls.backend is not None: return sim = SimulationContext.instance() NewtonManager.backend = sim.get_or_create_backend(cfg) NewtonManager._num_envs = cls.backend.model.num_envs - shadow_entities, registry_groups = geometry - NewtonManager._scene_data_mapping = None - NewtonManager._scene_data_version = None - NewtonManager._shadow_deformable_entities = shadow_entities - NewtonManager._scene_data_geometry_mapping = None - NewtonManager._mapped_sim_particle_offsets = None - cls._invalidate_shadow_deformable_batch_sync() - sim_particle_total = sum(entity.sim_particle_count for entity in shadow_entities) - if sim_particle_total > 0: - NewtonManager._sim_particle_q = wp.zeros(sim_particle_total, dtype=wp.vec3f, device=cfg.device) - else: - NewtonManager._sim_particle_q = None - NewtonManager._deformable_registry = [] - populate_shadow_deformable_registry(cls, registry_groups) + NewtonManager._transform_mapping = None + NewtonManager._transforms_version_last_update = None + NewtonManager._geometry_offsets = geometry_offsets + NewtonManager._geometry_timestamp_last_update = None cls.update_visualization_state() NewtonManager._visualization_stop_callback = sim.physics_manager.register_callback( lambda _payload: NewtonManager.clear(), @@ -2809,273 +2490,34 @@ def update_visualization_state(cls, scene_data_provider: SceneDataProvider | Non return if cls.backend.state_0.body_q is not None: - if cls._scene_data_version is None: + if cls._transforms_version_last_update is None: body_labels = list(cls.backend.model.body_label) body_paths = cls._resolve_scene_data_body_paths(body_labels, scene_data_provider.usd_stage) if len(set(body_paths)) != cls.backend.model.body_count or not set(body_paths).issubset( scene_data_provider.backend.transform_paths ): raise ValueError("Every Newton render body must have one unique SDP transform path.") - cls._scene_data_mapping = scene_data_provider.create_mapping(body_paths) + cls._transform_mapping = scene_data_provider.create_mapping(body_paths) transforms = SceneDataFormat.Transform() if scene_data_provider.get_transforms( - transforms, mapping=cls._scene_data_mapping, count=cls.backend.model.body_count + transforms, mapping=cls._transform_mapping, count=cls.backend.model.body_count ): if cls.backend.state_0.body_q is not transforms.transforms: cls.backend.state_0.body_q = transforms.transforms cls._invalidate_sensor_graph() - if cls._scene_data_version != scene_data_provider.backend.transforms_version: + if cls._transforms_version_last_update != scene_data_provider.backend.transforms_version: cls._mark_sensor_state_dirty() - cls._scene_data_version = scene_data_provider.backend.transforms_version - - if cls.backend.state_0.particle_q is not None and scene_data_provider.point_count > 0: - if cls._scene_data_points is None: - cls._scene_data_points = SceneDataFormat.Points() - - # Recreate when ``None``. Identity layouts return ``None`` from - # :meth:`create_geometry_mapping`, so this also refreshes mapped-offset and - # batch-sync metadata each frame. Caching ``None`` via a ready-flag without - # that refresh regresses Franka soft viz (stretched / missing deformables). - if cls._scene_data_geometry_mapping is None and cls._shadow_deformable_entities: - geometry_paths = [entity.root_path for entity in cls._shadow_deformable_entities] - geometry_offsets = [entity.sim_particle_offset for entity in cls._shadow_deformable_entities] - cls._scene_data_geometry_mapping = scene_data_provider.create_geometry_mapping( - geometry_paths, geometry_offsets - ) - - # Invalidate the mapped-offset cache so that it can be rebuilt immediately after. - cls._mapped_sim_particle_offsets = None - cls._invalidate_shadow_deformable_batch_sync() - - if cls._mapped_sim_particle_offsets is None: - cls._mapped_sim_particle_offsets = cls._geometry_mapped_sim_offsets(scene_data_provider) - - if cls._sim_particle_q is None: - sim_total = sum(entity.sim_particle_count for entity in cls._shadow_deformable_entities or []) - if sim_total > 0: - device = PhysicsManager._device or "cpu" - cls._sim_particle_q = wp.zeros(sim_total, dtype=wp.vec3f, device=device) - - if cls._sim_particle_q is not None: - cls._scene_data_points.points = cls._sim_particle_q - scene_data_provider.get_points( - cls._scene_data_points, - mapping=cls._scene_data_geometry_mapping, - allow_passthrough=False, - ) - cls._sync_render_particle_q_from_sim() - else: - cls._scene_data_points.points = cls.backend.state_0.particle_q - scene_data_provider.get_points( - cls._scene_data_points, - mapping=cls._scene_data_geometry_mapping, - allow_passthrough=False, - ) - - cls._mark_sensor_state_dirty() - - @classmethod - def _geometry_mapped_sim_offsets(cls, scene_data_provider: SceneDataProvider) -> set[int]: - """Return ``_sim_particle_q`` offsets filled by :meth:`get_points` for the cached mapping. - - Called once when the geometry mapping is created (or when that cache is - invalidated). ``create_geometry_mapping`` indexes by backend entity and stores - consumer destinations (or ``-1`` when a backend path has no consumer). The - inverse case — a shadow entity whose path the backend never reports — never - appears in that array, so its sim slice stays at the zero initialization. - """ - mapping = cls._scene_data_geometry_mapping - if mapping is not None: - return {int(value) for value in mapping.numpy() if int(value) >= 0} - - # Identity layout: backend entities land at sequential flat offsets. - offsets: set[int] = set() - flat_offset = 0 - for count in scene_data_provider.backend.geometry_counts: - offsets.add(flat_offset) - flat_offset += int(count) - return offsets - - @classmethod - def _invalidate_shadow_deformable_batch_sync(cls) -> None: - """Drop cached batched remap/copy metadata.""" - cls._shadow_deformable_remap_batches = None - cls._shadow_deformable_copy_batch = None - cls._shadow_deformable_batch_sync_key = None - - @classmethod - def _ensure_shadow_deformable_batch_sync(cls) -> None: - """Build batched remap/copy launch metadata for mapped shadow deformables.""" - entities = cls._shadow_deformable_entities or [] - mapped_offsets = cls._mapped_sim_particle_offsets - sync_key = ( - id(entities), - frozenset(mapped_offsets or ()), - tuple( - ( - entity.root_path, - entity.sim_particle_offset, - entity.vis_particle_offset, - entity.sim_particle_count, - entity.vis_particle_count, - id(entity.volume_vis_remap), - ) - for entity in entities - ), - ) - if cls._shadow_deformable_batch_sync_key == sync_key: - return - - cls._shadow_deformable_batch_sync_key = sync_key - cls._shadow_deformable_remap_batches = [] - cls._shadow_deformable_copy_batch = None - - if cls._sim_particle_q is None or not entities: - return - - device = str(cls._sim_particle_q.device) - copy_entity_ids: list[int] = [] - copy_src_offsets: list[int] = [] - copy_dst_offsets: list[int] = [] - copy_counts: list[int] = [] - remap_groups: dict[int, tuple[VolumeVisRemap, list]] = {} - - for entity_index, entity in enumerate(entities): - if mapped_offsets is not None and entity.sim_particle_offset not in mapped_offsets: - continue - - if entity.volume_vis_remap is not None: - remap_key = id(entity.volume_vis_remap) - if remap_key not in remap_groups: - remap_groups[remap_key] = (entity.volume_vis_remap, []) - remap_groups[remap_key][1].append(entity) - elif entity.vis_particle_count > 0 and entity.vis_particle_count == entity.sim_particle_count: - copy_src_offsets.append(entity.sim_particle_offset) - copy_dst_offsets.append(entity.vis_particle_offset) - copy_counts.append(entity.vis_particle_count) - - if copy_counts: - count_prefix = np.zeros(len(copy_counts), dtype=np.int32) - running = 0 - for index, count in enumerate(copy_counts): - count_prefix[index] = running - for _ in range(int(count)): - copy_entity_ids.append(index) - running += int(count) - cls._shadow_deformable_copy_batch = ( - wp.array(copy_entity_ids, dtype=wp.int32, device=device), - wp.array(copy_src_offsets, dtype=wp.int32, device=device), - wp.array(copy_dst_offsets, dtype=wp.int32, device=device), - wp.array(np.asarray(copy_counts, dtype=np.int32), dtype=wp.int32, device=device), - wp.array(count_prefix, dtype=wp.int32, device=device), - ) - - remap_batches: list[tuple] = [] - for remap, group_entities in remap_groups.values(): - entity_ids: list[int] = [] - sim_offsets: list[int] = [] - render_offsets: list[int] = [] - vis_counts: list[int] = [] - vis_prefix = np.zeros(len(group_entities), dtype=np.int32) - running = 0 - for index, entity in enumerate(group_entities): - vis_prefix[index] = running - for _ in range(entity.vis_particle_count): - entity_ids.append(index) - running += entity.vis_particle_count - sim_offsets.append(entity.sim_particle_offset) - render_offsets.append(entity.vis_particle_offset) - vis_counts.append(entity.vis_particle_count) - - if not entity_ids: - continue - - remap_batches.append( - ( - wp.array(entity_ids, dtype=wp.int32, device=device), - wp.array(np.asarray(sim_offsets, dtype=np.int32), dtype=wp.int32, device=device), - wp.array(np.asarray(render_offsets, dtype=wp.int32), dtype=wp.int32, device=device), - wp.array(np.asarray(vis_counts, dtype=wp.int32), dtype=wp.int32, device=device), - wp.array(vis_prefix, dtype=wp.int32, device=device), - remap, - ) - ) - - cls._shadow_deformable_remap_batches = remap_batches - - @classmethod - def _sync_render_particle_q_from_sim(cls) -> None: - """Copy or remap sim nodal positions into shadow ``particle_q`` render slots.""" - if cls.backend is None or cls.backend.state_0.particle_q is None or cls._sim_particle_q is None: - return - if not cls._shadow_deformable_entities: - return - - mapped_offsets = cls._mapped_sim_particle_offsets - for entity in cls._shadow_deformable_entities: - if mapped_offsets is not None and entity.sim_particle_offset not in mapped_offsets: - warned = cls._shadow_deformable_sync_skip_warned - if entity.root_path not in warned: - warned.add(entity.root_path) - logger.warning( - "Skipping particle sync for deformable '%s': no SceneData geometry " - "mapping resolved for sim_offset=%d; render slots stay at rest pose.", - entity.root_path, - entity.sim_particle_offset, - ) - continue - - if ( - entity.volume_vis_remap is None - and entity.vis_particle_count != entity.sim_particle_count - and entity.vis_particle_count > 0 - ): - warned = cls._shadow_deformable_sync_skip_warned - if entity.root_path not in warned: - warned.add(entity.root_path) - logger.warning( - "Skipping particle sync for deformable '%s': vis_count=%d != sim_count=%d " - "and no volume remapping table is available; render slots stay at rest pose.", - entity.root_path, - entity.vis_particle_count, - entity.sim_particle_count, - ) - - cls._ensure_shadow_deformable_batch_sync() - - for ( - entity_ids, - sim_offsets, - render_offsets, - vis_counts, - vis_prefix, - remap, - ) in cls._shadow_deformable_remap_batches or []: - launch_batch_volume_vis_remap( - cls._sim_particle_q, - cls.backend.state_0.particle_q, - entity_ids, - sim_offsets, - render_offsets, - vis_counts, - vis_prefix, - remap.tet_vertex_indices, - remap.bary_weights, - ) + cls._transforms_version_last_update = scene_data_provider.backend.transforms_version - copy_batch = cls._shadow_deformable_copy_batch - if copy_batch is not None: - entity_ids, src_offsets, dst_offsets, counts, count_prefix = copy_batch - launch_batch_particle_slice_copy( - cls._sim_particle_q, - cls.backend.state_0.particle_q, - entity_ids, - src_offsets, - dst_offsets, - counts, - count_prefix, + if cls._geometry_offsets: + scene_data_provider.get_geometry_points( + output=cls.backend.state_0.particle_q, offsets=cls._geometry_offsets ) + timestamp = scene_data_provider.backend.geometry_timestamp + if cls._geometry_timestamp_last_update != timestamp: + cls._mark_sensor_state_dirty() + cls._geometry_timestamp_last_update = timestamp @staticmethod def _resolve_scene_data_body_paths(body_paths: list[str | None], stage) -> list[str | None]: diff --git a/source/isaaclab_newton/isaaclab_newton/physics/vbd_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/vbd_manager.py index 757d04f8a780..d9292e118ed7 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/vbd_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/vbd_manager.py @@ -36,17 +36,10 @@ def initialize(cls, sim_context: SimulationContext) -> None: @classmethod def start_simulation(cls) -> None: - """Start simulation and bind registered deformables to Fabric.""" + """Color the prebuilt model before starting simulation.""" if cls._builder is not None: cls._builder.color(balance_colors=False) super().start_simulation() - try: - from isaaclab_contrib.deformable.deformable_object import setup_registered_deformable_fabric_sync - except ModuleNotFoundError as exc: - if exc.name not in {"isaaclab_contrib", "isaaclab_contrib.deformable"}: - raise - else: - setup_registered_deformable_fabric_sync(cls) @classmethod def instantiate_builder_from_stage(cls) -> None: diff --git a/source/isaaclab_newton/isaaclab_newton/physics/visualization_deformables.py b/source/isaaclab_newton/isaaclab_newton/physics/visualization_deformables.py index 7c7fe48be2b9..d7d878eecac3 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/visualization_deformables.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/visualization_deformables.py @@ -3,355 +3,57 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Shadow-model deformable discovery and registry helpers for PhysX/OVPhysX visualization.""" +"""Build Newton rendering geometry from imported deformable prototypes.""" from __future__ import annotations -import logging -from collections.abc import Sequence -from dataclasses import dataclass, field, replace - -import numpy as np import warp as wp from newton import ModelBuilder -from pxr import Usd - -from isaaclab.cloner import ClonePlan -from isaaclab.cloner.path import rebase, under -from isaaclab.scene_data.deformable_discovery import ( - DeformableStageEntry, - sort_deformable_entries_for_geometry_sync, -) -from isaaclab.scene_data.deformable_vis_remap import VolumeVisRemap, build_volume_vis_barycentric_remap -from isaaclab.sim.utils.transforms import resolve_prim_pose - -logger = logging.getLogger(__name__) - - -@dataclass -class ShadowDeformableEntity: - """One shadow-model deformable entity used for SceneData geometry mapping.""" - - root_path: str - sim_particle_offset: int - sim_particle_count: int - vis_particle_offset: int - vis_particle_count: int - volume_vis_remap: VolumeVisRemap | None = None - - -@dataclass -class ShadowDeformableRegistryGroup: - """Registry metadata for one replicated deformable asset.""" - - prim_path: str - sim_mesh_prim_path: str - vis_mesh_prim_path: str - deformable_type: str - particles_per_body: int - register_usd_vis_point_bindings: bool - """Whether USD visual-mesh ``points`` can be bound 1:1 to shadow ``particle_q`` slices. - - Used by consumers such as OVRTX that push remapped (or matching) particle - positions into the authored visual mesh. Newton Warp does not need this; - it renders the shadow model directly. - """ - particle_offsets: list[int] = field(default_factory=list) - entities: list[ShadowDeformableEntity] = field(default_factory=list) - - -def _needs_volume_vis_remap(entry: DeformableStageEntry) -> bool: - """Return ``True`` if this volume body needs barycentric sim-to-visual remapping. - - Required when a visual triangle mesh exists on a different prim from the sim mesh. - """ - return ( - entry.deformable_type == "volume" - and entry.vis_mesh_path != entry.sim_mesh_path - and entry.vis_vertices.size > 0 - and entry.vis_indices.size > 0 - ) - - -def _vertices_to_wp_vec3(vertices: np.ndarray | list) -> list[wp.vec3]: - """Convert baked ``(N, 3)`` vertex arrays to ``wp.vec3`` lists for Newton builders.""" - if isinstance(vertices, np.ndarray): - return [wp.vec3(float(v[0]), float(v[1]), float(v[2])) for v in vertices] - return vertices - - -def _mesh_indices(indices: np.ndarray | list) -> list: - """Return mesh topology indices as a Python list for Newton builders.""" - if isinstance(indices, np.ndarray): - return indices.tolist() - return list(indices) - - -def _build_volume_vis_remap(entry: DeformableStageEntry, device: str) -> VolumeVisRemap | None: - """Build device-resident barycentric sim-to-visual remap tables for one volume body. - - Args: - entry: Discovered deformable with sim tet topology and visual mesh verts. - device: Warp device for the uploaded remap arrays (typically the sim device). - - Returns: - A :class:`~isaaclab.scene_data.deformable_vis_remap.VolumeVisRemap` on success, - or ``None`` when no tet can be assigned (logs a warning). - """ - remap = build_volume_vis_barycentric_remap( - entry.vertices, - entry.indices, - entry.vis_vertices, - device=device, - ) - if remap is None: - logger.warning( - "Volume deformable '%s' could not build a sim-to-visual barycentric remap; " - "renderers may fall back to rest-pose visual geometry.", - entry.root_path, - ) - return remap - +from isaaclab.scene_data.deformable_discovery import DeformableStageEntry -def _expand_clone_plan_deformable_entries( - entries: Sequence[DeformableStageEntry], - clone_plan: ClonePlan, - rows: tuple[int, ...], -) -> list[DeformableStageEntry]: - """Expand prototype deformables into every destination selected by a clone plan. - Kit-less replication may retain only a source deformable on the USD stage. The - shadow builder nevertheless needs one deformable particle block and one visual - mesh binding for every cloned environment. - """ - expanded: dict[str, DeformableStageEntry] = {} - for entry in entries: - for source_idx, (source, destination) in enumerate( - zip(clone_plan.sources, clone_plan.destinations, strict=True) - ): - if not under(entry.root_path, source): - continue - if source_idx not in rows: - break - columns = np.flatnonzero(clone_plan.clone_mask[source_idx]) - for col in columns: - target = destination.format(int(clone_plan.env_ids[col])) - offset = ( - 0 if clone_plan.positions is None else clone_plan.positions[col] - clone_plan.positions[columns[0]] - ) - cloned_entry = replace( - entry, - root_path=rebase(entry.root_path, source, target), - sim_mesh_path=rebase(entry.sim_mesh_path, source, target), - vis_mesh_path=rebase(entry.vis_mesh_path, source, target), - init_pos=tuple(np.asarray(entry.init_pos) + offset), - ) - expanded.setdefault(cloned_entry.root_path, cloned_entry) - break - else: - expanded.setdefault(entry.root_path, entry) - - return list(expanded.values()) - - -def add_shadow_deformables_to_builder( - builder: ModelBuilder, - stage: Usd.Stage, - entries: Sequence[DeformableStageEntry], - clone_plan: ClonePlan, - rows: tuple[int, ...], - *, - device: str = "cpu", -) -> tuple[list[ShadowDeformableEntity], list[ShadowDeformableRegistryGroup]]: - """Add PhysX/OVPhysX deformable meshes to a shadow Newton builder. - - Volume deformables with a sim-to-visual barycentric remap are added as visual - triangle meshes (:meth:`~newton.ModelBuilder.add_cloth_mesh`) so Newton Warp and - OVRTX render the paired visual mesh rather than the tet simulation topology. +def add_shadow_deformables_to_builder(builder: ModelBuilder, entries: list[DeformableStageEntry]) -> dict[str, int]: + """Build declared visual geometry and return its native particle destinations. Args: - builder: Shadow :class:`~newton.ModelBuilder` under construction. - stage: Current USD stage. - entries: Deformable geometry imported from declared clone sources and shared roots. - clone_plan: Replication layout used to expand prototypes into destination environments. - rows: Plan rows routed to this Newton representation. - device: Warp device for barycentric remap tables uploaded during shadow build. + builder: Render-only Newton model under construction. + entries: Imported geometry expanded to this representation's destination instances. Returns: - Flat entity list for geometry mapping and grouped registry metadata for - USD visual-mesh point bindings (e.g. OVRTX). + Exact visual mesh paths mapped to their first render-particle indices. + SDP supplies the corresponding world-space points after physics initialization. """ - if not entries: - return [], [] - wildcard_groups: dict[tuple[str, str, str], list[DeformableStageEntry]] = {} + offsets = {} for entry in entries: - # Discovery bakes vertices into the source root's parent frame. Retain that - # pose and translate clones through the plan, without fetching destination prims. - pos, quat = resolve_prim_pose(stage.GetPrimAtPath(entry.root_path).GetParent()) - entry = replace(entry, init_pos=tuple(pos), init_rot=tuple(quat)) - key = (entry.root_path, entry.sim_mesh_path, entry.vis_mesh_path) - for source, destination in zip(clone_plan.sources, clone_plan.destinations, strict=True): - if under(entry.root_path, source): - key = tuple(rebase(path, source, destination.format("[^/]+")) for path in key) - break - wildcard_groups.setdefault(key, []).extend(_expand_clone_plan_deformable_entries([entry], clone_plan, rows)) - entries = [entry for group in wildcard_groups.values() for entry in group] - - flat_entities: list[ShadowDeformableEntity] = [] - registry_groups: list[ShadowDeformableRegistryGroup] = [] - sim_particle_cursor = 0 - - for (wildcard_root, wildcard_sim, wildcard_vis), group_entries in sorted(wildcard_groups.items()): - if not group_entries: - continue - template = group_entries[0] - uses_remap = _needs_volume_vis_remap(template) - render_count = template.vis_vertex_count if uses_remap else template.vertex_count - group = ShadowDeformableRegistryGroup( - prim_path=wildcard_root, - sim_mesh_prim_path=wildcard_sim, - vis_mesh_prim_path=wildcard_vis, - deformable_type=template.deformable_type, - particles_per_body=render_count, - register_usd_vis_point_bindings=uses_remap or template.sim_mesh_path == template.vis_mesh_path, - ) - - group_volume_vis_remap = _build_volume_vis_remap(template, device) if uses_remap else None - - for entry in sorted(group_entries, key=lambda item: item.root_path): - body_pos = wp.vec3(*entry.init_pos) - body_rot = wp.quat(*entry.init_rot) - - before_render = int(getattr(builder, "particle_count", 0)) - volume_vis_remap = None - - if entry.deformable_type == "surface": - builder.add_cloth_mesh( - pos=body_pos, - rot=body_rot, - scale=1.0, - vel=wp.vec3(0.0, 0.0, 0.0), - vertices=_vertices_to_wp_vec3(entry.vertices), - indices=_mesh_indices(entry.indices), - density=1.0, - tri_ke=1e4, - tri_ka=1e4, - tri_kd=1.5e-6, - edge_ke=5.0, - edge_kd=1e-2, - particle_radius=0.008, - ) - else: - # Build remap before allocating render slots so a failed embed can fall - # back to sim tet topology instead of leaving mismatched vis slots. - # Replicated clones share one template table (rest topology is identical). - volume_vis_remap = group_volume_vis_remap if _needs_volume_vis_remap(entry) else None - - if volume_vis_remap is not None: - builder.add_cloth_mesh( - pos=body_pos, - rot=body_rot, - scale=1.0, - vel=wp.vec3(0.0, 0.0, 0.0), - vertices=_vertices_to_wp_vec3(entry.vis_vertices), - indices=_mesh_indices(entry.vis_indices), - density=1.0, - tri_ke=1e4, - tri_ka=1e4, - tri_kd=1.5e-6, - edge_ke=5.0, - edge_kd=1e-2, - particle_radius=0.008, - ) - else: - builder.add_soft_mesh( - pos=body_pos, - rot=body_rot, - scale=1.0, - vel=wp.vec3(0.0, 0.0, 0.0), - vertices=_vertices_to_wp_vec3(entry.vertices), - indices=_mesh_indices(entry.indices), - density=1000.0, - k_mu=1e5, - k_lambda=1e5, - k_damp=0.0, - ) - - added_render = int(getattr(builder, "particle_count", 0)) - before_render - render_count = added_render if added_render > 0 else render_count - sim_count = entry.vertex_count - # Use the builder particle cursor so pre-existing particles (e.g. from - # USD import) keep shadow sync / OVRTX offsets aligned with particle_q. - entity = ShadowDeformableEntity( - root_path=entry.root_path, - sim_particle_offset=sim_particle_cursor, - sim_particle_count=sim_count, - vis_particle_offset=before_render, - vis_particle_count=render_count, - volume_vis_remap=volume_vis_remap, + offsets[entry.vis_mesh_path] = builder.particle_count + if entry.deformable_type == "surface" or entry.vis_mesh_path != entry.sim_mesh_path: + builder.add_cloth_mesh( + pos=wp.vec3(*entry.init_pos), + rot=wp.quat(*entry.init_rot), + scale=1.0, + vel=wp.vec3(0.0, 0.0, 0.0), + vertices=entry.vis_vertices, + indices=entry.vis_indices, + density=1.0, + tri_ke=1e4, + tri_ka=1e4, + tri_kd=1.5e-6, + edge_ke=5.0, + edge_kd=1e-2, + particle_radius=0.008, ) - sim_particle_cursor += sim_count - flat_entities.append(entity) - group.particle_offsets.append(entity.vis_particle_offset) - group.entities.append(entity) - - if group.entities: - group.particles_per_body = group.entities[0].vis_particle_count - if uses_remap: - # Only bind USD visual points when every body successfully remapped. - group.register_usd_vis_point_bindings = all( - entity.volume_vis_remap is not None for entity in group.entities - ) - if not group.register_usd_vis_point_bindings: - logger.warning( - "Skipping USD visual-mesh point bindings for %s deformable '%s'", - template.deformable_type, - wildcard_root, - ) - registry_groups.append(group) - - ordered_roots = [entry.root_path for entry in sort_deformable_entries_for_geometry_sync(entries)] - entity_by_root = {entity.root_path: entity for entity in flat_entities} - flat_entities = [entity_by_root[root_path] for root_path in ordered_roots] - - return flat_entities, registry_groups - - -def populate_shadow_deformable_registry( - manager_cls, - registry_groups: Sequence[ShadowDeformableRegistryGroup], -) -> None: - """Populate ``manager_cls._deformable_registry`` for USD visual-mesh point bindings. - - Under PhysX/OVPhysX sim, OVRTX (and any similar consumer) uses this registry to - bind authored visual-mesh ``points`` to shadow ``particle_q`` render slots. - - Args: - manager_cls: Physics manager class that owns ``_deformable_registry``. - registry_groups: Groups produced by :func:`add_shadow_deformables_to_builder`. - """ - try: - from isaaclab_contrib.deformable.deformable_object import DeformableRegistryEntry - except ImportError: - logger.debug("isaaclab_contrib deformable registry unavailable; skipping shadow registry population.") - return - - for group in registry_groups: - if not group.register_usd_vis_point_bindings: - continue - - manager_cls._deformable_registry.append( - DeformableRegistryEntry( - prim_path=group.prim_path, - sim_mesh_prim_path=group.sim_mesh_prim_path, - vis_mesh_prim_path=group.vis_mesh_prim_path, - vertices=[], - indices=[], - deformable_type=group.deformable_type, - init_pos=(0.0, 0.0, 0.0), - init_rot=(0.0, 0.0, 0.0, 1.0), - particle_offsets=list(group.particle_offsets), - particles_per_body=group.particles_per_body, + else: + builder.add_soft_mesh( + pos=wp.vec3(*entry.init_pos), + rot=wp.quat(*entry.init_rot), + scale=1.0, + vel=wp.vec3(0.0, 0.0, 0.0), + vertices=entry.vertices, + indices=entry.indices, + density=1000.0, + k_mu=1e5, + k_lambda=1e5, + k_damp=0.0, ) - ) + return offsets diff --git a/source/isaaclab_newton/isaaclab_newton/sim/spawners/mpm/mpm.py b/source/isaaclab_newton/isaaclab_newton/sim/spawners/mpm/mpm.py index 8f02f0b844aa..2c96b0e37f24 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/spawners/mpm/mpm.py +++ b/source/isaaclab_newton/isaaclab_newton/sim/spawners/mpm/mpm.py @@ -35,8 +35,8 @@ def spawn_mpm_particles( The asset root remains an ``Xform`` for normal Isaac Lab pose and cloning workflows. Explicit simulation points and their physics material are authored below it and imported by Newton during scene replication. The simulation - points stay invisible because :class:`~isaaclab_newton.assets.MPMObject` - maintains a separate mutable render cloud at ``/Particles``. + points stay invisible. A render cloud at ``/Particles`` is authored + alongside them so every backend clones the same declared geometry. Args: prim_path: Prim path or pattern at which to create the particle asset. @@ -59,9 +59,9 @@ def spawn_mpm_particles( raise TypeError(f"Unsupported MPM particle spawner config type: {type(cfg).__name__}") cfg.spawn_path = prim_path - from pxr import Sdf, UsdGeom, UsdShade, Vt # noqa: PLC0415 + from pxr import Gf, Sdf, UsdGeom, UsdShade, Vt # noqa: PLC0415 - from isaaclab.sim.utils import get_current_stage # noqa: PLC0415 + from isaaclab.sim.utils import bind_visual_material, get_current_stage # noqa: PLC0415 stage = get_current_stage() scene_prim = _find_owning_mpm_scene(stage) @@ -90,6 +90,21 @@ def spawn_mpm_particles( material = UsdShade.Material.Define(stage, f"{prim_path}{_PHYSICS_MATERIAL_SUFFIX}") _author_mpm_material(material.GetPrim(), cfg.material) UsdShade.MaterialBindingAPI.Apply(points_prim).Bind(material, materialPurpose="physics") + if cfg.visible: + visual_path = f"{prim_path}/Particles" + visual_points = UsdGeom.Points.Define(stage, visual_path) + visual_points.CreatePointsAttr(points.GetPointsAttr().Get()) + visual_points.CreateWidthsAttr(points.GetWidthsAttr().Get()) + visual_points.SetWidthsInterpolation(UsdGeom.Tokens.vertex) + visual_points.CreateDisplayColorAttr([Gf.Vec3f(*(float(value) for value in cfg.visual_color))]) + visual_points.GetPrim().CreateAttribute("isaaclab:pointsUpdateFrequency", Sdf.ValueTypeNames.Int).Set( + cfg.visual_update_frequency + ) + if cfg.visual_material is not None: + UsdGeom.Scope.Define(stage, f"{prim_path}/Looks") + material_path = f"{prim_path}/Looks/visualMaterial" + cfg.visual_material.func(material_path, cfg.visual_material) + bind_visual_material(visual_path, material_path, stage=stage) return root_prim diff --git a/source/isaaclab_newton/isaaclab_newton/sim/spawners/mpm/visualization.py b/source/isaaclab_newton/isaaclab_newton/sim/spawners/mpm/visualization.py index 5753fcdd795a..8aaaf74a1fe4 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/spawners/mpm/visualization.py +++ b/source/isaaclab_newton/isaaclab_newton/sim/spawners/mpm/visualization.py @@ -22,8 +22,7 @@ def create_mpm_particle_visualization( """Create one ``UsdGeom.Points`` prim per environment for USD-stage MPM particle rendering. The created prims are static USD containers: per-frame position updates are - handled by :meth:`isaaclab_newton.physics.NewtonManager.sync_particles_to_usd` - for prims registered via + handled by rendering consumers through SDP for prims registered via :meth:`isaaclab_newton.physics.NewtonManager.register_particle_visual_prim`. Args: diff --git a/source/isaaclab_newton/test/assets/test_mpm_object.py b/source/isaaclab_newton/test/assets/test_mpm_object.py index 92332751b6d1..bc54cd73ccfe 100644 --- a/source/isaaclab_newton/test/assets/test_mpm_object.py +++ b/source/isaaclab_newton/test/assets/test_mpm_object.py @@ -13,25 +13,17 @@ newton = pytest.importorskip("newton") -from isaaclab_newton.assets.mpm_object import MPMObject, MPMObjectCfg +from isaaclab_newton.assets.mpm_object import MPMObjectCfg from isaaclab_newton.physics import MPMSolverCfg, NewtonCfg, NewtonMPMManager from isaaclab_newton.sim.spawners.mpm import MPMGridCfg from isaaclab.assets import RigidObjectCfg +from isaaclab.cloner.query import iter_sources from isaaclab.scene import InteractiveScene, InteractiveSceneCfg from isaaclab.sim import SimulationCfg, build_simulation_context from isaaclab.utils import configclass -def test_mpm_object_cfg_resolves_asset_class(): - cfg = MPMObjectCfg( - prim_path="{ENV_REGEX_NS}/Sand", - spawn=MPMGridCfg(lower=(0.0, 0.0, 0.0), upper=(0.1, 0.1, 0.1), voxel_size=0.1), - ) - - assert cfg.class_type.__name__ == MPMObject.__name__ - - def test_mpm_object_initializes_from_interactive_scene(): @configclass class MPMSceneCfg(InteractiveSceneCfg): @@ -61,7 +53,7 @@ class MPMSceneCfg(InteractiveSceneCfg): assert media.num_instances == 2 assert media.particles_per_object == 1 assert media.data.particle_pos_w.torch.shape == (2, 1, 3) - assert not NewtonMPMManager._particle_visual_prims + assert not sim.get_scene_data_provider().get_geometry_points() default_state = media.data.default_particle_state_w.torch.clone() shifted_state = default_state[0:1].clone() @@ -129,7 +121,7 @@ class MPMSceneCfg(InteractiveSceneCfg): np.testing.assert_allclose(body_q, root_pose.detach().cpu().numpy()[0], rtol=1.0e-5, atol=1.0e-6) -def test_mpm_object_creates_usd_points_without_kit_visualizer(monkeypatch): +def test_mpm_object_publishes_points_without_kit_visualizer(): @configclass class MPMSceneCfg(InteractiveSceneCfg): media = MPMObjectCfg( @@ -150,7 +142,6 @@ class MPMSceneCfg(InteractiveSceneCfg): ) with build_simulation_context(sim_cfg=sim_cfg) as sim: - monkeypatch.setattr(sim, "resolve_visualizer_types", lambda: ["newton"]) scene = InteractiveScene(MPMSceneCfg(num_envs=2, env_spacing=1.0)) from pxr import UsdGeom # noqa: PLC0415 @@ -158,66 +149,32 @@ class MPMSceneCfg(InteractiveSceneCfg): sim.reset() media = scene["media"] - records = NewtonMPMManager._particle_visual_prims - assert len(records) == media.num_instances - + provider = sim.get_scene_data_provider() + publication = provider.get_geometry_points() expected_paths = [f"/World/envs/env_{env_idx}/Sand/Particles" for env_idx in range(media.num_instances)] - assert list(records) == expected_paths + assert list(publication) == expected_paths - for env_idx, prim_path in enumerate(expected_paths): - record = records[prim_path] - assert record.offset == media._recorded_particle_offsets[env_idx] - assert record.count == media.particles_per_object - assert record.sync_frequency == 1 - - points_prim = media.stage.GetPrimAtPath(prim_path) + for _, _, source_path, _ in iter_sources(sim.get_clone_plan(), media.cfg.prim_path): + points_prim = media.stage.GetPrimAtPath(source_path + "/Particles") assert points_prim.IsValid() points = UsdGeom.Points(points_prim) - assert points.GetResetXformStack() + assert not points.GetResetXformStack() assert len(points.GetPointsAttr().Get()) == media.particles_per_object assert len(points.GetWidthsAttr().Get()) == media.particles_per_object assert tuple(points.GetDisplayColorAttr().Get()[0]) == pytest.approx((0.1, 0.2, 0.3)) - -def test_mpm_usd_points_follow_particle_state(monkeypatch): - @configclass - class MPMSceneCfg(InteractiveSceneCfg): - media = MPMObjectCfg( - prim_path="{ENV_REGEX_NS}/Sand", - spawn=MPMGridCfg( - lower=(0.0, 0.0, 0.1), - upper=(0.1, 0.1, 0.2), - voxel_size=0.05, - visual_color=(0.1, 0.2, 0.3), - ), - ) - - sim_cfg = SimulationCfg( - dt=1.0 / 60.0, - device="cuda:0", - gravity=(0.0, 0.0, -9.81), - physics=NewtonCfg(solver_cfg=MPMSolverCfg(max_iterations=2, voxel_size=0.05), use_cuda_graph=False), - ) - - with build_simulation_context(sim_cfg=sim_cfg) as sim: - monkeypatch.setattr(sim, "resolve_visualizer_types", lambda: ["newton"]) - scene = InteractiveScene(MPMSceneCfg(num_envs=1, env_spacing=0.0)) - sim.reset() - - from pxr import UsdGeom # noqa: PLC0415 - - media = scene["media"] - prim_path = next(iter(NewtonMPMManager._particle_visual_prims)) - points = UsdGeom.Points(media.stage.GetPrimAtPath(prim_path)) - points_before = np.asarray(points.GetPointsAttr().Get(), dtype=np.float32) - + before = np.stack([values.numpy() for values in publication.values()]) for _ in range(3): sim.step(render=False) scene.update(sim.get_physics_dt()) - sim.render() - - points_after = np.asarray(points.GetPointsAttr().Get(), dtype=np.float32) - particle_pos = media.data.particle_pos_w.torch.detach().cpu().numpy()[0] - - assert np.max(np.abs(points_after - points_before)) > 0.0 - np.testing.assert_allclose(points_after, particle_pos, rtol=1.0e-5, atol=1.0e-6) + publication = provider.get_geometry_points() + actual = np.stack([values.numpy() for values in publication.values()]) + expected = media.data.particle_pos_w.torch.cpu().numpy() + assert np.any(actual != before) + np.testing.assert_allclose(actual, expected, atol=1.0e-6) + + state = media.data.particle_state_w.torch.clone() + state[..., 2] += 0.05 + media.write_particle_state_to_sim_index(state) + actual = np.stack([values.numpy() for values in provider.get_geometry_points().values()]) + np.testing.assert_allclose(actual, state[..., :3].cpu().numpy(), atol=1.0e-6) diff --git a/source/isaaclab_newton/test/cloner/test_rename_builder_labels.py b/source/isaaclab_newton/test/cloner/test_rename_builder_labels.py index d9f121bb4956..e536935ed642 100644 --- a/source/isaaclab_newton/test/cloner/test_rename_builder_labels.py +++ b/source/isaaclab_newton/test/cloner/test_rename_builder_labels.py @@ -22,111 +22,17 @@ from pxr import Sdf, Usd, UsdGeom, UsdPhysics from isaaclab.cloner import ClonePlan -from isaaclab.scene_data.deformable_discovery import DeformableStageEntry - -_VIS_LABEL_SUFFIXES = { - "body_label": "Body", - "joint_label": "Joint", - "shape_label": "Shape", - "articulation_label": "Articulation", - "constraint_mimic_label": "ConstraintMimic", - "equality_constraint_label": "EqualityConstraint", -} -# Equality constraints live in custom attributes (like real newton), not plain builder lists. -_VIS_BUILTIN_LABEL_ATTRS = tuple(attr for attr in _VIS_LABEL_SUFFIXES if attr != "equality_constraint_label") -_VIS_EQ_FREQ = "mujoco:equality_constraint" +from isaaclab.scene_data.deformable_discovery import ( + DeformableStageEntry, + deformable_prototypes, + expand_deformable_entries, +) +from isaaclab.sim.schemas import define_deformable_curve_properties _SRC = "/World/envs/env_0/protoA" _DST = "/World/envs/env_{}/protoA" -class _FakeVisualizationModelBuilder: - def __init__(self, up_axis=None): - self.up_axis = up_axis - self.shape_collision_filter_pairs = [] - self.shape_collision_group = [] - for attr in _VIS_BUILTIN_LABEL_ATTRS: - setattr(self, attr, []) - setattr(self, attr.replace("_label", "_world"), []) - self.custom_attributes = { - "mujoco:equality_constraint_label": newton.ModelBuilder.CustomAttribute( - name="equality_constraint_label", frequency=_VIS_EQ_FREQ, dtype=str, default="", namespace="mujoco" - ), - "mujoco:equality_constraint_world": newton.ModelBuilder.CustomAttribute( - name="equality_constraint_world", - frequency=_VIS_EQ_FREQ, - dtype=int, - default=0, - namespace="mujoco", - references="world", - ), - } - self.custom_frequencies = {} - self.geometry_sources = [] - self.world_slices = [] - self._current_world = None - - @property - def shape_count(self): - return len(self.shape_label) - - def begin_world(self): - self._current_world = len(self.world_slices) - self.world_slices.append([]) - - def end_world(self): - self._current_world = None - - def add_usd(self, stage, root_path=None, ignore_paths=None, schema_resolvers=None, **kwargs): - del stage, ignore_paths, schema_resolvers, kwargs - if root_path is None: - return {"path_shape_map": {}} - label_start = len(self.body_label) - geometry_start = len(self.geometry_sources) - for attr in _VIS_BUILTIN_LABEL_ATTRS: - getattr(self, attr).append(f"{root_path}/{_VIS_LABEL_SUFFIXES[attr]}") - getattr(self, attr.replace("_label", "_world")).append(self._current_world or 0) - self.shape_collision_group.append(1) - self.custom_attributes["mujoco:equality_constraint_label"].values.append( - f"{root_path}/{_VIS_LABEL_SUFFIXES['equality_constraint_label']}" - ) - self.custom_attributes["mujoco:equality_constraint_world"].values.append(self._current_world or 0) - self.geometry_sources.append(root_path) - self._record_world_slice(label_start, len(self.body_label), geometry_start, len(self.geometry_sources)) - return {"path_shape_map": {}} - - def add_builder(self, builder, xform=None): - del xform - label_start = len(self.body_label) - geometry_start = len(self.geometry_sources) - for attr in _VIS_BUILTIN_LABEL_ATTRS: - labels = getattr(builder, attr) - getattr(self, attr).extend(labels) - getattr(self, attr.replace("_label", "_world")).extend([self._current_world] * len(labels)) - self.shape_collision_group.extend(builder.shape_collision_group) - eq_labels = builder.custom_attributes["mujoco:equality_constraint_label"].values - self.custom_attributes["mujoco:equality_constraint_label"].values.extend(eq_labels) - self.custom_attributes["mujoco:equality_constraint_world"].values.extend([self._current_world] * len(eq_labels)) - self.geometry_sources.extend(builder.geometry_sources) - self._record_world_slice(label_start, len(self.body_label), geometry_start, len(self.geometry_sources)) - - def labels_for_world(self, world_id, attr): - if attr == "equality_constraint_label": - labels = self.custom_attributes["mujoco:equality_constraint_label"].values - else: - labels = getattr(self, attr) - return [label for start, end, _, _ in self.world_slices[world_id] for label in labels[start:end]] - - def geometry_sources_for_world(self, world_id): - return [ - source for _, _, start, end in self.world_slices[world_id] for source in self.geometry_sources[start:end] - ] - - def _record_world_slice(self, label_start, label_end, geometry_start, geometry_end): - if self._current_world is not None: - self.world_slices[self._current_world].append((label_start, label_end, geometry_start, geometry_end)) - - def _make_builder(worlds: list[int]) -> newton.ModelBuilder: builder = newton.ModelBuilder() builder.shape_label.extend(f"{_SRC}/shape_{world}" for world in worlds) @@ -231,27 +137,19 @@ def test_other_shape_attributes_without_world_references_pass_through(self): class TestReplicateBuilderMapping(unittest.TestCase): - @staticmethod - def _source_builder(root_path: str): - builder = _FakeVisualizationModelBuilder() - builder.add_usd(None, root_path=root_path) - return builder - - def test_source_local_sites_batched_with_correct_indices(self): + def test_local_and_env_root_sites_keep_indices_labels_and_world_positions(self): source_path, destination = "/World/envs/env_0", "/World/envs/env_{}" source = newton.ModelBuilder() source.add_body(xform=wp.transform((2.0, 0.0, 0.0), wp.quat_identity())) site_idx = source.add_site(body=0, xform=wp.transform(), label="ee") + root_site_idx = source.shape_count # Non-zero base so site indices are not trivially zero-based. builder = newton.ModelBuilder() builder.add_body(xform=wp.transform()) builder.add_shape(body=0, type=newton.GeoType.SPHERE) base_shape = builder.shape_count - stride = source.shape_count - positions = np.array([[2.0, 0.0, 0.0], [5.0, 0.0, 0.0], [8.0, 0.0, 0.0]], dtype=np.float32) - quaternions = np.array([[0.0, 0.0, 0.0, 1.0]] * 3, dtype=np.float32) with mock.patch.object(builder, "replicate", wraps=builder.replicate) as replicate: local_site_map, _, _ = replicate_builder_mapping( @@ -259,63 +157,34 @@ def test_source_local_sites_batched_with_correct_indices(self): (source_path,), np.ones((1, 3), dtype=np.bool_), positions, - quaternions, + np.array([[0.0, 0.0, 0.0, 1.0]] * 3, dtype=np.float32), {source_path: source}, destinations=(destination,), env_ids=np.arange(3, dtype=np.int64), source_site_indices={id(source): {"ee": [site_idx]}}, + env_root_sites={"origin": wp.transform((0.1, 0.0, 0.0), wp.quat_identity())}, ) replicate.assert_called_once() - self.assertEqual( - local_site_map["ee"], - [[base_shape + world * stride + site_idx] for world in range(3)], - ) - for world, world_indices in enumerate(local_site_map["ee"]): - self.assertEqual(builder.shape_label[world_indices[0]], f"/World/envs/env_{world}/ee") - - def test_env_root_sites_batched_at_correct_world_positions(self): - source_path, destination = "/World/envs/env_0", "/World/envs/env_{}" - source = newton.ModelBuilder() - source.add_body(xform=wp.transform((2.0, 0.0, 0.0), wp.quat_identity())) - - builder = newton.ModelBuilder() - base_shape = builder.shape_count - positions = np.array([[2.0, 0.0, 0.0], [5.0, 0.0, 0.0], [8.0, 0.0, 0.0]], dtype=np.float32) - quaternions = np.array([[0.0, 0.0, 0.0, 1.0]] * 3, dtype=np.float32) - env_root_offset = wp.transform((0.1, 0.0, 0.0), wp.quat_identity()) - - with mock.patch.object(builder, "replicate", wraps=builder.replicate) as replicate: - local_site_map, _, _ = replicate_builder_mapping( - builder, - (source_path,), - np.ones((1, 3), dtype=np.bool_), - positions, - quaternions, - {source_path: source}, - destinations=(destination,), - env_ids=np.arange(3, dtype=np.int64), - env_root_sites={"origin": env_root_offset}, + for name, index in (("ee", site_idx), ("origin", root_site_idx)): + self.assertEqual( + local_site_map[name], [[base_shape + world * source.shape_count + index] for world in range(3)] ) - - replicate.assert_called_once() - stride = source.shape_count - self.assertEqual(source.shape_count, 1) - self.assertEqual( - local_site_map["origin"], - [[base_shape + world * stride] for world in range(3)], + for world, (index,) in enumerate(local_site_map[name]): + self.assertEqual(builder.shape_label[index], f"/World/envs/env_{world}/{name}") + np.testing.assert_allclose( + [tuple(builder.shape_transform[index].p) for (index,) in local_site_map["origin"]], + positions + [0.1, 0.0, 0.0], + atol=1e-6, ) - for world, world_indices in enumerate(local_site_map["origin"]): - site_pos = builder.shape_transform[world_indices[0]].p - self.assertAlmostEqual(float(site_pos[0]), float(positions[world][0]) + 0.1, places=5) - self.assertAlmostEqual(float(site_pos[1]), 0.0, places=5) - self.assertEqual(builder.shape_label[world_indices[0]], f"/World/envs/env_{world}/origin") def test_inactive_source_rows_are_ignored(self): sources = ("/World/envs/env_0/inactive", "/World/envs/env_0/active") - source_builders = {source: self._source_builder(source) for source in sources} - source_builders[sources[0]].body_label.append("/outside/the/plan") - builder = _FakeVisualizationModelBuilder() + source_builders = {source: newton.ModelBuilder() for source in sources} + for path, source in source_builders.items(): + source.add_body(label=path) + source_builders[sources[0]].add_body(label="/outside/the/plan") + builder = newton.ModelBuilder() replicate_builder_mapping( builder, @@ -328,8 +197,9 @@ def test_inactive_source_rows_are_ignored(self): env_ids=np.arange(2, dtype=np.int64), ) - self.assertEqual(builder.geometry_sources_for_world(0), ["/World/envs/env_0/active"]) - self.assertEqual(builder.geometry_sources_for_world(1), []) + self.assertEqual(builder.body_label, ["/World/envs/env_0/active"]) + self.assertEqual(builder.body_world, [0]) + self.assertEqual(builder.world_count, 2) class TestVisualizationClonePlan(unittest.TestCase): @@ -341,34 +211,6 @@ def setUp(self): physics_manager=SimpleNamespace(register_callback=mock.Mock()), ) - def test_clone_plan_expands_prototype_deformables_to_selected_environments(self): - entry = DeformableStageEntry( - root_path="/World/envs/env_0/Deformable", - sim_mesh_path="/World/envs/env_0/Deformable/simulation_mesh", - vis_mesh_path="/World/envs/env_0/Deformable/visual_mesh", - deformable_type="surface", - vertex_count=3, - vis_vertex_count=3, - ) - clone_plan = ClonePlan( - sources=("/World/envs/env_0",), - destinations=("/World/envs/env_{}",), - clone_mask=np.ones((1, 4), dtype=np.bool_), - env_ids=np.arange(4, dtype=np.int64), - positions=np.zeros((4, 3), dtype=np.float32), - ) - - entries = visualization_deformables_module._expand_clone_plan_deformable_entries([entry], clone_plan, (0,)) - - self.assertEqual( - [entry.root_path for entry in entries], - [f"/World/envs/env_{env_id}/Deformable" for env_id in range(4)], - ) - self.assertEqual( - [entry.vis_mesh_path for entry in entries], - [f"/World/envs/env_{env_id}/Deformable/visual_mesh" for env_id in range(4)], - ) - @staticmethod def _define_xform(stage, path, translation=None): xform = UsdGeom.Xform.Define(stage, path) @@ -418,6 +260,48 @@ def test_visualization_builder_imports_only_declared_global_roots(self): offset = np.zeros(3) if positions is None else positions[1] - positions[0] np.testing.assert_allclose(target_position - source_position, offset) + def test_cable_import_binds_only_supported_native_instances_without_destination_prims(self): + stage = self.sim.stage = Usd.Stage.CreateInMemory() + source = "/Scene/copy_7/Rope" + shared = ("/Scene/SharedRope", "/Scene/PeriodicRope", "/Scene/MultiRope", "/Scene/CubicRope", "/Scene/OnePoint") + for path in (source, "/Scene/copy_7/OtherRope", *shared): + curve = UsdGeom.BasisCurves.Define(stage, path) + points, counts = [(0, 0, 0), (0, 1, 0), (1, 1, 0)], [3] + if path.endswith("MultiRope"): + points, counts = points * 2, [3, 3] + elif path.endswith("OnePoint"): + points, counts = points[:1], [1] + curve.CreatePointsAttr(points) + curve.CreateCurveVertexCountsAttr(counts) + curve.CreateTypeAttr(UsdGeom.Tokens.cubic if path.endswith("CubicRope") else UsdGeom.Tokens.linear) + curve.CreateWrapAttr( + UsdGeom.Tokens.periodic if path.endswith("PeriodicRope") else UsdGeom.Tokens.nonperiodic + ) + curve.CreateWidthsAttr([0.02]) + curve.SetWidthsInterpolation(UsdGeom.Tokens.constant) + define_deformable_curve_properties(path, stage) + plan = ClonePlan( + sources=(source, "/Scene/copy_7/OtherRope"), + destinations=("/Scene/copy_{}/Rope", "/Scene/copy_{}/OtherRope"), + clone_mask=np.ones((2, 2), dtype=np.bool_), + env_ids=np.array([7, 12]), + global_paths=shared, + context_rows={NewtonReplicateContext: (0,)}, + ) + builder, _, _ = NewtonReplicateContext(self.sim).replicate(plan) + model = builder.finalize(device="cpu") + with mock.patch( + "isaaclab_newton.physics.newton_manager.get_current_stage", + side_effect=AssertionError("Stage discovery is not a binding input."), + ): + bindings = replicate_module.NewtonManager.collect_cable_segment_shape_ids() + self.assertEqual(set(bindings), {"/Scene/SharedRope", source, "/Scene/copy_12/Rope"}) + for path, shape_ids in bindings.items(): + self.assertEqual( + [model.shape_label[index] for index in shape_ids], [f"{path}_edge_capsule_{i}" for i in range(2)] + ) + self.assertFalse(stage.GetPrimAtPath("/Scene/copy_12/Rope")) + def test_visualization_builder_disables_collision_pairs(self): stage = Usd.Stage.CreateInMemory() self.sim.stage = stage @@ -465,9 +349,9 @@ def test_visualization_builder_uses_clone_plan_sources_and_rewrites_labels(self) env_paths = [(env_id, f"/World/envs/env_{env_id}") for env_id in (0, 1)] for env_id, env_path in env_paths: self._define_xform(stage, env_path, (float(env_id) * 3.0, 0.0, 0.0)) - self._define_xform(stage, f"{env_path}/Object") - self._define_xform(stage, "/World/envs/env_0/Object/source_0_visual") - self._define_xform(stage, "/World/envs/env_1/Object/source_1_visual") + body = UsdGeom.Xform.Define(stage, f"{env_path}/Object") + UsdPhysics.RigidBodyAPI.Apply(body.GetPrim()) + UsdGeom.Cube.Define(stage, f"{env_path}/Object/source_{env_id}_visual").CreateSizeAttr(0.2) clone_plan = ClonePlan( sources=("/World/envs/env_0/Object", "/World/envs/env_1/Object"), @@ -478,43 +362,27 @@ def test_visualization_builder_uses_clone_plan_sources_and_rewrites_labels(self) context_rows={NewtonReplicateContext: (0, 1)}, ) - with ( - mock.patch.object(replicate_module, "ModelBuilder", _FakeVisualizationModelBuilder), - mock.patch.object(newton_clone_utils_module, "ModelBuilder", _FakeVisualizationModelBuilder), - mock.patch.object(replicate_module, "import_builder_visual_material_paths"), - mock.patch.object(newton_clone_utils_module, "import_builder_visual_material_paths"), - mock.patch.object(newton_clone_utils_module, "replace_newton_builder_shape_colors"), - ): - builder, _, _ = NewtonReplicateContext(self.sim).replicate(clone_plan) - + builder, _, _ = NewtonReplicateContext(self.sim).replicate(clone_plan) + self.assertEqual(builder.body_label, [f"/World/envs/env_{i}/Object" for i in range(3)]) self.assertEqual( - [builder.geometry_sources_for_world(i) for i in range(3)], - [["/World/envs/env_0/Object"], ["/World/envs/env_1/Object"], ["/World/envs/env_0/Object"]], + builder.shape_label, + [f"/World/envs/env_{i}/Object/source_{source}_visual" for i, source in enumerate((0, 1, 0))], ) - for attr, suffix in _VIS_LABEL_SUFFIXES.items(): - self.assertEqual( - [builder.labels_for_world(i, attr) for i in range(3)], - [ - [f"/World/envs/env_0/Object/{suffix}"], - [f"/World/envs/env_1/Object/{suffix}"], - [f"/World/envs/env_2/Object/{suffix}"], - ], - ) + self.assertEqual(builder.body_world, [0, 1, 2]) + self.assertEqual(builder.shape_world, [0, 1, 2]) def test_shadow_deformables_use_plan_placement_without_destination_prims(self): stage = Usd.Stage.CreateInMemory() self._define_xform(stage, "/Scene/copy_7", (10.0, 0.0, 0.0)) self._define_xform(stage, "/Scene/copy_7/Parent", (2.0, 0.0, 0.0)) - self._define_xform(stage, "/Scene/copy_7/Parent/Cloth", (3.0, 0.0, 0.0)) path = "/Scene/copy_7/Parent/Cloth" - entry = DeformableStageEntry( - root_path=path, - sim_mesh_path=f"{path}/Mesh", - vis_mesh_path=f"{path}/Mesh", - deformable_type="surface", - vertex_count=3, - vis_vertex_count=3, - ) + vertices = np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0]], dtype=np.float32) + cloth = UsdGeom.Mesh.Define(stage, path) + cloth.AddTranslateOp().Set((3.0, 0.0, 0.0)) + cloth.GetPrim().SetMetadata("apiSchemas", Sdf.TokenListOp.CreateExplicit(["OmniPhysicsDeformableBodyAPI"])) + cloth.CreatePointsAttr(vertices) + cloth.CreateFaceVertexCountsAttr([3]) + cloth.CreateFaceVertexIndicesAttr([0, 1, 2]) plan = ClonePlan( sources=(path,), destinations=("/Scene/copy_{}/Parent/Cloth",), @@ -524,19 +392,49 @@ def test_shadow_deformables_use_plan_placement_without_destination_prims(self): ) for positions in (plan.positions, None): with self.subTest(positions=positions): - builder = mock.Mock(particle_count=0) - entities, groups = visualization_deformables_module.add_shadow_deformables_to_builder( - builder, stage, [entry], replace(plan, positions=positions), (0,) + builder = newton.ModelBuilder() + offsets = visualization_deformables_module.add_shadow_deformables_to_builder( + builder, + expand_deformable_entries(replace(plan, positions=positions), deformable_prototypes(stage, plan)), ) - - self.assertEqual([entity.root_path for entity in entities], ["/Scene/copy_12/Parent/Cloth", path]) - self.assertEqual(groups[0].prim_path, "/Scene/copy_[^/]+/Parent/Cloth") + self.assertEqual(offsets, {path: 0, "/Scene/copy_12/Parent/Cloth": 3}) + self.assertFalse(stage.GetPrimAtPath("/Scene/copy_12")) offset = np.zeros(3) if positions is None else positions[2] - positions[0] - parent_position = np.array([12.0, 0.0, 0.0]) - np.testing.assert_allclose( - [tuple(call.kwargs["pos"]) for call in builder.add_cloth_mesh.call_args_list], - [parent_position + offset, parent_position], - ) + # Root translation is baked into vertices, not applied a second time at placement. + source_points = vertices + [15.0, 0.0, 0.0] + np.testing.assert_allclose(builder.particle_q, np.concatenate((source_points, source_points + offset))) + + def test_shadow_visual_topologies_keep_heterogeneous_offsets(self): + vertices = np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0], [0, 0, 1], [1, 0, 1], [0, 1, 1]]) + entries = tuple( + DeformableStageEntry( + root_path=f"/Source/{name}", + sim_mesh_path=f"/Source/{name}/Simulation", + vis_mesh_path=f"/Source/{name}/Visual", + deformable_type="volume", + vertex_count=4, + vis_vertex_count=count, + vis_vertices=vertices[:count], + vis_indices=np.arange(count), + ) + for name, count in (("A", 3), ("B", 6)) + ) + plan = ClonePlan( + sources=tuple(entry.root_path for entry in entries), + destinations=("/Copies/{}/Body",) * 2, + env_ids=np.array([2, 10, 30]), + clone_mask=np.array([[True, False, True], [False, True, False]]), + ) + builder = newton.ModelBuilder() + builder.add_particle(pos=wp.vec3(), vel=wp.vec3(), mass=1.0) + offsets = visualization_deformables_module.add_shadow_deformables_to_builder( + builder, expand_deformable_entries(plan, entries, (0, 1)) + ) + self.assertEqual( + offsets, {"/Copies/2/Body/Visual": 1, "/Copies/30/Body/Visual": 4, "/Copies/10/Body/Visual": 7} + ) + self.assertEqual(builder.particle_count, 13) + self.assertEqual(len(builder.tri_indices), 4) class TestReplicationNamesItsCopies(unittest.TestCase): diff --git a/source/isaaclab_newton/test/physics/test_newton_fabric_body_sync.py b/source/isaaclab_newton/test/physics/test_newton_fabric_body_sync.py index 000a13bda17f..7002b3973bfc 100644 --- a/source/isaaclab_newton/test/physics/test_newton_fabric_body_sync.py +++ b/source/isaaclab_newton/test/physics/test_newton_fabric_body_sync.py @@ -8,12 +8,14 @@ from __future__ import annotations import contextlib +from types import SimpleNamespace from isaaclab.app import AppLauncher # Launch Isaac Sim before importing Newton modules so USD schema bindings are initialized. simulation_app = AppLauncher(headless=True, enable_cameras=True).app +import numpy as np import pytest import torch import warp as wp @@ -25,13 +27,14 @@ from isaaclab_visualizers.kit import KitVisualizerCfg from pxr import Gf as UsdGf -from pxr import UsdGeom -from usdrt import Gf, Rt +from pxr import Sdf, UsdGeom +from usdrt import Gf, Rt, Vt +from usdrt import Sdf as RtSdf -import isaaclab.cloner as cloner import isaaclab.sim as sim_utils from isaaclab.assets import AssetBaseCfg, CableObjectCfg, RigidObjectCfg from isaaclab.scene import InteractiveScene, InteractiveSceneCfg +from isaaclab.scene_data import SceneDataFormat, SceneDataProvider from isaaclab.sensors import CameraCfg from isaaclab.sim import SimulationCfg, build_simulation_context from isaaclab.sim.spawners.materials import CableMaterialCfg @@ -64,6 +67,7 @@ class _RenderSceneCfg(InteractiveSceneCfg): @configclass class _CableRenderSceneCfg(InteractiveSceneCfg): + camera: CameraCfg = _RenderSceneCfg().camera cable: CableObjectCfg = CableObjectCfg( prim_path="{ENV_REGEX_NS}/Cable", spawn=CableCfg( @@ -324,30 +328,80 @@ def test_nested_bodies_keep_independent_world_poses(): @pytest.mark.isaacsim_ci @pytest.mark.skipif(not wp.get_cuda_device_count(), reason="CUDA is unavailable") -def test_periodic_cable_is_skipped_by_fabric_sync(): - """Periodic cables must not abort unsupported Fabric synchronization.""" - sim_cfg = SimulationCfg( - dt=1.0 / 120.0, - device="cuda:0", - physics=NewtonCfg( - solver_cfg=VBDSolverCfg(iterations=2), - num_substeps=1, - use_cuda_graph=False, - ), - ) - - with build_simulation_context(sim_cfg=sim_cfg) as sim: - cable_cfg = _CableRenderSceneCfg(num_envs=1, env_spacing=1.0).cable.spawn - cable_cfg.func("/World/Cable", cable_cfg) - curve = UsdGeom.BasisCurves(sim_utils.get_current_stage().GetPrimAtPath("/World/Cable/geometry/mesh")) - curve.GetWrapAttr().Set(UsdGeom.Tokens.periodic) - - plan = cloner.make_clone_plan((), 1, 0.0, global_paths=("/World/Cable",)) - sim.set_clone_plan(plan) - cloner.replicate(plan) - sim.reset() - - assert NewtonManager._cable_shape_ids is None +def test_fabric_geometry_sink_uses_sdp_world_points_and_frame_cadence(): + """Mesh, curve and cloud sinks consume only named SDP buffers, independent of Newton internals.""" + cfg = SimulationCfg(device="cuda:0", physics=NewtonCfg(), visualizer_cfgs=[]) + with build_simulation_context(sim_cfg=cfg) as sim: + parent = UsdGeom.Xform.Define(sim.stage, "/World/Geometry") + parent.AddTranslateOp().Set(UsdGf.Vec3d(10.0, 20.0, 30.0)) + points = {} + for schema, name in ((UsdGeom.Mesh, "Mesh"), (UsdGeom.BasisCurves, "Curve"), (UsdGeom.Points, "Cloud")): + path = f"/World/Geometry/{name}" + geometry = schema.Define(sim.stage, path) + geometry.CreatePointsAttr([(0.0, 0.0, 0.0)] * 3) + geometry.AddTranslateOp().Set(UsdGf.Vec3d(1.0, 2.0, 3.0)) + if name == "Mesh": + geometry.CreateFaceVertexCountsAttr([3]) + geometry.CreateFaceVertexIndicesAttr([0, 1, 2]) + elif name == "Curve": + geometry.CreateCurveVertexCountsAttr([3]) + geometry.CreateTypeAttr("linear") + geometry.CreateWrapAttr("nonperiodic") + else: + geometry.GetPrim().CreateAttribute("isaaclab:pointsUpdateFrequency", Sdf.ValueTypeNames.Int).Set(3) + points[path] = wp.array([[1.0, 0.0, 2.0], [2.0, 0.0, 2.0], [3.0, 0.0, 2.0]], wp.vec3f, device=sim.device) + batches = [] + for path, values in points.items(): + source = SceneDataFormat.Points() + source.points = values + batches.append((source, {path: (0, len(values))})) + provider = SceneDataProvider( + SimpleNamespace( + native_transform_formats=(), + geometry_timestamp=0, + get_geometry_batches=lambda _format=SceneDataFormat.Points: batches, + ) + ) + fabric = sim.get_or_create_backend(sim.fabric_cfg) + fabric.update_geometries(provider, 0) + simulation_app.update() + wp.synchronize_device(sim.device) + for path in points: + np.testing.assert_allclose( + _fabric_curve_points_world(path), points[path].numpy(), atol=1.0e-6, err_msg=path + ) + # Kit's first update can add prims; settle those structural changes before checking cadence. + fabric.update_geometries(provider, 0) + + # Replace pointers in the same published mapping; parents must never be applied a second time. + for (source, _), path in zip(batches, points, strict=True): + points[path] = wp.array([[4.0, 5.0, 6.0]] * 3, wp.vec3f, device=sim.device) + source.points = points[path] + provider.backend.geometry_timestamp += 1 + fabric.update_geometries(provider, 0) + wp.synchronize_device(sim.device) + for name in ("Mesh", "Curve"): + path = f"/World/Geometry/{name}" + np.testing.assert_allclose(_fabric_curve_points_world(path), points[path].numpy(), atol=1.0e-6) + cloud = UsdGeom.Points(sim.stage.GetPrimAtPath("/World/Geometry/Cloud")) + fabric.update_geometries(provider, 1) + fabric.update_geometries(provider, 2) + np.testing.assert_allclose(_fabric_curve_points_world(str(cloud.GetPath()))[0], [1.0, 0.0, 2.0]) + fabric.update_geometries(provider, 3) + np.testing.assert_allclose( + _fabric_curve_points_world(str(cloud.GetPath())), points["/World/Geometry/Cloud"].numpy(), atol=1.0e-6 + ) + # Runtime transport must not rewrite the authored USD points. + np.testing.assert_array_equal(cloud.GetPointsAttr().Get(), np.zeros((3, 3))) + + # Moving a prim to a new Fabric bucket must refresh the sink even with unchanged physics. + mesh_path = "/World/Geometry/Mesh" + mesh = fabric.stage.GetPrimAtPath(mesh_path) + mesh.CreateAttribute("test:geometryBucket", RtSdf.ValueTypeNames.Bool, custom=True).Set(True) + mesh.GetAttribute("points").Set(Vt.Vec3fArray([Gf.Vec3f(99.0)] * 3)) + fabric.update_geometries(provider, 3) + wp.synchronize_device(sim.device) + np.testing.assert_allclose(_fabric_curve_points_world(mesh_path), points[mesh_path].numpy(), atol=1.0e-6) @pytest.mark.isaacsim_ci @@ -371,50 +425,28 @@ def test_cable_points_follow_newton_segments_after_step_and_reset(): scene = InteractiveScene(_CableRenderSceneCfg(num_envs=2, env_spacing=2.0)) sim.register_interactive_scene(scene) try: - sim.reset() - scene.reset() - scene.update(0.0) - sim.render() - wp.synchronize_device(device) - cable = scene["cable"] - curve_path = "/World/envs/env_0/Cable/geometry/mesh" - initial_points = _fabric_curve_points_world(curve_path) - torch.testing.assert_close(initial_points, _expected_cable_points_world(cable), rtol=0.0, atol=1.0e-4) - - for _ in range(8): - scene.write_data_to_sim() - sim.step(render=False) - scene.update(sim.cfg.dt) - sim.render() - wp.synchronize_device(device) - moved_points = _fabric_curve_points_world(curve_path) - torch.testing.assert_close(moved_points, _expected_cable_points_world(cable), rtol=0.0, atol=1.0e-4) - assert not torch.allclose(moved_points, initial_points, rtol=0.0, atol=1.0e-5) - replicated_curve_path = "/World/envs/env_1/Cable/geometry/mesh" - torch.testing.assert_close( - _fabric_curve_points_world(replicated_curve_path), - _expected_cable_points_world(cable, env_id=1), - rtol=0.0, - atol=1.0e-4, - ) - - sim.reset() - scene.update(0.0) - sim.render() - wp.synchronize_device(device) - reset_points = _fabric_curve_points_world(curve_path) - torch.testing.assert_close(reset_points, _expected_cable_points_world(cable), rtol=0.0, atol=1.0e-4) - - for _ in range(8): - scene.write_data_to_sim() - sim.step(render=False) - scene.update(sim.cfg.dt) - sim.render() - wp.synchronize_device(device) - after_reset_points = _fabric_curve_points_world(curve_path) - torch.testing.assert_close(after_reset_points, _expected_cable_points_world(cable), rtol=0.0, atol=1.0e-4) - assert not torch.allclose(after_reset_points, reset_points, rtol=0.0, atol=1.0e-5) + paths = [f"{path}/Cable/geometry/mesh" for path in scene.env_prim_paths] + for reset_scene in (True, False): + sim.reset() + if reset_scene: + scene.reset() + scene.update(0.0) + _render(sim, scene) + initial_points = [_fabric_curve_points_world(path) for path in paths] + for env_id, initial in enumerate(initial_points): + expected = _expected_cable_points_world(cable, env_id) + torch.testing.assert_close(initial, expected, rtol=0.0, atol=1.0e-4) + for _ in range(8): + scene.write_data_to_sim() + sim.step(render=False) + scene.update(sim.cfg.dt) + _render(sim, scene) + for env_id, path in enumerate(paths): + moved = _fabric_curve_points_world(path) + expected = _expected_cable_points_world(cable, env_id) + torch.testing.assert_close(moved, expected, rtol=0.0, atol=1.0e-4) + assert not torch.allclose(moved, initial_points[env_id], rtol=0.0, atol=1.0e-5) finally: sim.register_interactive_scene(None) diff --git a/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py b/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py index a12c5ccf997e..e9a9118bd2b8 100644 --- a/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py +++ b/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py @@ -1389,12 +1389,9 @@ def test_initialize_solver_prepares_picking_before_graph_capture( monkeypatch.setitem(sys.modules, "usdrt", Mock()) monkeypatch.setattr(NewtonMJWarpManager, "_clone_physics_only", False) monkeypatch.setattr(newton_manager_module, "get_current_stage", lambda **kwargs: Mock()) - for kind in ("body", "cable", "particle"): - monkeypatch.setattr( - NewtonManager, - f"_initialize_fabric_{kind}_prims", - staticmethod(lambda *args, kind=kind: events.append(kind)), - ) + monkeypatch.setattr( + NewtonManager, "_initialize_fabric_body_prims", staticmethod(lambda *args: events.append("body")) + ) def on_physics_ready(_): events.append("ready") @@ -1429,7 +1426,7 @@ def build_solver_with_actuator_mode(cls, model, solver_cfg): sim.reset() sim.reset() - assert events == ["body", "cable", "ready", "particle", *expected_events] * 2 + assert events == ["body", "ready", *expected_events] * 2 def test_abstract_build_solver_raises(): diff --git a/source/isaaclab_newton/test/physics/test_vbd_core.py b/source/isaaclab_newton/test/physics/test_vbd_core.py index fc598952e137..3915142a275a 100644 --- a/source/isaaclab_newton/test/physics/test_vbd_core.py +++ b/source/isaaclab_newton/test/physics/test_vbd_core.py @@ -171,7 +171,6 @@ def hook(builder, world_idx, position, rotation): def test_vbd_colors_prebuilt_builder_before_start(monkeypatch): """VBD colors a prebuilt builder before starting simulation.""" physics = importlib.import_module("isaaclab_newton.physics") - deformable_module = importlib.import_module("isaaclab_contrib.deformable.deformable_object") events = [] class Builder: @@ -180,7 +179,6 @@ def color(self, *, balance_colors): monkeypatch.setattr(physics.NewtonVBDManager, "_builder", Builder()) monkeypatch.setattr(NewtonManager, "start_simulation", classmethod(lambda cls: events.append("start"))) - monkeypatch.setattr(deformable_module, "setup_registered_deformable_fabric_sync", lambda manager_cls: None) physics.NewtonVBDManager.start_simulation() diff --git a/source/isaaclab_newton/test/sim/test_mpm_spawners.py b/source/isaaclab_newton/test/sim/test_mpm_spawners.py index a594784f43ff..737d37583ac8 100644 --- a/source/isaaclab_newton/test/sim/test_mpm_spawners.py +++ b/source/isaaclab_newton/test/sim/test_mpm_spawners.py @@ -8,17 +8,23 @@ import subprocess import sys import textwrap +from types import SimpleNamespace import numpy as np import pytest +import warp as wp newton = pytest.importorskip("newton") +from isaaclab_newton.assets import MPMObject +from isaaclab_newton.physics import NewtonManager +from isaaclab_newton.physics.newton_manager import NewtonSceneDataBackend from isaaclab_newton.sim.spawners.mpm import MPMGridCfg, MPMParticleMaterialCfg, MPMPointsCfg from newton.solvers import SolverImplicitMPM from pxr import UsdGeom, UsdPhysics, UsdShade import isaaclab.sim as sim_utils +from isaaclab.cloner import ClonePlan pytestmark = pytest.mark.unit @@ -33,7 +39,7 @@ def stage(): return stage -def test_mpm_points_author_and_import_through_usd(stage): +def test_mpm_points_author_and_import_through_usd(stage, monkeypatch): material = MPMParticleMaterialCfg( young_modulus=2500.0, damping=0.125, @@ -80,9 +86,52 @@ def test_mpm_points_author_and_import_through_usd(stage): np.testing.assert_allclose(builder.particle_qd, ((0.0, 0.1, 0.0), (-0.2, 0.0, 0.0)), atol=3.0e-8) np.testing.assert_allclose(builder.particle_mass, cfg.mass) np.testing.assert_allclose(builder.particle_radius, cfg.radius) - - -def test_mpm_grid_authors_explicit_particles(stage): + visual_points = UsdGeom.Points(stage.GetPrimAtPath("/World/Media/Particles")) + assert not visual_points.GetResetXformStack() + np.testing.assert_allclose(visual_points.GetPointsAttr().Get(), cfg.positions) + np.testing.assert_allclose(visual_points.GetWidthsAttr().Get(), (0.1, 0.12)) + transform = UsdGeom.XformCache().GetLocalToWorldTransform(visual_points.GetPrim()) + np.testing.assert_allclose( + [transform.Transform(point) for point in visual_points.GetPointsAttr().Get()], builder.particle_q, atol=2.0e-7 + ) + shared_cfg = cfg.copy() + shared_cfg.func("/World/Shared", shared_cfg, translation=(-2.0, 0.0, 0.0)) + plan = ClonePlan( + sources=("/World/Media", "/World/Other"), + destinations=("/Scene/copy_{}/Media",) * 2, + clone_mask=np.array([[True, False, True], [False, True, False]]), + env_ids=np.array([12, 99, 7]), + global_paths=("/World/Shared",), + ) + monkeypatch.setattr(sim_utils.SimulationContext, "instance", lambda: SimpleNamespace(get_clone_plan=lambda: plan)) + state = SimpleNamespace(particle_q=wp.zeros(12, dtype=wp.vec3f, device="cpu")) + monkeypatch.setattr(NewtonSceneDataBackend, "state", property(lambda self: state)) + backend = NewtonSceneDataBackend() + backend.initialize_geometry(plan) + monkeypatch.setattr(NewtonManager, "_scene_data_backend", backend) + asset = SimpleNamespace( + cfg=SimpleNamespace(prim_path="/Scene/copy_[^/]+/Media", spawn=cfg), + _recorded_particle_offsets=[4, 10], + _particles_per_object=2, + ) + MPMObject._bind_particle_visualization(asset) + asset.cfg.prim_path = "/World/Shared" + asset.cfg.spawn = shared_cfg + asset._recorded_particle_offsets = [0] + MPMObject._bind_particle_visualization(asset) + assert not stage.GetPrimAtPath("/Scene/copy_12/Media/Particles") + [(publication, ranges)] = backend.get_geometry_batches() + assert publication.points is state.particle_q + assert ranges == { + "/Scene/copy_12/Media/Particles": (4, 2), + "/Scene/copy_7/Media/Particles": (10, 2), + "/World/Shared/Particles": (0, 2), + } + assert visual_points.GetPrim().GetAttribute("isaaclab:pointsUpdateFrequency").Get() == cfg.visual_update_frequency + + +@pytest.mark.parametrize("visible", [False, True]) +def test_mpm_grid_authors_explicit_particles(stage, visible): cfg = MPMGridCfg( lower=(0.0, 0.0, 0.0), upper=(0.2, 0.2, 0.2), @@ -90,12 +139,14 @@ def test_mpm_grid_authors_explicit_particles(stage): particles_per_cell=1.0, particle_placement="cell_center", jitter=0.0, + visible=visible, ) cfg.func("/World/Media", cfg) points = UsdGeom.Points(stage.GetPrimAtPath("/World/Media/geometry/points")) assert len(points.GetPointsAttr().Get()) == 8 assert len(points.GetPrim().GetAttribute("physics:masses").Get()) == 8 + assert bool(stage.GetPrimAtPath("/World/Media/Particles")) is visible def test_mpm_config_imports_do_not_load_pxr(): diff --git a/source/isaaclab_ov/changelog.d/plan-geometry.rst b/source/isaaclab_ov/changelog.d/plan-geometry.rst new file mode 100644 index 000000000000..362914170abb --- /dev/null +++ b/source/isaaclab_ov/changelog.d/plan-geometry.rst @@ -0,0 +1,8 @@ +Fixed +^^^^^ + +* Used declared prototype geometry and native ranges for OVPhysX deformable publications and OVRTX + visual bindings, including partial environment coverage and custom namespaces. +* Read OVPhysX deformable positions directly into the shared point buffer without an extra packing pass. +* Routed OVRTX deformable, particle, and cable updates exclusively through SDP, removing its Newton + model requirement and renderer-owned interpolation. diff --git a/source/isaaclab_ov/isaaclab_ov/assets/deformable_object/deformable_object.py b/source/isaaclab_ov/isaaclab_ov/assets/deformable_object/deformable_object.py index a46b2dfc63b9..d1ec2118932b 100644 --- a/source/isaaclab_ov/isaaclab_ov/assets/deformable_object/deformable_object.py +++ b/source/isaaclab_ov/isaaclab_ov/assets/deformable_object/deformable_object.py @@ -233,6 +233,7 @@ def write_nodal_pos_to_sim_index( self._data._nodal_state_w.timestamp = -1.0 self._data._root_pos_w.timestamp = -1.0 self.root_view.set_attribute(self._sim_nodal_position_type, self._get_nodal_pos_w_f32(), indices=env_ids) + OvPhysxManager.get_scene_data_backend().geometry_timestamp += 1 def write_nodal_velocity_to_sim_index( self, diff --git a/source/isaaclab_ov/isaaclab_ov/physics/ovphysx_manager.py b/source/isaaclab_ov/isaaclab_ov/physics/ovphysx_manager.py index 661272c0da6b..8aaa006be33f 100644 --- a/source/isaaclab_ov/isaaclab_ov/physics/ovphysx_manager.py +++ b/source/isaaclab_ov/isaaclab_ov/physics/ovphysx_manager.py @@ -19,6 +19,7 @@ import os import re import stat +from collections.abc import Sequence from typing import TYPE_CHECKING, Any, ClassVar import numpy as np @@ -29,12 +30,9 @@ from isaaclab.physics import PhysicsEvent, PhysicsManager from isaaclab.scene_data import SceneDataBackend, SceneDataFormat from isaaclab.scene_data.deformable_discovery import ( - build_deformable_root_path_lookup, - build_deformable_vertex_count_lookup, - discover_deformables_on_stage, - group_deformable_root_paths_for_views, - resolve_deformable_root_path, - resolve_deformable_vertex_count, + deformable_geometry_batches, + deformable_prototypes, + expand_deformable_entries, ) from isaaclab.sim.simulation_context import SimulationContext @@ -48,6 +46,8 @@ from .ovphysx_manager_cfg import DEFAULT_COOKED_COLLIDER_CACHE_DIR, OvPhysxBackendCfg if TYPE_CHECKING: + from isaaclab.scene_data.deformable_discovery import DeformableStageEntry + from .ovphysx_manager_cfg import OvPhysxCfg __all__ = ["OvPhysxManager", "OvPhysxSceneDataBackend"] @@ -98,12 +98,11 @@ def __init__(self): self._rigid_bindings: list[tuple[OvPhysxView, wp.array]] = [] self._transforms = SceneDataFormat.Transform() self.transforms_version = 0 - self._poses_version = -1 - self._points_data = SceneDataFormat.Points() - self._deformable_bindings: list[dict[str, Any]] = [] - self._geometry_paths: list[str] = [] - self._geometry_counts: list[int] = [] - self._merged_points: wp.array | None = None + self._transforms_version_last_update = -1 + self.geometry_timestamp = 0 + self._geometry_timestamp_last_update = -1 + self._geometry_batches: list | None = None + self._deformable_bindings: list[tuple[OvPhysxView, Any, wp.array]] = [] @property def transform_count(self) -> int: @@ -116,13 +115,15 @@ def transform_paths(self) -> list[str]: """Concatenated ``prim_paths`` across all bindings, in registration order.""" return [path for view, _ in self._rigid_bindings for path in view.prim_paths] - def setup(self, physx, stage, device: str) -> None: + def setup(self, physx, stage, device: str, entries: Sequence[DeformableStageEntry] | None = None) -> None: """Discover RigidBodyAPI prims, dedup by env-wildcard form, create one binding per pattern. Args: physx: Live ``ovphysx.PhysX`` instance (the wheel handle). stage: USD stage to traverse for RigidBodyAPI prims. device: Warp device string used to allocate the published buffers. + entries: Declared deformables captured before native stage import. ``None`` leaves + scene geometry uninitialized; an empty sequence declares no deformables. """ from isaaclab_ov import tensor_types as TT # local: keep heavy ovphysx out of module load @@ -130,9 +131,8 @@ def setup(self, physx, stage, device: str) -> None: self._transforms.transforms = None self.transforms_version += 1 self._deformable_bindings = [] - self._geometry_paths = [] - self._geometry_counts = [] - self._merged_points = None + self.geometry_timestamp += 1 + self._geometry_batches = None if stage is None: return @@ -168,154 +168,90 @@ def setup(self, physx, stage, device: str) -> None: self._rigid_bindings.append((view, buffer)) offset += view.count - self._setup_deformable_bindings(physx, stage, device) - - def _setup_deformable_bindings(self, physx, stage, device: str) -> None: - """Discover deformable prims and wire OVPhysX nodal-position bindings. + if entries is not None: + self._setup_deformable_bindings(physx, entries, device) - Args: - physx: Active OVPhysX simulation handle. - stage: USD stage used for deformable discovery. - device: Warp device for nodal position read buffers. - """ + def _setup_deformable_bindings(self, physx, entries: Sequence[DeformableStageEntry], device: str) -> None: + """Bind exact planned deformables directly into one flat publication buffer.""" from isaaclab_ov import tensor_types as TT - from isaaclab_ov.assets.deformable_object.views import OvPhysxDeformableBodyView - - entries = discover_deformables_on_stage(stage) - if not entries: - return - - path_to_count = build_deformable_vertex_count_lookup(entries) - path_to_root = build_deformable_root_path_lookup(entries) - path_to_type = {entry.root_path: entry.deformable_type for entry in entries} - grouped_paths = group_deformable_root_paths_for_views(list(path_to_type.keys()), path_to_type) - - self._geometry_paths = [] - self._geometry_counts = [] - entity_offset = 0 - for deformable_type in ("volume", "surface"): - if deformable_type == "volume": - sim_nodal_position_type = TT.DEFORMABLE_SIM_NODAL_POSITION - sim_element_indices_type = TT.DEFORMABLE_SIM_ELEMENT_INDICES - collision_element_indices_type = TT.DEFORMABLE_COLLISION_ELEMENT_INDICES - tensor_types = [ - sim_nodal_position_type, - sim_element_indices_type, - collision_element_indices_type, - ] - else: - sim_nodal_position_type = TT.SURFACE_DEFORMABLE_SIM_POSITION - sim_element_indices_type = TT.SURFACE_DEFORMABLE_SIM_ELEMENT_INDICES - collision_element_indices_type = None - tensor_types = [sim_nodal_position_type, sim_element_indices_type] - - patterns, exact_paths = grouped_paths[deformable_type] - for pattern in [*patterns, *exact_paths]: - try: - view = OvPhysxDeformableBodyView( - physx, - pattern=pattern, - device=device, - tensor_types=tensor_types, - eager=True, - simulation_nodal_position_type=sim_nodal_position_type, - simulation_element_indices_type=sim_element_indices_type, - collision_element_indices_type=collision_element_indices_type, - ) - except Exception as exc: - logger.warning("Failed to create %s deformable binding for %s: %s", deformable_type, pattern, exc) - continue - if view.count == 0: - continue - max_nodes = view.max_simulation_nodes_per_body - position_buf = wp.zeros((view.count, max_nodes, 3), dtype=wp.float32, device=device) - self._deformable_bindings.append( - { - "pattern": pattern, - "deformable_type": deformable_type, - "view": view, - "position_buf": position_buf, - "sim_nodal_position_type": sim_nodal_position_type, - "entity_offset": entity_offset, - "entity_count": view.count, - } - ) - for path in view.prim_paths: - # Prefer USD-discovered unpadded counts over padded max_nodes so - # SceneData ↔ shadow particle_q slices stay size-aligned. - resolved = resolve_deformable_vertex_count(path, path_to_count, fallback=-1) - if resolved < 0: - logger.warning( - "No USD vertex count for deformable path '%s'; using padded " - "max_simulation_nodes_per_body=%d.", - path, - max_nodes, - ) - count = int(max_nodes) - else: - count = min(int(resolved), int(max_nodes)) - # Views may report a child mesh; publish the discovered root so - # create_geometry_mapping matches shadow entity root_path exactly. - self._geometry_paths.append(resolve_deformable_root_path(path, path_to_root)) - self._geometry_counts.append(count) - entity_offset += view.count - total_points = sum(self._geometry_counts) - if total_points > 0: - self._merged_points = wp.zeros(total_points, dtype=wp.vec3f, device=device) + groups = {} + for entry in entries: + groups.setdefault((entry.deformable_type, entry.vertex_count), []).append(entry) - @property - def points(self) -> SceneDataFormat.Points: - """Return flattened OVPhysX deformable nodal positions.""" - from isaaclab.scene_data.geometry_points import pack_body_nodal_slices - - if self._merged_points is None or not self._deformable_bindings: - self._points_data.points = None - return self._points_data - - write_offset = 0 - path_index = 0 - device = str(self._merged_points.device) - for entry in self._deformable_bindings: - view = entry["view"] - view.read_into(entry["sim_nodal_position_type"], entry["position_buf"]) - nodal = entry["position_buf"].view(wp.vec3f).reshape((view.count, -1)) - view_counts = [self._geometry_counts[path_index + body_idx] for body_idx in range(view.count)] - pack_body_nodal_slices( - nodal, - self._merged_points, - view_counts, + views, native_entries = [], [] + for (deformable_type, count), entries in groups.items(): + tensor_type = ( + TT.DEFORMABLE_SIM_NODAL_POSITION if deformable_type == "volume" else TT.SURFACE_DEFORMABLE_SIM_POSITION + ) + view = OvPhysxView( + physx, + prim_paths=[entry.root_path for entry in entries], device=device, - dest_base_offset=write_offset, + tensor_types=[tensor_type], + eager=True, ) - write_offset += sum(int(count) for count in view_counts) - path_index += view.count - self._points_data.points = self._merged_points - return self._points_data + by_path = {path: entry for entry in entries for path in (entry.root_path, entry.sim_mesh_path)} + ordered = [by_path[path] for path in view.prim_paths] + if sorted(entry.root_path for entry in ordered) != sorted(entry.root_path for entry in entries): + raise RuntimeError("OVPhysX deformable binding does not cover the declared clone-plan entries.") + if tuple(view.binding_for(tensor_type).shape) != (len(entries), count, 3): + raise RuntimeError("OVPhysX deformable node counts disagree with the clone plan.") + native_entries.extend(ordered) + views.append((view, tensor_type, count)) + + if not views: + self._geometry_batches = [] + return + counts = [entry.vertex_count for entry in native_entries] + points = wp.empty(sum(counts), dtype=wp.vec3f, device=device) + offset = 0 + for view, tensor_type, count in views: + buffer = wp.array( + ptr=points.ptr + offset * wp.types.type_size_in_bytes(wp.vec3f), + shape=(view.count, count), + dtype=wp.vec3f, + device=device, + copy=False, + ) + self._deformable_bindings.append((view, tensor_type, buffer)) + offset += view.count * count + offsets = np.cumsum(np.r_[0, counts[:-1]]) + self._geometry_batches = deformable_geometry_batches(native_entries, points, offsets) - @property - def point_count(self) -> int: - """Return the total unpadded OVPhysX deformable nodal count.""" - return sum(self._geometry_counts) + def get_geometry_batches(self, output_format: Any = SceneDataFormat.Points) -> list: + """Publish native positions with exact visual paths and interpolation metadata. - @property - def geometry_paths(self) -> list[str]: - """Return one USD prim path per OVPhysX deformable body instance.""" - return self._geometry_paths + Raises: + RuntimeError: If scene geometry was not initialized from a clone plan. + """ + if self._geometry_batches is None: + raise RuntimeError("Declare and replicate a ClonePlan before requesting scene geometry.") + if self._geometry_timestamp_last_update != self.geometry_timestamp: + for view, tensor_type, buffer in self._deformable_bindings: + view.read_into(tensor_type, buffer) + self._geometry_timestamp_last_update = self.geometry_timestamp + return self._geometry_batches @property - def geometry_counts(self) -> list[int]: - """Return the unpadded nodal count for each OVPhysX deformable body.""" - return self._geometry_counts + def native_geometry_formats(self) -> tuple[Any, ...]: + """Return the native geometry formats compiled from the declared prototypes. + + Raises: + RuntimeError: If scene geometry was not initialized from a clone plan. + """ + if self._geometry_batches is None: + raise RuntimeError("Declare and replicate a ClonePlan before requesting scene geometry.") + return tuple(dict.fromkeys(publication._cls for publication, _ in self._geometry_batches)) @property def transforms(self) -> SceneDataFormat.Transform: """Publish native rigid-body poses [m, xyzw].""" - if self._poses_version != self.transforms_version: + if self._transforms_version_last_update != self.transforms_version: OvPhysxManager.pre_render() for view, buffer in self._rigid_bindings: view.read_into("rigid_body_pose", buffer) - self._poses_version = self.transforms_version + self._transforms_version_last_update = self.transforms_version return self._transforms @@ -577,6 +513,7 @@ def reset(cls, soft: bool = False) -> None: cls.dispatch_event(PhysicsEvent.PHYSICS_READY, payload={}) cls._kinematics_dirty = True cls._scene_data_backend.transforms_version += 1 + cls._scene_data_backend.geometry_timestamp += 1 @classmethod def forward(cls) -> None: @@ -585,6 +522,7 @@ def forward(cls) -> None: cls.backend.physx.update_articulations_kinematic() cls._kinematics_dirty = False cls._scene_data_backend.transforms_version += 1 + cls._scene_data_backend.geometry_timestamp += 1 @classmethod def pre_render(cls) -> None: @@ -603,6 +541,7 @@ def step(cls) -> None: cls.backend.physx.update_articulations_kinematic() cls._kinematics_dirty = False cls._scene_data_backend.transforms_version += 1 + cls._scene_data_backend.geometry_timestamp += 1 PhysicsManager._sim_time += dt @staticmethod @@ -932,6 +871,10 @@ def _warmup_and_load(cls) -> None: if sim is None: raise RuntimeError("OvPhysxManager: SimulationContext is not set.") + entries = None + if (plan := sim.get_clone_plan()) is not None: + entries = expand_deformable_entries(plan, deformable_prototypes(sim.stage, plan)) + ovphysx_device = "gpu" if "cuda" in PhysicsManager._device else "cpu" if cls._locked_device is not None and ovphysx_device != cls._locked_device: @@ -1004,7 +947,7 @@ def _warmup_and_load(cls) -> None: # via :meth:`get_scene_data_backend`. if cls._scene_data_backend is None: cls._scene_data_backend = OvPhysxSceneDataBackend() - cls._scene_data_backend.setup(cls.backend.physx, sim.stage, PhysicsManager._device) + cls._scene_data_backend.setup(cls.backend.physx, sim.stage, PhysicsManager._device, entries) cls.dispatch_event(PhysicsEvent.MODEL_INIT, payload={}) cls._warmup_done = True diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py index 398b06002bc4..cf45690f7a8f 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py @@ -23,7 +23,6 @@ import logging import math import os -import re import sys import weakref from collections.abc import Iterator, Sequence @@ -83,7 +82,6 @@ from isaaclab_ov.renderers.ovrtx_compat import OVRTX_VERSION, uses_prim_path_render_vars from isaaclab_ov.renderers.ovrtx_renderer_cfg import OVRTXBackendCfg, OVRTXRendererCfg from isaaclab_ov.renderers.ovrtx_renderer_kernels import ( - compute_cable_points_world_kernel, create_camera_transforms_kernel, extract_all_tiles_kernel, generate_random_colors_from_ids_kernel, @@ -385,24 +383,12 @@ def __init__(self, cfg: OVRTXRendererCfg): self._render_product_paths = [] self._camera_render_data: list[OVRTXCameraRenderData] = [] self._next_camera_id = 0 - # Shared by both paths. The legacy-only binding handles that pair with these live in - # _init_fields_legacy instead; the ovstage path drives the same offsets and counts - # through its stage queries. self._sdp = SimulationContext.instance().get_scene_data_provider() - self._transform_version = -1 + self._transforms_version_last_update = -1 self._object_scales: wp.array | None = None self._object_scales_by_path: dict[str, tuple[float, float, float]] = {} - self._deformable_particle_offsets: list[int] = [] - self._deformable_particle_counts: list[int] = [] - self._particle_visual_offsets: list[int] = [] - self._particle_visual_counts: list[int] = [] - # Shared Newton cable curve state used by both legacy and ovstage write paths. - self._cable_segment_counts: list[int] = [] - self._cable_max_points: int = 0 - self._cable_shape_ids: wp.array | None = None - self._cable_offsets: wp.array | None = None - self._cable_counts: wp.array | None = None - self._cable_points: wp.array | None = None + self._geometry_paths: list[str] = [] + self._geometry_timestamp_last_update = -1 self._initialized_scene = False self._exported_usd_string: str | None = None self._camera_prim_path: str | None = None @@ -553,18 +539,10 @@ def _create_object_scale_array(self, object_paths: list[str]) -> wp.array: return wp.array(scales, dtype=wp.vec3f, device=self._device) def _init_fields_legacy(self) -> None: - """Initialize the legacy-path instance fields. - - Only binding handles live here; geometry offsets and counts are shared with ovstage. - """ + """Initialize legacy-path binding handles.""" self._camera_xform_binding = None self._object_xform_binding = None - self._deformable_points_binding = None - self._particle_points_binding = None - self._particle_workaround_applied = False - self._cable_points_binding = None - # Stable Warp views into ``_cable_points`` for ASYNC GPU writes. - self._cable_point_slices: list[wp.array] = [] + self._geometry_points_binding = None def _initialize_camera_render_data_from_spec_legacy( self, spec: CameraRenderSpec, render_data: OVRTXCameraRenderData @@ -641,9 +619,7 @@ def _initialize_camera_render_data_from_spec_legacy( raise RuntimeError("Camera binding is None — cannot render without a valid camera binding") self._setup_xform_bindings_legacy() - self._setup_deformable_bindings_legacy(num_envs) - self._setup_particle_bindings_legacy() - self._setup_cable_bindings_legacy() + self._setup_geometry_bindings_legacy() def _clone_sources_in_ovrtx(self): """Clone sources in OVRTX using the scene :class:`~isaaclab.cloner.ClonePlan`.""" @@ -732,189 +708,36 @@ def _setup_xform_bindings_legacy(self): self._object_scales = self._create_object_scale_array(object_paths) - def _setup_deformable_bindings_legacy(self, num_envs: int): - """Setup OVRTX bindings for Newton deformable bodies. - - Args: - num_envs: Number of environments. - """ - try: - from isaaclab_newton.physics import NewtonManager - except ImportError: - logger.debug("NewtonManager not available, skipping deformable body bindings") - return - - # Early return if the deformable registry is empty. - deformable_registry = NewtonManager._deformable_registry - if not deformable_registry: - logger.debug("Deformable registry is empty, skipping deformable body bindings") - return - - # Validate the number of particle offsets for each deformable entry upfront. - bad_entries = [entry for entry in deformable_registry if len(entry.particle_offsets) != num_envs] - if bad_entries: - details = "\n".join( - f"- '{entry.prim_path}' has {len(entry.particle_offsets)} particle offsets" for entry in bad_entries - ) - raise RuntimeError( - f"OVRTX expects one particle offset per environment ({num_envs}), but the following " - f"deformable entries have a mismatched offset count:\n{details}" - ) - - self._deformable_particle_offsets = [] - self._deformable_particle_counts = [] - - vis_mesh_prim_paths: list[str] = [] - - # Each registry entry is one deformable asset registered at spawn time. Its - # ``vis_mesh_prim_path`` uses a regex env wildcard (e.g. ``env_.*``) to denote one - # homogeneous visual mesh replicated into every environment, not a subset of envs. - # During replication, Newton appends one particle block per env in contiguous env order - # and records the start index in ``entry.particle_offsets``; ``particles_per_body`` is - # the block size. The inner loop therefore emits one OVRTX mesh binding per env, - # resolving the env wildcard with ``env_idx`` and pairing it with that env's slice in - # the flat ``particle_q`` array. - # - # This mapping is valid only while deformable registry entries remain homogeneous across - # all envs with dense, contiguous env ids. If deformables later support env subsets or - # non-contiguous env ids, OVRTX must consume explicit per-instance env metadata instead - # of deriving env ids from ``enumerate(entry.particle_offsets)``. - for entry in deformable_registry: - for idx, particle_offset in enumerate(entry.particle_offsets): - self._deformable_particle_offsets.append(particle_offset) - self._deformable_particle_counts.append(entry.particles_per_body) - - vis_mesh_prim_paths.append( - re.sub(r"(?<=[Ee]nv_)(?:\[\^/\][*+]|\.\*)", str(idx), entry.vis_mesh_prim_path) - ) - - prim_count = len(vis_mesh_prim_paths) - if prim_count == 0: - logger.warning("No deformable visual prim paths collected, skipping deformable body bindings") - return - - # World-space particle_q is written directly into mesh points. Reset the xform stack - # and pin identity omni:xform so inherited env/asset transforms are not applied twice. - self.backend.renderer.write_attribute( - prim_paths=vis_mesh_prim_paths, - attribute_name="omni:resetXformStack", - tensor=np.full(prim_count, True, dtype=np.bool_), - prim_mode=PrimMode.MUST_EXIST, - ) - self.backend.renderer.write_attribute( - prim_paths=vis_mesh_prim_paths, - attribute_name="omni:xform", - tensor=np.tile(np.eye(4, dtype=np.float64), (prim_count, 1, 1)), - semantic=Semantic.XFORM_MAT4x4, - prim_mode=PrimMode.MUST_EXIST, - ) - - self._deformable_points_binding = self.backend.renderer.bind_array_attribute( - prim_paths=vis_mesh_prim_paths, - attribute_name="points", - dtype=np.float32, - shape=(3,), - prim_mode=PrimMode.MUST_EXIST, - flags=BindingFlag.OPTIMIZE, - ) - - if self._deformable_points_binding is None: - raise RuntimeError("Failed to create OVRTX deformable body bindings") - - def _setup_cable_bindings_legacy(self) -> None: - """Setup OVRTX ``points`` bindings for Newton cables (UsdGeom.BasisCurves). - - Cables are rigid segment bodies, not particles, so their curve points are derived from - ``body_q`` each frame rather than sliced out of ``particle_q``. - """ - discovered = self._discover_cable_segment_bindings() - if discovered is None: - return - - cable_prim_paths, flat_shape_ids, offsets, counts = discovered - prim_count = len(cable_prim_paths) - # Points are written in world space, so neutralise the inherited env/asset transform the - # same way the deformable path does; otherwise the transform is applied twice. - self.backend.renderer.write_attribute( - prim_paths=cable_prim_paths, - attribute_name="omni:resetXformStack", - tensor=np.full(prim_count, True, dtype=np.bool_), - prim_mode=PrimMode.MUST_EXIST, - ) - self.backend.renderer.write_attribute( - prim_paths=cable_prim_paths, - attribute_name="omni:xform", - tensor=np.tile(np.eye(4, dtype=np.float64), (prim_count, 1, 1)), - semantic=Semantic.XFORM_MAT4x4, - prim_mode=PrimMode.MUST_EXIST, - ) - - self._cable_points_binding = self.backend.renderer.bind_array_attribute( - prim_paths=cable_prim_paths, - attribute_name="points", - dtype=np.float32, - shape=(3,), - prim_mode=PrimMode.MUST_EXIST, - flags=BindingFlag.OPTIMIZE, - ) - if self._cable_points_binding is None: - raise RuntimeError("Failed to create OVRTX cable point bindings") - - # Device-resident buffers select OVRTX's GPU-interop update path via DLPack device. - self._allocate_cable_device_buffers(flat_shape_ids, offsets, counts) - self._cable_point_slices = [ - self._cable_points[offset + curve : offset + curve + segment_count + 1] - for curve, (offset, segment_count) in enumerate(zip(offsets, counts, strict=True)) - ] - - def _setup_particle_bindings_legacy(self) -> None: - """Setup OVRTX bindings for Newton particle clouds.""" - try: - from isaaclab_newton.physics import NewtonManager - except ImportError: - logger.debug("NewtonManager not available, skipping particle point bindings") + def _setup_geometry_bindings_legacy(self) -> None: + """Bind SDP's authored point prims without depending on their physics representation.""" + self._geometry_paths = list(self._sdp.get_geometry_points()) + if not self._geometry_paths: return - - particle_visual_prims = NewtonManager._particle_visual_prims - if not particle_visual_prims: - logger.debug("No particle visual prims registered, skipping particle point bindings") - return - - self._particle_visual_offsets = [] - self._particle_visual_counts = [] - points_prim_paths: list[str] = [] - - for prim_path, record in particle_visual_prims.items(): - points_prim_paths.append(prim_path) - self._particle_visual_offsets.append(record.offset) - self._particle_visual_counts.append(record.count) - - prim_count = len(points_prim_paths) - - # World-space particle_q is written directly into points. Reset the xform stack - # and pin identity omni:xform so inherited env/asset transforms are not applied twice. + prim_count = len(self._geometry_paths) + # Published points are world-space; do not apply inherited transforms a second time. self.backend.renderer.write_attribute( - prim_paths=points_prim_paths, + prim_paths=self._geometry_paths, attribute_name="omni:resetXformStack", tensor=np.full(prim_count, True, dtype=np.bool_), prim_mode=PrimMode.MUST_EXIST, ) self.backend.renderer.write_attribute( - prim_paths=points_prim_paths, + prim_paths=self._geometry_paths, attribute_name="omni:xform", tensor=np.tile(np.eye(4, dtype=np.float64), (prim_count, 1, 1)), semantic=Semantic.XFORM_MAT4x4, prim_mode=PrimMode.MUST_EXIST, ) - - self._particle_points_binding = self.backend.renderer.bind_array_attribute( - prim_paths=points_prim_paths, + self._geometry_points_binding = self.backend.renderer.bind_array_attribute( + prim_paths=self._geometry_paths, attribute_name="points", dtype=np.float32, shape=(3,), prim_mode=PrimMode.MUST_EXIST, flags=BindingFlag.OPTIMIZE, ) + if self._geometry_points_binding is None: + raise RuntimeError("Failed to create OVRTX geometry point bindings") def create_render_data(self, spec: CameraRenderSpec) -> OVRTXCameraRenderData: """Create OVRTX-specific RenderData with GPU buffers. @@ -1083,7 +906,7 @@ def _update_transforms_legacy(self) -> None: transforms = SceneDataFormat.TransposedMatrix44d() if not self._sdp.get_transforms(transforms, scales=self._object_scales): return - if self._transform_version == self._sdp.backend.transforms_version: + if self._transforms_version_last_update == self._sdp.backend.transforms_version: return # Blocking ``write()`` so the buffer stays valid until OVRTX finishes reading it. # ``DataAccess.ASYNC`` + the Warp CUDA stream let OVRTX read in place and wait @@ -1093,81 +916,22 @@ def _update_transforms_legacy(self) -> None: data_access=DataAccess.ASYNC, cuda_stream=self._warp_device.stream.cuda_stream, ) - self._transform_version = self._sdp.backend.transforms_version + self._transforms_version_last_update = self._sdp.backend.transforms_version def _update_geometries_legacy(self) -> None: - """Sync geometries to OVRTX.""" - if self._deformable_points_binding is not None: - self._write_particle_q_slices( - self._deformable_points_binding, - self._deformable_particle_offsets, - self._deformable_particle_counts, - ) - - if self._particle_points_binding is not None: - self._write_particle_q_slices( - self._particle_points_binding, - self._particle_visual_offsets, - self._particle_visual_counts, - ) - - if self._cable_points_binding is not None: - self._write_cable_points_legacy() - - def _write_cable_points_legacy(self) -> None: - """Recompute world-space cable curve points from Newton bodies and write them to OVRTX.""" - self._compute_cable_points_world() - - # Slices alias ``_cable_points``. Pass Warp's CUDA stream so OVRTX waits on-GPU instead of - # forcing a host sync. ``DataAccess.ASYNC`` + device tensors select GPU interop; ``SYNC`` or - # a host array silently takes the CPU path. - self._cable_points_binding.write( - cast(Any, self._cable_point_slices), + """Write SDP point views using the producing stream, without staging copies.""" + if self._geometry_points_binding is None: + return + points = self._sdp.get_geometry_points() + timestamp = self._sdp.backend.geometry_timestamp + if self._geometry_timestamp_last_update == timestamp: + return + self._geometry_points_binding.write( + cast(Any, [points[path] for path in self._geometry_paths]), data_access=DataAccess.ASYNC, cuda_stream=self._warp_device.stream.cuda_stream, ) - - def _write_particle_q_slices( - self, - binding: Any, - particle_offsets: list[int], - particle_counts: list[int], - ) -> None: - """Write world-space ``particle_q`` slices into one OVRTX array-attribute binding. - - Args: - binding: OVRTX array-attribute binding for the ``points`` attribute. - particle_offsets: Start index of each prim's slice into Newton's ``particle_q``. - particle_counts: Number of particles in each prim's slice. - """ - from isaaclab_newton.physics import NewtonManager - - state = NewtonManager.get_state() - if state is None: - raise RuntimeError("Newton state should not be None") - - particle_q = getattr(state, "particle_q", None) - if particle_q is None: - raise RuntimeError("Newton state has no particle_q but particle geometry bindings exist") - - particle_slices = [ - particle_q[particle_offset : particle_offset + particle_count] - for particle_offset, particle_count in zip(particle_offsets, particle_counts, strict=True) - ] - - # Array attributes cannot use ``binding.map()`` like rigid-body xforms, and - # ``DataAccess.ASYNC`` lets OVRTX read the slices in place (no copy on ingest). - # Because the slices alias ``particle_q``, OVRTX must not read them until the - # Warp kernels that wrote ``particle_q`` have finished. Passing ``cuda_stream`` - # hands OVRTX the Warp stream those kernels were enqueued on so it can insert a - # GPU-side wait (a cross-stream dependency) before its read, instead of us - # forcing a host-side ``wp.synchronize_device()`` that would stall the CPU. - cuda_stream = self._warp_device.stream.cuda_stream - binding.write( - cast(Any, particle_slices), - data_access=DataAccess.ASYNC, - cuda_stream=cuda_stream, - ) + self._geometry_timestamp_last_update = timestamp def _update_camera_legacy( self, @@ -1666,14 +1430,8 @@ def _safe_unbind(binding, name: str) -> None: self._camera_xform_binding = None _safe_unbind(self._object_xform_binding, "object transforms") self._object_xform_binding = None - _safe_unbind(self._deformable_points_binding, "deformable points") - self._deformable_points_binding = None - _safe_unbind(self._particle_points_binding, "particle points") - self._particle_points_binding = None - _safe_unbind(self._cable_points_binding, "cable points") - self._cable_points_binding = None - - self._particle_workaround_applied = False + _safe_unbind(self._geometry_points_binding, "geometry points") + self._geometry_points_binding = None # --------------------------------------------------------------------------- # Dispatch methods — route to ovstage or legacy implementation @@ -1687,77 +1445,6 @@ def _initialize_camera_render_data_from_spec( else: self._initialize_camera_render_data_from_spec_legacy(spec, render_data) - @staticmethod - def _discover_cable_segment_bindings() -> tuple[list[str], list[int], list[int], list[int]] | None: - """Collect cable prim paths and packed Newton segment shape ids, or ``None`` to skip. - - Returns: - ``None`` when Newton is unavailable or no renderable cables exist. Otherwise a tuple - ``(cable_prim_paths, flat_shape_ids, offsets, counts)`` where: - - * ``cable_prim_paths``: concrete ``BasisCurves`` prim paths to bind, one per curve. - * ``flat_shape_ids``: Newton shape ids for all curves, packed contiguously in segment - order (indices into ``model.shape_body`` / ``shape_transform`` / ``shape_scale``). - * ``offsets``: start index into ``flat_shape_ids`` for each curve. - * ``counts``: number of segment shapes (capsules) for each curve. - """ - try: - from isaaclab_newton.physics import NewtonManager - except ImportError: - logger.debug("NewtonManager not available, skipping cable point bindings") - return None - - cable_segment_shape_ids = NewtonManager.collect_cable_segment_shape_ids() - if not cable_segment_shape_ids: - logger.debug("No renderable Newton cables found, skipping cable point bindings") - return None - - cable_prim_paths: list[str] = [] - flat_shape_ids: list[int] = [] - offsets: list[int] = [] - counts: list[int] = [] - for prim_path, segment_shape_ids in cable_segment_shape_ids.items(): - cable_prim_paths.append(prim_path) - offsets.append(len(flat_shape_ids)) - counts.append(len(segment_shape_ids)) - flat_shape_ids.extend(segment_shape_ids) - return cable_prim_paths, flat_shape_ids, offsets, counts - - def _allocate_cable_device_buffers(self, flat_shape_ids: list[int], offsets: list[int], counts: list[int]) -> None: - """Allocate shared Warp arrays used by the cable point kernel.""" - device = self._device - self._cable_shape_ids = wp.array(flat_shape_ids, dtype=wp.int32, device=device) - self._cable_offsets = wp.array(offsets, dtype=wp.int32, device=device) - self._cable_counts = wp.array(counts, dtype=wp.int32, device=device) - self._cable_segment_counts = counts - self._cable_max_points = max(counts) + 1 if counts else 0 - self._cable_points = wp.zeros(sum(count + 1 for count in counts), dtype=wp.vec3f, device=device) - - def _compute_cable_points_world(self) -> None: - """Launch the cable endpoint kernel into ``_cable_points``.""" - from isaaclab_newton.physics import NewtonManager - - model = NewtonManager.get_model() - state = NewtonManager.get_state() - if state is None: - raise RuntimeError("Newton state should not be None") - - wp.launch( - compute_cable_points_world_kernel, - dim=(len(self._cable_segment_counts), self._cable_max_points), - inputs=[ - self._cable_shape_ids, - self._cable_offsets, - self._cable_counts, - model.shape_body, - state.body_q, - model.shape_transform, - model.shape_scale, - self._cable_points, - ], - device=self._device, - ) - def update_transforms(self) -> None: """Sync transforms to OVRTX.""" if self._use_ovstage: @@ -1856,17 +1543,8 @@ def close(self) -> None: self._close_ovstage() else: self._close_legacy() - self._deformable_particle_offsets = [] - self._deformable_particle_counts = [] - self._particle_visual_offsets = [] - self._particle_visual_counts = [] - self._cable_point_slices = [] - self._cable_segment_counts = [] - self._cable_max_points = 0 - self._cable_points = None - self._cable_shape_ids = None - self._cable_offsets = None - self._cable_counts = None + self._geometry_paths = [] + self._geometry_timestamp_last_update = -1 self._render_product_paths.clear() self._output_id_color_buffers.clear() self._initialized_scene = False @@ -1888,14 +1566,8 @@ def _init_fields_ovstage(self) -> None: self._camera_paths_list = None self._object_xform_query = None self._object_paths_list = None - self._deformable_points_query = None - self._deformable_paths_list = None - self._particle_points_query = None - self._particle_paths_list = None - self._cable_points_query = None - self._cable_paths_list = None - # DLTensor descriptors aliasing ``_cable_point_slices``; rebuilt only when cables rebind. - self._cable_point_tensors: list = [] + self._geometry_points_query = None + self._geometry_paths_list = None def _initialize_camera_render_data_from_spec_ovstage( self, spec: CameraRenderSpec, render_data: OVRTXCameraRenderData @@ -1994,9 +1666,7 @@ def _initialize_camera_render_data_from_spec_ovstage( ).wait() self._setup_xform_bindings_ovstage() - self._setup_deformable_bindings_ovstage(num_envs) - self._setup_particle_bindings_ovstage() - self._setup_cable_bindings_ovstage() + self._setup_geometry_bindings_ovstage() # Commit all init-time writes then attach. attach_ovstage happens last so the renderer # immediately sees the fully-configured scene on its first step. @@ -2111,184 +1781,27 @@ def _setup_xform_bindings_ovstage(self) -> None: self._object_scales = self._create_object_scale_array(object_paths) - def _setup_deformable_bindings_ovstage(self, num_envs: int) -> None: - """Setup OVRTX bindings for Newton deformable bodies (ovstage path). - - Args: - num_envs: Number of environments. - """ - try: - from isaaclab_newton.physics import NewtonManager - except ImportError: - logger.debug("NewtonManager not available, skipping deformable body bindings") - return - - # Early return if the deformable registry is empty. - deformable_registry = NewtonManager._deformable_registry - if not deformable_registry: - logger.debug("Deformable registry is empty, skipping deformable body bindings") - return - - # Validate the number of particle offsets for each deformable entry upfront. - bad_entries = [entry for entry in deformable_registry if len(entry.particle_offsets) != num_envs] - if bad_entries: - details = "\n".join( - f"- '{entry.prim_path}' has {len(entry.particle_offsets)} particle offsets" for entry in bad_entries - ) - raise RuntimeError( - f"OVRTX expects one particle offset per environment ({num_envs}), but the following " - f"deformable entries have a mismatched offset count:\n{details}" - ) - - self._deformable_particle_offsets = [] - self._deformable_particle_counts = [] - - vis_mesh_prim_paths: list[str] = [] - - # Each registry entry is one deformable asset registered at spawn time. Its - # ``vis_mesh_prim_path`` uses a regex env wildcard (e.g. ``env_.*``) to denote one - # homogeneous visual mesh replicated into every environment, not a subset of envs. - # During replication, Newton appends one particle block per env in contiguous env order - # and records the start index in ``entry.particle_offsets``; ``particles_per_body`` is - # the block size. The inner loop therefore emits one OVRTX mesh binding per env, - # resolving the env wildcard with ``env_idx`` and pairing it with that env's slice in - # the flat ``particle_q`` array. - # - # This mapping is valid only while deformable registry entries remain homogeneous across - # all envs with dense, contiguous env ids. If deformables later support env subsets or - # non-contiguous env ids, OVRTX must consume explicit per-instance env metadata instead - # of deriving env ids from ``enumerate(entry.particle_offsets)``. - for entry in deformable_registry: - for idx, particle_offset in enumerate(entry.particle_offsets): - self._deformable_particle_offsets.append(particle_offset) - self._deformable_particle_counts.append(entry.particles_per_body) - - vis_mesh_prim_paths.append( - re.sub(r"(?<=[Ee]nv_)(?:\[\^/\][*+]|\.\*)", str(idx), entry.vis_mesh_prim_path) - ) - - prim_count = len(vis_mesh_prim_paths) - if prim_count == 0: - logger.warning("No deformable visual prim paths collected, skipping deformable body bindings") - return - - self._deformable_paths_list = self.backend.paths.create_path_list_from_strings(vis_mesh_prim_paths) - self._deformable_points_query = self.backend.stage.query_from_path_list(self._deformable_paths_list) - - # particle_q is already in world space, so resetting the xform stack and pinning an identity - # omni:xform prevents the env-root and asset-root ancestor transforms from being applied on top. - self.backend.stage.write_attribute( - self._deformable_points_query, - "omni:resetXformStack", - ordinal=self._current_ordinal, - tensors=np.full(prim_count, True, dtype=np.bool_), - is_array=False, - ).wait() - - identity_xforms = np.tile(np.eye(4, dtype=np.float64), (prim_count, 1, 1)) - self.backend.stage.write_attribute( - self._deformable_points_query, - "omni:xform", - ordinal=self._current_ordinal, - tensors=xform_tensor_from_numpy(identity_xforms), - is_array=False, - semantic=ovstage.AttributeSemantic.MATRIX, - ).wait() - - if self._deformable_points_query is None: - raise RuntimeError("Failed to create OVRTX deformable body bindings") - - def _setup_cable_bindings_ovstage(self) -> None: - """Setup ovstage ``points`` bindings for Newton cables (``UsdGeom.BasisCurves``). - - Mirrors :meth:`_setup_cable_bindings_legacy`: the endpoint kernel writes device memory and - the per-frame handover is zero-copy. The per-curve slices and their DLTensor descriptors are - built once here rather than per frame, because the layout is fixed for the lifetime of the - binding — only the contents of ``_cable_points`` change each step. - """ - discovered = self._discover_cable_segment_bindings() - if discovered is None: - return - - cable_prim_paths, flat_shape_ids, offsets, counts = discovered - prim_count = len(cable_prim_paths) - self._cable_paths_list = self.backend.paths.create_path_list_from_strings(cable_prim_paths) - self._cable_points_query = self.backend.stage.query_from_path_list(self._cable_paths_list) - - # The kernel emits world space, so reset the xform stack and pin an identity omni:xform to - # stop the env-root and asset-root ancestor transforms being applied on top. - self.backend.stage.write_attribute( - self._cable_points_query, - "omni:resetXformStack", - ordinal=self._current_ordinal, - tensors=np.full(prim_count, True, dtype=np.bool_), - is_array=False, - ).wait() - - identity_xforms = np.tile(np.eye(4, dtype=np.float64), (prim_count, 1, 1)) - self.backend.stage.write_attribute( - self._cable_points_query, - "omni:xform", - ordinal=self._current_ordinal, - tensors=xform_tensor_from_numpy(identity_xforms), - is_array=False, - semantic=ovstage.AttributeSemantic.MATRIX, - ).wait() - - self._allocate_cable_device_buffers(flat_shape_ids, offsets, counts) - # The descriptors alias these slices, so both must outlive every write that uses them. - self._cable_point_slices = [ - self._cable_points[offset + curve : offset + curve + segment_count + 1] - for curve, (offset, segment_count) in enumerate(zip(offsets, counts, strict=True)) - ] - self._cable_point_tensors = [points_tensor_from_warp(points) for points in self._cable_point_slices] - - def _setup_particle_bindings_ovstage(self) -> None: - """Setup OVRTX bindings for Newton particle clouds (ovstage path).""" - try: - from isaaclab_newton.physics import NewtonManager - except ImportError: - logger.debug("NewtonManager not available, skipping particle point bindings") - return - - particle_visual_prims = NewtonManager._particle_visual_prims - if not particle_visual_prims: - logger.debug("No particle visual prims registered, skipping particle point bindings") + def _setup_geometry_bindings_ovstage(self) -> None: + """Bind SDP's authored point prims in the detached OVStage.""" + self._geometry_paths = list(self._sdp.get_geometry_points()) + if not self._geometry_paths: return - - self._particle_visual_offsets = [] - self._particle_visual_counts = [] - points_prim_paths: list[str] = [] - - for prim_path, record in particle_visual_prims.items(): - points_prim_paths.append(prim_path) - self._particle_visual_offsets.append(record.offset) - self._particle_visual_counts.append(record.count) - - prim_count = len(points_prim_paths) - - self._particle_paths_list = self.backend.paths.create_path_list_from_strings(points_prim_paths) - self._particle_points_query = self.backend.stage.query_from_path_list(self._particle_paths_list) - - # Divergence from the legacy path: ovstage's PrimMode has only UPSERT and INSERT, with no - # MUST_EXIST equivalent, so the writes below cannot assert that every registered particle - # path resolves to a real prim the way _setup_particle_bindings_legacy does. A stale or - # mistyped path silently upserts a new row instead of raising, surfacing as invisible - # particles rather than an error. Same caveat applies to _setup_deformable_bindings_ovstage. - # - # particle_q is already in world space, so resetting the xform stack and pinning an identity - # omni:xform prevents the env-root and asset-root ancestor transforms from being applied on top. + prim_count = len(self._geometry_paths) + self._geometry_paths_list = self.backend.paths.create_path_list_from_strings(self._geometry_paths) + self._geometry_points_query = self.backend.stage.query_from_path_list(self._geometry_paths_list) + if self._geometry_points_query is None: + raise RuntimeError("Failed to create OVRTX geometry point bindings") + # Published points are world-space; do not apply inherited transforms a second time. self.backend.stage.write_attribute( - self._particle_points_query, + self._geometry_points_query, "omni:resetXformStack", ordinal=self._current_ordinal, tensors=np.full(prim_count, True, dtype=np.bool_), is_array=False, ).wait() - identity_xforms = np.tile(np.eye(4, dtype=np.float64), (prim_count, 1, 1)) self.backend.stage.write_attribute( - self._particle_points_query, + self._geometry_points_query, "omni:xform", ordinal=self._current_ordinal, tensors=xform_tensor_from_numpy(identity_xforms), @@ -2296,9 +1809,6 @@ def _setup_particle_bindings_ovstage(self) -> None: semantic=ovstage.AttributeSemantic.MATRIX, ).wait() - if self._particle_points_query is None: - raise RuntimeError("Failed to create OVRTX particle point bindings") - def _update_transforms_ovstage(self) -> None: """Write SDP's matrix layout through the active ovstage ordinal.""" if self._object_xform_query is None: @@ -2306,7 +1816,7 @@ def _update_transforms_ovstage(self) -> None: transforms = SceneDataFormat.TransposedMatrix44d() if not self._sdp.get_transforms(transforms, scales=self._object_scales): return - if self._transform_version == self._sdp.backend.transforms_version: + if self._transforms_version_last_update == self._sdp.backend.transforms_version: return # Stream-ordered zero-copy handoff; wait until OVStage has consumed the shared buffer. self.backend.stage.write_attribute( @@ -2318,98 +1828,26 @@ def _update_transforms_ovstage(self) -> None: semantic=ovstage.AttributeSemantic.MATRIX, cuda_stream=self._warp_device.stream.cuda_stream, ).wait() - self._transform_version = self._sdp.backend.transforms_version + self._transforms_version_last_update = self._sdp.backend.transforms_version def _update_geometries_ovstage(self) -> None: - if self._deformable_points_query is not None or self._particle_points_query is not None: - # If either geometry query is not None, then Newton's the current physics backend - from isaaclab_newton.physics import NewtonManager - - newton_state = NewtonManager.get_state() - if newton_state is None: - raise RuntimeError("Newton state should not be None") - - # particle_q is the world-space particle positions for all deformable bodies and particle - # clouds. A non-None geometry query means entries were registered, so Newton must - # expose particle state; a missing particle_q here is an inconsistent state. - particle_q = getattr(newton_state, "particle_q", None) - if particle_q is None: - raise RuntimeError("Newton state has no particle_q but particle geometry queries exist") - - if self._deformable_points_query is not None: - self._write_particle_q_slices_ovstage( - self._deformable_points_query, - particle_q, - self._deformable_particle_offsets, - self._deformable_particle_counts, - ) - - if self._particle_points_query is not None: - self._write_particle_q_slices_ovstage( - self._particle_points_query, - particle_q, - self._particle_visual_offsets, - self._particle_visual_counts, - ) - - if self._cable_points_query is not None: - self._write_cable_points_ovstage() - - def _write_particle_q_slices_ovstage( - self, - query: Any, - particle_q: wp.array, - particle_offsets: list[int], - particle_counts: list[int], - ) -> None: - """Write world-space ``particle_q`` slices into the ``points`` column of one ovstage query. - - Args: - query: ovstage query selecting the prims whose ``points`` attribute is written. - particle_q: Flat world-space particle positions [m], shape ``[total_particles]``, - dtype ``wp.vec3f``. Slices are passed zero-copy as CUDA DLTensors. - particle_offsets: Start index of each prim's slice into :paramref:`particle_q`. - particle_counts: Number of particles in each prim's slice. - """ - particle_slices = [ - points_tensor_from_warp(particle_q[particle_offset : particle_offset + particle_count]) - for particle_offset, particle_count in zip(particle_offsets, particle_counts, strict=True) - ] - - # The slices alias ``particle_q`` and are handed over zero-copy, so ovstage must not read - # them until the Warp kernels that wrote ``particle_q`` have finished. Passing the producing - # Warp stream as ``cuda_stream`` gives producer ordering: ovstage drains the work already - # queued on that stream before it touches the slices. That replaces the device-wide - # ``wp.synchronize_device()`` with stream-scoped ordering and removes the host copy; it is - # not a nonblocking handoff, and the ``.wait()`` below can still block the calling thread. - self.backend.stage.write_attribute( - query, - "points", - ordinal=self._current_ordinal, - tensors=particle_slices, - is_array=True, - semantic=ovstage.AttributeSemantic.POINT, - cuda_stream=self._warp_device.stream.cuda_stream, - ).wait() - - def _write_cable_points_ovstage(self) -> None: - """Recompute world-space cable curve points on device and write them through ovstage.""" - self._compute_cable_points_world() - - # The cached descriptors alias ``_cable_points`` and are handed over zero-copy, so ovstage - # must not read them until the kernel above has landed. Passing the producing Warp stream as - # ``cuda_stream`` gives producer ordering: ovstage drains the work already queued on that - # stream before it touches the slices. That keeps the handover off the host; it is not a - # nonblocking handoff, and the ``.wait()`` below can still block the calling thread. + """Write SDP point views and wait until OVStage has consumed the borrowed arrays.""" + if self._geometry_points_query is None: + return + points = self._sdp.get_geometry_points() + timestamp = self._sdp.backend.geometry_timestamp + if self._geometry_timestamp_last_update == timestamp: + return self.backend.stage.write_attribute( - self._cable_points_query, + self._geometry_points_query, "points", ordinal=self._current_ordinal, - tensors=self._cable_point_tensors, + tensors=[points_tensor_from_warp(points[path]) for path in self._geometry_paths], is_array=True, semantic=ovstage.AttributeSemantic.POINT, cuda_stream=self._warp_device.stream.cuda_stream, ).wait() + self._geometry_timestamp_last_update = timestamp def _update_camera_ovstage( self, @@ -2500,23 +1938,11 @@ def _safe_destroy_path_list(path_list, name: str) -> None: self._object_xform_query = None _safe_destroy_path_list(self._object_paths_list, "object paths") self._object_paths_list = None - _safe_release_query(self._deformable_points_query, "deformable points") - self._deformable_points_query = None - _safe_destroy_path_list(self._deformable_paths_list, "deformable paths") - self._deformable_paths_list = None - _safe_release_query(self._particle_points_query, "particle points") - self._particle_points_query = None - _safe_destroy_path_list(self._particle_paths_list, "particle paths") - self._particle_paths_list = None - - _safe_release_query(self._cable_points_query, "cable points") - self._cable_points_query = None - _safe_destroy_path_list(self._cable_paths_list, "cable paths") - self._cable_paths_list = None + _safe_release_query(self._geometry_points_query, "geometry points") + self._geometry_points_query = None + _safe_destroy_path_list(self._geometry_paths_list, "geometry paths") + self._geometry_paths_list = None self._object_scales = None self._object_scales_by_path = {} - # Descriptors alias ``_cable_points``; drop them before the buffer so no cached - # DLTensor can outlive the device memory it points at. - self._cable_point_tensors = [] self._current_ordinal = 0 diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_cfg.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_cfg.py index d1f42f3414bf..1d178d3116c6 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_cfg.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_cfg.py @@ -53,9 +53,6 @@ class OVRTXRendererCfg(RendererCfg): renderer_type: str = "ovrtx" """Type identifier for OVRTX renderer.""" - cloning_contexts: tuple[type | str, ...] = ("isaaclab_newton.cloner:NewtonReplicateContext",) - """Newton geometry adapter used by the current OVRTX scene bindings.""" - temp_usd_dir: str | None = None """Directory for temporary USD debug dumps written during OVRTX stage preparation. diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_kernels.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_kernels.py index d5e598ebb5ef..c4ba235d4758 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_kernels.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_kernels.py @@ -173,88 +173,3 @@ def generate_random_colors_from_ids_kernel( """ i, j, k = wp.tid() output_colors[i, j, k] = random_color_from_id_wp(input_ids[i, j, k]) - - -@wp.func -def _cable_capsule_endpoint_world( - shape_id: int, - z_sign: float, - shape_body: wp.array(dtype=wp.int32), - body_q: wp.array(dtype=wp.transformf), - shape_transform: wp.array(dtype=wp.transformf), - shape_scale: wp.array(dtype=wp.vec3f), -) -> wp.vec3f: - """World-space tip of a Newton cable capsule along local ±Z. - - Args: - shape_id: Newton shape id of the capsule segment. - z_sign: ``+1`` / ``-1`` selects the local +Z / -Z capsule tip. - shape_body: Newton shape-to-body index map. - body_q: Body poses in world frame [m, quaternion]. - shape_transform: Local shape transforms relative to body [m, quaternion]. - shape_scale: Shape scales; capsule half-length is ``shape_scale[shape_id][1]`` [m]. - - Returns: - Capsule tip position in world frame [m]. - """ - shape_q = wp.transform_multiply(body_q[shape_body[shape_id]], shape_transform[shape_id]) - return wp.transform_point(shape_q, wp.vec3f(0.0, 0.0, z_sign * shape_scale[shape_id][1])) - - -@wp.kernel(enable_backward=False) -def compute_cable_points_world_kernel( - shape_ids: wp.array(dtype=wp.int32), # type: ignore - offsets: wp.array(dtype=wp.int32), # type: ignore - counts: wp.array(dtype=wp.int32), # type: ignore - shape_body: wp.array(dtype=wp.int32), # type: ignore - body_q: wp.array(dtype=wp.transformf), # type: ignore - shape_transform: wp.array(dtype=wp.transformf), # type: ignore - shape_scale: wp.array(dtype=wp.vec3f), # type: ignore - points_out: wp.array(dtype=wp.vec3f), # type: ignore -): - """Write world-space cable curve points from Newton segment bodies. - - Same endpoint construction as NewtonManager Fabric cable sync, but emits world space because - the OVRTX cable prims pin an identity omni:xform with the transform stack reset. Endpoints - come from the first and last capsule; interior points are the midpoint of the two adjacent - capsule ends. - - Launch with ``dim=(num_curves, max_segment_count + 1)``. Each thread owns one - ``(curve, point)``; threads with ``point > counts[curve]`` return immediately. - - Args: - shape_ids: Flattened Newton segment shape ids packed by curve. - offsets: Start index into ``shape_ids`` for each curve. - counts: Segment count per curve. - shape_body: Newton shape-to-body index map. - body_q: Body poses in world frame [m, quaternion]. - shape_transform: Local shape transforms relative to body [m, quaternion]. - shape_scale: Shape scales; capsule half-length is ``shape_scale[shape][1]`` [m]. - points_out: Flattened world-space curve points [m]. - """ - curve, point = wp.tid() - segment_count = counts[curve] - if point > segment_count: - return - - offset = offsets[curve] - # ``offset`` is the prefix sum of segment counts. Every preceding curve contributes one - # additional endpoint, so its point-buffer start is ``offset + curve``. - point_base = offset + curve - if point == 0: - endpoint_w = _cable_capsule_endpoint_world( - shape_ids[offset], -1.0, shape_body, body_q, shape_transform, shape_scale - ) - elif point == segment_count: - endpoint_w = _cable_capsule_endpoint_world( - shape_ids[offset + segment_count - 1], 1.0, shape_body, body_q, shape_transform, shape_scale - ) - else: - left_w = _cable_capsule_endpoint_world( - shape_ids[offset + point - 1], 1.0, shape_body, body_q, shape_transform, shape_scale - ) - right_w = _cable_capsule_endpoint_world( - shape_ids[offset + point], -1.0, shape_body, body_q, shape_transform, shape_scale - ) - endpoint_w = (left_w + right_w) * 0.5 - points_out[point_base + point] = endpoint_w diff --git a/source/isaaclab_ov/test/assets/test_deformable_object_helpers.py b/source/isaaclab_ov/test/assets/test_deformable_object_helpers.py index cd58647c8e1d..2e5daccebac6 100644 --- a/source/isaaclab_ov/test/assets/test_deformable_object_helpers.py +++ b/source/isaaclab_ov/test/assets/test_deformable_object_helpers.py @@ -22,11 +22,17 @@ DeformableObjectData, ) from isaaclab_ov.assets.deformable_object.kernels import vec6f # noqa: E402 +from isaaclab_ov.physics.ovphysx_manager import OvPhysxManager, OvPhysxSceneDataBackend # noqa: E402 wp.init() wp.set_device("cpu") +@pytest.fixture(autouse=True) +def scene_data_backend(monkeypatch): + monkeypatch.setattr(OvPhysxManager, "_scene_data_backend", OvPhysxSceneDataBackend()) + + class _FakeBodyView: """Minimal generic OVPhysX view used by the asset shell.""" diff --git a/source/isaaclab_ov/test/physics/test_ovphysx_scene_data_backend.py b/source/isaaclab_ov/test/physics/test_ovphysx_scene_data_backend.py index e338f7fcca18..0b93196976c2 100644 --- a/source/isaaclab_ov/test/physics/test_ovphysx_scene_data_backend.py +++ b/source/isaaclab_ov/test/physics/test_ovphysx_scene_data_backend.py @@ -814,7 +814,6 @@ def read(dst): ) monkeypatch.setattr(module, "UsdPhysics", SimpleNamespace(RigidBodyAPI=object())) - monkeypatch.setattr(module, "discover_deformables_on_stage", lambda stage: []) stage = SimpleNamespace(Traverse=lambda: (_fake_rigid_body_prim(path) for path in paths)) backend = module.OvPhysxSceneDataBackend() backend.setup(FakePhysX(), stage, "cpu") @@ -874,80 +873,94 @@ def fail_read(name, dst): sdp.get_transforms(SceneDataFormat.Transform()) -def test_deformable_only_setup_publishes_surface_geometry(monkeypatch): - """Surface SceneData views publish geometry even without rigid bodies. +@pytest.mark.parametrize("declared", [False, True]) +def test_geometry_publication_distinguishes_undeclared_and_empty_scenes(declared): + """Native setup without a geometry declaration cannot publish an empty scene.""" + from isaaclab_ov.physics.ovphysx_manager import OvPhysxSceneDataBackend - Regression: constructing ``OvPhysxDeformableBodyView`` without - ``simulation_nodal_position_type`` / ``simulation_element_indices_type`` - left cloth ``point_count`` at 0, so OVRTX wrote identity-xformed rest - buffers and the Franka cloth disappeared. - """ - import isaaclab_ov.physics.ovphysx_manager as om_mod + backend = OvPhysxSceneDataBackend() + stage = SimpleNamespace(Traverse=lambda: iter(())) + backend.setup(None, stage, "cpu", () if declared else None) + if declared: + assert backend.get_geometry_batches() == [] + assert backend.native_geometry_formats == () + backend.setup(None, stage, "cpu") + with pytest.raises(RuntimeError, match="ClonePlan"): + backend.get_geometry_batches() + with pytest.raises(RuntimeError, match="ClonePlan"): + _ = backend.native_geometry_formats + + +@pytest.mark.parametrize("node_padding", [0, 1]) +def test_deformable_only_setup_publishes_declared_geometry_in_native_order(node_padding): + """Mixed native views fill declared geometry slices without a packing pass.""" + import isaaclab_ov.physics.ovphysx_manager as module + import numpy as np + import warp as wp from isaaclab_ov import tensor_types as TT - from isaaclab_ov.physics.ovphysx_manager import OvPhysxSceneDataBackend + from isaaclab.scene_data import SceneDataProvider from isaaclab.scene_data.deformable_discovery import DeformableStageEntry - b = OvPhysxSceneDataBackend() - captured: dict = {} - - class _FakeView: - count = 2 - max_simulation_nodes_per_body = 4 - # Views may report a child mesh; SceneData must publish discovered roots. - prim_paths = [ - "/World/envs/env_0/Deformable/sim_mesh", - "/World/envs/env_1/Deformable/sim_mesh", - ] + values = {"/Clones/slot_2/Asset": 2.0, "/Clones/slot_9/Asset": 9.0, "/Shared": 100.0} + entries = [ + DeformableStageEntry(path, path + "/sim", path + "/vis", "volume" if path == "/Shared" else "surface", 4, 4) + for path in values + ] + entries[-1].vertices = np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0], [0, 0, 1]], dtype=np.float32) + entries[-1].indices = np.array([0, 1, 2, 3], dtype=np.int32) + entries[-1].vis_vertices = np.array([[0.5, 0, 0], [0, 0.5, 0]], dtype=np.float32) + entries[-1].vis_vertex_count = 2 + reads = [] + bindings = [] - def __init__(self, physx, **kwargs): - captured.update(kwargs) + class NativePhysX: + def create_tensor_binding(self, *, prim_paths, tensor_type): + bindings.append((prim_paths, tensor_type)) + nodes = 4 + node_padding - def read_into(self, tensor_type, dst): - captured["read_tensor_type"] = tensor_type + def read(dst): + data = np.array( + [np.arange(nodes * 3).reshape(nodes, 3) + values[path] for path in reversed(prim_paths)], + dtype=np.float32, + ) + wp.copy(dst, wp.array(data, dtype=wp.float32, device="cpu")) + reads.append((dst.ptr, dst.size * wp.types.type_size_in_bytes(dst.dtype))) - monkeypatch.setattr( - "isaaclab_ov.assets.deformable_object.views.OvPhysxDeformableBodyView", - _FakeView, - ) - monkeypatch.setattr( - om_mod, - "discover_deformables_on_stage", - lambda stage: [ - DeformableStageEntry( - root_path="/World/envs/env_0/Deformable", - sim_mesh_path="/World/envs/env_0/Deformable/sim_mesh", - vis_mesh_path="/World/envs/env_0/Deformable/geometry/mesh", - deformable_type="surface", - vertex_count=4, - vis_vertex_count=4, - ), - DeformableStageEntry( - root_path="/World/envs/env_1/Deformable", - sim_mesh_path="/World/envs/env_1/Deformable/sim_mesh", - vis_mesh_path="/World/envs/env_1/Deformable/geometry/mesh", - deformable_type="surface", - vertex_count=4, - vis_vertex_count=4, - ), - ], - ) + return SimpleNamespace( + shape=(len(prim_paths), nodes, 3), + count=len(prim_paths), + dtype=SimpleNamespace(code=2, bits=32, lanes=1), + prim_paths=[path + "/sim" for path in reversed(prim_paths)], + read=read, + destroy=lambda: None, + ) + backend = module.OvPhysxSceneDataBackend() stage = SimpleNamespace(Traverse=lambda: iter(())) - b.setup(physx=object(), stage=stage, device="cpu") - - assert captured["simulation_nodal_position_type"] == TT.SURFACE_DEFORMABLE_SIM_POSITION - assert captured["simulation_element_indices_type"] == TT.SURFACE_DEFORMABLE_SIM_ELEMENT_INDICES - assert TT.SURFACE_DEFORMABLE_SIM_POSITION in captured["tensor_types"] - assert TT.SURFACE_DEFORMABLE_SIM_ELEMENT_INDICES in captured["tensor_types"] - assert b.point_count == 8 - assert b.transform_count == 0 - assert b.transform_paths == [] - assert b.geometry_paths == [ - "/World/envs/env_0/Deformable", - "/World/envs/env_1/Deformable", - ] - assert b.geometry_counts == [4, 4] - - _ = b.points - assert captured["read_tensor_type"] == TT.SURFACE_DEFORMABLE_SIM_POSITION + if node_padding: + with pytest.raises(RuntimeError, match="node counts"): + backend.setup(NativePhysX(), stage, "cpu", entries) + return + backend.setup(NativePhysX(), stage, "cpu", entries) + + assert {kind for _, kind in bindings} == {TT.SURFACE_DEFORMABLE_SIM_POSITION, TT.DEFORMABLE_SIM_NODAL_POSITION} + provider = SceneDataProvider(backend) + visual = provider.get_geometry_points() + assert set(visual) == {path + "/vis" for path in values} + assert reads[0][0] == visual["/Clones/slot_9/Asset/vis"].ptr + assert reads[1][0] == reads[0][0] + reads[0][1] + assert visual["/Clones/slot_2/Asset/vis"].ptr == reads[0][0] + 4 * wp.types.type_size_in_bytes(wp.vec3f) + nodes = np.arange(12).reshape(4, 3) + for path in ("/Clones/slot_2/Asset", "/Clones/slot_9/Asset"): + np.testing.assert_array_equal(visual[path + "/vis"].numpy(), nodes + values[path]) + expected = (nodes[0] + nodes[[1, 2]]) / 2 + values["/Shared"] + np.testing.assert_array_equal(visual["/Shared/vis"].numpy(), expected) + assert provider.get_geometry_points() is visual + assert len(reads) == len(bindings) + values["/Shared"] += 1 + backend.geometry_timestamp += 1 + updated = provider.get_geometry_points() + assert len(reads) == 2 * len(bindings) + assert updated["/Shared/vis"] is visual["/Shared/vis"] + np.testing.assert_array_equal(updated["/Shared/vis"].numpy(), expected + 1) diff --git a/source/isaaclab_ov/test/test_ovrtx_clone_plan.py b/source/isaaclab_ov/test/test_ovrtx_clone_plan.py index ab1ac8d061d3..62098a425988 100644 --- a/source/isaaclab_ov/test/test_ovrtx_clone_plan.py +++ b/source/isaaclab_ov/test/test_ovrtx_clone_plan.py @@ -10,6 +10,7 @@ import importlib.util from pathlib import Path from types import SimpleNamespace +from unittest.mock import Mock import numpy as np import pytest @@ -66,29 +67,6 @@ def _make_multi_env_stage(num_envs: int) -> Usd.Stage: return stage -def _assert_export_contains_env_roots_and_children(exported: str, env_indices: range | list[int]) -> None: - """Listed environment roots appear in the stage export.""" - for env_idx in env_indices: - assert f'def Xform "env_{env_idx}"' in exported - assert f'def Xform "Object_env{env_idx}_only"' in exported - - assert exported.count('def Xform "Robot"') == len(env_indices) - assert exported.count('def Camera "Camera"') == len(env_indices) - - -def _assert_export_contains_empty_env_roots(exported: str, env_indices: range | list[int]) -> None: - """Listed environment roots remain while their non-source children are omitted.""" - for env_idx in env_indices: - assert f'def Xform "env_{env_idx}"' in exported - assert f'def Xform "Object_env{env_idx}_only"' not in exported - - -def _assert_export_omits_env_roots(exported: str, env_indices: range | list[int]) -> None: - """Listed environment roots are absent from the export.""" - for env_idx in env_indices: - assert f'def Xform "env_{env_idx}"' not in exported - - def _patch_simulation_context(monkeypatch: pytest.MonkeyPatch, clone_plan: ClonePlan | None) -> None: mock_ctx = SimpleNamespace(get_clone_plan=lambda: clone_plan) monkeypatch.setattr( @@ -119,7 +97,7 @@ def _make_ovrtx_renderer_without_backend() -> OVRTXRenderer: renderer._exported_usd_string = None renderer._initialized_scene = False renderer._use_ovstage = False - renderer._sdp = SimpleNamespace(backend=SimpleNamespace(transform_paths=[])) + renderer._sdp = SimpleNamespace(backend=SimpleNamespace(transform_paths=[]), get_geometry_points=lambda: {}) renderer._object_scales = None renderer._object_scales_by_path = {} return renderer @@ -375,38 +353,9 @@ def test_capture_object_scales_populates_source_and_destination_scale_array(env_ assert "/World/envs/Unplanned" not in renderer._object_scales_by_path -def test_prepare_stage_keeps_material_binding_inside_clone_source(monkeypatch: pytest.MonkeyPatch): - """A row export keeps its bound material beneath the root cloned by OVRTX.""" - num_envs = 3 - stage = _make_multi_env_stage(num_envs) - source = "/World/envs/env_0/Robot" - material = UsdShade.Material.Define(stage, f"{source}/warm") - body = UsdGeom.Xform.Define(stage, f"{source}/Body").GetPrim() - UsdShade.MaterialBindingAPI.Apply(body) - UsdShade.MaterialBindingAPI(body).Bind(material) - plan = ClonePlan( - sources=(source,), - destinations=("/World/envs/env_{}/Robot",), - clone_mask=np.ones((1, num_envs), dtype=np.bool_), - env_ids=np.arange(num_envs, dtype=np.int64), - positions=np.zeros((num_envs, 3), dtype=np.float32), - ) - _patch_simulation_context(monkeypatch, plan) - renderer = _make_ovrtx_renderer_without_backend() - - renderer.prepare_stage(stage, num_envs) - - exported_layer = Sdf.Layer.CreateAnonymous(".usda") - assert exported_layer.ImportFromString(renderer._exported_usd_string) - exported_stage = Usd.Stage.Open(exported_layer) - binding = UsdShade.MaterialBindingAPI(exported_stage.GetPrimAtPath(f"{source}/Body")).GetDirectBindingRel() - assert binding.GetTargets() == [Sdf.Path(f"{source}/warm")] - assert exported_stage.GetPrimAtPath(f"{source}/warm") - assert not exported_stage.GetPrimAtPath("/World/envs/env_1/Robot") - - -def test_prepare_stage_writes_pre_ovrtx_stage_dump(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - """prepare_stage writes the raw stage before OVRTX-specific preparation.""" +@pytest.mark.parametrize("dump_enabled", [False, True]) +def test_prepare_stage_writes_debug_dump_only_when_requested(tmp_path, monkeypatch, dump_enabled): + """The optional dump preserves the raw stage; default preparation performs no file writes.""" _patch_simulation_context( monkeypatch, ClonePlan( @@ -421,45 +370,19 @@ def test_prepare_stage_writes_pre_ovrtx_stage_dump(tmp_path: Path, monkeypatch: stage = _make_multi_env_stage(2) renderer = _make_ovrtx_renderer_without_backend() output_dir = tmp_path / "nested" / "usd" - renderer.cfg.temp_usd_dir = str(output_dir) + renderer.cfg.temp_usd_dir = str(output_dir) if dump_enabled else None + if not dump_enabled: + monkeypatch.setattr(ovrtx_renderer_module, "_write_file", Mock(side_effect=AssertionError("Unexpected dump"))) expected_pre_export = stage.ExportToString() renderer.prepare_stage(stage, 2) - pre_stage_path = output_dir / _PRE_OVRTX_STAGE_FILE - assert pre_stage_path.is_file() - assert pre_stage_path.read_text(encoding="utf-8") == expected_pre_export + assert output_dir.exists() is dump_enabled + if dump_enabled: + assert (output_dir / _PRE_OVRTX_STAGE_FILE).read_text(encoding="utf-8") == expected_pre_export assert (output_dir / _OVRTX_STAGE_FILE).exists() is False -def test_prepare_stage_skips_temp_usd_write_when_temp_usd_dir_unset(monkeypatch: pytest.MonkeyPatch): - """prepare_stage does not write debug dumps when temp_usd_dir is None.""" - _patch_simulation_context( - monkeypatch, - ClonePlan( - sources=("/World/envs/env_0",), - destinations=("/World/envs/env_{}",), - clone_mask=np.ones((1, 2), dtype=np.bool_), - env_ids=np.arange(2, dtype=np.int64), - positions=np.zeros((2, 3), dtype=np.float32), - ), - ) - write_calls: list[tuple[Path, str, str]] = [] - - def _record_write(output_dir: Path, file_name: str, content: str) -> None: - write_calls.append((output_dir, file_name, content)) - - monkeypatch.setattr("isaaclab_ov.renderers.ovrtx_renderer._write_file", _record_write) - - stage = _make_multi_env_stage(2) - renderer = _make_ovrtx_renderer_without_backend() - renderer.cfg.temp_usd_dir = None - - renderer.prepare_stage(stage, 2) - - assert write_calls == [] - - def test_initialize_camera_render_data_from_spec_writes_combined_stage_dump(tmp_path: Path): """_initialize_camera_render_data_from_spec writes the combined stage when temp_usd_dir is set.""" renderer = _make_ovrtx_renderer_without_backend() @@ -547,7 +470,7 @@ def _write_array_attribute(prim_paths: list[str], attribute_name: str, tensors: renderer.backend.renderer.bind_attribute = lambda **_kwargs: object() renderer.backend.renderer.write_attribute = lambda **_kwargs: None renderer._setup_xform_bindings_legacy = lambda: None - renderer._setup_deformable_bindings_legacy = lambda _num_envs: None + renderer._setup_geometry_bindings_legacy = lambda: None spec = _make_camera_render_spec(num_envs=num_envs) render_data = OVRTXCameraRenderData(spec, "cpu", render_scope_name="RenderCamera_0") @@ -563,46 +486,34 @@ def _write_array_attribute(prim_paths: list[str], attribute_name: str, tensors: ] -def test_prepare_stage_exports_only_clone_source_content(monkeypatch: pytest.MonkeyPatch): - """prepare_stage exports only its source-row content.""" - num_envs = 4 - - published = ClonePlan( - sources=("/World/envs/env_0",), - destinations=("/World/envs/env_{}",), - clone_mask=np.ones((1, num_envs), dtype=np.bool_), - env_ids=np.arange(num_envs, dtype=np.int64), - positions=np.zeros((num_envs, 3), dtype=np.float32), - ) - _patch_simulation_context(monkeypatch, published) - - stage = _make_multi_env_stage(num_envs) - renderer = _make_ovrtx_renderer_without_backend() - - renderer.prepare_stage(stage, 4) - - # Only the env_0 source subtree keeps content. The rows clone the env roots themselves, so the - # remaining roots are trimmed: OVRTX refuses to clone onto a prim that already exists. - _assert_export_contains_env_roots_and_children(renderer._exported_usd_string, [0]) - _assert_export_omits_env_roots(renderer._exported_usd_string, [1, 2, 3]) - - -def test_prepare_stage_keeps_env_roots_when_rows_target_prims_beneath_them(monkeypatch: pytest.MonkeyPatch): - """Rows cloning below the env roots keep them, since they carry transforms cloning cannot recreate.""" - num_envs = 3 - - _patch_simulation_context( - monkeypatch, - ClonePlan( - sources=("/World/envs/env_0/Robot",), - destinations=("/World/envs/env_{}/Robot",), - clone_mask=np.ones((1, num_envs), dtype=np.bool_), - env_ids=np.arange(num_envs, dtype=np.int64), - positions=np.zeros((num_envs, 3), dtype=np.float32), - ), +@pytest.mark.parametrize("suffix", ["", "/Robot"]) +def test_prepare_stage_exports_only_clone_sources_and_their_materials(monkeypatch, suffix): + """Keep prototype contents and material bindings, retaining only needed destination ancestors.""" + stage = _make_multi_env_stage(3) + source = f"/World/envs/env_0{suffix}" + material = UsdShade.Material.Define(stage, f"{source}/warm") + body = UsdGeom.Xform.Define(stage, f"{source}/Body").GetPrim() + UsdShade.MaterialBindingAPI.Apply(body) + UsdShade.MaterialBindingAPI(body).Bind(material) + plan = ClonePlan( + sources=(source,), + destinations=(f"/World/envs/env_{{}}{suffix}",), + clone_mask=np.ones((1, 3), dtype=np.bool_), + env_ids=np.arange(3), + positions=np.zeros((3, 3), dtype=np.float32), ) + _patch_simulation_context(monkeypatch, plan) renderer = _make_ovrtx_renderer_without_backend() - - renderer.prepare_stage(_make_multi_env_stage(num_envs), num_envs) - - _assert_export_contains_empty_env_roots(renderer._exported_usd_string, [1, 2]) + renderer.prepare_stage(stage, 3) + exported = Usd.Stage.CreateInMemory() + assert exported.GetRootLayer().ImportFromString(renderer._exported_usd_string) + binding = UsdShade.MaterialBindingAPI(exported.GetPrimAtPath(f"{source}/Body")).GetDirectBindingRel() + assert binding.GetTargets() == [Sdf.Path(f"{source}/warm")] + assert exported.GetPrimAtPath(f"{source}/warm") + assert exported.GetPrimAtPath("/World/envs/env_0/Robot") + assert bool(exported.GetPrimAtPath("/World/envs/env_0/Camera")) is (not suffix) + for env_id in (1, 2): + root = f"/World/envs/env_{env_id}" + assert bool(exported.GetPrimAtPath(root)) is bool(suffix) + assert not exported.GetPrimAtPath(f"{root}/Robot") + assert not exported.GetPrimAtPath(f"{root}/Object_env{env_id}_only") diff --git a/source/isaaclab_ov/test/test_ovrtx_deformable_bindings.py b/source/isaaclab_ov/test/test_ovrtx_deformable_bindings.py index e8aa63dc7217..45702538dc42 100644 --- a/source/isaaclab_ov/test/test_ovrtx_deformable_bindings.py +++ b/source/isaaclab_ov/test/test_ovrtx_deformable_bindings.py @@ -3,410 +3,122 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Unit tests for OVRTX deformable mesh point bindings.""" +"""OVRTX bindings consume backend-independent SDP point and transform publications.""" from __future__ import annotations import importlib.util from types import SimpleNamespace +from unittest.mock import MagicMock import numpy as np import pytest import warp as wp -_REQUIRED_MODULES = ("isaaclab_ov", "ovrtx", "pxr", "isaaclab_newton") +_REQUIRED_MODULES = ("isaaclab_ov", "ovrtx", "pxr") _MISSING_MODULES = [module for module in _REQUIRED_MODULES if importlib.util.find_spec(module) is None] - -pytestmark = [ - pytest.mark.skipif( - bool(_MISSING_MODULES), - reason=f"requires optional modules: {', '.join(_MISSING_MODULES)}", - ), -] +pytestmark = pytest.mark.skipif(bool(_MISSING_MODULES), reason=f"requires optional modules: {_MISSING_MODULES}") if not _MISSING_MODULES: - import isaaclab_ov.renderers.ovrtx_renderer as ovrtx_renderer_module # noqa: E402 - - # ovstage is an unconditional dependency of isaaclab_ov, so it is importable here. - import ovstage # noqa: E402 - from isaaclab_newton.physics import NewtonManager # noqa: E402 - from isaaclab_ov.renderers import OVRTXRendererCfg # noqa: E402 - from isaaclab_ov.renderers.ovrtx_renderer import OVRTXRenderer # noqa: E402 - from ovrtx import BindingFlag, DataAccess # noqa: E402 -else: - NewtonManager = None - OVRTXRenderer = None - OVRTXRendererCfg = None - ovrtx_renderer_module = None - BindingFlag = None - DataAccess = None - - -class _FakePointsBinding: - """Capture array writes made through an OVRTX array attribute binding.""" - - def __init__(self, attribute_name: str): - self.attribute_name = attribute_name - self.written = None - self.write_kwargs: dict | None = None - self.unbound = False - - def write(self, data, **kwargs): - self.written = data - self.write_kwargs = kwargs - - def map(self, device=None, device_id=0): # noqa: ARG002 - raise RuntimeError("bind_array_attribute bindings do not expose mapped point buffers") - - def unbind(self): - self.unbound = True - - -class _FakeOVRTXBackend: - """Minimal OVRTX backend stub for deformable binding setup.""" - - def __init__(self): - self.bindings = {} - self.calls = [] - self.writes = [] + import isaaclab_ov.renderers.ovrtx_renderer as ovrtx_renderer_module + from isaaclab_ov.renderers import OVRTXRendererCfg + from isaaclab_ov.renderers.ovrtx_renderer import OVRTXRenderer + from ovrtx import DataAccess - def bind_array_attribute(self, **kwargs): - self.calls.append(kwargs) - binding = _FakePointsBinding(kwargs["attribute_name"]) - self.bindings[kwargs["attribute_name"]] = binding - return binding - def bind_attribute(self, **kwargs): - self.calls.append(kwargs) - binding = _FakePointsBinding(kwargs["attribute_name"]) - self.bindings[kwargs["attribute_name"]] = binding - return binding - - def query_prims(self, **kwargs): # noqa: ARG002 - return { - "/World/envs/env_0/Deformable/mesh": {}, - "/World/envs/env_0/Deformable/geometry/mesh": {}, - } - - def write_attribute(self, **kwargs): - self.writes.append(kwargs) - - -def _make_renderer_without_backend(device: str = "cpu") -> tuple[OVRTXRenderer, _FakeOVRTXBackend]: +def _make_renderer_without_backend() -> tuple[OVRTXRenderer, MagicMock]: renderer = OVRTXRenderer.__new__(OVRTXRenderer) renderer.cfg = OVRTXRendererCfg() - renderer.backend = SimpleNamespace() - renderer._device = device - renderer._camera_prim_path = "/World/envs/env_0/Camera" - renderer._clone_plan = None - renderer.backend.renderer = _FakeOVRTXBackend() - renderer._deformable_points_binding = None - renderer._deformable_particle_offsets = [] - renderer._deformable_particle_counts = [] - renderer._particle_points_binding = None - renderer._particle_visual_offsets = [] - renderer._particle_visual_counts = [] - renderer._particle_workaround_applied = False - # Cable bindings are set in __init__, which this fixture bypasses via __new__. Without them - # _update_geometries_legacy raises AttributeError on its cable check before reaching anything - # this module is testing. - renderer._cable_points_binding = None - renderer._cable_segment_counts = [] + renderer.backend = SimpleNamespace(renderer=MagicMock()) + renderer._device = "cpu" + renderer._geometry_paths = [] + renderer._geometry_timestamp_last_update = -1 renderer._use_ovstage = False + renderer._init_fields_legacy() return renderer, renderer.backend.renderer -def test_setup_deformable_bindings_binds_mixed_surface_and_volume_entries(monkeypatch: pytest.MonkeyPatch): - """Registry metadata binds every surface and volume instance without a USD stage.""" - renderer, backend = _make_renderer_without_backend() - surface_entry = SimpleNamespace( - prim_path="/World/envs/env_[^/]+/DeformableSurface", - vis_mesh_prim_path="/World/envs/env_[^/]+/DeformableSurface/mesh", - deformable_type="surface", - particle_offsets=[0, 3], - particles_per_body=3, - ) - volume_entry = SimpleNamespace( - prim_path="/World/envs/env_[^/]+/DeformableVolume", - vis_mesh_prim_path="/World/envs/env_[^/]+/DeformableVolume/mesh", - deformable_type="volume", - particle_offsets=[6, 9], - particles_per_body=3, - ) - - monkeypatch.setattr("isaaclab.sim.utils.stage.get_current_stage", lambda: None) - monkeypatch.setattr(NewtonManager, "_deformable_registry", [surface_entry, volume_entry]) - - renderer._setup_deformable_bindings_legacy(num_envs=2) - - paths = [ - "/World/envs/env_0/DeformableSurface/mesh", - "/World/envs/env_1/DeformableSurface/mesh", - "/World/envs/env_0/DeformableVolume/mesh", - "/World/envs/env_1/DeformableVolume/mesh", - ] - assert len(backend.calls) == 1 - assert backend.calls[0]["prim_paths"] == paths - assert backend.calls[0]["attribute_name"] == "points" - assert backend.calls[0]["dtype"] is np.float32 - assert backend.calls[0]["shape"] == (3,) - assert [write["attribute_name"] for write in backend.writes] == ["omni:resetXformStack", "omni:xform"] - assert all(write["prim_paths"] == paths for write in backend.writes) - particle_q = wp.array(np.arange(36, dtype=np.float32).reshape(12, 3), dtype=wp.vec3f, device="cpu") - monkeypatch.setattr(NewtonManager, "get_state", classmethod(lambda cls: SimpleNamespace(particle_q=particle_q))) +@pytest.mark.parametrize("use_ovstage", [False, True]) +def test_geometry_bindings_borrow_mixed_sdp_points_and_follow_pointer_swaps(use_ovstage): + """Mesh, particle and curve points share one binding and retain publication ordering.""" + renderer, native = _make_renderer_without_backend() + assert not renderer.cfg.cloning_contexts + renderer._use_ovstage = use_ovstage renderer._warp_device = SimpleNamespace(stream=SimpleNamespace(cuda_stream=42)) - renderer.update_geometries() - written = backend.bindings["points"].written - assert len(written) == len(paths) - for points, offset in zip(written, surface_entry.particle_offsets + volume_entry.particle_offsets, strict=True): - np.testing.assert_array_equal(points.numpy(), particle_q.numpy()[offset : offset + 3]) - - -def test_setup_deformable_bindings_rejects_offset_count_mismatch(monkeypatch: pytest.MonkeyPatch): - """Registry entries must provide one particle offset per environment, listing every bad entry.""" - renderer, _backend = _make_renderer_without_backend() - bad_entry = SimpleNamespace( - prim_path="/World/envs/env_[^/]+/Deformable", - vis_mesh_prim_path="/World/envs/env_[^/]+/Deformable/mesh", - deformable_type="surface", - particle_offsets=[0], - particles_per_body=3, - ) - other_bad_entry = SimpleNamespace( - prim_path="/World/envs/env_[^/]+/DeformableOther", - vis_mesh_prim_path="/World/envs/env_[^/]+/DeformableOther/mesh", - deformable_type="surface", - particle_offsets=[0, 3, 6], - particles_per_body=3, - ) - - monkeypatch.setattr(NewtonManager, "_deformable_registry", [bad_entry, other_bad_entry]) - - with pytest.raises(RuntimeError, match="one particle offset per environment") as excinfo: - renderer._setup_deformable_bindings_legacy(num_envs=2) - - message = str(excinfo.value) - assert bad_entry.prim_path in message - assert other_bad_entry.prim_path in message - - -def test_update_geometries_rejects_inconsistent_deformable_mapping(monkeypatch: pytest.MonkeyPatch): - """Geometry sync fails fast when offset and count metadata drift out of alignment.""" - renderer, _backend = _make_renderer_without_backend() - renderer._deformable_points_binding = _FakePointsBinding("points") - renderer._deformable_particle_offsets = [0] - renderer._deformable_particle_counts = [3, 3] - particle_q = wp.array( - [wp.vec3f(float(i), 0.0, 0.0) for i in range(4)], - dtype=wp.vec3f, - device="cpu", - ) - monkeypatch.setattr(NewtonManager, "get_state", classmethod(lambda cls: SimpleNamespace(particle_q=particle_q))) - - with pytest.raises(ValueError, match="zip"): - renderer.update_geometries() - - -def test_setup_particle_points_bindings_binds_multiple_mpm_assets(monkeypatch: pytest.MonkeyPatch): - """Multiple MPM assets bind as ``num_assets * num_envs`` points prims, like deformables.""" - renderer, backend = _make_renderer_without_backend() - particle_visual_prims = { - "/World/envs/env_0/Media/Particles": SimpleNamespace(offset=0, count=5), - "/World/envs/env_1/Media/Particles": SimpleNamespace(offset=5, count=5), - "/World/envs/env_0/Foam/Particles": SimpleNamespace(offset=10, count=3), - "/World/envs/env_1/Foam/Particles": SimpleNamespace(offset=13, count=3), + points = { + path: wp.array(np.arange(count * 3, dtype=np.float32).reshape(count, 3), dtype=wp.vec3f, device="cpu") + for path, count in ( + ("/Cells/cell_3/Cloth/mesh", 3), + ("/Cells/cell_7/Volume/visual", 5), + ("/Shared/Particles", 4), + ("/Cells/cell_3/Cable/curve", 2), + ) } + publication = SimpleNamespace(points=points, geometry_timestamp=0) + last_points = points - monkeypatch.setattr(NewtonManager, "_particle_visual_prims", particle_visual_prims) + def read_points(): + nonlocal last_points + if publication.points is not last_points: + publication.geometry_timestamp += 1 + last_points = publication.points + return publication.points - renderer._setup_particle_bindings_legacy() + renderer._sdp = SimpleNamespace(backend=publication, get_geometry_points=read_points) + if use_ovstage: + renderer._init_fields_ovstage() + renderer._current_ordinal = 7 + renderer.backend.paths = SimpleNamespace(create_path_list_from_strings=lambda paths: paths) + renderer.backend.stage = MagicMock() + renderer.backend.stage.query_from_path_list.side_effect = lambda paths: paths + publication.points = {} + renderer._setup_geometry_bindings_ovstage() + renderer.update_geometries() + renderer.backend.stage.write_attribute.assert_not_called() + publication.points = points + renderer._setup_geometry_bindings_ovstage() + assert renderer._geometry_points_query == list(points) + write = renderer.backend.stage.write_attribute + assert [call.args[1] for call in write.call_args_list] == ["omni:resetXformStack", "omni:xform"] + np.testing.assert_array_equal(write.call_args_list[0].kwargs["tensors"], np.ones(len(points), dtype=np.bool_)) + write.reset_mock() + else: + publication.points = {} + renderer._setup_geometry_bindings_legacy() + renderer.update_geometries() + native.bind_array_attribute.assert_not_called() + publication.points = points + renderer._setup_geometry_bindings_legacy() + assert native.bind_array_attribute.call_args.kwargs["prim_paths"] == list(points) + np.testing.assert_array_equal( + native.write_attribute.call_args_list[0].kwargs["tensor"], np.ones(len(points), dtype=np.bool_) + ) + np.testing.assert_array_equal( + native.write_attribute.call_args_list[1].kwargs["tensor"], np.tile(np.eye(4), (len(points), 1, 1)) + ) + write = renderer._geometry_points_binding.write - assert len(backend.calls) == 1 - assert backend.calls[0]["attribute_name"] == "points" - assert backend.calls[0]["flags"] is BindingFlag.OPTIMIZE - assert [write["attribute_name"] for write in backend.writes] == ["omni:resetXformStack", "omni:xform"] - # Binding order follows dict insertion order (no path sort). - assert backend.calls[0]["prim_paths"] == [ - "/World/envs/env_0/Media/Particles", - "/World/envs/env_1/Media/Particles", - "/World/envs/env_0/Foam/Particles", - "/World/envs/env_1/Foam/Particles", - ] - particle_q = wp.array(np.arange(48, dtype=np.float32).reshape(16, 3), dtype=wp.vec3f, device="cpu") - monkeypatch.setattr(NewtonManager, "get_state", classmethod(lambda cls: SimpleNamespace(particle_q=particle_q))) - renderer._warp_device = SimpleNamespace(stream=SimpleNamespace(cuda_stream=42)) renderer.update_geometries() - for points, visual in zip(backend.bindings["points"].written, particle_visual_prims.values(), strict=True): - np.testing.assert_array_equal(points.numpy(), particle_q.numpy()[visual.offset : visual.offset + visual.count]) - - -def test_update_geometries_writes_deformable_and_mpm_bindings(monkeypatch: pytest.MonkeyPatch): - """Deformable and MPM points use GPU ASYNC writes from the first update.""" - renderer, backend = _make_renderer_without_backend() - renderer._deformable_points_binding = _FakePointsBinding("deformable_points") - renderer._deformable_particle_offsets = [1] - renderer._deformable_particle_counts = [1] - renderer._particle_points_binding = _FakePointsBinding("points") - renderer._particle_visual_offsets = [2] - renderer._particle_visual_counts = [2] - particle_q = wp.array( - [ - wp.vec3f(0.0, 0.0, 0.0), - wp.vec3f(1.0, 0.0, 0.0), - wp.vec3f(2.0, 3.0, 4.0), - wp.vec3f(5.0, 6.0, 7.0), - ], - dtype=wp.vec3f, - device="cpu", - ) - monkeypatch.setattr(NewtonManager, "get_state", classmethod(lambda cls: SimpleNamespace(particle_q=particle_q))) - - class _FakeStream: - cuda_stream = 42 - - renderer._warp_device = SimpleNamespace(stream=_FakeStream()) - renderer.update_geometries() + assert write.call_count == 1 + data = write.call_args.kwargs["tensors"] if use_ovstage else write.call_args.args[0] + assert [item.data if use_ovstage else item.ptr for item in data] == [array.ptr for array in points.values()] + kwargs = write.call_args.kwargs + assert kwargs["cuda_stream"] == 42 + if use_ovstage: + assert kwargs["ordinal"] == 7 and kwargs["is_array"] + else: + assert kwargs["data_access"] is DataAccess.ASYNC - deformable_written = renderer._deformable_points_binding.written - assert deformable_written is not None - assert len(deformable_written) == 1 - assert deformable_written[0].ptr == particle_q[1:2].ptr - np.testing.assert_array_equal(deformable_written[0].numpy(), particle_q.numpy()[1:2]) - assert renderer._deformable_points_binding.write_kwargs is not None - assert renderer._deformable_points_binding.write_kwargs["data_access"] is DataAccess.ASYNC - assert renderer._deformable_points_binding.write_kwargs["cuda_stream"] == 42 - - assert len(backend.writes) == 0 - mpm_written = renderer._particle_points_binding.written - assert mpm_written is not None - assert len(mpm_written) == 1 - assert mpm_written[0].ptr == particle_q[2:4].ptr - np.testing.assert_array_equal(mpm_written[0].numpy(), particle_q.numpy()[2:4]) - assert renderer._particle_points_binding.write_kwargs is not None - assert renderer._particle_points_binding.write_kwargs["data_access"] is DataAccess.ASYNC - assert renderer._particle_points_binding.write_kwargs["cuda_stream"] == 42 - - -def _install_cable_shapes(shapes: dict[str, list[int]], monkeypatch: pytest.MonkeyPatch) -> None: - """Install a fake :meth:`NewtonManager.collect_cable_segment_shape_ids` result.""" - monkeypatch.setattr(NewtonManager, "collect_cable_segment_shape_ids", classmethod(lambda cls: dict(shapes))) - - -def test_setup_cable_bindings_binds_curve_points(monkeypatch: pytest.MonkeyPatch): - """Renderable cables create a ``points`` array binding over their curve prims.""" - renderer, backend = _make_renderer_without_backend() - _install_cable_shapes({"/World/envs/env_0/Cable/geometry/mesh": [4, 5, 6]}, monkeypatch) - - renderer._setup_cable_bindings_legacy() - - assert len(backend.calls) == 1 - assert backend.calls[0]["prim_paths"] == ["/World/envs/env_0/Cable/geometry/mesh"] - assert backend.calls[0]["attribute_name"] == "points" - assert backend.calls[0]["dtype"] is np.float32 - assert backend.calls[0]["shape"] == (3,) - assert backend.calls[0]["flags"] is BindingFlag.OPTIMIZE - assert renderer._cable_points_binding is backend.bindings["points"] - - # World-space points are written directly, so the inherited env transform must be neutralised - # or it is applied twice -- the same contract the deformable path relies on. - assert [write["attribute_name"] for write in backend.writes] == ["omni:resetXformStack", "omni:xform"] - - -def test_setup_cable_bindings_noop_without_cables(monkeypatch: pytest.MonkeyPatch): - """A scene with no renderable cables binds nothing rather than failing.""" - renderer, backend = _make_renderer_without_backend() - _install_cable_shapes({}, monkeypatch) - - renderer._setup_cable_bindings_legacy() - - assert renderer._cable_points_binding is None - assert backend.calls == [] - - -def test_update_geometries_writes_one_slice_per_cable(monkeypatch: pytest.MonkeyPatch): - """Cable updates use disjoint point slices and GPU interop for unequal-length curves.""" - renderer, _ = _make_renderer_without_backend() - _install_cable_shapes( - { - "/World/envs/env_0/Cable/geometry/mesh": [0, 1, 2], - "/World/envs/env_1/Cable/geometry/mesh": [3, 4, 5, 6, 7], - "/World/envs/env_2/Cable/geometry/mesh": [8, 9], - }, - monkeypatch, - ) - renderer._setup_cable_bindings_legacy() - - model = SimpleNamespace(shape_body=None, shape_transform=None, shape_scale=None) - monkeypatch.setattr(NewtonManager, "get_model", classmethod(lambda cls: model)) - monkeypatch.setattr(NewtonManager, "get_state", classmethod(lambda cls: SimpleNamespace(body_q=None))) - # The kernel needs a live Newton model; this test covers the slicing around it, not the maths in it. - launch_kwargs: dict = {} - - def _capture_launch(*args, **kwargs): - launch_kwargs.update(kwargs) - if args: - launch_kwargs["kernel"] = args[0] - - monkeypatch.setattr(ovrtx_renderer_module.wp, "launch", _capture_launch) - renderer._warp_device = SimpleNamespace(stream=SimpleNamespace(cuda_stream=1234)) - + # The producer advances its timestamp during the request, not before the consumer calls it. + publication.points = {path: wp.clone(array) for path, array in reversed(tuple(points.items()))} renderer.update_geometries() - - assert launch_kwargs["dim"] == (3, 6) - written = renderer._cable_points_binding.written - assert written is not None - assert [len(slice_) for slice_ in written] == [4, 6, 3] - assert written[0].ptr == renderer._cable_points[0:4].ptr - assert written[1].ptr == renderer._cable_points[4:10].ptr - assert written[2].ptr == renderer._cable_points[10:13].ptr - # Zero-copy: OVRTX is handed the Warp stream so it waits on the kernel instead of forcing a host - # round-trip. Switching to SYNC would silently reintroduce a per-frame device copy, and is the - # only guard against that -- the downgrade does not raise, it just renders from a stale copy. - assert renderer._cable_points_binding.write_kwargs["data_access"] is DataAccess.ASYNC - assert renderer._cable_points_binding.write_kwargs["cuda_stream"] == 1234 - - -@pytest.mark.skipif(not wp.get_cuda_device_count(), reason="requires a CUDA device") -def test_write_particle_q_slices_ovstage_passes_device_slices_zero_copy(): - """The ovstage points write hands ``particle_q`` slices to ovstage as CUDA DLTensors, without a host copy.""" - renderer, _backend = _make_renderer_without_backend(device="cuda:0") - particle_q = wp.array( - [ - wp.vec3f(-1.0, -1.0, -1.0), - wp.vec3f(1.0, 2.0, 3.0), - wp.vec3f(4.0, 5.0, 6.0), - wp.vec3f(7.0, 8.0, 9.0), - ], - dtype=wp.vec3f, - device="cuda:0", - ) - writes: list[dict] = [] - - def _write(query, attribute, **kwargs): - writes.append({"query": query, "attribute": attribute, **kwargs}) - return SimpleNamespace(wait=lambda: None) - - renderer.backend.stage = SimpleNamespace(write_attribute=_write) - renderer._current_ordinal = 7 - renderer._warp_device = wp.get_device("cuda:0") - - renderer._write_particle_q_slices_ovstage("points_query", particle_q, [1], [3]) - - assert len(writes) == 1 - assert writes[0]["attribute"] == "points" - assert writes[0]["is_array"] is True - # The slices alias ``particle_q``, so ovstage is handed the producing Warp stream to order its - # read against, rather than the caller blocking the host on a device synchronize. - assert writes[0]["cuda_stream"] == wp.get_stream("cuda:0").cuda_stream - tensors = writes[0]["tensors"] - assert len(tensors) == 1 - # A zero-copy device view: the descriptor points straight at the slice's own CUDA buffer with - # the trailing component axis folded into point3f's three lanes. - assert tensors[0].device.device_type.value == ovstage.DLDeviceType.kDLCUDA - assert tensors[0].data == particle_q[1:4].ptr - assert tensors[0].shape_tuple == (3,) - assert tensors[0].dtype.lanes == 3 + renderer.update_geometries() + assert write.call_count == 2 + data = write.call_args.kwargs["tensors"] if use_ovstage else write.call_args.args[0] + assert [item.data if use_ovstage else item.ptr for item in data] == [ + publication.points[path].ptr for path in points + ] @pytest.mark.parametrize("use_ovstage", [False, True]) @@ -414,11 +126,6 @@ def test_update_transforms_consumes_sdp_matrices_once_per_publication(monkeypatc """Both OVRTX paths bind published bodies and consume SDP's scaled, transposed matrices.""" from isaaclab.scene_data import SceneDataFormat, SceneDataProvider - def reject_newton_access(*args, **kwargs): - raise AssertionError("Rigid transform transport must not read Newton state") - - monkeypatch.setattr(NewtonManager, "get_model", reject_newton_access) - monkeypatch.setattr(NewtonManager, "get_state", reject_newton_access) renderer, _ = _make_renderer_without_backend() paths = ["/World/Shared", "/World/envs/env_1/Object"] poses = np.array([[1, 2, 3, 0, 0, 0, 1], [4, 5, 6, 0, 0, 0, 1]], dtype=np.float32) @@ -427,7 +134,7 @@ def reject_newton_access(*args, **kwargs): backend = SimpleNamespace(transforms=transforms, transforms_version=0, transform_count=2, transform_paths=paths) backend.get_transforms = lambda _format: transforms renderer._sdp = SceneDataProvider(backend) - renderer._transform_version = -1 + renderer._transforms_version_last_update = -1 renderer._object_scales_by_path = {paths[0]: (2, 3, 4)} renderer._warp_device = SimpleNamespace(stream=SimpleNamespace(cuda_stream=99)) renderer._use_ovstage = use_ovstage @@ -448,7 +155,7 @@ def reject_newton_access(*args, **kwargs): writes.clear() else: renderer._setup_xform_bindings_legacy() - assert renderer.backend.renderer.calls[0]["prim_paths"] == paths + assert renderer.backend.renderer.bind_attribute.call_args.kwargs["prim_paths"] == paths renderer._object_xform_binding.write = lambda matrices, **kwargs: writes.append((None, matrices, kwargs)) renderer.update_transforms() @@ -474,29 +181,3 @@ def reject_newton_access(*args, **kwargs): assert updated is matrices expected[:, 3, :3] = poses[:, :3] np.testing.assert_array_equal(updated.numpy(), expected) - - -def test_update_camera_writes_without_mapping(monkeypatch: pytest.MonkeyPatch): - """Camera xforms are handed to ``write()`` instead of copied into a mapped OVRTX buffer.""" - renderer, _ = _make_renderer_without_backend() - render_data = SimpleNamespace(camera_xform_binding=_FakePointsBinding("omni:xform")) - camera_transforms = [] - - monkeypatch.setattr(ovrtx_renderer_module, "convert_camera_frame_orientation_convention_wp", lambda **kwargs: None) - monkeypatch.setattr(ovrtx_renderer_module.wp, "empty", lambda *args, **kwargs: object()) - - def _fake_zeros(*args, **kwargs): - arr = object() - camera_transforms.append(arr) - return arr - - monkeypatch.setattr(ovrtx_renderer_module.wp, "zeros", _fake_zeros) - monkeypatch.setattr(ovrtx_renderer_module.wp, "launch", lambda *args, **kwargs: None) - renderer._warp_device = SimpleNamespace(stream=SimpleNamespace(cuda_stream=7)) - - positions = SimpleNamespace(shape=(2,), warp=object()) - renderer.update_camera(render_data, positions, SimpleNamespace(warp=object()), object()) - - assert render_data.camera_xform_binding.written is camera_transforms[0] - assert render_data.camera_xform_binding.write_kwargs["data_access"] is DataAccess.ASYNC - assert render_data.camera_xform_binding.write_kwargs["cuda_stream"] == 7 diff --git a/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py b/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py index 402680a47048..5b4fdf3b9ce0 100644 --- a/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py +++ b/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py @@ -88,7 +88,9 @@ def _make_ovrtx_renderer_without_backend() -> OVRTXRenderer: @pytest.fixture(autouse=True) def _simulation_registry(monkeypatch): sim = types.SimpleNamespace(_backend_registry=[]) - sim.get_scene_data_provider = lambda: types.SimpleNamespace(backend=types.SimpleNamespace(transform_paths=[])) + sim.get_scene_data_provider = lambda: types.SimpleNamespace( + backend=types.SimpleNamespace(transform_paths=[]), get_geometry_points=lambda: {} + ) sim.get_or_create_backend = SimulationContext.get_or_create_backend.__get__(sim) sim.close_backend = SimulationContext.close_backend.__get__(sim) monkeypatch.setattr(SimulationContext, "_instance", sim) @@ -135,34 +137,6 @@ def __init__(self, **kwargs): assert not SimulationContext.instance()._backend_registry -def test_ovrtx_supported_output_types_key_set(): - """OVRTX publishes the documented key set and per-output spec.""" - renderer = _make_ovrtx_renderer_without_backend() - specs = renderer.supported_output_types() - - assert specs == renderer.cfg.supported_output_types() - assert set(specs.keys()) == { - RenderBufferKind.RGB, - RenderBufferKind.RGBA, - RenderBufferKind.RGB_HDR, - RenderBufferKind.ALBEDO, - RenderBufferKind.SIMPLE_SHADING_CONSTANT_DIFFUSE, - RenderBufferKind.SIMPLE_SHADING_DIFFUSE_MDL, - RenderBufferKind.SIMPLE_SHADING_FULL_MDL, - RenderBufferKind.SEMANTIC_SEGMENTATION, - RenderBufferKind.INSTANCE_SEGMENTATION, - RenderBufferKind.DEPTH, - RenderBufferKind.DISTANCE_TO_IMAGE_PLANE, - RenderBufferKind.DISTANCE_TO_CAMERA, - RenderBufferKind.NORMALS, - RenderBufferKind.MOTION_VECTORS, - } - assert specs[RenderBufferKind.RGBA] == RenderBufferSpec(4, wp.uint8) - assert specs[RenderBufferKind.RGB_HDR] == RenderBufferSpec(3, wp.float32) - assert specs[RenderBufferKind.DEPTH] == RenderBufferSpec(1, wp.float32) - assert specs[RenderBufferKind.MOTION_VECTORS] == RenderBufferSpec(2, wp.float32) - - # Each missing output runs with and without batching; ``use_ovstage`` only changes the ordinal bookkeeping. @pytest.mark.parametrize( ("missing_output", "batch", "use_ovstage"), @@ -254,7 +228,6 @@ def test_ovrtx_multiple_cameras_render_independent_views(monkeypatch, use_ovstag Use ``--basetemp=/tmp/ovrtx-camera-frames`` to choose where pytest writes the captures. Depth PNGs use a shared 0-8 m range (near is white); NPY files retain the raw depths [m]. """ - from isaaclab_newton.physics import NewtonManager from PIL import Image from pxr import Gf, Usd, UsdGeom, UsdLux @@ -266,8 +239,6 @@ def test_ovrtx_multiple_cameras_render_independent_views(monkeypatch, use_ovstag if not torch.cuda.is_available(): pytest.skip("OVRTX rendering requires CUDA") - # This static USD scene has no physics model or scene-data provider. - monkeypatch.setattr(NewtonManager, "get_model", classmethod(lambda cls: None)) monkeypatch.setenv("ISAAC_LAB_OVRTX_USE_OVSTAGE", str(int(use_ovstage))) stage = Usd.Stage.CreateInMemory() UsdGeom.SetStageUpAxis(stage, UsdGeom.Tokens.z) @@ -390,7 +361,13 @@ def test_ovrtx_set_outputs_wraps_caller_torch_zero_copy(): pytest.skip("OVRTX zero-copy wrapping requires a CUDA device") device = "cuda" - cfg = _make_camera_cfg(["rgb", "rgba", "depth", "rgb_hdr"]) + layouts = { + "rgba": RenderBufferSpec(4, wp.uint8), + "rgb_hdr": RenderBufferSpec(3, wp.float32), + "depth": RenderBufferSpec(1, wp.float32), + "motion_vectors": RenderBufferSpec(2, wp.float32), + } + cfg = _make_camera_cfg(["rgb", *layouts]) data = CameraData.allocate( data_types=cfg.data_types, height=8, @@ -402,10 +379,10 @@ def test_ovrtx_set_outputs_wraps_caller_torch_zero_copy(): render_data = _make_ovrtx_camera_render_data() renderer.set_outputs(render_data, data.output) - assert set(render_data.warp_buffers.keys()) >= {"rgba", "depth"} - assert render_data.warp_buffers["rgba"].ptr == data.output["rgba"].warp.ptr - assert render_data.warp_buffers["depth"].ptr == data.output["depth"].warp.ptr - assert render_data.warp_buffers["rgb_hdr"].ptr == data.output["rgb_hdr"].warp.ptr + for name, spec in layouts.items(): + assert render_data.warp_buffers[name].ptr == data.output[name].warp.ptr + assert render_data.warp_buffers[name].shape == (2, 8, 16, spec.channels) + assert render_data.warp_buffers[name].dtype is spec.dtype assert "rgb" not in render_data.warp_buffers @@ -601,30 +578,13 @@ def test_ovrtx_read_output_clears_stale_metadata_and_keeps_seeded_keys(): assert camera_data.info == {"rgb": None, "semantic_segmentation": None} -def test_ovrtx_semantic_spec_follows_colorize_flag(): - """Semantic segmentation output spec is colorized RGBA (uint8) or raw int32 IDs per the cfg flag.""" - colorized = OVRTXRenderer.__new__(OVRTXRenderer) - colorized.cfg = OVRTXRendererCfg(colorize_semantic_segmentation=True) - assert colorized.supported_output_types()[RenderBufferKind.SEMANTIC_SEGMENTATION] == RenderBufferSpec(4, wp.uint8) - - non_colorized = OVRTXRenderer.__new__(OVRTXRenderer) - non_colorized.cfg = OVRTXRendererCfg(colorize_semantic_segmentation=False) - assert non_colorized.supported_output_types()[RenderBufferKind.SEMANTIC_SEGMENTATION] == RenderBufferSpec( - 1, wp.int32 - ) - - -def test_ovrtx_instance_segmentation_spec_follows_colorize_flag(): - """Instance segmentation output spec is colorized RGBA (uint8) or raw int32 IDs per the cfg flag.""" - colorized = OVRTXRenderer.__new__(OVRTXRenderer) - colorized.cfg = OVRTXRendererCfg(colorize_instance_segmentation=True) - assert colorized.supported_output_types()[RenderBufferKind.INSTANCE_SEGMENTATION] == RenderBufferSpec(4, wp.uint8) - - non_colorized = OVRTXRenderer.__new__(OVRTXRenderer) - non_colorized.cfg = OVRTXRendererCfg(colorize_instance_segmentation=False) - assert non_colorized.supported_output_types()[RenderBufferKind.INSTANCE_SEGMENTATION] == RenderBufferSpec( - 1, wp.int32 - ) +@pytest.mark.parametrize("kind", [RenderBufferKind.SEMANTIC_SEGMENTATION, RenderBufferKind.INSTANCE_SEGMENTATION]) +@pytest.mark.parametrize("colorized", [False, True]) +def test_segmentation_spec_follows_colorize_flag(kind, colorized): + renderer = _make_ovrtx_renderer_without_backend() + setattr(renderer.cfg, f"colorize_{kind.value}", colorized) + expected = RenderBufferSpec(4, wp.uint8) if colorized else RenderBufferSpec(1, wp.int32) + assert renderer.supported_output_types()[kind] == expected def test_ovrtx_use_ovstage_defaults_to_disabled(monkeypatch): @@ -650,32 +610,21 @@ def test_ovrtx_use_ovstage_rejects_non_boolean_values(monkeypatch): ovrtx_use_ovstage_enabled() -def test_ovrtx_render_var_sync_is_gpu_side_off_linux(monkeypatch): - """Everywhere but Linux the mapping is ordered by a GPU-side wait on the Warp stream.""" - monkeypatch.setattr(sys, "platform", "win32") - monkeypatch.delenv(_DISABLE_LINUX_CUDA_CPU_SYNC_ENV, raising=False) - assert _gpu_side_render_var_sync_enabled() is True - - -def test_ovrtx_render_var_sync_waits_on_host_on_linux(monkeypatch): - """Linux blocks the calling thread instead, which measures faster there.""" - monkeypatch.setattr(sys, "platform", "linux") +@pytest.mark.parametrize( + ("platform", "setting", "gpu_side"), + [ + ("win32", None, True), + ("linux", None, False), + ("linux", "0", False), + ("linux", "1", True), + ], +) +def test_render_var_sync_respects_platform_and_override(monkeypatch, platform, setting, gpu_side): + monkeypatch.setattr(sys, "platform", platform) monkeypatch.delenv(_DISABLE_LINUX_CUDA_CPU_SYNC_ENV, raising=False) - assert _gpu_side_render_var_sync_enabled() is False - - -def test_ovrtx_render_var_sync_is_gpu_side_on_linux_when_disabled(monkeypatch): - """Opting out of the host wait puts Linux on the same GPU-side wait as every other platform.""" - monkeypatch.setattr(sys, "platform", "linux") - monkeypatch.setenv(_DISABLE_LINUX_CUDA_CPU_SYNC_ENV, "1") - assert _gpu_side_render_var_sync_enabled() is True - - -def test_ovrtx_render_var_sync_keeps_host_wait_when_explicitly_enabled(monkeypatch): - """``0`` is the default, so setting it explicitly must not change anything.""" - monkeypatch.setattr(sys, "platform", "linux") - monkeypatch.setenv(_DISABLE_LINUX_CUDA_CPU_SYNC_ENV, "0") - assert _gpu_side_render_var_sync_enabled() is False + if setting is not None: + monkeypatch.setenv(_DISABLE_LINUX_CUDA_CPU_SYNC_ENV, setting) + assert _gpu_side_render_var_sync_enabled() is gpu_side @pytest.mark.parametrize("value", ["", "true", "yes", "2"]) @@ -814,7 +763,7 @@ def test_create_render_data_rejects_cameras_outside_source_environment(camera_pa @pytest.mark.parametrize("use_ovstage", [False, True]) def test_intrinsic_updates_target_the_given_camera(monkeypatch, use_ovstage): - """Cameras sharing a renderer must bind and update distinct native camera paths.""" + """Distinct cameras, including a prototype-only wrist camera, bind every environment independently.""" renderer = _make_ovrtx_renderer_without_backend() renderer._initialized_scene = True renderer._device = "cpu" @@ -830,7 +779,9 @@ def test_intrinsic_updates_target_the_given_camera(monkeypatch, use_ovstage): renderer.backend.stage.query_from_path_list.side_effect = lambda paths: contextlib.nullcontext(object()) for name in ("add_usd_reference_from_string", "apply_usd_changes", "remove_usd"): monkeypatch.setattr(ovrtx_renderer_module.ovstage.population, name, MagicMock()) - paths = [[f"/World/envs/env_{i}/{name}" for i in range(2)] for name in ("CameraA", "CameraB")] + paths = [ + [f"/World/envs/env_{i}/{name}" for i in range(2)] for name in ("CameraA", "Robot/ee_link/palm_link/CameraB") + ] cameras = [ renderer.create_render_data( types.SimpleNamespace( @@ -840,7 +791,7 @@ def test_intrinsic_updates_target_the_given_camera(monkeypatch, use_ovstage): camera_prim_paths=camera_paths, ) ) - for camera_paths in paths + for camera_paths in (paths[0], paths[1][:1]) ] monkeypatch.setattr(wp, "get_stream", lambda device: types.SimpleNamespace(cuda_stream=99)) parameters = wp.zeros((5, 2), dtype=wp.float32, device="cpu") @@ -850,12 +801,9 @@ def test_intrinsic_updates_target_the_given_camera(monkeypatch, use_ovstage): bound_paths = [call.args[0] for call in renderer.backend.paths.create_path_list_from_strings.call_args_list] assert bound_paths == [[cameras[0].render_product_path], paths[0], [cameras[1].render_product_path], paths[1]] else: - bound_paths = [ - call.kwargs["prim_paths"] - for call in renderer.backend.renderer.bind_attribute.call_args_list - if call.kwargs["attribute_name"] in ovrtx_renderer_module._CAMERA_INTRINSIC_ATTRIBUTES - ] - assert bound_paths == [paths[0]] * 5 + [paths[1]] * 5 + bound_paths = [call.kwargs["prim_paths"] for call in renderer.backend.renderer.bind_attribute.call_args_list] + binding_count = 1 + len(ovrtx_renderer_module._CAMERA_INTRINSIC_ATTRIBUTES) + assert bound_paths == [paths[0]] * binding_count + [paths[1]] * binding_count assert all(not binding.write_async.called for binding in cameras[0].intrinsic_bindings) for row, binding in enumerate(cameras[1].intrinsic_bindings): assert binding.write_async.call_args.args[0].ptr == parameters[row].ptr @@ -866,44 +814,6 @@ def test_intrinsic_updates_target_the_given_camera(monkeypatch, use_ovstage): assert all(not binding.unbind.called for binding in cameras[0].intrinsic_bindings) -@pytest.mark.parametrize("use_ovstage", [False, True]) -def test_registered_camera_expands_env_0_prototype_to_every_env(monkeypatch, use_ovstage): - """Kitless runs author one prototype prim, so a later camera must still bind one prim per env.""" - renderer = _make_ovrtx_renderer_without_backend() - renderer._initialized_scene = True - renderer._device = "cpu" - renderer._next_camera_id = 0 - renderer._render_product_paths = [] - renderer._use_ovstage = use_ovstage - renderer._current_ordinal = 1 - renderer.backend.renderer = MagicMock() - renderer.backend.renderer.bind_attribute.side_effect = lambda **kwargs: MagicMock() - renderer.backend.stage = MagicMock() - renderer.backend.paths = MagicMock() - renderer.backend.paths.create_path_list_from_strings.side_effect = tuple - renderer.backend.stage.query_from_path_list.side_effect = lambda paths: contextlib.nullcontext(object()) - for name in ("add_usd_reference_from_string", "apply_usd_changes", "remove_usd"): - monkeypatch.setattr(ovrtx_renderer_module.ovstage.population, name, MagicMock()) - # A wrist-mounted camera nests several segments below the env root. - relative_path = "Robot/ee_link/palm_link/Camera" - camera = renderer.create_render_data( - types.SimpleNamespace( - cfg=_make_camera_cfg(["depth"]), - device="cpu", - num_instances=3, - camera_prim_paths=(f"/World/envs/env_0/{relative_path}",), - ) - ) - expected_paths = [f"/World/envs/env_{i}/{relative_path}" for i in range(3)] - if use_ovstage: - bound_paths = [call.args[0] for call in renderer.backend.paths.create_path_list_from_strings.call_args_list] - assert bound_paths == [[camera.render_product_path], expected_paths] - else: - bound_paths = [call.kwargs["prim_paths"] for call in renderer.backend.renderer.bind_attribute.call_args_list] - # One transform binding plus one binding per calibration column, each covering every env. - assert bound_paths == [expected_paths] * (1 + len(ovrtx_renderer_module._CAMERA_INTRINSIC_ATTRIBUTES)) - - class _RecordingBinding: def __init__(self, events: list[str], name: str): self._events = events @@ -930,15 +840,7 @@ def destroy(self) -> None: renderer._camera_render_data.append(render_data) renderer._camera_xform_binding = None renderer._object_xform_binding = _RecordingBinding(events, "object") - renderer._deformable_points_binding = _RecordingBinding(events, "deformable") - renderer._particle_points_binding = _RecordingBinding(events, "particle") - renderer._cable_points_binding = _RecordingBinding(events, "cable") - renderer._deformable_particle_offsets = [0] - renderer._deformable_particle_counts = [1] - renderer._particle_visual_offsets = [0] - renderer._particle_visual_counts = [1] - renderer._particle_workaround_applied = True - renderer._cable_segment_counts = [1] + renderer._geometry_points_binding = _RecordingBinding(events, "geometry") renderer.backend.renderer = Backend() renderer._render_product_paths = ["/RenderCamera_0/RenderProduct_camera"] renderer._output_id_color_buffers = {"semantic_segmentation": object()} @@ -986,16 +888,8 @@ def close(self) -> None: renderer._camera_paths_list = None renderer._object_xform_query = "object" renderer._object_paths_list = "object" - renderer._deformable_points_query = "deformable" - renderer._deformable_paths_list = "deformable" - renderer._particle_points_query = "particle" - renderer._particle_paths_list = "particle" - renderer._cable_points_query = "cable" - renderer._cable_paths_list = "cable" - renderer._deformable_particle_offsets = [0] - renderer._deformable_particle_counts = [1] - renderer._particle_visual_offsets = [0] - renderer._particle_visual_counts = [1] + renderer._geometry_points_query = "geometry" + renderer._geometry_paths_list = "geometry" renderer.backend.renderer = Backend() renderer.backend._resources = ExitStack() renderer._render_product_paths = ["/RenderCamera_0/RenderProduct_camera"] @@ -1017,9 +911,7 @@ def test_ovrtx_close_releases_legacy_renderer_state(): assert events == [ "unbind:camera", "unbind:object", - "unbind:deformable", - "unbind:particle", - "unbind:cable", + "unbind:geometry", "destroy_renderer", ] @@ -1037,12 +929,8 @@ def test_ovrtx_close_releases_ovstage_renderer_state(): "destroy_path_list:camera", "release_query:object", "destroy_path_list:object", - "release_query:deformable", - "destroy_path_list:deformable", - "release_query:particle", - "destroy_path_list:particle", - "release_query:cable", - "destroy_path_list:cable", + "release_query:geometry", + "destroy_path_list:geometry", "detach_ovstage", "destroy_renderer", "exit_stack_close", diff --git a/source/isaaclab_ov/test/test_ovrtx_renderer_rigid_object_rendering.py b/source/isaaclab_ov/test/test_ovrtx_renderer_rigid_object_rendering.py index c32fcd469af3..5289866d9a23 100644 --- a/source/isaaclab_ov/test/test_ovrtx_renderer_rigid_object_rendering.py +++ b/source/isaaclab_ov/test/test_ovrtx_renderer_rigid_object_rendering.py @@ -22,7 +22,7 @@ run_rigid_object_scale_and_pose_rendering_contract, ) -_REQUIRED_MODULES = ("isaaclab_ov", "ovrtx", "ovphysx", "isaaclab_newton", "newton") +_REQUIRED_MODULES = ("isaaclab_ov", "ovrtx", "ovphysx") _MISSING_MODULES = [module for module in _REQUIRED_MODULES if importlib.util.find_spec(module) is None] _OVSTAGE_AVAILABLE = importlib.util.find_spec("ovstage") is not None @@ -34,11 +34,9 @@ ] if not _MISSING_MODULES: - from isaaclab_newton.physics import NewtonManager # noqa: E402 from isaaclab_ov.physics import OvPhysxCfg # noqa: E402 from isaaclab_ov.renderers import OVRTXRendererCfg # noqa: E402 else: - NewtonManager = None OVRTXRendererCfg = None OvPhysxCfg = None @@ -56,7 +54,6 @@ ) def test_kinematic_rigid_object_scale_and_pose_are_rendered(monkeypatch: pytest.MonkeyPatch, use_ovstage: bool) -> None: """Kinematic OVPhysX transforms and root scale must reach OVRTX.""" - assert NewtonManager is not None assert OVRTXRendererCfg is not None assert OvPhysxCfg is not None monkeypatch.setenv("ISAAC_LAB_OVRTX_USE_OVSTAGE", str(int(use_ovstage))) @@ -66,6 +63,5 @@ def test_kinematic_rigid_object_scale_and_pose_are_rendered(monkeypatch: pytest. name=f"ovrtx (OVPhysX, {'ovstage' if use_ovstage else 'legacy'})", simulation_context_factory=lambda: build_simulation_context(sim_cfg=sim_cfg), renderer_cfg=OVRTXRendererCfg(), - cleanup=NewtonManager.clear, ) ) diff --git a/source/isaaclab_physx/changelog.d/plan-geometry.rst b/source/isaaclab_physx/changelog.d/plan-geometry.rst new file mode 100644 index 000000000000..3eb7a790108c --- /dev/null +++ b/source/isaaclab_physx/changelog.d/plan-geometry.rst @@ -0,0 +1,10 @@ +Fixed +^^^^^ + +* Initialized PhysX deformable publications from clone-plan paths and unpadded node counts, + including partially replicated assets and shared geometry, without completed-stage discovery. +* Published native padded nodal views and Fabric points through SDP. Moved foreign geometry updates + into the shared Fabric resource, including same-step position changes and MPM render cadence. +* Removed redundant native Fabric geometry requests, preserving standalone rendering without a clone plan. +* Converted foreign mesh geometry directly into GPU Fabric destinations. Routed Points and + BasisCurves through CPU Fabric for Hydra without USD point writes, transferring only due geometry. diff --git a/source/isaaclab_physx/isaaclab_physx/assets/deformable_object/deformable_object.py b/source/isaaclab_physx/isaaclab_physx/assets/deformable_object/deformable_object.py index 58abfdb614e9..7a44dab7eaf8 100644 --- a/source/isaaclab_physx/isaaclab_physx/assets/deformable_object/deformable_object.py +++ b/source/isaaclab_physx/isaaclab_physx/assets/deformable_object/deformable_object.py @@ -273,6 +273,7 @@ def write_nodal_pos_to_sim_index( self._data._root_pos_w.timestamp = -1.0 # set into simulation self.root_view.set_simulation_nodal_positions(self._get_nodal_pos_w_f32(), indices=env_ids) + SimulationManager.get_scene_data_backend().geometry_timestamp += 1 def write_nodal_pos_to_sim_mask( self, diff --git a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py index 43b8188e30f7..12856b00422f 100644 --- a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py +++ b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py @@ -16,11 +16,12 @@ import re import time import warnings -from collections.abc import Callable +from collections.abc import Callable, Sequence from datetime import datetime from enum import Enum from typing import TYPE_CHECKING, Any, ClassVar +import numpy as np import torch import warp as wp @@ -37,12 +38,9 @@ from isaaclab.physics import CallbackHandle, PhysicsEvent, PhysicsManager from isaaclab.scene_data import SceneDataBackend, SceneDataFormat from isaaclab.scene_data.deformable_discovery import ( - build_deformable_root_path_lookup, - build_deformable_vertex_count_lookup, - discover_deformables_on_stage, - group_deformable_root_paths_for_views, - resolve_deformable_root_path, - resolve_deformable_vertex_count, + deformable_geometry_batches, + deformable_prototypes, + expand_deformable_entries, ) from isaaclab.utils.string import to_camel_case @@ -51,6 +49,7 @@ from .physx_manager_cfg import PhysxBackendCfg if TYPE_CHECKING: + from isaaclab.scene_data.deformable_discovery import DeformableStageEntry from isaaclab.sim.simulation_context import SimulationContext from .physx_manager_cfg import PhysxCfg @@ -190,25 +189,23 @@ class PhysxSceneDataBackend(SceneDataBackend): def __init__(self): self._transforms = SceneDataFormat.Transform() self.transforms_version = 0 - self._points_data = SceneDataFormat.Points() + self.geometry_timestamp = 0 self.clear() def clear(self) -> None: """Drop the native binding and its derived views after simulation stops.""" self.backend = None self._rigid_body_view: omni.physics.tensors.RigidBodyView | None = None - self._volume_deformable_view: omni.physics.tensors.DeformableBodyView | None = None - self._surface_deformable_view: omni.physics.tensors.DeformableBodyView | None = None + self._deformable_bindings: list[tuple[Any, list]] | None = None self._transforms.transforms = None self.transforms_version += 1 - self._poses_version = self._fabric_version = -1 + self.geometry_timestamp += 1 + self._transforms_version_last_update = self._fabric_version_last_update = -1 + self._geometry_timestamp_last_update = -1 self._fabric_transforms = SceneDataFormat.FabricMatrix44() self._fabric_selection = None - self._points_data.points = None - self._geometry_paths: list[str] = [] - self._geometry_counts: list[int] = [] - self._merged_points: wp.array | None = None - self._geometry_discovered: bool = False + self._fabric_points = SceneDataFormat.FabricPoints() + self._fabric_points_selection = None def get_rigid_body_view(self) -> omni.physics.tensors.RigidBodyView | None: """Lazily create a rigid body view covering all rigid bodies in the scene. @@ -253,116 +250,87 @@ def get_rigid_body_view(self) -> omni.physics.tensors.RigidBodyView | None: self._rigid_body_view = self.backend.simulation_view.create_rigid_body_view(body_paths) return self._rigid_body_view - def _discover_deformable_geometry(self) -> None: - """Discover stage deformables and create PhysX volume/surface views once.""" - if self._geometry_discovered: - return - stage: Usd.Stage | None = omni.usd.get_context().get_stage() - if stage is None or self.backend is None: - return - self._geometry_discovered = True - - entries = discover_deformables_on_stage(stage) - if not entries: - return - - path_to_count = build_deformable_vertex_count_lookup(entries) - path_to_root = build_deformable_root_path_lookup(entries) - path_to_type = {entry.root_path: entry.deformable_type for entry in entries} - grouped_paths = group_deformable_root_paths_for_views(list(path_to_type.keys()), path_to_type) - - volume_patterns, exact_volume = grouped_paths["volume"] - surface_patterns, exact_surface = grouped_paths["surface"] - - if volume_patterns or exact_volume: - self._volume_deformable_view = self.backend.simulation_view.create_volume_deformable_body_view( - [*volume_patterns, *exact_volume] - ) - if surface_patterns or exact_surface: - self._surface_deformable_view = self.backend.simulation_view.create_surface_deformable_body_view( - [*surface_patterns, *exact_surface] - ) - - device = PhysicsManager._device or "cpu" - self._geometry_paths = [] - self._geometry_counts = [] - for view in (self._volume_deformable_view, self._surface_deformable_view): - if view is None or view._backend is None: + def _setup_deformable_geometry(self, entries: Sequence[DeformableStageEntry]) -> None: + """Bind exact visual mesh paths directly to padded native nodal buffers.""" + bindings = [] + for deformable_type in ("volume", "surface"): + declared = [entry for entry in entries if entry.deformable_type == deformable_type] + if not declared: continue - max_nodes = int(view.max_simulation_nodes_per_body) - for path in view.prim_paths: - # Prefer USD-discovered unpadded counts over padded max_nodes so - # SceneData ↔ shadow particle_q slices stay size-aligned. - resolved = resolve_deformable_vertex_count(path, path_to_count, fallback=-1) - if resolved < 0: - logger.warning( - "No USD vertex count for deformable path '%s'; using padded max_simulation_nodes_per_body=%d.", - path, - max_nodes, - ) - count = max_nodes - else: - count = min(int(resolved), max_nodes) - # Views may report a child mesh; publish the discovered root so - # create_geometry_mapping matches shadow entity root_path exactly. - self._geometry_paths.append(resolve_deformable_root_path(path, path_to_root)) - self._geometry_counts.append(count) - - total_points = sum(self._geometry_counts) - if total_points > 0: - self._merged_points = wp.empty(total_points, dtype=wp.vec3f, device=device) - - def _refresh_merged_points(self) -> None: - """Merge volume and surface deformable nodal positions into :attr:`points`.""" - from isaaclab.scene_data.geometry_points import pack_body_nodal_slices - - self._discover_deformable_geometry() - if self._merged_points is None: - self._points_data.points = None - return - - write_offset = 0 - path_index = 0 - device = str(self._merged_points.device) - for view in (self._volume_deformable_view, self._surface_deformable_view): - if view is None or view._backend is None: - continue - nodal = view.get_simulation_nodal_positions().view(wp.vec3f).reshape((view.count, -1)) - view_counts = [self._geometry_counts[path_index + body_idx] for body_idx in range(view.count)] - pack_body_nodal_slices( - nodal, - self._merged_points, - view_counts, - device=device, - dest_base_offset=write_offset, - ) - write_offset += sum(int(count) for count in view_counts) - path_index += view.count - self._points_data.points = self._merged_points + paths = [entry.root_path for entry in declared] + if deformable_type == "volume": + view = self.backend.simulation_view.create_volume_deformable_body_view(paths) + else: + view = self.backend.simulation_view.create_surface_deformable_body_view(paths) + by_path = {path: entry for entry in declared for path in (entry.root_path, entry.sim_mesh_path)} + ordered = [by_path[path] for path in view.prim_paths] + if sorted(entry.root_path for entry in ordered) != sorted(paths): + raise RuntimeError("PhysX deformable view does not cover the declared clone-plan entries.") + counts = np.asarray([entry.vertex_count for entry in ordered], dtype=np.int32) + if np.any(counts > view.max_simulation_nodes_per_body): + raise RuntimeError("PhysX deformable node capacity is smaller than the clone plan requires.") + points = view.get_simulation_nodal_positions().view(wp.vec3f).flatten() + native_offsets = np.arange(view.count) * view.max_simulation_nodes_per_body + batches = deformable_geometry_batches(ordered, points, native_offsets) + bindings.append((view, batches)) + self._deformable_bindings = bindings @property - def points(self) -> SceneDataFormat.Points: - """Return flattened PhysX deformable nodal positions.""" - self._refresh_merged_points() - return self._points_data + def native_geometry_formats(self) -> tuple[Any, ...]: + """Expose initialized geometry formats, including native Fabric points when available. - @property - def point_count(self) -> int: - """Return the total unpadded PhysX deformable nodal count.""" - self._discover_deformable_geometry() - return sum(self._geometry_counts) + Raises: + RuntimeError: If scene geometry was not initialized from a clone plan. + """ + if self._deformable_bindings is None: + raise RuntimeError("Declare and replicate a ClonePlan before requesting scene geometry.") + formats = tuple(dict.fromkeys(batch[0]._cls for _, batches in self._deformable_bindings for batch in batches)) + return (*formats, SceneDataFormat.FabricPoints) if PhysxManager._fabric is not None else formats - @property - def geometry_paths(self) -> list[str]: - """Return one USD prim path per PhysX deformable body instance.""" - self._discover_deformable_geometry() - return self._geometry_paths + def get_geometry_batches(self, output_format: Any = SceneDataFormat.Points) -> Any: + """Publish padded native nodes or native Fabric points without an intermediate copy. - @property - def geometry_counts(self) -> list[int]: - """Return the unpadded nodal count for each PhysX deformable body.""" - self._discover_deformable_geometry() - return self._geometry_counts + Raises: + RuntimeError: If scene geometry was not initialized from a clone plan. + """ + if self._deformable_bindings is None: + raise RuntimeError("Declare and replicate a ClonePlan before requesting scene geometry.") + if output_format is SceneDataFormat.FabricPoints and PhysxManager._fabric is not None: + self._update_fabric() + if self._fabric_points_selection is None: + stage = usdrt.Usd.Stage.Attach(PhysxManager._stage_id) + for _, batches in self._deformable_bindings: + for _, paths in batches: + for path in paths: + prim = stage.GetPrimAtPath(path) + prim.CreateAttribute("isaaclab:geometry", usdrt.Sdf.ValueTypeNames.Bool, True).Set(True) + self._fabric_points_selection = stage.SelectPrims( + require_attrs=[ + (usdrt.Sdf.ValueTypeNames.Point3fArray, "points", usdrt.Usd.Access.Read), + (usdrt.Sdf.ValueTypeNames.Bool, "isaaclab:geometry", usdrt.Usd.Access.Read), + ], + device=str(PhysicsManager._device), + ) + if self._fabric_points_selection.PrepareForReuse() or self._fabric_points.points is None: + self._fabric_points.points = wp.fabricarrayarray(data=self._fabric_points_selection, attrib="points") + self.geometry_timestamp += 1 + self._fabric_version_last_update = (self.transforms_version, self.geometry_timestamp) + return [(self._fabric_points, {})] + if self._geometry_timestamp_last_update != self.geometry_timestamp: + for view, batches in self._deformable_bindings: + points = view.get_simulation_nodal_positions().view(wp.vec3f).flatten() + for publication, _ in batches: + publication.points = points + self._geometry_timestamp_last_update = self.geometry_timestamp + return [batch for _, batches in self._deformable_bindings for batch in batches] + + def _update_fabric(self) -> None: + """Refresh the native stage once when either poses or geometry changed.""" + PhysxManager.pre_render() + version = (self.transforms_version, self.geometry_timestamp) + if self._fabric_version_last_update != version: + PhysxManager._fabric.force_update(0.0, 0.0) + self._fabric_version_last_update = version @property def native_transform_formats(self) -> tuple[Any, ...]: @@ -375,9 +343,9 @@ def native_transform_formats(self) -> tuple[Any, ...]: def transforms(self) -> SceneDataFormat.Transform: """Publish native rigid-body poses [m, xyzw].""" PhysxManager.pre_render() - if self._poses_version != self.transforms_version and (view := self.get_rigid_body_view()): + if self._transforms_version_last_update != self.transforms_version and (view := self.get_rigid_body_view()): self._transforms.transforms = view.get_transforms().view(wp.transformf) - self._poses_version = self.transforms_version + self._transforms_version_last_update = self.transforms_version return self._transforms @property @@ -398,9 +366,7 @@ def get_transforms(self, output_format: Any) -> SceneDataFormat.Transform | Scen """Publish the requested native representation, refreshing only that representation.""" if output_format is not SceneDataFormat.FabricMatrix44 or PhysxManager._fabric is None: return self.transforms - PhysxManager.pre_render() - if self._fabric_version != self.transforms_version: - PhysxManager._fabric.force_update(0.0, 0.0) + self._update_fabric() if self._fabric_selection is None: stage = usdrt.Usd.Stage.Attach(PhysxManager._stage_id) self._fabric_selection = stage.SelectPrims( @@ -411,7 +377,7 @@ def get_transforms(self, output_format: Any) -> SceneDataFormat.Transform | Scen if self._fabric_selection.PrepareForReuse() or self._fabric_transforms.matrices is None: self._fabric_transforms.matrices = wp.fabricarray(self._fabric_selection, "omni:fabric:worldMatrix") self.transforms_version += 1 - self._fabric_version = self.transforms_version + self._fabric_version_last_update = (self.transforms_version, self.geometry_timestamp) return self._fabric_transforms @@ -536,6 +502,7 @@ def reset(cls, soft: bool = False) -> None: cls.backend.simulation_view._backend.initialize_kinematic_bodies() cls.invalidate_transforms(kinematics=True) + cls._scene_data_backend.geometry_timestamp += 1 cls.raise_callback_exception_if_any() @classmethod @@ -546,6 +513,7 @@ def forward(cls) -> None: cls.backend.simulation_view.update_articulations_kinematic() cls._kinematics_dirty = False cls.invalidate_transforms() + cls._scene_data_backend.geometry_timestamp += 1 if cls._fabric is not None: cls._scene_data_backend.get_transforms(SceneDataFormat.FabricMatrix44) @@ -589,6 +557,7 @@ def step(cls) -> None: physx_sim.fetch_results() cls._kinematics_dirty = False cls.invalidate_transforms() + cls._scene_data_backend.geometry_timestamp += 1 device = PhysicsManager._device if "cuda" in device: torch.cuda.set_device(device) @@ -998,6 +967,11 @@ def _warmup_and_create_views(cls) -> None: stage_id = get_current_stage_id() + sim = PhysicsManager._sim + entries = None + if (plan := sim.get_clone_plan()) is not None: + entries = expand_deformable_entries(plan, deformable_prototypes(sim.stage, plan)) + is_gpu = "cuda" in PhysicsManager.get_device() physx = omni.physx.get_physx_interface() @@ -1023,12 +997,13 @@ def _warmup_and_create_views(cls) -> None: return # Register the complete tensor view only after PhysX has loaded the stage. - sim = PhysicsManager._sim cls.backend = sim.get_or_create_backend(PhysxBackendCfg(stage_id=stage_id)) cls._scene_data_backend.backend = cls.backend # Final update after view creation physx.update_simulation(cls.get_physics_dt(), 0.0) + if entries is not None: + cls._scene_data_backend._setup_deformable_geometry(entries) cls._view_created = True cls._event_bus.dispatch_event(IsaacEvents.SIMULATION_VIEW_CREATED.value, payload={}) diff --git a/source/isaaclab_physx/isaaclab_physx/renderers/fabric.py b/source/isaaclab_physx/isaaclab_physx/renderers/fabric.py index 5d8f1e6b8231..e73665b52b4d 100644 --- a/source/isaaclab_physx/isaaclab_physx/renderers/fabric.py +++ b/source/isaaclab_physx/isaaclab_physx/renderers/fabric.py @@ -13,7 +13,7 @@ import usdrt import usdrt.hierarchy -from pxr import Usd, UsdUtils +from pxr import Usd, UsdGeom, UsdUtils from isaaclab.scene_data import SceneDataFormat, SceneDataProvider from isaaclab.sim import BackendCfg @@ -36,7 +36,7 @@ def _capture_scales( class FabricBackend: - """Own one stage's native Fabric handles and shared transform bindings. + """Own one stage's native Fabric handles and shared transform/geometry bindings. Consumers supply the simulation's SDP explicitly. Its producer layout stays fixed for the binding's lifetime; it is not part of the native stage/device identity. @@ -51,7 +51,8 @@ def __init__(self, cfg: FabricBackendCfg): self.transforms: SceneDataFormat.FabricMatrix44 | None = None self._selection = self._write_selection = None self._mapping = self._scales = None - self._version = -1 + self._transforms_version_last_update = -1 + self._geometry_bindings = None def bind_transforms(self, provider: SceneDataProvider) -> None: """Bind the initialized simulation's rigid destinations once; native Fabric needs no conversion binding.""" @@ -102,19 +103,93 @@ def update_transforms(self, provider: SceneDataProvider) -> None: self.transforms.matrices = wp.fabricarray(self._write_selection, "omni:fabric:localMatrix") provider.get_transforms(self.transforms, self._mapping, scales=self._scales) version = provider.backend.transforms_version - if self._selection is not None and (changed or self._version != version): + if self._selection is not None and (changed or self._transforms_version_last_update != version): self._write_selection.PrepareForReuse() device = self._scales.device wp.synchronize_stream(device) - if not self.hierarchy.update_world_xforms_gpu(not changed and self._version != -1): + if not self.hierarchy.update_world_xforms_gpu(not changed and self._transforms_version_last_update != -1): raise RuntimeError("Fabric GPU transform hierarchy update failed.") wp.synchronize_device(device) - self._version = version + self._transforms_version_last_update = version + + def update_geometries(self, provider: SceneDataProvider, frame: int) -> None: + """Request world-space visual vertices [m] directly into due Fabric destinations.""" + # The native transform request refreshes the whole Fabric stage, including geometry. + if SceneDataFormat.FabricMatrix44 in provider.backend.native_transform_formats: + return + batches = provider.backend.get_geometry_batches() + if self._geometry_bindings is None: + self._bind_geometries(provider, tuple(path for _, ranges in batches for path in ranges)) + timestamp = provider.backend.geometry_timestamp + for index, (selections, frequency, output, offsets, frame_last_update, timestamp_last_update) in enumerate( + self._geometry_bindings + ): + selection, write_selection = selections + changed = selection.PrepareForReuse() + if not changed and ( + timestamp == timestamp_last_update or frequency > 1 and frame - frame_last_update < frequency + ): + continue + write_selection.PrepareForReuse() + if output is None or changed: + indices = wp.fabricarray(selection, f"isaaclab:geometryIndex:group{index}").numpy() + rows = {int(value): row for row, value in enumerate(indices)} + offsets = {path: rows[row] for row, path in enumerate(offsets)} + output = SceneDataFormat.FabricPoints() + output.points = wp.fabricarrayarray(data=write_selection, attrib="points", dtype=wp.vec3f) + provider.get_geometry_points(output=output, offsets=offsets) + self._geometry_bindings[index] = ( + selections, + frequency, + output, + offsets, + frame, + provider.backend.geometry_timestamp, + ) + + def _bind_geometries(self, provider: SceneDataProvider, paths: tuple[str, ...]) -> None: + """Bind declared destinations by device and cadence; SDP owns all data movement.""" + # Foreign physics publishes world points. Author the sink once so Kit's USD refresh agrees. + groups = {} + for path in paths: + geometry = UsdGeom.Xformable(provider.usd_stage.GetPrimAtPath(path)) + geometry.ClearXformOpOrder() + geometry.SetResetXformStack(True) + prim_type = geometry.GetPrim().GetTypeName() + if prim_type == "Mesh": + device, frequency = self.device, 1 + elif prim_type in {"BasisCurves", "Points"}: + # RTX Hydra reads these points from CPU Fabric (BasisCurves: NVBug 6502662). + device = "cpu" + frequency = geometry.GetPrim().GetAttribute("isaaclab:pointsUpdateFrequency").Get() or 1 + else: + raise TypeError(f"Unsupported Fabric point destination {path}: {prim_type}.") + groups.setdefault((device, frequency), []).append(path) + self.stage.SynchronizeToFabric() + self._geometry_bindings = [] + for index, ((device, frequency), group_paths) in enumerate(groups.items()): + tag = f"isaaclab:geometryIndex:group{index}" + for row, path in enumerate(group_paths): + prim = self.stage.GetPrimAtPath(path) + usdrt.Rt.Xformable(prim).SetWorldXformFromUsd() + prim.CreateAttribute(tag, usdrt.Sdf.ValueTypeNames.Int, custom=True).Set(row) + attrs = [ + (usdrt.Sdf.ValueTypeNames.Point3fArray, "points", usdrt.Usd.Access.Read), + (usdrt.Sdf.ValueTypeNames.Int, tag, usdrt.Usd.Access.Read), + ] + selection = self.stage.SelectPrims(require_attrs=attrs, device=device) + write_selection = self.stage.SelectPrims( + require_attrs=[(*attrs[0][:2], usdrt.Usd.Access.ReadWrite), attrs[1]], + device=device, + ) + offsets = dict.fromkeys(group_paths, 0) + self._geometry_bindings.append(((selection, write_selection), frequency, None, offsets, -frequency, -1)) def close(self) -> None: """Release stage-bound selections and borrowed SDP buffers.""" self.transforms = self._selection = self._write_selection = None self._mapping = self._scales = self.hierarchy = self.stage = None + self._geometry_bindings = None @configclass diff --git a/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py b/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py index de60212345bb..16a851a56dbe 100644 --- a/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py +++ b/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py @@ -583,9 +583,9 @@ def update_transforms(self) -> None: self._fabric.update_transforms(SimulationContext.instance().get_scene_data_provider()) def update_geometries(self) -> None: - """No-op for Isaac RTX - uses USD scene directly. - See :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.update_geometries`.""" - pass + """Update shared Fabric geometry from SDP's visual point publication.""" + sim = SimulationContext.instance() + self._fabric.update_geometries(sim.get_scene_data_provider(), sim.render_generation) def update_camera( self, diff --git a/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_contract.py b/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_contract.py index 91b8fcdc2814..c7533bf16e06 100644 --- a/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_contract.py +++ b/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_contract.py @@ -19,6 +19,7 @@ from packaging import version from isaaclab.renderers import RenderBufferKind, RenderBufferSpec +from isaaclab.scene_data import SceneDataFormat from isaaclab.sim import SimulationContext from isaaclab.utils.renderers import ISAAC_RTX_SHOW_ALL_PARTITIONS_BY_DEFAULT_SETTING @@ -38,6 +39,7 @@ def _install_omni_stubs(monkeypatch): monkeypatch.setitem(sys.modules, "omni.syntheticdata", syntheticdata_module) monkeypatch.setitem(sys.modules, "omni.usd", usd_module) monkeypatch.setitem(sys.modules, "usdrt", MagicMock()) + monkeypatch.setitem(sys.modules, "usdrt.hierarchy", MagicMock()) monkeypatch.setattr(omni_module, "replicator", replicator_module, raising=False) monkeypatch.setattr(omni_module, "syntheticdata", syntheticdata_module, raising=False) monkeypatch.setattr(omni_module, "usd", usd_module, raising=False) @@ -72,6 +74,18 @@ def test_isaac_rtx_supported_output_types_include_rgb_hdr(monkeypatch, isaac_sim assert all((kind in specs) is is_supported for kind in requires_6_0) +def test_native_fabric_geometry_needs_no_separate_publication(monkeypatch): + _install_omni_stubs(monkeypatch) + from isaaclab_physx.renderers.fabric import FabricBackend + + provider = SimpleNamespace( + backend=SimpleNamespace(native_transform_formats=(SceneDataFormat.FabricMatrix44,)), + get_geometry_points=MagicMock(), + ) + FabricBackend.__new__(FabricBackend).update_geometries(provider, 0) + provider.get_geometry_points.assert_not_called() + + def test_create_render_data_uses_unique_sdf_safe_render_product_name(monkeypatch): """Each tiled render product gets a fresh ``rp_`` name. diff --git a/source/isaaclab_physx/test/sim/test_physx_scene_data_backend.py b/source/isaaclab_physx/test/sim/test_physx_scene_data_backend.py index 6f0874866d47..7efcb3b0b22d 100644 --- a/source/isaaclab_physx/test/sim/test_physx_scene_data_backend.py +++ b/source/isaaclab_physx/test/sim/test_physx_scene_data_backend.py @@ -101,6 +101,34 @@ def test_pose_publication_refreshes_after_physics_but_reuses_clean_reads(monkeyp np.testing.assert_array_equal(output.matrices.numpy()[0, :3, 3], [4, 5, 6]) assert view.get_transforms.call_count == 3 assert backend.transforms_version == version + + points = wp.zeros(2, dtype=wp.vec3f, device="cpu") + pointers = wp.array([points.ptr], dtype=wp.uint64, device="cpu") + lengths = wp.array([len(points)], dtype=wp.uint64, device="cpu") + backend._fabric_points_selection = SimpleNamespace( + PrepareForReuse=Mock(return_value=False), + __fabric_arrays_interface__={ + "version": 1, + "device": "cpu", + "attribs": { + "points": { + "type": (True, "f4", 3, 1, "vector"), + "access": 1, + "pointers": [pointers.ptr], + "counts": [1], + "array_lengths": [lengths.ptr], + } + }, + }, + ) + backend._setup_deformable_geometry(()) + geometry = provider.get_geometry_points(output=SceneDataFormat.FabricPoints()) + assert geometry._cls is SceneDataFormat.FabricPoints and geometry.points is not None + backend.geometry_timestamp += 1 + assert provider.get_geometry_points(output=geometry) is geometry + assert provider.get_transforms(SceneDataFormat.FabricMatrix44()) + assert fabric.force_update.call_count == 4 + assert view.get_transforms.call_count == 3 backend.clear() assert backend.transforms_version > version @@ -145,63 +173,91 @@ def create_rigid_body_view(self, body_paths): ] -def test_discover_deformable_geometry_publishes_discovered_roots(monkeypatch): - """PhysX deformable views may report child meshes; geometry_paths must be roots.""" +@pytest.mark.parametrize("declared", [False, True]) +def test_geometry_publication_distinguishes_undeclared_and_empty_scenes(monkeypatch, declared): + """Only an explicitly initialized, empty geometry declaration publishes no batches.""" from isaaclab_physx.physics import physx_manager - from isaaclab_physx.physics.physx_manager import PhysxSceneDataBackend + monkeypatch.setattr(physx_manager.PhysxManager, "_fabric", None) + backend = physx_manager.PhysxSceneDataBackend() + if declared: + backend._setup_deformable_geometry(()) + assert backend.get_geometry_batches() == [] + assert backend.native_geometry_formats == () + else: + with pytest.raises(RuntimeError, match="ClonePlan"): + backend.get_geometry_batches() + with pytest.raises(RuntimeError, match="ClonePlan"): + _ = backend.native_geometry_formats + + +@pytest.mark.parametrize("capacity", [3, 8]) +def test_deformable_geometry_uses_declared_counts_and_native_order(monkeypatch, capacity): + """Declared unpadded counts survive native reordering and mesh paths.""" + from isaaclab_physx.physics import physx_manager + + from isaaclab.scene_data import SceneDataProvider from isaaclab.scene_data.deformable_discovery import DeformableStageEntry - class _FakeDeformableView: - _backend = object() - max_simulation_nodes_per_body = 8 - prim_paths = [ - "/World/envs/env_0/Deformable/sim_mesh", - "/World/envs/env_1/Deformable/sim_mesh", - ] + values = {"/Clones/slot_2/Asset": 2.0, "/Clones/slot_9/Asset": 9.0, "/Shared": 100.0} + counts = {"/Clones/slot_2/Asset": 4, "/Clones/slot_9/Asset": 4, "/Shared": 2} + entries = [ + DeformableStageEntry( + path, path + "/sim", path + "/vis", "volume" if path == "/Shared" else "surface", count, count + ) + for path, count in counts.items() + ] + native_points, reads = {}, [] + + def create_view(paths): + paths = list(reversed(paths)) + nodal = np.full((len(paths), capacity, 3), -999.0, dtype=np.float32) + for index, path in enumerate(paths): + nodal[index, : counts[path]] = values[path] + points = wp.array(nodal, dtype=wp.float32, device="cpu") + for index, path in enumerate(paths): + native_points[path + "/vis"] = points, index * capacity - class _SimulationView: - def create_volume_deformable_body_view(self, patterns): - return None + def read(): + reads.append(points.ptr) + return points - def create_surface_deformable_body_view(self, patterns): - return _FakeDeformableView() + return SimpleNamespace( + count=len(paths), + prim_paths=[path + "/sim" for path in paths], + max_simulation_nodes_per_body=capacity, + get_simulation_nodal_positions=read, + ) monkeypatch.setattr( - physx_manager.omni.usd, - "get_context", - lambda: SimpleNamespace(get_stage=lambda: object()), + physx_manager.omni.usd, "get_context", lambda: pytest.fail("Geometry bindings must not fetch a stage.") ) - monkeypatch.setattr( - physx_manager, - "discover_deformables_on_stage", - lambda stage: [ - DeformableStageEntry( - root_path="/World/envs/env_0/Deformable", - sim_mesh_path="/World/envs/env_0/Deformable/sim_mesh", - vis_mesh_path="/World/envs/env_0/Deformable/vis_mesh", - deformable_type="surface", - vertex_count=4, - vis_vertex_count=4, - ), - DeformableStageEntry( - root_path="/World/envs/env_1/Deformable", - sim_mesh_path="/World/envs/env_1/Deformable/sim_mesh", - vis_mesh_path="/World/envs/env_1/Deformable/vis_mesh", - deformable_type="surface", - vertex_count=4, - vis_vertex_count=4, - ), - ], + backend = physx_manager.PhysxSceneDataBackend() + backend.backend = SimpleNamespace( + simulation_view=SimpleNamespace( + create_volume_deformable_body_view=create_view, create_surface_deformable_body_view=create_view + ) ) + if capacity < 4: + with pytest.raises(RuntimeError, match="node capacity"): + backend._setup_deformable_geometry(entries) + return + backend._setup_deformable_geometry(entries) - backend = PhysxSceneDataBackend() - assert backend.geometry_paths == [] - backend.backend = SimpleNamespace(simulation_view=_SimulationView()) - backend._discover_deformable_geometry() - - assert backend.geometry_paths == [ - "/World/envs/env_0/Deformable", - "/World/envs/env_1/Deformable", - ] - assert backend.geometry_counts == [4, 4] + provider = SceneDataProvider(backend) + visual = provider.get_geometry_points() + assert set(visual) == {path + "/vis" for path in counts} + for path, (native, offset) in native_points.items(): + assert visual[path].ptr == native.ptr + offset * wp.types.type_size_in_bytes(wp.vec3f) + np.testing.assert_array_equal(visual[path].numpy(), np.full((counts[path[:-4]], 3), values[path[:-4]])) + read_count = len(reads) + assert provider.get_geometry_points() is visual + assert len(reads) == read_count + backend.geometry_timestamp += 1 + provider.get_geometry_points() + assert len(reads) == read_count + 2 + backend.clear() + with pytest.raises(RuntimeError, match="ClonePlan"): + backend.get_geometry_batches() + with pytest.raises(RuntimeError, match="ClonePlan"): + _ = backend.native_geometry_formats diff --git a/source/isaaclab_tasks/changelog.d/sdp-geometry-reset.skip b/source/isaaclab_tasks/changelog.d/sdp-geometry-reset.skip new file mode 100644 index 000000000000..4ecf50b41e9f --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/sdp-geometry-reset.skip @@ -0,0 +1 @@ +Removed obsolete renderer cadence resets; native geometry publications track authored writes. diff --git a/source/isaaclab_tasks/isaaclab_tasks/benchmark/render_benchmark/render_benchmark_env.py b/source/isaaclab_tasks/isaaclab_tasks/benchmark/render_benchmark/render_benchmark_env.py index b4bf6d2f41f2..916f8e8c18dc 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/benchmark/render_benchmark/render_benchmark_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/benchmark/render_benchmark/render_benchmark_env.py @@ -123,8 +123,6 @@ def _get_observations(self) -> dict: if self.cfg.benchmark_mode == "render" and self._anim_phases is not None: self._pose_joints_directly() self.sim.forward() - # forward() changes transforms without advancing the renderer's physics-step key. - self.sim.render_context.reset_scene_state_cadence() # Sensor buffers update lazily, so reading the camera's data is what drives the render. # This access is the work the benchmark measures: keep it unconditional even when no diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/multitask_env.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/multitask_env.py index ace525ab8eac..3802c5f173b7 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/multitask_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/multitask_env.py @@ -50,7 +50,6 @@ def _reset_idx(self, env_ids: Sequence[int]) -> None: self.extras["log"].update(manager.reset(global_env_ids)) self.episode_length_buf[global_env_ids] = 0 - self.sim.render_context.reset_scene_state_cadence() def _get_entity_selection(self, asset_name: str) -> SceneEntitySelectionCfg: """Return the cached selection configuration for a complete scene asset.""" diff --git a/source/isaaclab_tasks/test/benchmark/test_render_benchmark_cfg.py b/source/isaaclab_tasks/test/benchmark/test_render_benchmark_cfg.py index cc332b357f84..ba072f6a81f6 100644 --- a/source/isaaclab_tasks/test/benchmark/test_render_benchmark_cfg.py +++ b/source/isaaclab_tasks/test/benchmark/test_render_benchmark_cfg.py @@ -130,7 +130,6 @@ def test_benchmark_mode_orders_joint_updates_and_rendering(mode): "articulation.write_joint_position_to_sim_index", "articulation.write_joint_velocity_to_sim_index", "sim.forward", - "sim.render_context.reset_scene_state_cadence", "render", ] velocity = articulation.write_joint_velocity_to_sim_index.call_args.kwargs["velocity"] diff --git a/source/isaaclab_tasks/test/rendering_test_utils.py b/source/isaaclab_tasks/test/rendering_test_utils.py index f7c97040b63c..fff5230881b6 100644 --- a/source/isaaclab_tasks/test/rendering_test_utils.py +++ b/source/isaaclab_tasks/test/rendering_test_utils.py @@ -2336,8 +2336,7 @@ def rendering_test_mpm_particles( """Golden-image AOV coverage for USD-stage MPM particle rendering. Covers the ``UsdGeom.Points`` clouds authored by - :func:`~isaaclab_newton.sim.spawners.mpm.visualization.create_mpm_particle_visualization` - and re-synced every frame by ``NewtonManager.sync_particles_to_usd``. The camera frames the + MPM spawners and updated by the shared Fabric resource through SDP. The camera frames the UR10 particle pile head-on so the particles, not the workcell, dominate the frame. MPM runs only on Newton's coupled MPM/MJWarp solver, so ``UR10ParticlePushEnvCfg`` pins diff --git a/source/isaaclab_visualizers/changelog.d/sdp-geometry-publication.rst b/source/isaaclab_visualizers/changelog.d/sdp-geometry-publication.rst new file mode 100644 index 000000000000..d6d62a0903a6 --- /dev/null +++ b/source/isaaclab_visualizers/changelog.d/sdp-geometry-publication.rst @@ -0,0 +1,5 @@ +Fixed +^^^^^ + +* Routed Kit deformable, particle, and cable updates through the shared Fabric resource and SDP + geometry publications, removing dependence on physics-manager render callbacks. diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py index 2b2602d14590..bd5f575438b7 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py @@ -221,6 +221,7 @@ def step(self, dt: float) -> None: if self._runtime_headless: return self._fabric.update_transforms(self._scene_data_provider) + self._fabric.update_geometries(self._scene_data_provider, SimulationContext.instance().render_generation) if self.cfg.origin_type == "asset": self._update_asset_tracking_camera() _externally_paused = self.is_training_paused() @@ -293,6 +294,7 @@ def render_rgb_array(self) -> np.ndarray: import omni.replicator.core as rep self._fabric.update_transforms(self._scene_data_provider) + self._fabric.update_geometries(self._scene_data_provider, SimulationContext.instance().render_generation) if self._runtime_headless and self.cfg.origin_type == "asset": self._update_asset_tracking_camera() camera_path = self._controlled_camera_path or "/OmniverseKit_Persp" diff --git a/source/isaaclab_visualizers/test/test_kit_visualizer_scene_partitioning.py b/source/isaaclab_visualizers/test/test_kit_visualizer_scene_partitioning.py index 51a5f40a9106..158e027f24e5 100644 --- a/source/isaaclab_visualizers/test/test_kit_visualizer_scene_partitioning.py +++ b/source/isaaclab_visualizers/test/test_kit_visualizer_scene_partitioning.py @@ -25,6 +25,8 @@ def test_viewport_pose_publication_is_deferred_for_headless_capture(monkeypatch, visualizer._is_initialized = True visualizer._fabric = MagicMock() visualizer._scene_data_provider = MagicMock() + sim = MagicMock(render_generation=12) + monkeypatch.setattr(kit_visualizer_module.SimulationContext, "instance", lambda: sim) monkeypatch.setattr(visualizer, "is_training_paused", lambda: True) tracking = MagicMock() monkeypatch.setattr(visualizer, "_update_asset_tracking_camera", tracking) @@ -34,11 +36,12 @@ def test_viewport_pose_publication_is_deferred_for_headless_capture(monkeypatch, visualizer.step(0.1) assert tracking.call_count == int(not headless) - request = visualizer._fabric.update_transforms if headless: - request.assert_not_called() + visualizer._fabric.update_transforms.assert_not_called() + visualizer._fabric.update_geometries.assert_not_called() else: - request.assert_called_once_with(visualizer._scene_data_provider) + visualizer._fabric.update_transforms.assert_called_once_with(visualizer._scene_data_provider) + visualizer._fabric.update_geometries.assert_called_once_with(visualizer._scene_data_provider, 12) @pytest.mark.parametrize("generated", [False, True]) From fa5837c019db44e7f7bdd2c78a8a258188d65a13 Mon Sep 17 00:00:00 2001 From: Mustafa H <34825877+StafaH@users.noreply.github.com> Date: Fri, 25 Sep 2026 00:46:36 -0700 Subject: [PATCH 05/10] [Core] Refactor and cleanup core subpackage for 3.0 [5B/N] (#7990) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description Follow-up to #7985 in the series that splits the core cleanup in #7949 into small, reviewable pieces. It makes the fixed tendon position-limit API consistent across the PhysX, OVPhysX, and Newton backends, and fixes the Newton fixed tendon mask API. > **Stacked on #7985.** Until that merges, this diff also shows its two commits. The PR-specific commits fix tendon position limits and clarify the remaining Newton property mappings. ## Fixed tendon position limits The data layout was inconsistent: `fixed_tendon_pos_limits` is documented and reported as `wp.vec2f` of shape (N, T) (torch: (N, T, 2)), but the setters disagreed. - **PhysX:** the setters validated (N, T) `wp.float32` and wrote with a 2D float kernel, while the buffer is (N, T, 2). They could not accept the layout the asset reports, which is why `test_articulation_iface.py` excluded them. They now validate `wp.vec2f` (torch tensors with a trailing dimension of 2), like `write_joint_position_limit_to_sim_*`. - **OVPhysX:** the setters accepted only the (N, T, 2) `wp.float32` form and rejected `wp.vec2f` arrays (e.g. `data.fixed_tendon_pos_limits.warp`); a float reached the kernel instead of raising. They now accept both forms and raise `ValueError` for a float, like the OVPhysX joint position limits. - **Newton:** raised `NotImplementedError`. They now write the MuJoCo `tendon_range` (already bound for reading `fixed_tendon_pos_limits`), which `write_fixed_tendon_properties_to_sim_*` applies and the solver refreshes on `ModelFlags.TENDON_PROPERTIES` (added in #7985). With this, `randomize_fixed_tendon_parameters` can randomize position limits on all three backends. ## Newton fixed tendon mask API - `set_fixed_tendon_stiffness_mask` / `set_fixed_tendon_damping_mask` called `_resolve_env_mask` and `_resolve_fixed_tendon_mask`, which the Newton articulation does not define (only its rigid object collection does), and passed `full_data=True` to index setters without that argument. Both always raised. The articulation now has the mask-to-index helpers, and the mask setters pass the selected data to the index setters. - `write_fixed_tendon_properties_to_sim_mask` called `_resolve_mask` without its required argument, got a boolean mask where indices were needed, and passed `env_ids` positionally to a keyword-only method. It now resolves the masks and forwards them. - The fixed tendon writers now take the `fixed_tendon_ids` / `fixed_tendon_mask` arguments the base class declares. ## Newton tendon semantics The force-gain implementation is now proposed in [Newton #4311](https://github.com/newton-physics/newton/pull/4311), with the [Isaac Lab setters implemented on a dependent branch](https://github.com/ooctipus/IsaacLab/tree/fix/pr7990-tendon-limit-gains). Those setters require the Newton API addition and are kept out of this PR until the dependency is updated. The existing Newton 1.6.0 pin remains unchanged here. Newton's MuJoCo solver supports tendons. Corrected errors and API docs that called tendons PhysX-only or implied that MuJoCo cannot represent limit stiffness. The shared limit-stiffness, rest-length, and offset setters remain unimplemented; position ranges only act on tendons with limits enabled in the model. Newton already [converts joint-limit force gains to solref](https://github.com/newton-physics/newton/blob/v1.6.0/newton/_src/solvers/mujoco/kernels.py#L2622-L2732), scaling stiffness and damping by inverse inertia times `(1 - dmax)`. An isolated GPU check with Newton 1.6.0 / MuJoCo Warp 3.12 and constant impedance 0.95 adapted this conversion using `tendon_invweight0`: a 3 N load remained at the expected 3 cm limit penetration for stiffness 100 N/m, with both 1 kg and 10 kg masses. This verifies static compliance in that setup, not full PhysX trajectory parity. A tendon conversion should live in Newton's solver, where it can refresh after inertia changes. Before enabling the shared setter, reconcile these behaviors: - PhysX's damping also enters its limit response; MuJoCo has separate passive and limit damping. Copying the shared damping into both needs validation against [PhysX's tendon spring implementation](https://github.com/NVIDIA-Omniverse/PhysX/blob/da950a3537927784951853c66618036f332ca0ce/physx/source/lowleveldynamics/shared/DyCpuGpuArticulation.h#L765-L855). - Newton's joint converter resets nonpositive stiffness or damping to MuJoCo's default solref. That fallback does not preserve a shared API's zero-gain meaning. - Finite impedance and timestep clamping affect transient response even when static compliance matches. The Newton property writer also now reads each staged value at the selected environment/tendon indices. Previously, writing environment 1, tendon 1 could silently copy environment 0, tendon 0. ## Tests - `test/assets/test_articulation_iface.py`: `set_fixed_tendon_position_limit` is added to the fixed tendon writer matrix (torch/warp × index/mask × all/subset × invalid input) for the mock, PhysX, and OVPhysX backends; the note excluding it is removed. 12 of these cases fail on `develop`. - `isaaclab_newton/test/assets/test_articulation.py`: `test_fixed_tendon_properties_reach_solver` now uses a mask setter, the position-limit setter, and the mask writer, and checks the MuJoCo model's `tendon_range`. It fails on #7985 alone. - The full `test_articulation_iface.py` (1965 passed) and the Newton tendon tests pass. - The Newton integration regression now covers two environments and a selected stiffness/damping update. It fails with the original writer (12 instead of the requested 42 at environment 1, tendon 1) and passes after the fix with Newton 1.6.0. All formatting/pre-commit checks passed. ## Type of change - Bug fix (non-breaking change which fixes an issue) - New feature (non-breaking change which adds functionality) ## Checklist - [x] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html) - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] I have added a changelog fragment under `source//changelog.d/` for every touched package (do **not** edit `CHANGELOG.rst` or bump `extension.toml` — CI handles that) - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there ## Release backport - [x] Backport this pull request to the active release branch after it merges into `develop` --------- Signed-off-by: Octi Zhang Co-authored-by: Octi Zhang --- docs/source/api/lab/isaaclab.sim.schemas.rst | 17 +- .../core-cleanup-envs-managers-fixes.rst | 14 ++ .../core-cleanup-tendon-position-limits.skip | 1 + .../isaaclab/isaaclab/envs/mdp/curriculums.py | 2 +- source/isaaclab/isaaclab/envs/mdp/events.py | 138 +++++------ .../isaaclab/managers/event_manager.py | 6 +- .../test/assets/test_articulation_iface.py | 7 +- .../envs/test_articulation_ordering_events.py | 32 +++ .../envs/test_modify_env_param_curr_term.py | 13 + .../test/managers/test_event_manager.py | 12 + .../core-cleanup-envs-managers-fixes.rst | 14 ++ ...e-cleanup-tendon-position-limits.minor.rst | 21 ++ .../assets/articulation/articulation.py | 231 +++++++++--------- .../assets/articulation/articulation_data.py | 49 +++- .../sim/schemas/schemas_cfg.py | 6 +- .../test/assets/test_articulation.py | 39 +++ .../core-cleanup-tendon-position-limits.rst | 7 + .../assets/articulation/articulation.py | 30 ++- .../core-cleanup-tendon-position-limits.rst | 8 + .../assets/articulation/articulation.py | 50 ++-- .../isaaclab_physx/sim/schemas/schemas_cfg.py | 9 +- 21 files changed, 455 insertions(+), 251 deletions(-) create mode 100644 source/isaaclab/changelog.d/core-cleanup-envs-managers-fixes.rst create mode 100644 source/isaaclab/changelog.d/core-cleanup-tendon-position-limits.skip create mode 100644 source/isaaclab_newton/changelog.d/core-cleanup-envs-managers-fixes.rst create mode 100644 source/isaaclab_newton/changelog.d/core-cleanup-tendon-position-limits.minor.rst create mode 100644 source/isaaclab_ov/changelog.d/core-cleanup-tendon-position-limits.rst create mode 100644 source/isaaclab_physx/changelog.d/core-cleanup-tendon-position-limits.rst diff --git a/docs/source/api/lab/isaaclab.sim.schemas.rst b/docs/source/api/lab/isaaclab.sim.schemas.rst index 8b56fb73841a..d32ed1f9892d 100644 --- a/docs/source/api/lab/isaaclab.sim.schemas.rst +++ b/docs/source/api/lab/isaaclab.sim.schemas.rst @@ -280,10 +280,19 @@ Tendon .. autofunction:: modify_fixed_tendon_properties .. autofunction:: modify_spatial_tendon_properties -Tendon cfg classes are PhysX-only and live in -:mod:`isaaclab_physx.sim.schemas` -(:class:`~isaaclab_physx.sim.schemas.PhysxFixedTendonPropertiesCfg`, -:class:`~isaaclab_physx.sim.schemas.PhysxSpatialTendonPropertiesCfg`). +PhysX tendon schemas are configured through :mod:`isaaclab_physx.sim.schemas`. +Newton's MuJoCo solver also supports tendons; +:class:`~isaaclab_newton.sim.schemas.MujocoFixedTendonCfg` tunes fixed-tendon spring stiffness and damping. + +Position limits specify a range of the accumulated tendon coordinate on both backends. Their force response +uses different parameters: PhysX uses force stiffness and shares tendon damping with the limit, while +MuJoCo uses separate ``solreflimit`` and ``solimplimit`` parameters. Newton already +`converts force gains for joint limits +`_ +using inverse inertia and impedance. The corresponding tendon conversion is not implemented, so Isaac Lab's +shared tendon limit-stiffness API still raises :class:`NotImplementedError`. This is an implementation gap; +MuJoCo supports stiffness/damping through its +`solver parameters `_. Deformable Body --------------- diff --git a/source/isaaclab/changelog.d/core-cleanup-envs-managers-fixes.rst b/source/isaaclab/changelog.d/core-cleanup-envs-managers-fixes.rst new file mode 100644 index 000000000000..b0676b1f347e --- /dev/null +++ b/source/isaaclab/changelog.d/core-cleanup-envs-managers-fixes.rst @@ -0,0 +1,14 @@ +Fixed +^^^^^ + +* Fixed :class:`~isaaclab.envs.mdp.events.randomize_fixed_tendon_parameters` raising ``NotImplementedError`` + for limit stiffness, rest length, and offset on the PhysX and OVPhysX backends. The term + no longer checks the physics backend; it calls the asset setters, and a backend that does not support a + property raises ``NotImplementedError`` itself. +* Fixed :class:`~isaaclab.envs.mdp.events.randomize_fixed_tendon_parameters` passing tensors with an extra + dimension to the fixed tendon setters when all tendons are selected. +* Fixed :class:`~isaaclab.envs.mdp.curriculums.modify_env_param` failing on addresses that index into a + dictionary value, such as ``"params.ranges[1].high"``. +* Fixed :class:`~isaaclab.managers.EventManager` not calling :meth:`~isaaclab.managers.ManagerTermBase.reset` + on class-based event terms when the manager is created while the simulation is playing. Class-based + ``"prestartup"`` terms are now also reset with the manager. diff --git a/source/isaaclab/changelog.d/core-cleanup-tendon-position-limits.skip b/source/isaaclab/changelog.d/core-cleanup-tendon-position-limits.skip new file mode 100644 index 000000000000..f1d03e9cb6bb --- /dev/null +++ b/source/isaaclab/changelog.d/core-cleanup-tendon-position-limits.skip @@ -0,0 +1 @@ +Test coverage for fixed tendon position limits. diff --git a/source/isaaclab/isaaclab/envs/mdp/curriculums.py b/source/isaaclab/isaaclab/envs/mdp/curriculums.py index 422943363d31..65d2e858a8f4 100644 --- a/source/isaaclab/isaaclab/envs/mdp/curriculums.py +++ b/source/isaaclab/isaaclab/envs/mdp/curriculums.py @@ -209,7 +209,7 @@ def _process_accessors(self, root: ManagerBasedRLEnv, path: str) -> tuple[callab # we are accessing a list element name, idx = container_path # find underlying attribute - if isinstance(container_path, dict): + if isinstance(container, dict): seq = container[name] # type: ignore[assignment] else: seq = getattr(container, name) diff --git a/source/isaaclab/isaaclab/envs/mdp/events.py b/source/isaaclab/isaaclab/envs/mdp/events.py index 904927378ba1..96550f32e79a 100644 --- a/source/isaaclab/isaaclab/envs/mdp/events.py +++ b/source/isaaclab/isaaclab/envs/mdp/events.py @@ -1894,8 +1894,6 @@ def __call__( operation: Literal["add", "scale", "abs"] = "abs", distribution: Literal["uniform", "log_uniform", "gaussian"] = "uniform", ): - _backend = env.sim.physics_manager.__name__.lower() - # resolve environment ids if env_ids is None: env_ids = torch.arange(env.scene.num_envs, device=self.asset.device) @@ -1905,6 +1903,8 @@ def __call__( tendon_ids = slice(None) # for optimization purposes else: tendon_ids = torch.tensor(self.asset_cfg.fixed_tendon_ids, dtype=torch.int, device=self.asset.device) + # index rows and columns jointly only when both are tensors; with a slice the result is already 2D + env_ids_for_slice = env_ids[:, None] if isinstance(tendon_ids, torch.Tensor) else env_ids # sample tendon properties from the given ranges and set into the physics simulation # stiffness @@ -1918,7 +1918,7 @@ def __call__( distribution=distribution, ) self.asset.set_fixed_tendon_stiffness_index( - stiffness=stiffness[env_ids[:, None], tendon_ids], fixed_tendon_ids=tendon_ids, env_ids=env_ids + stiffness=stiffness[env_ids_for_slice, tendon_ids], fixed_tendon_ids=tendon_ids, env_ids=env_ids ) if damping_distribution_params is not None: @@ -1931,94 +1931,84 @@ def __call__( distribution=distribution, ) self.asset.set_fixed_tendon_damping_index( - damping=damping[env_ids[:, None], tendon_ids], fixed_tendon_ids=tendon_ids, env_ids=env_ids + damping=damping[env_ids_for_slice, tendon_ids], fixed_tendon_ids=tendon_ids, env_ids=env_ids ) # limit stiffness if limit_stiffness_distribution_params is not None: - if _backend == "physx": - limit_stiffness = _randomize_prop_by_op( - self.asset.data.fixed_tendon_limit_stiffness.torch.clone(), - limit_stiffness_distribution_params, - env_ids, - tendon_ids, - operation=operation, - distribution=distribution, - ) - self.asset.set_fixed_tendon_limit_stiffness( - limit_stiffness[env_ids[:, None], tendon_ids], tendon_ids, env_ids - ) - else: - raise NotImplementedError("Limit stiffness is not support in Newton.") + limit_stiffness = _randomize_prop_by_op( + self.asset.data.fixed_tendon_limit_stiffness.torch.clone(), + limit_stiffness_distribution_params, + env_ids, + tendon_ids, + operation=operation, + distribution=distribution, + ) + self.asset.set_fixed_tendon_limit_stiffness_index( + limit_stiffness=limit_stiffness[env_ids_for_slice, tendon_ids], + fixed_tendon_ids=tendon_ids, + env_ids=env_ids, + ) if lower_limit_distribution_params is not None or upper_limit_distribution_params is not None: - if _backend == "physx": - limit = self.asset.data.fixed_tendon_pos_limits.torch.clone() - # -- lower limit - if lower_limit_distribution_params is not None: - limit[..., 0] = _randomize_prop_by_op( - limit[..., 0], - lower_limit_distribution_params, - env_ids, - tendon_ids, - operation=operation, - distribution=distribution, - ) - # -- upper limit - if upper_limit_distribution_params is not None: - limit[..., 1] = _randomize_prop_by_op( - limit[..., 1], - upper_limit_distribution_params, - env_ids, - tendon_ids, - operation=operation, - distribution=distribution, - ) - - # check if the limits are valid - tendon_limits = limit[env_ids[:, None], tendon_ids] - if (tendon_limits[..., 0] > tendon_limits[..., 1]).any(): - raise ValueError( - "Randomization term 'randomize_fixed_tendon_parameters' is setting lower tendon limits that are" - " greater than upper tendon limits." - ) - self.asset.set_fixed_tendon_position_limit_index( - limit=tendon_limits, fixed_tendon_ids=tendon_ids, env_ids=env_ids - ) - else: - raise NotImplementedError("Position limits is not yet implemented with Newton.") - - if rest_length_distribution_params is not None: - if _backend == "physx": - rest_length = _randomize_prop_by_op( - self.asset.data.fixed_tendon_rest_length.torch.clone(), - rest_length_distribution_params, + limit = self.asset.data.fixed_tendon_pos_limits.torch.clone() + # -- lower limit + if lower_limit_distribution_params is not None: + limit[..., 0] = _randomize_prop_by_op( + limit[..., 0], + lower_limit_distribution_params, env_ids, tendon_ids, operation=operation, distribution=distribution, ) - self.asset.set_fixed_tendon_rest_length_index( - rest_length=rest_length[env_ids[:, None], tendon_ids], fixed_tendon_ids=tendon_ids, env_ids=env_ids - ) - else: - raise NotImplementedError("Rest length is not yet implemented with Newton.") - # offset - if offset_distribution_params is not None: - if _backend == "physx": - offset = _randomize_prop_by_op( - self.asset.data.fixed_tendon_offset.torch.clone(), - offset_distribution_params, + # -- upper limit + if upper_limit_distribution_params is not None: + limit[..., 1] = _randomize_prop_by_op( + limit[..., 1], + upper_limit_distribution_params, env_ids, tendon_ids, operation=operation, distribution=distribution, ) - self.asset.set_fixed_tendon_offset_index( - offset=offset[env_ids[:, None], tendon_ids], fixed_tendon_ids=tendon_ids, env_ids=env_ids + + # check if the limits are valid + tendon_limits = limit[env_ids_for_slice, tendon_ids] + if (tendon_limits[..., 0] > tendon_limits[..., 1]).any(): + raise ValueError( + "Randomization term 'randomize_fixed_tendon_parameters' is setting lower tendon limits that are" + " greater than upper tendon limits." ) - else: - raise NotImplementedError("Offset is not supported in Newton.") + self.asset.set_fixed_tendon_position_limit_index( + limit=tendon_limits, fixed_tendon_ids=tendon_ids, env_ids=env_ids + ) + + if rest_length_distribution_params is not None: + rest_length = _randomize_prop_by_op( + self.asset.data.fixed_tendon_rest_length.torch.clone(), + rest_length_distribution_params, + env_ids, + tendon_ids, + operation=operation, + distribution=distribution, + ) + self.asset.set_fixed_tendon_rest_length_index( + rest_length=rest_length[env_ids_for_slice, tendon_ids], fixed_tendon_ids=tendon_ids, env_ids=env_ids + ) + # offset + if offset_distribution_params is not None: + offset = _randomize_prop_by_op( + self.asset.data.fixed_tendon_offset.torch.clone(), + offset_distribution_params, + env_ids, + tendon_ids, + operation=operation, + distribution=distribution, + ) + self.asset.set_fixed_tendon_offset_index( + offset=offset[env_ids_for_slice, tendon_ids], fixed_tendon_ids=tendon_ids, env_ids=env_ids + ) self.asset.write_fixed_tendon_properties_to_sim_index(env_ids=env_ids) diff --git a/source/isaaclab/isaaclab/managers/event_manager.py b/source/isaaclab/isaaclab/managers/event_manager.py index e9103a9d73e8..9e763ad8d04d 100644 --- a/source/isaaclab/isaaclab/managers/event_manager.py +++ b/source/isaaclab/isaaclab/managers/event_manager.py @@ -15,7 +15,7 @@ import torch from prettytable import PrettyTable -from .manager_base import ManagerBase +from .manager_base import ManagerBase, ManagerTermBase from .manager_term_cfg import EventTermCfg if TYPE_CHECKING: @@ -388,8 +388,8 @@ def _prepare_terms(self): self._mode_term_names[term_cfg.mode].append(term_name) self._mode_term_cfgs[term_cfg.mode].append(term_cfg) - # check if the term is a class - if inspect.isclass(term_cfg.func): + # check if the term is a class; it is already instantiated if the simulation is playing + if inspect.isclass(term_cfg.func) or isinstance(term_cfg.func, ManagerTermBase): self._mode_class_term_cfgs[term_cfg.mode].append(term_cfg) # resolve the mode of the events diff --git a/source/isaaclab/test/assets/test_articulation_iface.py b/source/isaaclab/test/assets/test_articulation_iface.py index e0064b220ae7..3272734d275d 100644 --- a/source/isaaclab/test/assets/test_articulation_iface.py +++ b/source/isaaclab/test/assets/test_articulation_iface.py @@ -2025,7 +2025,8 @@ def test_joint_aliases(self, backend, num_instances, num_joints, num_bodies, dev # Tendon tests — parametrize, properties, finders, data, writers # --------------------------------------------------------------------------- -# Newton does not support tendons (always 0), so exclude it from tendon tests. +# Newton's fixed-tendon solver integration is covered in isaaclab_newton/test/assets/test_articulation.py. +# This fixture matrix also requires spatial tendons and PhysX-specific properties. _tendon_backends = pytest.mark.parametrize("backend", [b for b in BACKENDS if b != "newton"], indirect=False) _tendon_dims = pytest.mark.parametrize( @@ -2473,10 +2474,8 @@ def test_spatial_tendon_offset( ("set_fixed_tendon_limit_stiffness", "limit_stiffness", wp.float32, True), ("set_fixed_tendon_rest_length", "rest_length", wp.float32, True), ("set_fixed_tendon_offset", "offset", wp.float32, True), + ("set_fixed_tendon_position_limit", "limit", wp.vec2f, True), ] -# Note: set_fixed_tendon_position_limit is excluded because the PhysX backend stores -# pos_limits as (N, T, 2) float32 while the setter validates (N, T) float32. This data -# layout mismatch prevents consistent testing across mock and PhysX backends. class TestArticulationWritersFixedTendon: diff --git a/source/isaaclab/test/envs/test_articulation_ordering_events.py b/source/isaaclab/test/envs/test_articulation_ordering_events.py index 99f7db07ebd6..b22d4167e361 100644 --- a/source/isaaclab/test/envs/test_articulation_ordering_events.py +++ b/source/isaaclab/test/envs/test_articulation_ordering_events.py @@ -273,3 +273,35 @@ def test_newton_joint_parameter_randomization_writes_static_and_viscous_friction torch.testing.assert_close(viscous_write["joint_viscous_friction_coeff"], torch.full((1, 2), 0.5)) torch.testing.assert_close(static_write["env_ids"], torch.tensor([0], dtype=torch.int32)) torch.testing.assert_close(viscous_write["env_ids"], torch.tensor([0], dtype=torch.int32)) + + +def test_fixed_tendon_randomization_writes_limit_stiffness_and_rest_length(): + """Fixed tendon randomization writes every requested property through the asset setters.""" + tendon_values = torch.zeros((_NUM_ENVS, 2)) + writes = {} + asset = SimpleNamespace( + device="cpu", + data=SimpleNamespace( + fixed_tendon_limit_stiffness=SimpleNamespace(torch=tendon_values.clone()), + fixed_tendon_rest_length=SimpleNamespace(torch=tendon_values.clone()), + ), + set_fixed_tendon_limit_stiffness_index=lambda **kwargs: writes.update(limit_stiffness=kwargs), + set_fixed_tendon_rest_length_index=lambda **kwargs: writes.update(rest_length=kwargs), + write_fixed_tendon_properties_to_sim_index=lambda **kwargs: writes.update(sim=kwargs), + ) + asset_cfg = SimpleNamespace(name="robot", fixed_tendon_ids=slice(None)) + cfg = SimpleNamespace(params={"asset_cfg": asset_cfg, "operation": "abs"}) + env = SimpleNamespace(scene=_FakeScene(robot=asset)) + + term = events_module.randomize_fixed_tendon_parameters(cfg, env) + term( + env, + torch.tensor([0]), + asset_cfg, + limit_stiffness_distribution_params=(2.0, 2.0), + rest_length_distribution_params=(0.5, 0.5), + ) + + torch.testing.assert_close(writes["limit_stiffness"]["limit_stiffness"], torch.full((1, 2), 2.0)) + torch.testing.assert_close(writes["rest_length"]["rest_length"], torch.full((1, 2), 0.5)) + assert "sim" in writes diff --git a/source/isaaclab/test/envs/test_modify_env_param_curr_term.py b/source/isaaclab/test/envs/test_modify_env_param_curr_term.py index 2f028ccf2cc6..6ad8c584d7b4 100644 --- a/source/isaaclab/test/envs/test_modify_env_param_curr_term.py +++ b/source/isaaclab/test/envs/test_modify_env_param_curr_term.py @@ -10,6 +10,8 @@ # launch omniverse app simulation_app = AppLauncher(headless=True).app +from types import SimpleNamespace + import pytest import torch @@ -162,3 +164,14 @@ def test_curriculum_modify_env_param(device): env.step(actions) env.close() + + +def test_modify_env_param_indexes_into_dict_values(): + """An indexed address part resolves through a dictionary key, e.g. ``params.ranges[1].high``.""" + env = SimpleNamespace(params={"ranges": [SimpleNamespace(high=1.0), SimpleNamespace(high=2.0)]}) + cfg = CurrTerm(func=mdp.modify_env_param, params={"address": "params.ranges[1].high"}) + term = mdp.modify_env_param(cfg, env) + + term(env, None, "params.ranges[1].high", modify_fn=lambda env, env_ids, value: value * 10) + + assert env.params["ranges"][1].high == 20.0 diff --git a/source/isaaclab/test/managers/test_event_manager.py b/source/isaaclab/test/managers/test_event_manager.py index 762d2bcb4e42..9ae299c4a507 100644 --- a/source/isaaclab/test/managers/test_event_manager.py +++ b/source/isaaclab/test/managers/test_event_manager.py @@ -190,6 +190,18 @@ def test_class_terms(env): assert len(event_man._mode_class_term_cfgs["reset"]) == 1 +def test_class_terms_created_while_playing_are_reset(env, monkeypatch): + """Class terms instantiated while the simulation is playing are reset with the manager.""" + monkeypatch.setattr(env.sim, "is_playing", lambda: True) + event_man = EventManager({"term": EventTermCfg(func=reset_dummy2_to_zero_class, mode="reset")}, env) + reset_calls = [] + monkeypatch.setattr(event_man.get_term_cfg("term").func, "reset", lambda env_ids=None: reset_calls.append(env_ids)) + + event_man.reset() + + assert reset_calls == [None] + + def test_config_empty(env): """Test the creation of reward manager with empty config.""" event_man = EventManager(None, env) diff --git a/source/isaaclab_newton/changelog.d/core-cleanup-envs-managers-fixes.rst b/source/isaaclab_newton/changelog.d/core-cleanup-envs-managers-fixes.rst new file mode 100644 index 000000000000..3220ecea338b --- /dev/null +++ b/source/isaaclab_newton/changelog.d/core-cleanup-envs-managers-fixes.rst @@ -0,0 +1,14 @@ +Fixed +^^^^^ + +* Fixed :meth:`~isaaclab_newton.assets.Articulation.write_fixed_tendon_properties_to_sim_index` not notifying + the solver of the change, so written fixed tendon stiffness and damping never reached the MuJoCo model. +* Fixed :meth:`~isaaclab_newton.assets.Articulation.write_fixed_tendon_properties_to_sim_index` failing when + ``env_ids`` is None. + +Changed +^^^^^^^ + +* Changed the fixed tendon limit stiffness, rest length, and offset setters and data properties of + :class:`~isaaclab_newton.assets.Articulation` to explain why the Newton backend does not support them in + their :class:`NotImplementedError`. diff --git a/source/isaaclab_newton/changelog.d/core-cleanup-tendon-position-limits.minor.rst b/source/isaaclab_newton/changelog.d/core-cleanup-tendon-position-limits.minor.rst new file mode 100644 index 000000000000..067786347f7c --- /dev/null +++ b/source/isaaclab_newton/changelog.d/core-cleanup-tendon-position-limits.minor.rst @@ -0,0 +1,21 @@ +Added +^^^^^ + +* Added fixed tendon position limits to :class:`~isaaclab_newton.assets.Articulation`: + :meth:`~isaaclab_newton.assets.Articulation.set_fixed_tendon_position_limit_index` and + :meth:`~isaaclab_newton.assets.Articulation.set_fixed_tendon_position_limit_mask` now write the MuJoCo tendon + range instead of raising :class:`NotImplementedError`. + +Fixed +^^^^^ + +* Fixed selected tendon property writes reading staged values from the first environment or tendon instead + of the selected indices. +* Fixed :meth:`~isaaclab_newton.assets.Articulation.set_fixed_tendon_stiffness_mask` and + :meth:`~isaaclab_newton.assets.Articulation.set_fixed_tendon_damping_mask` raising ``AttributeError``. +* Fixed :meth:`~isaaclab_newton.assets.Articulation.write_fixed_tendon_properties_to_sim_mask` raising + ``TypeError``, and added the ``fixed_tendon_ids`` and ``fixed_tendon_mask`` arguments that the base class + declares to the fixed tendon writers. +* Corrected fixed tendon errors and documentation to distinguish unimplemented Isaac Lab properties from + Newton's MuJoCo tendon support. Identified the missing tendon force-gain conversion and the existing + Newton joint-limit conversion that provides a reference implementation. diff --git a/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation.py b/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation.py index 39ed08aa3194..99a5341ea520 100644 --- a/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation.py +++ b/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation.py @@ -40,7 +40,7 @@ from isaaclab_newton.physics import NewtonManager as SimulationManager from .actuator_control import NewtonActuatorControl -from .articulation_data import ArticulationData +from .articulation_data import ArticulationData, _unsupported_fixed_tendon_property if TYPE_CHECKING: from isaaclab.assets.articulation.articulation_cfg import ArticulationCfg @@ -2703,9 +2703,10 @@ def set_fixed_tendon_stiffness_mask( # Resolve masks. env_ids = self._resolve_env_mask(env_mask) fixed_tendon_ids = self._resolve_fixed_tendon_mask(fixed_tendon_mask) - # Set full data to True to ensure the right code path is taken inside the kernel. self.set_fixed_tendon_stiffness_index( - stiffness=stiffness, fixed_tendon_ids=fixed_tendon_ids, env_ids=env_ids, full_data=True + stiffness=self._select_full_data(stiffness, env_ids, fixed_tendon_ids), + fixed_tendon_ids=fixed_tendon_ids, + env_ids=env_ids, ) def set_fixed_tendon_damping_index( @@ -2799,9 +2800,10 @@ def set_fixed_tendon_damping_mask( # Resolve masks. env_ids = self._resolve_env_mask(env_mask) fixed_tendon_ids = self._resolve_fixed_tendon_mask(fixed_tendon_mask) - # Set full data to True to ensure the right code path is taken inside the kernel. self.set_fixed_tendon_damping_index( - damping=damping, fixed_tendon_ids=fixed_tendon_ids, env_ids=env_ids, full_data=True + damping=self._select_full_data(damping, env_ids, fixed_tendon_ids), + fixed_tendon_ids=fixed_tendon_ids, + env_ids=env_ids, ) def set_fixed_tendon_limit_stiffness_index( @@ -2811,25 +2813,19 @@ def set_fixed_tendon_limit_stiffness_index( fixed_tendon_ids: Sequence[int] | torch.Tensor | wp.array | None = None, env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ) -> None: - """Set fixed tendon limit stiffness into internal buffers using indices. - - This function does not apply the tendon limit stiffness to the simulation. It only fills the buffers with - the desired values. To apply the tendon limit stiffness, call the - :meth:`write_fixed_tendon_properties_to_sim_index` method. + """Set fixed tendon limit stiffness (unimplemented in this backend). - .. note:: - This method expects partial data. - - .. tip:: - Both the index and mask methods have dedicated optimized implementations. Performance is similar for both. - However, to allow graphed pipelines, the mask method must be used. + See :attr:`ArticulationData.fixed_tendon_limit_stiffness` for the backend limitation. Args: limit_stiffness: Fixed tendon limit stiffness. Shape is (len(env_ids), len(fixed_tendon_ids)). fixed_tendon_ids: The tendon indices to set the limit stiffness for. Defaults to None (all fixed tendons). env_ids: Environment indices. If None, then all indices are used. + + Raises: + NotImplementedError: This shared property has no implementation in Isaac Lab's Newton backend. """ - raise NotImplementedError() + raise _unsupported_fixed_tendon_property("limit_stiffness") def set_fixed_tendon_limit_stiffness_mask( self, @@ -2838,26 +2834,20 @@ def set_fixed_tendon_limit_stiffness_mask( fixed_tendon_mask: wp.array | None = None, env_mask: wp.array | None = None, ) -> None: - """Set fixed tendon limit stiffness into internal buffers using masks. + """Set fixed tendon limit stiffness (unimplemented in this backend). - This function does not apply the tendon limit stiffness to the simulation. It only fills the buffers with - the desired values. To apply the tendon limit stiffness, call the - :meth:`write_fixed_tendon_properties_to_sim_mask` method. - - .. note:: - This method expects full data. - - .. tip:: - Both the index and mask methods have dedicated optimized implementations. Performance is similar for both. - However, to allow graphed pipelines, the mask method must be used. + See :attr:`ArticulationData.fixed_tendon_limit_stiffness` for the backend limitation. Args: limit_stiffness: Fixed tendon limit stiffness. Shape is (num_instances, num_fixed_tendons). fixed_tendon_mask: Fixed tendon mask. If None, then all fixed tendons are used. Shape is (num_fixed_tendons,). env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). + + Raises: + NotImplementedError: This shared property has no implementation in Isaac Lab's Newton backend. """ - raise NotImplementedError() + raise _unsupported_fixed_tendon_property("limit_stiffness") def set_fixed_tendon_position_limit_index( self, @@ -2865,7 +2855,6 @@ def set_fixed_tendon_position_limit_index( limit: float | torch.Tensor | wp.array, fixed_tendon_ids: Sequence[int] | torch.Tensor | wp.array | None = None, env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, - full_data: bool = False, ) -> None: """Set fixed tendon position limit into internal buffers using indices. @@ -2873,21 +2862,30 @@ def set_fixed_tendon_position_limit_index( the desired values. To apply the tendon position limit, call the :meth:`write_fixed_tendon_properties_to_sim_index` method. + This updates MuJoCo's tendon range; limits must already be enabled in the model. + .. note:: - This method expects partial data or full data. + This method expects partial data. .. tip:: For maximum performance we recommend using the index method. This is because in PhysX, the tensor API is only supporting indexing, hence masks need to be converted to indices. Args: - limit: Fixed tendon position limit. Shape is (len(env_ids), len(fixed_tendon_ids)) or - (num_instances, num_fixed_tendons) if full_data. + limit: Fixed tendon position limits ``[lower, upper]`` [m]. Shape is (len(env_ids), len(fixed_tendon_ids)) + with dtype wp.vec2f, or (len(env_ids), len(fixed_tendon_ids), 2) for a torch tensor. fixed_tendon_ids: The tendon indices to set the position limit for. Defaults to None (all fixed tendons). env_ids: Environment indices. If None, then all indices are used. - full_data: Whether to expect full data. Defaults to False. """ - raise NotImplementedError() + env_ids = self._resolve_env_ids(env_ids) + fixed_tendon_ids = self._resolve_fixed_tendon_ids(fixed_tendon_ids) + self.assert_shape_and_dtype(limit, (env_ids.shape[0], fixed_tendon_ids.shape[0]), wp.vec2f, "limit") + if isinstance(limit, float): + raise ValueError("Fixed tendon position limits must be a tensor or array, not a float.") + # the vec2f buffer and a vec2f input both view as (..., 2) float tensors in torch + limit = wp.to_torch(limit) if isinstance(limit, wp.array) else limit + rows, cols = self._to_torch_ids(env_ids), self._to_torch_ids(fixed_tendon_ids) + wp.to_torch(self.data._fixed_tendon_pos_limits)[rows[:, None], cols] = limit def set_fixed_tendon_position_limit_mask( self, @@ -2902,6 +2900,8 @@ def set_fixed_tendon_position_limit_mask( the desired values. To apply the tendon position limit, call the :meth:`write_fixed_tendon_properties_to_sim_mask` method. + This updates MuJoCo's tendon range; limits must already be enabled in the model. + .. note:: This method expects full data. @@ -2910,11 +2910,18 @@ def set_fixed_tendon_position_limit_mask( is only supporting indexing, hence masks need to be converted to indices. Args: - limit: Fixed tendon position limit. Shape is (num_instances, num_fixed_tendons). + limit: Fixed tendon position limits ``[lower, upper]`` [m]. Shape is (num_instances, num_fixed_tendons) + with dtype wp.vec2f, or (num_instances, num_fixed_tendons, 2) for a torch tensor. fixed_tendon_mask: Fixed tendon mask. If None, then all fixed tendons are used. env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). """ - raise NotImplementedError() + env_ids = self._resolve_env_mask(env_mask) + fixed_tendon_ids = self._resolve_fixed_tendon_mask(fixed_tendon_mask) + self.set_fixed_tendon_position_limit_index( + limit=self._select_full_data(limit, env_ids, fixed_tendon_ids), + fixed_tendon_ids=fixed_tendon_ids, + env_ids=env_ids, + ) def set_fixed_tendon_rest_length_index( self, @@ -2923,25 +2930,19 @@ def set_fixed_tendon_rest_length_index( fixed_tendon_ids: Sequence[int] | torch.Tensor | wp.array | None = None, env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ) -> None: - """Set fixed tendon rest length into internal buffers using indices. - - This function does not apply the tendon rest length to the simulation. It only fills the buffers with - the desired values. To apply the tendon rest length, call the - :meth:`write_fixed_tendon_properties_to_sim_index` method. - - .. note:: - This method expects partial data. + """Set fixed tendon rest length (unimplemented in this backend). - .. tip:: - Both the index and mask methods have dedicated optimized implementations. Performance is similar for both. - However, to allow graphed pipelines, the mask method must be used. + See :attr:`ArticulationData.fixed_tendon_rest_length` for the backend limitation. Args: rest_length: Fixed tendon rest length. Shape is (len(env_ids), len(fixed_tendon_ids)). fixed_tendon_ids: The tendon indices to set the rest length for. Defaults to None (all fixed tendons). env_ids: Environment indices. If None, then all indices are used. + + Raises: + NotImplementedError: This shared property has no implementation in Isaac Lab's Newton backend. """ - raise NotImplementedError() + raise _unsupported_fixed_tendon_property("rest_length") def set_fixed_tendon_rest_length_mask( self, @@ -2950,26 +2951,20 @@ def set_fixed_tendon_rest_length_mask( fixed_tendon_mask: wp.array | None = None, env_mask: wp.array | None = None, ) -> None: - """Set fixed tendon rest length into internal buffers using masks. + """Set fixed tendon rest length (unimplemented in this backend). - This function does not apply the tendon rest length to the simulation. It only fills the buffers with - the desired values. To apply the tendon rest length, call the - :meth:`write_fixed_tendon_properties_to_sim_mask` method. - - .. note:: - This method expects full data. - - .. tip:: - Both the index and mask methods have dedicated optimized implementations. Performance is similar for both. - However, to allow graphed pipelines, the mask method must be used. + See :attr:`ArticulationData.fixed_tendon_rest_length` for the backend limitation. Args: rest_length: Fixed tendon rest length. Shape is (num_instances, num_fixed_tendons). fixed_tendon_mask: Fixed tendon mask. If None, then all fixed tendons are used. Shape is (num_fixed_tendons,). env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). + + Raises: + NotImplementedError: This shared property has no implementation in Isaac Lab's Newton backend. """ - raise NotImplementedError() + raise _unsupported_fixed_tendon_property("rest_length") def set_fixed_tendon_position_target_index( self, @@ -3012,25 +3007,19 @@ def set_fixed_tendon_offset_index( fixed_tendon_ids: Sequence[int] | torch.Tensor | wp.array | None = None, env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ) -> None: - """Set fixed tendon offset into internal buffers using indices. - - This function does not apply the tendon offset to the simulation. It only fills the buffers with - the desired values. To apply the tendon offset, call the - :meth:`write_fixed_tendon_properties_to_sim_index` method. + """Set fixed tendon offset (unimplemented in this backend). - .. note:: - This method expects partial data. - - .. tip:: - Both the index and mask methods have dedicated optimized implementations. Performance is similar for both. - However, to allow graphed pipelines, the mask method must be used. + See :attr:`ArticulationData.fixed_tendon_offset` for the backend limitation. Args: offset: Fixed tendon offset. Shape is (len(env_ids), len(fixed_tendon_ids)). fixed_tendon_ids: The tendon indices to set the offset for. Defaults to None (all fixed tendons). env_ids: Environment indices. If None, then all indices are used. + + Raises: + NotImplementedError: This shared property has no implementation in Isaac Lab's Newton backend. """ - raise NotImplementedError() + raise _unsupported_fixed_tendon_property("offset") def set_fixed_tendon_position_target_mask( self, @@ -3070,34 +3059,31 @@ def set_fixed_tendon_offset_mask( fixed_tendon_mask: wp.array | None = None, env_mask: wp.array | None = None, ) -> None: - """Set fixed tendon offset into internal buffers using masks. - - This function does not apply the tendon offset to the simulation. It only fills the buffers with - the desired values. To apply the tendon offset, call the - :meth:`write_fixed_tendon_properties_to_sim_mask` method. - - .. note:: - This method expects full data. + """Set fixed tendon offset (unimplemented in this backend). - .. tip:: - Both the index and mask methods have dedicated optimized implementations. Performance is similar for both. - However, to allow graphed pipelines, the mask method must be used. + See :attr:`ArticulationData.fixed_tendon_offset` for the backend limitation. Args: offset: Fixed tendon offset. Shape is (num_instances, num_fixed_tendons). fixed_tendon_mask: Fixed tendon mask. If None, then all fixed tendons are used. Shape is (num_fixed_tendons,). env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). + + Raises: + NotImplementedError: This shared property has no implementation in Isaac Lab's Newton backend. """ - raise NotImplementedError() + raise _unsupported_fixed_tendon_property("offset") def write_fixed_tendon_properties_to_sim_index( self, *, + fixed_tendon_ids: Sequence[int] | torch.Tensor | wp.array | None = None, env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ) -> None: """Write fixed tendon properties into the simulation using indices. + Writes the stiffness, damping, and position limits set with the ``set_fixed_tendon_*`` methods. + .. tip:: Both the index and mask methods have dedicated optimized implementations. Performance is similar for both. However, to allow graphed pipelines, the mask method must be used. @@ -3107,37 +3093,22 @@ def write_fixed_tendon_properties_to_sim_index( (all fixed tendons). env_ids: Environment indices. If None, then all indices are used. """ - # TODO: Combine into one - wp.launch( - shared_kernels.write_2d_data_to_buffer_with_indices_kernel(env_ids, self._ALL_FIXED_TENDON_INDICES), - dim=(env_ids.shape[0], self._ALL_FIXED_TENDON_INDICES.shape[0]), - inputs=[ - self.data._fixed_tendon_damping, - env_ids, - self._ALL_FIXED_TENDON_INDICES, - ], - outputs=[ - self.data._sim_bind_fixed_tendon_damping, - ], - device=self.device, - ) - wp.launch( - shared_kernels.write_2d_data_to_buffer_with_indices_kernel(env_ids, self._ALL_FIXED_TENDON_INDICES), - dim=(env_ids.shape[0], self._ALL_FIXED_TENDON_INDICES.shape[0]), - inputs=[ - self.data._fixed_tendon_stiffness, - env_ids, - self._ALL_FIXED_TENDON_INDICES, - ], - outputs=[ - self.data._sim_bind_fixed_tendon_stiffness, - ], - device=self.device, - ) + env_ids = self._resolve_env_ids(env_ids) + fixed_tendon_ids = self._resolve_fixed_tendon_ids(fixed_tendon_ids) + rows, cols = self._to_torch_ids(env_ids)[:, None], self._to_torch_ids(fixed_tendon_ids) + for staged, sim_bind in ( + (self.data._fixed_tendon_stiffness, self.data._sim_bind_fixed_tendon_stiffness), + (self.data._fixed_tendon_damping, self.data._sim_bind_fixed_tendon_damping), + (self.data._fixed_tendon_pos_limits, self.data._sim_bind_fixed_tendon_pos_limits), + ): + wp.to_torch(sim_bind)[rows, cols] = wp.to_torch(staged)[rows, cols] + # the solver keeps its own copy of the tendon properties and only re-reads them when notified + SimulationManager.add_model_change(ModelFlags.TENDON_PROPERTIES) def write_fixed_tendon_properties_to_sim_mask( self, *, + fixed_tendon_mask: wp.array | None = None, env_mask: wp.array | None = None, ) -> None: """Write fixed tendon properties into the simulation using masks. @@ -3147,11 +3118,13 @@ def write_fixed_tendon_properties_to_sim_mask( However, to allow graphed pipelines, the mask method must be used. Args: + fixed_tendon_mask: Fixed tendon mask. If None, then all fixed tendons are updated. env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). """ - env_ids = self._resolve_mask(env_mask) - - self.write_fixed_tendon_properties_to_sim_index(env_ids) + self.write_fixed_tendon_properties_to_sim_index( + fixed_tendon_ids=self._resolve_fixed_tendon_mask(fixed_tendon_mask), + env_ids=self._resolve_env_mask(env_mask), + ) def set_spatial_tendon_stiffness_index( self, @@ -3821,6 +3794,34 @@ def _resolve_spatial_tendon_ids( return self._ALL_SPATIAL_TENDON_INDICES return spatial_tendon_ids + def _resolve_env_mask(self, env_mask: wp.array | torch.Tensor | None) -> wp.array | torch.Tensor: + """Resolve an environment mask to environment indices.""" + return self._ALL_INDICES if env_mask is None else self._mask_to_ids(env_mask) + + def _resolve_fixed_tendon_mask(self, fixed_tendon_mask: wp.array | torch.Tensor | None) -> wp.array | torch.Tensor: + """Resolve a fixed tendon mask to fixed tendon indices.""" + return self._ALL_FIXED_TENDON_INDICES if fixed_tendon_mask is None else self._mask_to_ids(fixed_tendon_mask) + + @staticmethod + def _mask_to_ids(mask: wp.array | torch.Tensor) -> torch.Tensor: + """Convert a boolean mask to int32 indices.""" + mask = wp.to_torch(mask) if isinstance(mask, wp.array) else mask + return torch.nonzero(mask)[:, 0].to(torch.int32) + + @staticmethod + def _to_torch_ids(ids: wp.array | torch.Tensor) -> torch.Tensor: + """View resolved indices as a torch tensor for indexing.""" + return (wp.to_torch(ids) if isinstance(ids, wp.array) else ids).long() + + def _select_full_data( + self, data: float | torch.Tensor | wp.array, env_ids: wp.array | torch.Tensor, ids: wp.array | torch.Tensor + ) -> float | torch.Tensor: + """Select the rows and columns of full (num_instances, num_items, ...) data given by the indices.""" + if isinstance(data, float): + return data + data = wp.to_torch(data) if isinstance(data, wp.array) else data + return data[self._to_torch_ids(env_ids)[:, None], self._to_torch_ids(ids)] + def _resolve_mask(self, mask: wp.array | torch.Tensor | None, full_mask: wp.array) -> wp.array: """Resolve a mask to a warp array. diff --git a/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation_data.py b/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation_data.py index b17e37f3c782..588a4754e218 100644 --- a/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation_data.py +++ b/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation_data.py @@ -42,6 +42,30 @@ ) +# Shared tendon properties that Isaac Lab's Newton backend does not implement. +_UNSUPPORTED_FIXED_TENDON_PROPERTIES = { + "limit_stiffness": ( + "the tendon path has no force-gain conversion for MuJoCo solreflimit/solimplimit; " + "Newton already provides such a conversion for joint limits" + ), + "rest_length": ( + "Isaac Lab does not expose MuJoCo springlength through this property, and Newton's SolverMuJoCo " + "does not propagate springlength changes in its runtime tendon update" + ), + "offset": ( + "MuJoCo has no equivalent length-offset field; Isaac Lab does not implement a mapping that " + "preserves the PhysX spring and limit behavior" + ), +} + + +def _unsupported_fixed_tendon_property(name: str) -> NotImplementedError: + """Explain an unimplemented shared property without implying that Newton lacks tendons.""" + reason = _UNSUPPORTED_FIXED_TENDON_PROPERTIES[name] + label = name.replace("_", " ") + return NotImplementedError(f"Fixed tendon {label} is not implemented in Isaac Lab's Newton backend: {reason}.") + + class ArticulationData(BaseArticulationData): """Data container for an articulation. @@ -537,6 +561,9 @@ def fixed_tendon_stiffness(self) -> ProxyArray: def fixed_tendon_damping(self) -> ProxyArray: """Fixed tendon damping provided to the simulation. + MuJoCo uses this for passive tendon damping. Its limit response uses separate ``solreflimit`` + parameters; PhysX's tendon damping parameter also affects its limit response. + Shape is (num_instances, num_fixed_tendons), dtype = wp.float32. In torch this resolves to (num_instances, num_fixed_tendons). """ @@ -546,34 +573,46 @@ def fixed_tendon_damping(self) -> ProxyArray: def fixed_tendon_limit_stiffness(self) -> ProxyArray: """Fixed tendon limit stiffness provided to the simulation. + Not implemented in this backend. MuJoCo's ``solreflimit`` supports stiffness/damping, but + force gains require an inverse-inertia and impedance conversion. Newton already applies + this conversion to joint limits; the tendon path does not yet implement it. + Shape is (num_instances, num_fixed_tendons), dtype = wp.float32. In torch this resolves to (num_instances, num_fixed_tendons). """ - raise NotImplementedError + raise _unsupported_fixed_tendon_property("limit_stiffness") @property def fixed_tendon_rest_length(self) -> ProxyArray: """Fixed tendon rest length provided to the simulation. + Not exposed by this backend. MuJoCo has ``springlength``, but Newton's runtime tendon update + does not propagate changes to it. + Shape is (num_instances, num_fixed_tendons), dtype = wp.float32. In torch this resolves to (num_instances, num_fixed_tendons). """ - raise NotImplementedError + raise _unsupported_fixed_tendon_property("rest_length") @property def fixed_tendon_offset(self) -> ProxyArray: """Fixed tendon offset provided to the simulation. + Not implemented in this backend. Preserving a PhysX accumulated-length offset would require + coordinated changes to MuJoCo's spring reference and limit range. + Shape is (num_instances, num_fixed_tendons), dtype = wp.float32. In torch this resolves to (num_instances, num_fixed_tendons). """ - raise NotImplementedError + raise _unsupported_fixed_tendon_property("offset") @property def fixed_tendon_pos_limits(self) -> ProxyArray: """Fixed tendon position limits provided to the simulation. - Shape is (num_instances, num_fixed_tendons, 2), dtype = wp.vec2f. In torch this resolves to + MuJoCo enforces this range only for tendons whose limits were enabled in the model. + + Shape is (num_instances, num_fixed_tendons), dtype = wp.vec2f. In torch this resolves to (num_instances, num_fixed_tendons, 2). """ return self._fixed_tendon_pos_limits_ta @@ -1792,9 +1831,11 @@ def _create_buffers(self) -> None: if self._num_fixed_tendons > 0: self._fixed_tendon_stiffness = wp.clone(self._sim_bind_fixed_tendon_stiffness) self._fixed_tendon_damping = wp.clone(self._sim_bind_fixed_tendon_damping) + self._fixed_tendon_pos_limits = wp.clone(self._sim_bind_fixed_tendon_pos_limits) else: self._fixed_tendon_stiffness = wp.zeros((self._num_instances, 0), dtype=wp.float32, device=self.device) self._fixed_tendon_damping = wp.zeros((self._num_instances, 0), dtype=wp.float32, device=self.device) + self._fixed_tendon_pos_limits = wp.zeros((self._num_instances, 0), dtype=wp.vec2f, device=self.device) # Unlike the properties above this is a per-step command, so it starts at zero rather than # cloning a sim binding: MuJoCo holds the tendon's control in its own array, not on the tendon. self._fixed_tendon_position_target = wp.zeros( diff --git a/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py b/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py index 4fc2c3969381..f13ec11a3789 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py @@ -682,9 +682,9 @@ class NewtonMaterialPropertiesCfg(RigidBodyMaterialBaseCfg): class MujocoFixedTendonCfg(FixedTendonFragment): """``mjc:*`` fixed-tendon attributes for a ``MjcTendon`` prim. - The Mujoco fixed-tendon fragment. Newton has no tendon solver; this models only the ``mjc:*`` - tune path the Newton/Mujoco importer reads from a ``MjcTendon`` prim, carrying only the fields - that path maps. Overrides :attr:`func` with a custom applier + Newton's MuJoCo solver supports fixed tendons. This fragment tunes the spring stiffness and + damping read from a ``MjcTendon`` prim; limit response is configured separately in MuJoCo. + Overrides :attr:`func` with a custom applier (:func:`~isaaclab_newton.sim.schemas.apply_mujoco_fixed_tendon`) that gates on the ``MjcTendon`` prim type. Can be combined with :class:`~isaaclab_physx.sim.schemas.PhysxTendonAxisRootCfg` in the same fragment list passed to diff --git a/source/isaaclab_newton/test/assets/test_articulation.py b/source/isaaclab_newton/test/assets/test_articulation.py index f1e3b59ef992..8f10e66d5f74 100644 --- a/source/isaaclab_newton/test/assets/test_articulation.py +++ b/source/isaaclab_newton/test/assets/test_articulation.py @@ -1935,6 +1935,45 @@ def test_hand_with_tendons_initializes_and_targets_only_given_envs(sim, num_arti assert commanded[joint_ids].sum() > untouched[joint_ids].sum() +@pytest.mark.parametrize("num_articulations", [2]) +@pytest.mark.parametrize("device", ["cuda:0"]) +@pytest.mark.parametrize("articulation_type", ["shadow_hand"]) +def test_fixed_tendon_properties_reach_solver(sim, num_articulations, device, articulation_type): + """Written fixed tendon stiffness, damping, and position limits reach the MuJoCo solver. + + Covers both the index and the mask setters and writers. + """ + articulation, _ = generate_articulation( + generate_articulation_cfg(articulation_type=articulation_type), num_articulations, device + ) + sim.reset() + shape = (num_articulations, articulation.num_fixed_tendons) + limits = torch.tensor([-0.1, 0.2], device=device).expand(*shape, 2) + + articulation.set_fixed_tendon_stiffness_mask(stiffness=torch.full(shape, 12.0, device=device)) + articulation.set_fixed_tendon_damping_index(damping=torch.full(shape, 3.0, device=device)) + articulation.set_fixed_tendon_position_limit_index(limit=limits) + articulation.write_fixed_tendon_properties_to_sim_mask() + sim.step() + + solver_model = SimulationManager._solver.mjw_model + np.testing.assert_allclose(solver_model.tendon_stiffness.numpy(), 12.0) + np.testing.assert_allclose(solver_model.tendon_damping.numpy(), 3.0) + np.testing.assert_allclose(solver_model.tendon_range.numpy(), limits.cpu().numpy(), rtol=1e-6) + torch.testing.assert_close(articulation.data.fixed_tendon_pos_limits.torch, limits) + + articulation.set_fixed_tendon_stiffness_index(stiffness=42.0, env_ids=[1], fixed_tendon_ids=[1]) + articulation.set_fixed_tendon_damping_index(damping=6.0, env_ids=[1], fixed_tendon_ids=[1]) + articulation.write_fixed_tendon_properties_to_sim_index(env_ids=[1], fixed_tendon_ids=[1]) + sim.step() + expected_stiffness = np.full(shape, 12.0) + expected_damping = np.full(shape, 3.0) + expected_stiffness[1, 1] = 42.0 + expected_damping[1, 1] = 6.0 + np.testing.assert_allclose(solver_model.tendon_stiffness.numpy(), expected_stiffness) + np.testing.assert_allclose(solver_model.tendon_damping.numpy(), expected_damping) + + @pytest.mark.parametrize("device", ["cpu"]) @pytest.mark.parametrize("add_ground_plane", [True]) @pytest.mark.parametrize("articulation_type", ["anymal"]) diff --git a/source/isaaclab_ov/changelog.d/core-cleanup-tendon-position-limits.rst b/source/isaaclab_ov/changelog.d/core-cleanup-tendon-position-limits.rst new file mode 100644 index 000000000000..7ba7524bb78d --- /dev/null +++ b/source/isaaclab_ov/changelog.d/core-cleanup-tendon-position-limits.rst @@ -0,0 +1,7 @@ +Fixed +^^^^^ + +* Fixed :meth:`~isaaclab_ov.assets.Articulation.set_fixed_tendon_position_limit_index` and + :meth:`~isaaclab_ov.assets.Articulation.set_fixed_tendon_position_limit_mask` rejecting ``wp.vec2f`` arrays, + the layout of :attr:`~isaaclab_ov.assets.ArticulationData.fixed_tendon_pos_limits`, and passing a float to the + kernel instead of raising ``ValueError``. diff --git a/source/isaaclab_ov/isaaclab_ov/assets/articulation/articulation.py b/source/isaaclab_ov/isaaclab_ov/assets/articulation/articulation.py index 6559048006e4..eef55824f9e3 100644 --- a/source/isaaclab_ov/isaaclab_ov/assets/articulation/articulation.py +++ b/source/isaaclab_ov/isaaclab_ov/assets/articulation/articulation.py @@ -3020,7 +3020,20 @@ def set_fixed_tendon_position_limit_index( """ env_ids = self._resolve_env_ids(env_ids) tendon_ids = self._resolve_fixed_tendon_ids(fixed_tendon_ids) - self.assert_shape_and_dtype(limit, (env_ids.shape[0], tendon_ids.shape[0], 2), wp.float32, "limit") + if isinstance(limit, float): + raise ValueError("Fixed tendon position limits must be a tensor or array, not a float.") + # accept both wp.vec2f (N, T) and the (N, T, 2) wp.float32 form, like the joint position limits + if isinstance(limit, wp.array) and limit.dtype == wp.vec2f: + self.assert_shape_and_dtype(limit, (env_ids.shape[0], tendon_ids.shape[0]), wp.vec2f, "limit") + limit = wp.array( + ptr=limit.ptr, + shape=(env_ids.shape[0], tendon_ids.shape[0], 2), + dtype=wp.float32, + device=str(limit.device), + copy=False, + ) + else: + self.assert_shape_and_dtype(limit, (env_ids.shape[0], tendon_ids.shape[0], 2), wp.float32, "limit") if env_ids.shape[0] == 0 or tendon_ids.shape[0] == 0: return sim_env_ids = self._sim_env_ids_view(env_ids.shape[0]) @@ -3074,7 +3087,20 @@ def set_fixed_tendon_position_limit_mask( """ env_mask_wp = self._resolve_env_mask(env_mask) tendon_mask_wp = self._resolve_fixed_tendon_mask(fixed_tendon_mask) - self.assert_shape_and_dtype(limit, (self._num_instances, self._num_fixed_tendons, 2), wp.float32, "limit") + if isinstance(limit, float): + raise ValueError("Fixed tendon position limits must be a tensor or array, not a float.") + # accept both wp.vec2f (N, T) and the (N, T, 2) wp.float32 form, like the joint position limits + if isinstance(limit, wp.array) and limit.dtype == wp.vec2f: + self.assert_shape_and_dtype(limit, (self._num_instances, self._num_fixed_tendons), wp.vec2f, "limit") + limit = wp.array( + ptr=limit.ptr, + shape=(self._num_instances, self._num_fixed_tendons, 2), + dtype=wp.float32, + device=str(limit.device), + copy=False, + ) + else: + self.assert_shape_and_dtype(limit, (self._num_instances, self._num_fixed_tendons, 2), wp.float32, "limit") wp.launch( shared_kernels.write_joint_position_limit_to_buffer_mask, dim=(self._num_instances, self._num_fixed_tendons), diff --git a/source/isaaclab_physx/changelog.d/core-cleanup-tendon-position-limits.rst b/source/isaaclab_physx/changelog.d/core-cleanup-tendon-position-limits.rst new file mode 100644 index 000000000000..cc13a3410f1f --- /dev/null +++ b/source/isaaclab_physx/changelog.d/core-cleanup-tendon-position-limits.rst @@ -0,0 +1,8 @@ +Fixed +^^^^^ + +* Fixed :meth:`~isaaclab_physx.assets.Articulation.set_fixed_tendon_position_limit_index` and + :meth:`~isaaclab_physx.assets.Articulation.set_fixed_tendon_position_limit_mask` rejecting position limits in + the layout the asset stores and reports them in. They now take ``wp.vec2f`` arrays of shape + (num_envs, num_fixed_tendons), or torch tensors with a trailing dimension of 2, like the joint position limits. +* Corrected tendon configuration documentation that incorrectly described tendons as a PhysX-only feature. diff --git a/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation.py b/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation.py index 228fd14618dd..e44a915a2221 100644 --- a/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation.py +++ b/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation.py @@ -3037,8 +3037,9 @@ def set_fixed_tendon_position_limit_index( is only supporting indexing, hence masks need to be converted to indices. Args: - limit: Fixed tendon position limit. Shape is (len(env_ids), len(fixed_tendon_ids)) or - (num_instances, num_fixed_tendons) if full_data. + limit: Fixed tendon position limits ``[lower, upper]`` [m]. Shape is (len(env_ids), len(fixed_tendon_ids)) + or (num_instances, num_fixed_tendons) if full_data, with dtype wp.vec2f. A torch tensor has a + trailing dimension of 2. fixed_tendon_ids: The tendon indices to set the position limit for. Defaults to None (all fixed tendons). env_ids: Environment indices. If None, then all indices are used. full_data: Whether to expect full data. Defaults to False. @@ -3046,41 +3047,17 @@ def set_fixed_tendon_position_limit_index( # resolve indices env_ids = self._resolve_env_ids(env_ids) fixed_tendon_ids = self._resolve_fixed_tendon_ids(fixed_tendon_ids) - if full_data: - self.assert_shape_and_dtype(limit, (self.num_instances, self.num_fixed_tendons), wp.float32, "limit") - else: - self.assert_shape_and_dtype(limit, (env_ids.shape[0], fixed_tendon_ids.shape[0]), wp.float32, "limit") - # Warp kernels can ingest torch tensors directly, so we don't need to convert to warp arrays here. if isinstance(limit, float): - wp.launch( - articulation_kernels.float_data_to_buffer_with_indices_kernel(env_ids, fixed_tendon_ids), - dim=(env_ids.shape[0], fixed_tendon_ids.shape[0]), - inputs=[ - limit, - env_ids, - fixed_tendon_ids, - ], - outputs=[ - self.data._fixed_tendon_pos_limits, - ], - device=self.device, - ) + raise ValueError("Fixed tendon position limits must be a tensor or array, not a float.") + if full_data: + self.assert_shape_and_dtype(limit, (self.num_instances, self.num_fixed_tendons), wp.vec2f, "limit") else: - wp.launch( - shared_kernels.write_2d_data_to_buffer_with_indices_kernel(env_ids, fixed_tendon_ids), - dim=(env_ids.shape[0], fixed_tendon_ids.shape[0]), - inputs=[ - limit, - env_ids, - fixed_tendon_ids, - full_data, - ], - outputs=[ - self.data._fixed_tendon_pos_limits, - ], - device=self.device, - ) - # Only updates internal buffers, does not apply the position limit to the simulation. + self.assert_shape_and_dtype(limit, (env_ids.shape[0], fixed_tendon_ids.shape[0]), wp.vec2f, "limit") + # the (num_instances, num_fixed_tendons, 2) buffer and a vec2f input both view as (..., 2) torch tensors + limit = wp.to_torch(limit) if isinstance(limit, wp.array) else limit + rows = (wp.to_torch(env_ids) if isinstance(env_ids, wp.array) else env_ids).long()[:, None] + cols = (wp.to_torch(fixed_tendon_ids) if isinstance(fixed_tendon_ids, wp.array) else fixed_tendon_ids).long() + wp.to_torch(self.data._fixed_tendon_pos_limits)[rows, cols] = limit[rows, cols] if full_data else limit def set_fixed_tendon_position_limit_mask( self, @@ -3103,7 +3080,8 @@ def set_fixed_tendon_position_limit_mask( is only supporting indexing, hence masks need to be converted to indices. Args: - limit: Fixed tendon position limit. Shape is (num_instances, num_fixed_tendons). + limit: Fixed tendon position limits ``[lower, upper]`` [m]. Shape is (num_instances, num_fixed_tendons) + with dtype wp.vec2f. A torch tensor has a trailing dimension of 2. fixed_tendon_mask: Fixed tendon mask. If None, then all fixed tendons are used. env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). """ diff --git a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py index ea0199eb5885..fc4de76f200d 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/schemas/schemas_cfg.py @@ -1131,11 +1131,11 @@ class SDFMeshPropertiesCfg(PhysxSDFMeshPropertiesCfg): class PhysxFixedTendonPropertiesCfg: """PhysX fixed-tendon properties for an articulation. - Tendons are a PhysX-only feature -- Newton has no tendon system -- so this class - is a pure data carrier that is consumed by the PhysX-specific writer + This class configures PhysX tendon schemas and is consumed by the PhysX-specific writer :func:`~isaaclab.sim.schemas.modify_fixed_tendon_properties`. The writer authors the multi-instance ``PhysxTendonAxisRootAPI`` schema; this cfg class declares no - metadata-driven writer plumbing of its own. + metadata-driven writer plumbing of its own. For Newton's MuJoCo fixed tendons, use + :class:`~isaaclab_newton.sim.schemas.MujocoFixedTendonCfg`. See :func:`~isaaclab.sim.schemas.modify_fixed_tendon_properties` for more information. @@ -1198,8 +1198,7 @@ def __post_init__(self): class PhysxSpatialTendonPropertiesCfg: """PhysX spatial-tendon properties for an articulation. - Tendons are a PhysX-only feature -- Newton has no tendon system -- so this class - is a pure data carrier that is consumed by the PhysX-specific writer + This class configures PhysX spatial-tendon schemas and is consumed by the PhysX-specific writer :func:`~isaaclab.sim.schemas.modify_spatial_tendon_properties`. The writer authors every existing ``PhysxTendonAttachmentRootAPI`` instance; this cfg class declares no metadata-driven writer plumbing of its own. From 8835a34eaad3b2a4c49a03dee05620170bab64d3 Mon Sep 17 00:00:00 2001 From: Mustafa H <34825877+StafaH@users.noreply.github.com> Date: Fri, 25 Sep 2026 00:52:04 -0700 Subject: [PATCH 06/10] [Core] Refactor and cleanup core subpackage for 3.0 [5D/N] (#7992) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description Part of the series that splits the core cleanup in #7949 into small, reviewable pieces. Following #7985, event terms and task code should not branch on the physics manager's class name; they should call the asset API and let each backend handle (or reject) what it supports. An audit found the remaining places that branched on the backend name, plus one Newton solver gap. This PR fixes them. It is independent of #7985 / #7990 / #7991. ## Changes - **`randomize_joint_parameters`** chose the friction write path with `"newton" in manager_name`, treating every other backend (including OVPhysX) as PhysX. It now randomizes dynamic friction whenever the asset data exposes `joint_dynamic_friction_coeff` (PhysX, OVPhysX; not Newton), and makes one `write_joint_friction_coefficient_to_sim_index` call on every backend. Newton's writer already applies the viscous part and ignores dynamic friction. Its invalid-operation error also named `'randomize_fixed_tendon_parameters'`; it now names the right term. - **`randomize_rigid_body_com`** sliced the pose to a position on Newton (`"newton" in manager_name`) because Newton's `set_coms_*` accepted positions only, unlike the base API, PhysX, and OVPhysX, which take poses. Newton's `set_coms_index` / `set_coms_mask` (articulation, rigid object, rigid object collection) now also accept poses (trailing dimension 7 or `wp.transformf`) and ignore the orientation, since Newton models the center of mass as a position. The event passes poses on every backend. - **`randomize_rigid_body_scale`** detected articulations with `cls.__name__ == "Articulation"` over the MRO; it now uses `isinstance(asset, BaseArticulation)`. - **NIST `reset_end_effector_around_asset`** (`isaaclab_tasks` contrib) no longer performs a backend-specific Jacobian refresh. Joint writes invalidate articulation data, and subsequent body-pose reads refresh forward kinematics on demand. This also removes the invalid `root_physx_view` access on OVPhysX. - **Newton Featherstone inertia changes.** `SolverFeatherstone` derives its inertia data (`body_I_m`, `body_X_com`) from the model only in its constructor, and its `notify_model_changed` ignores `ModelFlags.BODY_INERTIAL_PROPERTIES`. So `set_masses_*`, `set_coms_*`, `set_inertias_*`, and the mass/CoM/inertia randomization events silently had no effect on Featherstone. The real fix is upstream in Newton. Until then, a manager can declare model changes its solver does not apply (`NewtonManager._ignored_model_changes`). Featherstone declares `BODY_INERTIAL_PROPERTIES`, and the manager logs a warning the first time such a change is flushed. - **Pretrained checkpoints** (`isaaclab_rl`): OVPhysX configs resolve to the PhysX checkpoints through the `"physx"` substring match. This is intended, since they run the same PhysX engine, so it is only documented. Not changed: Newton ignores `joint_velocity_limit` on MJWarp (documented by Newton). A warning was considered, but actuator setup writes velocity limits for every articulation, so it would fire on every MJWarp run. ## Tests - `isaaclab_physx/test/assets/test_articulation.py`: the two CUDA cases of `test_write_joint_state_data_consistency` pass, verifying fresh body state after manual joint writes without a simulation step or explicit Jacobian read. - `test/envs/test_articulation_ordering_events.py`: the Newton joint-parameter randomization test no longer gives the term a physics manager. It fails on `develop`, where the term reads `env.sim`. The fake Newton articulation now forwards viscous friction the way Newton's writer does. - `isaaclab_newton/test/assets/test_articulation.py`: `test_randomize_rigid_body_com` also sets CoMs from full poses (fails on `develop` with a shape mismatch). - `isaaclab_newton/test/physics/test_newton_manager_abstraction.py`: `test_initialize_solver_populates_canonical_state` checks that only Featherstone registers `BODY_INERTIAL_PROPERTIES` as ignored (8 solver variants). - Also passing: the full Newton manager abstraction tests (199) and the Newton articulation / rigid object / collection CoM and friction tests. ## Type of change - Bug fix (non-breaking change which fixes an issue) ## Release backport - [x] Backport this pull request to the active release branch after it merges into `develop` ## Checklist - [x] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html) - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] I have added a changelog fragment under `source//changelog.d/` for every touched package (do **not** edit `CHANGELOG.rst` or bump `extension.toml` — CI handles that) - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Signed-off-by: Octi Zhang Co-authored-by: Octi Zhang --- .../core-cleanup-backend-routing.rst | 10 +++ source/isaaclab/isaaclab/envs/mdp/events.py | 81 +++++++------------ .../envs/test_articulation_ordering_events.py | 18 +++-- .../core-cleanup-backend-routing.rst | 14 ++++ .../assets/articulation/articulation.py | 6 ++ .../isaaclab_newton/assets/kernels.py | 21 +++++ .../assets/rigid_object/rigid_object.py | 6 ++ .../rigid_object_collection.py | 6 ++ .../physics/featherstone_manager.py | 9 ++- .../isaaclab_newton/physics/newton_manager.py | 8 ++ .../test/assets/test_articulation.py | 6 ++ .../test_newton_manager_abstraction.py | 5 +- .../core-cleanup-backend-routing.skip | 1 + .../utils/pretrained_checkpoint.py | 1 + .../core-cleanup-backend-routing.rst | 6 ++ .../isaaclab_tasks/contrib/nist/mdp/events.py | 3 - 16 files changed, 136 insertions(+), 65 deletions(-) create mode 100644 source/isaaclab/changelog.d/core-cleanup-backend-routing.rst create mode 100644 source/isaaclab_newton/changelog.d/core-cleanup-backend-routing.rst create mode 100644 source/isaaclab_rl/changelog.d/core-cleanup-backend-routing.skip create mode 100644 source/isaaclab_tasks/changelog.d/core-cleanup-backend-routing.rst diff --git a/source/isaaclab/changelog.d/core-cleanup-backend-routing.rst b/source/isaaclab/changelog.d/core-cleanup-backend-routing.rst new file mode 100644 index 000000000000..3686d1aa847d --- /dev/null +++ b/source/isaaclab/changelog.d/core-cleanup-backend-routing.rst @@ -0,0 +1,10 @@ +Fixed +^^^^^ + +* Fixed :class:`~isaaclab.envs.mdp.events.randomize_joint_parameters` choosing the friction write path by the + physics manager name. It now randomizes dynamic friction whenever the asset exposes it, which also covers + OVPhysX explicitly, and its invalid-operation error names the right term. +* Fixed :class:`~isaaclab.envs.mdp.events.randomize_rigid_body_com` choosing the center of mass layout by the + physics manager name; it passes poses to every backend. +* Fixed :func:`~isaaclab.envs.mdp.events.randomize_rigid_body_scale` detecting articulations by class name + instead of :class:`~isaaclab.assets.BaseArticulation`. diff --git a/source/isaaclab/isaaclab/envs/mdp/events.py b/source/isaaclab/isaaclab/envs/mdp/events.py index 96550f32e79a..f0c4a608bd0c 100644 --- a/source/isaaclab/isaaclab/envs/mdp/events.py +++ b/source/isaaclab/isaaclab/envs/mdp/events.py @@ -77,9 +77,11 @@ def randomize_rigid_body_scale( " Please ensure that the event term is called before the simulation starts by using the 'usd' mode." ) + from ...assets import BaseArticulation # noqa: PLC0415 + asset: RigidObject = env.scene[asset_cfg.name] - if any(cls.__name__ == "Articulation" for cls in type(asset).__mro__): + if isinstance(asset, BaseArticulation): raise ValueError( "Scaling an articulation randomly is not supported, as it affects joint attributes and can cause" " unexpected behavior. To achieve different scales, we recommend generating separate USD files for" @@ -908,13 +910,13 @@ class randomize_rigid_body_com(ManagerTermBase): This class tracks the original CoM values and randomizes from those defaults on each call, ensuring repeatable randomization across resets. - Automatically detects the active physics backend: + The CoM pose (position and quaternion) is passed to ``set_coms_index``; backends that model the CoM as a + position only (Newton) ignore the orientation. - - **PhysX**: Passes the full CoM pose (position + quaternion) to ``set_coms_index``. - - **Newton**: Passes position-only (vec3) to ``set_coms_index``. Note that on Newton - (MuJoCo Warp), runtime CoM changes may cause simulation instability because - ``notify_model_changed(BODY_INERTIAL_PROPERTIES)`` does not fully recompute the - mass matrix after ``body_ipos`` changes. Use with caution until this is fixed upstream. + .. note:: + On Newton (MuJoCo Warp), runtime CoM changes may cause simulation instability because + ``notify_model_changed(BODY_INERTIAL_PROPERTIES)`` does not fully recompute the mass matrix after + ``body_ipos`` changes. Use with caution until this is fixed upstream. """ def __init__(self, cfg: EventTermCfg, env: ManagerBasedEnv): @@ -928,11 +930,6 @@ def __init__(self, cfg: EventTermCfg, env: ManagerBasedEnv): self.asset_cfg: SceneEntityCfg = cfg.params["asset_cfg"] self.asset: RigidObject | Articulation = env.scene[self.asset_cfg.name] - - # detect physics backend - manager_name = env.sim.physics_manager.__name__.lower() - self._is_newton = "newton" in manager_name - self.default_com = None def __call__( @@ -969,12 +966,8 @@ def __call__( coms = self.default_com.clone() coms[env_ids[:, None], body_ids, :3] += rand_samples - # Newton expects position-only (vec3f), PhysX expects the full pose (pos + quat) # note: pass partial data of shape (len(env_ids), len(body_ids), ...) to match the API - if self._is_newton: - self.asset.set_coms_index(coms=coms[env_ids[:, None], body_ids, :3], body_ids=body_ids, env_ids=env_ids) - else: - self.asset.set_coms_index(coms=coms[env_ids[:, None], body_ids], body_ids=body_ids, env_ids=env_ids) + self.asset.set_coms_index(coms=coms[env_ids[:, None], body_ids], body_ids=body_ids, env_ids=env_ids) class _RandomizeRigidBodyColliderOffsetsPhysx: @@ -1665,19 +1658,15 @@ def __init__(self, cfg: EventTermCfg, env: ManagerBasedEnv): self.asset_cfg: SceneEntityCfg = cfg.params["asset_cfg"] self.asset: Articulation = env.scene[self.asset_cfg.name] - # detect physics backend - manager_name = env.sim.physics_manager.__name__.lower() - self._backend = "newton" if "newton" in manager_name else "physx" - - # cache default values (common to both backends) + # cache default values self.default_joint_friction_coeff = self.asset.data.joint_friction_coeff.torch.clone() self.default_joint_armature = self.asset.data.joint_armature.torch.clone() self.default_joint_pos_limits = self.asset.data.joint_pos_limits.torch.clone() - - # Newton supports static friction and passive viscous damping but not dynamic friction. self.default_viscous_joint_friction_coeff = self.asset.data.joint_viscous_friction_coeff.torch.clone() - if self._backend == "physx": - self.default_dynamic_joint_friction_coeff = (self.asset.data.joint_dynamic_friction_coeff.torch).clone() + # dynamic friction is only exposed by backends that model it (e.g. not Newton) + self.default_dynamic_joint_friction_coeff = None + if hasattr(self.asset.data, "joint_dynamic_friction_coeff"): + self.default_dynamic_joint_friction_coeff = self.asset.data.joint_dynamic_friction_coeff.torch.clone() # check for valid operation if cfg.params["operation"] == "scale": @@ -1687,7 +1676,7 @@ def __init__(self, cfg: EventTermCfg, env: ManagerBasedEnv): _validate_scale_range(cfg.params["armature_distribution_params"], "armature_distribution_params") elif cfg.params["operation"] not in ("abs", "add"): raise ValueError( - "Randomization term 'randomize_fixed_tendon_parameters' does not support operation:" + "Randomization term 'randomize_joint_parameters' does not support operation:" f" '{cfg.params['operation']}'." ) @@ -1747,18 +1736,8 @@ def __call__( viscous_friction_coeff = torch.clamp(viscous_friction_coeff, min=0.0) viscous_friction_coeff = viscous_friction_coeff[env_ids_for_slice, joint_ids] - if self._backend == "newton": - self.asset.write_joint_friction_coefficient_to_sim_index( - joint_friction_coeff=static_friction_coeff, - joint_ids=joint_ids, - env_ids=env_ids, - ) - self.asset.write_joint_viscous_friction_coefficient_to_sim_index( - joint_viscous_friction_coeff=viscous_friction_coeff, - joint_ids=joint_ids, - env_ids=env_ids, - ) - else: + dynamic_friction_coeff = None + if self.default_dynamic_joint_friction_coeff is not None: dynamic_friction_coeff = _randomize_prop_by_op( self.default_dynamic_joint_friction_coeff.clone(), friction_distribution_params, @@ -1767,23 +1746,17 @@ def __call__( operation=operation, distribution=distribution, ) - # Clamp to non-negative - dynamic_friction_coeff = torch.clamp(dynamic_friction_coeff, min=0.0) - - # Ensure dynamic ≤ static (same shape before indexing) - dynamic_friction_coeff = torch.minimum(dynamic_friction_coeff, friction_coeff) - - # Index once at the end + # dynamic friction is non-negative and at most the static friction + dynamic_friction_coeff = torch.minimum(torch.clamp(dynamic_friction_coeff, min=0.0), friction_coeff) dynamic_friction_coeff = dynamic_friction_coeff[env_ids_for_slice, joint_ids] - # Single write call for all versions - self.asset.write_joint_friction_coefficient_to_sim_index( - joint_friction_coeff=static_friction_coeff, - joint_dynamic_friction_coeff=dynamic_friction_coeff, - joint_viscous_friction_coeff=viscous_friction_coeff, - joint_ids=joint_ids, - env_ids=env_ids, - ) + self.asset.write_joint_friction_coefficient_to_sim_index( + joint_friction_coeff=static_friction_coeff, + joint_dynamic_friction_coeff=dynamic_friction_coeff, + joint_viscous_friction_coeff=viscous_friction_coeff, + joint_ids=joint_ids, + env_ids=env_ids, + ) if armature_distribution_params is not None: armature = _randomize_prop_by_op( diff --git a/source/isaaclab/test/envs/test_articulation_ordering_events.py b/source/isaaclab/test/envs/test_articulation_ordering_events.py index b22d4167e361..00b91353781c 100644 --- a/source/isaaclab/test/envs/test_articulation_ordering_events.py +++ b/source/isaaclab/test/envs/test_articulation_ordering_events.py @@ -106,8 +106,16 @@ def __init__(self, body_ordering): self.static_friction_writes = [] self.viscous_friction_writes = [] - def write_joint_friction_coefficient_to_sim_index(self, **kwargs): - """Record Newton static-friction writes.""" + def write_joint_friction_coefficient_to_sim_index( + self, *, joint_dynamic_friction_coeff=None, joint_viscous_friction_coeff=None, **kwargs + ): + """Record Newton static-friction writes; like Newton, forward viscous friction and drop dynamic friction.""" + if joint_viscous_friction_coeff is not None: + self.write_joint_viscous_friction_coefficient_to_sim_index( + joint_viscous_friction_coeff=joint_viscous_friction_coeff, + joint_ids=kwargs["joint_ids"], + env_ids=kwargs["env_ids"], + ) self.static_friction_writes.append(kwargs) def write_joint_viscous_friction_coefficient_to_sim_index(self, **kwargs): @@ -250,10 +258,8 @@ def test_newton_joint_parameter_randomization_writes_static_and_viscous_friction "friction_distribution_params": (0.5, 0.5), } ) - env = SimpleNamespace( - scene=_FakeScene(robot=asset), - sim=SimpleNamespace(physics_manager=type("NewtonManager", (), {})), - ) + # the term does not read the physics backend; the asset API decides what it supports + env = SimpleNamespace(scene=_FakeScene(robot=asset)) term = events_module.randomize_joint_parameters(cfg, env) term( diff --git a/source/isaaclab_newton/changelog.d/core-cleanup-backend-routing.rst b/source/isaaclab_newton/changelog.d/core-cleanup-backend-routing.rst new file mode 100644 index 000000000000..3808658e7335 --- /dev/null +++ b/source/isaaclab_newton/changelog.d/core-cleanup-backend-routing.rst @@ -0,0 +1,14 @@ +Changed +^^^^^^^ + +* Changed the ``set_coms_index`` and ``set_coms_mask`` methods of the Newton + :class:`~isaaclab_newton.assets.Articulation`, :class:`~isaaclab_newton.assets.RigidObject`, and + :class:`~isaaclab_newton.assets.RigidObjectCollection` to also accept center of mass poses (trailing dimension + of 7 or ``wp.transformf``), like the other backends. The orientation is ignored. + +Fixed +^^^^^ + +* Fixed mass, center of mass, and inertia changes on the Featherstone solver being dropped silently. The + solver does not apply them after it is constructed; the Newton manager now logs a warning the first time + such a change is made. diff --git a/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation.py b/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation.py index 99a5341ea520..0a7472993113 100644 --- a/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation.py +++ b/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation.py @@ -2438,12 +2438,15 @@ def set_coms_index( Args: coms: Center of mass position of all bodies. Shape is (len(env_ids), len(body_ids), 3). In warp the expected shape is (num_instances, num_bodies), with dtype wp.vec3f. + Poses with a trailing dimension of 7 (dtype wp.transformf) are also accepted; their + orientation is ignored. body_ids: Body indices. If None, then all bodies are used. env_ids: Environment indices. If None, then all indices are used. """ # resolve all indices env_ids = self._resolve_env_ids(env_ids) body_ids = self._resolve_body_ids(body_ids) + coms = shared_kernels.com_positions(coms) self.assert_shape_and_dtype(coms, (env_ids.shape[0], body_ids.shape[0]), wp.vec3f, "coms") # Warp kernels can ingest torch tensors directly, so we don't need to convert to warp arrays here. has_body_ordering = self.data.has_body_ordering @@ -2488,12 +2491,15 @@ def set_coms_mask( coms: Center of mass position of all bodies. Shape is (num_instances, num_bodies, 3) or (num_instances, num_bodies, 7) (transformf convention — only position is used). In warp the expected shape is (num_instances, num_bodies), with dtype wp.vec3f or wp.transformf. + Poses with a trailing dimension of 7 (dtype wp.transformf) are also accepted; their + orientation is ignored. body_mask: Body mask. If None, then all bodies are used. Shape is (num_bodies,). env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). """ # resolve masks env_mask = self._resolve_mask(env_mask, self._ALL_ENV_MASK) body_mask = self._resolve_mask(body_mask, self._ALL_BODY_MASK) + coms = shared_kernels.com_positions(coms) self.assert_shape_and_dtype_mask(coms, (env_mask, body_mask), wp.vec3f, "coms") has_body_ordering = self.data.has_body_ordering ordering_kernels.write_2d_user_to_backend_with_mask( diff --git a/source/isaaclab_newton/isaaclab_newton/assets/kernels.py b/source/isaaclab_newton/isaaclab_newton/assets/kernels.py index 7155c01199ba..d081992217cc 100644 --- a/source/isaaclab_newton/isaaclab_newton/assets/kernels.py +++ b/source/isaaclab_newton/isaaclab_newton/assets/kernels.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any +import torch import warp as wp from isaaclab.utils.warp.index_kernel import IndexKernelDispatcher @@ -1556,3 +1557,23 @@ def update_wrench_array_with_force_and_torque( torques[env_index, body_index], body_rot_w, ) + + +def com_positions(coms: float | torch.Tensor | wp.array) -> float | torch.Tensor | wp.array: + """Return center of mass positions, dropping the orientation when poses are given. + + Newton models a body's center of mass as a position, while the asset API also accepts poses + (position and quaternion (x, y, z, w)) like the other backends. The orientation is ignored. + + Args: + coms: Center of mass positions [m] with a trailing dimension of 3 (or dtype ``wp.vec3f``), or poses with a + trailing dimension of 7 (or dtype ``wp.transformf``). + + Returns: + The center of mass positions [m]; inputs that are already positions are returned unchanged. + """ + if isinstance(coms, wp.array) and coms.dtype == wp.transformf: + coms = wp.to_torch(coms) + if isinstance(coms, torch.Tensor) and coms.shape[-1] == 7: + return coms[..., :3].contiguous() + return coms diff --git a/source/isaaclab_newton/isaaclab_newton/assets/rigid_object/rigid_object.py b/source/isaaclab_newton/isaaclab_newton/assets/rigid_object/rigid_object.py index eb4cf3ad5b21..ddd6f4791945 100644 --- a/source/isaaclab_newton/isaaclab_newton/assets/rigid_object/rigid_object.py +++ b/source/isaaclab_newton/isaaclab_newton/assets/rigid_object/rigid_object.py @@ -882,12 +882,15 @@ def set_coms_index( Args: coms: Center of mass position of all bodies. Shape is (len(env_ids), len(body_ids), 3). + Poses with a trailing dimension of 7 (dtype wp.transformf) are also accepted; their + orientation is ignored. body_ids: The body indices to set the center of mass pose for. Defaults to None (all bodies). env_ids: The environment indices to set the center of mass pose for. Defaults to None (all environments). """ # resolve all indices env_ids = self._resolve_env_ids(env_ids) body_ids = self._resolve_body_ids(body_ids) + coms = shared_kernels.com_positions(coms) self.assert_shape_and_dtype(coms, (env_ids.shape[0], body_ids.shape[0]), wp.vec3f, "coms") # Warp kernels can ingest torch tensors directly, so we don't need to convert to warp arrays here. wp.launch( @@ -930,6 +933,8 @@ def set_coms_mask( Args: coms: Center of mass position of all bodies. Shape is (num_instances, num_bodies, 3). + Poses with a trailing dimension of 7 (dtype wp.transformf) are also accepted; their + orientation is ignored. body_mask: Body mask. If None, then all bodies are used. env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). """ @@ -938,6 +943,7 @@ def set_coms_mask( env_mask = self._ALL_ENV_MASK if body_mask is None: body_mask = self._ALL_BODY_MASK + coms = shared_kernels.com_positions(coms) self.assert_shape_and_dtype_mask(coms, (env_mask, body_mask), wp.vec3f, "coms") wp.launch( shared_kernels.write_body_com_position_to_buffer_mask, diff --git a/source/isaaclab_newton/isaaclab_newton/assets/rigid_object_collection/rigid_object_collection.py b/source/isaaclab_newton/isaaclab_newton/assets/rigid_object_collection/rigid_object_collection.py index c52ae05bc56d..1a1bcfd54407 100644 --- a/source/isaaclab_newton/isaaclab_newton/assets/rigid_object_collection/rigid_object_collection.py +++ b/source/isaaclab_newton/isaaclab_newton/assets/rigid_object_collection/rigid_object_collection.py @@ -975,12 +975,15 @@ def set_coms_index( Args: coms: Center of mass position of all bodies. Shape is (len(env_ids), len(body_ids), 3). + Poses with a trailing dimension of 7 (dtype wp.transformf) are also accepted; their + orientation is ignored. body_ids: The body indices to set the center of mass pose for. Defaults to None (all bodies). env_ids: The environment indices to set the center of mass pose for. Defaults to None (all environments). """ # resolve all indices env_ids = self._resolve_env_ids(env_ids) body_ids = self._resolve_body_ids(body_ids) + coms = shared_kernels.com_positions(coms) self.assert_shape_and_dtype(coms, (env_ids.shape[0], body_ids.shape[0]), wp.vec3f, "coms") # Write to consolidated buffer wp.launch( @@ -1023,6 +1026,8 @@ def set_coms_mask( Args: coms: Center of mass position of all bodies. Shape is (num_instances, num_bodies, 3). + Poses with a trailing dimension of 7 (dtype wp.transformf) are also accepted; their + orientation is ignored. body_mask: Body mask. If None, then all bodies are used. env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). """ @@ -1031,6 +1036,7 @@ def set_coms_mask( env_mask = self._ALL_ENV_MASK if body_mask is None: body_mask = self._ALL_BODY_MASK + coms = shared_kernels.com_positions(coms) self.assert_shape_and_dtype_mask(coms, (env_mask, body_mask), wp.vec3f, "coms") wp.launch( shared_kernels.write_body_com_position_to_buffer_mask, diff --git a/source/isaaclab_newton/isaaclab_newton/physics/featherstone_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/featherstone_manager.py index 1f8f04eff319..56aac60d7021 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/featherstone_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/featherstone_manager.py @@ -7,7 +7,7 @@ from __future__ import annotations -from newton import Model +from newton import Model, ModelFlags from newton.solvers import SolverFeatherstone from .featherstone_manager_cfg import FeatherstoneSolverCfg @@ -36,3 +36,10 @@ def _build_solver(cls, model: Model, solver_cfg: FeatherstoneSolverCfg) -> None: NewtonManager._use_single_state = False NewtonManager._needs_collision_pipeline = True NewtonManager._supports_rigid_body_force_input = True + # SolverFeatherstone derives its inertia data from the model only when it is constructed + NewtonManager._ignored_model_changes = { + ModelFlags.BODY_INERTIAL_PROPERTIES: ( + "The Newton Featherstone solver does not apply mass, center of mass, or inertia changes made after" + " the simulation starts; the simulation keeps the initial values." + ) + } diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index 25cb56d5dca0..1dbf20be0b5d 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -461,6 +461,9 @@ class NewtonManager(PhysicsManager): # Model changes (callbacks use unified system from PhysicsManager) _model_changes: set[int] = set() + # Model changes the active solver does not apply, mapped to the reason; each is warned about once + _ignored_model_changes: dict[int, str] = {} + _warned_model_changes: set[int] = set() # Scene data backend _scene_data_backend: NewtonSceneDataBackend | None = None @@ -709,6 +712,9 @@ def step(cls) -> None: if cls._model_changes: with wp.ScopedDevice(PhysicsManager._device): for change in cls._model_changes: + if change in cls._ignored_model_changes and change not in cls._warned_model_changes: + logger.warning(cls._ignored_model_changes[change]) + cls._warned_model_changes.add(change) cls._solver.notify_model_changed(change) NewtonManager._model_changes = set() @@ -828,6 +834,8 @@ def clear(cls): NewtonManager._supports_rigid_body_force_input = False NewtonManager._contacts = None NewtonManager._needs_collision_pipeline = False + NewtonManager._ignored_model_changes = {} + NewtonManager._warned_model_changes = set() NewtonManager._deterministic_mode = wp.DeterministicMode.NOT_GUARANTEED NewtonManager._eval_fk = _eval_fk_unbound NewtonManager._reset_solver_internals_delegate = _reset_solver_internals_unbound diff --git a/source/isaaclab_newton/test/assets/test_articulation.py b/source/isaaclab_newton/test/assets/test_articulation.py index 8f10e66d5f74..02575521726c 100644 --- a/source/isaaclab_newton/test/assets/test_articulation.py +++ b/source/isaaclab_newton/test/assets/test_articulation.py @@ -3693,6 +3693,12 @@ def test_randomize_rigid_body_com(sim, num_articulations, device, add_ground_pla updated_com = articulation.data.body_com_pos_b.torch torch.testing.assert_close(updated_com, new_com, atol=1e-5, rtol=1e-5) + # poses (position and quaternion) are accepted too, like on the other backends; the orientation is ignored + com_poses = articulation.data.body_com_pose_b.torch.clone() + com_poses[..., :3] = original_com + articulation.set_coms_index(coms=com_poses, env_ids=env_ids) + torch.testing.assert_close(articulation.data.body_com_pos_b.torch, original_com, atol=1e-5, rtol=1e-5) + @pytest.mark.parametrize("num_articulations", [2]) @pytest.mark.parametrize("device", test_devices(DeviceScope.CUDA)) diff --git a/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py b/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py index e9a9118bd2b8..c5cf56892cd8 100644 --- a/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py +++ b/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py @@ -68,7 +68,7 @@ ) from isaaclab_newton.physics.mpm_manager import _make_solver_config from isaaclab_newton.renderers.newton_warp_renderer import NewtonWarpRenderer -from newton import JointTargetMode, JointType, ModelBuilder, ShapeFlags +from newton import JointTargetMode, JointType, ModelBuilder, ModelFlags, ShapeFlags from newton.selection import ArticulationView from newton.solvers import SolverFeatherstone, SolverImplicitMPM, SolverKamino, SolverMuJoCo, SolverVBD, SolverXPBD @@ -1564,6 +1564,9 @@ def test_initialize_solver_populates_canonical_state( assert NewtonManager._use_single_state is expected_use_single_state assert NewtonManager._needs_collision_pipeline is expected_needs_collision_pipeline assert NewtonManager._supports_rigid_body_force_input is expected_supports_force_input + # only Featherstone ignores inertial property changes after construction, and warns about it + ignores_inertia = ModelFlags.BODY_INERTIAL_PROPERTIES in NewtonManager._ignored_model_changes + assert ignores_inertia is (expected_manager is NewtonFeatherstoneManager) assert NewtonManager._reset_solver_internals_delegate.__self__ is expected_manager assert ( NewtonManager._reset_solver_internals_delegate.__func__ is expected_manager._reset_solver_internals.__func__ diff --git a/source/isaaclab_rl/changelog.d/core-cleanup-backend-routing.skip b/source/isaaclab_rl/changelog.d/core-cleanup-backend-routing.skip new file mode 100644 index 000000000000..ee7929993b62 --- /dev/null +++ b/source/isaaclab_rl/changelog.d/core-cleanup-backend-routing.skip @@ -0,0 +1 @@ +Documented that OVPhysX configs resolve to the PhysX pretrained checkpoints. diff --git a/source/isaaclab_rl/isaaclab_rl/utils/pretrained_checkpoint.py b/source/isaaclab_rl/isaaclab_rl/utils/pretrained_checkpoint.py index d64c31ff9f80..0ffbccb9b1b0 100644 --- a/source/isaaclab_rl/isaaclab_rl/utils/pretrained_checkpoint.py +++ b/source/isaaclab_rl/isaaclab_rl/utils/pretrained_checkpoint.py @@ -459,6 +459,7 @@ def _get_physics_backend_name(physics_cfg: PhysicsCfg | None) -> str: if solver_name is None: raise ValueError(f"Unsupported Newton solver for pretrained checkpoints: {type(solver_cfg).__name__}") return f"newton{solver_name}" + # OVPhysX configs match too: they run the same PhysX engine, so they share the PhysX checkpoints if "physx" in type_path: return "physx" raise ValueError(f"Unable to identify physics backend from {type(physics_cfg).__name__}") diff --git a/source/isaaclab_tasks/changelog.d/core-cleanup-backend-routing.rst b/source/isaaclab_tasks/changelog.d/core-cleanup-backend-routing.rst new file mode 100644 index 000000000000..d47006671985 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/core-cleanup-backend-routing.rst @@ -0,0 +1,6 @@ +Fixed +^^^^^ + +* Removed the redundant backend-specific Jacobian refresh from the NIST + ``reset_end_effector_around_asset`` event. Articulation data refreshes forward kinematics on demand + after joint writes, avoiding access to ``root_physx_view`` on OVPhysX. diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/nist/mdp/events.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/nist/mdp/events.py index ca8318a15e7c..4e33829d1c1f 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/nist/mdp/events.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/nist/mdp/events.py @@ -237,7 +237,6 @@ def __init__(self, cfg: EventTermCfg, env: ManagerBasedRLEnv): ) self.solver: DifferentialInverseKinematicsAction = None # type: ignore self.grasp_angle_range = (0.3, 0.7) - self.is_physx = "physx" in env.sim.physics_manager.__name__.lower() def __call__( self, @@ -284,5 +283,3 @@ def __call__( joint_ids=self.joint_ids, env_ids=env_ids, # type: ignore ) - if self.is_physx: - self.robot.root_physx_view.get_jacobians() From a2cca93d19e09db135189ad0d7dabd690a9a16ce Mon Sep 17 00:00:00 2001 From: Mustafa H <34825877+StafaH@users.noreply.github.com> Date: Fri, 25 Sep 2026 01:38:51 -0700 Subject: [PATCH 07/10] [Tests] Prune extension package tests with the test-audit skill (#8019) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Extension package test jobs: 27:03 → 20:58 (−22%) Each package job runs through `pytest tools` on the same machine with a warm Warp cache, once on `develop` and once on this PR. The same files fail on both sides, only from optional dependencies missing in the timing environment (`pytetwild`, viser/rerun, OpenXR). Those files pass with the extras installed. | Package job | `develop` | this PR | Test functions | |---|---:|---:|---:| | `isaaclab_rl` | 7:28 | 6:46 | 168 → 146 | | `isaaclab_mimic` | 8:19 | **4:08** | 20 → 15 | | `isaaclab_visualizers` | 5:23 | 4:58 | 93 → 75 | | `isaaclab_assets` | 1:47 | 1:27 | 2 → 2 | | `isaaclab_contrib` | 1:44 | 1:31 | 143 → 102 | | `isaaclab_teleop` | 1:40 | 1:29 | 250 → 186 | | `isaaclab_experimental` | 0:42 | 0:39 | 182 → 109 | | `isaaclab_ppisp` (not in CI) | – | – | 24 → 20 | | **Total** | **27:03** | **20:58** | **882 → 655** | Test lines: 27,724 → 24,181. The only change outside test files: the tiled Newton visualizer file was merged into `test_visualizer_integration_newton.py`, so its retry entry in `tools/conftest.py` was dropped and a flaky mark was carried into the merged file. ## What changed Pruned with the `isaaclab-auditing-tests` skill (#8007), campaign mode, one commit per package. **Method.** Every test was marked keep, fix, merge or delete. Whenever a deletion's keeper does not carry the assertions verbatim, a deliberate production mutation proves the keeper fails. An independent review then compared deleted coverage against the keepers, and every gap it found was restored and mutation-proven: - the RSL-RL recurrent-config fields (`stochastic`, `init_noise_std`, `state_dependent_std`); - Warp graph-cache replay identity; - the tiled Newton visualizer retry. - **Merged:** repeated scene or env builds that checked overlapping properties, and parametrize axes that reach no distinct code path. - **Deleted:** mock self-tests, restated config values, import and inventory checks, and duplicate per-backend replays of backend-independent helpers. - **Fixed:** assertions that could not fail. ## Follow-ups - `isaaclab_ppisp` has no CI job in `build.yaml`. - The three byte-identical mimic dataset-generation files could become one parametrized file. ## Release backport - [x] Backport this pull request to the active release branch after it merges into `develop` ## Checklist - [x] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html) - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [x] My changes generate no new warnings - [x] I have added a changelog fragment under `source//changelog.d/` for every touched package - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Co-authored-by: Octi Zhang --- .../test-prune-extension-tests.skip | 1 + source/isaaclab_assets/test/test_gelsight.py | 17 +- .../test/test_valid_configs.py | 5 +- .../test-prune-extension-tests.skip | 1 + .../test/actuators/test_thruster.py | 190 ++++----- .../test/assets/test_multirotor.py | 301 +++---------- .../test_drone_geometric_controllers.py | 187 +------- .../test/coupling/test_coupler.py | 82 +--- .../test/custom_coupling/test_env_cfg.py | 15 +- .../test/custom_coupling/test_manager.py | 14 +- .../custom_coupling/test_manual_coupling.py | 112 +---- .../test/deformable/test_deformable_object.py | 327 ++++---------- .../test/rl/test_rlinf_extension.py | 251 ++--------- .../test/sensors/test_visuotactile_render.py | 27 +- .../test/sensors/test_visuotactile_sensor.py | 74 +--- .../test-prune-extension-tests.skip | 1 + .../test/envs/mdp/parity_helpers.py | 49 +-- .../test/envs/mdp/test_actions_warp_parity.py | 92 +--- .../test/envs/mdp/test_capture_safety.py | 29 +- .../test/envs/mdp/test_events_warp_parity.py | 151 ++----- .../envs/mdp/test_observations_warp_parity.py | 229 ++-------- .../test/envs/mdp/test_rewards_warp_parity.py | 362 ++++------------ .../envs/mdp/test_terminations_warp_parity.py | 189 +++------ .../test/envs/test_frontend.py | 162 +------ .../test/envs/test_frontend_cfg_conversion.py | 60 ++- .../test/utils/test_manager_call_switch.py | 136 +----- .../test/utils/test_warp_graph_cache.py | 62 +-- .../test-prune-extension-tests.skip | 1 + .../test_generate_dataset_franka_state.py | 8 - ...test_generate_dataset_franka_visuomotor.py | 7 - .../test_generate_dataset_gr1t2_nutpour.py | 7 - .../test_generate_dataset_gr1t2_pickplace.py | 7 - .../test/test_generation_failure_cap.py | 31 +- .../test/test_selection_strategy.py | 7 + .../test-prune-extension-tests.skip | 1 + source/isaaclab_ppisp/test/test_ppisp.py | 18 +- .../isaaclab_ppisp/test/test_ppisp_kernels.py | 121 +++--- .../test/test_ppisp_pipeline_perf.py | 400 ------------------ .../test-prune-extension-tests.skip | 1 + .../test/export/test_leapp_export_flow.py | 2 - .../test/export/test_leapp_recurrent_state.py | 18 - .../test/test_apply_video_recording.py | 2 - source/isaaclab_rl/test/test_entrypoints.py | 120 ++---- .../test/test_entrypoints_common.py | 95 ++--- .../test/test_rsl_rl_cfg_deprecation.py | 139 ++---- .../test/test_template_generator.py | 153 ++----- source/isaaclab_rl/test/test_wrappers.py | 42 +- .../test-prune-extension-tests.skip | 1 + .../test/test_cloudxr_lifecycle.py | 95 +---- .../test/test_control_events.py | 179 +------- .../test/test_hand_debug_viz.py | 21 +- .../test/test_host_client_messaging.py | 90 +--- .../test/test_openxr_device_constructors.py | 60 +-- .../isaaclab_teleop/test/test_oxr_device.py | 45 +- .../isaaclab_teleop/test/test_retargeters.py | 120 ++---- .../isaaclab_teleop/test/test_system_check.py | 53 +-- .../test/test_target_frame_rebase.py | 215 +++------- .../test/test_xr_camera_feed.py | 73 ++-- .../test/test_xr_camera_feed_kit_scene_ui.py | 16 +- .../test-prune-extension-tests.skip | 1 + .../franka_cloth/newton-kit-tiled.png | Bin 176928 -> 0 bytes .../franka_cloth/newton-kit-viewport.png | Bin 165259 -> 0 bytes .../franka_cloth/newton-newton-viewport.png | Bin 71134 -> 0 bytes .../test/test_kit_visual_material.py | 24 -- .../test/test_newton_adapter.py | 232 +++------- .../test/test_newton_visual_material.py | 9 +- .../test_newton_visualizer_viewer_release.py | 49 +-- .../test_visualizer_integration_newton.py | 1 + ...est_visualizer_tiled_integration_newton.py | 44 -- .../test/visualizer_golden_utils.py | 74 ---- .../test/visualizer_integration_utils.py | 216 +--------- tools/conftest.py | 1 - 72 files changed, 1189 insertions(+), 4736 deletions(-) create mode 100644 source/isaaclab_assets/changelog.d/test-prune-extension-tests.skip create mode 100644 source/isaaclab_contrib/changelog.d/test-prune-extension-tests.skip create mode 100644 source/isaaclab_experimental/changelog.d/test-prune-extension-tests.skip create mode 100644 source/isaaclab_mimic/changelog.d/test-prune-extension-tests.skip create mode 100644 source/isaaclab_ppisp/changelog.d/test-prune-extension-tests.skip delete mode 100644 source/isaaclab_ppisp/test/test_ppisp_pipeline_perf.py create mode 100644 source/isaaclab_rl/changelog.d/test-prune-extension-tests.skip create mode 100644 source/isaaclab_teleop/changelog.d/test-prune-extension-tests.skip create mode 100644 source/isaaclab_visualizers/changelog.d/test-prune-extension-tests.skip delete mode 100644 source/isaaclab_visualizers/test/golden_images/franka_cloth/newton-kit-tiled.png delete mode 100644 source/isaaclab_visualizers/test/golden_images/franka_cloth/newton-kit-viewport.png delete mode 100644 source/isaaclab_visualizers/test/golden_images/franka_cloth/newton-newton-viewport.png delete mode 100644 source/isaaclab_visualizers/test/test_kit_visual_material.py delete mode 100644 source/isaaclab_visualizers/test/test_visualizer_tiled_integration_newton.py diff --git a/source/isaaclab_assets/changelog.d/test-prune-extension-tests.skip b/source/isaaclab_assets/changelog.d/test-prune-extension-tests.skip new file mode 100644 index 000000000000..810e645fde4d --- /dev/null +++ b/source/isaaclab_assets/changelog.d/test-prune-extension-tests.skip @@ -0,0 +1 @@ +Dropped the CPU pass of the asset configuration test and checked that the GelSight Mini render data resolves. diff --git a/source/isaaclab_assets/test/test_gelsight.py b/source/isaaclab_assets/test/test_gelsight.py index 5b908cc336cf..d996956a25a0 100644 --- a/source/isaaclab_assets/test/test_gelsight.py +++ b/source/isaaclab_assets/test/test_gelsight.py @@ -5,11 +5,18 @@ """Tests for predefined GelSight sensor configurations.""" +import os + +import pytest + +from isaaclab.utils.assets import retrieve_file_path + from isaaclab_assets.sensors import GELSIGHT_MINI_CFG -def test_gelsight_mini_uses_available_background_path(): - """Verify the GelSight Mini background resolves to the available render data.""" - assert f"{GELSIGHT_MINI_CFG.sensor_data_dir_name}/{GELSIGHT_MINI_CFG.background_path}" == ( - "gelsight_r15_data/bg.jpg" - ) +@pytest.mark.parametrize("file_name", [GELSIGHT_MINI_CFG.background_path, GELSIGHT_MINI_CFG.calib_path]) +def test_gelsight_mini_render_data_is_available(file_name, tmp_path): + """The GelSight Mini render data resolves to files on the asset server, as the renderer loads them.""" + remote_path = os.path.join(GELSIGHT_MINI_CFG.base_data_path, GELSIGHT_MINI_CFG.sensor_data_dir_name, file_name) + + assert os.path.isfile(retrieve_file_path(remote_path, download_dir=str(tmp_path))) diff --git a/source/isaaclab_assets/test/test_valid_configs.py b/source/isaaclab_assets/test/test_valid_configs.py index acd68b260e54..12a5941e94e9 100644 --- a/source/isaaclab_assets/test/test_valid_configs.py +++ b/source/isaaclab_assets/test/test_valid_configs.py @@ -22,6 +22,7 @@ from isaaclab.assets import AssetBase, AssetBaseCfg from isaaclab.sim import build_simulation_context +from isaaclab.test.utils import DeviceScope, test_devices import isaaclab_assets as lab_assets # noqa: F401 @@ -41,8 +42,8 @@ def registered_entities(): return registered_entities -# Add parameterization for the device -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +# config validity (USD paths, joint/body names, actuator patterns) does not depend on the device +@pytest.mark.parametrize("device", test_devices(DeviceScope.DEFAULT_CUDA)) def test_asset_configs(registered_entities, device): """Check all registered asset configurations.""" # iterate over all registered assets diff --git a/source/isaaclab_contrib/changelog.d/test-prune-extension-tests.skip b/source/isaaclab_contrib/changelog.d/test-prune-extension-tests.skip new file mode 100644 index 000000000000..41c60576ea1b --- /dev/null +++ b/source/isaaclab_contrib/changelog.d/test-prune-extension-tests.skip @@ -0,0 +1 @@ +Pruned redundant isaaclab_contrib tests and repaired weak assertions in a test-audit campaign. diff --git a/source/isaaclab_contrib/test/actuators/test_thruster.py b/source/isaaclab_contrib/test/actuators/test_thruster.py index 322005954737..6ef2ece5605d 100644 --- a/source/isaaclab_contrib/test/actuators/test_thruster.py +++ b/source/isaaclab_contrib/test/actuators/test_thruster.py @@ -12,11 +12,17 @@ """Rest of imports follows""" +import math from types import SimpleNamespace import pytest import torch +# The thruster model is pure torch bookkeeping; env/motor counts and device select no distinct branch. +NUM_ENVS = 2 +NUM_MOTORS = 4 +DEVICE = "cpu" + def make_thruster_cfg(num_motors: int): """Create a minimal Thruster-like config object for tests.""" @@ -35,23 +41,20 @@ def make_thruster_cfg(num_motors: int): ) -@pytest.mark.parametrize("num_envs", [1, 2, 4]) -@pytest.mark.parametrize("num_motors", [1, 2, 4]) -@pytest.mark.parametrize("device", ["cpu", "cuda"]) -def test_zero_thrust_const_is_handled(num_envs, num_motors, device): +def test_zero_thrust_const_is_handled(): """When thrust_const_range contains zeros, Thruster clamps values and compute returns finite outputs.""" from isaaclab_contrib.actuators import Thruster - cfg = make_thruster_cfg(num_motors) + cfg = make_thruster_cfg(NUM_MOTORS) cfg.thrust_const_range = (0.0, 0.0) - thruster_names = [f"t{i}" for i in range(num_motors)] + thruster_names = [f"t{i}" for i in range(NUM_MOTORS)] thruster_ids = slice(None) - init_rps = torch.ones(num_envs, num_motors, device=device) + init_rps = torch.ones(NUM_ENVS, NUM_MOTORS, device=DEVICE) - thr = Thruster(cfg, thruster_names, thruster_ids, num_envs, device, init_rps) # type: ignore[arg-type] + thr = Thruster(cfg, thruster_names, thruster_ids, NUM_ENVS, DEVICE, init_rps) # type: ignore[arg-type] - command = torch.full((num_envs, num_motors), 1.0, device=device) + command = torch.full((NUM_ENVS, NUM_MOTORS), 1.0, device=DEVICE) action = SimpleNamespace(thrusts=command.clone(), thruster_indices=thruster_ids) thr.compute(action) # type: ignore[arg-type] @@ -59,24 +62,21 @@ def test_zero_thrust_const_is_handled(num_envs, num_motors, device): assert torch.isfinite(action.thrusts).all() -@pytest.mark.parametrize("num_envs", [1, 2, 4]) -@pytest.mark.parametrize("num_motors", [1, 2, 4]) -@pytest.mark.parametrize("device", ["cpu", "cuda"]) -def test_negative_thrust_range_results_finite(num_envs, num_motors, device): +def test_negative_thrust_range_results_finite(): """Negative configured thrust ranges are clamped and yield finite outputs after hardening.""" from isaaclab_contrib.actuators import Thruster - cfg = make_thruster_cfg(num_motors) + cfg = make_thruster_cfg(NUM_MOTORS) cfg.thrust_range = (-5.0, -1.0) cfg.thrust_const_range = (0.05, 0.05) - thruster_names = [f"t{i}" for i in range(num_motors)] + thruster_names = [f"t{i}" for i in range(NUM_MOTORS)] thruster_ids = slice(None) - init_rps = torch.ones(num_envs, num_motors, device=device) + init_rps = torch.ones(NUM_ENVS, NUM_MOTORS, device=DEVICE) - thr = Thruster(cfg, thruster_names, thruster_ids, num_envs, device, init_rps) # type: ignore[arg-type] + thr = Thruster(cfg, thruster_names, thruster_ids, NUM_ENVS, DEVICE, init_rps) # type: ignore[arg-type] - command = torch.full((num_envs, num_motors), -2.0, device=device) + command = torch.full((NUM_ENVS, NUM_MOTORS), -2.0, device=DEVICE) action = SimpleNamespace(thrusts=command.clone(), thruster_indices=thruster_ids) thr.compute(action) # type: ignore[arg-type] @@ -84,121 +84,89 @@ def test_negative_thrust_range_results_finite(num_envs, num_motors, device): assert torch.isfinite(action.thrusts).all() -@pytest.mark.parametrize("num_envs", [2, 3, 4]) -@pytest.mark.parametrize("num_motors", [2, 4]) -@pytest.mark.parametrize("device", ["cpu", "cuda"]) -def test_tensor_vs_slice_indices_and_subset_reset(num_envs, num_motors, device): - """Compute should accept tensor or slice thruster indices, and reset_idx should affect only specified envs.""" +def test_reset_idx_resamples_only_selected_envs(): + """reset_idx re-samples parameters and re-initializes thrust only for the selected env.""" from isaaclab_contrib.actuators import Thruster - cfg = make_thruster_cfg(num_motors) - - thruster_names = [f"t{i}" for i in range(num_motors)] - # Use motor indices that exist for the given num_motors - motor_indices = [0, min(2, num_motors - 1)] - thruster_ids_tensor = torch.tensor(motor_indices, dtype=torch.int64, device=device) - thruster_ids_slice = slice(None) - init_rps = torch.ones(num_envs, num_motors, device=device) - - thr_tensor = Thruster(cfg, thruster_names, thruster_ids_tensor, num_envs, device, init_rps) # type: ignore[arg-type] - thr_slice = Thruster(cfg, thruster_names, thruster_ids_slice, num_envs, device, init_rps) # type: ignore[arg-type] + num_envs = 3 + cfg = make_thruster_cfg(NUM_MOTORS) + thruster_names = [f"t{i}" for i in range(NUM_MOTORS)] + init_rps = torch.arange(1, num_envs + 1, dtype=torch.float32, device=DEVICE)[:, None].repeat(1, NUM_MOTORS) - command = torch.full((num_envs, num_motors), cfg.thrust_range[1] * 0.5, device=device) - action_tensor = SimpleNamespace(thrusts=command.clone(), thruster_indices=thruster_ids_tensor) - action_slice = SimpleNamespace(thrusts=command.clone(), thruster_indices=thruster_ids_slice) + thr = Thruster(cfg, thruster_names, slice(None), num_envs, DEVICE, init_rps) # type: ignore[arg-type] - thr_tensor.compute(action_tensor) # type: ignore[arg-type] - thr_slice.compute(action_slice) # type: ignore[arg-type] + # Move the thrust state away from its initial value so re-initialization is observable. + command = torch.full((num_envs, NUM_MOTORS), cfg.thrust_range[1] * 0.5, device=DEVICE) + thr.compute(SimpleNamespace(thrusts=command, thruster_indices=slice(None))) # type: ignore[arg-type] + # Mutate a sampled parameter so re-sampling produces a measurable change. + thr.tau_inc_s[0, 0] = thr.tau_inc_s[0, 0] + 1.0 + prev_val = thr.tau_inc_s[0, 0].item() + prev_thrust = thr.curr_thrust.clone() + prev_tau_inc = thr.tau_inc_s.clone() - assert action_tensor.thrusts.shape == (num_envs, num_motors) - assert action_slice.thrusts.shape == (num_envs, num_motors) + thr.reset_idx(torch.tensor([0], dtype=torch.int64, device=DEVICE)) - # Test reset on the last environment - env_to_reset = num_envs - 1 - prev = thr_tensor.curr_thrust.clone() - thr_tensor.reset_idx(torch.tensor([env_to_reset], dtype=torch.int64, device=device)) - assert not torch.allclose(prev[env_to_reset], thr_tensor.curr_thrust[env_to_reset]) + assert not torch.isclose(torch.tensor(prev_val, device=DEVICE), thr.tau_inc_s[0, 0]) + torch.testing.assert_close(thr.curr_thrust[0], thr.thrust_const[0] * init_rps[0] ** 2) + torch.testing.assert_close(thr.curr_thrust[1:], prev_thrust[1:]) + torch.testing.assert_close(thr.tau_inc_s[1:], prev_tau_inc[1:]) -@pytest.mark.parametrize("num_envs", [1, 2, 4]) -@pytest.mark.parametrize("num_motors", [1, 2, 4]) -@pytest.mark.parametrize("device", ["cpu", "cuda"]) -def test_mixing_and_integration_modes(num_envs, num_motors, device): - """Verify mixing factor selection and integration kernel choice reflect the config.""" +@pytest.mark.parametrize(("use_discrete_approximation", "integration_scheme"), [(True, "euler"), (False, "rk4")]) +def test_mixing_and_integration_modes(use_discrete_approximation, integration_scheme): + """One compute step matches a hand-derived reference for each mixing and integration mode.""" from isaaclab_contrib.actuators import Thruster - cfg = make_thruster_cfg(num_motors) - - thruster_names = [f"t{i}" for i in range(num_motors)] - - # discrete mixing - cfg.use_discrete_approximation = True - cfg.integration_scheme = "euler" - thr_d = Thruster( - cfg, thruster_names, slice(None), num_envs, device, torch.ones(num_envs, num_motors, device=device) - ) # type: ignore[arg-type] - # bound method objects are recreated on access; compare underlying functions instead - assert getattr(thr_d.mixing_factor_function, "__func__", None) is Thruster.discrete_mixing_factor - assert getattr(thr_d._step_thrust, "__func__", None) is Thruster.compute_thrust_with_rpm_time_constant - - # continuous mixing and RK4 - cfg.use_discrete_approximation = False - cfg.integration_scheme = "rk4" - thr_c = Thruster( - cfg, thruster_names, slice(None), num_envs, device, torch.ones(num_envs, num_motors, device=device) - ) # type: ignore[arg-type] - assert getattr(thr_c.mixing_factor_function, "__func__", None) is Thruster.continuous_mixing_factor - assert getattr(thr_c._step_thrust, "__func__", None) is Thruster.compute_thrust_with_rpm_time_constant_rk4 - - -@pytest.mark.parametrize("num_envs", [1, 2, 4]) -@pytest.mark.parametrize("num_motors", [1, 2, 4]) -@pytest.mark.parametrize("device", ["cpu", "cuda"]) -def test_thruster_compute_clamps_and_shapes(num_envs, num_motors, device): + cfg = make_thruster_cfg(NUM_MOTORS) + cfg.use_discrete_approximation = use_discrete_approximation + cfg.integration_scheme = integration_scheme + cfg.max_thrust_rate = 1.0e6 # keep the rate clamp inactive + cfg.thrust_const_range = (0.1, 0.1) + cfg.tau_inc_range = (0.02, 0.02) + cfg.tau_dec_range = (0.02, 0.02) + thruster_names = [f"t{i}" for i in range(NUM_MOTORS)] + init_rps = torch.ones(NUM_ENVS, NUM_MOTORS, device=DEVICE) + + thr = Thruster(cfg, thruster_names, slice(None), NUM_ENVS, DEVICE, init_rps) # type: ignore[arg-type] + command = torch.full((NUM_ENVS, NUM_MOTORS), 2.5, device=DEVICE) + out = thr.compute(SimpleNamespace(thrusts=command, thruster_indices=slice(None))) # type: ignore[arg-type] + + # rpm error decays as d(rpm)/dt = k * (rpm_des - rpm), with k = 1/(dt + tau) (discrete) or 1/tau (continuous). + rpm, rpm_des = 1.0, 5.0 # sqrt(0.1 / 0.1), sqrt(2.5 / 0.1) + k = 1.0 / (0.01 + 0.02) if use_discrete_approximation else 1.0 / 0.02 + h = 0.01 * k + if integration_scheme == "euler": + gain = h + else: + # RK4 on a linear ODE reproduces the 4th-order Taylor expansion of exp(-h). + gain = 1.0 - sum((-h) ** n / math.factorial(n) for n in range(5)) + expected = 0.1 * (rpm + gain * (rpm_des - rpm)) ** 2 + torch.testing.assert_close(out.thrusts, torch.full_like(out.thrusts, expected)) + + cfg.integration_scheme = "bad" + with pytest.raises(ValueError, match="integration scheme unknown"): + Thruster(cfg, thruster_names, slice(None), NUM_ENVS, DEVICE, init_rps) # type: ignore[arg-type] + + +def test_thruster_compute_clamps_and_shapes(): """Thruster.compute should return thrusts with correct shape and within clamp bounds.""" from isaaclab_contrib.actuators import Thruster - cfg = make_thruster_cfg(num_motors) + cfg = make_thruster_cfg(NUM_MOTORS) - thruster_names = [f"t{i}" for i in range(num_motors)] + thruster_names = [f"t{i}" for i in range(NUM_MOTORS)] thruster_ids = slice(None) - init_rps = torch.ones(num_envs, num_motors, device=device) + init_rps = torch.ones(NUM_ENVS, NUM_MOTORS, device=DEVICE) - thr = Thruster(cfg, thruster_names, thruster_ids, num_envs, device, init_rps) # type: ignore[arg-type] + thr = Thruster(cfg, thruster_names, thruster_ids, NUM_ENVS, DEVICE, init_rps) # type: ignore[arg-type] # command above max to check clamping - command = torch.full((num_envs, num_motors), cfg.thrust_range[1] * 2.0, device=device) + command = torch.full((NUM_ENVS, NUM_MOTORS), cfg.thrust_range[1] * 2.0, device=DEVICE) action = SimpleNamespace(thrusts=command.clone(), thruster_indices=thruster_ids) out = thr.compute(action) # type: ignore[arg-type] - assert out.thrusts.shape == (num_envs, num_motors) + assert out.thrusts.shape == (NUM_ENVS, NUM_MOTORS) # values must be clipped to configured range assert torch.all(out.thrusts <= cfg.thrust_range[1] + 1e-6) assert torch.all(out.thrusts >= cfg.thrust_range[0] - 1e-6) - - -@pytest.mark.parametrize("num_envs", [1, 2, 4]) -@pytest.mark.parametrize("num_motors", [1, 2, 4]) -@pytest.mark.parametrize("device", ["cpu", "cuda"]) -def test_thruster_reset_idx_changes_state(num_envs, num_motors, device): - """reset_idx should re-sample parameters for specific env indices.""" - from isaaclab_contrib.actuators import Thruster - - cfg = make_thruster_cfg(num_motors) - - thruster_names = [f"t{i}" for i in range(num_motors)] - thruster_ids = slice(None) - init_rps = torch.ones(num_envs, num_motors, device=device) - - thr = Thruster(cfg, thruster_names, thruster_ids, num_envs, device, init_rps) # type: ignore[arg-type] - - # Mutate an internal sampled parameter so reset produces a measurable change. - thr.tau_inc_s[0, 0] = thr.tau_inc_s[0, 0] + 1.0 - prev_val = thr.tau_inc_s[0, 0].item() - - # reset only environment 0 - thr.reset_idx(torch.tensor([0], dtype=torch.int64, device=device)) - - # at least the first tau_inc value for env 0 should differ from the mutated value - assert not torch.isclose(torch.tensor(prev_val, device=device), thr.tau_inc_s[0, 0]) diff --git a/source/isaaclab_contrib/test/assets/test_multirotor.py b/source/isaaclab_contrib/test/assets/test_multirotor.py index 0d737aafcd47..9753e7c4f152 100644 --- a/source/isaaclab_contrib/test/assets/test_multirotor.py +++ b/source/isaaclab_contrib/test/assets/test_multirotor.py @@ -17,16 +17,15 @@ """Rest everything follows.""" -import contextlib import types import warnings import pytest import torch +from isaaclab_physx.physics import PhysxCfg -import isaaclab.sim as sim_utils import isaaclab.sim.utils.prims as prim_utils -from isaaclab.sim import build_simulation_context +from isaaclab.sim import SimulationCfg, build_simulation_context from isaaclab_contrib.assets import Multirotor, MultirotorCfg @@ -41,23 +40,6 @@ ## from isaaclab_assets.robots.arl_robot_1 import ARL_ROBOT_1_CFG - -def generate_multirotor_cfg(usd_path: str | None = None) -> MultirotorCfg: - """Generate a multirotor configuration for tests. - - If an ARL-provided config is available, prefer that. Otherwise return a - minimal `MultirotorCfg` so integration tests can still run when a USD is - provided. - """ - if ARL_ROBOT_1_CFG is not None: - return ARL_ROBOT_1_CFG - - if usd_path is None: - return MultirotorCfg() - - return MultirotorCfg(spawn=sim_utils.UsdFileCfg(usd_path=usd_path)) - - # ----------------------- # Unit tests (simulator-free) # ----------------------- @@ -109,34 +91,26 @@ def make_multirotor_stub(num_instances: int, num_thrusters: int, device=torch.de return m -@pytest.mark.parametrize("num_instances", [1, 2, 4]) -@pytest.mark.parametrize("num_thrusters", [1, 2, 4]) -@pytest.mark.parametrize("device", ["cpu", "cuda"]) -def test_multirotor_combine_thrusts_unit(num_instances, num_thrusters, device): - m = make_multirotor_stub(num_instances=num_instances, num_thrusters=num_thrusters, device=torch.device(device)) +def test_multirotor_combine_thrusts_unit(): + # Allocation matmul and the force/torque split select no branch on instance/thruster counts or device. + num_instances, num_thrusters = 2, 4 + m = make_multirotor_stub(num_instances=num_instances, num_thrusters=num_thrusters) # Create thrust target with predictable values - thrust_values = torch.arange(1.0, num_instances * num_thrusters + 1.0, device=device).reshape( - num_instances, num_thrusters - ) + thrust_values = torch.arange(1.0, num_instances * num_thrusters + 1.0).reshape(num_instances, num_thrusters) m._thrust_target_sim = thrust_values - # allocation maps first two thrusters to force x and y, rest to zero - # Create allocation matrix: 6 rows (wrench dims) x num_thrusters cols + # Signed entries so a sign-dropping allocation bug changes the wrench. alloc = [ - [ - 1.0 if r == 0 and c == 0 else 0.0 if c >= 2 else (1.0 if r == 1 and c == 1 else 0.0) - for c in range(num_thrusters) - ] - for r in range(6) + [float((r + 1) * (c + 1)) * (-1.0 if (r + c) % 2 else 1.0) for c in range(num_thrusters)] for r in range(6) ] m.cfg.allocation_matrix = alloc - m.allocation_matrix = torch.tensor(alloc, device=device) + m.allocation_matrix = torch.tensor(alloc) m._combine_thrusts() # Expected wrench: thrust @ allocation.T - alloc_t = torch.tensor(alloc, device=device) + alloc_t = torch.tensor(alloc) expected = torch.matmul(thrust_values, alloc_t.T) assert torch.allclose(m._internal_wrench_target_sim, expected) @@ -144,33 +118,31 @@ def test_multirotor_combine_thrusts_unit(num_instances, num_thrusters, device): assert torch.allclose(m._internal_torque_target_sim[:, 0, :], expected[:, 3:]) -@pytest.mark.parametrize("num_instances", [1, 2, 4]) -@pytest.mark.parametrize("num_thrusters", [1, 2, 4]) -@pytest.mark.parametrize("device", ["cpu", "cuda"]) -def test_set_thrust_target_broadcasting_unit(num_instances, num_thrusters, device): - m = make_multirotor_stub(num_instances=num_instances, num_thrusters=num_thrusters, device=torch.device(device)) +def test_set_thrust_target_broadcasting_unit(): + num_instances, num_thrusters = 4, 4 + m = make_multirotor_stub(num_instances=num_instances, num_thrusters=num_thrusters) # Set full-row targets for env 0 - targets = torch.arange(1.0, num_thrusters + 1.0, device=device).unsqueeze(0) + targets = torch.arange(1.0, num_thrusters + 1.0).unsqueeze(0) m.set_thrust_target(targets, thruster_ids=slice(None), env_ids=slice(0, 1)) assert torch.allclose(m._data.thrust_target[0], targets[0]) # Set a column across all envs (use integer thruster id so broadcasting works) # Use the last thruster to avoid index out of bounds thruster_id = num_thrusters - 1 - column_values = torch.full((num_instances,), 9.0, device=device) + column_values = torch.full((num_instances,), 9.0) m.set_thrust_target(column_values, thruster_ids=thruster_id, env_ids=slice(None)) assert torch.allclose(m._data.thrust_target[:, thruster_id], column_values) - -def test_multirotor_data_annotations(): - from isaaclab_contrib.assets.multirotor.multirotor_data import MultirotorData - - # The class defines attributes for thruster state; the defaults should be None - md = MultirotorData.__new__(MultirotorData) - assert getattr(md, "default_thruster_rps", None) is None - assert getattr(md, "thrust_target", None) is None - assert getattr(md, "applied_thrust", None) is None + # Tensor env ids with a thruster id list broadcast to a (envs x thrusters) sub-block. + before = m._data.thrust_target.clone() + env_ids = torch.tensor([1, 3]) + thruster_ids = [0, 2] + block = torch.tensor([[5.0, 6.0], [7.0, 8.0]]) + m.set_thrust_target(block, thruster_ids=thruster_ids, env_ids=env_ids) + expected = before.clone() + expected[1, 0], expected[1, 2], expected[3, 0], expected[3, 2] = 5.0, 6.0, 7.0, 8.0 + assert torch.equal(m._data.thrust_target, expected) def test_set_thrust_target_env_slice_unit(): @@ -188,84 +160,6 @@ def test_set_thrust_target_env_slice_unit(): assert torch.allclose(m._data.thrust_target[3], original[3]) -def test_combine_thrusts_with_zero_allocation(): - """When allocation matrix is zero, combined wrench/force/torque are zero.""" - m = make_multirotor_stub(num_instances=2, num_thrusters=3) - - # zero allocation - zero_alloc = [[0.0 for _ in range(3)] for _ in range(6)] - m.cfg.allocation_matrix = zero_alloc - m.allocation_matrix = torch.zeros(6, 3, device=m.device) - - m._thrust_target_sim = torch.tensor([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], device=m.device) - m._combine_thrusts() - - assert torch.allclose(m._internal_wrench_target_sim, torch.zeros_like(m._internal_wrench_target_sim)) - assert torch.allclose(m._internal_force_target_sim, torch.zeros_like(m._internal_force_target_sim)) - assert torch.allclose(m._internal_torque_target_sim, torch.zeros_like(m._internal_torque_target_sim)) - - -def test_arl_cfg_structure_and_counts(): - """Validate the ARL robot config structure (or a safe fallback).""" - # Use the ARL-provided config if available, otherwise synthesize a - # lightweight fallback so this test never skips. - cfg = ARL_ROBOT_1_CFG - if cfg is None: - cfg = types.SimpleNamespace() - # default allocation: 4 thrusters - cfg.allocation_matrix = [[1.0 if r < 2 and c == r else 0.0 for c in range(4)] for r in range(6)] - cfg.actuators = types.SimpleNamespace(thrusters=types.SimpleNamespace(dt=0.01)) - - # allocation matrix must be present and be a list of 6 rows (wrench dims) - assert hasattr(cfg, "allocation_matrix") - alloc = cfg.allocation_matrix - assert alloc is None or isinstance(alloc, list) - if alloc is not None: - assert len(alloc) == 6, "Allocation matrix must have 6 rows (wrench dims)" - assert all(isinstance(r, (list, tuple)) for r in alloc) - num_thr = len(alloc[0]) if len(alloc) > 0 else 0 - assert num_thr > 0, "Allocation matrix must contain at least one thruster column" - - # If actuators metadata exists, it should expose thruster timing values - if hasattr(cfg, "actuators") and cfg.actuators is not None: - thr = getattr(cfg.actuators, "thrusters", None) - if thr is not None: - assert hasattr(thr, "dt") - - -def test_arl_allocation_applies_to_stub(): - """Create a stub with the ARL allocation matrix (or fallback) and verify - `_combine_thrusts` produces the expected internal wrench via matrix - multiplication. - """ - cfg = ARL_ROBOT_1_CFG - if cfg is None or getattr(cfg, "allocation_matrix", None) is None: - # fallback allocation: simple mapping for 4 thrusters - alloc = [[1.0 if r < 2 and c == r else 0.0 for c in range(4)] for r in range(6)] - else: - alloc = cfg.allocation_matrix - - num_thr = len(alloc[0]) - m = make_multirotor_stub(num_instances=2, num_thrusters=num_thr) - # push allocation into the stub (both cfg view and tensor view) - m.cfg.allocation_matrix = alloc - m.allocation_matrix = torch.tensor(alloc, device=m.device) - - # Set a predictable thrust pattern and compute expected wrench manually. - m._thrust_target_sim = torch.tensor([[1.0] * num_thr, [2.0] * num_thr], device=m.device) - m._combine_thrusts() - - # expected: thrusts (N x T) @ allocation.T (T x 6) -> (N x 6) - alloc_t = torch.tensor(alloc, device=m.device) - expected = torch.matmul(m._thrust_target_sim, alloc_t.T) - - assert expected.shape == m._internal_wrench_target_sim.shape - assert torch.allclose(m._internal_wrench_target_sim, expected) - # also check split into force/torque matches - assert torch.allclose(m._internal_force_target_sim[:, 0, :], expected[:, :3]) - assert torch.allclose(m._internal_torque_target_sim[:, 0, :], expected[:, 3:]) - - def generate_multirotor( multirotor_cfg: MultirotorCfg, num_multirotors: int, device: str ) -> tuple[Multirotor, torch.Tensor]: @@ -279,42 +173,18 @@ def generate_multirotor( for i in range(num_multirotors): prim_utils.create_prim(f"/World/Env_{i}", "Xform", translation=translations[i][:3]) - # Replace the prim_path like other tests do and try to spawn a real - # Multirotor. If creating a full Multirotor fails (missing cfg fields - # or simulator not available) fall back to the simulator-free stub so - # tests can still run and validate behavior without IsaacSim. - try: - multirotor = Multirotor(multirotor_cfg.replace(prim_path="/World/Env_[^/]*/Robot")) - return multirotor, translations - except Exception: - # Determine a reasonable number of thrusters for the stub from the - # cfg allocation matrix if provided, otherwise default to 4. - alloc = getattr(multirotor_cfg, "allocation_matrix", None) - num_thrusters = 4 - if isinstance(alloc, list) and len(alloc) > 0 and isinstance(alloc[0], (list, tuple)): - num_thrusters = len(alloc[0]) - - # Create a simulator-free multirotor stub bound to the same device. - stub = make_multirotor_stub(num_multirotors, num_thrusters, device=torch.device(device)) - return stub, translations + multirotor = Multirotor(multirotor_cfg.replace(prim_path="/World/Env_[^/]*/Robot")) + return multirotor, translations @pytest.fixture def sim(request): - """Create a simulation context for integration tests (app + sim). - - Uses `build_simulation_context` from the project utils so tests match - `test_articulation.py` behaviour. - """ + """Create a PhysX simulation context without gravity, matching the ARL drone tasks' actuator setup.""" device = request.getfixturevalue("device") if "device" in request.fixturenames else "cpu" - gravity_enabled = request.getfixturevalue("gravity_enabled") if "gravity_enabled" in request.fixturenames else True - add_ground_plane = ( - request.getfixturevalue("add_ground_plane") if "add_ground_plane" in request.fixturenames else False - ) + # Thruster actuators run in Isaac Lab, not as Newton-native actuators (as in the ARL drone tasks). + sim_cfg = SimulationCfg(dt=0.01, gravity=(0.0, 0.0, 0.0), physics=PhysxCfg(), use_newton_actuators=False) - with build_simulation_context( - device=device, auto_add_lighting=True, gravity_enabled=gravity_enabled, add_ground_plane=add_ground_plane - ) as sim: + with build_simulation_context(device=device, sim_cfg=sim_cfg, auto_add_lighting=True) as sim: sim._app_control_on_stop_handle = None yield sim @@ -323,93 +193,26 @@ def sim(request): @pytest.mark.parametrize("device", ["cpu"]) # restrict to cpu for CI without GPUs @pytest.mark.isaacsim_ci def test_multirotor_thruster_buffers_and_actuators(sim, num_multirotors, device): - """Check thruster buffers and actuator wiring in an integration environment. - - This test will be skipped automatically when `ARL_ROBOT_1_CFG` is not - available in the test environment (lightweight setups). - """ - cfg = generate_multirotor_cfg() - - # Try to create either a real multirotor or fall back to a stub; the - # generate_multirotor helper will return a stub when IsaacSim or a full - # cfg is not available so the test never skips. + """Thrust targets pass through the thruster actuators and lift the real multirotor.""" + cfg = ARL_ROBOT_1_CFG.replace( + actuators={"thrusters": ARL_ROBOT_1_CFG.actuators["thrusters"].replace(dt=sim.cfg.dt)} + ) multirotor, _ = generate_multirotor(cfg, num_multirotors, device=sim.device) - - # If we created a real multirotor, it should be initialized by the test - # scaffolding. If we got a stub, it won't have `is_initialized`. - if hasattr(multirotor, "is_initialized"): - sim.reset() - assert multirotor.is_initialized - - # If thruster buffers exist they should have the expected 2D shape - if hasattr(multirotor, "data") and getattr(multirotor.data, "thrust_target", None) is not None: - assert multirotor.data.thrust_target.ndim == 2 - - # Determine number of thrusters exposed by the asset or stub - try: - num_thr = multirotor.num_thrusters - except Exception: - # Stub exposes `_data` shape - num_thr = multirotor._data.thrust_target.shape[1] - - # Broadcast a simple thrust target and either step the sim (real) or - # emulate the actuator by combining thrusts on the stub. - multirotor.set_thrust_target(torch.ones(num_multirotors, num_thr, device=sim.device)) - if hasattr(multirotor, "update") and hasattr(sim, "step"): - for _ in range(3): - sim.step() - multirotor.update(sim.cfg.dt) - else: - # For the stub, emulate a single actuator update by combining thrusts - if hasattr(multirotor, "_combine_thrusts"): - multirotor._thrust_target_sim = multirotor._data.thrust_target.clone() - multirotor._combine_thrusts() - # set applied_thrust to computed_thrust to mimic an actuator - if hasattr(multirotor._data, "computed_thrust"): - multirotor._data.applied_thrust = multirotor._data.computed_thrust.clone() - - data_container = multirotor.data if hasattr(multirotor, "data") else multirotor._data - assert hasattr(data_container, "applied_thrust") - applied = data_container.applied_thrust + sim.reset() + assert multirotor.is_initialized + assert multirotor.data.thrust_target.ndim == 2 + + num_thr = multirotor.num_thrusters + thrust_range = ARL_ROBOT_1_CFG.actuators["thrusters"].thrust_range + multirotor.set_thrust_target(torch.full((num_multirotors, num_thr), 5.0, device=sim.device)) + for _ in range(3): + multirotor.write_data_to_sim() + sim.step() + multirotor.update(sim.cfg.dt) + + applied = multirotor.data.applied_thrust assert applied.shape == (num_multirotors, num_thr) - - -@pytest.mark.parametrize("num_multirotors", [1]) -@pytest.mark.parametrize("device", ["cpu"]) -@pytest.mark.isaacsim_ci -def test_set_thrust_target_broadcasting_integration(sim, num_multirotors, device): - """Ensure `set_thrust_target` broadcasting works in the integration context.""" - cfg = generate_multirotor_cfg() - multirotor, _ = generate_multirotor(cfg, num_multirotors, device=sim.device) - - # Determine number of thrusters for assertion (stub vs real asset) - # try: - # num_thr = multirotor.num_thrusters - # except Exception: - # num_thr = multirotor._data.thrust_target.shape[1] - - # Set a single-thruster column across all envs - multirotor.set_thrust_target( - torch.tensor([9.0] * num_multirotors, device=sim.device), thruster_ids=0, env_ids=slice(None) - ) - # Check that the first column of thrust_target has been updated - data = multirotor.data if hasattr(multirotor, "data") else multirotor._data - assert torch.allclose(data.thrust_target[:, 0], torch.tensor([9.0] * num_multirotors, device=sim.device)) - - # Minimal cleanup to avoid unraisable destructor warnings when a real - # Multirotor was created during the test. - if hasattr(multirotor, "_clear_callbacks"): - try: - for _a in ( - "_prim_deletion_callback_id", - "_initialize_handle", - "_invalidate_initialize_handle", - "_debug_vis_handle", - ): - if not hasattr(multirotor, _a): - setattr(multirotor, _a, None) - multirotor._clear_callbacks() - except Exception: - pass - with contextlib.suppress(Exception): - del multirotor + assert torch.all(applied > 0.0) + assert torch.all(applied >= thrust_range[0]) and torch.all(applied <= thrust_range[1]) + # With gravity off, the upward thrust must accelerate the body along +z. + assert torch.all(multirotor.data.root_lin_vel_w.torch[:, 2] > 0.0) diff --git a/source/isaaclab_contrib/test/controllers/test_drone_geometric_controllers.py b/source/isaaclab_contrib/test/controllers/test_drone_geometric_controllers.py index 920447bc7ca4..15c33adfdd0b 100644 --- a/source/isaaclab_contrib/test/controllers/test_drone_geometric_controllers.py +++ b/source/isaaclab_contrib/test/controllers/test_drone_geometric_controllers.py @@ -93,13 +93,6 @@ def _mock_instance(): monkeypatch.setattr(sim_utils.SimulationContext, "instance", _mock_instance) -def _device_param(device_str: str) -> torch.device: - """Return the torch.device or skip when CUDA is unavailable.""" - if device_str == "cuda" and not torch.cuda.is_available(): - pytest.skip("CUDA not available on this system") - return torch.device(device_str) - - def _create_vel_cfg() -> LeeVelControllerCfg: """Create velocity controller config with required parameters.""" cfg = LeeVelControllerCfg() @@ -142,35 +135,29 @@ def _create_att_cfg() -> LeeAttControllerCfg: return cfg -@pytest.mark.parametrize("device_str", ["cpu", "cuda"]) -@pytest.mark.parametrize("num_envs", [1, 2, 8]) -@pytest.mark.parametrize("num_bodies", [1, 4]) +# Controllers are pure torch with inertia aggregation patched out: device, env and body counts select no branch. @pytest.mark.parametrize( - "controller_cls,cfg_factory,mod_name", + "controller_cls,cfg_factory,mod_name,gain_names", [ - ("LeeVelController", _create_vel_cfg, vel_mod), - ("LeePosController", _create_pos_cfg, pos_mod), - ("LeeAccController", _create_acc_cfg, acc_mod), - ("LeeAttController", _create_att_cfg, att_mod), + ("LeeVelController", _create_vel_cfg, vel_mod, ("K_vel",)), + ("LeePosController", _create_pos_cfg, pos_mod, ("K_pos",)), + ("LeeAccController", _create_acc_cfg, acc_mod, ("K_rot", "K_angvel")), + ("LeeAttController", _create_att_cfg, att_mod, ("K_rot", "K_angvel")), ], ) def test_lee_controllers_basic( monkeypatch: pytest.MonkeyPatch, - device_str: str, - num_envs: int, - num_bodies: int, controller_cls: str, cfg_factory, mod_name, + gain_names: tuple[str, ...], ): - """Controllers return finite (N, 6) wrench on zero state and counter gravity on +Z. - - Tests various configurations of number of environments and bodies to catch edge cases. - """ - device = _device_param(device_str) + """Controllers return finite (N, 6) wrench on zero state, counter gravity on +Z, and randomize gains in range.""" + device = torch.device("cpu") + num_envs = 2 _patch_aggregate(monkeypatch, mod_name, num_envs, device) _patch_sim_context(monkeypatch, mod_name) - robot = _DummyRobot(num_envs, num_bodies, device) + robot = _DummyRobot(num_envs, 1, device) cfg = cfg_factory() controller = getattr(mod_name, controller_cls)(cfg, robot, num_envs=num_envs, device=str(device)) @@ -183,150 +170,14 @@ def test_lee_controllers_basic( assert torch.isfinite(wrench).all(), "Wrench contains non-finite values" assert torch.all(wrench[:, 2] > 0.0), "Body-z force should oppose gravity" - -@pytest.mark.parametrize("device_str", ["cpu", "cuda"]) -@pytest.mark.parametrize("num_envs", [1, 2, 8]) -@pytest.mark.parametrize("num_bodies", [1, 4]) -def test_lee_vel_randomize_params_within_bounds( - monkeypatch: pytest.MonkeyPatch, device_str: str, num_envs: int, num_bodies: int -): - """Randomized gains stay within configured ranges for velocity controller. - - Tests edge cases with single and multiple environments and bodies. - """ - device = _device_param(device_str) - _patch_aggregate(monkeypatch, vel_mod, num_envs, device) - _patch_sim_context(monkeypatch, vel_mod) - robot = _DummyRobot(num_envs, num_bodies, device) - - cfg = _create_vel_cfg() - controller = vel_mod.LeeVelController(cfg, robot, num_envs=num_envs, device=str(device)) - - controller.reset_idx(env_ids=None) - - # Ensure tensors are on the correct device - K_vel_min = torch.tensor(cfg.K_vel_range[0], device=device, dtype=torch.float32) - K_vel_max = torch.tensor(cfg.K_vel_range[1], device=device, dtype=torch.float32) - - # Move controller gains to same device if needed - K_vel_current = controller.K_vel_current.to(device) - - assert K_vel_current.shape == (num_envs, 3), f"Expected shape ({num_envs}, 3), got {K_vel_current.shape}" - assert torch.all(K_vel_current >= K_vel_min), f"K_vel below minimum: {K_vel_current.min()} < {K_vel_min.min()}" - assert torch.all(K_vel_current <= K_vel_max), f"K_vel above maximum: {K_vel_current.max()} > {K_vel_max.max()}" - - -@pytest.mark.parametrize("device_str", ["cpu", "cuda"]) -@pytest.mark.parametrize("num_envs", [1, 2, 8]) -@pytest.mark.parametrize("num_bodies", [1, 4]) -def test_lee_pos_randomize_params_within_bounds( - monkeypatch: pytest.MonkeyPatch, device_str: str, num_envs: int, num_bodies: int -): - """Randomized gains stay within configured ranges for position controller. - - Tests edge cases with single and multiple environments and bodies. - """ - device = _device_param(device_str) - _patch_aggregate(monkeypatch, pos_mod, num_envs, device) - _patch_sim_context(monkeypatch, pos_mod) - robot = _DummyRobot(num_envs, num_bodies, device) - - cfg = _create_pos_cfg() - controller = pos_mod.LeePosController(cfg, robot, num_envs=num_envs, device=str(device)) - - controller.reset_idx(env_ids=None) - - # Check K_pos gains - K_pos_min = torch.tensor(cfg.K_pos_range[0], device=device, dtype=torch.float32) - K_pos_max = torch.tensor(cfg.K_pos_range[1], device=device, dtype=torch.float32) - K_pos_current = controller.K_pos_current.to(device) - - assert K_pos_current.shape == (num_envs, 3), f"Expected shape ({num_envs}, 3), got {K_pos_current.shape}" - assert torch.all(K_pos_current >= K_pos_min), f"K_pos below minimum: {K_pos_current.min()} < {K_pos_min.min()}" - assert torch.all(K_pos_current <= K_pos_max), f"K_pos above maximum: {K_pos_current.max()} > {K_pos_max.max()}" - - -@pytest.mark.parametrize("device_str", ["cpu", "cuda"]) -@pytest.mark.parametrize("num_envs", [1, 2, 8]) -@pytest.mark.parametrize("num_bodies", [1, 4]) -def test_lee_acc_randomize_params_within_bounds( - monkeypatch: pytest.MonkeyPatch, device_str: str, num_envs: int, num_bodies: int -): - """Randomized gains stay within configured ranges for acceleration controller. - - Tests edge cases with single and multiple environments and bodies. - """ - device = _device_param(device_str) - _patch_aggregate(monkeypatch, acc_mod, num_envs, device) - _patch_sim_context(monkeypatch, acc_mod) - robot = _DummyRobot(num_envs, num_bodies, device) - - cfg = _create_acc_cfg() - controller = acc_mod.LeeAccController(cfg, robot, num_envs=num_envs, device=str(device)) - controller.reset_idx(env_ids=None) - # Check K_rot gains - K_rot_min = torch.tensor(cfg.K_rot_range[0], device=device, dtype=torch.float32) - K_rot_max = torch.tensor(cfg.K_rot_range[1], device=device, dtype=torch.float32) - K_rot_current = controller.K_rot_current.to(device) - - assert K_rot_current.shape == (num_envs, 3), f"Expected shape ({num_envs}, 3), got {K_rot_current.shape}" - assert torch.all(K_rot_current >= K_rot_min), f"K_rot below minimum: {K_rot_current.min()} < {K_rot_min.min()}" - assert torch.all(K_rot_current <= K_rot_max), f"K_rot above maximum: {K_rot_current.max()} > {K_rot_max.max()}" - - # Check K_angvel gains - K_angvel_min = torch.tensor(cfg.K_angvel_range[0], device=device, dtype=torch.float32) - K_angvel_max = torch.tensor(cfg.K_angvel_range[1], device=device, dtype=torch.float32) - K_angvel_current = controller.K_angvel_current.to(device) - - assert K_angvel_current.shape == (num_envs, 3), f"Expected shape ({num_envs}, 3), got {K_angvel_current.shape}" - assert torch.all(K_angvel_current >= K_angvel_min), ( - f"K_angvel below minimum: {K_angvel_current.min()} < {K_angvel_min.min()}" - ) - assert torch.all(K_angvel_current <= K_angvel_max), ( - f"K_angvel above maximum: {K_angvel_current.max()} > {K_angvel_max.max()}" - ) - - -@pytest.mark.parametrize("device_str", ["cpu", "cuda"]) -@pytest.mark.parametrize("num_envs", [1, 2, 8]) -@pytest.mark.parametrize("num_bodies", [1, 4]) -def test_lee_att_randomize_params_within_bounds( - monkeypatch: pytest.MonkeyPatch, device_str: str, num_envs: int, num_bodies: int -): - """Randomized gains stay within configured ranges for attitude controller. - - Tests edge cases with single and multiple environments and bodies. - """ - device = _device_param(device_str) - _patch_aggregate(monkeypatch, att_mod, num_envs, device) - _patch_sim_context(monkeypatch, att_mod) - robot = _DummyRobot(num_envs, num_bodies, device) - - cfg = _create_att_cfg() - controller = att_mod.LeeAttController(cfg, robot, num_envs=num_envs, device=str(device)) - - controller.reset_idx(env_ids=None) + for name in gain_names: + gain_range = getattr(cfg, f"{name}_range") + gain_min = torch.tensor(gain_range[0], device=device, dtype=torch.float32) + gain_max = torch.tensor(gain_range[1], device=device, dtype=torch.float32) + gain = getattr(controller, f"{name}_current").to(device) - # Check K_rot gains - K_rot_min = torch.tensor(cfg.K_rot_range[0], device=device, dtype=torch.float32) - K_rot_max = torch.tensor(cfg.K_rot_range[1], device=device, dtype=torch.float32) - K_rot_current = controller.K_rot_current.to(device) - - assert K_rot_current.shape == (num_envs, 3), f"Expected shape ({num_envs}, 3), got {K_rot_current.shape}" - assert torch.all(K_rot_current >= K_rot_min), f"K_rot below minimum: {K_rot_current.min()} < {K_rot_min.min()}" - assert torch.all(K_rot_current <= K_rot_max), f"K_rot above maximum: {K_rot_current.max()} > {K_rot_max.max()}" - - # Check K_angvel gains - K_angvel_min = torch.tensor(cfg.K_angvel_range[0], device=device, dtype=torch.float32) - K_angvel_max = torch.tensor(cfg.K_angvel_range[1], device=device, dtype=torch.float32) - K_angvel_current = controller.K_angvel_current.to(device) - - assert K_angvel_current.shape == (num_envs, 3), f"Expected shape ({num_envs}, 3), got {K_angvel_current.shape}" - assert torch.all(K_angvel_current >= K_angvel_min), ( - f"K_angvel below minimum: {K_angvel_current.min()} < {K_angvel_min.min()}" - ) - assert torch.all(K_angvel_current <= K_angvel_max), ( - f"K_angvel above maximum: {K_angvel_current.max()} > {K_angvel_max.max()}" - ) + assert gain.shape == (num_envs, 3), f"Expected {name} shape ({num_envs}, 3), got {gain.shape}" + assert torch.all(gain >= gain_min), f"{name} below minimum: {gain.min()} < {gain_min.min()}" + assert torch.all(gain <= gain_max), f"{name} above maximum: {gain.max()} > {gain_max.max()}" diff --git a/source/isaaclab_contrib/test/coupling/test_coupler.py b/source/isaaclab_contrib/test/coupling/test_coupler.py index 52fc486ce88d..34fd22395090 100644 --- a/source/isaaclab_contrib/test/coupling/test_coupler.py +++ b/source/isaaclab_contrib/test/coupling/test_coupler.py @@ -39,7 +39,7 @@ from isaaclab_contrib.coupling import ( CouplerAdmmCfg, - CouplerCfg, + CouplerCfg, # noqa: F401 - importing every public name guards the package exports CouplerEntryCfg, CouplerProxyCfg, CouplerProxyMappingCfg, @@ -61,19 +61,6 @@ def assign(self, values: np.ndarray) -> None: self.data = np.asarray(values).copy() -def test_public_coupler_config_resolves_renamed_class(): - """The package-level config points at the renamed coupler implementation.""" - assert CouplerCfg().class_type.__name__ == "NewtonCouplerManager" - - -def test_public_coupling_exports_are_importable(): - """The lazy-export stub must not retain deleted public symbols.""" - import isaaclab_contrib.coupling as coupling - - for name in coupling.__all__: - assert getattr(coupling, name) is not None - - @dataclass class _FakeModel: """Fields consulted by the coupler's pure configuration helpers.""" @@ -321,15 +308,6 @@ def test_resolved_entry_validation_rejects_inactive_entry(): ) -def test_resolved_entry_validation_accepts_active_proxy_destination(): - NewtonCouplerManager._validate_resolved_entries( - _FakeModel(), - [_entry("source", bodies=[0]), _entry("destination")], - CouplerProxyCfg(), - {"destination"}, - ) - - def test_resolved_entry_validation_accepts_static_shape_ownership(): NewtonCouplerManager._validate_resolved_entries( _FakeModel(), @@ -433,32 +411,6 @@ def custom_pipeline(model_view): assert isinstance(cfg.proxies[1].collision_pipeline, NewtonCollisionPipelineCfg) -def test_entry_build_uses_solver_config_class_type(): - class _RecordingManager: - @classmethod - def _create_solver(cls, model, solver_cfg): - return SimpleNamespace(model=model, solver_cfg=solver_cfg) - - solver_cfg = XPBDSolverCfg() - solver_cfg.class_type = _RecordingManager - entry = NewtonCouplerManager._ResolvedEntry( - config=CouplerEntryCfg( - name="entry", - solver_cfg=solver_cfg, - ), - bodies=[], - particles=[], - joints=[], - shapes=[], - ) - - solver_entry = NewtonCouplerManager._build_entry(entry) - solver = solver_entry.solver("entry-view") - - assert solver.model == "entry-view" - assert solver.solver_cfg is entry.config.solver_cfg - - @pytest.mark.parametrize( "solver_cfg", [ @@ -506,10 +458,6 @@ def _create_solver(cls, model, solver_cfg): assert solver_entry.in_place is True -def test_mpm_entry_does_not_request_external_contacts(): - assert NewtonCouplerManager._requires_external_contacts(MPMSolverCfg()) is False - - @pytest.mark.parametrize( ("grid_type", "max_active_cell_count", "expected"), [ @@ -559,28 +507,22 @@ def test_coupler_clear_releases_nested_manager_state(monkeypatch): def test_mpm_entry_reuses_builder_lifecycle_hooks(monkeypatch): - """Coupled MPM entries register attributes and normalize kinematic colliders.""" + """Coupled MPM entries normalize kinematic colliders before finalize.""" events: list[tuple[str, object]] = [] builder = object() solver_cfg = CouplerProxyCfg( entries=[CouplerEntryCfg(name="media", solver_cfg=MPMSolverCfg())], ) monkeypatch.setattr(coupler.PhysicsManager, "_cfg", SimpleNamespace(solver_cfg=solver_cfg)) - monkeypatch.setattr( - coupler.NewtonMPMManager, - "_register_builder_attributes", - classmethod(lambda cls, value: events.append(("register", value))), - ) monkeypatch.setattr( coupler.NewtonMPMManager, "_prepare_builder_for_finalize", classmethod(lambda cls, value: events.append(("finalize", value))), ) - NewtonCouplerManager._register_builder_attributes(builder) NewtonCouplerManager._prepare_builder_for_finalize(builder) - assert events == [("register", builder), ("finalize", builder)] + assert events == [("finalize", builder)] def test_nested_solvers_register_their_builder_attributes(monkeypatch): @@ -646,24 +588,6 @@ def test_nested_solver_scopes_mujoco_joint_properties( assert model.joint_damping.numpy()[-1] == pytest.approx(expected_damping) -def test_contact_initialization_prepares_coupled_solver_buffers(monkeypatch): - """Entry-local contact buffers are allocated before graph capture.""" - events: list[tuple[str, object | None]] = [] - contacts = object() - solver = SimpleNamespace(prepare_contacts=lambda value: events.append(("prepare", value))) - monkeypatch.setattr( - coupler.NewtonVBDManager, - "_initialize_contacts", - classmethod(lambda cls: events.append(("initialize", None))), - ) - monkeypatch.setattr(coupler.NewtonManager, "_solver", solver) - monkeypatch.setattr(coupler.NewtonManager, "_contacts", contacts) - - NewtonCouplerManager._initialize_contacts() - - assert events == [("initialize", None), ("prepare", contacts)] - - @pytest.mark.parametrize(("mask_values", "should_reset"), [([True, False], True), ([False, False], False)]) def test_single_world_mpm_reset_promotes_local_mask(monkeypatch, mask_values, should_reset): """The only selected local MPM world uses the full-grid reset path.""" diff --git a/source/isaaclab_contrib/test/custom_coupling/test_env_cfg.py b/source/isaaclab_contrib/test/custom_coupling/test_env_cfg.py index 2d3c5eec7699..a07c3f8384fb 100644 --- a/source/isaaclab_contrib/test/custom_coupling/test_env_cfg.py +++ b/source/isaaclab_contrib/test/custom_coupling/test_env_cfg.py @@ -8,9 +8,7 @@ import pytest import isaaclab_contrib.custom_coupling.tasks # noqa: F401 -from isaaclab_contrib.custom_coupling.franka_soft_env_cfg import PhysicsCfg -from isaaclab_tasks.core.lift.config.franka_soft.franka_soft_env_cfg import PhysicsCfg as CorePhysicsCfg from isaaclab_tasks.utils import resolve_task_config MANUAL_MANAGER = "isaaclab_contrib.custom_coupling.coupled_mjwarp_vbd_manager:NewtonCoupledMJWarpVBDManager" @@ -23,15 +21,12 @@ def test_example_default_preset_uses_the_manual_coupler() -> None: assert env_cfg.sim.physics.class_type == MANUAL_MANAGER + # The moved preset name is selectable on the contrib example. + env_cfg, _ = resolve_task_config( + "IsaacContrib-Lift-Soft-Franka-Custom-Coupling", "", overrides=("physics=newton_mjwarp_vbd",) + ) -def test_core_declares_only_the_proxy_preset() -> None: - """The manual preset lives in contrib; core declares only the proxy variant.""" - core_variants = set(type(CorePhysicsCfg()).__dataclass_fields__) - contrib_variants = set(type(PhysicsCfg()).__dataclass_fields__) - - assert "newton_mjwarp_vbd_proxy" in core_variants - assert "newton_mjwarp_vbd" not in core_variants - assert "newton_mjwarp_vbd" in contrib_variants + assert env_cfg.sim.physics.class_type == MANUAL_MANAGER def test_core_task_rejects_the_removed_preset_name() -> None: diff --git a/source/isaaclab_contrib/test/custom_coupling/test_manager.py b/source/isaaclab_contrib/test/custom_coupling/test_manager.py index 4912b7e93648..e75a81dfbba0 100644 --- a/source/isaaclab_contrib/test/custom_coupling/test_manager.py +++ b/source/isaaclab_contrib/test/custom_coupling/test_manager.py @@ -19,17 +19,6 @@ from isaaclab_contrib.custom_coupling.newton_manager_cfg import CoupledMJWarpVBDSolverCfg -def test_register_builder_attributes_includes_nested_solvers(monkeypatch: pytest.MonkeyPatch) -> None: - """The custom coupled manager delegates builder setup to both configured children.""" - cfg = CoupledMJWarpVBDSolverCfg() - monkeypatch.setattr(manager_module.PhysicsManager, "_cfg", SimpleNamespace(solver_cfg=cfg)) - builder = ModelBuilder() - - NewtonCoupledMJWarpVBDManager._register_builder_attributes(builder) - - assert builder.has_custom_attribute("mujoco:condim") - - def test_registered_mujoco_solver_imports_mujoco_joint_properties(monkeypatch: pytest.MonkeyPatch) -> None: """The coupled manager imports joint properties consumed by its MuJoCo solver.""" cfg = CoupledMJWarpVBDSolverCfg() @@ -139,6 +128,9 @@ def test_build_solver_sets_capabilities(monkeypatch: pytest.MonkeyPatch) -> None NewtonCoupledMJWarpVBDManager._build_solver(MagicMock(), solver_cfg) + assert manager_module.NewtonManager._solver is manager_module.SolverBase.return_value + assert manager_module.NewtonManager._use_single_state is False + assert manager_module.NewtonManager._needs_collision_pipeline is True assert manager_module.NewtonManager._supports_contact_sensors is False assert manager_module.NewtonManager._supports_rigid_body_force_input is True diff --git a/source/isaaclab_contrib/test/custom_coupling/test_manual_coupling.py b/source/isaaclab_contrib/test/custom_coupling/test_manual_coupling.py index 82f6c98e35de..7fa740667a39 100644 --- a/source/isaaclab_contrib/test/custom_coupling/test_manual_coupling.py +++ b/source/isaaclab_contrib/test/custom_coupling/test_manual_coupling.py @@ -16,15 +16,14 @@ """Rest everything follows.""" -import pytest import torch -from isaaclab_newton.assets import Articulation, RigidObject +from isaaclab_newton.assets import RigidObject from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg, VBDSolverCfg from isaaclab_newton.sim.schemas import NewtonDeformableBodyPropertiesCfg from isaaclab_newton.sim.spawners.materials import NewtonDeformableBodyMaterialCfg import isaaclab.sim as sim_utils -from isaaclab.assets import AssetBaseCfg, RigidObjectCfg +from isaaclab.assets import RigidObjectCfg from isaaclab.assets.deformable_object import DeformableObjectCfg from isaaclab.cloner import CloneCfg, clone_plan_from_env_0, replicate from isaaclab.sim import SimulationCfg, build_simulation_context @@ -32,8 +31,6 @@ from isaaclab_contrib.custom_coupling import CoupledMJWarpVBDSolverCfg from isaaclab_contrib.deformable import DeformableObject -from isaaclab_assets import FRANKA_PANDA_CFG # isort:skip - def _make_coupled_cfg(coupling_mode: str) -> SimulationCfg: """Create a simulation config for the manual coupling manager.""" @@ -62,64 +59,6 @@ def _coupled_sim_context(cfg: SimulationCfg, device="cuda:0"): return build_simulation_context(device=device, sim_cfg=cfg, auto_add_lighting=True) -@pytest.fixture -def sim(request): - """Create a manual coupling simulation context.""" - coupling_mode = getattr(request, "param", "one_way") - with _coupled_sim_context(_make_coupled_cfg(coupling_mode)) as sim: - sim._app_control_on_stop_handle = None - yield sim - - -def generate_robot_and_two_cubes( - colliding_cube_pos: tuple = (0.3, 0.0, 1.0), - free_cube_pos: tuple = (2.0, 0.0, 1.0), -) -> tuple[Articulation, DeformableObject, DeformableObject]: - """Create one robot, one colliding cube, and one free cube.""" - sim_utils.create_prim("/World/env_0", "Xform", translation=(0.0, 0.0, 0.0)) - - ground_cfg = AssetBaseCfg(prim_path="/World/defaultGroundPlane", spawn=sim_utils.GroundPlaneCfg()) - robot_cfg = FRANKA_PANDA_CFG.replace(prim_path="/World/env_[^/]+/Robot") - colliding_cube_cfg = DeformableObjectCfg( - prim_path="/World/env_[^/]+/cube_collide", - spawn=sim_utils.MeshCuboidCfg( - size=(0.05, 0.05, 0.05), - deformable_props=NewtonDeformableBodyPropertiesCfg(), - visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.2, 0.8, 0.2)), - physics_material=NewtonDeformableBodyMaterialCfg( - density=500.0, - k_mu=1e5, - k_lambda=1e5, - particle_radius=0.005, - ), - ), - init_state=DeformableObjectCfg.InitialStateCfg(pos=colliding_cube_pos), - ) - free_cube_cfg = DeformableObjectCfg( - prim_path="/World/env_[^/]+/cube_free", - spawn=sim_utils.MeshCuboidCfg( - size=(0.05, 0.05, 0.05), - deformable_props=NewtonDeformableBodyPropertiesCfg(), - visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.8, 0.2, 0.2)), - physics_material=NewtonDeformableBodyMaterialCfg( - density=500.0, - k_mu=1e4, - k_lambda=1e4, - particle_radius=0.005, - ), - ), - init_state=DeformableObjectCfg.InitialStateCfg(pos=free_cube_pos), - ) - plan = clone_plan_from_env_0( - CloneCfg(clone_template="/World/env_{}"), (ground_cfg, robot_cfg, colliding_cube_cfg, free_cube_cfg), 1, 0.0 - ) - ground_cfg.class_type(ground_cfg) - robot = Articulation(robot_cfg) - colliding_cube, free_cube = DeformableObject(colliding_cube_cfg), DeformableObject(free_cube_cfg) - replicate(plan) - return robot, colliding_cube, free_cube - - def generate_lateral_rigid_and_deformable_cubes( rigid_cube_pos: tuple = (0.0, 0.0, 1.0), deformable_cube_pos: tuple = (-0.16, 0.0, 1.0), @@ -161,14 +100,15 @@ def generate_lateral_rigid_and_deformable_cubes( return rigid_cube, deformable_cube -def _run_lateral_rigid_cube_response(coupling_mode: str) -> float: - """Run a compact lateral contact scene and return rigid cube X displacement.""" +def _run_lateral_rigid_cube_response(coupling_mode: str) -> tuple[float, float]: + """Run a compact lateral contact scene and return the rigid and deformable cube X displacements.""" with _coupled_sim_context(_make_coupled_cfg(coupling_mode)) as sim: sim._app_control_on_stop_handle = None rigid_cube, deformable_cube = generate_lateral_rigid_and_deformable_cubes() sim.reset() initial_rigid_x = rigid_cube.data.root_pos_w.torch[0, 0].item() + initial_deformable_x = deformable_cube.data.root_pos_w.torch[0, 0].item() nodal_vel = torch.zeros_like(deformable_cube.data.nodal_vel_w.torch) nodal_vel[..., 0] = 2.0 deformable_cube.write_nodal_velocity_to_sim_index(nodal_vel) @@ -178,42 +118,18 @@ def _run_lateral_rigid_cube_response(coupling_mode: str) -> float: rigid_cube.update(sim.cfg.dt) deformable_cube.update(sim.cfg.dt) - return rigid_cube.data.root_pos_w.torch[0, 0].item() - initial_rigid_x + return ( + rigid_cube.data.root_pos_w.torch[0, 0].item() - initial_rigid_x, + deformable_cube.data.root_pos_w.torch[0, 0].item() - initial_deformable_x, + ) def test_two_way_coupling_applies_reaction_to_rigid_body(): - """Test that two-way coupling laterally pushes a rigid body.""" - one_way_dx = _run_lateral_rigid_cube_response("one_way") - two_way_dx = _run_lateral_rigid_cube_response("two_way") + """Test that two-way coupling laterally pushes a rigid body, while rigid contact blocks the deformable.""" + one_way_dx, one_way_deformable_dx = _run_lateral_rigid_cube_response("one_way") + two_way_dx, _ = _run_lateral_rigid_cube_response("two_way") assert abs(one_way_dx) < 1e-2 + # Rigid-to-deformable contact stops the deformable (about 2 m of free flight otherwise). + assert one_way_deformable_dx < 0.1 assert two_way_dx > one_way_dx + 1e-2 - - -def test_deformable_deflected_by_rigid_contact(sim): - """Test that rigid contact deflects a falling deformable cube.""" - robot, colliding_cube, free_cube = generate_robot_and_two_cubes( - colliding_cube_pos=(0.3, 0.0, 1.0), - free_cube_pos=(2.0, 0.0, 1.0), - ) - sim.reset() - - initial_xy_collide = colliding_cube.data.root_pos_w.torch[0, :2].clone() - initial_xy_free = free_cube.data.root_pos_w.torch[0, :2].clone() - - for _ in range(120): - sim.step() - robot.update(sim.cfg.dt) - colliding_cube.update(sim.cfg.dt) - free_cube.update(sim.cfg.dt) - - final_xy_collide = colliding_cube.data.root_pos_w.torch[0, :2] - final_xy_free = free_cube.data.root_pos_w.torch[0, :2] - - displacement_collide = (final_xy_collide - initial_xy_collide).norm().item() - displacement_free = (final_xy_free - initial_xy_free).norm().item() - - assert displacement_collide > displacement_free + 0.01, ( - f"Colliding cube should be deflected more than free cube: " - f"collide={displacement_collide:.4f}, free={displacement_free:.4f}" - ) diff --git a/source/isaaclab_contrib/test/deformable/test_deformable_object.py b/source/isaaclab_contrib/test/deformable/test_deformable_object.py index 415187f240fb..b5a9b0bd9e45 100644 --- a/source/isaaclab_contrib/test/deformable/test_deformable_object.py +++ b/source/isaaclab_contrib/test/deformable/test_deformable_object.py @@ -34,19 +34,19 @@ from isaaclab.cloner import CloneCfg, clone_plan_from_env_0, replicate from isaaclab.sim import SimulationCfg, build_simulation_context -NEWTON_VBD_CFG = SimulationCfg( - physics=NewtonCfg( - solver_cfg=VBDSolverCfg(iterations=3), - num_substeps=2, - ), -) - -def _newton_sim_context(device="cuda:0", gravity_enabled=True): +def _newton_sim_context(device="cuda:0", gravity_enabled=True, num_substeps=2, use_cuda_graph=True): """Helper to create a Newton VBD simulation context.""" - NEWTON_VBD_CFG.device = device - NEWTON_VBD_CFG.gravity = (0.0, 0.0, -9.81) if gravity_enabled else (0.0, 0.0, 0.0) - return build_simulation_context(device=device, sim_cfg=NEWTON_VBD_CFG, auto_add_lighting=True) + cfg = SimulationCfg( + physics=NewtonCfg( + solver_cfg=VBDSolverCfg(iterations=3), + num_substeps=num_substeps, + use_cuda_graph=use_cuda_graph, + ), + ) + cfg.device = device + cfg.gravity = (0.0, 0.0, -9.81) if gravity_enabled else (0.0, 0.0, 0.0) + return build_simulation_context(device=device, sim_cfg=cfg, auto_add_lighting=True) def generate_cubes_scene(num_cubes: int = 1, height: float = 1.0) -> DeformableObject: @@ -203,6 +203,26 @@ def test_initialization(sim): root_vel = cube_object.data.root_vel_w.torch assert root_vel.shape == (num_cubes, 3) + # Written nodal state reads back before and after a step (cache invalidation). + for _ in range(2): + nodal_state = torch.randn(num_cubes, particles_per_body, 6, device=sim.device) + cube_object.write_nodal_state_to_sim_index(nodal_state) + torch.testing.assert_close(cube_object.data.nodal_state_w.torch, nodal_state, rtol=1e-5, atol=1e-5) + sim.step() + cube_object.update(sim.cfg.dt) + + # Public write APIs reject wrong tensor shapes. + wrong_pos = torch.zeros(particles_per_body, 3, device=sim.device) + wrong_vel = torch.zeros(1, particles_per_body, 2, device=sim.device) + wrong_targets = torch.zeros(2, particles_per_body, 3, device=sim.device) + + with pytest.raises(AssertionError, match="Shape mismatch"): + cube_object.write_nodal_pos_to_sim_index(wrong_pos) + with pytest.raises(AssertionError, match="Shape mismatch"): + cube_object.write_nodal_velocity_to_sim_index(wrong_vel, env_ids=torch.tensor([0], device=sim.device)) + with pytest.raises(AssertionError, match="Shape mismatch"): + cube_object.write_nodal_kinematic_target_to_sim_index(wrong_targets) + def test_surface_initialization_and_freefall(sim): """Test initialization and stepping for surface deformable objects.""" @@ -229,96 +249,55 @@ def test_surface_initialization_and_freefall(sim): assert torch.all(cloth_object.data.root_pos_w.torch[:, 2] < initial_root_z) -def test_set_nodal_state(sim): - """Test setting the state of the deformable object.""" - num_cubes = 2 - cube_object = generate_cubes_scene(num_cubes=num_cubes) - - sim.reset() - - for state_type_to_randomize in ["nodal_pos_w", "nodal_vel_w"]: - state_dict = { - "nodal_pos_w": torch.zeros_like(cube_object.data.nodal_pos_w.torch), - "nodal_vel_w": torch.zeros_like(cube_object.data.nodal_vel_w.torch), - } - - for _ in range(5): - state_dict[state_type_to_randomize] = torch.randn( - num_cubes, cube_object.max_sim_vertices_per_body, 3, device=sim.device - ) - - for _ in range(5): - nodal_state = torch.cat( - [ - state_dict["nodal_pos_w"], - state_dict["nodal_vel_w"], - ], - dim=-1, - ) - cube_object.write_nodal_state_to_sim_index(nodal_state) - - torch.testing.assert_close(cube_object.data.nodal_state_w.torch, nodal_state, rtol=1e-5, atol=1e-5) - - sim.step() - cube_object.update(sim.cfg.dt) - - -def test_write_partial_env_ids(sim): - """Test writing to a subset of environments using env_ids.""" - num_cubes = 2 +def test_full_data_writes_selected_env(sim): + """Test index, full-data, warp-input and mask writes update only the selected environment.""" + num_cubes = 3 cube_object = generate_cubes_scene(num_cubes=num_cubes) sim.reset() particles_per_body = cube_object.max_sim_vertices_per_body - default_pos = cube_object.data.nodal_pos_w.torch.clone() - # Write new positions only for env 0 + # Indexed partial position write to env 0. + default_pos = cube_object.data.nodal_pos_w.torch.clone() new_pos = torch.randn(1, particles_per_body, 3, device=sim.device) cube_object.write_nodal_pos_to_sim_index(new_pos, env_ids=torch.tensor([0], device=sim.device)) cube_object.update(sim.cfg.dt) read_pos = cube_object.data.nodal_pos_w.torch - - # env 0 should have new positions torch.testing.assert_close(read_pos[0], new_pos[0], rtol=1e-5, atol=1e-5) - - # other envs should be unchanged torch.testing.assert_close(read_pos[1:], default_pos[1:], rtol=1e-5, atol=1e-5) - -def test_write_partial_velocity_env_ids(sim): - """Test writing nodal velocities to a subset of environments.""" - num_cubes = 4 - cube_object = generate_cubes_scene(num_cubes=num_cubes) - - sim.reset() - - particles_per_body = cube_object.max_sim_vertices_per_body + # Indexed partial velocity write to env 1. default_vel = cube_object.data.nodal_vel_w.torch.clone() - - env_ids = torch.tensor([1], device=sim.device) new_vel = torch.full((1, particles_per_body, 3), 0.25, device=sim.device) new_vel[..., 2] = 1.0 - cube_object.write_nodal_velocity_to_sim_index(new_vel, env_ids=env_ids) + cube_object.write_nodal_velocity_to_sim_index(new_vel, env_ids=torch.tensor([1], device=sim.device)) cube_object.update(sim.cfg.dt) read_vel = cube_object.data.nodal_vel_w.torch torch.testing.assert_close(read_vel[1], new_vel[0], rtol=1e-5, atol=1e-5) torch.testing.assert_close(cube_object.data.root_vel_w.torch[1], new_vel[0].mean(dim=0), rtol=1e-5, atol=1e-5) torch.testing.assert_close(read_vel[0], default_vel[0], rtol=1e-5, atol=1e-5) - if num_cubes > 2: - torch.testing.assert_close(read_vel[2:], default_vel[2:], rtol=1e-5, atol=1e-5) + torch.testing.assert_close(read_vel[2:], default_vel[2:], rtol=1e-5, atol=1e-5) + # Indexed kinematic target write to env 2 with a device-native Warp input. + default_targets = cube_object.data.nodal_kinematic_target.torch.clone() + targets = torch.zeros(1, particles_per_body, 4, device=sim.device) + targets[0, :, :3] = cube_object.data.default_nodal_state_w.torch[2, :, :3] + targets[0, :, :3] += torch.tensor([0.0, 0.0, 0.1], device=sim.device) + targets[0, :, 3] = 0.0 -def test_full_data_writes_selected_env(sim): - """Test full-sized write buffers with selected environment ids.""" - num_cubes = 3 - cube_object = generate_cubes_scene(num_cubes=num_cubes) + cube_object.write_nodal_kinematic_target_to_sim_index( + wp.from_torch(targets.contiguous(), dtype=wp.vec4f), env_ids=torch.tensor([2], device=sim.device) + ) - sim.reset() + read_targets = cube_object.data.nodal_kinematic_target.torch + torch.testing.assert_close(read_targets[2], targets[0], rtol=1e-5, atol=1e-5) + torch.testing.assert_close(read_targets[0], default_targets[0], rtol=1e-5, atol=1e-5) + torch.testing.assert_close(read_targets[1], default_targets[1], rtol=1e-5, atol=1e-5) - particles_per_body = cube_object.max_sim_vertices_per_body + # Full-sized buffers with selected environment ids. env_ids = torch.tensor([1], device=sim.device) default_pos = cube_object.data.nodal_pos_w.torch.clone() @@ -357,40 +336,7 @@ def test_full_data_writes_selected_env(sim): torch.testing.assert_close(read_targets[0], default_targets[0], rtol=1e-5, atol=1e-5) torch.testing.assert_close(read_targets[2], default_targets[2], rtol=1e-5, atol=1e-5) - -def test_kinematic_target_partial_env_ids_with_warp_input(sim): - """Test indexed kinematic target writes with device-native input arrays.""" - num_cubes = 3 - cube_object = generate_cubes_scene(num_cubes=num_cubes) - - sim.reset() - - particles_per_body = cube_object.max_sim_vertices_per_body - env_ids = torch.tensor([2], device=sim.device) - - default_targets = cube_object.data.nodal_kinematic_target.torch.clone() - targets = torch.zeros(1, particles_per_body, 4, device=sim.device) - targets[0, :, :3] = cube_object.data.default_nodal_state_w.torch[2, :, :3] - targets[0, :, :3] += torch.tensor([0.0, 0.0, 0.1], device=sim.device) - targets[0, :, 3] = 0.0 - - cube_object.write_nodal_kinematic_target_to_sim_index( - wp.from_torch(targets.contiguous(), dtype=wp.vec4f), env_ids=env_ids - ) - - read_targets = cube_object.data.nodal_kinematic_target.torch - torch.testing.assert_close(read_targets[2], targets[0], rtol=1e-5, atol=1e-5) - torch.testing.assert_close(read_targets[0], default_targets[0], rtol=1e-5, atol=1e-5) - torch.testing.assert_close(read_targets[1], default_targets[1], rtol=1e-5, atol=1e-5) - - -def test_mask_writes_selected_env(sim): - """Test full-sized write buffers with selected environment masks.""" - num_cubes = 3 - cube_object = generate_cubes_scene(num_cubes=num_cubes) - - sim.reset() - + # Full-sized buffers with selected environment masks. env_mask = wp.array([False, True, False], dtype=wp.bool, device=sim.device) default_state = cube_object.data.nodal_state_w.torch.clone() @@ -442,50 +388,26 @@ def test_mask_writes_selected_env(sim): torch.testing.assert_close(read_targets[2], default_targets[2], rtol=1e-5, atol=1e-5) -@pytest.mark.parametrize( - "num_cubes, randomize_pos, randomize_rot", - [ - (1, False, False), - (1, True, False), - (1, False, True), - (2, True, True), - ], -) @flaky(max_runs=3, min_passes=1) -def test_set_nodal_state_with_applied_transform(num_cubes, randomize_pos, randomize_rot): +def test_set_nodal_state_with_applied_transform(): """Test setting the state of the deformable object with applied transform. Applies random position/rotation transforms to the default nodal state, writes it to simulation, steps with no gravity, and verifies the mean nodal position (root_pos_w) matches the expected transformed centroid. """ - cfg = SimulationCfg( - physics=NewtonCfg( - solver_cfg=VBDSolverCfg(iterations=3), - num_substeps=2, - ), - ) - cfg.device = "cuda:0" - cfg.gravity = (0.0, 0.0, 0.0) - - with build_simulation_context(device="cuda:0", sim_cfg=cfg, auto_add_lighting=True) as sim: + num_cubes = 2 + with _newton_sim_context(gravity_enabled=False) as sim: sim._app_control_on_stop_handle = None cube_object = generate_cubes_scene(num_cubes=num_cubes, height=5.0) sim.reset() for _ in range(5): nodal_state = cube_object.data.default_nodal_state_w.torch.clone() - mean_nodal_pos_default = nodal_state[..., :3].mean(dim=1) - - if randomize_pos: - pos_w = 0.5 * torch.rand(cube_object.num_instances, 3, device=sim.device) - pos_w[:, 2] += 0.5 - else: - pos_w = None - if randomize_rot: - quat_w = math_utils.random_orientation(cube_object.num_instances, device=sim.device) - else: - quat_w = None + + pos_w = 0.5 * torch.rand(cube_object.num_instances, 3, device=sim.device) + pos_w[:, 2] += 0.5 + quat_w = math_utils.random_orientation(cube_object.num_instances, device=sim.device) # transform_nodal_pos: center, rotate, translate, un-center nodal_pos = nodal_state[..., :3] @@ -494,11 +416,6 @@ def test_set_nodal_state_with_applied_transform(num_cubes, randomize_pos, random nodal_state[..., :3] = math_utils.transform_points(centered, pos_w, quat_w) + mean_pos mean_nodal_pos_init = nodal_state[..., :3].mean(dim=1) - if pos_w is None: - torch.testing.assert_close(mean_nodal_pos_init, mean_nodal_pos_default, rtol=1e-5, atol=1e-5) - else: - torch.testing.assert_close(mean_nodal_pos_init, mean_nodal_pos_default + pos_w, rtol=1e-5, atol=1e-5) - cube_object.write_nodal_state_to_sim_index(nodal_state) for _ in range(50): @@ -508,7 +425,9 @@ def test_set_nodal_state_with_applied_transform(num_cubes, randomize_pos, random torch.testing.assert_close(cube_object.data.root_pos_w.torch, mean_nodal_pos_init, rtol=1e-4, atol=1e-4) -def test_freefall_analytical(sim): +# One substep without CUDA graphs leaves an odd number of state swaps, so reads must use the current state. +@pytest.mark.parametrize(("num_substeps", "use_cuda_graph"), [(2, True), (1, False)]) +def test_freefall_analytical(num_substeps, use_cuda_graph): """Test that one step of free-fall matches the inertia target prediction. VBD computes an inertia target per substep (h = sub_dt):: @@ -532,47 +451,36 @@ def test_freefall_analytical(sim): """ g = -9.81 dt = 1.0 / 60.0 - num_substeps = 2 sub_dt = dt / num_substeps expected_dz = g * sub_dt**2 * num_substeps * (num_substeps + 1) / 2 - cube_object = generate_cubes_scene(num_cubes=1, height=5.0) - sim.reset() - - x0 = cube_object.data.nodal_pos_w.torch.clone() - sim.step() - cube_object.update(sim.cfg.dt) - x1 = cube_object.data.nodal_pos_w.torch - - dz = x1[..., 2] - x0[..., 2] - # Every vertex should have the same Z displacement under uniform gravity - torch.testing.assert_close(dz, torch.full_like(dz, expected_dz), rtol=1e-2, atol=1e-5) - - -def test_nodal_pos_reads_current_state_after_odd_substep_swap(): - """Test deformable reads use the current Newton state after state swapping.""" - cfg = SimulationCfg( - physics=NewtonCfg( - solver_cfg=VBDSolverCfg(iterations=3), - num_substeps=1, - use_cuda_graph=False, - ), - ) - cfg.device = "cuda:0" - cfg.gravity = (0.0, 0.0, -9.81) - - with build_simulation_context(device="cuda:0", sim_cfg=cfg, auto_add_lighting=True) as sim: + with _newton_sim_context(num_substeps=num_substeps, use_cuda_graph=use_cuda_graph) as sim: sim._app_control_on_stop_handle = None cube_object = generate_cubes_scene(num_cubes=1, height=5.0) - sim.reset() - initial_pos = cube_object.data.nodal_pos_w.torch.clone() + x0 = cube_object.data.nodal_pos_w.torch.clone() sim.step() cube_object.update(sim.cfg.dt) + x1 = cube_object.data.nodal_pos_w.torch + + dz = x1[..., 2] - x0[..., 2] + # Every vertex should have the same Z displacement under uniform gravity + torch.testing.assert_close(dz, torch.full_like(dz, expected_dz), rtol=1e-2, atol=1e-5) + + # Deformable write paths remain valid after a simulation reset. + initial_pos = cube_object.data.default_nodal_state_w.torch[..., :3].clone() + first_pos = initial_pos + torch.tensor([0.1, 0.0, 0.0], device=sim.device) + cube_object.write_nodal_pos_to_sim_index(first_pos) + cube_object.update(sim.cfg.dt) + torch.testing.assert_close(cube_object.data.nodal_pos_w.torch, first_pos, rtol=1e-5, atol=1e-5) - stepped_pos = cube_object.data.nodal_pos_w.torch - assert torch.all(stepped_pos[..., 2] < initial_pos[..., 2]) + sim.reset() + + second_pos = initial_pos + torch.tensor([0.0, -0.1, 0.2], device=sim.device) + cube_object.write_nodal_pos_to_sim_index(second_pos) + cube_object.update(sim.cfg.dt) + torch.testing.assert_close(cube_object.data.nodal_pos_w.torch, second_pos, rtol=1e-5, atol=1e-5) def test_set_kinematic_targets(sim): @@ -626,31 +534,9 @@ def test_set_kinematic_targets(sim): default_root_z = default_pos[1:, :, 2].mean(dim=1) assert torch.all(final_root_z < default_root_z) - -def test_kinematic_target_release_restores_free_motion(sim): - """Test that a pinned deformable falls again after kinematic targets are released.""" - cube_object = generate_cubes_scene(num_cubes=1, height=5.0) - - sim.reset() - - particles_per_body = cube_object.max_sim_vertices_per_body - default_state = cube_object.data.default_nodal_state_w.torch - default_pos = default_state[..., :3].clone() - - targets = torch.zeros(1, particles_per_body, 4, device=sim.device) - targets[0, :, :3] = default_pos[0] - targets[0, :, 3] = 0.0 - cube_object.write_nodal_kinematic_target_to_sim_index(targets) - - for _ in range(5): - cube_object.write_data_to_sim() - sim.step() - cube_object.update(sim.cfg.dt) - - torch.testing.assert_close(cube_object.data.nodal_pos_w.torch[0], default_pos[0], rtol=1e-5, atol=1e-5) - - targets[0, :, 3] = 1.0 - cube_object.write_nodal_kinematic_target_to_sim_index(targets) + # Releasing the pin on env 0 restores free motion. + nodal_kinematic_targets[0, :, 3] = 1.0 + cube_object.write_nodal_kinematic_target_to_sim_index(nodal_kinematic_targets) for _ in range(20): cube_object.write_data_to_sim() @@ -677,42 +563,3 @@ def test_multiple_deformable_assets_do_not_alias(sim): torch.testing.assert_close(cuboid.data.nodal_pos_w.torch, cuboid_pos, rtol=1e-5, atol=1e-5) torch.testing.assert_close(cylinder.data.nodal_pos_w.torch, cylinder_default, rtol=1e-5, atol=1e-5) assert cuboid._recorded_particle_offsets != cylinder._recorded_particle_offsets - - -def test_rebind_after_sim_reset(sim): - """Test that deformable write paths remain valid after a simulation reset.""" - cube_object = generate_cubes_scene(num_cubes=1, height=2.0) - - sim.reset() - - initial_pos = cube_object.data.default_nodal_state_w.torch[..., :3].clone() - first_pos = initial_pos + torch.tensor([0.1, 0.0, 0.0], device=sim.device) - cube_object.write_nodal_pos_to_sim_index(first_pos) - cube_object.update(sim.cfg.dt) - torch.testing.assert_close(cube_object.data.nodal_pos_w.torch, first_pos, rtol=1e-5, atol=1e-5) - - sim.reset() - - second_pos = initial_pos + torch.tensor([0.0, -0.1, 0.2], device=sim.device) - cube_object.write_nodal_pos_to_sim_index(second_pos) - cube_object.update(sim.cfg.dt) - torch.testing.assert_close(cube_object.data.nodal_pos_w.torch, second_pos, rtol=1e-5, atol=1e-5) - - -def test_write_shape_validation(sim): - """Test public write APIs reject wrong tensor shapes.""" - cube_object = generate_cubes_scene(num_cubes=2) - - sim.reset() - - particles_per_body = cube_object.max_sim_vertices_per_body - wrong_pos = torch.zeros(particles_per_body, 3, device=sim.device) - wrong_vel = torch.zeros(1, particles_per_body, 2, device=sim.device) - wrong_targets = torch.zeros(2, particles_per_body, 3, device=sim.device) - - with pytest.raises(AssertionError, match="Shape mismatch"): - cube_object.write_nodal_pos_to_sim_index(wrong_pos) - with pytest.raises(AssertionError, match="Shape mismatch"): - cube_object.write_nodal_velocity_to_sim_index(wrong_vel, env_ids=torch.tensor([0], device=sim.device)) - with pytest.raises(AssertionError, match="Shape mismatch"): - cube_object.write_nodal_kinematic_target_to_sim_index(wrong_targets) diff --git a/source/isaaclab_contrib/test/rl/test_rlinf_extension.py b/source/isaaclab_contrib/test/rl/test_rlinf_extension.py index a59fce9551b1..963454d2623c 100644 --- a/source/isaaclab_contrib/test/rl/test_rlinf_extension.py +++ b/source/isaaclab_contrib/test/rl/test_rlinf_extension.py @@ -242,21 +242,6 @@ def test_get_isaaclab_cfg_missing_section(self, yaml_config_file: str) -> None: class TestObsConversion: """Tests for ``_convert_isaaclab_obs_to_gr00t``.""" - def test_main_images_conversion(self, set_config_env) -> None: - """Main images should be converted from (B,H,W,C) to (B,1,H,W,C) numpy.""" - # Prime the config cache - ext._load_full_cfg() - - B, H, W, C = 4, 64, 64, 3 - env_obs = { - "main_images": torch.randn(B, H, W, C), - "task_descriptions": ["test"] * B, - } - result = ext._convert_isaaclab_obs_to_gr00t(env_obs) - assert "video.room_view" in result - assert isinstance(result["video.room_view"], np.ndarray) - assert result["video.room_view"].shape == (B, 1, H, W, C) - def test_extra_view_images_conversion(self, set_config_env) -> None: """Extra view images should be split into separate GR00T video keys.""" ext._load_full_cfg() @@ -271,21 +256,6 @@ def test_extra_view_images_conversion(self, set_config_env) -> None: assert "video.right_wrist" in result assert result["video.left_wrist"].shape == (B, 1, H, W, C) - def test_states_conversion(self, set_config_env) -> None: - """States should be sliced and mapped to GR00T state keys.""" - ext._load_full_cfg() - - B, D = 4, 20 - env_obs = { - "states": torch.randn(B, D), - "task_descriptions": ["test"] * B, - } - result = ext._convert_isaaclab_obs_to_gr00t(env_obs) - assert "state.arm" in result - assert result["state.arm"].shape == (B, 1, 7) - assert "state.hand" in result - assert result["state.hand"].shape == (B, 1, 7) - def test_task_descriptions_passthrough(self, set_config_env) -> None: """Task descriptions should be passed through to GR00T annotation key.""" ext._load_full_cfg() @@ -294,13 +264,37 @@ def test_task_descriptions_passthrough(self, set_config_env) -> None: env_obs = {"task_descriptions": descs} result = ext._convert_isaaclab_obs_to_gr00t(env_obs) assert result["annotation.human.action.task_description"] == descs + # An empty observation still carries an (empty) task description. + assert ext._convert_isaaclab_obs_to_gr00t({})["annotation.human.action.task_description"] == [] - def test_empty_obs(self, set_config_env) -> None: - """Empty observation should still include task description key.""" + def test_obs_state_slicing_consistency(self, set_config_env) -> None: + """State slices must match the original tensor content after conversion.""" ext._load_full_cfg() - result = ext._convert_isaaclab_obs_to_gr00t({}) - assert "annotation.human.action.task_description" in result + batch_size, state_dim = 8, 20 + states = torch.arange(batch_size * state_dim, dtype=torch.float32).reshape(batch_size, state_dim) + obs = {"states": states, "task_descriptions": ["t"] * batch_size} + gr00t_obs = ext._convert_isaaclab_obs_to_gr00t(obs) + + # gr00t_mapping.state[0]: slice [0,7] → state.arm + np.testing.assert_allclose(gr00t_obs["state.arm"], states[:, None, :7].numpy(), atol=1e-6) + # gr00t_mapping.state[1]: slice [7,14] → state.hand + np.testing.assert_allclose(gr00t_obs["state.hand"], states[:, None, 7:14].numpy(), atol=1e-6) + + def test_image_value_preservation(self, set_config_env) -> None: + """Pixel values should survive the obs conversion without corruption.""" + ext._load_full_cfg() + + B, H, W, C = 2, 8, 8, 3 + img = torch.rand(B, H, W, C) + obs = {"main_images": img, "task_descriptions": ["t"] * B} + gr00t_obs = ext._convert_isaaclab_obs_to_gr00t(obs) + + np.testing.assert_allclose( + gr00t_obs["video.room_view"][:, 0], + img.cpu().numpy(), + atol=1e-6, + ) # --------------------------------------------------------------------------- @@ -347,16 +341,6 @@ def test_no_padding(self, yaml_config_file: str) -> None: result = ext._convert_gr00t_to_isaaclab_action(action_chunk, chunk_size=1) assert result.shape == (B, 1, D) - def test_chunk_size_slicing(self, set_config_env) -> None: - """Only the first ``chunk_size`` time steps should be kept.""" - ext._load_full_cfg() - - B, T, D = 2, 10, 4 - action_chunk = {"joint": np.ones((B, T, D))} - result = ext._convert_gr00t_to_isaaclab_action(action_chunk, chunk_size=3) - # chunk_size=3 + prefix=3 + suffix=2 → (B, 3, D+5) - assert result.shape[1] == 3 - # --------------------------------------------------------------------------- # Tests: embodiment tag patching @@ -386,11 +370,11 @@ def test_existing_tag_skipped(self) -> None: def test_default_tag_values(self) -> None: """Defaults should be ``new_embodiment`` with ID 31.""" - # new_embodiment is already in the mock registry, so it will be skipped - cfg = {} - ext._patch_embodiment_tags(cfg) - mapping = _rlinf_mocks["rlinf.models.embodiment.gr00t.embodiment_tags"].EMBODIMENT_TAG_MAPPING + # Remove the default tag so the patch must add it from the defaults. + del mapping["new_embodiment"] + ext._patch_embodiment_tags({}) + assert mapping["new_embodiment"] == 31 @@ -410,6 +394,9 @@ def test_tasks_registered_from_yaml(self, set_config_env) -> None: registry = _rlinf_mocks["rlinf.envs.isaaclab"].REGISTER_ISAACLAB_ENVS assert "Isaac-Test-Task-v0" in registry assert "Isaac-Test-Task-Eval-v0" in registry + base = _rlinf_mocks["rlinf.envs.isaaclab.isaaclab_env"].IsaaclabBaseEnv + for env_cls in registry.values(): + assert issubclass(env_cls, base) def test_no_duplicate_registration(self, set_config_env) -> None: """Calling register twice should not duplicate entries.""" @@ -431,16 +418,6 @@ def test_empty_config_no_registration(self, yaml_config_file: str) -> None: registry = _rlinf_mocks["rlinf.envs.isaaclab"].REGISTER_ISAACLAB_ENVS assert len(registry) == 0 - def test_registered_class_is_subclass(self, set_config_env) -> None: - """Registered env classes should inherit from IsaaclabBaseEnv.""" - ext._load_full_cfg() - ext._register_isaaclab_envs() - - registry = _rlinf_mocks["rlinf.envs.isaaclab"].REGISTER_ISAACLAB_ENVS - base = _rlinf_mocks["rlinf.envs.isaaclab.isaaclab_env"].IsaaclabBaseEnv - for env_cls in registry.values(): - assert issubclass(env_cls, base) - # --------------------------------------------------------------------------- # Tests: converter registration @@ -452,8 +429,7 @@ class TestConverterRegistration: def test_converters_registered(self, set_config_env) -> None: """Obs and action converters should be added to RLinf's registries.""" - cfg = ext._SAMPLE_CFG if hasattr(ext, "_SAMPLE_CFG") else {"obs_converter_type": "isaaclab"} - ext._register_gr00t_converters(cfg) + ext._register_gr00t_converters({"obs_converter_type": "isaaclab"}) sim_io = _rlinf_mocks["rlinf.models.embodiment.gr00t.simulation_io"] assert "isaaclab" in sim_io.OBS_CONVERSION @@ -472,158 +448,3 @@ def test_no_duplicate_converter_registration(self) -> None: assert sim_io.OBS_CONVERSION["isaaclab"] is sentinel assert sim_io.ACTION_CONVERSION["isaaclab"] is sentinel - - -# --------------------------------------------------------------------------- -# Tests: random policy simulation (obs → GR00T → action → env, no Isaac Sim) -# --------------------------------------------------------------------------- - - -class TestRandomPolicy: - """Simulate a random-action loop through the full obs/action conversion pipeline. - - This is analogous to ``test_random_actions`` in the RSL-RL wrapper tests but - does NOT require Isaac Sim or a GPU. Instead of creating a real environment - it synthesises batches of random observations (images + states), feeds them - through ``_convert_isaaclab_obs_to_gr00t``, generates random GR00T-format - action chunks, converts them back via ``_convert_gr00t_to_isaaclab_action``, - and validates every intermediate tensor. - """ - - NUM_STEPS = 50 - BATCH_SIZE = 8 - IMG_H, IMG_W, IMG_C = 64, 64, 3 - STATE_DIM = 20 - ACTION_ARM_DIM = 7 - ACTION_HAND_DIM = 7 - CHUNK_SIZE = 4 - - # -- helpers ---------------------------------------------------------- - - @staticmethod - def _check_valid_array(data: np.ndarray | torch.Tensor) -> bool: - """Return True when *data* contains no NaN / Inf values.""" - if isinstance(data, torch.Tensor): - return not (torch.isnan(data).any() or torch.isinf(data).any()) - return bool(np.isfinite(data).all()) - - def _make_random_obs(self) -> dict: - """Build a fake IsaacLab-style observation dict with random data.""" - B, H, W, C, D = self.BATCH_SIZE, self.IMG_H, self.IMG_W, self.IMG_C, self.STATE_DIM - return { - "main_images": torch.rand(B, H, W, C), - "extra_view_images": torch.rand(B, 2, H, W, C), - "states": torch.randn(B, D), - "task_descriptions": [f"task_{i}" for i in range(B)], - } - - def _make_random_gr00t_action(self) -> dict: - """Build a fake GR00T-style action chunk with random data.""" - B, T = self.BATCH_SIZE, self.CHUNK_SIZE + 2 # model outputs more than chunk_size - return { - "arm": np.random.randn(B, T, self.ACTION_ARM_DIM), - "hand": np.random.randn(B, T, self.ACTION_HAND_DIM), - } - - # -- tests ------------------------------------------------------------ - - def test_single_step_roundtrip(self, set_config_env) -> None: - """A single obs→GR00T→action roundtrip should produce valid arrays.""" - ext._load_full_cfg() - - obs = self._make_random_obs() - gr00t_obs = ext._convert_isaaclab_obs_to_gr00t(obs) - - # Validate GR00T obs - for key, val in gr00t_obs.items(): - if isinstance(val, np.ndarray): - assert self._check_valid_array(val), f"NaN/Inf in gr00t_obs['{key}']" - - # Simulate model producing a random action - action_chunk = self._make_random_gr00t_action() - action = ext._convert_gr00t_to_isaaclab_action(action_chunk, chunk_size=self.CHUNK_SIZE) - - assert self._check_valid_array(action), "NaN/Inf in converted action" - # Expected: (B, chunk_size, arm+hand+prefix_pad+suffix_pad) - prefix_pad = 3 # from _SAMPLE_YAML - suffix_pad = 2 - expected_dim = self.ACTION_ARM_DIM + self.ACTION_HAND_DIM + prefix_pad + suffix_pad - assert action.shape == (self.BATCH_SIZE, self.CHUNK_SIZE, expected_dim) - - def test_multi_step_no_nan(self, set_config_env) -> None: - """Run NUM_STEPS random steps; no NaN/Inf should ever appear.""" - ext._load_full_cfg() - - for step in range(self.NUM_STEPS): - obs = self._make_random_obs() - gr00t_obs = ext._convert_isaaclab_obs_to_gr00t(obs) - - for key, val in gr00t_obs.items(): - if isinstance(val, np.ndarray): - assert self._check_valid_array(val), f"Step {step}: NaN/Inf in gr00t_obs['{key}']" - - action_chunk = self._make_random_gr00t_action() - action = ext._convert_gr00t_to_isaaclab_action(action_chunk, chunk_size=1) - - assert self._check_valid_array(action), f"Step {step}: NaN/Inf in action" - assert action.ndim == 3 and action.shape[0] == self.BATCH_SIZE - - def test_varying_batch_sizes(self, set_config_env) -> None: - """Pipeline should work for different batch sizes (1, 16, 128).""" - ext._load_full_cfg() - - for B in (1, 16, 128): - H, W, C = self.IMG_H, self.IMG_W, self.IMG_C - obs = { - "main_images": torch.rand(B, H, W, C), - "states": torch.randn(B, self.STATE_DIM), - "task_descriptions": ["test"] * B, - } - gr00t_obs = ext._convert_isaaclab_obs_to_gr00t(obs) - assert gr00t_obs["video.room_view"].shape[0] == B - - action_chunk = { - "arm": np.random.randn(B, 4, self.ACTION_ARM_DIM), - "hand": np.random.randn(B, 4, self.ACTION_HAND_DIM), - } - action = ext._convert_gr00t_to_isaaclab_action(action_chunk, chunk_size=2) - assert action.shape[0] == B - - def test_action_padding_is_zero(self, set_config_env) -> None: - """Prefix and suffix padding regions must always be exactly zero.""" - ext._load_full_cfg() - - for _ in range(10): - action_chunk = self._make_random_gr00t_action() - action = ext._convert_gr00t_to_isaaclab_action(action_chunk, chunk_size=self.CHUNK_SIZE) - # prefix_pad=3 → first 3 cols zero; suffix_pad=2 → last 2 cols zero - np.testing.assert_array_equal(action[:, :, :3], 0.0) - np.testing.assert_array_equal(action[:, :, -2:], 0.0) - - def test_obs_state_slicing_consistency(self, set_config_env) -> None: - """State slices must match the original tensor content after conversion.""" - ext._load_full_cfg() - - states = torch.arange(self.STATE_DIM, dtype=torch.float32).unsqueeze(0).expand(self.BATCH_SIZE, -1) - obs = {"states": states, "task_descriptions": ["t"] * self.BATCH_SIZE} - gr00t_obs = ext._convert_isaaclab_obs_to_gr00t(obs) - - # gr00t_mapping.state[0]: slice [0,7] → state.arm - np.testing.assert_allclose(gr00t_obs["state.arm"][0, 0], np.arange(7, dtype=np.float32), atol=1e-6) - # gr00t_mapping.state[1]: slice [7,14] → state.hand - np.testing.assert_allclose(gr00t_obs["state.hand"][0, 0], np.arange(7, 14, dtype=np.float32), atol=1e-6) - - def test_image_value_preservation(self, set_config_env) -> None: - """Pixel values should survive the obs conversion without corruption.""" - ext._load_full_cfg() - - B, H, W, C = 2, 8, 8, 3 - img = torch.rand(B, H, W, C) - obs = {"main_images": img, "task_descriptions": ["t"] * B} - gr00t_obs = ext._convert_isaaclab_obs_to_gr00t(obs) - - np.testing.assert_allclose( - gr00t_obs["video.room_view"][:, 0], - img.cpu().numpy(), - atol=1e-6, - ) diff --git a/source/isaaclab_contrib/test/sensors/test_visuotactile_render.py b/source/isaaclab_contrib/test/sensors/test_visuotactile_render.py index 6b9902ea09fa..bc8f9e720243 100644 --- a/source/isaaclab_contrib/test/sensors/test_visuotactile_render.py +++ b/source/isaaclab_contrib/test/sensors/test_visuotactile_render.py @@ -90,27 +90,12 @@ def gelsight_render_setup(): ) device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu") - # Create render instance + # Create render instance; skip only when the data files cannot be fetched try: render = GelsightRender(cfg, device=device) - yield render, device - except Exception as e: - # If initialization fails (e.g., missing data files), skip tests - pytest.skip(f"GelsightRender initialization failed (likely network/Nucleus issue): {e}") - - -def test_gelsight_render_initialization(gelsight_render_setup): - """Test GelsightRender initialization with default files.""" - render, device = gelsight_render_setup - - # Check that render object was created - assert render is not None - assert render.device == device - - # Check that background was loaded (non-empty) - assert render.background is not None - assert render.background.size > 0 - assert render.background.shape[2] == 3 # RGB + except OSError as e: + pytest.skip(f"GelsightRender data files unavailable (likely network/Nucleus issue): {e}") + yield render, device def test_gelsight_render_compute(gelsight_render_setup): @@ -126,8 +111,12 @@ def test_gelsight_render_compute(gelsight_render_setup): # Render output = render.render(height_map) + flat_output = render.render(torch.zeros_like(height_map)) # Check output assert output is not None assert output.shape == (1, height, width, 3) assert output.dtype == torch.uint8 + # The bump changes the rendered image relative to a flat gel + bump = (0, slice(height // 4, height // 2), slice(width // 4, width // 2)) + assert not torch.equal(output[bump], flat_output[bump]) diff --git a/source/isaaclab_contrib/test/sensors/test_visuotactile_sensor.py b/source/isaaclab_contrib/test/sensors/test_visuotactile_sensor.py index 6bbdd1ef9145..15398d8e9949 100644 --- a/source/isaaclab_contrib/test/sensors/test_visuotactile_sensor.py +++ b/source/isaaclab_contrib/test/sensors/test_visuotactile_sensor.py @@ -241,13 +241,13 @@ def test_sensor_minimum_config(setup_minimum_config): assert sensor_minimum.data.tactile_normal_force is None assert sensor_minimum.data.tactile_shear_force is None - # Check reset functionality + # Resetting (all and selected envs) keeps the disabled outputs unset sensor_minimum.reset() - - for i in range(10): - sim.step() - sensor_minimum.update(dt) sensor_minimum.reset(env_ids=[0]) + assert sensor_minimum.data.tactile_depth_image is None + assert sensor_minimum.data.tactile_rgb_image is None + assert sensor_minimum.data.tactile_points_pos_w is None + assert sensor_minimum.data.tactile_normal_force is None @pytest.mark.isaacsim_ci @@ -272,31 +272,6 @@ def test_sensor_cam_type_false(setup_tactile_cam): assert "Camera configuration data types are not supported" in str(excinfo.value) -@pytest.mark.isaacsim_ci -def test_sensor_cam_set(setup_tactile_cam): - """Test sensor with camera configuration using existing camera prim.""" - sim, sensor_cfg, dt, robot_cfg, object_cfg, nut_cfg = setup_tactile_cam - robot = Articulation(cfg=robot_cfg) - sensor = VisuoTactileSensor(cfg=sensor_cfg) - sim.reset() - sensor.get_initial_render() - for _ in range(10): - sim.step() - sensor.update(dt, force_recompute=True) - robot.update(dt) - assert sensor.is_initialized - assert sensor.data.tactile_depth_image.shape == (1, 320, 240, 1) - assert sensor.data.tactile_rgb_image.shape == (1, 320, 240, 3) - assert sensor.data.tactile_points_pos_w is None - - sensor.reset() - for _ in range(10): - sim.step() - sensor.update(dt, force_recompute=True) - robot.update(dt) - sensor.reset(env_ids=[0]) - - @pytest.mark.isaacsim_ci def test_sensor_cam_set_wrong_prim(setup_tactile_cam): """Test sensor initialization fails with invalid camera prim path.""" @@ -342,18 +317,26 @@ def test_sensor_cam_new_spawn(setup_tactile_cam): def test_sensor_rgb_forcefield(setup_nut_rgb_ff): """Test sensor with both camera and force field enabled, detecting contact forces.""" sim, sensor_cfg, dt, robot_cfg, cube_cfg, nut_cfg = setup_nut_rgb_ff + # A mismatched camera update period is overridden by the sensor's update period. + sensor_cfg.update_period = dt + sensor_cfg.camera_cfg.update_period = dt * 2 robot = Articulation(cfg=robot_cfg) sensor = VisuoTactileSensor(cfg=sensor_cfg) nut = RigidObject(cfg=nut_cfg) sim.reset() sensor.get_initial_render() - for _ in range(10): + assert sensor.cfg.camera_cfg.update_period == sensor.cfg.update_period + for i in range(10): sim.step() sensor.update(dt, force_recompute=True) robot.update(dt) nut.update(dt) - # check str - print(sensor) + assert torch.allclose( + wp.to_torch(sensor._timestamp_last_update), torch.tensor((i + 1) * dt, device=sensor.device) + ) + assert torch.allclose( + wp.to_torch(sensor._camera_sensor._timestamp_last_update), torch.tensor((i + 1) * dt, device=sensor.device) + ) assert sensor.is_initialized assert sensor.data.tactile_depth_image.shape == (1, 320, 240, 1) assert sensor.data.tactile_rgb_image.shape == (1, 320, 240, 3) @@ -426,28 +409,3 @@ def test_sensor_force_field_contact_object_no_sdf(setup_nut_rgb_ff): sensor.update(dt) cube.update(dt) assert "No SDF mesh found under contact object at path" in str(excinfo.value) - - -@pytest.mark.isaacsim_ci -def test_sensor_update_period_mismatch(setup_nut_rgb_ff): - """Test sensor with both camera and force field enabled, detecting contact forces.""" - sim, sensor_cfg, dt, robot_cfg, cube_cfg, nut_cfg = setup_nut_rgb_ff - sensor_cfg.update_period = dt - sensor_cfg.camera_cfg.update_period = dt * 2 - robot = Articulation(cfg=robot_cfg) - sensor = VisuoTactileSensor(cfg=sensor_cfg) - nut = RigidObject(cfg=nut_cfg) - sim.reset() - sensor.get_initial_render() - assert sensor.cfg.camera_cfg.update_period == sensor.cfg.update_period - for i in range(10): - sim.step() - sensor.update(dt, force_recompute=True) - robot.update(dt) - nut.update(dt) - assert torch.allclose( - wp.to_torch(sensor._timestamp_last_update), torch.tensor((i + 1) * dt, device=sensor.device) - ) - assert torch.allclose( - wp.to_torch(sensor._camera_sensor._timestamp_last_update), torch.tensor((i + 1) * dt, device=sensor.device) - ) diff --git a/source/isaaclab_experimental/changelog.d/test-prune-extension-tests.skip b/source/isaaclab_experimental/changelog.d/test-prune-extension-tests.skip new file mode 100644 index 000000000000..79f65a70fc5d --- /dev/null +++ b/source/isaaclab_experimental/changelog.d/test-prune-extension-tests.skip @@ -0,0 +1 @@ +Pruned redundant and vacuous isaaclab_experimental tests and repaired weak assertions in a test-audit campaign. diff --git a/source/isaaclab_experimental/test/envs/mdp/parity_helpers.py b/source/isaaclab_experimental/test/envs/mdp/parity_helpers.py index 2a2e027e5f29..758b2f9ecbee 100644 --- a/source/isaaclab_experimental/test/envs/mdp/parity_helpers.py +++ b/source/isaaclab_experimental/test/envs/mdp/parity_helpers.py @@ -100,16 +100,6 @@ def run_warp_obs(func, env, shape, device=DEVICE, **kwargs): return wp.to_torch(out).clone() -def run_warp_obs_captured(func, env, shape, device=DEVICE, **kwargs): - """Run a warp observation function under CUDA graph capture and return the result.""" - out = wp.zeros(shape, dtype=wp.float32, device=device) - func(env, out, **kwargs) # warm-up - with wp.ScopedCapture() as capture: - func(env, out, **kwargs) - wp.capture_launch(capture.graph) - return wp.to_torch(out).clone() - - def run_warp_rew(func, env, device=DEVICE, **kwargs): """Run a warp reward function and return the result as a torch tensor.""" out = wp.zeros((NUM_ENVS,), dtype=wp.float32, device=device) @@ -117,16 +107,6 @@ def run_warp_rew(func, env, device=DEVICE, **kwargs): return wp.to_torch(out).clone() -def run_warp_rew_captured(func, env, device=DEVICE, **kwargs): - """Run a warp reward function under CUDA graph capture.""" - out = wp.zeros((NUM_ENVS,), dtype=wp.float32, device=device) - func(env, out, **kwargs) # warm-up - with wp.ScopedCapture() as capture: - func(env, out, **kwargs) - wp.capture_launch(capture.graph) - return wp.to_torch(out).clone() - - def run_warp_term(func, env, device=DEVICE, **kwargs): """Run a warp termination function and return the result as a torch tensor.""" out = wp.zeros((NUM_ENVS,), dtype=wp.bool, device=device) @@ -134,12 +114,17 @@ def run_warp_term(func, env, device=DEVICE, **kwargs): return wp.to_torch(out).clone() -def run_warp_term_captured(func, env, device=DEVICE, **kwargs): - """Run a warp termination function under CUDA graph capture.""" - out = wp.zeros((NUM_ENVS,), dtype=wp.bool, device=device) - func(env, out, **kwargs) # warm-up +def run_warp_captured_mutated(func, env, mutate, shape=(NUM_ENVS,), dtype=wp.float32, device=DEVICE, **kwargs): + """Capture a warp term, overwrite its inputs in place with ``mutate()``, replay, and return the result. + + Replaying against unchanged memory cannot reveal a baked stale pointer or scalar, so callers + compare the result against the stable term evaluated on the *mutated* data. + """ + out = wp.zeros(shape, dtype=dtype, device=device) + func(env, out, **kwargs) # warm-up outside the capture with wp.ScopedCapture() as capture: func(env, out, **kwargs) + mutate() wp.capture_launch(capture.graph) return wp.to_torch(out).clone() @@ -294,10 +279,15 @@ def resolve_joint_mask(self, joint_ids=None): class MockWrenchComposer: - """Mock wrench composer with no-op methods.""" + """Mock wrench composer that records the last forces and torques it was handed.""" - def set_forces_and_torques_mask(self, *a, **kw): - pass + def __init__(self): + self.last_forces = None + self.last_torques = None + + def set_forces_and_torques_mask(self, forces=None, torques=None, **kw): + self.last_forces = forces + self.last_torques = torques class MockArticulation: @@ -324,14 +314,15 @@ def __init__(self, data: MockArticulationData, num_bodies: int = 1, num_joints: self.last_vel_target = None self.last_effort_target = None self.last_joint_mask = None + self.last_root_velocity = None # -- Simulation write stubs (no-op, for event tests) -------------------- def write_root_velocity_to_sim(self, *a, **kw): pass - def write_root_velocity_to_sim_mask(self, *a, **kw): - pass + def write_root_velocity_to_sim_mask(self, root_velocity=None, **kw): + self.last_root_velocity = root_velocity def write_root_pose_to_sim(self, *a, **kw): pass diff --git a/source/isaaclab_experimental/test/envs/mdp/test_actions_warp_parity.py b/source/isaaclab_experimental/test/envs/mdp/test_actions_warp_parity.py index f2db4f64fbe9..bcc795a9361b 100644 --- a/source/isaaclab_experimental/test/envs/mdp/test_actions_warp_parity.py +++ b/source/isaaclab_experimental/test/envs/mdp/test_actions_warp_parity.py @@ -103,29 +103,30 @@ class TestJointActions: """Test JointAction subclasses: process, apply, reset.""" def test_joint_effort_process_apply(self, env, asset, actions_wp): - cfg = JointEffortActionCfg(asset_name="robot", joint_names=[".*"]) + cfg = JointEffortActionCfg(asset_name="robot", joint_names=[".*"], scale=2.5) term = JointEffortAction(cfg, env) term.process_actions(actions_wp, action_offset=0) term.apply_actions() - # Processed = raw * scale(1.0) + offset(0.0) = raw - assert_close(term.processed_actions, actions_wp) - assert asset.last_effort_target is not None + # Processed = raw * scale(2.5) + offset(0.0), and that buffer is what reaches the asset + assert_close(term.processed_actions, wp.to_torch(actions_wp) * 2.5) + assert asset.last_effort_target is term.processed_actions - def test_joint_position_default_offset(self, env, asset, art_data, actions_wp): - cfg = JointPositionActionCfg(asset_name="robot", joint_names=[".*"], use_default_offset=True) - term = JointPositionAction(cfg, env) + def test_position_scale_and_offset(self, env, asset, art_data, actions_wp): + """scale=2, use_default_offset=True -> processed == raw * 2 + defaults[0], applied as the target. + Same affine formula as the stable ``JointAction.process_actions``.""" + cfg = JointPositionActionCfg(asset_name="robot", joint_names=[".*"], scale=2.0, use_default_offset=True) + term = JointPositionAction(cfg, env) term.process_actions(actions_wp, action_offset=0) term.apply_actions() - # Processed = raw * 1.0 + default_joint_pos[0] - defaults = art_data.default_joint_pos.torch[0] raw = wp.to_torch(actions_wp) - expected = raw + defaults.unsqueeze(0) + defaults = art_data.default_joint_pos.torch[0] + expected = raw * 2.0 + defaults.unsqueeze(0) assert_close(term.processed_actions, expected) - assert asset.last_pos_target is not None + assert asset.last_pos_target is term.processed_actions def test_joint_action_reset(self, env, asset, actions_wp): cfg = JointEffortActionCfg(asset_name="robot", joint_names=[".*"]) @@ -156,72 +157,3 @@ def test_joint_action_reset_masked(self, env, asset, actions_wp): assert_close(raw_after[: NUM_ENVS // 2], torch.zeros(NUM_ENVS // 2, NUM_JOINTS, device=DEVICE)) # Second half unchanged assert_close(raw_after[NUM_ENVS // 2 :], raw_before[NUM_ENVS // 2 :]) - - def test_joint_action_with_scale(self, env, asset, actions_wp): - cfg = JointEffortActionCfg(asset_name="robot", joint_names=[".*"], scale=2.5) - term = JointEffortAction(cfg, env) - - term.process_actions(actions_wp, action_offset=0) - - raw = wp.to_torch(actions_wp) - expected = raw * 2.5 - assert_close(term.processed_actions, expected) - - -# ============================================================================ -# Mathematical parity tests: warp processed_actions == raw * scale + offset -# (This is the same formula used by the stable JointAction.process_actions.) -# ============================================================================ - - -class TestJointActionMathParity: - """Verify warp processed_actions match the affine formula raw * scale + offset. - - The stable ``JointAction.process_actions`` computes - ``processed = raw * scale + offset``. These tests verify the warp - implementation produces identical results for various scale/offset - configurations, confirming mathematical parity without needing to - instantiate the stable classes (which require a full env). - """ - - def test_effort_identity(self, env, actions_wp): - """scale=1, offset=0 -> processed == raw.""" - cfg = JointEffortActionCfg(asset_name="robot", joint_names=[".*"]) - term = JointEffortAction(cfg, env) - term.process_actions(actions_wp, action_offset=0) - - raw = wp.to_torch(actions_wp) - expected = raw * 1.0 + 0.0 - assert_close(term.processed_actions, expected) - - def test_effort_with_scale(self, env, actions_wp): - """scale=3.0, offset=0 -> processed == raw * 3.""" - cfg = JointEffortActionCfg(asset_name="robot", joint_names=[".*"], scale=3.0) - term = JointEffortAction(cfg, env) - term.process_actions(actions_wp, action_offset=0) - - raw = wp.to_torch(actions_wp) - expected = raw * 3.0 - assert_close(term.processed_actions, expected) - - def test_position_with_default_offset(self, env, art_data, actions_wp): - """use_default_offset=True -> processed == raw + defaults[0].""" - cfg = JointPositionActionCfg(asset_name="robot", joint_names=[".*"], use_default_offset=True) - term = JointPositionAction(cfg, env) - term.process_actions(actions_wp, action_offset=0) - - raw = wp.to_torch(actions_wp) - defaults = art_data.default_joint_pos.torch[0] - expected = raw * 1.0 + defaults.unsqueeze(0) - assert_close(term.processed_actions, expected) - - def test_position_scale_and_offset(self, env, art_data, actions_wp): - """scale=2, use_default_offset=True -> processed == raw * 2 + defaults[0].""" - cfg = JointPositionActionCfg(asset_name="robot", joint_names=[".*"], scale=2.0, use_default_offset=True) - term = JointPositionAction(cfg, env) - term.process_actions(actions_wp, action_offset=0) - - raw = wp.to_torch(actions_wp) - defaults = art_data.default_joint_pos.torch[0] - expected = raw * 2.0 + defaults.unsqueeze(0) - assert_close(term.processed_actions, expected) diff --git a/source/isaaclab_experimental/test/envs/mdp/test_capture_safety.py b/source/isaaclab_experimental/test/envs/mdp/test_capture_safety.py index 686c56c39aeb..7fbc95efebe7 100644 --- a/source/isaaclab_experimental/test/envs/mdp/test_capture_safety.py +++ b/source/isaaclab_experimental/test/envs/mdp/test_capture_safety.py @@ -250,8 +250,8 @@ def mutate() -> None: # Warp MDP terms not yet exercised by this harness. Pre-existing terms only: a *new* term must # arrive with a CAPTURE_SPECS entry instead of a row here. Several are already covered by the -# hand-written ``TestCapturedDataMutation*`` classes in the parity test files; the rest are an -# explicit backlog rather than a silent gap. +# capture-mutate-replay checks in the parity test files; the rest are an explicit backlog +# rather than a silent gap. def _ids(module: str, *names: str) -> list[str]: """Qualified ``":"`` identities for several terms in one module.""" return [f"{module}:{name}" for name in names] @@ -259,7 +259,7 @@ def _ids(module: str, *names: str) -> list[str]: _SH = "isaaclab_experimental.envs.mdp" _TE = "isaaclab_tasks_experimental.core" -_MUT = "covered by the mutate-replay class in the matching parity test file" +_MUT = "covered by the capture-mutate-replay check in the matching parity test file" _NONE = "capturable (fixed dim=num_envs launch over a mask) but no capture coverage yet" _MIRROR = "per-task warp mirror; isaaclab_tasks_experimental has no test directory" @@ -406,9 +406,14 @@ def _discover_warp_mdp_terms() -> set[str]: def test_every_warp_mdp_term_is_declared(): - """Every warp MDP term is either exercised here or listed as an unaudited pre-existing term.""" - declared = {spec.qualified for spec in CAPTURE_SPECS} | set(CAPTURE_UNAUDITED) - undeclared = sorted(_discover_warp_mdp_terms() - declared) + """Every warp MDP term is either exercised here or listed as an unaudited pre-existing term. + + The declarations must also stay truthful: a term moved into the harness loses its unaudited + row, and rows for terms that no longer exist are dropped, so the backlog cannot rot. + """ + specified = {spec.qualified for spec in CAPTURE_SPECS} + discovered = _discover_warp_mdp_terms() + undeclared = sorted(discovered - (specified | set(CAPTURE_UNAUDITED))) assert not undeclared, ( "warp MDP terms with no capture declaration: " @@ -416,18 +421,10 @@ def test_every_warp_mdp_term_is_declared(): + ". Add a CAPTURE_SPECS entry; CAPTURE_UNAUDITED is for pre-existing terms only." ) - -def test_no_term_is_both_specified_and_unaudited(): - """A term moved into the harness must lose its unaudited row, so the backlog stays truthful.""" - overlap = sorted({spec.qualified for spec in CAPTURE_SPECS} & set(CAPTURE_UNAUDITED)) - + overlap = sorted(specified & set(CAPTURE_UNAUDITED)) assert not overlap, f"remove from CAPTURE_UNAUDITED, now exercised here: {overlap}" - -def test_unaudited_terms_still_exist(): - """Drop rows for terms that no longer exist, so the list cannot rot.""" - stale = sorted(set(CAPTURE_UNAUDITED) - _discover_warp_mdp_terms()) - + stale = sorted(set(CAPTURE_UNAUDITED) - discovered) assert not stale, f"CAPTURE_UNAUDITED lists terms that no longer exist: {stale}" diff --git a/source/isaaclab_experimental/test/envs/mdp/test_events_warp_parity.py b/source/isaaclab_experimental/test/envs/mdp/test_events_warp_parity.py index 24b9eb022665..e93e73267938 100644 --- a/source/isaaclab_experimental/test/envs/mdp/test_events_warp_parity.py +++ b/source/isaaclab_experimental/test/envs/mdp/test_events_warp_parity.py @@ -22,7 +22,6 @@ NUM_ACTIONS, NUM_ENVS, NUM_JOINTS, - MockActionManagerTorch, MockActionManagerWarp, MockArticulation, MockArticulationData, @@ -107,101 +106,26 @@ class _Env: return env -@pytest.fixture() -def stable_env(scene, action_wp, episode_length_buf): - """Env with torch action manager (for stable functions).""" - - class _Env: - pass - - env = _Env() - env.scene = scene - env.action_manager = MockActionManagerTorch(action_wp[0], action_wp[1]) - env.num_envs = NUM_ENVS - env.device = DEVICE - env.episode_length_buf = episode_length_buf - env.step_dt = 0.02 - env.max_episode_length_s = 10.0 - return env - - @pytest.fixture() def all_joints_cfg(): return MockSceneEntityCfg("robot", list(range(NUM_JOINTS)), NUM_JOINTS, DEVICE) # ============================================================================ -# Event parity tests: deterministic (zero-width range) warp vs stable +# Event capture-mutate-replay tests # ============================================================================ -class TestEventParity: - """Verify warp event functions produce the same result as stable torch equivalents. - - Since warp and stable use different RNG implementations, parity is tested using - deterministic (zero-width) ranges where randomness has no effect. Both must - produce ``default + 0`` (offset) or ``default * 1`` (scale), clamped to limits. - """ - - def test_reset_joints_by_offset_parity(self, warp_env, stable_env, art_data, all_joints_cfg): - """Zero-offset: both warp and stable should produce clamped defaults.""" - cfg = all_joints_cfg - mask = wp.array([True] * NUM_ENVS, dtype=wp.bool, device=DEVICE) - - # Set known defaults - new_defaults = np.full((NUM_ENVS, NUM_JOINTS), 0.5, dtype=np.float32) - copy_np_to_wp(art_data.default_joint_pos, new_defaults) +def _defaults_beyond_limits(value: float) -> np.ndarray: + """Defaults at ``value`` with half the joints pushed past the +/-3.14 soft limits.""" + defaults = np.full((NUM_ENVS, NUM_JOINTS), value, dtype=np.float32) + defaults[:, 0::4] = 5.0 + defaults[:, 1::4] = -5.0 + return defaults - # Run warp version - warp_evt.reset_joints_by_offset( - warp_env, mask, position_range=(0.0, 0.0), velocity_range=(0.0, 0.0), asset_cfg=cfg - ) - wp.synchronize() - warp_pos = art_data.joint_pos.torch.clone() - warp_vel = art_data.joint_vel.torch.clone() - # Run stable version (writes via write_joint_position_to_sim_index — which our mock - # does not implement, so we compute the expected result directly) - defaults_t = art_data.default_joint_pos.torch.clone() - limits_t = art_data.soft_joint_pos_limits.torch - vel_limits_t = art_data.soft_joint_vel_limits.torch - expected_pos = defaults_t.clamp(limits_t[..., 0], limits_t[..., 1]) - expected_vel = art_data.default_joint_vel.torch.clone().clamp(-vel_limits_t, vel_limits_t) - - assert_close(warp_pos, expected_pos) - assert_close(warp_vel, expected_vel) - - def test_reset_joints_by_scale_parity(self, warp_env, stable_env, art_data, all_joints_cfg): - """Scale=1.0: both warp and stable should produce clamped defaults.""" - cfg = all_joints_cfg - mask = wp.array([True] * NUM_ENVS, dtype=wp.bool, device=DEVICE) - - # Set known defaults - new_defaults = np.full((NUM_ENVS, NUM_JOINTS), 0.25, dtype=np.float32) - copy_np_to_wp(art_data.default_joint_pos, new_defaults) - - # Run warp version - warp_evt.reset_joints_by_scale( - warp_env, mask, position_range=(1.0, 1.0), velocity_range=(1.0, 1.0), asset_cfg=cfg - ) - wp.synchronize() - warp_pos = art_data.joint_pos.torch.clone() - warp_vel = art_data.joint_vel.torch.clone() - - # Expected: default * 1.0, clamped to limits - defaults_t = art_data.default_joint_pos.torch.clone() - limits_t = art_data.soft_joint_pos_limits.torch - vel_limits_t = art_data.soft_joint_vel_limits.torch - expected_pos = defaults_t.clamp(limits_t[..., 0], limits_t[..., 1]) - expected_vel = art_data.default_joint_vel.torch.clone().clamp(-vel_limits_t, vel_limits_t) - - assert_close(warp_pos, expected_pos) - assert_close(warp_vel, expected_vel) - - -# ============================================================================ -# Event capture-mutate-replay tests (from test_mdp_warp_parity.py) -# ============================================================================ +def _clamped(defaults: np.ndarray) -> torch.Tensor: + return torch.tensor(np.clip(defaults, -3.14, 3.14), device=DEVICE) class TestEventCapturedDataMutation: @@ -210,7 +134,7 @@ class TestEventCapturedDataMutation: # -- reset_joints_by_offset ------------------------------------------------- def test_reset_joints_by_offset(self, warp_env, art_data, all_joints_cfg): - """With zero-width offset, result == defaults. Mutate defaults -> result tracks.""" + """With zero-width offset, result == defaults clamped to limits. Mutate defaults -> result tracks.""" cfg = all_joints_cfg mask = wp.array([True] * NUM_ENVS, dtype=wp.bool, device=DEVICE) @@ -225,23 +149,23 @@ def test_reset_joints_by_offset(self, warp_env, art_data, all_joints_cfg): warp_env, mask, position_range=(0.0, 0.0), velocity_range=(0.0, 0.0), asset_cfg=cfg ) - # Mutate defaults in-place - new_defaults = np.full((NUM_ENVS, NUM_JOINTS), 0.5, dtype=np.float32) + # Mutate defaults in-place, some beyond the soft limits + new_defaults = _defaults_beyond_limits(0.5) copy_np_to_wp(art_data.default_joint_pos, new_defaults) # Replay wp.capture_launch(cap.graph) wp.synchronize() - # With zero offset, joint_pos should equal new defaults (clamped to limits [-3.14, 3.14]) - result = art_data.joint_pos.torch - expected = torch.full((NUM_ENVS, NUM_JOINTS), 0.5, device=DEVICE) - assert_close(result, expected) + assert_close(art_data.joint_pos.torch, _clamped(new_defaults)) + vel_limits_t = art_data.soft_joint_vel_limits.torch + expected_vel = art_data.default_joint_vel.torch.clone().clamp(-vel_limits_t, vel_limits_t) + assert_close(art_data.joint_vel.torch, expected_vel) # -- reset_joints_by_scale -------------------------------------------------- def test_reset_joints_by_scale(self, warp_env, art_data, all_joints_cfg): - """With scale=1.0, result == defaults. Mutate defaults -> result tracks.""" + """With scale=1.0, result == defaults clamped to limits. Mutate defaults -> result tracks.""" cfg = all_joints_cfg mask = wp.array([True] * NUM_ENVS, dtype=wp.bool, device=DEVICE) @@ -253,20 +177,21 @@ def test_reset_joints_by_scale(self, warp_env, art_data, all_joints_cfg): warp_env, mask, position_range=(1.0, 1.0), velocity_range=(1.0, 1.0), asset_cfg=cfg ) - new_defaults = np.full((NUM_ENVS, NUM_JOINTS), 0.25, dtype=np.float32) + new_defaults = _defaults_beyond_limits(0.25) copy_np_to_wp(art_data.default_joint_pos, new_defaults) wp.capture_launch(cap.graph) wp.synchronize() - result = art_data.joint_pos.torch - expected = torch.full((NUM_ENVS, NUM_JOINTS), 0.25, device=DEVICE) - assert_close(result, expected) + assert_close(art_data.joint_pos.torch, _clamped(new_defaults)) + vel_limits_t = art_data.soft_joint_vel_limits.torch + expected_vel = art_data.default_joint_vel.torch.clone().clamp(-vel_limits_t, vel_limits_t) + assert_close(art_data.joint_vel.torch, expected_vel) # -- push_by_setting_velocity ----------------------------------------------- def test_push_by_setting_velocity(self, warp_env, art_data, all_joints_cfg): - """With zero-width velocity range, scratch == root_vel_w. Mutate root_vel_w -> scratch tracks.""" + """With zero-width velocity range, the written root velocity == root_vel_w. Mutate root_vel_w -> tracks.""" mask = wp.array([True] * NUM_ENVS, dtype=wp.bool, device=DEVICE) zero_range = { "x": (0.0, 0.0), @@ -288,29 +213,33 @@ def test_push_by_setting_velocity(self, warp_env, art_data, all_joints_cfg): wp.capture_launch(cap.graph) wp.synchronize() - scratch = wp.to_torch(warp_evt.push_by_setting_velocity._scratch_vel) + written = wp.to_torch(warp_env.scene["robot"].last_root_velocity) expected = torch.tensor([1.0, 2.0, 3.0, 0.1, 0.2, 0.3], device=DEVICE).expand(NUM_ENVS, -1) - assert_close(scratch, expected) + assert_close(written, expected) # -- apply_external_force_torque -------------------------------------------- def test_apply_external_force_torque(self, warp_env, art_data, all_joints_cfg): - """With zero-width ranges, forces/torques are zero. Non-zero ranges produce non-zero output.""" - mask = wp.array([True] * NUM_ENVS, dtype=wp.bool, device=DEVICE) + """A degenerate non-zero range reaches masked envs through the replayed kernel; unmasked envs get zero.""" + mask = wp.array([i < NUM_ENVS // 2 for i in range(NUM_ENVS)], dtype=wp.bool, device=DEVICE) - # Zero-range: forces and torques should be zero - warp_evt.apply_external_force_torque(warp_env, mask, force_range=(0.0, 0.0), torque_range=(0.0, 0.0)) + warp_evt.apply_external_force_torque(warp_env, mask, force_range=(2.0, 2.0), torque_range=(3.0, 3.0)) with wp.ScopedCapture() as cap: - warp_evt.apply_external_force_torque(warp_env, mask, force_range=(0.0, 0.0), torque_range=(0.0, 0.0)) + warp_evt.apply_external_force_torque(warp_env, mask, force_range=(2.0, 2.0), torque_range=(3.0, 3.0)) + composer = warp_env.scene["robot"].permanent_wrench_composer + forces = wp.to_torch(composer.last_forces) + torques = wp.to_torch(composer.last_torques) + # clear the buffers so only the replayed kernel can produce the expected values + forces.fill_(-1.0) + torques.fill_(-1.0) wp.capture_launch(cap.graph) wp.synchronize() - forces = wp.to_torch(warp_evt.apply_external_force_torque._scratch_forces) - torques = wp.to_torch(warp_evt.apply_external_force_torque._scratch_torques) - assert_close(forces, torch.zeros_like(forces)) - assert_close(torques, torch.zeros_like(torques)) - - # -- reset_root_state_uniform ----------------------------------------------- + half = NUM_ENVS // 2 + assert_close(forces[:half], torch.full_like(forces[:half], 2.0)) + assert_close(torques[:half], torch.full_like(torques[:half], 3.0)) + assert_close(forces[half:], torch.zeros_like(forces[half:])) + assert_close(torques[half:], torch.zeros_like(torques[half:])) # -- env_mask selectivity --------------------------------------------------- diff --git a/source/isaaclab_experimental/test/envs/mdp/test_observations_warp_parity.py b/source/isaaclab_experimental/test/envs/mdp/test_observations_warp_parity.py index 9f1eea23bf8d..0f014cc2b3fd 100644 --- a/source/isaaclab_experimental/test/envs/mdp/test_observations_warp_parity.py +++ b/source/isaaclab_experimental/test/envs/mdp/test_observations_warp_parity.py @@ -36,8 +36,8 @@ MockSceneEntityCfg, assert_close, mutate_art_data, + run_warp_captured_mutated, run_warp_obs, - run_warp_obs_captured, ) import isaaclab.envs.mdp.observations as stable_obs @@ -209,148 +209,24 @@ def subset_cfg(): class TestObservationParity: - """Verify experimental observation Warp kernels match stable torch implementations.""" + """Verify experimental observation Warp kernels match stable torch implementations. - # -- Root state observations ------------------------------------------------ - - def test_base_pos_z(self, warp_env, stable_env, all_joints_cfg): - cfg = all_joints_cfg - expected = stable_obs.base_pos_z(stable_env, asset_cfg=cfg) - actual = run_warp_obs(warp_obs.base_pos_z, warp_env, (NUM_ENVS, 1), asset_cfg=cfg) - actual_cap = run_warp_obs_captured(warp_obs.base_pos_z, warp_env, (NUM_ENVS, 1), asset_cfg=cfg) - assert_close(actual, expected) - assert_close(actual_cap, expected) - - def test_base_lin_vel(self, warp_env, stable_env, all_joints_cfg): - cfg = all_joints_cfg - expected = stable_obs.base_lin_vel(stable_env, asset_cfg=cfg) - actual = run_warp_obs(warp_obs.base_lin_vel, warp_env, (NUM_ENVS, 3), asset_cfg=cfg) - actual_cap = run_warp_obs_captured(warp_obs.base_lin_vel, warp_env, (NUM_ENVS, 3), asset_cfg=cfg) - assert_close(actual, expected) - assert_close(actual_cap, expected) - - def test_base_ang_vel(self, warp_env, stable_env, all_joints_cfg): - cfg = all_joints_cfg - expected = stable_obs.base_ang_vel(stable_env, asset_cfg=cfg) - actual = run_warp_obs(warp_obs.base_ang_vel, warp_env, (NUM_ENVS, 3), asset_cfg=cfg) - actual_cap = run_warp_obs_captured(warp_obs.base_ang_vel, warp_env, (NUM_ENVS, 3), asset_cfg=cfg) - assert_close(actual, expected) - assert_close(actual_cap, expected) - - def test_projected_gravity(self, warp_env, stable_env, all_joints_cfg): - cfg = all_joints_cfg - expected = stable_obs.projected_gravity(stable_env, asset_cfg=cfg) - actual = run_warp_obs(warp_obs.projected_gravity, warp_env, (NUM_ENVS, 3), asset_cfg=cfg) - actual_cap = run_warp_obs_captured(warp_obs.projected_gravity, warp_env, (NUM_ENVS, 3), asset_cfg=cfg) - assert_close(actual, expected) - assert_close(actual_cap, expected) - - # -- Joint observations (all joints) ---------------------------------------- - - def test_joint_pos_all(self, warp_env, stable_env, all_joints_cfg): - cfg = all_joints_cfg - expected = stable_obs.joint_pos(stable_env, asset_cfg=cfg) - actual = run_warp_obs(warp_obs.joint_pos, warp_env, (NUM_ENVS, NUM_JOINTS), asset_cfg=cfg) - actual_cap = run_warp_obs_captured(warp_obs.joint_pos, warp_env, (NUM_ENVS, NUM_JOINTS), asset_cfg=cfg) - assert_close(actual, expected) - assert_close(actual_cap, expected) - - def test_joint_vel_all(self, warp_env, stable_env, all_joints_cfg): - cfg = all_joints_cfg - expected = stable_obs.joint_vel(stable_env, asset_cfg=cfg) - actual = run_warp_obs(warp_obs.joint_vel, warp_env, (NUM_ENVS, NUM_JOINTS), asset_cfg=cfg) - actual_cap = run_warp_obs_captured(warp_obs.joint_vel, warp_env, (NUM_ENVS, NUM_JOINTS), asset_cfg=cfg) - assert_close(actual, expected) - assert_close(actual_cap, expected) - - # -- Joint observations (subset) ------------------------------------------- - - def test_joint_pos_subset(self, warp_env, stable_env, subset_cfg): - cfg = subset_cfg - n_selected = len(cfg.joint_ids) - expected = stable_obs.joint_pos(stable_env, asset_cfg=cfg) - actual = run_warp_obs(warp_obs.joint_pos, warp_env, (NUM_ENVS, n_selected), asset_cfg=cfg) - actual_cap = run_warp_obs_captured(warp_obs.joint_pos, warp_env, (NUM_ENVS, n_selected), asset_cfg=cfg) - assert_close(actual, expected) - assert_close(actual_cap, expected) - - def test_joint_vel_subset(self, warp_env, stable_env, subset_cfg): - cfg = subset_cfg - n_selected = len(cfg.joint_ids) - expected = stable_obs.joint_vel(stable_env, asset_cfg=cfg) - actual = run_warp_obs(warp_obs.joint_vel, warp_env, (NUM_ENVS, n_selected), asset_cfg=cfg) - actual_cap = run_warp_obs_captured(warp_obs.joint_vel, warp_env, (NUM_ENVS, n_selected), asset_cfg=cfg) - assert_close(actual, expected) - assert_close(actual_cap, expected) - - # -- Normalized joint position ---------------------------------------------- - - def test_joint_pos_limit_normalized(self, warp_env, stable_env, all_joints_cfg): - cfg = all_joints_cfg - expected = stable_obs.joint_pos_limit_normalized(stable_env, asset_cfg=cfg) - actual = run_warp_obs(warp_obs.joint_pos_limit_normalized, warp_env, (NUM_ENVS, NUM_JOINTS), asset_cfg=cfg) - actual_cap = run_warp_obs_captured( - warp_obs.joint_pos_limit_normalized, warp_env, (NUM_ENVS, NUM_JOINTS), asset_cfg=cfg - ) - assert_close(actual, expected) - assert_close(actual_cap, expected) - - # -- Action observation ----------------------------------------------------- - - def test_last_action(self, warp_env, stable_env, action_wp): - # Stable last_action returns env.action_manager.action (torch tensor) - expected = stable_obs.last_action(stable_env) - actual = run_warp_obs(warp_obs.last_action, warp_env, (NUM_ENVS, NUM_ACTIONS)) - actual_cap = run_warp_obs_captured(warp_obs.last_action, warp_env, (NUM_ENVS, NUM_ACTIONS)) - assert_close(actual, expected) - assert_close(actual_cap, expected) - - -# ============================================================================ -# Observation parity tests (from test_mdp_warp_parity_new_terms.py) -# ============================================================================ - - -class TestObservationParityNewTerms: - """Verify observation Warp kernels for newly migrated terms match stable torch implementations.""" + Each term is checked eagerly, then captured, has its inputs overwritten in place, and is + replayed: the replay must match stable on the *new* data, which proves it is capture-safe. + """ - def test_generated_commands(self, warp_env_bodies, stable_env_bodies): - expected = stable_obs.generated_commands(stable_env_bodies, command_name="vel") - actual = run_warp_obs(warp_obs.generated_commands, warp_env_bodies, (NUM_ENVS, CMD_DIM), command_name="vel") - actual_cap = run_warp_obs_captured( - warp_obs.generated_commands, warp_env_bodies, (NUM_ENVS, CMD_DIM), command_name="vel" + @staticmethod + def _check(warp_fn, stable_fn, warp_env, stable_env, art_data, shape, **kwargs): + assert_close(run_warp_obs(warp_fn, warp_env, shape, **kwargs), stable_fn(stable_env, **kwargs)) + actual_cap = run_warp_captured_mutated( + warp_fn, warp_env, lambda: mutate_art_data(art_data, warp_env), shape=shape, **kwargs ) - assert_close(actual, expected) - assert_close(actual_cap, expected) + assert_close(actual_cap, stable_fn(stable_env, **kwargs)) - -# ============================================================================ -# Capture-then-mutate-then-replay observation tests (from test_mdp_warp_parity.py) -# ============================================================================ - - -def _mutate_art_data(art_data: MockArticulationData, warp_env, rng_seed: int = 200): - """Mutate every data array in-place so captured graphs see fresh values.""" - mutate_art_data(art_data, warp_env, rng_seed=rng_seed) - - -class TestCapturedDataMutationObservations: - """Capture a graph, mutate buffer data in-place, replay -- results must match stable on the *new* data. - - This verifies observation MDP functions are truly capture-safe. - """ - - def _capture_mutate_check_obs(self, warp_fn, stable_fn, warp_env, stable_env, art_data, shape, **kwargs): - out = wp.zeros(shape, dtype=wp.float32, device=DEVICE) - warp_fn(warp_env, out, **kwargs) # warm-up - with wp.ScopedCapture() as cap: - warp_fn(warp_env, out, **kwargs) - _mutate_art_data(art_data, warp_env) - wp.capture_launch(cap.graph) - assert_close(wp.to_torch(out).clone(), stable_fn(stable_env, **kwargs)) + # -- Root state observations ------------------------------------------------ def test_base_pos_z(self, warp_env, stable_env, art_data, all_joints_cfg): - self._capture_mutate_check_obs( + self._check( warp_obs.base_pos_z, stable_obs.base_pos_z, warp_env, @@ -361,7 +237,7 @@ def test_base_pos_z(self, warp_env, stable_env, art_data, all_joints_cfg): ) def test_base_lin_vel(self, warp_env, stable_env, art_data, all_joints_cfg): - self._capture_mutate_check_obs( + self._check( warp_obs.base_lin_vel, stable_obs.base_lin_vel, warp_env, @@ -372,7 +248,7 @@ def test_base_lin_vel(self, warp_env, stable_env, art_data, all_joints_cfg): ) def test_base_ang_vel(self, warp_env, stable_env, art_data, all_joints_cfg): - self._capture_mutate_check_obs( + self._check( warp_obs.base_ang_vel, stable_obs.base_ang_vel, warp_env, @@ -383,7 +259,7 @@ def test_base_ang_vel(self, warp_env, stable_env, art_data, all_joints_cfg): ) def test_projected_gravity(self, warp_env, stable_env, art_data, all_joints_cfg): - self._capture_mutate_check_obs( + self._check( warp_obs.projected_gravity, stable_obs.projected_gravity, warp_env, @@ -393,30 +269,24 @@ def test_projected_gravity(self, warp_env, stable_env, art_data, all_joints_cfg) asset_cfg=all_joints_cfg, ) - def test_joint_pos(self, warp_env, stable_env, art_data, all_joints_cfg): - self._capture_mutate_check_obs( - warp_obs.joint_pos, - stable_obs.joint_pos, - warp_env, - stable_env, - art_data, - (NUM_ENVS, NUM_JOINTS), - asset_cfg=all_joints_cfg, + # -- Joint observations (non-identity subset, so the id gather is exercised) ---- + + def test_joint_pos_subset(self, warp_env, stable_env, art_data, subset_cfg): + shape = (NUM_ENVS, len(subset_cfg.joint_ids)) + self._check( + warp_obs.joint_pos, stable_obs.joint_pos, warp_env, stable_env, art_data, shape, asset_cfg=subset_cfg ) - def test_joint_vel(self, warp_env, stable_env, art_data, all_joints_cfg): - self._capture_mutate_check_obs( - warp_obs.joint_vel, - stable_obs.joint_vel, - warp_env, - stable_env, - art_data, - (NUM_ENVS, NUM_JOINTS), - asset_cfg=all_joints_cfg, + def test_joint_vel_subset(self, warp_env, stable_env, art_data, subset_cfg): + shape = (NUM_ENVS, len(subset_cfg.joint_ids)) + self._check( + warp_obs.joint_vel, stable_obs.joint_vel, warp_env, stable_env, art_data, shape, asset_cfg=subset_cfg ) + # -- Normalized joint position ---------------------------------------------- + def test_joint_pos_limit_normalized(self, warp_env, stable_env, art_data, all_joints_cfg): - self._capture_mutate_check_obs( + self._check( warp_obs.joint_pos_limit_normalized, stable_obs.joint_pos_limit_normalized, warp_env, @@ -426,33 +296,28 @@ def test_joint_pos_limit_normalized(self, warp_env, stable_env, art_data, all_jo asset_cfg=all_joints_cfg, ) + # -- Action observation ----------------------------------------------------- + def test_last_action(self, warp_env, stable_env, art_data): - self._capture_mutate_check_obs( - warp_obs.last_action, - stable_obs.last_action, - warp_env, - stable_env, - art_data, - (NUM_ENVS, NUM_ACTIONS), + # Stable last_action returns env.action_manager.action (torch tensor) + self._check( + warp_obs.last_action, stable_obs.last_action, warp_env, stable_env, art_data, (NUM_ENVS, NUM_ACTIONS) ) -# ============================================================================ -# Capture-mutate-replay observation tests (from test_mdp_warp_parity_new_terms.py) -# ============================================================================ +class TestObservationParityNewTerms: + """Verify observation Warp kernels for newly migrated terms match stable torch implementations.""" + def test_generated_commands(self, warp_env_bodies, stable_env_bodies, cmd_tensor): + expected = stable_obs.generated_commands(stable_env_bodies, command_name="vel") + actual = run_warp_obs(warp_obs.generated_commands, warp_env_bodies, (NUM_ENVS, CMD_DIM), command_name="vel") + assert_close(actual, expected) -class TestCapturedDataMutationObservationsNewTerms: - """Capture graph, mutate buffer data, replay -- verify new-terms observation results match stable.""" + # Mutate the command tensor in-place: the cached zero-copy view must pick it up on replay. + def mutate(): + cmd_tensor[:] = torch.randn_like(cmd_tensor) - def test_generated_commands(self, warp_env_bodies, stable_env_bodies, art_data_bodies, cmd_tensor): - """Mutate command tensor, replay captured graph, verify new commands are read.""" - out = wp.zeros((NUM_ENVS, CMD_DIM), dtype=wp.float32, device=DEVICE) - warp_obs.generated_commands(warp_env_bodies, out, command_name="vel") - with wp.ScopedCapture() as cap: - warp_obs.generated_commands(warp_env_bodies, out, command_name="vel") - # Mutate the command tensor in-place (zero-copy view picks it up) - cmd_tensor[:] = torch.randn_like(cmd_tensor) - wp.capture_launch(cap.graph) - expected = stable_obs.generated_commands(stable_env_bodies, command_name="vel") - assert_close(wp.to_torch(out).clone(), expected) + actual_cap = run_warp_captured_mutated( + warp_obs.generated_commands, warp_env_bodies, mutate, shape=(NUM_ENVS, CMD_DIM), command_name="vel" + ) + assert_close(actual_cap, stable_obs.generated_commands(stable_env_bodies, command_name="vel")) diff --git a/source/isaaclab_experimental/test/envs/mdp/test_rewards_warp_parity.py b/source/isaaclab_experimental/test/envs/mdp/test_rewards_warp_parity.py index 82270d6a4f6b..684b6f052dbd 100644 --- a/source/isaaclab_experimental/test/envs/mdp/test_rewards_warp_parity.py +++ b/source/isaaclab_experimental/test/envs/mdp/test_rewards_warp_parity.py @@ -39,10 +39,11 @@ MockSensorCfg, MockTerminationManager, assert_close, + copy_np_to_wp, mutate_art_data, mutate_body_data, + run_warp_captured_mutated, run_warp_rew, - run_warp_rew_captured, ) import isaaclab.envs.mdp.rewards as stable_rew @@ -211,8 +212,9 @@ def all_joints_cfg(): @pytest.fixture() -def body_cfg(): - return MockBodyCfg("robot", BODY_IDS) +def subset_cfg(): + """Non-identity joint subset, so the masked kernels' false branch is exercised.""" + return MockSceneEntityCfg("robot", [0, 2, 5, 8], NUM_JOINTS, DEVICE) @pytest.fixture() @@ -226,35 +228,48 @@ def sensor_cfg(): class TestRewardParity: - """Verify experimental reward Warp kernels match stable torch implementations.""" + """Verify experimental reward Warp kernels match stable torch implementations. + + Each term is checked eagerly, then captured, has its inputs overwritten in place, and is + replayed: the replay must match stable on the *new* data, which proves it is capture-safe. + """ + + @staticmethod + def _check(warp_fn, stable_fn, warp_env, stable_env, mutate, **kwargs): + assert_close(run_warp_rew(warp_fn, warp_env, **kwargs), stable_fn(stable_env, **kwargs)) + actual_cap = run_warp_captured_mutated(warp_fn, warp_env, mutate, **kwargs) + assert_close(actual_cap, stable_fn(stable_env, **kwargs)) + + @staticmethod + def _mutate_terminated(term_mgr): + def mutate(): + term_mgr.terminated[:] = torch.rand(NUM_ENVS, device=DEVICE) < 0.5 + + return mutate # -- General rewards -------------------------------------------------------- def test_is_alive(self, warp_env, stable_env, term_mgr): # Set some envs as terminated so the reward is non-trivial term_mgr.terminated[::2] = True - expected = stable_rew.is_alive(stable_env) - actual = run_warp_rew(warp_rew.is_alive, warp_env) - actual_cap = run_warp_rew_captured(warp_rew.is_alive, warp_env) - assert_close(actual, expected) - assert_close(actual_cap, expected) + self._check(warp_rew.is_alive, stable_rew.is_alive, warp_env, stable_env, self._mutate_terminated(term_mgr)) def test_is_terminated(self, warp_env, stable_env, term_mgr): term_mgr.terminated[::3] = True - expected = stable_rew.is_terminated(stable_env) - actual = run_warp_rew(warp_rew.is_terminated, warp_env) - actual_cap = run_warp_rew_captured(warp_rew.is_terminated, warp_env) - assert_close(actual, expected) - assert_close(actual_cap, expected) + self._check( + warp_rew.is_terminated, stable_rew.is_terminated, warp_env, stable_env, self._mutate_terminated(term_mgr) + ) @pytest.mark.parametrize( "term_keys", - [".*", ["success"], ["success", "fell_over"], ["time_out"]], - ids=["all", "single", "subset", "timeout_only"], + [".*", ["success", "fell_over"], ["time_out"]], + ids=["all", "subset", "timeout_only"], ) def test_is_terminated_term(self, warp_env, stable_env, term_keys): """The warp twin resolves the term selection to a column mask at init, so every - selection shape is compared against the stable term's per-term lookup.""" + selection shape is compared against the stable term's per-term lookup. + + Capture safety is owned by ``test_capture_safety.py``.""" from isaaclab.managers.manager_term_cfg import RewardTermCfg params = {"term_keys": term_keys} @@ -270,323 +285,104 @@ def test_is_terminated_term(self, warp_env, stable_env, term_keys): assert expected.any() == (term_keys != ["time_out"]) assert_close(run_warp_rew(warp_fn, warp_env, term_keys=term_keys), expected) - assert_close(run_warp_rew_captured(warp_fn, warp_env, term_keys=term_keys), expected) # -- Root penalties --------------------------------------------------------- - def test_lin_vel_z_l2(self, warp_env, stable_env, all_joints_cfg): - cfg = all_joints_cfg - expected = stable_rew.lin_vel_z_l2(stable_env, asset_cfg=cfg) - actual = run_warp_rew(warp_rew.lin_vel_z_l2, warp_env, asset_cfg=cfg) - actual_cap = run_warp_rew_captured(warp_rew.lin_vel_z_l2, warp_env, asset_cfg=cfg) - assert_close(actual, expected) - assert_close(actual_cap, expected) - - def test_ang_vel_xy_l2(self, warp_env, stable_env, all_joints_cfg): - cfg = all_joints_cfg - expected = stable_rew.ang_vel_xy_l2(stable_env, asset_cfg=cfg) - actual = run_warp_rew(warp_rew.ang_vel_xy_l2, warp_env, asset_cfg=cfg) - actual_cap = run_warp_rew_captured(warp_rew.ang_vel_xy_l2, warp_env, asset_cfg=cfg) - assert_close(actual, expected) - assert_close(actual_cap, expected) - - def test_flat_orientation_l2(self, warp_env, stable_env, all_joints_cfg): - cfg = all_joints_cfg - expected = stable_rew.flat_orientation_l2(stable_env, asset_cfg=cfg) - actual = run_warp_rew(warp_rew.flat_orientation_l2, warp_env, asset_cfg=cfg) - actual_cap = run_warp_rew_captured(warp_rew.flat_orientation_l2, warp_env, asset_cfg=cfg) - assert_close(actual, expected) - assert_close(actual_cap, expected) - - # -- Joint L2 penalties (masked) -------------------------------------------- - - def test_joint_vel_l2(self, warp_env, stable_env, all_joints_cfg): - cfg = all_joints_cfg - expected = stable_rew.joint_vel_l2(stable_env, asset_cfg=cfg) - actual = run_warp_rew(warp_rew.joint_vel_l2, warp_env, asset_cfg=cfg) - actual_cap = run_warp_rew_captured(warp_rew.joint_vel_l2, warp_env, asset_cfg=cfg) - assert_close(actual, expected) - assert_close(actual_cap, expected) - - def test_joint_acc_l2(self, warp_env, stable_env, all_joints_cfg): - cfg = all_joints_cfg - expected = stable_rew.joint_acc_l2(stable_env, asset_cfg=cfg) - actual = run_warp_rew(warp_rew.joint_acc_l2, warp_env, asset_cfg=cfg) - actual_cap = run_warp_rew_captured(warp_rew.joint_acc_l2, warp_env, asset_cfg=cfg) - assert_close(actual, expected) - assert_close(actual_cap, expected) - - def test_joint_torques_l2(self, warp_env, stable_env, all_joints_cfg): - cfg = all_joints_cfg - expected = stable_rew.joint_torques_l2(stable_env, asset_cfg=cfg) - actual = run_warp_rew(warp_rew.joint_torques_l2, warp_env, asset_cfg=cfg) - actual_cap = run_warp_rew_captured(warp_rew.joint_torques_l2, warp_env, asset_cfg=cfg) - assert_close(actual, expected) - assert_close(actual_cap, expected) - - # -- Joint L1 penalties (masked) -------------------------------------------- - - def test_joint_vel_l1(self, warp_env, stable_env, all_joints_cfg): - cfg = all_joints_cfg - expected = stable_rew.joint_vel_l1(stable_env, asset_cfg=cfg) - actual = run_warp_rew(warp_rew.joint_vel_l1, warp_env, asset_cfg=cfg) - actual_cap = run_warp_rew_captured(warp_rew.joint_vel_l1, warp_env, asset_cfg=cfg) - assert_close(actual, expected) - assert_close(actual_cap, expected) - - # -- Action penalties ------------------------------------------------------- - - def test_action_l2(self, warp_env, stable_env): - expected = stable_rew.action_l2(stable_env) - actual = run_warp_rew(warp_rew.action_l2, warp_env) - actual_cap = run_warp_rew_captured(warp_rew.action_l2, warp_env) - assert_close(actual, expected) - assert_close(actual_cap, expected) - - def test_action_rate_l2(self, warp_env, stable_env): - expected = stable_rew.action_rate_l2(stable_env) - actual = run_warp_rew(warp_rew.action_rate_l2, warp_env) - actual_cap = run_warp_rew_captured(warp_rew.action_rate_l2, warp_env) - assert_close(actual, expected) - assert_close(actual_cap, expected) - - # -- Limit penalties -------------------------------------------------------- - - def test_joint_pos_limits(self, warp_env, stable_env, all_joints_cfg): - cfg = all_joints_cfg - expected = stable_rew.joint_pos_limits(stable_env, asset_cfg=cfg) - actual = run_warp_rew(warp_rew.joint_pos_limits, warp_env, asset_cfg=cfg) - actual_cap = run_warp_rew_captured(warp_rew.joint_pos_limits, warp_env, asset_cfg=cfg) - assert_close(actual, expected) - assert_close(actual_cap, expected) - - # -- Additional penalties --------------------------------------------------- - - def test_joint_deviation_l1(self, warp_env, stable_env, all_joints_cfg): - cfg = all_joints_cfg - expected = stable_rew.joint_deviation_l1(stable_env, asset_cfg=cfg) - actual = run_warp_rew(warp_rew.joint_deviation_l1, warp_env, asset_cfg=cfg) - actual_cap = run_warp_rew_captured(warp_rew.joint_deviation_l1, warp_env, asset_cfg=cfg) - assert_close(actual, expected) - assert_close(actual_cap, expected) - - -# ============================================================================ -# New reward parity tests (from test_mdp_warp_parity_new_terms.py) -# ============================================================================ - - -class TestNewRewardParity: - """Verify newly migrated reward Warp kernels match stable torch implementations.""" - - def test_track_lin_vel_xy_exp(self, warp_env_bodies, stable_env_bodies, body_cfg): - cfg = MockBodyCfg("robot") - cfg.joint_ids = list(range(NUM_JOINTS)) # needed for stable - std = 0.25 - expected = stable_rew.track_lin_vel_xy_exp(stable_env_bodies, std=std, command_name="vel", asset_cfg=cfg) - actual = run_warp_rew( - warp_rew.track_lin_vel_xy_exp, warp_env_bodies, std=std, command_name="vel", asset_cfg=cfg - ) - actual_cap = run_warp_rew_captured( - warp_rew.track_lin_vel_xy_exp, warp_env_bodies, std=std, command_name="vel", asset_cfg=cfg - ) - assert_close(actual, expected) - assert_close(actual_cap, expected) - - def test_track_ang_vel_z_exp(self, warp_env_bodies, stable_env_bodies, body_cfg): - cfg = MockBodyCfg("robot") - cfg.joint_ids = list(range(NUM_JOINTS)) - std = 0.25 - expected = stable_rew.track_ang_vel_z_exp(stable_env_bodies, std=std, command_name="vel", asset_cfg=cfg) - actual = run_warp_rew(warp_rew.track_ang_vel_z_exp, warp_env_bodies, std=std, command_name="vel", asset_cfg=cfg) - actual_cap = run_warp_rew_captured( - warp_rew.track_ang_vel_z_exp, warp_env_bodies, std=std, command_name="vel", asset_cfg=cfg - ) - assert_close(actual, expected) - assert_close(actual_cap, expected) - - def test_undesired_contacts(self, warp_env_bodies, stable_env_bodies, sensor_cfg): - threshold = 1.0 - expected = stable_rew.undesired_contacts(stable_env_bodies, threshold=threshold, sensor_cfg=sensor_cfg) - actual = run_warp_rew(warp_rew.undesired_contacts, warp_env_bodies, threshold=threshold, sensor_cfg=sensor_cfg) - actual_cap = run_warp_rew_captured( - warp_rew.undesired_contacts, warp_env_bodies, threshold=threshold, sensor_cfg=sensor_cfg - ) - assert_close(actual, expected.float()) - assert_close(actual_cap, expected.float()) - - -# ============================================================================ -# Capture-then-mutate-then-replay reward tests (from test_mdp_warp_parity.py) -# ============================================================================ - - -def _mutate_art_data(art_data: MockArticulationData, warp_env, rng_seed: int = 200): - """Mutate every data array in-place so captured graphs see fresh values.""" - mutate_art_data(art_data, warp_env, rng_seed=rng_seed) - - -def _mutate_body_data(art_data: MockArticulationData, rng_seed=200): - """Mutate body-level and root-level data in-place so captured graphs see fresh values.""" - mutate_body_data(art_data, rng_seed=rng_seed) - - -class TestCapturedDataMutationRewards: - """Capture a graph, mutate buffer data in-place, replay -- results must match stable on the *new* data. - - This verifies reward MDP functions are truly capture-safe. - """ - - def _capture_mutate_check_rew(self, warp_fn, stable_fn, warp_env, stable_env, art_data, **kwargs): - out = wp.zeros((NUM_ENVS,), dtype=wp.float32, device=DEVICE) - warp_fn(warp_env, out, **kwargs) - with wp.ScopedCapture() as cap: - warp_fn(warp_env, out, **kwargs) - _mutate_art_data(art_data, warp_env) - wp.capture_launch(cap.graph) - assert_close(wp.to_torch(out).clone(), stable_fn(stable_env, **kwargs)) - def test_lin_vel_z_l2(self, warp_env, stable_env, art_data, all_joints_cfg): - self._capture_mutate_check_rew( + self._check( warp_rew.lin_vel_z_l2, stable_rew.lin_vel_z_l2, warp_env, stable_env, - art_data, + lambda: mutate_art_data(art_data, warp_env), asset_cfg=all_joints_cfg, ) def test_ang_vel_xy_l2(self, warp_env, stable_env, art_data, all_joints_cfg): - self._capture_mutate_check_rew( + self._check( warp_rew.ang_vel_xy_l2, stable_rew.ang_vel_xy_l2, warp_env, stable_env, - art_data, + lambda: mutate_art_data(art_data, warp_env), asset_cfg=all_joints_cfg, ) def test_flat_orientation_l2(self, warp_env, stable_env, art_data, all_joints_cfg): - self._capture_mutate_check_rew( + self._check( warp_rew.flat_orientation_l2, stable_rew.flat_orientation_l2, warp_env, stable_env, - art_data, + lambda: mutate_art_data(art_data, warp_env), asset_cfg=all_joints_cfg, ) - def test_joint_vel_l2(self, warp_env, stable_env, art_data, all_joints_cfg): - self._capture_mutate_check_rew( - warp_rew.joint_vel_l2, - stable_rew.joint_vel_l2, - warp_env, - stable_env, - art_data, - asset_cfg=all_joints_cfg, - ) + # -- Masked joint penalties (subset cfg, so the mask's false branch runs) --- - def test_joint_acc_l2(self, warp_env, stable_env, art_data, all_joints_cfg): - self._capture_mutate_check_rew( - warp_rew.joint_acc_l2, - stable_rew.joint_acc_l2, + @pytest.mark.parametrize( + "term", + ["joint_vel_l2", "joint_acc_l2", "joint_torques_l2", "joint_vel_l1", "joint_pos_limits", "joint_deviation_l1"], + ) + def test_masked_joint_penalty(self, warp_env, stable_env, art_data, subset_cfg, term): + self._check( + getattr(warp_rew, term), + getattr(stable_rew, term), warp_env, stable_env, - art_data, - asset_cfg=all_joints_cfg, + lambda: mutate_art_data(art_data, warp_env), + asset_cfg=subset_cfg, ) - def test_joint_torques_l2(self, warp_env, stable_env, art_data, all_joints_cfg): - self._capture_mutate_check_rew( - warp_rew.joint_torques_l2, - stable_rew.joint_torques_l2, - warp_env, - stable_env, - art_data, - asset_cfg=all_joints_cfg, - ) + # -- Action penalties ------------------------------------------------------- def test_action_l2(self, warp_env, stable_env, art_data): - self._capture_mutate_check_rew( - warp_rew.action_l2, - stable_rew.action_l2, - warp_env, - stable_env, - art_data, + self._check( + warp_rew.action_l2, stable_rew.action_l2, warp_env, stable_env, lambda: mutate_art_data(art_data, warp_env) ) def test_action_rate_l2(self, warp_env, stable_env, art_data): - self._capture_mutate_check_rew( + self._check( warp_rew.action_rate_l2, stable_rew.action_rate_l2, warp_env, stable_env, - art_data, - ) - - def test_joint_pos_limits(self, warp_env, stable_env, art_data, all_joints_cfg): - self._capture_mutate_check_rew( - warp_rew.joint_pos_limits, - stable_rew.joint_pos_limits, - warp_env, - stable_env, - art_data, - asset_cfg=all_joints_cfg, - ) - - def test_joint_deviation_l1(self, warp_env, stable_env, art_data, all_joints_cfg): - self._capture_mutate_check_rew( - warp_rew.joint_deviation_l1, - stable_rew.joint_deviation_l1, - warp_env, - stable_env, - art_data, - asset_cfg=all_joints_cfg, + lambda: mutate_art_data(art_data, warp_env), ) -# ============================================================================ -# Capture-mutate-replay reward tests for new terms (from test_mdp_warp_parity_new_terms.py) -# ============================================================================ - - -class TestCapturedDataMutationRewardsNewTerms: - """Capture graph, mutate buffer data, replay -- verify new-terms reward results match stable.""" - - def _capture_mutate_check_rew(self, warp_fn, stable_fn, warp_env, stable_env, art_data, **kwargs): - out = wp.zeros((NUM_ENVS,), dtype=wp.float32, device=DEVICE) - warp_fn(warp_env, out, **kwargs) - with wp.ScopedCapture() as cap: - warp_fn(warp_env, out, **kwargs) - _mutate_body_data(art_data) - wp.capture_launch(cap.graph) - expected = stable_fn(stable_env, **kwargs) - assert_close(wp.to_torch(out).clone(), expected) +class TestNewRewardParity: + """Verify newly migrated reward Warp kernels match stable torch implementations.""" - def test_track_lin_vel_xy_exp(self, warp_env_bodies, stable_env_bodies, art_data_bodies): + @pytest.mark.parametrize("term", ["track_lin_vel_xy_exp", "track_ang_vel_z_exp"]) + def test_track_velocity_exp(self, warp_env_bodies, stable_env_bodies, art_data_bodies, term): cfg = MockBodyCfg("robot") - cfg.joint_ids = list(range(NUM_JOINTS)) - self._capture_mutate_check_rew( - warp_rew.track_lin_vel_xy_exp, - stable_rew.track_lin_vel_xy_exp, + cfg.joint_ids = list(range(NUM_JOINTS)) # needed for stable + TestRewardParity._check( + getattr(warp_rew, term), + getattr(stable_rew, term), warp_env_bodies, stable_env_bodies, - art_data_bodies, + lambda: mutate_body_data(art_data_bodies), std=0.25, command_name="vel", asset_cfg=cfg, ) - def test_track_ang_vel_z_exp(self, warp_env_bodies, stable_env_bodies, art_data_bodies): - cfg = MockBodyCfg("robot") - cfg.joint_ids = list(range(NUM_JOINTS)) - self._capture_mutate_check_rew( - warp_rew.track_ang_vel_z_exp, - stable_rew.track_ang_vel_z_exp, - warp_env_bodies, - stable_env_bodies, - art_data_bodies, - std=0.25, - command_name="vel", - asset_cfg=cfg, + def test_undesired_contacts(self, warp_env_bodies, stable_env_bodies, contact_data, sensor_cfg): + threshold = 1.0 + expected = stable_rew.undesired_contacts(stable_env_bodies, threshold=threshold, sensor_cfg=sensor_cfg) + actual = run_warp_rew(warp_rew.undesired_contacts, warp_env_bodies, threshold=threshold, sensor_cfg=sensor_cfg) + assert_close(actual, expected.float()) + + def mutate(): + rng = np.random.RandomState(300) + history = contact_data.net_normal_forces_w_history + copy_np_to_wp(history, rng.randn(*history.shape, 3).astype(np.float32) * 2.0) + wp.synchronize() + + actual_cap = run_warp_captured_mutated( + warp_rew.undesired_contacts, warp_env_bodies, mutate, threshold=threshold, sensor_cfg=sensor_cfg ) + expected = stable_rew.undesired_contacts(stable_env_bodies, threshold=threshold, sensor_cfg=sensor_cfg) + assert_close(actual_cap, expected.float()) diff --git a/source/isaaclab_experimental/test/envs/mdp/test_terminations_warp_parity.py b/source/isaaclab_experimental/test/envs/mdp/test_terminations_warp_parity.py index 15637d2bb0e4..9676f2b06044 100644 --- a/source/isaaclab_experimental/test/envs/mdp/test_terminations_warp_parity.py +++ b/source/isaaclab_experimental/test/envs/mdp/test_terminations_warp_parity.py @@ -41,11 +41,11 @@ MockSensorCfg, MockTerminationManager, assert_equal, + copy_np_to_wp, make_pose_command_term, mutate_art_data, - mutate_body_data, + run_warp_captured_mutated, run_warp_term, - run_warp_term_captured, ) import isaaclab.envs.mdp.terminations as stable_term @@ -211,6 +211,12 @@ def all_joints_cfg(): return MockSceneEntityCfg("robot", list(range(NUM_JOINTS)), NUM_JOINTS, DEVICE) +@pytest.fixture() +def subset_cfg(): + """Non-identity joint subset, so the masked kernel's false branch is exercised.""" + return MockSceneEntityCfg("robot", [0, 2, 5, 8], NUM_JOINTS, DEVICE) + + @pytest.fixture() def sensor_cfg(): return MockSensorCfg("contact_sensor", BODY_IDS) @@ -222,55 +228,81 @@ def sensor_cfg(): class TestTerminationParity: - """Verify experimental termination Warp kernels match stable torch implementations.""" - - def test_root_height_below_minimum(self, warp_env, stable_env, all_joints_cfg): - cfg = all_joints_cfg - min_h = 0.5 - expected = stable_term.root_height_below_minimum(stable_env, minimum_height=min_h, asset_cfg=cfg) - actual = run_warp_term(warp_term.root_height_below_minimum, warp_env, minimum_height=min_h, asset_cfg=cfg) - actual_cap = run_warp_term_captured( - warp_term.root_height_below_minimum, warp_env, minimum_height=min_h, asset_cfg=cfg + """Verify experimental termination Warp kernels match stable torch implementations. + + Each term is checked eagerly, then captured, has its inputs overwritten in place, and is + replayed: the replay must match stable on the *new* data, which proves it is capture-safe. + """ + + def test_root_height_below_minimum(self, warp_env, stable_env, art_data, all_joints_cfg): + kwargs = {"minimum_height": 0.5, "asset_cfg": all_joints_cfg} + expected = stable_term.root_height_below_minimum(stable_env, **kwargs) + assert_equal(run_warp_term(warp_term.root_height_below_minimum, warp_env, **kwargs), expected) + + actual_cap = run_warp_captured_mutated( + warp_term.root_height_below_minimum, + warp_env, + lambda: mutate_art_data(art_data, warp_env), + dtype=wp.bool, + **kwargs, ) - assert_equal(actual, expected) - assert_equal(actual_cap, expected) + assert_equal(actual_cap, stable_term.root_height_below_minimum(stable_env, **kwargs)) - def test_joint_pos_out_of_manual_limit(self, warp_env, stable_env, all_joints_cfg): - cfg = all_joints_cfg + def test_joint_pos_out_of_manual_limit(self, warp_env, stable_env, art_data, subset_cfg): bounds = (-1.0, 1.0) + cfg = subset_cfg expected = stable_term.joint_pos_out_of_manual_limit(stable_env, bounds=bounds, asset_cfg=cfg) actual = run_warp_term(warp_term.joint_pos_out_of_manual_limit, warp_env, bounds=bounds, asset_cfg=cfg) - actual_cap = run_warp_term_captured( - warp_term.joint_pos_out_of_manual_limit, warp_env, bounds=bounds, asset_cfg=cfg - ) assert_equal(actual, expected) - assert_equal(actual_cap, expected) - -# ============================================================================ -# Termination parity tests (from test_mdp_warp_parity_new_terms.py) -# ============================================================================ + # joint_pos_out_of_manual_limit uses a 2D kernel that only writes True + # (never clears to False), so the output must be zeroed before each call. + # We include the zeroing inside the captured graph. + out = wp.zeros((NUM_ENVS,), dtype=wp.bool, device=DEVICE) + # warm-up + out.zero_() + warp_term.joint_pos_out_of_manual_limit(warp_env, out, bounds=bounds, asset_cfg=cfg) + # capture (including the zero) + with wp.ScopedCapture() as cap: + out.zero_() + warp_term.joint_pos_out_of_manual_limit(warp_env, out, bounds=bounds, asset_cfg=cfg) + mutate_art_data(art_data, warp_env) + wp.capture_launch(cap.graph) + expected = stable_term.joint_pos_out_of_manual_limit(stable_env, bounds=bounds, asset_cfg=cfg) + assert_equal(wp.to_torch(out).clone(), expected) class TestTerminationParityNewTerms: """Verify termination Warp kernels for newly migrated terms match stable torch implementations.""" def test_time_out(self, warp_env_bodies, stable_env_bodies): + # Lengths straddle max_episode_length (500), so both outcomes occur. + warp_env_bodies.episode_length_buf[:] = torch.randint(0, 600, (NUM_ENVS,), dtype=torch.int64, device=DEVICE) expected = stable_term.time_out(stable_env_bodies) - actual = run_warp_term(warp_term.time_out, warp_env_bodies) - actual_cap = run_warp_term_captured(warp_term.time_out, warp_env_bodies) - assert_equal(actual, expected) - assert_equal(actual_cap, expected) - - def test_illegal_contact(self, warp_env_bodies, stable_env_bodies, sensor_cfg): - threshold = 1.0 - expected = stable_term.illegal_contact(stable_env_bodies, threshold=threshold, sensor_cfg=sensor_cfg) - actual = run_warp_term(warp_term.illegal_contact, warp_env_bodies, threshold=threshold, sensor_cfg=sensor_cfg) - actual_cap = run_warp_term_captured( - warp_term.illegal_contact, warp_env_bodies, threshold=threshold, sensor_cfg=sensor_cfg + assert expected.any() and not expected.all() + assert_equal(run_warp_term(warp_term.time_out, warp_env_bodies), expected) + + def mutate(): + warp_env_bodies.episode_length_buf[:] = torch.randint(0, 600, (NUM_ENVS,), dtype=torch.int64, device=DEVICE) + + actual_cap = run_warp_captured_mutated(warp_term.time_out, warp_env_bodies, mutate, dtype=wp.bool) + assert_equal(actual_cap, stable_term.time_out(stable_env_bodies)) + + def test_illegal_contact(self, warp_env_bodies, stable_env_bodies, contact_data, sensor_cfg): + kwargs = {"threshold": 1.0, "sensor_cfg": sensor_cfg} + expected = stable_term.illegal_contact(stable_env_bodies, **kwargs) + assert_equal(run_warp_term(warp_term.illegal_contact, warp_env_bodies, **kwargs), expected) + + def mutate(): + rng = np.random.RandomState(300) + history = contact_data.net_normal_forces_w_history + copy_np_to_wp(history, rng.randn(*history.shape, 3).astype(np.float32) * 2.0) + wp.synchronize() + + actual_cap = run_warp_captured_mutated( + warp_term.illegal_contact, warp_env_bodies, mutate, dtype=wp.bool, **kwargs ) - assert_equal(actual, expected) - assert_equal(actual_cap, expected) + assert_equal(actual_cap, stable_term.illegal_contact(stable_env_bodies, **kwargs)) class TestPoseCommandSuccessParity: @@ -307,9 +339,9 @@ def test_pose_command_success(self, scene_bodies, position_threshold, orientatio ) assert_equal(run_warp_term(warp_fn, env, **params), expected) # the stable term ORs into the sticky tracker; the twin must too, or the terminating - # step is never recorded before ``reset()`` reads and clears it + # step is never recorded before ``reset()`` reads and clears it. Capture safety is + # owned by ``test_capture_safety.py``. assert_equal(command._succeeded, expected) - assert_equal(run_warp_term_captured(warp_fn, env, **params), expected) def _run_both(self, scene_bodies, **thresholds): """Return ``(stable, warp)`` results for one threshold configuration.""" @@ -345,84 +377,3 @@ def test_negative_threshold_is_configured_not_unset(self, scene_bodies): assert not expected.any(), "no distance is below a negative threshold" assert_equal(actual, expected) - - -# ============================================================================ -# Capture-then-mutate-then-replay termination tests (from test_mdp_warp_parity.py) -# ============================================================================ - - -def _mutate_art_data(art_data: MockArticulationData, warp_env, rng_seed: int = 200): - """Mutate every data array in-place so captured graphs see fresh values.""" - mutate_art_data(art_data, warp_env, rng_seed=rng_seed) - - -def _mutate_body_data(art_data: MockArticulationData, rng_seed=200): - """Mutate body-level and root-level data in-place so captured graphs see fresh values.""" - mutate_body_data(art_data, rng_seed=rng_seed) - - -class TestCapturedDataMutationTerminations: - """Capture a graph, mutate buffer data in-place, replay -- results must match stable on the *new* data. - - This verifies termination MDP functions are truly capture-safe. - """ - - def _capture_mutate_check_term(self, warp_fn, stable_fn, warp_env, stable_env, art_data, **kwargs): - out = wp.zeros((NUM_ENVS,), dtype=wp.bool, device=DEVICE) - warp_fn(warp_env, out, **kwargs) - with wp.ScopedCapture() as cap: - warp_fn(warp_env, out, **kwargs) - _mutate_art_data(art_data, warp_env) - wp.capture_launch(cap.graph) - assert_equal(wp.to_torch(out).clone(), stable_fn(stable_env, **kwargs)) - - def test_root_height_below_minimum(self, warp_env, stable_env, art_data, all_joints_cfg): - self._capture_mutate_check_term( - warp_term.root_height_below_minimum, - stable_term.root_height_below_minimum, - warp_env, - stable_env, - art_data, - minimum_height=0.5, - asset_cfg=all_joints_cfg, - ) - - def test_joint_pos_out_of_manual_limit(self, warp_env, stable_env, art_data, all_joints_cfg): - # joint_pos_out_of_manual_limit uses a 2D kernel that only writes True - # (never clears to False), so the output must be zeroed before each call. - # We include the zeroing inside the captured graph. - bounds = (-1.0, 1.0) - cfg = all_joints_cfg - out = wp.zeros((NUM_ENVS,), dtype=wp.bool, device=DEVICE) - # warm-up - out.zero_() - warp_term.joint_pos_out_of_manual_limit(warp_env, out, bounds=bounds, asset_cfg=cfg) - # capture (including the zero) - with wp.ScopedCapture() as cap: - out.zero_() - warp_term.joint_pos_out_of_manual_limit(warp_env, out, bounds=bounds, asset_cfg=cfg) - _mutate_art_data(art_data, warp_env) - wp.capture_launch(cap.graph) - expected = stable_term.joint_pos_out_of_manual_limit(stable_env, bounds=bounds, asset_cfg=cfg) - assert_equal(wp.to_torch(out).clone(), expected) - - -# ============================================================================ -# Capture-mutate-replay termination tests for new terms (from test_mdp_warp_parity_new_terms.py) -# ============================================================================ - - -class TestCapturedDataMutationTerminationsNewTerms: - """Capture graph, mutate buffer data, replay -- verify new-terms termination results match stable.""" - - def test_time_out(self, warp_env_bodies, stable_env_bodies, art_data_bodies): - out = wp.zeros((NUM_ENVS,), dtype=wp.bool, device=DEVICE) - warp_term.time_out(warp_env_bodies, out) - with wp.ScopedCapture() as cap: - warp_term.time_out(warp_env_bodies, out) - # Mutate episode length in-place - warp_env_bodies.episode_length_buf[:] = torch.randint(0, 600, (NUM_ENVS,), dtype=torch.int64, device=DEVICE) - wp.capture_launch(cap.graph) - expected = stable_term.time_out(stable_env_bodies) - assert_equal(wp.to_torch(out).clone(), expected) diff --git a/source/isaaclab_experimental/test/envs/test_frontend.py b/source/isaaclab_experimental/test/envs/test_frontend.py index c6cbbbb3a322..ff71cdb49b0d 100644 --- a/source/isaaclab_experimental/test/envs/test_frontend.py +++ b/source/isaaclab_experimental/test/envs/test_frontend.py @@ -7,7 +7,6 @@ Pure-Python unit tests; no app launch. Covers: -* :class:`Workflow` enum surface. * :func:`SceneEntityCfg.from_stable` field copy. * :func:`_require_newton_physics` hard-check. * :func:`_walk_terms` recursive ManagerTermBaseCfg discovery over instance @@ -15,8 +14,9 @@ * :meth:`_mirror_module` / :meth:`_nearest_mdp_module` deterministic mirror resolution and :meth:`_cfg_route_modules` cfg-hierarchy resolution. * :func:`_promote_scene_entity_cfgs` walks ``term.params`` dicts. -* :func:`_swap_mdp` swaps ``func`` *and* ``class_type``; raises with a path - list when twins are missing. +* :func:`_swap_mdp` raises with a path list when twins are missing and skips + already-warp terms (the swap itself is covered by the real-registry tests in + ``test_frontend_cfg_conversion.py``). * :func:`_resolve_warp_twin` rejects stable-origin re-exports. * :func:`_assert_direct_warp_registration` accepts warp-rooted entry points and rejects stable ones. @@ -35,7 +35,6 @@ from isaaclab_experimental.envs.frontend import ( FrontendIncompatibleError, WarpFrontend, - Workflow, ) from isaaclab_experimental.managers.scene_entity_cfg import SceneEntityCfg as WarpSceneEntityCfg from isaaclab_newton.physics import NewtonCfg @@ -130,16 +129,6 @@ def _term(func=None, params: dict | None = None) -> RewardTermCfg: return RewardTermCfg(func=func or _stable_func, weight=1.0, params=params or {}) -# ====================================================================== -# Enums -# ====================================================================== - - -def test_workflow_values(): - assert Workflow.MANAGER_BASED == "manager_based" - assert Workflow.DIRECT == "direct" - - # ====================================================================== # build() # ====================================================================== @@ -188,17 +177,6 @@ def fake_env(*, cfg: Any, **kwargs: Any) -> Any: # ====================================================================== -def test_from_stable_copies_minimum_fields(): - stable = StableSceneEntityCfg(name="robot") - warp = WarpSceneEntityCfg.from_stable(stable) - assert isinstance(warp, WarpSceneEntityCfg) - assert warp.name == "robot" - # Warp-only fields stay None until :meth:`resolve` runs. - assert warp.joint_mask is None - assert warp.joint_ids_wp is None - assert warp.body_ids_wp is None - - def test_from_stable_copies_all_selection_fields(): stable = StableSceneEntityCfg( name="robot", @@ -226,6 +204,11 @@ def test_from_stable_copies_all_selection_fields(): "preserve_order", ): assert getattr(warp, field) == getattr(stable, field), f"field {field!r} mismatch" + assert isinstance(warp, WarpSceneEntityCfg) + # Warp-only fields stay None until :meth:`resolve` runs. + assert warp.joint_mask is None + assert warp.joint_ids_wp is None + assert warp.body_ids_wp is None # ====================================================================== @@ -243,17 +226,13 @@ def test_require_newton_passes_for_newton(): fe.WarpFrontend._require_newton_physics(_cfg_with_physics(NewtonCfg()), "Isaac-Test-v0") # no raise -def test_require_newton_rejects_physx(): +@pytest.mark.parametrize("physics", [PhysxCfg(), None], ids=["physx", "none"]) +def test_require_newton_rejects_non_newton(physics): with pytest.raises(FrontendIncompatibleError) as exc: - fe.WarpFrontend._require_newton_physics(_cfg_with_physics(PhysxCfg()), "Isaac-Test-v0") + fe.WarpFrontend._require_newton_physics(_cfg_with_physics(physics), "Isaac-Test-v0") message = str(exc.value) assert "Select Newton while composing the task configuration" in message - assert "PhysxCfg" in message - - -def test_require_newton_rejects_none(): - with pytest.raises(FrontendIncompatibleError): - fe.WarpFrontend._require_newton_physics(_cfg_with_physics(None), "Isaac-Test-v0") + assert type(physics).__name__ in message # ====================================================================== @@ -271,39 +250,6 @@ def test_walk_terms_yields_each_term_with_its_path(): assert paths == {"rewards.r1", "rewards.r2", "events.e1"} -def test_walk_terms_descends_into_obs_subgroups(): - cfg = _CfgFixture( - observations=_ObservationsCfg( - policy=_PolicyObsGroup(o1=ObservationTermCfg(func=_stable_func)), - perception=_ExtraObsGroup(o3=ObservationTermCfg(func=_stable_func)), - ), - ) - paths = {".".join(p) for p, _ in fe.WarpFrontend._walk_terms(cfg)} - # Discovery is purely type-driven; no obs group name is hardcoded. - assert paths == {"observations.policy.o1", "observations.perception.o3"} - - -def test_walk_terms_stops_at_terms(): - # The walker must not descend into term.params / term.func — yields the term itself. - nested_se_cfg = StableSceneEntityCfg(name="robot") - cfg = _CfgFixture(rewards=_RewardsCfg(r1=_term(params={"asset_cfg": nested_se_cfg}))) - terms = list(fe.WarpFrontend._walk_terms(cfg)) - assert len(terms) == 1 - _, term = terms[0] - assert isinstance(term, RewardTermCfg) - - -def test_walk_terms_skips_non_configclass_attrs(): - # A namespace without __dataclass_fields__ is not descended into. - cfg = types.SimpleNamespace(some_plain_attr="hello") - assert list(fe.WarpFrontend._walk_terms(cfg)) == [] - - -def test_walk_terms_skips_none_subtrees(): - cfg = _CfgFixture(rewards=None, events=None) - assert list(fe.WarpFrontend._walk_terms(cfg)) == [] - - def test_walk_terms_descends_into_dict_groups(): # A manager group expressed as a dict of {name: term} (rather than a configclass) # must still have its terms discovered — the warp managers accept both forms. @@ -350,17 +296,6 @@ def test_mirror_module_maps_stable_roots(): assert fe.WarpFrontend._mirror_module("") is None -def test_nearest_mdp_module_truncates_at_mdp_boundary(): - # A symbol module resolves to its own mdp package on the mirrored path. - assert ( - fe.WarpFrontend._nearest_mdp_module("isaaclab_tasks_experimental.core.locomotion.mdp.rewards") - == "isaaclab_tasks_experimental.core.locomotion.mdp" - ) - assert fe.WarpFrontend._nearest_mdp_module("isaaclab_experimental.envs.mdp.events") == ( - "isaaclab_experimental.envs.mdp" - ) - - def test_nearest_mdp_module_walks_up_cfg_paths(): # A cfg module path walks up to the task family's mdp package. assert ( @@ -408,18 +343,6 @@ def _patch_twin_modules(monkeypatch: pytest.MonkeyPatch, modules: list[Any]) -> ) -def test_swap_mdp_swaps_func_and_class_type(monkeypatch: pytest.MonkeyPatch): - fake = _fake_mdp_module({"_stable_func": _warp_twin_func, "_StableActionCls": _WarpActionCls}) - _patch_twin_modules(monkeypatch, [fake]) - term_reward = _term(func=_stable_func) - term_action = _term() - term_action.class_type = _StableActionCls # set attr to exercise class_type swap - cfg = _CfgFixture(rewards=_RewardsCfg(r1=term_reward, r2=term_action)) - fe.WarpFrontend._swap_mdp(cfg, "Isaac-Test-v0") - assert cfg.rewards.r1.func is _warp_twin_func - assert cfg.rewards.r2.class_type is _WarpActionCls - - def test_swap_mdp_missing_twin_raises_with_path_list(monkeypatch: pytest.MonkeyPatch): _patch_twin_modules(monkeypatch, [_fake_mdp_module({})]) cfg = _CfgFixture(rewards=_RewardsCfg(r1=_term(func=_stable_func))) @@ -508,37 +431,15 @@ def test_promote_scene_entity_cfgs_walks_all_term_groups(): # ====================================================================== -# Twin resolution and swap-candidate heuristic +# Twin resolution # ====================================================================== -def test_resolve_warp_twin_accepts_warp_origin(): - module = types.SimpleNamespace(foo=_warp_twin_func) - assert fe.WarpFrontend._resolve_warp_twin("foo", [module]) is _warp_twin_func - - def test_resolve_warp_twin_rejects_stable_origin(): module = types.SimpleNamespace(foo=_stable_func) # same name, stable origin assert fe.WarpFrontend._resolve_warp_twin("foo", [module]) is None -def test_resolve_warp_twin_returns_none_when_absent(): - assert fe.WarpFrontend._resolve_warp_twin("missing", [types.SimpleNamespace()]) is None - - -def test_is_swap_candidate_stable_callable(): - assert fe.WarpFrontend._is_swap_candidate(_stable_func) - - -def test_is_swap_candidate_rejects_warp_callable(): - assert not fe.WarpFrontend._is_swap_candidate(_warp_twin_func) - - -def test_is_swap_candidate_rejects_non_callables(): - assert not fe.WarpFrontend._is_swap_candidate(42) - assert not fe.WarpFrontend._is_swap_candidate("string") - - # ====================================================================== # Direct workflow guard # ====================================================================== @@ -550,9 +451,6 @@ def test_is_swap_candidate_rejects_non_callables(): "_Frontend-Test-Declared-Direct-v0": ("isaaclab_tasks.fake:DirectEnv", "types:SimpleNamespace"), "_Frontend-Test-Broken-Direct-v0": ("isaaclab_tasks.fake:DirectEnv", "definitely_not_a_module:Env"), "_Frontend-Test-Missing-Class-Direct-v0": ("isaaclab_tasks.fake:DirectEnv", "types:NoSuchClass"), - # Follows the _direct_env:Env convention, so it resolves to the - # mirrored _warp_env:WarpEnv with no warp_entry_point. - "_Frontend-Test-NameMirror-Direct-v0": ("isaaclab_tasks.core.cartpole.cartpole_direct_env:CartpoleEnv", None), # Real shared reorient env class: both Allegro and Shadow register it, and it # mirrors to the real ReorientDirectWarpEnv (which declares cfg AllegroHandEnvCfg). # Used to exercise the cfg-compatibility gate on a genuinely shared base env. @@ -599,15 +497,6 @@ def test_resolve_direct_warp_class_returns_none_without_declaration(): assert fe.WarpFrontend._resolve_direct_warp_class("_Frontend-Test-Stable-Direct-v0") is None -def test_resolve_direct_warp_class_by_name_mirror(): - """A direct task following the ``_direct_env:Env`` convention resolves - to ``_warp_env:WarpEnv`` with no ``warp_entry_point`` declared.""" - env_class = fe.WarpFrontend._resolve_direct_warp_class("_Frontend-Test-NameMirror-Direct-v0") - assert env_class is not None - assert env_class.__name__ == "CartpoleWarpEnv" - assert env_class.__module__.endswith("cartpole_warp_env") - - def test_resolve_direct_warp_class_rejects_broken_module(): with pytest.raises(FrontendIncompatibleError): fe.WarpFrontend._resolve_direct_warp_class("_Frontend-Test-Broken-Direct-v0") @@ -655,29 +544,11 @@ class _GuardWarpEnvNoAnnotation: pass -def test_declared_stable_cfg_name_reads_annotation(): - assert fe.WarpFrontend._declared_stable_cfg_name(_GuardWarpEnv) == "_GuardDeclaredCfg" - assert fe.WarpFrontend._declared_stable_cfg_name(_GuardWarpEnvNoAnnotation) is None - - -def test_assert_cfg_supported_accepts_exact_cfg(): - fe.WarpFrontend._assert_cfg_supported(_GuardWarpEnv, _GuardDeclaredCfg(), "_task") # no raise - - def test_assert_cfg_supported_accepts_subclass_cfg(): # A task cfg that IS-A the declared cfg is accepted (covers camera/variant subclasses). fe.WarpFrontend._assert_cfg_supported(_GuardWarpEnv, _GuardSubclassCfg(), "_task") # no raise -def test_assert_cfg_supported_rejects_unrelated_cfg(): - # This is the Shadow-reorient-into-Allegro-twin regression: a cfg the twin does - # not implement must be a hard error, not a silent wrong-MDP run. - with pytest.raises(FrontendIncompatibleError) as exc: - fe.WarpFrontend._assert_cfg_supported(_GuardWarpEnv, _GuardUnrelatedCfg(), "_task") - msg = str(exc.value) - assert "_GuardDeclaredCfg" in msg and "_GuardUnrelatedCfg" in msg - - def test_assert_cfg_supported_skips_when_no_annotation(): # No cfg contract declared → nothing to enforce → never raises. fe.WarpFrontend._assert_cfg_supported(_GuardWarpEnvNoAnnotation, _GuardUnrelatedCfg(), "_task") @@ -714,10 +585,3 @@ def test_resolve_rejects_twin_when_cfg_mismatches(): fe.WarpFrontend._resolve_direct_warp_class("_Frontend-Test-Reorient-Direct-v0", ShadowHandEnvCfg()) msg = str(exc.value) assert "AllegroHandEnvCfg" in msg and "ShadowHandEnvCfg" in msg - - -def test_resolve_name_only_skips_cfg_check(): - # Without a cfg, resolution reports the name mapping alone (no applicability gate). - env_class = fe.WarpFrontend._resolve_direct_warp_class("_Frontend-Test-Reorient-Direct-v0") - assert env_class is not None - assert env_class.__name__ == "ReorientDirectWarpEnv" diff --git a/source/isaaclab_experimental/test/envs/test_frontend_cfg_conversion.py b/source/isaaclab_experimental/test/envs/test_frontend_cfg_conversion.py index b807d83249bb..7496d4c7824f 100644 --- a/source/isaaclab_experimental/test/envs/test_frontend_cfg_conversion.py +++ b/source/isaaclab_experimental/test/envs/test_frontend_cfg_conversion.py @@ -96,17 +96,19 @@ def _load_adapted_cfg(task_id: str): @functools.lru_cache(maxsize=1) -def _sweep_warp_support() -> tuple[frozenset[str], dict[str, str], dict[str, str]]: +def _sweep_warp_support() -> tuple[frozenset[str], dict[str, str], dict[str, str], dict[str, object]]: """Ask every stable manager-based task whether it adapts for warp. Cached: the sweep instantiates every registered cfg, so it runs once per session. Returns: - ``(supported, incompatible, unimportable)`` — task ids that adapt, task ids that - do not mapped to the reason, and task ids whose cfg could not be built at all - (an optional dependency missing from this environment). + ``(supported, incompatible, unimportable, adapted)`` — task ids that adapt, task ids + that do not mapped to the reason, task ids whose cfg could not be built at all (an + optional dependency missing from this environment), and the adapted cfg per + supported task id. """ supported: set[str] = set() + adapted: dict[str, object] = {} incompatible: dict[str, str] = {} unimportable: dict[str, str] = {} for task_id, spec in gym.registry.items(): @@ -124,9 +126,10 @@ def _sweep_warp_support() -> tuple[frozenset[str], dict[str, str], dict[str, str reason = WarpFrontend.check_compatibility(cfg) if reason is None: supported.add(task_id) + adapted[task_id] = cfg # adapted in place by check_compatibility else: incompatible[task_id] = reason - return frozenset(supported), incompatible, unimportable + return frozenset(supported), incompatible, unimportable, adapted def _format_task_set(task_ids) -> str: @@ -140,14 +143,30 @@ def test_warp_supported_task_set_matches_the_registry(): Losing a task is always a failure — that is a task that used to train under ``--frontend warp`` and no longer does. Gaining one is a failure only when every candidate cfg was importable, since a partial environment cannot see the full set. + + Every supported cfg must also have its action terms swapped: they carry a ``class_type`` + (not a ``func``) on a base that is not a ManagerTermBaseCfg, and the warp ActionManager + rejects a stable ActionTerm at runtime. """ - supported, _, unimportable = _sweep_warp_support() + supported, incompatible, unimportable, adapted = _sweep_warp_support() lost = _WARP_SUPPORTED_TASKS - supported assert not lost, "tasks lost warp frontend support:\n " + "\n ".join( - f"{task_id}: {_sweep_warp_support()[1].get(task_id, 'cfg no longer importable')}" for task_id in sorted(lost) + f"{task_id}: {incompatible.get(task_id, 'cfg no longer importable')}" for task_id in sorted(lost) ) + for task_id in sorted(supported): + actions = getattr(adapted[task_id], "actions", None) + if actions is None: + continue + for name, term in vars(actions).items(): + class_type = getattr(term, "class_type", None) + if class_type is not None: + assert class_type.__module__.startswith(_WARP_ROOTS), ( + f"{task_id}: action term '{name}' class_type was not swapped to a warp twin" + f" (got {class_type.__module__}.{class_type.__name__})" + ) + gained = supported - _WARP_SUPPORTED_TASKS if unimportable: # Never claim coverage that was not measured: name what this environment could not judge. @@ -170,34 +189,11 @@ def _cfg_entry_point(task_id: str) -> str: return cfg_entry -def test_no_manager_warp_variants_remain(): - """End-state pin: manager-based warp execution needs no parallel registrations.""" - assert _manager_warp_tasks() == [], "unexpected ManagerBasedRLEnvWarp registrations reappeared" - - def test_no_warp_task_registrations_remain(): - """End-state pin: warp execution needs no parallel ``-Warp`` task ids at all.""" + """End-state pin: warp execution needs no parallel ``-Warp`` task ids or ManagerBasedRLEnvWarp registrations.""" warp_ids = sorted(task_id for task_id in gym.registry if "-Warp" in task_id) assert warp_ids == [], f"unexpected warp task registrations: {warp_ids}" - - -@pytest.mark.parametrize("task_id", sorted(_WARP_SUPPORTED_TASKS), ids=sorted(_WARP_SUPPORTED_TASKS)) -def test_stable_task_cfg_adapts_to_warp(task_id: str): - """Each covered stable task adapts without a missing twin (the --frontend warp path).""" - cfg = _load_adapted_cfg(task_id) - - # Action terms carry a ``class_type`` (not a ``func``) and live on a base that - # is not a ManagerTermBaseCfg; guard that the adapter still swaps them to the - # warp ActionTerm, otherwise the warp ActionManager rejects them at runtime. - actions = getattr(cfg, "actions", None) - if actions is not None: - for name, term in vars(actions).items(): - class_type = getattr(term, "class_type", None) - if class_type is not None: - assert class_type.__module__.startswith(_WARP_ROOTS), ( - f"{task_id}: action term '{name}' class_type was not swapped to a warp twin" - f" (got {class_type.__module__}.{class_type.__name__})" - ) + assert _manager_warp_tasks() == [], "unexpected ManagerBasedRLEnvWarp registrations reappeared" _DIRECT_WARP_TASKS = [ diff --git a/source/isaaclab_experimental/test/utils/test_manager_call_switch.py b/source/isaaclab_experimental/test/utils/test_manager_call_switch.py index b4745939eaa0..8f4bb904c7c1 100644 --- a/source/isaaclab_experimental/test/utils/test_manager_call_switch.py +++ b/source/isaaclab_experimental/test/utils/test_manager_call_switch.py @@ -21,19 +21,6 @@ def _add_one(a: wp.array(dtype=wp.float32), b: wp.array(dtype=wp.float32)): b[i] = a[i] + 1.0 -class TestManagerCallMode(unittest.TestCase): - """Tests for the ManagerCallMode enum.""" - - def test_enum_values(self): - self.assertEqual(ManagerCallMode.STABLE, 0) - self.assertEqual(ManagerCallMode.WARP_NOT_CAPTURED, 1) - self.assertEqual(ManagerCallMode.WARP_CAPTURED, 2) - - def test_ordering(self): - self.assertLess(ManagerCallMode.STABLE, ManagerCallMode.WARP_NOT_CAPTURED) - self.assertLess(ManagerCallMode.WARP_NOT_CAPTURED, ManagerCallMode.WARP_CAPTURED) - - # ====================================================================== # Config loading # ====================================================================== @@ -153,23 +140,10 @@ def test_register_capturability_true_is_noop(self): self.assertEqual(switch.get_mode_for_manager("RewardManager"), ManagerCallMode.WARP_CAPTURED) def test_register_capturability_does_not_upgrade(self): - """If a manager is already capped to NOT_CAPTURED, registering capturable=False again shouldn't change it.""" + """Once capped to NOT_CAPTURED, a later capturable=True registration must not lift the cap.""" switch = ManagerCallSwitch(cfg_source={"default": 2}) switch.register_manager_capturability("RewardManager", capturable=False) - switch.register_manager_capturability("RewardManager", capturable=False) - self.assertEqual( - switch.get_mode_for_manager("RewardManager"), - ManagerCallMode.WARP_NOT_CAPTURED, - ) - - def test_capturability_interacts_with_static_cap(self): - """Dynamic capturability should respect existing static caps.""" - switch = ManagerCallSwitch( - cfg_source={"default": 2}, - max_modes={"RewardManager": ManagerCallMode.WARP_NOT_CAPTURED}, - ) - # Already capped, register_capturability(False) should be harmless - switch.register_manager_capturability("RewardManager", capturable=False) + switch.register_manager_capturability("RewardManager", capturable=True) self.assertEqual( switch.get_mode_for_manager("RewardManager"), ManagerCallMode.WARP_NOT_CAPTURED, @@ -221,26 +195,26 @@ def test_stable_mode_without_stable_call_raises(self): ) def test_warp_eager_mode_calls_warp_fn(self): + """WARP_NOT_CAPTURED calls the warp fn exactly once per call (no warm-up, no stable call).""" switch = ManagerCallSwitch(cfg_source={"default": 1}) - called = {"stable": False, "warp": False} + called = {"stable": 0, "warp": 0} def stable_fn(): - called["stable"] = True + called["stable"] += 1 return "stable_result" def warp_fn(): - called["warp"] = True + called["warp"] += 1 return "warp_result" - result = switch.call_stage( - stage="RewardManager_compute", - warp_call={"fn": warp_fn}, - stable_call={"fn": stable_fn}, - ) - self.assertFalse(called["stable"]) - self.assertTrue(called["warp"]) + call = {"stage": "RewardManager_compute", "warp_call": {"fn": warp_fn}, "stable_call": {"fn": stable_fn}} + result = switch.call_stage(**call) + self.assertEqual(called, {"stable": 0, "warp": 1}, "Eager mode should call the warp fn exactly once") self.assertEqual(result, "warp_result") + switch.call_stage(**call) + self.assertEqual(called["warp"], 2, "Eager mode should call fn again each time") + def test_output_transform(self): """The 'output' key in call spec should transform the return value.""" switch = ManagerCallSwitch(cfg_source={"default": 1}) @@ -277,32 +251,6 @@ class TestStageDispatchCaptured(unittest.TestCase): def setUp(self): self.device = "cuda:0" - def test_captured_mode_produces_correct_output(self): - """WARP_CAPTURED should capture and replay a warp kernel correctly.""" - switch = ManagerCallSwitch(cfg_source={"default": 2}) - src = wp.full(4, value=5.0, dtype=wp.float32, device=self.device) - dst = wp.zeros(4, dtype=wp.float32, device=self.device) - - def warp_fn(): - wp.launch(_add_one, dim=4, inputs=[src, dst], device=self.device) - return dst - - # First call: warm-up + capture - result = switch.call_stage( - stage="RewardManager_compute", - warp_call={"fn": warp_fn}, - ) - self.assertAlmostEqual(result.numpy()[0], 6.0, places=5) - - # Replay - wp.copy(src, wp.full(4, value=10.0, dtype=wp.float32, device=self.device)) - result2 = switch.call_stage( - stage="RewardManager_compute", - warp_call={"fn": warp_fn}, - ) - self.assertIs(result, result2, "Replay must return same reference") - self.assertAlmostEqual(result2.numpy()[0], 11.0, places=5) - def test_captured_warmup_call_count(self): """WARP_CAPTURED first call should invoke fn exactly 2 times (warm-up + capture).""" switch = ManagerCallSwitch(cfg_source={"default": 2}) @@ -327,55 +275,6 @@ def warp_fn(): switch.call_stage(stage="RewardManager_compute", warp_call={"fn": warp_fn}) self.assertEqual(call_count[0], 2) - def test_captured_warmup_handles_hasattr_guard(self): - """Warm-up should flush first-call allocations so capture doesn't record them. - - This simulates the real-world pattern where MDP terms allocate scratch - buffers on first call using hasattr guards. - """ - switch = ManagerCallSwitch(cfg_source={"default": 2}) - src = wp.ones(8, dtype=wp.float32, device=self.device) - holder = {} - - def fn_with_guard(): - if "buf" not in holder: - # First-call allocation — must happen during warm-up, not capture - holder["buf"] = wp.zeros(8, dtype=wp.float32, device=self.device) - wp.launch(_add_one, dim=8, inputs=[src, holder["buf"]], device=self.device) - return holder["buf"] - - # Should not raise — warm-up handles the allocation outside capture context - result = switch.call_stage( - stage="RewardManager_compute", - warp_call={"fn": fn_with_guard}, - ) - result_np = result.numpy() - for val in result_np: - self.assertAlmostEqual(val, 2.0, places=5) - - # Replay should also work (allocation already done, only kernel replays) - wp.copy(src, wp.full(8, value=5.0, dtype=wp.float32, device=self.device)) - result2 = switch.call_stage( - stage="RewardManager_compute", - warp_call={"fn": fn_with_guard}, - ) - for val in result2.numpy(): - self.assertAlmostEqual(val, 6.0, places=5) - - def test_warp_eager_no_warmup(self): - """WARP_NOT_CAPTURED mode should call fn exactly once per call (no warm-up).""" - switch = ManagerCallSwitch(cfg_source={"default": 1}) - call_count = [0] - - def warp_fn(): - call_count[0] += 1 - - switch.call_stage(stage="RewardManager_compute", warp_call={"fn": warp_fn}) - self.assertEqual(call_count[0], 1, "Eager mode should call fn exactly once") - - switch.call_stage(stage="RewardManager_compute", warp_call={"fn": warp_fn}) - self.assertEqual(call_count[0], 2, "Eager mode should call fn again each time") - # ====================================================================== # Graph invalidation @@ -454,17 +353,6 @@ def test_invalid_manager_raises(self): class TestManagerNameParsing(unittest.TestCase): """Tests for stage name → manager name extraction.""" - def test_valid_stage_name(self): - switch = ManagerCallSwitch(cfg_source={"default": 1}) - # Dispatch a stage with valid name format - called = [False] - - def fn(): - called[0] = True - - switch.call_stage(stage="RewardManager_compute", warp_call={"fn": fn}) - self.assertTrue(called[0]) - def test_invalid_stage_name_raises(self): switch = ManagerCallSwitch(cfg_source={"default": 1}) with self.assertRaises(ValueError): diff --git a/source/isaaclab_experimental/test/utils/test_warp_graph_cache.py b/source/isaaclab_experimental/test/utils/test_warp_graph_cache.py index 1b9c10a76895..7a715c639d44 100644 --- a/source/isaaclab_experimental/test/utils/test_warp_graph_cache.py +++ b/source/isaaclab_experimental/test/utils/test_warp_graph_cache.py @@ -54,48 +54,10 @@ def counted_launch(): self.cache.capture_or_replay("stage_a", counted_launch) self.assertEqual(call_count[0], 2, "Replay should NOT invoke fn again") - def test_warmup_flushes_first_call_allocations(self): - """Warm-up should handle first-call allocations so capture is clean. - - Simulates a hasattr guard pattern: allocate a buffer on first call only. - Without warm-up, the allocation would be recorded in the graph. - """ - holder = {} - src = wp.ones(8, dtype=wp.float32, device=self.device) - - def fn_with_hasattr_guard(): - if "buf" not in holder: - holder["buf"] = wp.zeros(8, dtype=wp.float32, device=self.device) - wp.launch(_add_one, dim=8, inputs=[src, holder["buf"]], device=self.device) - return holder["buf"] - - # Should not raise — warm-up handles the allocation outside capture - result = self.cache.capture_or_replay("guarded", fn_with_hasattr_guard) - self.assertIsNotNone(result) - - # Verify the kernel produced correct output - result_np = result.numpy() - for val in result_np: - self.assertAlmostEqual(val, 2.0, places=5) - # ------------------------------------------------------------------ # Capture / replay correctness # ------------------------------------------------------------------ - def test_capture_produces_correct_output(self): - """After capture, replaying the graph should produce correct results.""" - src = wp.full(4, value=3.0, dtype=wp.float32, device=self.device) - dst = wp.zeros(4, dtype=wp.float32, device=self.device) - - def my_fn(): - wp.launch(_add_one, dim=4, inputs=[src, dst], device=self.device) - return dst - - result = self.cache.capture_or_replay("compute", my_fn) - result_np = result.numpy() - for val in result_np: - self.assertAlmostEqual(val, 4.0, places=5) - def test_replay_uses_updated_input(self): """Replay should re-read from the same input buffer (pointer-stable). @@ -111,30 +73,19 @@ def my_fn(): return dst # Capture - self.cache.capture_or_replay("replay_test", my_fn) + first = self.cache.capture_or_replay("replay_test", my_fn) # Update input in-place wp.copy(src, wp.full(4, value=10.0, dtype=wp.float32, device=self.device)) # Replay — should see updated input result = self.cache.capture_or_replay("replay_test", my_fn) + # Replay returns the cached object from the capture call, not a new buffer. + self.assertIs(result, first) result_np = result.numpy() for val in result_np: self.assertAlmostEqual(val, 11.0, places=5) - def test_cached_result_is_same_reference(self): - """Replay should return the exact same object reference as capture.""" - src = wp.zeros(4, dtype=wp.float32, device=self.device) - dst = wp.zeros(4, dtype=wp.float32, device=self.device) - - def my_fn(): - wp.launch(_add_one, dim=4, inputs=[src, dst], device=self.device) - return dst - - result1 = self.cache.capture_or_replay("ref_test", my_fn) - result2 = self.cache.capture_or_replay("ref_test", my_fn) - self.assertIs(result1, result2, "Replay must return the same object reference") - def test_multiple_stages_independent(self): """Different stages should be captured and replayed independently.""" src_a = wp.full(4, value=1.0, dtype=wp.float32, device=self.device) @@ -211,18 +162,15 @@ def fn_b(): self.assertEqual(count_a[0], 2) self.assertEqual(count_b[0], 2) - # Invalidate only "a" + # Invalidate only "a"; a stage that was never captured is a no-op self.cache.invalidate("a") + self.cache.invalidate("nonexistent") self.cache.capture_or_replay("a", fn_a) self.cache.capture_or_replay("b", fn_b) self.assertEqual(count_a[0], 4, "Stage 'a' should re-capture after invalidation") self.assertEqual(count_b[0], 2, "Stage 'b' should replay (not re-capture)") - def test_invalidate_nonexistent_stage_is_noop(self): - """Invalidating a stage that was never captured should not raise.""" - self.cache.invalidate("nonexistent") # should not raise - # ------------------------------------------------------------------ # Args / kwargs forwarding # ------------------------------------------------------------------ diff --git a/source/isaaclab_mimic/changelog.d/test-prune-extension-tests.skip b/source/isaaclab_mimic/changelog.d/test-prune-extension-tests.skip new file mode 100644 index 000000000000..801facce8812 --- /dev/null +++ b/source/isaaclab_mimic/changelog.d/test-prune-extension-tests.skip @@ -0,0 +1 @@ +Pruned redundant single-environment Mimic generation runs and failure-cap cases, and seeded the selection-strategy tests. diff --git a/source/isaaclab_mimic/test/test_generate_dataset_franka_state.py b/source/isaaclab_mimic/test/test_generate_dataset_franka_state.py index d860bae497f1..590a424d76c7 100644 --- a/source/isaaclab_mimic/test/test_generate_dataset_franka_state.py +++ b/source/isaaclab_mimic/test/test_generate_dataset_franka_state.py @@ -155,14 +155,6 @@ def _run_generation(workflow_root: str, input_file: str, output_file: str, num_e ) -def test_generate_dataset_franka_state(setup_test_environment): - """Test dataset generation for the state-based cube-stack environment (single env).""" - workflow_root = setup_test_environment - annotated_input_path = os.path.join(DATASETS_DOWNLOAD_DIR, "annotated_dataset.hdf5") - generated_output_path = os.path.join(DATASETS_DOWNLOAD_DIR, "generated_dataset.hdf5") - _run_generation(workflow_root, annotated_input_path, generated_output_path, num_envs=1) - - def test_generate_dataset_franka_state_multi_env(setup_test_environment): """Test dataset generation for the state-based cube-stack environment (5 envs).""" workflow_root = setup_test_environment diff --git a/source/isaaclab_mimic/test/test_generate_dataset_franka_visuomotor.py b/source/isaaclab_mimic/test/test_generate_dataset_franka_visuomotor.py index 091c6d218b9c..ee6a15e4cb30 100644 --- a/source/isaaclab_mimic/test/test_generate_dataset_franka_visuomotor.py +++ b/source/isaaclab_mimic/test/test_generate_dataset_franka_visuomotor.py @@ -94,13 +94,6 @@ def _run_generation(workflow_root: str, input_file: str, output_file: str, num_e ) -def test_generate_dataset_franka_visuomotor(setup_visuomotor_test_environment): - """Test dataset generation for the visuomotor cube-stack environment (single env).""" - workflow_root, input_file = setup_visuomotor_test_environment - output_file = os.path.join(DATASETS_DOWNLOAD_DIR, "generated_dataset.hdf5") - _run_generation(workflow_root, input_file, output_file, num_envs=1) - - def test_generate_dataset_franka_visuomotor_multi_env(setup_visuomotor_test_environment): """Test dataset generation for the visuomotor cube-stack environment (5 envs).""" workflow_root, input_file = setup_visuomotor_test_environment diff --git a/source/isaaclab_mimic/test/test_generate_dataset_gr1t2_nutpour.py b/source/isaaclab_mimic/test/test_generate_dataset_gr1t2_nutpour.py index 74b992b28236..7ebcd50d7bc5 100644 --- a/source/isaaclab_mimic/test/test_generate_dataset_gr1t2_nutpour.py +++ b/source/isaaclab_mimic/test/test_generate_dataset_gr1t2_nutpour.py @@ -94,13 +94,6 @@ def _run_generation(workflow_root: str, input_file: str, output_file: str, num_e ) -def test_generate_dataset_gr1t2_nutpour(setup_nutpour_gr1t2_test_environment): - """Test dataset generation for the GR1T2 nut-pour environment (single env).""" - workflow_root, input_file = setup_nutpour_gr1t2_test_environment - output_file = os.path.join(DATASETS_DOWNLOAD_DIR, "generated_dataset.hdf5") - _run_generation(workflow_root, input_file, output_file, num_envs=1) - - def test_generate_dataset_gr1t2_nutpour_multi_env(setup_nutpour_gr1t2_test_environment): """Test dataset generation for the GR1T2 nut-pour environment (5 envs).""" workflow_root, input_file = setup_nutpour_gr1t2_test_environment diff --git a/source/isaaclab_mimic/test/test_generate_dataset_gr1t2_pickplace.py b/source/isaaclab_mimic/test/test_generate_dataset_gr1t2_pickplace.py index f9f458941b55..80e055d27417 100644 --- a/source/isaaclab_mimic/test/test_generate_dataset_gr1t2_pickplace.py +++ b/source/isaaclab_mimic/test/test_generate_dataset_gr1t2_pickplace.py @@ -94,13 +94,6 @@ def _run_generation(workflow_root: str, input_file: str, output_file: str, num_e ) -def test_generate_dataset_gr1t2_pickplace(setup_pickplace_gr1t2_test_environment): - """Test dataset generation for the GR1T2 pick-place environment (single env).""" - workflow_root, input_file = setup_pickplace_gr1t2_test_environment - output_file = os.path.join(DATASETS_DOWNLOAD_DIR, "generated_dataset.hdf5") - _run_generation(workflow_root, input_file, output_file, num_envs=1) - - def test_generate_dataset_gr1t2_pickplace_multi_env(setup_pickplace_gr1t2_test_environment): """Test dataset generation for the GR1T2 pick-place environment (5 envs).""" workflow_root, input_file = setup_pickplace_gr1t2_test_environment diff --git a/source/isaaclab_mimic/test/test_generation_failure_cap.py b/source/isaaclab_mimic/test/test_generation_failure_cap.py index c029c1addf8b..f9699e4f42c6 100644 --- a/source/isaaclab_mimic/test/test_generation_failure_cap.py +++ b/source/isaaclab_mimic/test/test_generation_failure_cap.py @@ -95,10 +95,14 @@ def step(actions): return how, generation.num_success, generation.num_failures, generation.num_attempts -def test_failure_cap_stops_a_run_that_never_succeeds(): - how, succ, fail, attempts = _run([False] * 100, max_num_failures=5) +@pytest.mark.parametrize(("num_envs", "attempts_per_step", "expected_failures"), [(1, 1, 5), (4, 1, 5), (4, 4, 8)]) +def test_failure_cap_stops_a_run_that_never_succeeds(num_envs, attempts_per_step, expected_failures): + """Stop at the cap, counting attempts that complete together on the step that crosses it.""" + how, succ, fail, attempts = _run( + [False] * 100, max_num_failures=5, num_envs=num_envs, attempts_per_step=attempts_per_step + ) assert how == "exited" - assert (succ, fail, attempts) == (0, 5, 5) + assert (succ, fail, attempts) == (0, expected_failures, expected_failures) def test_no_cap_by_default_keeps_the_success_guarantee(): @@ -122,29 +126,12 @@ def test_cap_reached_before_enough_successes_ends_the_run(): assert (succ, fail) == (2, 4) -@pytest.mark.parametrize("max_num_failures", [None, 3, 100]) -def test_attempt_based_termination_is_unchanged(max_num_failures): +def test_attempt_based_termination_is_unchanged(): """With the guarantee off the run stops on ``generation_num_trials`` attempts, cap or no cap. A cap of 3 against 10 requested attempts is the case that matters: the fixed-attempt contract says the run delivers the attempts it was asked for, so the cap must not end it at 3. """ - how, _, _, attempts = _run( - [False] * 100, max_num_failures=max_num_failures, generation_num_trials=10, generation_guarantee=False - ) + how, _, _, attempts = _run([False] * 100, max_num_failures=3, generation_num_trials=10, generation_guarantee=False) assert how == "exited" assert attempts == 10 - - -def test_bound_is_exact_when_attempts_end_on_separate_steps(): - """Four environments, one attempt landing per step: the bound is read between every attempt.""" - how, _, fail, _ = _run([False] * 100, max_num_failures=5, num_envs=4, attempts_per_step=1) - assert how == "exited" - assert fail == 5 - - -def test_attempts_ending_together_overshoot_the_bound_by_at_most_num_envs_minus_one(): - """Attempts that end on the step that crosses the bound are already complete and still count.""" - how, _, fail, _ = _run([False] * 100, max_num_failures=5, num_envs=4, attempts_per_step=4) - assert how == "exited" - assert 5 < fail <= 5 + 4 - 1 diff --git a/source/isaaclab_mimic/test/test_selection_strategy.py b/source/isaaclab_mimic/test/test_selection_strategy.py index e378932f72c5..c37ea27ba951 100644 --- a/source/isaaclab_mimic/test/test_selection_strategy.py +++ b/source/isaaclab_mimic/test/test_selection_strategy.py @@ -26,6 +26,13 @@ NUM_ITERS = 1000 +@pytest.fixture(autouse=True) +def seed_rngs(): + """Seed the NumPy and torch generators that the poses and the strategies draw from.""" + np.random.seed(0) + torch.manual_seed(0) + + @pytest.fixture def nearest_neighbor_object_strategy(): """Fixture for NearestNeighborObjectStrategy.""" diff --git a/source/isaaclab_ppisp/changelog.d/test-prune-extension-tests.skip b/source/isaaclab_ppisp/changelog.d/test-prune-extension-tests.skip new file mode 100644 index 000000000000..a7594a593e00 --- /dev/null +++ b/source/isaaclab_ppisp/changelog.d/test-prune-extension-tests.skip @@ -0,0 +1 @@ +Pruned redundant PPISP tests, removed the opt-in pipeline benchmark, and checked the CRF against a reference. diff --git a/source/isaaclab_ppisp/test/test_ppisp.py b/source/isaaclab_ppisp/test/test_ppisp.py index 5b65ef570725..453595d849d0 100644 --- a/source/isaaclab_ppisp/test/test_ppisp.py +++ b/source/isaaclab_ppisp/test/test_ppisp.py @@ -90,23 +90,6 @@ def test_ppisp_camera_attr_import_uses_first_time_sample(): assert cfg.inputs["colorLatentBlue"] == pytest.approx((0.1, 0.2)) -def test_normalize_ppisp_cfg_imports_camera_attrs_from_stage(): - stage = Usd.Stage.CreateInMemory() - _author_ppisp_camera( - stage, - attrs={ - "exposureOffset": 1.5, - "colorLatentRed": (0.25, -0.5), - }, - ) - - cfg = normalize_ppisp_cfg(PpispCfg(camera_prim_path="/World/Camera_ppisp"), stage=stage) - - assert cfg.camera_prim_path is None - assert cfg.inputs["exposureOffset"] == 1.5 - assert cfg.inputs["colorLatentRed"] == pytest.approx((0.25, -0.5)) - - def test_normalize_ppisp_cfg_requires_stage_for_camera_prim_path(): with pytest.raises(ValueError, match="requires a USD stage"): normalize_ppisp_cfg(PpispCfg(camera_prim_path="/World/Camera_ppisp")) @@ -130,6 +113,7 @@ def test_normalize_ppisp_cfg_applies_explicit_overrides_after_camera_attr_import stage=stage, ) + assert cfg.camera_prim_path is None assert cfg.inputs["exposureOffset"] == 2.0 assert cfg.inputs["colorLatentRed"] == pytest.approx((0.25, -0.5)) diff --git a/source/isaaclab_ppisp/test/test_ppisp_kernels.py b/source/isaaclab_ppisp/test/test_ppisp_kernels.py index 90f10a2ec3d1..6b3ac48d7ea7 100644 --- a/source/isaaclab_ppisp/test/test_ppisp_kernels.py +++ b/source/isaaclab_ppisp/test/test_ppisp_kernels.py @@ -37,8 +37,6 @@ PPISP_CONTROLLER_PARAM_COUNT, ) -from isaaclab.sensors.camera.tiled_camera_cfg import TiledCameraCfg - wp.init() @@ -150,29 +148,6 @@ def test_ppisp_warp_responsivity_lifts_dim_input(): assert boosted_np[..., :3].astype(float).mean() > baseline_np[..., :3].astype(float).mean() -def test_ppisp_warp_controller_exposure_increases_ldr_output(): - import numpy as np - - hdr_color = _hdr(0.25, (1, 4, 4, 3)) - baseline = _rgba((1, 4, 4, 4)) - exposed = _rgba((1, 4, 4, 4)) - baseline_params = wp.zeros((1, 9), dtype=wp.float32) - exposed_params = wp.from_numpy( - np.array([[1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]], dtype=np.float32), - dtype=wp.float32, - ) - cfg = normalize_ppisp_cfg(PpispCfg()) - - apply_ppisp_to_rgba_with_controller_params(hdr_color, baseline, cfg, baseline_params) - apply_ppisp_to_rgba_with_controller_params(hdr_color, exposed, cfg, exposed_params) - - baseline_np = baseline.numpy() - exposed_np = exposed.numpy() - assert (baseline_np[..., 3] == 255).all() - assert (exposed_np[..., 3] == 255).all() - assert exposed_np[..., :3].astype(float).mean() > baseline_np[..., :3].astype(float).mean() - - def test_ppisp_warp_controller_params_match_static_color_latent_order(): import numpy as np @@ -214,8 +189,8 @@ def test_ppisp_warp_controller_params_match_static_color_latent_order(): np.testing.assert_array_equal(controller.numpy(), static.numpy()) -@pytest.mark.parametrize("num_cameras", [1, 2]) -@pytest.mark.parametrize("image_shape", [(7, 8), (80, 96)]) +# batch size and image size go through independent index math, so each value is covered once +@pytest.mark.parametrize(("num_cameras", "image_shape"), [(1, (7, 8)), (2, (80, 96))]) def test_ppisp_controller_network_matches_numpy_reference(num_cameras, image_shape): import numpy as np @@ -285,49 +260,65 @@ def test_ppisp_warp_vignetting_uses_detiled_camera_coordinates(): assert (rgba_np[0] == rgba_np[1]).all() -def test_ppisp_warp_crf_extreme_centers_stay_finite(): +def _crf_reference(x, toe_raw: float, shoulder_raw: float, gamma_raw: float, center_raw: float): + """Float64 NumPy reference of the PPISP camera response function.""" import numpy as np - hdr_data = np.array( - [ - [ - [[0.0, 0.25, 0.5], [0.75, 1.0, 0.5]], - [[0.0, 0.25, 0.5], [0.75, 1.0, 0.5]], - ] - ], - dtype=np.float32, - ) - hdr_color = wp.from_numpy(hdr_data, dtype=wp.float32) - rgba = _rgba((1, 2, 2, 4)) - - apply_ppisp_to_rgba( - hdr_color, - rgba, - normalize_ppisp_cfg( - PpispCfg( - inputs={ - "crfCenterR": -100.0, - "crfCenterG": 100.0, - "crfCenterB": -100.0, - } - ) - ), + def bounded_softplus(raw: float, min_value: float) -> float: + return min_value + np.log1p(np.exp(raw)) + + toe = bounded_softplus(toe_raw, 0.3) + shoulder = bounded_softplus(shoulder_raw, 0.3) + gamma = bounded_softplus(gamma_raw, 0.1) + # the center is kept away from 0 and 1 because it divides both curve segments + center = np.clip(1.0 / (1.0 + np.exp(-center_raw)), 1.0e-6, 1.0 - 1.0e-6) + a = shoulder * center / ((shoulder - toe) * center + toe) + x = np.clip(np.asarray(x, dtype=np.float64), 0.0, 1.0) + y = np.where( + x <= center, + a * (x / center) ** toe, + 1.0 - (1.0 - a) * ((1.0 - x) / (1.0 - center)) ** shoulder, ) - - rgba_np = rgba.numpy() - assert (rgba_np[..., 3] == 255).all() - assert np.isfinite(rgba_np.astype(float)).all() + return np.maximum(y, 0.0) ** gamma -def test_tiled_camera_cfg_accepts_ppisp_cfg(): - ppisp_cfg = PpispCfg(inputs={"exposureOffset": 1.0}) +def test_ppisp_warp_crf_extreme_centers_match_reference(): + """Saturated CRF centers produce the reference curve instead of NaN-cast bytes.""" + import numpy as np - cfg = TiledCameraCfg( - prim_path="/World/Camera", - width=4, - height=4, - data_types=["rgb"], - isp_cfg=ppisp_cfg, + # a gray ramp passes the default exposure, vignetting, and color stages unchanged + ramp = np.linspace(0.0, 1.0, 16, dtype=np.float32) + hdr_data = np.repeat(ramp[None, None, :, None], 3, axis=3) + hdr_color = wp.from_numpy(hdr_data, dtype=wp.float32) + rgba = _rgba((1, 1, 16, 4)) + cfg = normalize_ppisp_cfg( + PpispCfg( + inputs={ + "crfCenterR": -100.0, + "crfCenterG": 100.0, + "crfCenterB": -100.0, + # distinct curve shapes so the reference can tell toe, shoulder, and gamma apart + "crfToeR": 0.5, + "crfShoulderR": -0.5, + "crfGammaG": 0.8, + "crfToeB": -0.5, + "crfShoulderB": 0.5, + } + ) ) - assert cfg.isp_cfg == ppisp_cfg + apply_ppisp_to_rgba(hdr_color, rgba, cfg) + + rgba_np = rgba.numpy()[0, 0] + assert (rgba_np[:, 3] == 255).all() + for channel, suffix in enumerate("RGB"): + expected = _crf_reference( + ramp, + cfg.inputs[f"crfToe{suffix}"], + cfg.inputs[f"crfShoulder{suffix}"], + cfg.inputs[f"crfGamma{suffix}"], + cfg.inputs[f"crfCenter{suffix}"], + ) + expected_bytes = np.floor(np.clip(expected, 0.0, 1.0) * 255.0) + # float32 kernel math may land one byte away from the float64 reference at a floor boundary + np.testing.assert_allclose(rgba_np[:, channel].astype(float), expected_bytes, atol=1.0, err_msg=suffix) diff --git a/source/isaaclab_ppisp/test/test_ppisp_pipeline_perf.py b/source/isaaclab_ppisp/test/test_ppisp_pipeline_perf.py deleted file mode 100644 index 5037fd510d1b..000000000000 --- a/source/isaaclab_ppisp/test/test_ppisp_pipeline_perf.py +++ /dev/null @@ -1,400 +0,0 @@ -# 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 - -"""Opt-in PPISP pipeline performance sweep. - -This test is skipped by default because it is a benchmarking sweep, not a -correctness test. The configured resolutions are final tiled render-target -resolutions; the benchmark derives each per-environment image size from the -same near-square layout used by tiled camera visualization. Run manually with: - -``` -uv pip install pytest -ISAACLAB_PPISP_PERF=1 uv run python -m pytest \ - source/isaaclab_ppisp/test/test_ppisp_pipeline_perf.py -s -``` -""" - -from __future__ import annotations - -import csv -import gc -import math -import os -import re -from functools import lru_cache -from pathlib import Path - -import numpy as np -import pytest -import warp as wp -from isaaclab_ppisp import PPISP_CONTROLLER_EXPECTED_WEIGHTS_LEN, PpispCfg, PpispPipeline -from isaaclab_ppisp.kernels import ( - PPISP_CONTROLLER_FEATURE_LEN, - PPISP_CONTROLLER_HIDDEN_DIM, - PPISP_CONTROLLER_MLP_THREAD_GROUP_SIZE, - PPISP_CONTROLLER_OFF_CONV1_B, - PPISP_CONTROLLER_OFF_CONV2_B, - PPISP_CONTROLLER_OFF_CONV3_B, - PPISP_CONTROLLER_OFF_EXP_B, - PPISP_CONTROLLER_OFF_TRUNK0_B, - PPISP_CONTROLLER_OFF_TRUNK1_B, - PPISP_CONTROLLER_OFF_TRUNK2_B, - PPISP_CONTROLLER_PARAM_COUNT, - PPISP_CONTROLLER_POOL_CELL_COUNT, - PPISP_CONTROLLER_POOL_THREAD_GROUP_SIZE, - _ppisp_controller_mlp_native_kernel, - _ppisp_controller_pool_features_native_kernel, - apply_ppisp_to_rgba, - apply_ppisp_to_rgba_with_controller_params, -) - -pytestmark = pytest.mark.skipif( - os.environ.get("ISAACLAB_PPISP_PERF", "0") != "1", - reason="PPISP performance sweep is opt-in; set ISAACLAB_PPISP_PERF=1 to run.", -) - -_DEFAULT_RESOLUTIONS = ((256, 256), (512, 512), (1024, 1024), (2048, 2048)) -_DEFAULT_NUM_ENVS = (1, 8, 32, 128, 512) -_DEFAULT_WARMUP_ITERS = 2 -_DEFAULT_MEASURE_ITERS = 5 -_DEFAULT_MEMORY_FRACTION = 0.85 - - -def _parse_resolutions(value: str | None) -> tuple[tuple[int, int], ...]: - if not value: - return _DEFAULT_RESOLUTIONS - resolutions = [] - for item in value.split(","): - item = item.strip().lower() - if not item: - continue - if "x" in item: - height, width = item.split("x", maxsplit=1) - resolutions.append((int(height), int(width))) - else: - size = int(item) - resolutions.append((size, size)) - return tuple(resolutions) - - -def _parse_ints(value: str | None, default: tuple[int, ...]) -> tuple[int, ...]: - if not value: - return default - return tuple(int(item.strip()) for item in value.split(",") if item.strip()) - - -def _parse_variants(value: str | None) -> tuple[str, ...]: - if not value: - return ("static", "controller") - variants = tuple(item.strip().lower() for item in value.split(",") if item.strip()) - valid = {"static", "controller"} - unknown = set(variants) - valid - if unknown: - raise ValueError(f"Unknown PPISP perf variants: {sorted(unknown)}. Expected any of {sorted(valid)}.") - return variants - - -def _tile_grid_shape(num_envs: int) -> tuple[int, int]: - cols = max(1, math.ceil(math.sqrt(max(1, num_envs)))) - rows = math.ceil(max(1, num_envs) / cols) - return rows, cols - - -def _per_env_resolution(final_height: int, final_width: int, num_envs: int) -> tuple[int, int, int, int]: - tile_rows, tile_cols = _tile_grid_shape(num_envs) - return max(1, final_height // tile_rows), max(1, final_width // tile_cols), tile_rows, tile_cols - - -def _estimated_bytes(num_envs: int, height: int, width: int, *, controller: bool) -> int: - hdr_bytes = num_envs * height * width * 3 * np.dtype(np.float32).itemsize - rgba_bytes = num_envs * height * width * 4 * np.dtype(np.uint8).itemsize - controller_bytes = 0 - if controller: - controller_bytes += PPISP_CONTROLLER_EXPECTED_WEIGHTS_LEN * np.dtype(np.float32).itemsize - controller_bytes += num_envs * (1600 + 9) * np.dtype(np.float32).itemsize - return hdr_bytes + rgba_bytes + controller_bytes - - -@lru_cache(maxsize=1) -def _synthetic_controller_weights() -> tuple[float, ...]: - rng = np.random.default_rng(11) - weights = rng.normal(0.0, 0.003, size=PPISP_CONTROLLER_EXPECTED_WEIGHTS_LEN).astype(np.float32) - for offset, size in ( - (PPISP_CONTROLLER_OFF_CONV1_B, 16), - (PPISP_CONTROLLER_OFF_CONV2_B, 32), - (PPISP_CONTROLLER_OFF_CONV3_B, 64), - (PPISP_CONTROLLER_OFF_TRUNK0_B, PPISP_CONTROLLER_HIDDEN_DIM), - (PPISP_CONTROLLER_OFF_TRUNK1_B, PPISP_CONTROLLER_HIDDEN_DIM), - (PPISP_CONTROLLER_OFF_TRUNK2_B, PPISP_CONTROLLER_HIDDEN_DIM), - ): - weights[offset : offset + size] += 0.04 - weights[PPISP_CONTROLLER_OFF_EXP_B] = 0.0 - return tuple(float(value) for value in weights) - - -def _is_controller_variant(variant: str) -> bool: - return variant != "static" - - -def _make_pipeline(variant: str) -> PpispPipeline: - if variant == "static": - return PpispPipeline(PpispCfg(inputs={"exposureOffset": 0.0})) - return PpispPipeline(PpispCfg(controller_weights=_synthetic_controller_weights())) - - -def _time_pipeline_apply( - pipeline: PpispPipeline, - hdr: wp.array, - rgba: wp.array, - *, - warmup_iters: int, - measure_iters: int, -) -> float: - for _ in range(warmup_iters): - pipeline.apply(hdr, rgba) - wp.synchronize() - - start = wp.Event(enable_timing=True) - end = wp.Event(enable_timing=True) - wp.record_event(start) - for _ in range(measure_iters): - pipeline.apply(hdr, rgba) - wp.record_event(end) - return float(wp.get_event_elapsed_time(start, end)) / float(measure_iters) - - -def _time_gpu_operation(operation, *, warmup_iters: int, measure_iters: int) -> float: - for _ in range(warmup_iters): - operation() - wp.synchronize() - - start = wp.Event(enable_timing=True) - end = wp.Event(enable_timing=True) - wp.record_event(start) - for _ in range(measure_iters): - operation() - wp.record_event(end) - return float(wp.get_event_elapsed_time(start, end)) / float(measure_iters) - - -def _launch_controller_pool( - hdr: wp.array, - weights: wp.array, - features: wp.array, - responsivity: float, -) -> None: - wp.launch_tiled( - _ppisp_controller_pool_features_native_kernel, - dim=(int(hdr.shape[0]), PPISP_CONTROLLER_POOL_CELL_COUNT), - inputs=[hdr, weights, features, int(hdr.shape[2]), int(hdr.shape[1]), float(responsivity)], - device=str(hdr.device), - block_dim=PPISP_CONTROLLER_POOL_THREAD_GROUP_SIZE, - ) - - -def _launch_controller_mlp( - features: wp.array, - weights: wp.array, - controller_params: wp.array, - prior_exposure: float, -) -> None: - wp.launch_tiled( - _ppisp_controller_mlp_native_kernel, - dim=(int(features.shape[0]),), - inputs=[features, weights, controller_params, float(prior_exposure)], - device=str(features.device), - block_dim=PPISP_CONTROLLER_MLP_THREAD_GROUP_SIZE, - ) - - -def _print_perf_table(rows: list[dict[str, object]]) -> None: - print( - "\n[ppisp pipeline perf]\n" - "variant,num_envs,final_height,final_width,tile_rows,tile_cols,per_env_height,per_env_width," - "processed_mpix,final_mpix,estimated_gib,mean_ms,staged_total_ms,controller_pool_ms," - "controller_mlp_ms,apply_ms,stage_delta_ms,ms_per_env,throughput_mpix_s,status,error" - ) - for row in rows: - print( - "{variant},{num_envs},{final_height},{final_width},{tile_rows},{tile_cols}," - "{per_env_height},{per_env_width},{processed_mpix:.3f},{final_mpix:.3f},{estimated_gib:.2f}," - "{mean_ms:.3f},{staged_total_ms:.3f},{controller_pool_ms:.3f},{controller_mlp_ms:.3f}," - "{apply_ms:.3f},{stage_delta_ms:.3f},{ms_per_env:.5f},{throughput_mpix_s:.2f},{status},{error}".format( - **row - ) - ) - - -def _is_allocation_error(exc: Exception) -> bool: - message = str(exc).lower() - return bool(re.search(r"out[ -]of[ -]memory|failed to allocate|allocation", message)) - - -def test_ppisp_pipeline_perf_sweep(tmp_path): - if not wp.is_cuda_available(): - pytest.skip("PPISP performance sweep requires CUDA.") - - device_name = os.environ.get("ISAACLAB_PPISP_PERF_DEVICE", "cuda:0") - device = wp.get_device(device_name) - if not device.is_cuda: - pytest.skip(f"PPISP performance sweep requires a CUDA device, got {device_name}.") - - resolutions = _parse_resolutions(os.environ.get("ISAACLAB_PPISP_PERF_RESOLUTIONS")) - num_envs_values = _parse_ints(os.environ.get("ISAACLAB_PPISP_PERF_NUM_ENVS"), _DEFAULT_NUM_ENVS) - variants = _parse_variants(os.environ.get("ISAACLAB_PPISP_PERF_VARIANTS")) - warmup_iters = int(os.environ.get("ISAACLAB_PPISP_PERF_WARMUP_ITERS", _DEFAULT_WARMUP_ITERS)) - measure_iters = int(os.environ.get("ISAACLAB_PPISP_PERF_MEASURE_ITERS", _DEFAULT_MEASURE_ITERS)) - memory_fraction = float(os.environ.get("ISAACLAB_PPISP_PERF_MEMORY_FRACTION", _DEFAULT_MEMORY_FRACTION)) - max_case_bytes = int(device.total_memory * memory_fraction) - output_path = Path(os.environ.get("ISAACLAB_PPISP_PERF_OUTPUT", tmp_path / "ppisp_pipeline_perf.csv")) - - rows: list[dict[str, object]] = [] - pipelines = {variant: _make_pipeline(variant) for variant in variants} - with wp.ScopedDevice(device): - for final_height, final_width in resolutions: - for num_envs in num_envs_values: - height, width, tile_rows, tile_cols = _per_env_resolution(final_height, final_width, num_envs) - processed_mpix = (num_envs * height * width) / 1.0e6 - for variant in variants: - estimated_bytes = _estimated_bytes( - num_envs, - height, - width, - controller=_is_controller_variant(variant), - ) - row = { - "variant": variant, - "num_envs": num_envs, - "final_height": final_height, - "final_width": final_width, - "tile_rows": tile_rows, - "tile_cols": tile_cols, - "per_env_height": height, - "per_env_width": width, - "processed_mpix": processed_mpix, - "final_mpix": (final_height * final_width) / 1.0e6, - "estimated_gib": estimated_bytes / 1024**3, - "mean_ms": 0.0, - "staged_total_ms": 0.0, - "controller_pool_ms": 0.0, - "controller_mlp_ms": 0.0, - "apply_ms": 0.0, - "stage_delta_ms": 0.0, - "ms_per_env": 0.0, - "throughput_mpix_s": 0.0, - "status": "ok", - "error": "", - } - if estimated_bytes > max_case_bytes: - row["status"] = "skipped_memory_guard" - rows.append(row) - continue - - hdr = None - rgba = None - weights = None - features = None - controller_params = None - try: - hdr = wp.full( - (num_envs, height, width, 3), - value=wp.float32(0.25), - dtype=wp.float32, - device=device, - ) - rgba = wp.empty((num_envs, height, width, 4), dtype=wp.uint8, device=device) - pipeline = pipelines[variant] - mean_ms = _time_pipeline_apply( - pipeline, - hdr, - rgba, - warmup_iters=warmup_iters, - measure_iters=measure_iters, - ) - if _is_controller_variant(variant): - weights = wp.array(pipeline.cfg.controller_weights, dtype=wp.float32, device=device) - features = wp.empty( - (num_envs, PPISP_CONTROLLER_FEATURE_LEN), dtype=wp.float32, device=device - ) - controller_params = wp.empty( - (num_envs, PPISP_CONTROLLER_PARAM_COUNT), dtype=wp.float32, device=device - ) - _launch_controller_pool(hdr, weights, features, float(pipeline.cfg.controller_responsivity)) - _launch_controller_mlp( - features, - weights, - controller_params, - pipeline.cfg.controller_prior_exposure, - ) - wp.synchronize() - controller_pool_ms = _time_gpu_operation( - lambda: _launch_controller_pool( - hdr, weights, features, float(pipeline.cfg.controller_responsivity) - ), - warmup_iters=warmup_iters, - measure_iters=measure_iters, - ) - controller_mlp_ms = _time_gpu_operation( - lambda: _launch_controller_mlp( - features, - weights, - controller_params, - pipeline.cfg.controller_prior_exposure, - ), - warmup_iters=warmup_iters, - measure_iters=measure_iters, - ) - apply_ms = _time_gpu_operation( - lambda: apply_ppisp_to_rgba_with_controller_params( - hdr, rgba, pipeline.cfg, controller_params - ), - warmup_iters=warmup_iters, - measure_iters=measure_iters, - ) - staged_total_ms = controller_pool_ms + controller_mlp_ms + apply_ms - row["controller_pool_ms"] = controller_pool_ms - row["controller_mlp_ms"] = controller_mlp_ms - else: - apply_ms = _time_gpu_operation( - lambda: apply_ppisp_to_rgba(hdr, rgba, pipeline.cfg), - warmup_iters=warmup_iters, - measure_iters=measure_iters, - ) - staged_total_ms = apply_ms - row["staged_total_ms"] = staged_total_ms - row["apply_ms"] = apply_ms - row["stage_delta_ms"] = mean_ms - staged_total_ms - row["mean_ms"] = mean_ms - row["ms_per_env"] = mean_ms / float(num_envs) - row["throughput_mpix_s"] = row["processed_mpix"] / (mean_ms / 1000.0) - except Exception as exc: - if _is_allocation_error(exc): - row["status"] = "skipped_allocation" - else: - row["status"] = f"failed:{type(exc).__name__}" - row["error"] = str(exc).replace("\n", " ")[:160] - finally: - hdr = None - rgba = None - weights = None - features = None - controller_params = None - gc.collect() - wp.synchronize() - rows.append(row) - - output_path.parent.mkdir(parents=True, exist_ok=True) - with output_path.open("w", newline="", encoding="utf-8") as csv_file: - writer = csv.DictWriter(csv_file, fieldnames=list(rows[0])) - writer.writeheader() - writer.writerows(rows) - - _print_perf_table(rows) - print(f"[ppisp pipeline perf] wrote {output_path}") - assert rows - assert any(row["status"] == "ok" for row in rows) - failed_rows = [row for row in rows if str(row["status"]).startswith("failed:")] - assert not failed_rows diff --git a/source/isaaclab_rl/changelog.d/test-prune-extension-tests.skip b/source/isaaclab_rl/changelog.d/test-prune-extension-tests.skip new file mode 100644 index 000000000000..c767712c94d6 --- /dev/null +++ b/source/isaaclab_rl/changelog.d/test-prune-extension-tests.skip @@ -0,0 +1 @@ +Pruned redundant isaaclab_rl tests and merged their assertions into the retained keepers. diff --git a/source/isaaclab_rl/test/export/test_leapp_export_flow.py b/source/isaaclab_rl/test/export/test_leapp_export_flow.py index ee0861784133..17c3d4c461c4 100644 --- a/source/isaaclab_rl/test/export/test_leapp_export_flow.py +++ b/source/isaaclab_rl/test/export/test_leapp_export_flow.py @@ -20,10 +20,8 @@ _OUTPUT_TAIL_SIZE = 5000 _PRETRAINED_TASKS = [ - "Isaac-Ant", "Isaac-Cartpole", "IsaacContrib-Navigation-Flat-AnymalC", - "Isaac-Velocity-Flat-AnymalD", "Isaac-Velocity-Rough-AnymalD", "Isaac-Velocity-Rough-G1", "IsaacContrib-Velocity-Flat-Spot", diff --git a/source/isaaclab_rl/test/export/test_leapp_recurrent_state.py b/source/isaaclab_rl/test/export/test_leapp_recurrent_state.py index f926ebd2caab..a5d695386752 100644 --- a/source/isaaclab_rl/test/export/test_leapp_recurrent_state.py +++ b/source/isaaclab_rl/test/export/test_leapp_recurrent_state.py @@ -31,24 +31,6 @@ def _load_backend_export_module(backend: str) -> ModuleType: class TestSharedRecurrentState: """Tests for recurrent-state helpers shared by the export backends.""" - def test_checkpoint_path_naturally_sorts_numbered_files(self, tmp_path): - """Select the latest numbered checkpoint without importing the task package.""" - export_common = _load_export_common_module() - checkpoint_dir = tmp_path / "run" / "checkpoints" - checkpoint_dir.mkdir(parents=True) - (checkpoint_dir / "model_9.pt").touch() - expected = checkpoint_dir / "model_10.pt" - expected.touch() - - resolved = export_common.get_checkpoint_path( - str(tmp_path), - run_dir="run", - checkpoint=r"model_.*\.pt", - other_dirs=["checkpoints"], - ) - - assert resolved == str(expected) - def test_lstm_state_detection_requires_two_tensors(self): """Only two-tensor recurrent state is treated as LSTM feedback.""" export_common = _load_export_common_module() diff --git a/source/isaaclab_rl/test/test_apply_video_recording.py b/source/isaaclab_rl/test/test_apply_video_recording.py index 4a450199ca3a..415b0122681f 100644 --- a/source/isaaclab_rl/test/test_apply_video_recording.py +++ b/source/isaaclab_rl/test/test_apply_video_recording.py @@ -63,10 +63,8 @@ def test_apply_video_recording_creates_default_recorder( ("subdir", "existing_prefix", "checkpoint_name", "expected_prefix"), [ ("play", "clip", "model_1200.pt", "clip_model_1200"), - ("play", "eval", "model_42.pt", "eval_model_42"), ("play", "clip_model_1200", "model_120.pt", "clip_model_1200_model_120"), ("play", "clip", "custom_1200.pt", "clip"), - ("play", "clip", "final.pt", "clip"), ("train", "clip", "model_1200.pt", "clip"), ], ) diff --git a/source/isaaclab_rl/test/test_entrypoints.py b/source/isaaclab_rl/test/test_entrypoints.py index 716c3943be2e..9d1bbcd4388f 100644 --- a/source/isaaclab_rl/test/test_entrypoints.py +++ b/source/isaaclab_rl/test/test_entrypoints.py @@ -29,7 +29,6 @@ ("statement", "unexpected_modules"), [ ("import isaaclab_rl", ["isaaclab_rl.entrypoints", "torch"]), - ("import isaaclab_rl.entrypoints", ["isaaclab_rl.entrypoints.multigpu", "torch"]), ("import isaaclab_rl.entrypoints.backends", ["torch"]), # the LEAPP runtime must only load once the simulation has launched ("import isaaclab_rl.entrypoints.backends.export_rsl_rl", ["leapp", "isaaclab.utils.leapp"]), @@ -37,14 +36,11 @@ ("import isaaclab_rl.rl_games.pbt", ["isaaclab_rl.rl_games.pbt.pbt", "rl_games", "torch"]), ("import isaaclab_rl.rsl_rl", ["isaaclab_rl.rsl_rl.vecenv_wrapper", "rsl_rl", "torch"]), ("import isaaclab_rl.utils", ["torch"]), + # resolves through isaaclab_rl.entrypoints, so it also covers importing that package directly ( "from isaaclab_rl import run_play_cli", ["isaaclab_rl.entrypoints.multigpu", "torch"], ), - ( - "from isaaclab_rl.entrypoints import run_play_cli", - ["isaaclab_rl.entrypoints.multigpu", "torch"], - ), ], ) def test_public_namespaces_do_not_import_unrelated_frameworks(statement: str, unexpected_modules: list[str]) -> None: @@ -227,68 +223,36 @@ def test_zero_agent_supports_direct_multi_agent_action_spaces() -> None: assert torch.equal(actions["object"], torch.zeros(3, 1, dtype=torch.int64)) -@pytest.mark.parametrize("policy", ["zero", "random"]) -def test_simple_agents_default_to_newton_visualizer( - policy: simple_agents.PolicyName, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Checkpoint-free agents default to Newton visualization.""" +def test_simple_agents_parse_device_and_default_to_newton_visualizer(monkeypatch: pytest.MonkeyPatch) -> None: + """Checkpoint-free agents default to Newton visualization and retain an explicit CLI device.""" monkeypatch.setattr(sys, "argv", ["pytest"]) - args = simple_agents._parse_args([], policy) + args = simple_agents._parse_args([], "zero") assert args.device is None assert args.visualizer == ["newton_gl"] - -@pytest.mark.parametrize("policy", ["zero", "random"]) -def test_simple_agents_accept_explicit_device( - policy: simple_agents.PolicyName, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Checkpoint-free agents should retain an explicit CLI device.""" - monkeypatch.setattr(sys, "argv", ["pytest"]) - - args = simple_agents._parse_args(["--device", "cuda:1"], policy) + args = simple_agents._parse_args(["--device", "cuda:1"], "random") assert args.device == "cuda:1" -def test_simple_agents_preserve_task_device_default(monkeypatch: pytest.MonkeyPatch) -> None: - """Checkpoint-free agents should not replace a task-required device with the CLI default.""" - - class _ExpectedStop(Exception): - pass - - class _Cfg: - scene = SimpleNamespace(num_envs=1) - sim = SimpleNamespace(device="cpu", use_fabric=True) - - def validate(self) -> None: - pass - - args = SimpleNamespace( - num_envs=None, - device=None, - disable_fabric=False, - task="Cpu-Task", - ) - - def launch_simulation(cfg, launcher_args): - assert cfg.sim.device == "cpu" - assert launcher_args.device == "cpu" - raise _ExpectedStop - - monkeypatch.setattr(simple_agents, "_parse_args", lambda argv, policy: args) - monkeypatch.setattr(simple_agents, "resolve_task_config", lambda task, agent: (_Cfg(), None)) - monkeypatch.setattr(simple_agents, "launch_simulation", launch_simulation) - - with pytest.raises(_ExpectedStop): - simple_agents.run([], policy="zero") - - -def test_simple_agents_apply_explicit_device_override(monkeypatch: pytest.MonkeyPatch) -> None: - """Checkpoint-free agents should continue to honor an explicit CLI device.""" +@pytest.mark.parametrize( + ("cli_device", "expected_device", "policy"), + [ + # a task-required device is not replaced with the CLI default + (None, "cpu", "zero"), + # an explicit CLI device overrides the task default + ("cuda:1", "cuda:1", "random"), + ], +) +def test_simple_agents_resolve_simulation_device( + cli_device: str | None, + expected_device: str, + policy: simple_agents.PolicyName, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Checkpoint-free agents keep the task device unless the CLI selects one.""" class _ExpectedStop(Exception): pass @@ -302,14 +266,14 @@ def validate(self) -> None: args = SimpleNamespace( num_envs=None, - device="cuda:1", + device=cli_device, disable_fabric=False, task="Cpu-Task", ) def launch_simulation(cfg, launcher_args): - assert cfg.sim.device == "cuda:1" - assert launcher_args.device == "cuda:1" + assert cfg.sim.device == expected_device + assert launcher_args.device == expected_device raise _ExpectedStop monkeypatch.setattr(simple_agents, "_parse_args", lambda argv, policy: args) @@ -317,7 +281,7 @@ def launch_simulation(cfg, launcher_args): monkeypatch.setattr(simple_agents, "launch_simulation", launch_simulation) with pytest.raises(_ExpectedStop): - simple_agents.run([], policy="random") + simple_agents.run([], policy=policy) def test_zero_agent_rejects_invalid_config_before_launch(monkeypatch: pytest.MonkeyPatch) -> None: @@ -456,6 +420,7 @@ def fake_run_train_cli(argv: list[str]) -> int: TrainingRequest( backend="rsl_rl", task="Isaac-Cartpole", + checkpoint="latest", num_envs=32, max_iterations=10, distributed=True, @@ -469,6 +434,8 @@ def fake_run_train_cli(argv: list[str]) -> int: "rsl_rl", "--task", "Isaac-Cartpole", + "--checkpoint", + "latest", "--num_envs", "32", "--max_iterations", @@ -478,24 +445,6 @@ def fake_run_train_cli(argv: list[str]) -> int: ] -def test_train_request_maps_checkpoint_to_backend_argument(monkeypatch) -> None: - """Training requests forward a checkpoint so training can resume from it.""" - received: list[str] = [] - - def fake_run_train_cli(argv: list[str]) -> int: - received.extend(argv) - return 0 - - monkeypatch.setattr(api, "run_train_cli", fake_run_train_cli) - - api.train(TrainingRequest(backend="rsl_rl", task="Isaac-Cartpole", checkpoint="latest")) - assert received == ["--rl_library", "rsl_rl", "--task", "Isaac-Cartpole", "--checkpoint", "latest"] - - received.clear() - api.train(TrainingRequest(backend="rlinf", task="Isaac-Task", checkpoint="model")) - assert received == ["--rl_library", "rlinf", "--task", "Isaac-Task", "--checkpoint", "model"] - - def test_rlinf_parser_uses_unified_checkpoint_and_iteration_flags() -> None: """RLinf accepts the public checkpoint and iteration option names.""" from isaaclab_rl.entrypoints.backends import train_rlinf @@ -542,7 +491,7 @@ def run(argv: list[str]) -> None: def test_play_request_uses_unified_checkpoint_argument(monkeypatch) -> None: - """RLinf requests map shared fields to its focused backend arguments.""" + """Playback requests map shared fields, including the checkpoint, to CLI arguments.""" received: list[str] = [] def fake_run_play_cli(argv: list[str]) -> int: @@ -814,17 +763,6 @@ def test_humanoid_amp_tasks_register_canonical_skrl_config(motion) -> None: assert load_cfg_from_registry(spec.id, "skrl_cfg_entry_point")["agent"]["class"] == "AMP" -def test_skrl_entrypoints_do_not_infer_algorithm_from_registry_key() -> None: - """Registry-key spelling is a config-source concern, never an algorithm identity.""" - root = Path(__file__).parents[3] - paths = list(root.glob("source/isaaclab*/**/*skrl.py")) - for path in paths: - source = path.read_text() - - assert 'split("_cfg")' not in source, path - assert 'agent_library="skrl"' not in source, path - - def test_skrl_play_restores_jax_backend(monkeypatch) -> None: """SKRL playback removes the JAX backend setting it created after an exception.""" skrl = pytest.importorskip("skrl") diff --git a/source/isaaclab_rl/test/test_entrypoints_common.py b/source/isaaclab_rl/test/test_entrypoints_common.py index fbc14b7af3be..d51b3f967dbf 100644 --- a/source/isaaclab_rl/test/test_entrypoints_common.py +++ b/source/isaaclab_rl/test/test_entrypoints_common.py @@ -129,14 +129,23 @@ def test_capture_env_sensors_rejects_unknown_output_format(tmp_path: Path) -> No def test_wrap_sensor_capture_uses_training_sensor_frame_directory(tmp_path: Path) -> None: - """The train helper wraps the env with the configured sensor capture output directory.""" + """The train helper wraps the env with the sensor capture options parsed by the common train CLI.""" env = _FakeEnv() - args_cli = argparse.Namespace( - capture_env_sensors=2, - capture_env_sensors_length=5, - capture_env_sensors_interval=7, - capture_env_sensors_format="file", + parser = argparse.ArgumentParser() + add_common_train_args(parser, agent_default=None, agent_help="", include_agent=False) + args_cli = parser.parse_args( + [ + "--capture_env_sensors", + "2", + "--capture_env_sensors_length", + "5", + "--capture_env_sensors_interval", + "7", + "--capture_env_sensors_format", + "file", + ] ) + assert args_cli.capture_env_sensors_format == "file" wrapped_env = wrap_sensor_capture(env, str(tmp_path), args_cli) @@ -145,6 +154,8 @@ def test_wrap_sensor_capture_uses_training_sensor_frame_directory(tmp_path: Path assert wrapped_env.frame_count == 5 assert wrapped_env.capture_num_envs == 2 assert wrapped_env.interval == 7 + # the file format writes images instead of opening a TensorBoard writer + assert wrapped_env.writer is None assert wrapped_env.env is env @@ -156,30 +167,6 @@ def test_wrap_sensor_capture_returns_env_when_disabled(tmp_path: Path) -> None: assert wrap_sensor_capture(env, str(tmp_path), args_cli) is env -def test_common_train_args_include_sensor_capture_options() -> None: - """Common train parsers expose sensor capture CLI arguments.""" - parser = argparse.ArgumentParser() - add_common_train_args(parser, agent_default=None, agent_help="", include_agent=False) - - args_cli = parser.parse_args( - [ - "--capture_env_sensors", - "3", - "--capture_env_sensors_length", - "4", - "--capture_env_sensors_interval", - "5", - "--capture_env_sensors_format", - "file", - ] - ) - - assert args_cli.capture_env_sensors == 3 - assert args_cli.capture_env_sensors_length == 4 - assert args_cli.capture_env_sensors_interval == 5 - assert args_cli.capture_env_sensors_format == "file" - - def test_enable_cameras_for_video_enables_cameras_for_sensor_capture() -> None: """Sensor capture requires camera rendering even when normal video capture is disabled.""" args_cli = argparse.Namespace(video=False, capture_env_sensors=1, enable_cameras=False) @@ -301,14 +288,23 @@ def test_run_summary_reports_concrete_backends( def test_apply_env_overrides_records_the_deterministic_request(monkeypatch: pytest.MonkeyPatch) -> None: - """The deterministic option is recorded in the physics configuration.""" + """The deterministic option is recorded in the physics configuration and leaves both alone when off.""" + import warp as wp + import isaaclab_tasks # noqa: F401 from isaaclab_tasks.utils import resolve_task_config monkeypatch.setattr(rl_common.sys, "argv", ["train.py"]) + monkeypatch.setattr(wp.config, "deterministic", wp.DeterministicMode.NOT_GUARANTEED) env_cfg, _ = resolve_task_config("Isaac-Cartpole-Camera", "rl_games_cfg_entry_point") assert env_cfg.sim.physics.deterministic is False + args_cli = argparse.Namespace(num_envs=None, device=None, deterministic=False) + rl_common.apply_env_overrides(args_cli, env_cfg, apply_device=False) + + assert env_cfg.sim.physics.deterministic is False + assert wp.config.deterministic == wp.DeterministicMode.NOT_GUARANTEED + args_cli = argparse.Namespace(num_envs=None, device=None, deterministic=True) rl_common.apply_env_overrides(args_cli, env_cfg, apply_device=False) @@ -317,20 +313,6 @@ def test_apply_env_overrides_records_the_deterministic_request(monkeypatch: pyte assert env_cfg.sim.physics.solver_cfg.disable_sensors is False -def test_apply_env_overrides_leaves_physics_alone_without_the_flag(monkeypatch: pytest.MonkeyPatch) -> None: - """Without ``--deterministic`` the physics config is untouched.""" - import isaaclab_tasks # noqa: F401 - from isaaclab_tasks.utils import resolve_task_config - - monkeypatch.setattr(rl_common.sys, "argv", ["train.py"]) - env_cfg, _ = resolve_task_config("Isaac-Cartpole-Camera", "rl_games_cfg_entry_point") - - args_cli = argparse.Namespace(num_envs=None, device=None, deterministic=False) - rl_common.apply_env_overrides(args_cli, env_cfg, apply_device=False) - - assert env_cfg.sim.physics.deterministic is False - - @pytest.mark.parametrize( ("already_set", "configured_mode", "expected"), [ @@ -339,7 +321,6 @@ def test_apply_env_overrides_leaves_physics_alone_without_the_flag(monkeypatch: ("NOT_GUARANTEED", "run_to_run", "RUN_TO_RUN"), ("NOT_GUARANTEED", "gpu_to_gpu", "GPU_TO_GPU"), ("RUN_TO_RUN", "gpu_to_gpu", "GPU_TO_GPU"), - ("GPU_TO_GPU", None, "GPU_TO_GPU"), ("GPU_TO_GPU", "run_to_run", "GPU_TO_GPU"), ], ) @@ -359,21 +340,12 @@ def test_apply_env_overrides_raises_warp_determinism_to_the_configured_mode( assert wp.config.deterministic == getattr(wp.DeterministicMode, expected) -def test_apply_env_overrides_leaves_warp_alone_without_the_flag(monkeypatch: pytest.MonkeyPatch) -> None: - """Warp determinism is unchanged when the option is disabled.""" +def test_apply_env_overrides_records_the_request_for_unknown_backend(monkeypatch: pytest.MonkeyPatch) -> None: + """Unknown physics backends receive the backend-agnostic request.""" import warp as wp - monkeypatch.setattr(wp.config, "deterministic", wp.DeterministicMode.NOT_GUARANTEED) - env_cfg = ManagerBasedRLEnvCfg(sim=SimulationCfg(physics=NewtonCfg())) - - args_cli = argparse.Namespace(num_envs=None, device=None, deterministic=False) - rl_common.apply_env_overrides(args_cli, env_cfg, apply_device=False) - - assert wp.config.deterministic == wp.DeterministicMode.NOT_GUARANTEED - - -def test_apply_env_overrides_records_the_request_for_unknown_backend() -> None: - """Unknown physics backends receive the backend-agnostic request.""" + # the request also raises Warp's process-wide determinism; restore it after the test + monkeypatch.setattr(wp.config, "deterministic", wp.config.deterministic) physics = SimpleNamespace(deterministic=False) env_cfg = SimpleNamespace(sim=SimpleNamespace(physics=physics)) @@ -383,8 +355,11 @@ def test_apply_env_overrides_records_the_request_for_unknown_backend() -> None: assert physics.deterministic is True -def test_apply_env_overrides_tolerates_a_config_without_physics() -> None: +def test_apply_env_overrides_tolerates_a_config_without_physics(monkeypatch: pytest.MonkeyPatch) -> None: """A configuration without a physics backend is accepted.""" + import warp as wp + + monkeypatch.setattr(wp.config, "deterministic", wp.config.deterministic) env_cfg = SimpleNamespace(sim=SimpleNamespace(physics=None)) args_cli = argparse.Namespace(num_envs=None, device=None, deterministic=True) diff --git a/source/isaaclab_rl/test/test_rsl_rl_cfg_deprecation.py b/source/isaaclab_rl/test/test_rsl_rl_cfg_deprecation.py index a3ba0b639c4e..ac8cd90c8afa 100644 --- a/source/isaaclab_rl/test/test_rsl_rl_cfg_deprecation.py +++ b/source/isaaclab_rl/test/test_rsl_rl_cfg_deprecation.py @@ -56,20 +56,6 @@ def _ppo_mlp_policy(): ) -def _ppo_rnn_policy(): - return RslRlPpoActorCriticRecurrentCfg( - init_noise_std=1.0, - actor_obs_normalization=False, - critic_obs_normalization=False, - actor_hidden_dims=[256, 256], - critic_hidden_dims=[128, 128], - activation="elu", - rnn_type="lstm", - rnn_hidden_dim=256, - rnn_num_layers=1, - ) - - def _distillation_mlp_policy(): return RslRlDistillationStudentTeacherCfg( init_noise_std=1.0, @@ -147,13 +133,12 @@ def _distillation_runner(**kw): # rsl-rl < 4.0.0 # =================================================================== class TestBelow4: - def test_no_policy_raises(self): - with pytest.raises(ValueError, match="policy"): - handle_deprecated_rsl_rl_cfg(_on_policy_runner(algorithm=_ppo_algo()), "3.0.0") - - def test_distillation_no_policy_raises(self): + @pytest.mark.parametrize( + ("make_runner", "make_algo"), [(_on_policy_runner, _ppo_algo), (_distillation_runner, _distillation_algo)] + ) + def test_no_policy_raises(self, make_runner, make_algo): with pytest.raises(ValueError, match="policy"): - handle_deprecated_rsl_rl_cfg(_distillation_runner(algorithm=_distillation_algo()), "3.0.0") + handle_deprecated_rsl_rl_cfg(make_runner(algorithm=make_algo()), "3.0.0") def test_preserves_policy(self, capsys): cfg = _on_policy_runner(policy=_ppo_mlp_policy(), algorithm=_ppo_algo()) @@ -190,24 +175,19 @@ def test_empirical_normalization_does_not_overwrite(self): assert cfg.policy.actor_obs_normalization is False assert cfg.policy.critic_obs_normalization is False - def test_clears_model_cfgs(self): - critic = _mlp_model() - critic.stochastic = False - cfg = _on_policy_runner(policy=_ppo_mlp_policy(), algorithm=_ppo_algo(), actor=_mlp_model(), critic=critic) - handle_deprecated_rsl_rl_cfg(cfg, "3.0.0") - assert is_missing(cfg.actor) - assert is_missing(cfg.critic) - - def test_distillation_clears_model_cfgs(self): - cfg = _distillation_runner( - policy=_distillation_mlp_policy(), - algorithm=_distillation_algo(), - student=_mlp_model(), - teacher=_mlp_model(), - ) + @pytest.mark.parametrize( + ("make_runner", "make_algo", "make_policy", "model_names"), + [ + (_on_policy_runner, _ppo_algo, _ppo_mlp_policy, ("actor", "critic")), + (_distillation_runner, _distillation_algo, _distillation_mlp_policy, ("student", "teacher")), + ], + ) + def test_clears_model_cfgs(self, make_runner, make_algo, make_policy, model_names): + models = {name: _mlp_model() for name in model_names} + cfg = make_runner(policy=make_policy(), algorithm=make_algo(), **models) handle_deprecated_rsl_rl_cfg(cfg, "3.0.0") - assert is_missing(cfg.student) - assert is_missing(cfg.teacher) + for name in model_names: + assert is_missing(getattr(cfg, name)) # =================================================================== @@ -244,7 +224,8 @@ def test_infers_mlp_actor_critic(self, capsys): def test_infers_rnn_actor_critic(self): p = RslRlPpoActorCriticRecurrentCfg( - init_noise_std=1.0, + init_noise_std=1.5, + state_dependent_std=True, actor_obs_normalization=False, critic_obs_normalization=False, actor_hidden_dims=[256, 256], @@ -261,22 +242,29 @@ def test_infers_rnn_actor_critic(self): assert cfg.actor.rnn_type == "gru" assert cfg.actor.rnn_hidden_dim == 128 assert cfg.actor.rnn_num_layers == 3 + assert cfg.actor.stochastic is True + assert cfg.actor.init_noise_std == 1.5 + assert cfg.actor.state_dependent_std is True assert isinstance(cfg.critic, RslRlRNNModelCfg) + assert cfg.critic.stochastic is False - def test_skips_existing_actor(self): + @pytest.mark.parametrize(("has_actor", "has_critic"), [(True, False), (False, True), (True, True)]) + def test_skips_existing_actor_critic(self, has_actor, has_critic): actor = _mlp_model() actor.hidden_dims = [999] - cfg = _on_policy_runner(policy=_ppo_mlp_policy(), algorithm=_ppo_algo(), actor=actor) - handle_deprecated_rsl_rl_cfg(cfg, "4.0.0") - assert cfg.actor.hidden_dims == [999] - - def test_skips_existing_critic(self): + # a non-stochastic existing critic also passes the legacy stochastic validation critic = _mlp_model() critic.stochastic = False critic.hidden_dims = [777] - cfg = _on_policy_runner(policy=_ppo_mlp_policy(), algorithm=_ppo_algo(), critic=critic) + cfg = _on_policy_runner( + policy=_ppo_mlp_policy(), + algorithm=_ppo_algo(), + actor=actor if has_actor else MISSING, + critic=critic if has_critic else MISSING, + ) handle_deprecated_rsl_rl_cfg(cfg, "4.0.0") - assert cfg.critic.hidden_dims == [777] + assert cfg.actor.hidden_dims == ([999] if has_actor else [256, 256]) + assert cfg.critic.hidden_dims == ([777] if has_critic else [128, 128]) def test_empirical_norm_then_inference(self): p = _ppo_mlp_policy() @@ -317,7 +305,9 @@ def test_distillation_infers_rnn_student_teacher(self): assert isinstance(cfg.student, RslRlRNNModelCfg) assert cfg.student.rnn_type == "gru" + assert cfg.student.stochastic is True assert isinstance(cfg.teacher, RslRlRNNModelCfg) + assert cfg.teacher.stochastic is True assert cfg.teacher.init_noise_std == 0.0 def test_distillation_skips_existing_student_teacher(self): @@ -336,12 +326,6 @@ def test_distillation_skips_existing_student_teacher(self): assert cfg.teacher.hidden_dims == [777] # Stochastic tests - def test_validates_stochastic_on_existing_models(self): - critic = _mlp_model() - critic.stochastic = False - cfg = _on_policy_runner(algorithm=_ppo_algo(), actor=_mlp_model(), critic=critic) - handle_deprecated_rsl_rl_cfg(cfg, "4.0.0") # should not raise - def test_missing_stochastic_raises(self): a = _mlp_model() a.stochastic = MISSING @@ -436,32 +420,6 @@ def test_mlp_policy_full_pipeline(self): assert cfg.actor.distribution_cfg.std_type == "log" assert cfg.critic.distribution_cfg is None - def test_mlp_policy_state_dependent_pipeline(self): - p = _ppo_mlp_policy() - p.init_noise_std = 0.5 - p.state_dependent_std = True - cfg = _on_policy_runner(policy=p, algorithm=_ppo_algo()) - handle_deprecated_rsl_rl_cfg(cfg, "5.0.0") - - assert isinstance(cfg.actor.distribution_cfg, RslRlMLPModelCfg.HeteroscedasticGaussianDistributionCfg) - - def test_rnn_policy_full_pipeline(self): - p = _ppo_rnn_policy() - p.init_noise_std = 1.5 - p.rnn_type = "gru" - p.rnn_hidden_dim = 64 - p.rnn_num_layers = 3 - cfg = _on_policy_runner(policy=p, algorithm=_ppo_algo()) - handle_deprecated_rsl_rl_cfg(cfg, "5.0.0") - - assert is_missing(cfg.policy) - assert isinstance(cfg.actor, RslRlRNNModelCfg) - assert cfg.actor.rnn_type == "gru" - assert isinstance(cfg.actor.distribution_cfg, RslRlMLPModelCfg.GaussianDistributionCfg) - assert cfg.actor.distribution_cfg.init_std == 1.5 - assert isinstance(cfg.critic, RslRlRNNModelCfg) - assert cfg.critic.distribution_cfg is None - def test_distillation_mlp_policy_full_pipeline(self): p = _distillation_mlp_policy() p.init_noise_std = 0.9 @@ -476,32 +434,7 @@ def test_distillation_mlp_policy_full_pipeline(self): assert isinstance(cfg.teacher.distribution_cfg, RslRlMLPModelCfg.GaussianDistributionCfg) assert cfg.teacher.distribution_cfg.init_std == 0.0 - def test_distillation_rnn_policy_full_pipeline(self): - p = _distillation_rnn_policy() - p.init_noise_std = 1.2 - cfg = _distillation_runner(policy=p, algorithm=_distillation_algo()) - handle_deprecated_rsl_rl_cfg(cfg, "5.0.0") - - assert is_missing(cfg.policy) - assert isinstance(cfg.student, RslRlRNNModelCfg) - assert isinstance(cfg.student.distribution_cfg, RslRlMLPModelCfg.GaussianDistributionCfg) - assert isinstance(cfg.teacher, RslRlRNNModelCfg) - assert cfg.teacher.distribution_cfg.init_std == 0.0 - def test_no_policy_no_models_is_noop(self): cfg = _on_policy_runner(algorithm=_ppo_algo()) handle_deprecated_rsl_rl_cfg(cfg, "5.0.0") assert is_missing(cfg.actor) and is_missing(cfg.critic) - - def test_empirical_norm_rnn_policy_full_pipeline(self): - p = _ppo_rnn_policy() - p.init_noise_std = 2.0 - p.state_dependent_std = True - p.actor_obs_normalization = MISSING - p.critic_obs_normalization = MISSING - cfg = _on_policy_runner(policy=p, algorithm=_ppo_algo(), empirical_normalization=True) - handle_deprecated_rsl_rl_cfg(cfg, "5.0.0") - - assert is_missing(cfg.policy) and is_missing(cfg.empirical_normalization) - assert cfg.actor.obs_normalization is True - assert isinstance(cfg.actor.distribution_cfg, RslRlMLPModelCfg.HeteroscedasticGaussianDistributionCfg) diff --git a/source/isaaclab_rl/test/test_template_generator.py b/source/isaaclab_rl/test/test_template_generator.py index cb1b9017dff5..676d4bf96bb3 100644 --- a/source/isaaclab_rl/test/test_template_generator.py +++ b/source/isaaclab_rl/test/test_template_generator.py @@ -174,6 +174,12 @@ def test_generator_registers_single_agent_rl_config_entry_points_for_all_librari generate(specification) + if not external: + # generated internal task families are importable packages + task_family = _task_folder(project_name, "single-agent") + discovered = {info.name for info in pkgutil.iter_modules([str(root_dir)]) if info.ispkg} + assert {task_family, f"{task_family}_direct"} <= discovered + for workflow_name in ["manager-based", "direct"]: workflow_type = "single-agent" task_id = _task_id(project_name, workflow_name, workflow_type, external) @@ -286,6 +292,14 @@ def test_generator_registers_multi_agent_skrl_config_entry_points(tmp_path, monk generate(specification) + if external: + # generated VS Code launch configs select the matching skrl algorithm + launch_config = (root_dir / project_name / ".vscode" / "tools" / "launch.template.json").read_text() + assert '"module": "isaaclab"' in launch_config + assert "scripts/skrl" not in launch_config + for algorithm in ["AMP", "PPO", "IPPO", "MAPPO"]: + assert f'"--algorithm", "{algorithm}"' in launch_config + single_task_id = _task_id(project_name, "direct", "single-agent", external) single_task_dir = _task_dir(root_dir, project_name, "direct", "single-agent", external) _unregister(single_task_id) @@ -379,34 +393,6 @@ def test_generator_rejects_algorithms_unsupported_by_selected_workflows(tmp_path ) -def test_external_launch_configs_pass_skrl_algorithm_for_every_generated_skrl_agent(tmp_path, monkeypatch): - """Verify generated VS Code launch configs select the matching skrl algorithm.""" - project_name = "template_launch_external" - root_dir = tmp_path / "external_root" - monkeypatch.setattr(generator, "_setup_git_repo", lambda project_dir: None) - - generate( - { - "external": True, - "path": str(root_dir), - "name": project_name, - "workflows": [ - {"name": "direct", "type": "single-agent"}, - {"name": "direct", "type": "multi-agent"}, - ], - "rl_libraries": [ - {"name": "skrl", "algorithms": ["amp", "ppo", "ippo", "mappo"]}, - ], - } - ) - - launch_config = (root_dir / project_name / ".vscode" / "tools" / "launch.template.json").read_text() - assert '"module": "isaaclab"' in launch_config - assert "scripts/skrl" not in launch_config - for algorithm in ["AMP", "PPO", "IPPO", "MAPPO"]: - assert f'"--algorithm", "{algorithm}"' in launch_config - - def test_external_project_uses_src_layout_and_installed_isaaclab_commands(tmp_path, monkeypatch): """Generated projects must expose an installable src package to the Isaac Lab CLI.""" project_name = "template_uv_project" @@ -544,7 +530,11 @@ def _top_level_imported_roots(source: str) -> set[str]: @pytest.mark.parametrize("external", [True, False]) def test_generated_files_cover_requested_agents_and_compile(tmp_path, monkeypatch, external): - """Generated workflows contain valid agent files and defer runtime imports.""" + """Generated workflows contain valid agent files and defer runtime imports. + + External projects are also imported with ``omni``/``pxr`` blocked (NVBug 6251247): the generated package + ``__init__`` must stay passive, including when the omni-dependent example UI extension is generated. + """ project_name = f"template_imports_{'external' if external else 'internal'}" root_dir = tmp_path / ("external_root" if external else "internal_tasks") monkeypatch.setattr(generator, "_setup_git_repo", lambda project_dir: None) @@ -563,6 +553,7 @@ def test_generated_files_cover_requested_agents_and_compile(tmp_path, monkeypatc } if external: specification["path"] = str(root_dir) + specification["include_ui_extension"] = True generate(specification) @@ -585,86 +576,23 @@ def test_generated_files_cover_requested_agents_and_compile(tmp_path, monkeypatc expected_files.add(f"{library['name']}_{algorithm}_cfg{extension}") assert {path.name for path in agents_dir.glob("*_cfg.*")} == expected_files + if not external: + return -def test_generated_direct_env_type_checking_import_resolves(tmp_path, monkeypatch): - """Generated external direct environments must reference their generated config module for type checking.""" - project_name = "template_direct_type_checking" - root_dir = tmp_path / "external_root" - monkeypatch.setattr(generator, "_setup_git_repo", lambda project_dir: None) - generate( - { - "external": True, - "path": str(root_dir), - "name": project_name, - "workflows": [{"name": "direct", "type": "single-agent"}], - "rl_libraries": [{"name": "rsl_rl", "algorithms": ["ppo"]}], - } - ) - - env_file = ( - root_dir / project_name / "src" / project_name / "tasks" / "balance_direct" / "config" / "cartpole" / "env.py" - ) - tree = ast.parse(env_file.read_text()) + project_dir = root_dir / project_name + # external direct environments reference their generated config module for type checking + env_file = project_dir / "src" / project_name / "tasks" / "balance_direct" / "config" / "cartpole" / "env.py" type_checking_imports = [ node.module - for node in ast.walk(tree) + for node in ast.walk(ast.parse(env_file.read_text())) if isinstance(node, ast.ImportFrom) and node.level == 1 and node.module is not None ] - assert "env_cfg" in type_checking_imports assert (env_file.parent / "env_cfg.py").is_file() - -def test_extension_toml_registers_ui_extension_as_separate_module(tmp_path, monkeypatch): - """The UI extension must stay loadable by Kit via its own ``[[python.module]]`` entry now that the package - ``__init__`` no longer imports it (so ``import `` stays omni-free). Locks both halves of the fix.""" - project_name = "template_ui_module" - root_dir = tmp_path / "external_root" - monkeypatch.setattr(generator, "_setup_git_repo", lambda project_dir: None) - generate( - { - "external": True, - "path": str(root_dir), - "name": project_name, - "include_ui_extension": True, - "workflows": [{"name": "direct", "type": "single-agent"}], - "rl_libraries": [{"name": "skrl", "algorithms": ["ppo"]}], - } - ) - package_dir = root_dir / project_name / "src" / project_name - extension_toml = (package_dir.parent.parent / "config" / "extension.toml").read_text() + # the UI extension stays loadable by Kit through its own ``[[python.module]]`` entry + extension_toml = (project_dir / "config" / "extension.toml").read_text() assert f'name = "{project_name}.ui_extension_example"' in extension_toml - # the package __init__ must not eagerly import the omni-dependent UI module (a comment mentioning it is fine) - init_tree = ast.parse((package_dir / "__init__.py").read_text()) - imported = [node.module for node in ast.walk(init_tree) if isinstance(node, ast.ImportFrom)] - imported += [alias.name for node in ast.walk(init_tree) if isinstance(node, ast.Import) for alias in node.names] - assert not any(module and "ui_extension_example" in module for module in imported) - - -def test_generated_package_import_is_omni_and_pxr_free(tmp_path, monkeypatch): - """NVBug 6251247: importing a generated project must not pull ``omni``/``pxr`` (headless / pre-SimulationApp). - - The generated package ``__init__`` stays passive; if it imports the example UI extension (``omni.ext``) or any - pxr-loading module, ``import `` crashes before Isaac Sim starts. We import it with - ``omni``/``pxr`` blocked. - """ - project_name = "template_import_safe" - root_dir = tmp_path / "external_root" - monkeypatch.setattr(generator, "_setup_git_repo", lambda project_dir: None) - generate( - { - "external": True, - "path": str(root_dir), - "name": project_name, - "workflows": [ - {"name": "direct", "type": "single-agent"}, - {"name": "manager-based", "type": "single-agent"}, - {"name": "direct", "type": "multi-agent"}, - ], - "rl_libraries": _all_libraries(), - } - ) - source_dir = root_dir / project_name / "src" program = textwrap.dedent( f""" @@ -676,7 +604,7 @@ def find_spec(self, name, path=None, target=None): raise ImportError(f"BLOCKED eager import of {{name!r}} at package import time") return None - sys.path.insert(0, {str(source_dir)!r}) + sys.path.insert(0, {str(project_dir / "src")!r}) sys.meta_path.insert(0, _Blocker()) import {project_name} # noqa: F401 print("OK") @@ -740,29 +668,6 @@ def find_spec(self, name, path=None, target=None): ) -def test_generated_internal_task_families_are_importable_packages(tmp_path, monkeypatch): - """Generated tasks use importable task-family and robot-config packages.""" - tasks_dir = tmp_path / "isaaclab_tasks" - tasks_dir.mkdir() - monkeypatch.setattr(generator, "_setup_git_repo", lambda project_dir: None) - monkeypatch.setattr(generator, "TASKS_DIR", str(tasks_dir)) - generate( - { - "external": False, - "name": "template_internal_reg", - "workflows": [ - {"name": "manager-based", "type": "single-agent"}, - {"name": "direct", "type": "single-agent"}, - ], - "rl_libraries": [{"name": "skrl", "algorithms": ["ppo"]}], - } - ) - task_family = _task_folder("template_internal_reg", "single-agent") - direct_family = f"{task_family}_direct" - discovered = {info.name for info in pkgutil.iter_modules([str(tasks_dir)]) if info.ispkg} - assert {task_family, direct_family} <= discovered - - def test_generated_external_project_registers_tasks_on_tasks_import(tmp_path, monkeypatch): """A freshly generated external project must register all tasks when its task package is imported. diff --git a/source/isaaclab_rl/test/test_wrappers.py b/source/isaaclab_rl/test/test_wrappers.py index 2e1596fe06c7..ae97b4c862c6 100644 --- a/source/isaaclab_rl/test/test_wrappers.py +++ b/source/isaaclab_rl/test/test_wrappers.py @@ -68,21 +68,29 @@ def raw_env(task: str, library: str, finite_horizon: bool) -> Iterator[Any]: env.close() +# Only the RSL-RL wrapper branches on manager-based vs direct envs, so the task rotates across rows +# instead of crossing every library. @pytest.mark.parametrize( - ("library", "finite_horizon"), + ("library", "finite_horizon", "task"), [ - ("rsl_rl", False), - ("rsl_rl", True), - ("rl_games", False), - ("sb3", False), - ("skrl", False), - ("torchrl", False), - ("torchrl", True), + ("rsl_rl", False, "Isaac-Cartpole"), + ("rsl_rl", True, "Isaac-Cartpole-Direct"), + ("rl_games", False, "Isaac-Cartpole"), + ("sb3", False, "Isaac-Cartpole"), + ("skrl", False, "Isaac-Cartpole-Direct"), + ("torchrl", False, "Isaac-Cartpole"), + ("torchrl", True, "Isaac-Cartpole-Direct"), ], ) -@pytest.mark.parametrize("task", ["Isaac-Cartpole", "Isaac-Cartpole-Direct"]) def test_wrapper_reset_step_and_timeout(library: str, finite_horizon: bool, raw_env: Any) -> None: + if library == "sb3": + assert not raw_env.unwrapped.single_action_space.is_bounded("both") env = _wrap_env(library, raw_env) + if library == "sb3": + # SB3 sees normalized bounds without modifying the underlying environment. + np.testing.assert_array_equal(env.action_space.low, -1.0) + np.testing.assert_array_equal(env.action_space.high, 1.0) + assert not raw_env.unwrapped.single_action_space.is_bounded("both") _assert_finite(env.reset()) if library == "torchrl": from torchrl.envs.utils import check_env_specs @@ -145,22 +153,6 @@ def test_torchrl_actor_uses_unbatched_action_bounds() -> None: assert actor(batch)["action"].shape == (6, 1) -@pytest.mark.parametrize("library", ["sb3"]) -@pytest.mark.parametrize("finite_horizon", [False]) -@pytest.mark.parametrize("task", ["Isaac-Cartpole"]) -def test_sb3_normalizes_unbounded_action_space(raw_env: Any) -> None: - """Expose normalized bounds to SB3 without modifying the underlying environment.""" - from isaaclab_rl.sb3 import Sb3VecEnvWrapper - - assert not raw_env.unwrapped.single_action_space.is_bounded("both") - - env = Sb3VecEnvWrapper(raw_env) - - np.testing.assert_array_equal(env.action_space.low, -1.0) - np.testing.assert_array_equal(env.action_space.high, 1.0) - assert not raw_env.unwrapped.single_action_space.is_bounded("both") - - def _assert_observation_buffer(env: Any) -> None: observations = env.get_observations() assert isinstance(observations, TensorDict) diff --git a/source/isaaclab_teleop/changelog.d/test-prune-extension-tests.skip b/source/isaaclab_teleop/changelog.d/test-prune-extension-tests.skip new file mode 100644 index 000000000000..a4201b5f1269 --- /dev/null +++ b/source/isaaclab_teleop/changelog.d/test-prune-extension-tests.skip @@ -0,0 +1 @@ +Pruned redundant isaaclab_teleop tests and repaired weak assertions in a test-audit campaign. diff --git a/source/isaaclab_teleop/test/test_cloudxr_lifecycle.py b/source/isaaclab_teleop/test/test_cloudxr_lifecycle.py index 18dfd52631b2..135544007fcf 100644 --- a/source/isaaclab_teleop/test/test_cloudxr_lifecycle.py +++ b/source/isaaclab_teleop/test/test_cloudxr_lifecycle.py @@ -110,6 +110,7 @@ def _restore_stubs(): from isaaclab_teleop.isaac_teleop_cfg import ( # noqa: E402 CLOUDXR_AVP_ENV, CLOUDXR_JS_ENV, + CLOUDXR_STANDALONE_ENV, IsaacTeleopCfg, ) from isaaclab_teleop.session_lifecycle import TeleopSessionLifecycle, cloudxr_eula_accepted # noqa: E402 @@ -158,26 +159,17 @@ def _make_lifecycle( class TestEnvProfilePaths: """Tests for the shipped .env profile path constants.""" - def test_avp_env_is_absolute_path(self): - assert os.path.isabs(CLOUDXR_AVP_ENV) - - def test_js_env_is_absolute_path(self): - assert os.path.isabs(CLOUDXR_JS_ENV) - - def test_avp_env_file_exists(self): - assert Path(CLOUDXR_AVP_ENV).is_file(), f"Missing: {CLOUDXR_AVP_ENV}" - - def test_js_env_file_exists(self): - assert Path(CLOUDXR_JS_ENV).is_file(), f"Missing: {CLOUDXR_JS_ENV}" - - def test_avp_env_filename(self): - assert Path(CLOUDXR_AVP_ENV).name == "avp-cloudxr.env" - - def test_js_env_filename(self): - assert Path(CLOUDXR_JS_ENV).name == "cloudxrjs-cloudxr.env" - - def test_profiles_are_in_same_directory(self): - assert Path(CLOUDXR_AVP_ENV).parent == Path(CLOUDXR_JS_ENV).parent + @pytest.mark.parametrize( + ("env_file", "expected_name"), + [ + (CLOUDXR_AVP_ENV, "avp-cloudxr.env"), + (CLOUDXR_JS_ENV, "cloudxrjs-cloudxr.env"), + (CLOUDXR_STANDALONE_ENV, "cloudxr-standalone.env"), + ], + ) + def test_env_file_exists(self, env_file, expected_name): + assert Path(env_file).is_file(), f"Missing: {env_file}" + assert Path(env_file).name == expected_name # ============================================================================ @@ -225,20 +217,12 @@ def test_session_config_receives_deadline_paced_pipelined_retargeting(self): class TestEnsureCloudXRRuntime: """Tests for the ``_ensure_cloudxr_runtime`` method on TeleopSessionLifecycle.""" - def test_skip_when_env_var_set(self): - """ISAACLAB_CXR_SKIP_AUTOLAUNCH=1 skips the launch entirely.""" - lifecycle = _make_lifecycle(cloudxr_env_file="/tmp/test.env") - - with patch.dict(os.environ, {"ISAACLAB_CXR_SKIP_AUTOLAUNCH": "1"}): - lifecycle._ensure_cloudxr_runtime() - - assert lifecycle._cloudxr_launcher is None - - def test_skip_when_env_var_set_with_whitespace(self): - """Whitespace around the env var value is stripped before comparison.""" + @pytest.mark.parametrize("value", ["1", " 1 "]) + def test_skip_when_env_var_set(self, value): + """ISAACLAB_CXR_SKIP_AUTOLAUNCH=1 skips the launch, even with auto-launch enabled; whitespace is stripped.""" lifecycle = _make_lifecycle(cloudxr_env_file="/tmp/test.env") - with patch.dict(os.environ, {"ISAACLAB_CXR_SKIP_AUTOLAUNCH": " 1 "}): + with patch.dict(os.environ, {"ISAACLAB_CXR_SKIP_AUTOLAUNCH": value}): lifecycle._ensure_cloudxr_runtime() assert lifecycle._cloudxr_launcher is None @@ -259,19 +243,6 @@ def test_no_skip_when_env_var_zero(self): assert lifecycle._cloudxr_launcher is not None - def test_skip_when_auto_launch_false(self): - """auto_launch_cloudxr=False skips the launch.""" - lifecycle = _make_lifecycle( - cloudxr_env_file="/tmp/test.env", - auto_launch_cloudxr=False, - ) - - with patch.dict(os.environ, {}, clear=False): - os.environ.pop("ISAACLAB_CXR_SKIP_AUTOLAUNCH", None) - lifecycle._ensure_cloudxr_runtime() - - assert lifecycle._cloudxr_launcher is None - def test_idempotency(self): """Calling _ensure_cloudxr_runtime twice does not create a second launcher.""" sentinel = MagicMock() @@ -305,18 +276,6 @@ def test_launches_with_correct_args(self): ) assert lifecycle._cloudxr_launcher is mock_cls.return_value - def test_env_var_takes_precedence_over_auto_launch(self): - """ISAACLAB_CXR_SKIP_AUTOLAUNCH=1 overrides auto_launch_cloudxr=True.""" - lifecycle = _make_lifecycle( - cloudxr_env_file="/tmp/test.env", - auto_launch_cloudxr=True, - ) - - with patch.dict(os.environ, {"ISAACLAB_CXR_SKIP_AUTOLAUNCH": "1"}): - lifecycle._ensure_cloudxr_runtime() - - assert lifecycle._cloudxr_launcher is None - # ============================================================================ # CloudXR EULA acceptance @@ -367,12 +326,6 @@ def test_does_not_accept_when_unset(self): assert cloudxr_eula_accepted() is False assert self._accept_eula_passed_to_launcher() is False - def test_independent_of_skip_autolaunch(self): - """The two CloudXR variables do not read each other's value.""" - with patch.dict(os.environ, {"ISAACLAB_CXR_SKIP_AUTOLAUNCH": "1"}): - os.environ.pop("ISAACLAB_CXR_ACCEPT_EULA", None) - assert cloudxr_eula_accepted() is False - def test_exported_from_package_root(self): """``teleop_replay_agent.py`` imports the helper from the package root, not the submodule.""" import isaaclab_teleop @@ -421,11 +374,7 @@ def test_stop_calls_launcher_stop(self): lifecycle, mock_launcher = self._make_started_lifecycle() lifecycle.stop() mock_launcher.stop.assert_called_once() - - def test_stop_clears_launcher_on_success(self): - """After a successful stop(), _cloudxr_launcher is set to None.""" - lifecycle, mock_launcher = self._make_started_lifecycle() - lifecycle.stop() + # After a successful stop(), the launcher reference is cleared. assert lifecycle._cloudxr_launcher is None def test_stop_retains_launcher_on_runtime_error(self): @@ -438,7 +387,7 @@ def test_stop_retains_launcher_on_runtime_error(self): assert lifecycle._cloudxr_launcher is mock_launcher def test_start_without_cloudxr_env_file(self): - """start() works normally when no cloudxr_env_file is provided.""" + """start() and stop() work normally when no cloudxr_env_file is provided.""" lifecycle = _make_lifecycle(cloudxr_env_file=None) fake_teleop_modules = { @@ -451,6 +400,9 @@ def test_start_without_cloudxr_env_file(self): assert lifecycle._cloudxr_launcher is None + lifecycle.stop() + assert lifecycle._cloudxr_launcher is None + def test_start_with_auto_launch_disabled(self): """start() skips CloudXR launch when auto_launch_cloudxr=False.""" lifecycle = _make_lifecycle( @@ -472,11 +424,6 @@ def test_start_with_auto_launch_disabled(self): assert lifecycle._cloudxr_launcher is None - def test_stop_without_cloudxr_env_file(self): - """stop() does not error when no CloudXR launcher was created.""" - lifecycle = _make_lifecycle(cloudxr_env_file=None) - lifecycle.stop() - if __name__ == "__main__": pytest.main([__file__, "-v"]) diff --git a/source/isaaclab_teleop/test/test_control_events.py b/source/isaaclab_teleop/test/test_control_events.py index dc0ddfac699c..dafe7a2ac18c 100644 --- a/source/isaaclab_teleop/test/test_control_events.py +++ b/source/isaaclab_teleop/test/test_control_events.py @@ -180,20 +180,6 @@ def _drive_to_running(proc) -> None: # =========================================================================== -class TestStartCommand: - def test_start_sets_run_toggle(self): - proc = TeleopMessageProcessor(name="test") - result = _step(proc, _tracked(b"start")) - assert result["run_toggle"] is True - assert result["kill"] is False - assert result["reset"] is False - - def test_start_does_not_set_reset(self): - proc = TeleopMessageProcessor(name="test") - result = _step(proc, _tracked(b"start")) - assert result["reset"] is False - - class TestStopCommand: def test_stop_from_stopped_is_noop(self): proc = TeleopMessageProcessor(name="test") @@ -202,25 +188,6 @@ def test_stop_from_stopped_is_noop(self): assert result["kill"] is False -class TestResetCommand: - def test_reset_sets_reset_flag(self): - proc = TeleopMessageProcessor(name="test") - result = _step(proc, _tracked(b"reset")) - assert result["reset"] is True - assert result["run_toggle"] is False - assert result["kill"] is False - - -class TestResetPulseBehaviour: - def test_reset_clears_on_next_step(self): - proc = TeleopMessageProcessor(name="test") - result = _step(proc, _tracked(b"reset")) - assert result["reset"] is True - - result = _step(proc, _empty_tracked()) - assert result["reset"] is False - - class TestKillAlwaysFalse: def test_kill_is_always_false(self): proc = TeleopMessageProcessor(name="test") @@ -242,6 +209,8 @@ def test_full_sequence_reaches_running(self): # Frame 0: "start" received, first toggle edge queued r0 = _step(proc, _tracked(b"start")) assert r0["run_toggle"] is True # edge 1: STOPPED -> PAUSED + assert r0["kill"] is False + assert r0["reset"] is False # Frame 1: queue drains False (prev resets) r1 = _step(proc, _empty_tracked()) @@ -255,16 +224,9 @@ def test_full_sequence_reaches_running(self): r3 = _step(proc, _empty_tracked()) assert r3["run_toggle"] is False - def test_shadow_state_is_running_after_sequence(self): - proc = TeleopMessageProcessor(name="test") - _step(proc, _tracked(b"start")) - _step(proc, _empty_tracked()) - _step(proc, _empty_tracked()) - assert proc._shadow_state == "running" - class TestStartFromPaused: - """``start`` from PAUSED needs 1 toggle edge.""" + """``stop`` from RUNNING and ``start`` from PAUSED each need 1 toggle edge.""" def test_single_edge_reaches_running(self): proc = TeleopMessageProcessor(name="test") @@ -276,7 +238,7 @@ def test_single_edge_reaches_running(self): # Stop to reach PAUSED (prev_toggle is True from start sequence, # so a False is prepended before the toggle edge) - _step(proc, _tracked(b"stop")) # drains False (prepended) + assert _step(proc, _tracked(b"stop"))["run_toggle"] is False # drains False (prepended) r_stop_edge = _step(proc, _empty_tracked()) # drains True (edge) assert r_stop_edge["run_toggle"] is True assert proc._shadow_state == "paused" @@ -287,54 +249,8 @@ def test_single_edge_reaches_running(self): assert r_start_edge["run_toggle"] is True assert proc._shadow_state == "running" - -class TestStartFromRunning: - """``start`` when already RUNNING is a no-op.""" - - def test_start_from_running_noop(self): - proc = TeleopMessageProcessor(name="test") - _step(proc, _tracked(b"start")) - _step(proc, _empty_tracked()) - _step(proc, _empty_tracked()) - assert proc._shadow_state == "running" - - result = _step(proc, _tracked(b"start")) - assert result["run_toggle"] is False - - -class TestStopFromRunning: - """``stop`` from RUNNING uses one toggle edge to reach PAUSED.""" - - def test_stop_pauses(self): - proc = TeleopMessageProcessor(name="test") - _step(proc, _tracked(b"start")) - _step(proc, _empty_tracked()) - _step(proc, _empty_tracked()) - assert proc._shadow_state == "running" - - # prev_toggle is True, so stop prepends False before the edge - r0 = _step(proc, _tracked(b"stop")) - assert r0["run_toggle"] is False # prepended False - r1 = _step(proc, _empty_tracked()) - assert r1["run_toggle"] is True # edge: RUNNING -> PAUSED - assert proc._shadow_state == "paused" - - -class TestStopFromPaused: - """``stop`` when already PAUSED is a no-op.""" - - def test_stop_from_paused_noop(self): - proc = TeleopMessageProcessor(name="test") - _step(proc, _tracked(b"start")) - _step(proc, _empty_tracked()) - _step(proc, _empty_tracked()) - # Stop to PAUSED - _step(proc, _tracked(b"stop")) - _step(proc, _empty_tracked()) - assert proc._shadow_state == "paused" - - result = _step(proc, _tracked(b"stop")) - assert result["run_toggle"] is False + # Start when already RUNNING is a no-op. + assert _step(proc, _tracked(b"start"))["run_toggle"] is False class TestCommandDuringToggleSequence: @@ -357,12 +273,6 @@ def test_second_start_during_sequence_ignored(self): class TestInjectReset: - def test_inject_reset_produces_pulse(self): - proc = TeleopMessageProcessor(name="test") - proc.inject_reset() - result = _step(proc, _empty_tracked()) - assert result["reset"] is True - def test_inject_reset_clears_after_one_step(self): proc = TeleopMessageProcessor(name="test") proc.inject_reset() @@ -370,12 +280,6 @@ def test_inject_reset_clears_after_one_step(self): result = _step(proc, _empty_tracked()) assert result["reset"] is False - def test_inject_reset_combines_with_message_reset(self): - proc = TeleopMessageProcessor(name="test") - proc.inject_reset() - result = _step(proc, _tracked(b"reset")) - assert result["reset"] is True - def test_operator_inject_reset_cancels_pending_toggle(self): # An operator reset (pause=True) cancels a toggle queued in the same step so the # session does not reach RUNNING right after the reset; the reset pulse still fires. @@ -440,7 +344,8 @@ def test_operator_reset_from_paused_stays_paused(self): _step(proc, _tracked(b"stop")) # RUNNING -> PAUSED _step(proc, _empty_tracked()) assert proc._shadow_state == "paused" - _step(proc, _empty_tracked()) # settle idle + # Stop when already PAUSED is a no-op (and settles the toggle output idle). + assert _step(proc, _tracked(b"stop"))["run_toggle"] is False result = _step(proc, _tracked(b"reset")) assert result["reset"] is True assert result["run_toggle"] is False @@ -453,6 +358,8 @@ def test_operator_reset_from_stopped_stays_stopped(self): assert result["reset"] is True assert result["run_toggle"] is False assert proc._shadow_state == "stopped" + # The channel reset is a one-frame pulse. + assert _step(proc, _empty_tracked())["reset"] is False # =========================================================================== @@ -461,16 +368,14 @@ def test_operator_reset_from_stopped_stays_stopped(self): class TestWordBoundaryMatching: - @pytest.mark.parametrize("payload", [b"teleop start", b"xr start session", b"start now"]) - def test_start_word(self, payload: bytes): + def test_start_word(self): proc = TeleopMessageProcessor(name="test") - result = _step(proc, _tracked(payload)) + result = _step(proc, _tracked(b"teleop start")) assert result["run_toggle"] is True - @pytest.mark.parametrize("payload", [b"teleop reset", b"env reset"]) - def test_reset_word(self, payload: bytes): + def test_reset_word(self): proc = TeleopMessageProcessor(name="test") - result = _step(proc, _tracked(payload)) + result = _step(proc, _tracked(b"teleop reset")) assert result["reset"] is True @@ -488,21 +393,10 @@ def test_reset_wins_over_start(self): class TestEmptyAndNullBatches: - def test_empty_data_list(self): - proc = TeleopMessageProcessor(name="test") - result = _step(proc, _empty_tracked()) - assert result["run_toggle"] is False - assert result["kill"] is False - assert result["reset"] is False - - def test_null_data(self): - proc = TeleopMessageProcessor(name="test") - result = _step(proc, _null_tracked()) - assert result["run_toggle"] is False - - def test_none_input(self): + @pytest.mark.parametrize("messages_tracked", [_null_tracked(), None], ids=["null_data", "none_input"]) + def test_null_data(self, messages_tracked): proc = TeleopMessageProcessor(name="test") - result = _step(proc, None) + result = _step(proc, messages_tracked) assert result["run_toggle"] is False @@ -547,15 +441,13 @@ def test_json_start_teleop(self): result = _step(proc, _tracked(_json_command("start teleop"))) assert result["run_toggle"] is True - def test_json_stop_teleop_from_stopped_noop(self): + def test_json_stop_teleop_pauses_running_session(self): proc = TeleopMessageProcessor(name="test") + _drive_to_running(proc) + # The idle toggle output means no False is prepended: the edge fires immediately. result = _step(proc, _tracked(_json_command("stop teleop"))) - assert result["run_toggle"] is False - - def test_json_reset_teleop(self): - proc = TeleopMessageProcessor(name="test") - result = _step(proc, _tracked(_json_command("reset teleop"))) - assert result["reset"] is True + assert result["run_toggle"] is True + assert proc._shadow_state == "paused" def test_json_wrong_type_ignored(self): payload = json.dumps({"type": "other_event", "message": {"command": "start"}}).encode("utf-8") @@ -576,17 +468,6 @@ def test_json_message_as_string(self): class TestExtractCommand: - def test_plain_text(self): - assert _extract_command("start teleop") == "start teleop" - - def test_json_teleop_command(self): - text = json.dumps({"type": "teleop_command", "message": {"command": "stop"}}) - assert _extract_command(text) == "stop" - - def test_json_wrong_type(self): - text = json.dumps({"type": "other", "message": {"command": "start"}}) - assert _extract_command(text) is None - def test_json_no_message_key(self): text = json.dumps({"type": "teleop_command"}) assert _extract_command(text) is None @@ -603,19 +484,11 @@ def test_json_non_dict_value_returns_none(self): class TestClassifyCommand: - def test_exact_words(self): - assert _classify_command("start") == "start" - assert _classify_command("stop") == "stop" - assert _classify_command("reset") == "reset" - def test_word_boundary_prevents_false_match(self): assert _classify_command("upstart") is None assert _classify_command("nonstop") is None assert _classify_command("unreset") is None - def test_reset_beats_start(self): - assert _classify_command("reset and start") == "reset" - def test_stop_beats_start(self): assert _classify_command("stop and start") == "stop" @@ -649,14 +522,6 @@ def last_control_events(self): assert result.is_active is True assert result.should_reset is True - def test_device_with_none_events(self): - class FakeDevice: - last_control_events = None - - result = poll_control_events(FakeDevice()) - assert result.is_active is None - assert result.should_reset is False - def test_duck_typed_snapshot(self): class FakeSnapshot: is_active = False diff --git a/source/isaaclab_teleop/test/test_hand_debug_viz.py b/source/isaaclab_teleop/test/test_hand_debug_viz.py index d05504dfc77c..15e22b494ff9 100644 --- a/source/isaaclab_teleop/test/test_hand_debug_viz.py +++ b/source/isaaclab_teleop/test/test_hand_debug_viz.py @@ -220,13 +220,6 @@ def __getitem__(self, idx): class TestChainHandDebugOutputs: - def test_chains_when_hands_source_present(self): - pipeline = _FakePipeline([_FakeLeaf("controllers"), _FakeHandsSource("hands")]) - mapping: dict = {} - TeleopSessionLifecycle._chain_hand_debug_outputs(pipeline, mapping) - assert mapping[TeleopSessionLifecycle.HAND_LEFT_KEY] == "selector:hands:hand_left" - assert mapping[TeleopSessionLifecycle.HAND_RIGHT_KEY] == "selector:hands:hand_right" - def test_mapping_untouched_without_hands_source(self): pipeline = _FakePipeline([_FakeLeaf("controllers"), _FakeLeaf("head")]) mapping: dict = {"action": "selector:user_pipeline:action"} @@ -241,7 +234,8 @@ def test_mapping_untouched_without_hands_source(self): class TestStartChaining: def _start(self, enable_debug_visualization: bool) -> TeleopSessionLifecycle: - pipeline = _FakePipeline([_FakeHandsSource("hands")]) + # The HandsSource is discovered even when it is not the first leaf. + pipeline = _FakePipeline([_FakeLeaf("controllers"), _FakeHandsSource("hands")]) cfg = IsaacTeleopCfg(pipeline_builder=lambda: pipeline, control_channel_uuid=None) lifecycle = TeleopSessionLifecycle(cfg, enable_debug_visualization=enable_debug_visualization) with patch.object(lifecycle, "_try_start_session", return_value=True): @@ -250,8 +244,8 @@ def _start(self, enable_debug_visualization: bool) -> TeleopSessionLifecycle: def test_start_chains_when_enabled(self): lifecycle = self._start(enable_debug_visualization=True) - assert TeleopSessionLifecycle.HAND_LEFT_KEY in lifecycle.pipeline.output_mapping - assert TeleopSessionLifecycle.HAND_RIGHT_KEY in lifecycle.pipeline.output_mapping + assert lifecycle.pipeline.output_mapping[TeleopSessionLifecycle.HAND_LEFT_KEY] == "selector:hands:hand_left" + assert lifecycle.pipeline.output_mapping[TeleopSessionLifecycle.HAND_RIGHT_KEY] == "selector:hands:hand_right" assert TeleopSessionLifecycle._CONTROLLER_LEFT_KEY in lifecycle.pipeline.output_mapping def test_start_does_not_chain_when_disabled(self): @@ -279,13 +273,6 @@ def test_none_when_hands_inactive(self): } assert _extract_world_joint_positions(result, np.eye(4)) is None - def test_identity_transform_passes_through(self): - joints = np.arange(78, dtype=np.float32).reshape(26, 3) - result = {TeleopSessionLifecycle.HAND_LEFT_KEY: _FakeTensorGroup(joints)} - positions = _extract_world_joint_positions(result, np.eye(4)) - assert positions.shape == (26, 3) - torch.testing.assert_close(positions, torch.as_tensor(joints)) - def test_both_hands_are_concatenated(self): left = np.zeros((26, 3), dtype=np.float32) right = np.ones((26, 3), dtype=np.float32) diff --git a/source/isaaclab_teleop/test/test_host_client_messaging.py b/source/isaaclab_teleop/test/test_host_client_messaging.py index 27a1e7b22488..b8090dd5dbc1 100644 --- a/source/isaaclab_teleop/test/test_host_client_messaging.py +++ b/source/isaaclab_teleop/test/test_host_client_messaging.py @@ -139,12 +139,6 @@ def test_subscribes_when_a_dispatcher_exists(self, teleop, monkeypatch): class TestControlPipelineWiring: """The outbound half of the control channel.""" - def test_control_pipeline_builds(self, teleop): - lifecycle = _make_lifecycle(teleop) - pipeline, processor = lifecycle._build_control_pipeline(teleop.TELEOP_CONTROL_CHANNEL_UUID) - assert pipeline is not None - assert processor is not None - def test_source_receives_the_lifecycle_outbound_queue(self, teleop): # The source only puts bytes on the wire from the queue it was handed. # If the lifecycle enqueued into a different deque every host message @@ -157,31 +151,9 @@ def test_source_receives_the_lifecycle_outbound_queue(self, teleop): sources = [n for n in pipeline.get_leaf_nodes() if isinstance(n, MessageChannelSource)] assert len(sources) == 1 assert sources[0]._outbound_queue is lifecycle._pending_client_messages - - def test_source_name_matches_the_recorded_replay_channel(self, teleop): # Replay matches the recorded channel by node name; renaming this breaks # MCAP replay of teleop control. - from isaacteleop.retargeting_engine.deviceio_source_nodes import MessageChannelSource - - lifecycle = _make_lifecycle(teleop) - pipeline, _ = lifecycle._build_control_pipeline(teleop.TELEOP_CONTROL_CHANNEL_UUID) - - (source,) = [n for n in pipeline.get_leaf_nodes() if isinstance(n, MessageChannelSource)] - assert source.name == "_teleop_control_source" - - def test_message_channel_sink_is_not_a_device_sink(self, teleop): - # Regression guard. The outbound path deliberately does NOT build a - # MessageChannelSink and does NOT put one in - # TeleopSessionConfig(sinks=...): unlike HapticSink it is a plain - # BaseRetargeter, so _validate_sinks rejects it and the session dies at - # start with "sinks entries must be an IDeviceIOSink". Delivery goes - # through the source's outbound queue instead. If upstream ever makes - # this an IDeviceIOSink, revisit that decision. - from isaacteleop.retargeting_engine.deviceio_source_nodes import IDeviceIOSink, MessageChannelSink - from isaacteleop.retargeting_engine.deviceio_source_nodes.haptic_sink import HapticSink - - assert not issubclass(MessageChannelSink, IDeviceIOSink) - assert issubclass(HapticSink, IDeviceIOSink) + assert sources[0].name == "_teleop_control_source" def test_queued_message_is_shaped_the_way_the_source_drains_it(self, teleop): # poll_tracker iterates batch.data and reads .payload off each entry. @@ -195,13 +167,6 @@ def test_queued_message_is_shaped_the_way_the_source_drains_it(self, teleop): class TestSendClientMessage: """Queueing behaviour of the public send API.""" - def test_message_is_queued_as_encoded_json(self, teleop): - lifecycle = _make_lifecycle(teleop) - lifecycle.send_client_message({"type": "system_notice", "message": {"level": "warning"}}) - - (batch,) = lifecycle._pending_client_messages - assert json.loads(batch.data[0].payload.decode())["type"] == "system_notice" - def test_no_control_channel_drops_the_message(self, teleop): lifecycle = _make_lifecycle(teleop, control_channel_uuid=None) lifecycle.send_client_message({"type": "system_notice"}) @@ -259,27 +224,6 @@ def test_message_queued_before_start_survives(self, teleop, monkeypatch): (batch,) = lifecycle._pending_client_messages assert json.loads(batch.data[0].payload.decode())["type"] == "custom" - def test_start_still_queues_its_own_notice(self, teleop, monkeypatch): - # The path that already works must keep working: a failing check queues - # a notice during start() even with nothing queued beforehand. - from isaaclab_teleop import system_check - - lifecycle = _make_lifecycle(teleop) - monkeypatch.setattr( - system_check, - "check_system_requirements", - lambda device=None: system_check.SystemCheckResult( - items=(system_check.SystemCheckItem("CPU governor", False, "powersave", "performance"),) - ), - ) - monkeypatch.setattr(type(lifecycle), "_build_combined_pipeline", lambda self, pipeline: MagicMock()) - monkeypatch.setattr(type(lifecycle), "_try_start_session", lambda self: False) - - lifecycle.start() - - (batch,) = lifecycle._pending_client_messages - assert json.loads(batch.data[0].payload.decode())["type"] == "system_notice" - def test_caller_message_and_startup_notice_coexist(self, teleop, monkeypatch): from isaaclab_teleop import system_check @@ -315,38 +259,6 @@ def test_stop_clears_undelivered_messages(self, teleop, monkeypatch): class TestSystemCheckOnStart: """The startup check feeding the client notice.""" - def test_failing_check_queues_a_notice(self, teleop, monkeypatch): - from isaaclab_teleop import system_check - - lifecycle = _make_lifecycle(teleop) - monkeypatch.setattr( - system_check, - "check_system_requirements", - lambda device=None: system_check.SystemCheckResult( - items=(system_check.SystemCheckItem("CPU governor", False, "powersave", "performance"),) - ), - ) - - lifecycle._run_system_check() - - (batch,) = lifecycle._pending_client_messages - assert json.loads(batch.data[0].payload.decode())["type"] == "system_notice" - - def test_passing_check_queues_nothing(self, teleop, monkeypatch): - from isaaclab_teleop import system_check - - lifecycle = _make_lifecycle(teleop) - monkeypatch.setattr( - system_check, - "check_system_requirements", - lambda device=None: system_check.SystemCheckResult( - items=(system_check.SystemCheckItem("CPU governor", True, "performance", "performance"),) - ), - ) - - lifecycle._run_system_check() - assert not lifecycle._pending_client_messages - def test_check_raising_does_not_break_startup(self, teleop, monkeypatch): from isaaclab_teleop import system_check diff --git a/source/isaaclab_teleop/test/test_openxr_device_constructors.py b/source/isaaclab_teleop/test/test_openxr_device_constructors.py index 5210359ec825..f12ee4fba3fd 100644 --- a/source/isaaclab_teleop/test/test_openxr_device_constructors.py +++ b/source/isaaclab_teleop/test/test_openxr_device_constructors.py @@ -8,7 +8,12 @@ import pytest from isaaclab_teleop.deprecated.openxr import OpenXRDevice, OpenXRDeviceCfg, XrCfg -from isaaclab_teleop.deprecated.openxr.retargeters import GripperRetargeterCfg, Se3AbsRetargeterCfg +from isaaclab_teleop.deprecated.openxr.retargeters import ( + GripperRetargeter, + GripperRetargeterCfg, + Se3AbsRetargeter, + Se3AbsRetargeterCfg, +) # Import teleop device factory for testing from isaaclab_teleop.deprecated.teleop_device_factory import create_teleop_device @@ -76,57 +81,6 @@ def mock_environment(mocker): } -""" -Test OpenXR devices. -""" - - -def test_openxr_constructors(mock_environment, mocker): - """Test constructor for OpenXRDevice.""" - xr_cfg = XrCfg( - anchor_pos=(1.0, 2.0, 3.0), - anchor_rot=(0.0, 0.1, 0.2, 0.3), - near_plane=0.2, - ) - config = OpenXRDeviceCfg(xr_cfg=xr_cfg) - - mock_controller_retargeter = mocker.MagicMock() - mock_head_retargeter = mocker.MagicMock() - retargeters = [mock_controller_retargeter, mock_head_retargeter] - - device_mod = importlib.import_module("isaaclab_teleop.deprecated.openxr.openxr_device") - mocker.patch.dict( - "sys.modules", - { - "carb": mock_environment["carb"], - "omni.kit.xr.core": mock_environment["omni"].kit.xr.core, - }, - ) - mocker.patch.object(device_mod, "carb", mock_environment["carb"]) - mocker.patch.object(device_mod, "XRCore", mock_environment["omni"].kit.xr.core.XRCore) - mocker.patch.object(device_mod, "XRPoseValidityFlags", mock_environment["omni"].kit.xr.core.XRPoseValidityFlags) - - mock_stage = mocker.MagicMock() - mock_prim = mocker.MagicMock() - mock_prim.IsValid.return_value = False - mock_stage.GetPrimAtPath.return_value = mock_prim - mocker.patch.object(device_mod, "sim_utils", mocker.MagicMock()) - device_mod.sim_utils.get_current_stage.return_value = mock_stage - device_mod.sim_utils.create_prim.return_value = None - - device = OpenXRDevice(config) - assert device._xr_cfg == xr_cfg - - device = OpenXRDevice(cfg=config, retargeters=retargeters) - assert device._retargeters == retargeters - - device = OpenXRDevice(cfg=config, retargeters=retargeters) - assert device._xr_cfg == xr_cfg - assert device._retargeters == retargeters - - device.reset() - - """ Test teleop device factory. """ @@ -217,6 +171,8 @@ def test_create_teleop_device_with_retargeters(mock_environment, mocker): device = create_teleop_device("test_xr", devices_cfg) assert len(device._retargeters) == 2 + assert isinstance(device._retargeters[0], Se3AbsRetargeter) + assert isinstance(device._retargeters[1], GripperRetargeter) def test_create_teleop_device_device_not_found(): diff --git a/source/isaaclab_teleop/test/test_oxr_device.py b/source/isaaclab_teleop/test/test_oxr_device.py index be2a604a6489..8c627785c91a 100644 --- a/source/isaaclab_teleop/test/test_oxr_device.py +++ b/source/isaaclab_teleop/test/test_oxr_device.py @@ -194,35 +194,13 @@ def test_xr_anchor(empty_env, mock_xrcore): device.reset() -@pytest.mark.isaacsim_ci -def test_xr_anchor_default(empty_env, mock_xrcore): - """Test XR anchor creation with default configuration.""" - env, _ = empty_env - # Create a proper config object with default values - device = OpenXRDevice(OpenXRDeviceCfg()) - - # Check that the xr anchor prim is created with the correct default pose - xr_anchor_view = sim_utils.FrameView("/World/XRAnchor") - assert xr_anchor_view.count == 1 - - position, orientation = xr_anchor_view.get_world_poses() - np.testing.assert_almost_equal(position.torch.numpy().tolist(), [[0, 0, 0]]) - np.testing.assert_almost_equal(orientation.torch.numpy().tolist(), [[0, 0, 0, 1]]) - - # Check that xr anchor mode and custom anchor are set correctly - assert carb.settings.get_settings().get("/persistent/xr/anchorMode") == "custom anchor" - assert carb.settings.get_settings().get("/xrstage/customAnchor") == "/World/XRAnchor" - - device.reset() - - @pytest.mark.isaacsim_ci def test_xr_anchor_multiple_devices(empty_env, mock_xrcore): - """Test XR anchor behavior with multiple devices.""" + """Test XR anchor behavior with multiple devices and the raw tracking data format.""" env, _ = empty_env - # Create proper config objects with default values + # Create proper config objects with default values; the no-op retargeter triggers data collection device_1 = OpenXRDevice(OpenXRDeviceCfg()) - device_2 = OpenXRDevice(OpenXRDeviceCfg()) + device_2 = OpenXRDevice(OpenXRDeviceCfg(), retargeters=[NoOpRetargeter(RetargeterCfg())]) # Check that the xr anchor prim is created with the correct default pose xr_anchor_view = sim_utils.FrameView("/World/XRAnchor") @@ -236,20 +214,8 @@ def test_xr_anchor_multiple_devices(empty_env, mock_xrcore): assert carb.settings.get_settings().get("/persistent/xr/anchorMode") == "custom anchor" assert carb.settings.get_settings().get("/xrstage/customAnchor") == "/World/XRAnchor" - device_1.reset() - device_2.reset() - - -@pytest.mark.isaacsim_ci -def test_get_raw_data(empty_env, mock_xrcore): - """Test the _get_raw_data method returns correctly formatted tracking data.""" - env, _ = empty_env - # Create a proper config object with default values and a no-op retargeter to trigger data collection - retargeter = NoOpRetargeter(RetargeterCfg()) - device = OpenXRDevice(OpenXRDeviceCfg(), retargeters=[retargeter]) - # Get raw tracking data - raw_data = device._get_raw_data() + raw_data = device_2._get_raw_data() # Check that the data structure is as expected from isaaclab.devices.device_base import DeviceBase @@ -274,3 +240,6 @@ def test_get_raw_data(empty_env, mock_xrcore): assert len(head_pose) == 7 # [x, y, z, qx, qy, qz, qw] np.testing.assert_almost_equal(head_pose[:3], [0.1, 0.2, 0.3]) # Position np.testing.assert_almost_equal(head_pose[3:], [0.1, 0.2, 0.3, 0.9]) # Orientation + + device_1.reset() + device_2.reset() diff --git a/source/isaaclab_teleop/test/test_retargeters.py b/source/isaaclab_teleop/test/test_retargeters.py index c7ad6a196fda..8b06c1a98ca3 100644 --- a/source/isaaclab_teleop/test/test_retargeters.py +++ b/source/isaaclab_teleop/test/test_retargeters.py @@ -217,59 +217,6 @@ def test_retarget(self): self.assertTrue(torch.equal(result, torch.tensor([0.0, 0.0, 0.0, 0.8]))) -class TestUnitreeG1Retargeter(unittest.TestCase): - def test_retarget(self): - cfg = UnitreeG1RetargeterCfg( - enable_visualization=False, sim_device="cpu", hand_joint_names=["joint1", "joint2"] - ) - retargeter = UnitreeG1Retargeter(cfg) - - # Replace _hands_controller with a configured mock. - # NOTE: We cannot use @patch on the module-level class because lazy_export() - # in parent __init__.py files replaces the module __dict__, so the class's - # __globals__ (old dict) diverges from the module attribute (new dict). - mock_dex_retargeting = MagicMock() - mock_dex_retargeting.get_joint_names.return_value = ["joint1", "joint2"] - mock_dex_retargeting.get_left_joint_names.return_value = ["joint1"] - mock_dex_retargeting.get_right_joint_names.return_value = ["joint2"] - mock_dex_retargeting.compute_left.return_value = np.array([0.1]) - mock_dex_retargeting.compute_right.return_value = np.array([0.2]) - retargeter._hands_controller = mock_dex_retargeting - - wrist_pose = np.array([0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0]) - data = { - DeviceBase.TrackingTarget.HAND_LEFT: {"wrist": wrist_pose}, - DeviceBase.TrackingTarget.HAND_RIGHT: {"wrist": wrist_pose}, - } - - result = retargeter.retarget(data) - self.assertEqual(result.shape, (16,)) - - -class TestGR1T2Retargeter(unittest.TestCase): - def test_retarget(self): - cfg = GR1T2RetargeterCfg(enable_visualization=False, sim_device="cpu", hand_joint_names=["joint1", "joint2"]) - retargeter = GR1T2Retargeter(cfg) - - # Replace _hands_controller with a configured mock (see TestUnitreeG1Retargeter note). - mock_dex_retargeting = MagicMock() - mock_dex_retargeting.get_joint_names.return_value = ["joint1", "joint2"] - mock_dex_retargeting.get_left_joint_names.return_value = ["joint1"] - mock_dex_retargeting.get_right_joint_names.return_value = ["joint2"] - mock_dex_retargeting.compute_left.return_value = np.array([0.1]) - mock_dex_retargeting.compute_right.return_value = np.array([0.2]) - retargeter._hands_controller = mock_dex_retargeting - - wrist_pose = np.array([0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0]) - data = { - DeviceBase.TrackingTarget.HAND_LEFT: {"wrist": wrist_pose}, - DeviceBase.TrackingTarget.HAND_RIGHT: {"wrist": wrist_pose}, - } - - result = retargeter.retarget(data) - self.assertEqual(result.shape, (16,)) - - class TestG1LowerBodyStandingMotionControllerRetargeter(unittest.TestCase): def test_retarget(self): cfg = G1LowerBodyStandingMotionControllerRetargeterCfg( @@ -349,33 +296,52 @@ def test_retarget(self): result = retargeter.retarget(data) # Output: [left_wrist(7), right_wrist(7), hand_joints(14)] self.assertEqual(result.shape, (28,)) + # An identity controller pose maps to the fixed controller-to-wrist rotation (xyzw) for both hands. + expected_quat = np.array([-0.4619, 0.5358, 0.4619, 0.5358]) + expected_quat /= np.linalg.norm(expected_quat) + for wrist in (result[0:7].numpy(), result[7:14].numpy()): + np.testing.assert_allclose(wrist[:3], np.zeros(3), atol=1e-6) + quat = wrist[3:] if wrist[6] * expected_quat[3] >= 0 else -wrist[3:] + np.testing.assert_allclose(quat, expected_quat, atol=1e-3) + # Released buttons leave every hand joint at rest. + np.testing.assert_allclose(result[14:].numpy(), np.zeros(14), atol=1e-6) -class TestG1TriHandUpperBodyRetargeter(unittest.TestCase): - def test_retarget(self): - cfg = G1TriHandUpperBodyRetargeterCfg( - enable_visualization=False, sim_device="cpu", hand_joint_names=["joint1", "joint2"] - ) - retargeter = G1TriHandUpperBodyRetargeter(cfg) - - # Replace _hands_controller with a configured mock (see TestUnitreeG1Retargeter note). - mock_dex_retargeting = MagicMock() - mock_dex_retargeting.get_joint_names.return_value = ["joint1", "joint2"] - mock_dex_retargeting.get_left_joint_names.return_value = ["joint1"] - mock_dex_retargeting.get_right_joint_names.return_value = ["joint2"] - mock_dex_retargeting.compute_left.return_value = np.array([0.1]) - mock_dex_retargeting.compute_right.return_value = np.array([0.2]) - retargeter._hands_controller = mock_dex_retargeting +class TestDexHandUpperBodyRetargeters(unittest.TestCase): + """Upper-body retargeters that combine both wrists with dex-retargeted hand joints.""" - wrist_pose = np.array([0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0]) - data = { - DeviceBase.TrackingTarget.HAND_LEFT: {"wrist": wrist_pose}, - DeviceBase.TrackingTarget.HAND_RIGHT: {"wrist": wrist_pose}, - } - - result = retargeter.retarget(data) - # Output: [left_wrist(7), right_wrist(7), joints(2)] - self.assertEqual(result.shape, (16,)) + def test_retarget(self): + for retargeter_cls, cfg_cls in ( + (UnitreeG1Retargeter, UnitreeG1RetargeterCfg), + (GR1T2Retargeter, GR1T2RetargeterCfg), + (G1TriHandUpperBodyRetargeter, G1TriHandUpperBodyRetargeterCfg), + ): + with self.subTest(retargeter=retargeter_cls.__name__): + cfg = cfg_cls(enable_visualization=False, sim_device="cpu", hand_joint_names=["joint1", "joint2"]) + retargeter = retargeter_cls(cfg) + + # Replace _hands_controller with a configured mock. + # NOTE: We cannot use @patch on the module-level class because lazy_export() + # in parent __init__.py files replaces the module __dict__, so the class's + # __globals__ (old dict) diverges from the module attribute (new dict). + mock_dex_retargeting = MagicMock() + mock_dex_retargeting.get_joint_names.return_value = ["joint1", "joint2"] + mock_dex_retargeting.get_left_joint_names.return_value = ["joint1"] + mock_dex_retargeting.get_right_joint_names.return_value = ["joint2"] + mock_dex_retargeting.compute_left.return_value = np.array([0.1]) + mock_dex_retargeting.compute_right.return_value = np.array([0.2]) + retargeter._hands_controller = mock_dex_retargeting + + wrist_pose = np.array([0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0]) + data = { + DeviceBase.TrackingTarget.HAND_LEFT: {"wrist": wrist_pose}, + DeviceBase.TrackingTarget.HAND_RIGHT: {"wrist": wrist_pose}, + } + + result = retargeter.retarget(data) + # Output: [left_wrist(7), right_wrist(7), joints(2)]; each hand fills its own joint. + self.assertEqual(result.shape, (16,)) + np.testing.assert_allclose(result[14:16].numpy(), [0.1, 0.2], rtol=1e-6) if __name__ == "__main__": diff --git a/source/isaaclab_teleop/test/test_system_check.py b/source/isaaclab_teleop/test/test_system_check.py index 0dc9df8d2942..34fa72e1ac14 100644 --- a/source/isaaclab_teleop/test/test_system_check.py +++ b/source/isaaclab_teleop/test/test_system_check.py @@ -18,7 +18,7 @@ import json import pytest -from isaaclab_teleop import system_check +from isaaclab_teleop import session_lifecycle, system_check from isaaclab_teleop.system_check import SystemCheckItem, SystemCheckResult, check_system_requirements @@ -175,6 +175,10 @@ def test_failing_score_still_flags_the_governor(self, monkeypatch): assert not result.passed assert {item.name for item in result.failures} == {"CPU single-thread", "CPU governor"} assert items["CPU governor"].detail == system_check.CPU_GOVERNOR_FIX + # The client notice carries the fix command too. + message_items = {item["name"]: item for item in result.to_message()["message"]["items"]} + assert message_items["CPU governor"]["detail"] == system_check.CPU_GOVERNOR_FIX + assert message_items["CPU governor"]["actual"] == "powersave" def test_unmeasurable_score_still_flags_the_governor(self, monkeypatch): # Without a score there is no evidence the machine is fast enough, so @@ -187,10 +191,6 @@ def test_performance_governor_passes_regardless_of_score(self, monkeypatch): _, items = _at_spec(monkeypatch, score=0.5, governor="performance") assert items["CPU governor"].passed - def test_requirement_text_names_the_alternative(self, monkeypatch): - _, items = _at_spec(monkeypatch, governor="performance") - assert "single-thread" in items["CPU governor"].required - class TestMultiGpuDeviceSelection: """The GPU probe must measure the adapter teleop actually runs on. @@ -211,6 +211,8 @@ def test_selected_device_is_measured_not_ordinal_zero(self, monkeypatch): result, items = _at_spec(monkeypatch, gpus=self._MIXED, current_device=0, device="cuda:1") assert result.passed assert "RTX PRO 6000" in items["GPU memory"].actual + # The reported ordinal identifies the measured adapter. + assert "cuda:1" in items["GPU memory"].actual def test_weak_selected_device_is_not_masked_by_a_capable_ordinal_zero(self, monkeypatch): # The inverse error: passing because ordinal 0 happens to be capable. @@ -219,15 +221,6 @@ def test_weak_selected_device_is_not_masked_by_a_capable_ordinal_zero(self, monk assert not result.passed assert {item.name for item in result.failures} == {"GPU memory", "GPU architecture"} - def test_reported_ordinal_identifies_the_measured_adapter(self, monkeypatch): - _, items = _at_spec(monkeypatch, gpus=self._MIXED, current_device=0, device="cuda:1") - assert "cuda:1" in items["GPU memory"].actual - - def test_unset_device_falls_back_to_the_current_device(self, monkeypatch): - result, items = _at_spec(monkeypatch, gpus=self._MIXED, current_device=1, device=None) - assert result.passed - assert "cuda:1" in items["GPU memory"].actual - @pytest.mark.parametrize("device", ["cuda", "cpu", None]) def test_non_ordinal_devices_use_the_current_device(self, monkeypatch, device): # A CPU simulation device still warrants a GPU probe: CloudXR encodes on @@ -298,19 +291,6 @@ def test_message_carries_only_failing_items(self, monkeypatch): "System memory", } - def test_governor_failure_carries_the_fix_command(self, monkeypatch): - # A failing score is what makes the governor gate at all now. - result, _ = _at_spec(monkeypatch, score=0.5, governor="powersave") - items = {item["name"]: item for item in result.to_message()["message"]["items"]} - assert items["CPU governor"]["detail"] == system_check.CPU_GOVERNOR_FIX - assert items["CPU governor"]["actual"] == "powersave" - - def test_message_is_json_serializable(self, monkeypatch): - import json - - result, _ = _at_spec(monkeypatch, score=0.4) - assert json.loads(json.dumps(result.to_message()))["type"] == "system_notice" - class TestMessageBudget: """A notice must fit the teleop channel, which does not fragment. @@ -321,7 +301,8 @@ class TestMessageBudget: an unbudgeted notice goes missing exactly when it matters most. """ - LIMIT = 900 + # The budget the lifecycle actually enforces, so the two cannot drift apart. + LIMIT = session_lifecycle._MAX_CLIENT_MESSAGE_BYTES def _failing(self, count: int) -> SystemCheckResult: return SystemCheckResult( @@ -337,17 +318,11 @@ def _failing(self, count: int) -> SystemCheckResult: ) ) - @pytest.mark.parametrize("count", [1, 2, 3, 4, 5, 9, 20]) + @pytest.mark.parametrize("count", [1, 5, 9, 20]) def test_notice_always_fits_the_budget(self, count): message = self._failing(count).to_message(max_bytes=self.LIMIT) assert len(json.dumps(message).encode()) <= self.LIMIT - def test_every_check_failing_at_once_fits(self): - # The realistic worst case: one failure per check the module performs. - message = self._failing(9).to_message(max_bytes=self.LIMIT) - assert len(json.dumps(message).encode()) <= self.LIMIT - assert message["message"]["items"] - def test_small_notice_keeps_its_remediation_hints(self): # Budgeting must not strip the actionable part when there is room. message = self._failing(1).to_message(max_bytes=self.LIMIT) @@ -362,6 +337,7 @@ def test_details_are_dropped_before_requirements(self): def test_truncation_says_how_many_were_omitted(self): message = self._failing(9).to_message(max_bytes=self.LIMIT) shown = len(message["message"]["items"]) + assert shown > 0 assert f"{9 - shown} more not shown" in message["message"]["summary"] def test_no_budget_returns_the_full_notice(self): @@ -369,13 +345,6 @@ def test_no_budget_returns_the_full_notice(self): assert len(message["message"]["items"]) == 9 assert all(item["detail"] for item in message["message"]["items"]) - def test_budget_matches_the_transport_limit(self): - # Pins the test budget to what the lifecycle actually enforces, so the - # two cannot drift apart. - from isaaclab_teleop import session_lifecycle - - assert session_lifecycle._MAX_CLIENT_MESSAGE_BYTES == self.LIMIT - class TestSystemCheckFormatting: """Terminal output.""" diff --git a/source/isaaclab_teleop/test/test_target_frame_rebase.py b/source/isaaclab_teleop/test/test_target_frame_rebase.py index 044b5da7d305..d61a9fb5676e 100644 --- a/source/isaaclab_teleop/test/test_target_frame_rebase.py +++ b/source/isaaclab_teleop/test/test_target_frame_rebase.py @@ -7,26 +7,25 @@ """Tests for the target-frame rebase logic, _to_numpy_4x4 helper, and config-driven auto-selection. -These tests exercise pure math (no Omniverse/Isaac Sim stack required). +These tests need no Omniverse/Isaac Sim stack. """ from __future__ import annotations +from types import SimpleNamespace +from unittest.mock import MagicMock + import numpy as np import pytest import torch -from isaaclab_teleop.session_lifecycle import _to_numpy_4x4 +from isaaclab_teleop.isaac_teleop_device import IsaacTeleopDevice +from isaaclab_teleop.session_lifecycle import TeleopSessionLifecycle, _to_numpy_4x4 # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- -@pytest.fixture -def identity_4x4() -> np.ndarray: - return np.eye(4, dtype=np.float32) - - @pytest.fixture def translation_matrix() -> np.ndarray: """A pure translation of (1, 2, 3).""" @@ -52,12 +51,6 @@ def rotation_90z_matrix() -> np.ndarray: class TestToNumpy4x4: - def test_from_ndarray_float32(self, identity_4x4: np.ndarray): - result = _to_numpy_4x4(identity_4x4) - assert isinstance(result, np.ndarray) - assert result.dtype == np.float32 - np.testing.assert_array_equal(result, identity_4x4) - def test_from_ndarray_float64_casts(self): mat = np.eye(4, dtype=np.float64) result = _to_numpy_4x4(mat) @@ -104,161 +97,81 @@ def test_from_list(self): # --------------------------------------------------------------------------- -# Matrix multiplication (rebase) tests +# Rebase of the world_T_anchor external input # --------------------------------------------------------------------------- -class TestRebaseMultiplication: - def test_rebase_identity_is_noop(self, translation_matrix: np.ndarray): - """target_T_world = I should leave anchor_matrix unchanged.""" - identity = np.eye(4, dtype=np.float32) - result = _to_numpy_4x4(identity) @ translation_matrix - np.testing.assert_array_almost_equal(result, translation_matrix) - - def test_rebase_translation(self, identity_4x4: np.ndarray): - """Rebasing by a pure translation offsets the origin.""" - target_T_world = np.eye(4, dtype=np.float32) - target_T_world[:3, 3] = [10.0, 20.0, 30.0] +def _world_T_anchor_input(target_T_world) -> np.ndarray: + """Run ``_build_external_inputs`` for a session that only consumes ``world_T_anchor``.""" + ValueInput = pytest.importorskip("isaacteleop.retargeting_engine.interface").ValueInput - world_T_anchor = np.eye(4, dtype=np.float32) - world_T_anchor[:3, 3] = [1.0, 2.0, 3.0] + lifecycle = object.__new__(TeleopSessionLifecycle) + lifecycle._session = MagicMock() + lifecycle._session.has_external_inputs.return_value = True + lifecycle._session.get_external_input_specs.return_value = [TeleopSessionLifecycle.WORLD_T_ANCHOR_INPUT_NAME] - result = _to_numpy_4x4(target_T_world) @ world_T_anchor - np.testing.assert_array_almost_equal(result[:3, 3], [11.0, 22.0, 33.0]) + world_T_anchor = np.eye(4, dtype=np.float32) + world_T_anchor[:3, 3] = [1.0, 0.0, 0.0] - def test_rebase_rotation(self, rotation_90z_matrix: np.ndarray): - """A 90-deg Z rotation rebase should rotate the anchor translation.""" - world_T_anchor = np.eye(4, dtype=np.float32) - world_T_anchor[:3, 3] = [1.0, 0.0, 0.0] + external_inputs = lifecycle._build_external_inputs(lambda: world_T_anchor, target_T_world) + return np.asarray(external_inputs[TeleopSessionLifecycle.WORLD_T_ANCHOR_INPUT_NAME][ValueInput.VALUE][0]) - result = _to_numpy_4x4(rotation_90z_matrix) @ world_T_anchor - # After 90-deg Z rotation: (1,0,0) -> (0,1,0) - np.testing.assert_array_almost_equal(result[:3, 3], [0.0, 1.0, 0.0]) - # Rotation part should match the 90-deg Z rotation - np.testing.assert_array_almost_equal(result[:3, :3], rotation_90z_matrix[:3, :3]) +class TestBuildExternalInputsRebase: + def test_target_T_world_left_multiplies_the_anchor(self, rotation_90z_matrix: np.ndarray): + """The anchor input becomes ``target_T_world @ world_T_anchor`` (order matters for rotation + translation).""" + target_T_world = rotation_90z_matrix.copy() + target_T_world[:3, 3] = [10.0, 0.0, 0.0] - def test_rebase_with_torch_tensor(self, translation_matrix: np.ndarray): - """target_T_world as a torch.Tensor should work identically.""" - target_T_world = torch.eye(4, dtype=torch.float32) - target_T_world[:3, 3] = torch.tensor([5.0, 5.0, 5.0]) - - result = _to_numpy_4x4(target_T_world) @ translation_matrix - - expected = np.eye(4, dtype=np.float32) - expected[:3, 3] = [6.0, 7.0, 8.0] - np.testing.assert_array_almost_equal(result, expected) - - def test_none_target_leaves_anchor_unchanged(self, translation_matrix: np.ndarray): - """When target_T_world is None, the calling code should skip multiplication.""" - target_T_world = None - anchor_matrix = translation_matrix.copy() - - if target_T_world is not None: - anchor_matrix = _to_numpy_4x4(target_T_world) @ anchor_matrix - - np.testing.assert_array_equal(anchor_matrix, translation_matrix) - - def test_inverse_rebase_recovers_identity(self): - """target_T_world = inv(world_T_anchor) should yield identity.""" - world_T_anchor = np.array( + # Rotating the anchor offset (1, 0, 0) by 90 deg about Z gives (0, 1, 0), then the target translation applies. + expected = np.array( [ - [0.0, -1.0, 0.0, 3.0], - [1.0, 0.0, 0.0, -1.0], - [0.0, 0.0, 1.0, 2.0], + [0.0, -1.0, 0.0, 10.0], + [1.0, 0.0, 0.0, 1.0], + [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0], ], dtype=np.float32, ) - target_T_world = np.linalg.inv(world_T_anchor).astype(np.float32) + np.testing.assert_array_almost_equal(_world_T_anchor_input(target_T_world), expected) - result = _to_numpy_4x4(target_T_world) @ world_T_anchor - np.testing.assert_array_almost_equal(result, np.eye(4, dtype=np.float32), decimal=5) + def test_none_target_leaves_anchor_unchanged(self): + expected = np.eye(4, dtype=np.float32) + expected[:3, 3] = [1.0, 0.0, 0.0] + np.testing.assert_array_equal(_world_T_anchor_input(None), expected) # --------------------------------------------------------------------------- -# Config-driven auto-selection tests +# Config-driven auto-selection in IsaacTeleopDevice.advance() # --------------------------------------------------------------------------- - -def _simulate_advance_selection( - target_T_world: np.ndarray | None, - target_frame_prim_path: str | None, - auto_read_result: np.ndarray | None = None, -) -> tuple[np.ndarray | None, bool]: - """Replicate the auto-selection logic from IsaacTeleopDevice.advance(). - - Returns the target_T_world that would be passed to step(), and whether - _get_target_frame_T_world would have been called. - """ - auto_read_called = False - - def fake_get_target_frame_T_world(): - nonlocal auto_read_called - auto_read_called = True - return auto_read_result - - if target_T_world is None and target_frame_prim_path is not None: - target_T_world = fake_get_target_frame_T_world() - - return target_T_world, auto_read_called - - -class TestConfigDrivenAutoSelection: - """Tests for the advance() auto-selection logic between explicit target_T_world - and config-driven target_frame_prim_path. - - These tests replicate the branching logic from advance() without importing - the full IsaacTeleopDevice (which requires Isaac Sim runtime dependencies). - """ - - def test_no_config_no_explicit_passes_none(self): - """When neither config nor explicit target_T_world is set, step() receives None.""" - result, called = _simulate_advance_selection(target_T_world=None, target_frame_prim_path=None) - assert result is None - assert not called - - def test_explicit_target_is_passed_through(self): - """An explicit target_T_world should be passed directly to step().""" - explicit = np.eye(4, dtype=np.float32) - explicit[:3, 3] = [1.0, 2.0, 3.0] - - result, called = _simulate_advance_selection(target_T_world=explicit, target_frame_prim_path=None) - np.testing.assert_array_equal(result, explicit) - assert not called - - def test_config_prim_triggers_auto_read(self): - """When target_frame_prim_path is set, _get_target_frame_T_world is called.""" - auto_matrix = np.eye(4, dtype=np.float32) - auto_matrix[:3, 3] = [9.0, 8.0, 7.0] - - result, called = _simulate_advance_selection( - target_T_world=None, - target_frame_prim_path="/World/Robot/base_link", - auto_read_result=auto_matrix, - ) - assert called - np.testing.assert_array_equal(result, auto_matrix) - - def test_explicit_overrides_config(self): - """An explicit target_T_world takes precedence over config prim path.""" - explicit = np.eye(4, dtype=np.float32) - explicit[:3, 3] = [42.0, 0.0, 0.0] - - result, called = _simulate_advance_selection( - target_T_world=explicit, - target_frame_prim_path="/World/Robot/base_link", - auto_read_result=np.eye(4, dtype=np.float32), - ) - assert not called - np.testing.assert_array_equal(result, explicit) - - def test_config_prim_returns_none_passes_none(self): - """If the prim read fails (returns None), step() receives None.""" - result, called = _simulate_advance_selection( - target_T_world=None, - target_frame_prim_path="/World/Robot/base_link", - auto_read_result=None, - ) - assert called - assert result is None +_EXPLICIT = np.diag([2.0, 2.0, 2.0, 1.0]).astype(np.float32) +_AUTO = np.diag([3.0, 3.0, 3.0, 1.0]).astype(np.float32) + + +@pytest.mark.parametrize( + ("prim_path", "explicit", "auto_result", "expected", "expect_auto_read"), + [ + pytest.param(None, None, _AUTO, None, False, id="no-config-no-explicit"), + pytest.param(None, _EXPLICIT, _AUTO, _EXPLICIT, False, id="explicit-only"), + pytest.param("/World/Robot/base_link", None, _AUTO, _AUTO, True, id="config-auto-read"), + pytest.param("/World/Robot/base_link", _EXPLICIT, _AUTO, _EXPLICIT, False, id="explicit-overrides-config"), + pytest.param("/World/Robot/base_link", None, None, None, True, id="config-read-fails"), + ], +) +def test_advance_selects_target_frame(prim_path, explicit, auto_result, expected, expect_auto_read): + """advance() passes an explicit target_T_world through, else reads the configured prim.""" + device = object.__new__(IsaacTeleopDevice) + device._cfg = SimpleNamespace(target_frame_prim_path=prim_path) + device._session_lifecycle = MagicMock() + device._session_lifecycle.step.return_value = None + device._anchor_manager = MagicMock() + device._get_target_frame_T_world = MagicMock(return_value=auto_result) + device._dispatch_control_callbacks = lambda: None + + device.advance(target_T_world=explicit) + + assert device._get_target_frame_T_world.called is expect_auto_read + step_kwargs = device._session_lifecycle.step.call_args.kwargs + assert step_kwargs["anchor_world_matrix_fn"] is device._anchor_manager.get_world_matrix + assert step_kwargs["target_T_world"] is expected diff --git a/source/isaaclab_teleop/test/test_xr_camera_feed.py b/source/isaaclab_teleop/test/test_xr_camera_feed.py index f8339ae92f7b..2d35ad32a0ad 100644 --- a/source/isaaclab_teleop/test/test_xr_camera_feed.py +++ b/source/isaaclab_teleop/test/test_xr_camera_feed.py @@ -182,22 +182,6 @@ def test_pip_rejects_multiple_environments_before_camera_creation(monkeypatch): load_presenter.assert_called_once_with() -def test_xr_without_pip_preserves_multiple_environments(monkeypatch): - env_cfg = _teleop_env_cfg([], num_envs=2) - load_presenter = Mock() - monkeypatch.setattr(camera_feed, "_load_kit_scene_ui_presenter", load_presenter) - - session = XrCameraFeedSession.prepare( - env_cfg, - enabled=True, - camera_rendering_enabled=True, - ) - - assert not session.enabled - load_presenter.assert_not_called() - assert env_cfg.scene.num_envs == 2 - - def test_kitless_xr_with_configured_pip_preserves_multiple_environments(monkeypatch): env_cfg = _teleop_env_cfg( [XrCameraFeedCfg(camera_name="robot_pov_cam")], @@ -217,7 +201,8 @@ def test_kitless_xr_with_configured_pip_preserves_multiple_environments(monkeypa def test_empty_camera_feed_selection_skips_pip(monkeypatch): - env_cfg = _teleop_env_cfg([]) + # Without PiP feeds, XR keeps a multi-environment scene untouched. + env_cfg = _teleop_env_cfg([], num_envs=2) load_presenter = Mock() monkeypatch.setattr(camera_feed, "_load_kit_scene_ui_presenter", load_presenter) @@ -225,7 +210,7 @@ def test_empty_camera_feed_selection_skips_pip(monkeypatch): assert not session.enabled load_presenter.assert_not_called() - assert vars(env_cfg.scene) == {"num_envs": 1} + assert vars(env_cfg.scene) == {"num_envs": 2} @pytest.mark.parametrize( @@ -288,20 +273,19 @@ def test_existing_camera_rejects_invalid_feed_render_policy_before_version_query get_isaac_sim_version.assert_not_called() -def test_session_refresh_publishes_buffer_refreshed_by_env_reset(): - events = [] - session = XrCameraFeedSession([], None, None, requires_responsive_denoising=False) - - class _Manager: - def refresh(self, *, publish=True): - events.append("publish" if publish else "request") - - session._manager = _Manager() +def test_session_refresh_publishes_buffer_refreshed_by_env_reset(monkeypatch): + cfg = XrCameraFeedCfg(camera_name="robot_pov_cam", max_update_hz=0.0) + before = _FakeImage(data_ptr=100) + after = _FakeImage(data_ptr=200) + manager, presenter, cameras = _manager(monkeypatch, [cfg], {"robot_pov_cam": before}) + cameras["robot_pov_cam"].image_on_update = after + session = XrCameraFeedSession([cfg], None, None, requires_responsive_denoising=False) + session._manager = manager session._bound = True session.refresh() - assert events == ["publish"] + assert presenter.panels[0].uploads == [after] def test_camera_rendering_switch_disables_pip_before_presenter_load(monkeypatch): @@ -375,16 +359,21 @@ def test_automatic_layouts_preserve_order(layout, expected): @pytest.mark.parametrize( - "layout", + ("layout", "match"), [ - XrCameraFeedLayoutCfg(mode="diagonal"), - XrCameraFeedLayoutCfg(distance_m=0.0), - XrCameraFeedLayoutCfg(placement="world"), - XrCameraFeedLayoutCfg(placement="world", world_position_m=(0.0, 0.0, 0.0), world_orientation_xyzw=(0, 0, 0, 0)), + (XrCameraFeedLayoutCfg(mode="diagonal"), "layout mode"), + (XrCameraFeedLayoutCfg(distance_m=0.0), "distance_m"), + (XrCameraFeedLayoutCfg(placement="world"), "world_position_m"), + ( + XrCameraFeedLayoutCfg( + placement="world", world_position_m=(0.0, 0.0, 0.0), world_orientation_xyzw=(0, 0, 0, 0) + ), + "non-zero", + ), ], ) -def test_invalid_layouts_fail_before_panel_creation(layout): - with pytest.raises(ValueError): +def test_invalid_layouts_fail_before_panel_creation(layout, match): + with pytest.raises(ValueError, match=match): camera_feed._validate_layout_cfg(layout) @@ -511,20 +500,6 @@ def test_manager_rejects_invalid_update_rate(monkeypatch, value): _manager(monkeypatch, [cfg], {"robot_pov_cam": _FakeImage()}) -def test_public_api_exports_camera_feed_types(): - import isaaclab_teleop - - assert isaaclab_teleop.XrCameraFeedCfg is XrCameraFeedCfg - assert isaaclab_teleop.XrCameraFeedLayoutCfg is XrCameraFeedLayoutCfg - assert isaaclab_teleop.XrCameraFeedSession is XrCameraFeedSession - for removed_name in ( - "XrCameraFeedManager", - "XrCameraFeedPresentationBackend", - "XrCameraFeedPresentationCfg", - ): - assert not hasattr(isaaclab_teleop, removed_name) - - def test_isaac_teleop_default_has_no_camera_feeds(): cfg = IsaacTeleopCfg(pipeline_builder=lambda: None) diff --git a/source/isaaclab_teleop/test/test_xr_camera_feed_kit_scene_ui.py b/source/isaaclab_teleop/test/test_xr_camera_feed_kit_scene_ui.py index abc989d6a9b1..2353fb8af8ea 100644 --- a/source/isaaclab_teleop/test/test_xr_camera_feed_kit_scene_ui.py +++ b/source/isaaclab_teleop/test/test_xr_camera_feed_kit_scene_ui.py @@ -904,23 +904,10 @@ def test_image_source_treats_empty_annotator_frame_as_not_ready(scene_ui_module, source.close() -def test_image_source_falls_back_when_replicator_attach_fails(scene_ui_module, monkeypatch, caplog): - annotator = Mock() - annotator.attach.side_effect = RuntimeError("attach failed") - _install_replicator(monkeypatch, annotator) - camera = SimpleNamespace(_render_data=SimpleNamespace(render_product=SimpleNamespace(path="/Render/Camera"))) - presenter = scene_ui_module._KitSceneUiCameraFeedPresenter() - - source = presenter.create_image_source("robot_pov_cam", camera) - - assert source is None - annotator.detach.assert_called_once_with(["/Render/Camera"]) - assert "Falling back to the Camera RGBA buffer" in caplog.text - - def test_image_source_authors_feed_policy_when_replicator_attach_falls_back( scene_ui_module, monkeypatch, + caplog, ): events = [] annotator = Mock() @@ -957,6 +944,7 @@ def fail_attach(paths): ("apply", "OmniRtxDebugSettingsAPI_1"), ("set", False), ] + assert "Falling back to the Camera RGBA buffer" in caplog.text def test_image_source_falls_back_when_replicator_read_fails(scene_ui_module, monkeypatch, caplog): diff --git a/source/isaaclab_visualizers/changelog.d/test-prune-extension-tests.skip b/source/isaaclab_visualizers/changelog.d/test-prune-extension-tests.skip new file mode 100644 index 000000000000..8dd4a4d87444 --- /dev/null +++ b/source/isaaclab_visualizers/changelog.d/test-prune-extension-tests.skip @@ -0,0 +1 @@ +Pruned redundant visualizer tests and removed the unused franka-cloth golden-image helpers. diff --git a/source/isaaclab_visualizers/test/golden_images/franka_cloth/newton-kit-tiled.png b/source/isaaclab_visualizers/test/golden_images/franka_cloth/newton-kit-tiled.png deleted file mode 100644 index 78a1990fb5aed9742bd03530e946c9465c17ac63..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 176928 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⪚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 diff --git a/source/isaaclab_visualizers/test/golden_images/franka_cloth/newton-kit-viewport.png b/source/isaaclab_visualizers/test/golden_images/franka_cloth/newton-kit-viewport.png deleted file mode 100644 index e08bc4e5f782b863949d5519d88cffb2feca82fc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 165259 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

;>`Zg#X)pud(7?%0$dU}@EJu| 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

= a valid preset selection.""" + presets = {"env": {}, "agent": {}} + presets[section] = {"": {"default": None, "fast": None}} + _, sel, _, _ = parse_overrides([f"{section}=fast"], presets) + assert sel == [(section, "", "fast")] # ============================================================================= @@ -1394,17 +1293,19 @@ def test_resolve_presets_idempotent(): def test_unknown_global_preset_name_detected(): - """A selected preset name that doesn't match any PresetCfg field is detected. - - This catches typos like presets=peg_insrt_4mm (missing 'e'). The validation - in register_task raises ValueError before resolution begins. - """ - cfg = PresetCfgEnvCfg() - presets = {"env": collect_presets(cfg), "agent": {}} - all_known = {name for alts in presets.values() for fields in alts.values() for name in fields if name != "default"} + """``register_task`` rejects a selected preset name that no PresetCfg declares (e.g. a typo).""" + import gymnasium as gym - assert "newton_mjwarp" in all_known - assert "typo_preset" not in all_known + gym.register( + id="Isaac-Hydra-UnknownPreset-Test", + entry_point="dummy:Env", + kwargs={"env_cfg_entry_point": PresetCfgEnvCfg}, + ) + try: + with pytest.raises(ValueError, match="typo_preset"): + hydra_mod.register_task("Isaac-Hydra-UnknownPreset-Test", None, overrides=["presets=typo_preset"]) + finally: + del gym.registry["Isaac-Hydra-UnknownPreset-Test"] def test_resolve_presets_errors_on_no_default(): @@ -1505,26 +1406,6 @@ class _PhysxPhysicsCfg(_RealPhysicsCfg): dt: float = 0.005 -def test_validate_typed_presets_passes_when_selector_hits_its_type(): - """``physics=newton_mjwarp`` that landed on a PhysicsCfg does not raise.""" - hydra_mod._validate_typed_presets( - {PresetTarget.PHYSICS: {"newton_mjwarp"}}, - typed_hits={"newton_mjwarp": {PresetTarget.PHYSICS}}, - ) - - -def test_validate_typed_presets_raises_when_selector_misses_its_type(): - """``physics=newton_mjwarp`` that never landed on a PhysicsCfg must raise.""" - with pytest.raises(ValueError, match="physics=newton_mjwarp"): - hydra_mod._validate_typed_presets({PresetTarget.PHYSICS: {"newton_mjwarp"}}, typed_hits={}) - - -def test_validate_typed_presets_ignores_broadcast_presets(): - """A plain ``presets=`` broadcast is never in ``requested``, so it is trusted.""" - # No typed selectors requested -> nothing to validate, even with no hits. - hydra_mod._validate_typed_presets({}, typed_hits={}) - - def test_resolve_active_presets_records_physics_hit_for_selector(): """End-to-end: selecting a name that resolves to a real PhysicsCfg records a PHYSICS hit.""" diff --git a/source/isaaclab_tasks/test/core/test_lazy_export_stubs.py b/source/isaaclab_tasks/test/core/test_lazy_export_stubs.py index 0c23d79e30f9..f229f28271f4 100644 --- a/source/isaaclab_tasks/test/core/test_lazy_export_stubs.py +++ b/source/isaaclab_tasks/test/core/test_lazy_export_stubs.py @@ -84,30 +84,6 @@ def _write_stub(content: str) -> str: return path -def test_parse_stub_single_absolute_named_import(): - """Test single absolute named import extraction.""" - stub = _write_stub("from some.package import alpha, beta\n") - try: - _, _, _, absolute_named = _parse_stub(stub) - finally: - os.unlink(stub) - - assert "some.package" in absolute_named - assert absolute_named["some.package"] == ["alpha", "beta"] - - -def test_parse_stub_multiple_absolute_named_imports(): - """Test multiple absolute named imports from different packages.""" - stub = _write_stub("from pkg_a import foo\nfrom pkg_b import bar, baz\n") - try: - _, _, _, absolute_named = _parse_stub(stub) - finally: - os.unlink(stub) - - assert absolute_named["pkg_a"] == ["foo"] - assert absolute_named["pkg_b"] == ["bar", "baz"] - - def test_parse_stub_same_package_multiple_lines_accumulates(): """Test that imports from the same package on multiple lines accumulate.""" stub = _write_stub("from pkg import a\nfrom pkg import b, c\n") @@ -119,33 +95,11 @@ def test_parse_stub_same_package_multiple_lines_accumulates(): assert absolute_named["pkg"] == ["a", "b", "c"] -def test_parse_stub_absolute_wildcard_not_in_absolute_named(): - """Test that absolute wildcard imports go to fallbacks, not absolute_named.""" - stub = _write_stub("from some.package import *\n") - try: - _, fallbacks, _, absolute_named = _parse_stub(stub) - finally: - os.unlink(stub) - - assert "some.package" in fallbacks - assert absolute_named == {} - - -def test_parse_stub_relative_import_not_in_absolute_named(): - """Test that relative imports are not included in absolute_named.""" - stub = _write_stub("from .sub import foo, bar\n") - try: - _, _, _, absolute_named = _parse_stub(stub) - finally: - os.unlink(stub) - - assert absolute_named == {} - - def test_parse_stub_mixed_import_kinds(): """All four import kinds in one stub are routed correctly.""" stub = _write_stub( "from .local import thing\nfrom .wildmod import *\nfrom abs.pkg import *\nfrom abs.other import x, y\n" + "from abs.more import z\n" ) try: filtered_path, fallbacks, rel_wildcards, absolute_named = _parse_stub(stub) @@ -156,7 +110,7 @@ def test_parse_stub_mixed_import_kinds(): assert fallbacks == ["abs.pkg"] assert rel_wildcards == ["wildmod"] - assert absolute_named == {"abs.other": ["x", "y"]} + assert absolute_named == {"abs.other": ["x", "y"], "abs.more": ["z"]} assert filtered_path is not None diff --git a/source/isaaclab_tasks/test/core/test_lift_kuka_ovphysx_presets.py b/source/isaaclab_tasks/test/core/test_lift_kuka_ovphysx_presets.py index d77855b1568e..a57172ea8b51 100644 --- a/source/isaaclab_tasks/test/core/test_lift_kuka_ovphysx_presets.py +++ b/source/isaaclab_tasks/test/core/test_lift_kuka_ovphysx_presets.py @@ -14,17 +14,10 @@ @pytest.mark.parametrize( - "task_name", + "task_name,preset_args", [ - "Isaac-Lift-KukaAllegro", - "Isaac-Reorient-KukaAllegro", - ], -) -@pytest.mark.parametrize( - "preset_args", - [ - ("physics=ovphysx", "presets=shapes"), - ("presets=shapes", "physics=ovphysx"), + ("Isaac-Lift-KukaAllegro", ("physics=ovphysx", "presets=shapes")), + ("Isaac-Reorient-KukaAllegro", ("presets=shapes", "physics=ovphysx")), ], ) def test_ovphysx_rejects_heterogeneous_shapes_independent_of_argument_order( diff --git a/source/isaaclab_tasks/test/core/test_ovphysx_camera_presets.py b/source/isaaclab_tasks/test/core/test_ovphysx_camera_presets.py index 17b39c68f741..74914f272864 100644 --- a/source/isaaclab_tasks/test/core/test_ovphysx_camera_presets.py +++ b/source/isaaclab_tasks/test/core/test_ovphysx_camera_presets.py @@ -22,12 +22,15 @@ "task_name,presets", [ ("Isaac-Reorient-Cube-Shadow-Camera-Direct", "ovphysx,ovrtx,rgb"), - ("Isaac-Reorient-KukaAllegro-Camera", "ovphysx,ovrtx,rgb64,single_camera,cube"), - ("Isaac-Lift-KukaAllegro-Camera", "ovphysx,ovrtx,rgb64,single_camera,cube"), + ("Isaac-Reorient-KukaAllegro-Camera", "ovphysx,ovrtx,rgb64,single_camera"), + ("Isaac-Lift-KukaAllegro-Camera", "ovphysx,ovrtx,rgb64,single_camera"), ], ) def test_ovphysx_camera_preset_resolves_kitless(task_name: str, presets: str): - """OVPhysX camera presets should select kitless physics and rendering backends.""" + """OVPhysX camera presets should select kitless physics and rendering backends. + + For the Kuka tasks, ``ovphysx`` alone must also select the supported object, event, and curriculum configs. + """ old_argv = sys.argv.copy() try: sys.argv = [sys.argv[0], f"presets={presets}"] @@ -45,28 +48,12 @@ def test_ovphysx_camera_preset_resolves_kitless(task_name: str, presets: str): assert isinstance(camera_cfg.renderer_cfg, OVRTXRendererCfg) assert scan(env_cfg).needs_kit is False + if task_name != "Isaac-Reorient-Cube-Shadow-Camera-Direct": + robot_asset_cfg = env_cfg.events.robot_physics_material.params["asset_cfg"] + object_asset_cfg = env_cfg.events.object_physics_material.params["asset_cfg"] -@pytest.mark.parametrize( - "task_name", - [ - "Isaac-Reorient-KukaAllegro-Camera", - "Isaac-Lift-KukaAllegro-Camera", - ], -) -def test_kuka_ovphysx_preset_uses_supported_task_configuration(task_name: str): - """Kuka OVPhysX presets should select supported object, event, and curriculum configs.""" - old_argv = sys.argv.copy() - try: - sys.argv = [sys.argv[0], "presets=ovphysx,ovrtx,rgb64,single_camera"] - env_cfg, _ = resolve_task_config(task_name, "rsl_rl_cfg_entry_point") - finally: - sys.argv = old_argv - - robot_asset_cfg = env_cfg.events.robot_physics_material.params["asset_cfg"] - object_asset_cfg = env_cfg.events.object_physics_material.params["asset_cfg"] - - assert isinstance(env_cfg.scene.object.spawn, CuboidCfg) - assert robot_asset_cfg.body_names == ".*" - assert object_asset_cfg.body_names == ".*" - assert env_cfg.events.variable_gravity is not None - assert env_cfg.curriculum.gravity_adr is not None + assert isinstance(env_cfg.scene.object.spawn, CuboidCfg) + assert robot_asset_cfg.body_names == ".*" + assert object_asset_cfg.body_names == ".*" + assert env_cfg.events.variable_gravity is not None + assert env_cfg.curriculum.gravity_adr is not None diff --git a/source/isaaclab_tasks/test/core/test_parse_cfg.py b/source/isaaclab_tasks/test/core/test_parse_cfg.py index 7a73a08446f1..c1594240875a 100644 --- a/source/isaaclab_tasks/test/core/test_parse_cfg.py +++ b/source/isaaclab_tasks/test/core/test_parse_cfg.py @@ -6,6 +6,7 @@ """Tests for :func:`isaaclab_tasks.utils.parse_cfg.parse_env_cfg`.""" import pytest +from isaaclab_physx.physics import PhysxCfg import isaaclab_tasks # noqa: F401 from isaaclab_tasks.utils import parse_env_cfg @@ -20,7 +21,7 @@ def test_parse_env_cfg_rejects_bare_string_overrides(): def test_parse_env_cfg_accepts_list_overrides(): """A properly wrapped override list should apply without error.""" env_cfg = parse_env_cfg("Isaac-Cartpole", overrides=["physics=isaacsim_physx"]) - assert env_cfg is not None + assert isinstance(env_cfg.sim.physics, PhysxCfg) def test_parse_env_cfg_preserves_task_device_when_omitted(): @@ -38,7 +39,7 @@ def test_parse_env_cfg_applies_explicit_device_override(): assert env_cfg.sim.device == "cpu" -@pytest.mark.parametrize("missing_module", ["pinocchio", "pink", "qpsolvers", "daqp", "unrelated_dependency"]) +@pytest.mark.parametrize("missing_module", ["pinocchio", "daqp", "unrelated_dependency"]) def test_task_config_missing_pink_dependency(monkeypatch: pytest.MonkeyPatch, missing_module: str): """Missing Pink dependencies should identify the task without hiding unrelated import errors.""" from isaaclab_tasks.utils import parse_cfg diff --git a/source/isaaclab_tasks/test/core/test_preset_cli.py b/source/isaaclab_tasks/test/core/test_preset_cli.py index 6258394c060b..80c78bd3774e 100644 --- a/source/isaaclab_tasks/test/core/test_preset_cli.py +++ b/source/isaaclab_tasks/test/core/test_preset_cli.py @@ -29,33 +29,6 @@ def _make_parser() -> argparse.ArgumentParser: return parser -# --------------------------------------------------------------------------- -# PresetTarget: per-target metadata on the enum -# --------------------------------------------------------------------------- - - -def test_all_legacy_aliases_aggregates_per_target_tables(): - from isaaclab_tasks.utils.preset_target import PresetTarget - - flat = PresetTarget.all_legacy_aliases() - assert flat["newton"] == "newton_mjwarp" - assert flat["kamino"] == "newton_kamino" - - -def test_preset_target_carries_base_classes(): - """Typed targets carry the cfg base classes whose subclass instances - should bucket to them. DOMAIN carries no base classes (it's the - catch-all).""" - from isaaclab.physics import PhysicsCfg - from isaaclab.renderers.renderer_cfg import RendererCfg - - from isaaclab_tasks.utils.preset_target import PresetTarget - - assert PresetTarget.PHYSICS.base_classes == (PhysicsCfg,) - assert PresetTarget.RENDERER.base_classes == (RendererCfg,) - assert PresetTarget.DOMAIN.base_classes == () - - # --------------------------------------------------------------------------- # setup_preset_cli: parse-only, returns the pre-fold remainder verbatim # --------------------------------------------------------------------------- @@ -101,22 +74,6 @@ def test_setup_preset_cli_passes_typed_tokens_verbatim(monkeypatch): ] -def test_setup_preset_cli_does_not_mutate_sys_argv(monkeypatch): - """``setup_preset_cli`` must not mutate ``sys.argv`` -- mutation is the - caller's responsibility. Locks the contract that ``rsl_rl/{train,play}.py`` - rely on so an ``--external_callback`` hook invoked after ``setup_preset_cli`` - can still read the user's original command line and return tokens that the - caller intersects against the remainder.""" - original = ["train.py", "--task=Foo-v0", "physics=newton_mjwarp", "env.sim.dt=0.001"] - monkeypatch.setattr("sys.argv", original) - from isaaclab_tasks.utils.preset_cli import setup_preset_cli - - _, remaining = setup_preset_cli(_make_parser()) - assert sys.argv == original - # Remainder carries the typed selector unchanged. - assert remaining == ["physics=newton_mjwarp", "env.sim.dt=0.001"] - - def test_setup_preset_cli_namespace_carries_no_preset_attributes(monkeypatch): """Preset tokens are never registered with argparse, so the parsed Namespace gains no ``physics`` / ``renderer`` / ``presets`` attribute. @@ -140,34 +97,6 @@ def test_setup_preset_cli_namespace_carries_no_preset_attributes(monkeypatch): ) -def test_setup_preset_cli_does_not_leak_into_app_launcher_sim_app_intersection(monkeypatch): - """Mirrors the literal intersection :class:`~isaaclab.app.AppLauncher` - computes (``set(_SIM_APP_CFG_TYPES) & set(vars(args))``, - ``app_launcher.py:681``). After ``setup_preset_cli`` runs with all three - preset selectors, no preset name can be in that intersection -- the only - keys present are those AppLauncher itself registered on the parser - (``headless``, ``experience``, ...). - """ - monkeypatch.setattr( - "sys.argv", - ["train.py", "--task=Foo-v0", "physics=newton_mjwarp", "renderer=newton_renderer", "presets=albedo"], - ) - from isaaclab.app import AppLauncher - - from isaaclab_tasks.utils.preset_cli import setup_preset_cli - from isaaclab_tasks.utils.preset_target import PresetTarget - - args, _ = setup_preset_cli(_make_parser()) - intersection = set(AppLauncher._SIM_APP_CFG_TYPES.keys()) & set(vars(args).keys()) - leaked = {t.value for t in PresetTarget} & intersection - assert not leaked, ( - f"setup_preset_cli leaked preset value(s) {sorted(leaked)} into the AppLauncher" - " SimulationApp forwarding set -- they would land in SimulationApp.config and crash" - " Kit (``None.lower()`` for ``renderer``). The hydra-style grammar keeps the namespace" - " clean of preset attributes; this test guards against accidentally re-introducing them." - ) - - # --------------------------------------------------------------------------- # External-callback intersection: typed selectors survive on the raw remainder # @@ -196,21 +125,6 @@ def test_intersection_preserves_typed_selection(): # --------------------------------------------------------------------------- -def test_argv_helper_finds_task_equals_form(): - from isaaclab_tasks.utils.preset_cli import _ArgvHelper - - argv = _ArgvHelper(["train.py", "--task=Foo-v0"]) - assert argv.task_name == "Foo-v0" - assert argv.help_requested is False - - -def test_argv_helper_finds_task_separated_form(): - from isaaclab_tasks.utils.preset_cli import _ArgvHelper - - argv = _ArgvHelper(["train.py", "--task", "Foo-v0"]) - assert argv.task_name == "Foo-v0" - - def test_argv_helper_task_missing_returns_none(): from isaaclab_tasks.utils.preset_cli import _ArgvHelper @@ -339,24 +253,6 @@ def test_help_without_task_says_pass_task(monkeypatch, capsys): ], id="zero_variants_everywhere", ), - pytest.param( - "physics_only", - [ - "physics=NAME (typed) selects a physics backend. Available: - alpha - beta", - "renderer=NAME (typed) selects a renderer backend. Available: (none)", - "presets=NAME[,NAME,...] broadcast: applied to every matching PresetCfg. Available: (none)", - ], - id="typed_populated_other_typed_empty", - ), - pytest.param( - "domain_only", - [ - "physics=NAME (typed) selects a physics backend. Available: (none)", - "renderer=NAME (typed) selects a renderer backend. Available: (none)", - "presets=NAME[,NAME,...] broadcast: applied to every matching PresetCfg. Available: - heavy - light", - ], - id="domain_bucket_only", - ), pytest.param( "mixed", [ @@ -395,14 +291,6 @@ class _HelpRendCfg(RendererCfg): class _EmptyCfg: pass - @configclass - class _PhysOnlyCfg: - physics: object = preset(default=_HelpPhysCfg(), alpha=_HelpPhysCfg(), beta=_HelpPhysCfg()) - - @configclass - class _DomainOnlyCfg: - weight: object = preset(default=1.0, light=0.5, heavy=2.0) - @configclass class _MixedCfg: physics: object = preset(default=_HelpPhysCfg(), my_phys=_HelpPhysCfg()) @@ -411,8 +299,6 @@ class _MixedCfg: builders = { "empty": _EmptyCfg, - "physics_only": _PhysOnlyCfg, - "domain_only": _DomainOnlyCfg, "mixed": _MixedCfg, } @@ -431,38 +317,3 @@ class _MixedCfg: for phrase in expected_phrases: assert phrase in flat, f"Missing phrase: {phrase!r}" - - -def test_preset_cli_has_no_agent_selection_surface(): - """Keep the preset CLI bounded to parsing and preset help.""" - import inspect - from pathlib import Path - - import isaaclab_tasks.utils.preset_cli as preset_cli - - assert tuple(inspect.signature(preset_cli.setup_preset_cli).parameters) == ("parser", "argv") - forbidden_symbols = ( - "_AGENT_UNSET", - "_AgentDescriptionBuilder", - "_agent_passed_explicitly", - "_auto_select_agent", - "_enumerate_agents", - ) - assert not [name for name in forbidden_symbols if hasattr(preset_cli, name)] - - repo_root = Path(__file__).resolve().parents[4] - paths = [*repo_root.glob("source/*/**/*.py"), *repo_root.glob("scripts/**/*.py")] - forbidden_kwarg = "agent_" + "library=" - offenders = [ - path for path in paths if "setup_preset_cli" in path.read_text() and forbidden_kwarg in path.read_text() - ] - assert not offenders - - -def test_task_registrations_have_no_preset_to_agent_maps(): - """A shared preset name must compose config roots without a registry-side pairing table.""" - from pathlib import Path - - task_root = Path(__file__).resolve().parents[2] / "isaaclab_tasks" - offenders = [path for path in task_root.rglob("*.py") if "agent_preset_compatibility" in path.read_text()] - assert not offenders diff --git a/source/isaaclab_tasks/test/core/test_preset_kit_decision.py b/source/isaaclab_tasks/test/core/test_preset_kit_decision.py index 163042d2a54c..2a3ae90befb2 100644 --- a/source/isaaclab_tasks/test/core/test_preset_kit_decision.py +++ b/source/isaaclab_tasks/test/core/test_preset_kit_decision.py @@ -89,19 +89,12 @@ def fake_parent_init(self, cfg, *_args, **_kwargs): assert env.observation_space.shape == (2, 3, 45, 80) -def test_rtx_is_renderer_selector(): - """The automatic RTX selector is exposed as ``renderer=rtx``.""" +def test_rtx_and_isaacsim_physx_are_typed_selectors(): + """``renderer=rtx`` (automatic RTX) and ``physics=isaacsim_physx`` (concrete Isaac Sim PhysX) are exposed.""" preset_map = enumerate_task_presets(_CAMERA_PRESETS_TASK) assert preset_map is not None assert "rtx" in preset_map[PresetTarget.RENDERER] - - -def test_isaacsim_physx_is_physics_selector(): - """The concrete Isaac Sim PhysX selector is exposed as ``physics=isaacsim_physx``.""" - preset_map = enumerate_task_presets(_CAMERA_PRESETS_TASK) - - assert preset_map is not None assert "isaacsim_physx" in preset_map[PresetTarget.PHYSICS] diff --git a/source/isaaclab_tasks/test/core/test_reach_franka_presets.py b/source/isaaclab_tasks/test/core/test_reach_franka_presets.py index 436f780e6e86..af6602e5ed77 100644 --- a/source/isaaclab_tasks/test/core/test_reach_franka_presets.py +++ b/source/isaaclab_tasks/test/core/test_reach_franka_presets.py @@ -62,11 +62,9 @@ def test_reach_diffik_abs_legacy_task_is_a_deprecated_alias(): _REACH_PRESET_CASES = [ (_TASK, (), "JointPositionActionCfg", "NewtonCfg"), (_TASK, ("isaacsim_physx",), "JointPositionActionCfg", "PhysxCfg"), - (_TASK, ("newton_mjwarp",), "JointPositionActionCfg", "NewtonCfg"), (_TASK, ("ovphysx",), "JointPositionActionCfg", "OvPhysxCfg"), (_TASK, ("diffik",), "DifferentialInverseKinematicsActionCfg", "NewtonCfg"), (_TASK, ("diffik", "isaacsim_physx"), "DifferentialInverseKinematicsActionCfg", "PhysxCfg"), - (_TASK, ("diffik", "newton_mjwarp"), "DifferentialInverseKinematicsActionCfg", "NewtonCfg"), (_TASK, ("diffik_abs", "isaacsim_physx"), "DifferentialInverseKinematicsActionCfg", "PhysxCfg"), (_TASK, ("diffik_abs", "newton_mjwarp"), "DifferentialInverseKinematicsActionCfg", "NewtonCfg"), (_TASK, ("diffik_abs", "ovphysx"), "DifferentialInverseKinematicsActionCfg", "OvPhysxCfg"), @@ -76,7 +74,6 @@ def test_reach_diffik_abs_legacy_task_is_a_deprecated_alias(): (_OSC_TASK, ("ovphysx",), "OperationalSpaceControllerActionCfg", "OvPhysxCfg"), ("Isaac-Reach-UR10", (), "JointPositionActionCfg", "NewtonCfg"), ("Isaac-Reach-UR10", ("isaacsim_physx",), "JointPositionActionCfg", "PhysxCfg"), - ("Isaac-Reach-UR10", ("newton_mjwarp",), "JointPositionActionCfg", "NewtonCfg"), ] @@ -149,13 +146,10 @@ def test_reach_newton_ik_configures_gravity_compensation(): assert mujoco_props.gravcomp == pytest.approx(1.0) assert cfg.scene.robot.spawn.usd_path.endswith("/FrankaEmika/franka_panda.usda") - -def test_reach_newton_ik_uses_native_se3_command_convention(): - cfg = _load_env_cfg("newton_ik", "newton_mjwarp") + # Native SE(3) command convention: one relative 6-DoF pose objective. pose_objectives = [ objective for objective in cfg.actions.arm_action.objectives if isinstance(objective, NewtonIKPoseObjectiveCfg) ] - assert len(pose_objectives) == 1 assert pose_objectives[0].command_type == "pose" assert pose_objectives[0].use_relative_mode diff --git a/source/isaaclab_tasks/test/core/test_rendering_cartpole.py b/source/isaaclab_tasks/test/core/test_rendering_cartpole.py index 2ccac9e7fdb1..eeba6ba433c0 100644 --- a/source/isaaclab_tasks/test/core/test_rendering_cartpole.py +++ b/source/isaaclab_tasks/test/core/test_rendering_cartpole.py @@ -15,7 +15,7 @@ import pytest # noqa: E402 from rendering_test_utils import ( # noqa: E402 - PHYSICS_RENDERER_AOV_GROUPS, + MINIMAL_PHYSICS_RENDERER_AOV_GROUPS, make_attach_comparison_properties_fixture, make_determinism_fixture, make_generate_html_report_fixture, @@ -31,7 +31,7 @@ _attach_comparison_properties_fixture = make_attach_comparison_properties_fixture(_COMPARISON_SCORES) -@pytest.mark.parametrize("physics_backend,renderer,data_types", PHYSICS_RENDERER_AOV_GROUPS) +@pytest.mark.parametrize("physics_backend,renderer,data_types", MINIMAL_PHYSICS_RENDERER_AOV_GROUPS) def test_rendering_cartpole(physics_backend, renderer, data_types): """Test cartpole environment rendering correctness.""" rendering_test_cartpole(physics_backend, renderer, data_types, _COMPARISON_SCORES, compare_golden=True) diff --git a/source/isaaclab_tasks/test/core/test_rendering_cartpole_kitless.py b/source/isaaclab_tasks/test/core/test_rendering_cartpole_kitless.py index 2337c2fca7ae..3cabdd67325f 100644 --- a/source/isaaclab_tasks/test/core/test_rendering_cartpole_kitless.py +++ b/source/isaaclab_tasks/test/core/test_rendering_cartpole_kitless.py @@ -9,7 +9,7 @@ import pytest from rendering_test_utils import ( - KITLESS_PHYSICS_RENDERER_AOV_COMBINATIONS, + MINIMAL_KITLESS_PHYSICS_RENDERER_AOV_COMBINATIONS, group_rendering_params, make_attach_comparison_properties_fixture, make_determinism_fixture, @@ -21,7 +21,9 @@ pytestmark = pytest.mark.arm_ci -_RENDERING_PARAMS = group_rendering_params(make_kitless_rendering_params(KITLESS_PHYSICS_RENDERER_AOV_COMBINATIONS)) +_RENDERING_PARAMS = group_rendering_params( + make_kitless_rendering_params(MINIMAL_KITLESS_PHYSICS_RENDERER_AOV_COMBINATIONS) +) _COMPARISON_SCORES: list[dict] = [] _determinism_fixture = make_determinism_fixture() diff --git a/source/isaaclab_tasks/test/core/test_rendering_franka_cable.py b/source/isaaclab_tasks/test/core/test_rendering_franka_cable.py index dd49f2236e9c..adc3a26e07f8 100644 --- a/source/isaaclab_tasks/test/core/test_rendering_franka_cable.py +++ b/source/isaaclab_tasks/test/core/test_rendering_franka_cable.py @@ -24,6 +24,8 @@ pytestmark = pytest.mark.isaacsim_ci +# Newton cables have no PhysX preset, so only the Newton physics rows apply. +_RENDERING_PARAMS = [param for param in PHYSICS_RENDERER_AOV_GROUPS if param.values[0] != "physx"] _COMPARISON_SCORES: list[dict] = [] _determinism_fixture = make_determinism_fixture() @@ -31,7 +33,7 @@ _attach_comparison_properties_fixture = make_attach_comparison_properties_fixture(_COMPARISON_SCORES) -@pytest.mark.parametrize("physics_backend,renderer,data_types", PHYSICS_RENDERER_AOV_GROUPS) +@pytest.mark.parametrize("physics_backend,renderer,data_types", _RENDERING_PARAMS) def test_rendering_franka_cable(physics_backend, renderer, data_types): """Test Franka cable rendering correctness across AOVs.""" rendering_test_franka_cable(physics_backend, renderer, data_types, _COMPARISON_SCORES) diff --git a/source/isaaclab_tasks/test/core/test_rendering_franka_soft.py b/source/isaaclab_tasks/test/core/test_rendering_franka_soft.py deleted file mode 100644 index 5add80181a83..000000000000 --- a/source/isaaclab_tasks/test/core/test_rendering_franka_soft.py +++ /dev/null @@ -1,37 +0,0 @@ -# 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 - -"""Rendering correctness tests for test-local Franka soft camera setup.""" - -# Launch Isaac Sim Simulator first for kit-based combinations. -from isaaclab.app import AppLauncher - -app_launcher = AppLauncher(headless=True, enable_cameras=True) -simulation_app = app_launcher.app - -from pathlib import Path # noqa: E402 - -import pytest # noqa: E402 -from rendering_test_utils import ( # noqa: E402 - PHYSICS_RENDERER_AOV_GROUPS, - make_attach_comparison_properties_fixture, - make_determinism_fixture, - make_generate_html_report_fixture, - rendering_test_franka_soft, -) - -pytestmark = pytest.mark.isaacsim_ci - -_COMPARISON_SCORES: list[dict] = [] - -_determinism_fixture = make_determinism_fixture() -_generate_html_report_fixture = make_generate_html_report_fixture(_COMPARISON_SCORES, Path(__file__).stem + ".html") -_attach_comparison_properties_fixture = make_attach_comparison_properties_fixture(_COMPARISON_SCORES) - - -@pytest.mark.parametrize("physics_backend,renderer,data_types", PHYSICS_RENDERER_AOV_GROUPS) -def test_rendering_franka_soft(physics_backend, renderer, data_types): - """Test Franka soft rendering correctness.""" - rendering_test_franka_soft(physics_backend, renderer, data_types, _COMPARISON_SCORES) diff --git a/source/isaaclab_tasks/test/core/test_rendering_lift_kuka_hetero_kitless.py b/source/isaaclab_tasks/test/core/test_rendering_lift_kuka_hetero_kitless.py index 15db93c70671..d9ac63963c4e 100644 --- a/source/isaaclab_tasks/test/core/test_rendering_lift_kuka_hetero_kitless.py +++ b/source/isaaclab_tasks/test/core/test_rendering_lift_kuka_hetero_kitless.py @@ -18,7 +18,11 @@ rendering_test_lift_kuka, ) -_RENDERING_PARAMS = group_rendering_params(make_kitless_rendering_params_lift()) +# The OVPhysX preset selects the homogeneous cube because heterogeneous multi-asset scenes are unsupported; +# OVPhysX Kuka goldens live in test_rendering_lift_kuka_homo_kitless.py. +_RENDERING_PARAMS = group_rendering_params( + [param for param in make_kitless_rendering_params_lift() if param.values[1] != "ovphysx"] +) _COMPARISON_SCORES: list[dict] = [] _determinism_fixture = make_determinism_fixture() @@ -32,8 +36,4 @@ ) def test_rendering_lift_kuka_hetero_kitless(ovstage_variant, physics_backend, renderer, data_types): """Camera output must match golden images (Lift KukaAllegro Lift, single camera).""" - if physics_backend == "ovphysx": - pytest.skip( - "The OVPhysX preset selects the homogeneous cube because heterogeneous multi-asset scenes are unsupported." - ) rendering_test_lift_kuka(physics_backend, renderer, data_types, False, _COMPARISON_SCORES) diff --git a/source/isaaclab_tasks/test/core/test_rendering_registered_tasks.py b/source/isaaclab_tasks/test/core/test_rendering_registered_tasks.py index 47a83430aabb..23867f1b3c09 100644 --- a/source/isaaclab_tasks/test/core/test_rendering_registered_tasks.py +++ b/source/isaaclab_tasks/test/core/test_rendering_registered_tasks.py @@ -76,9 +76,6 @@ def _collect_camera_outputs(env: object) -> dict[str, dict[str, torch.Tensor]]: "cartpole", id="Isaac-Cartpole-Camera-Direct-albedo-depth-cartpole", ), - ("Isaac-Cartpole-Camera-Direct", "simple_shading_constant_diffuse", "cartpole"), - ("Isaac-Cartpole-Camera-Direct", "simple_shading_diffuse_mdl", "cartpole"), - ("Isaac-Cartpole-Camera-Direct", "simple_shading_full_mdl", "cartpole"), ("Isaac-Reorient-Cube-Shadow-Camera-Direct", None, "shadow_hand"), ] diff --git a/source/isaaclab_tasks/test/core/test_rendering_shadow_hand.py b/source/isaaclab_tasks/test/core/test_rendering_shadow_hand.py index 8df228f18438..33adb555aceb 100644 --- a/source/isaaclab_tasks/test/core/test_rendering_shadow_hand.py +++ b/source/isaaclab_tasks/test/core/test_rendering_shadow_hand.py @@ -15,7 +15,7 @@ import pytest # noqa: E402 from rendering_test_utils import ( # noqa: E402 - PHYSICS_RENDERER_AOV_GROUPS, + MINIMAL_PHYSICS_RENDERER_AOV_GROUPS, make_attach_comparison_properties_fixture, make_determinism_fixture, make_generate_html_report_fixture, @@ -31,7 +31,7 @@ _attach_comparison_properties_fixture = make_attach_comparison_properties_fixture(_COMPARISON_SCORES) -@pytest.mark.parametrize("physics_backend,renderer,data_types", PHYSICS_RENDERER_AOV_GROUPS) +@pytest.mark.parametrize("physics_backend,renderer,data_types", MINIMAL_PHYSICS_RENDERER_AOV_GROUPS) def test_rendering_shadow_hand(physics_backend, renderer, data_types): """Test shadow hand environment rendering correctness.""" rendering_test_shadow_hand(physics_backend, renderer, data_types, _COMPARISON_SCORES) diff --git a/source/isaaclab_tasks/test/core/test_rendering_shadow_hand_kitless.py b/source/isaaclab_tasks/test/core/test_rendering_shadow_hand_kitless.py index 08de12319cfc..ea44307f02c2 100644 --- a/source/isaaclab_tasks/test/core/test_rendering_shadow_hand_kitless.py +++ b/source/isaaclab_tasks/test/core/test_rendering_shadow_hand_kitless.py @@ -9,7 +9,7 @@ import pytest from rendering_test_utils import ( - KITLESS_PHYSICS_RENDERER_AOV_COMBINATIONS, + MINIMAL_KITLESS_PHYSICS_RENDERER_AOV_COMBINATIONS, group_rendering_params, make_attach_comparison_properties_fixture, make_determinism_fixture, @@ -21,7 +21,9 @@ # no arm_ci marker: intermittently stalls the arm64 runner mid-file (renderer hang after a few # passing cases, killed at the per-file timeout); x86 kitless coverage remains in place -_RENDERING_PARAMS = group_rendering_params(make_kitless_rendering_params(KITLESS_PHYSICS_RENDERER_AOV_COMBINATIONS)) +_RENDERING_PARAMS = group_rendering_params( + make_kitless_rendering_params(MINIMAL_KITLESS_PHYSICS_RENDERER_AOV_COMBINATIONS) +) _COMPARISON_SCORES: list[dict] = [] _determinism_fixture = make_determinism_fixture() diff --git a/source/isaaclab_tasks/test/core/test_rendering_shadow_hand_yellow_bg_kitless.py b/source/isaaclab_tasks/test/core/test_rendering_shadow_hand_yellow_bg_kitless.py deleted file mode 100644 index da427916ce65..000000000000 --- a/source/isaaclab_tasks/test/core/test_rendering_shadow_hand_yellow_bg_kitless.py +++ /dev/null @@ -1,25 +0,0 @@ -# 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 - -"""Kit-less golden render test: Shadow Hand with yellow camera background via OVRTX (RGB only).""" - -import os - -# OVRTX uses Vulkan, which requires a display socket even in headless mode. -os.environ.setdefault("DISPLAY", ":0") - -import pytest # noqa: E402 -from rendering_test_utils import ( # noqa: E402 - make_require_ovlibs_install_fixture, - rendering_test_shadow_hand_yellow_bg, -) - -_require_ovlibs_install_fixture = make_require_ovlibs_install_fixture() - - -@pytest.mark.parametrize("physics_backend,renderer", [("newton", "ovrtx")]) -def test_rgb_yellow_background_ovrtx(physics_backend, renderer): - """Kit-less golden render test: RGB output with yellow background via OVRTX.""" - rendering_test_shadow_hand_yellow_bg(physics_backend, renderer, comparison_scores=[]) diff --git a/source/isaaclab_tasks/test/core/test_reorient_utils.py b/source/isaaclab_tasks/test/core/test_reorient_utils.py index 15e9b1b810d0..5eff5ca1e7df 100644 --- a/source/isaaclab_tasks/test/core/test_reorient_utils.py +++ b/source/isaaclab_tasks/test/core/test_reorient_utils.py @@ -149,7 +149,10 @@ def __init__(self, names): self._names = names def find_fixed_tendons(self, name_keys, preserve_order=False): + # Like the real lookup: query order only when requested, articulation order otherwise. indices = [self._names.index(name) for name in name_keys if name in self._names] + if not preserve_order: + indices.sort() return indices, [self._names[i] for i in indices] diff --git a/source/isaaclab_tasks/test/core/test_rl_device_separation.py b/source/isaaclab_tasks/test/core/test_rl_device_separation.py index 0a3f823d1ca9..32b5704e4a26 100644 --- a/source/isaaclab_tasks/test/core/test_rl_device_separation.py +++ b/source/isaaclab_tasks/test/core/test_rl_device_separation.py @@ -12,10 +12,9 @@ 1. sim_device: Where physics simulation runs and environment buffers live 2. rl_device: Where policy networks and training computations occur -Test Scenarios: - - GPU simulation + GPU RL: Same device (no transfers needed, optimal performance) +Test Scenarios (cross-device only; the same-device case needs no transfer): - GPU simulation + CPU RL: Cross-device transfers (wrapper handles transfers) - - CPU simulation + CPU RL: CPU-only operation + - CPU simulation + GPU RL: Actions transferred from the policy device to the simulation Each test verifies the wrapper correctly: 1. Unwrapped env: operates entirely on sim_device @@ -303,16 +302,6 @@ def _test_skrl_device_separation(sim_device: str, rl_device: str): # ============================================================================ -def test_rsl_rl_device_separation_gpu_to_gpu(): - """Test RSL-RL with GPU simulation and GPU RL (default configuration).""" - try: - import isaaclab_rl.rsl_rl # noqa: F401 - except ImportError: - pytest.skip("RSL-RL not installed") - - _test_rsl_rl_device_separation(sim_device="cuda:0", rl_device="cuda:0") - - def test_rsl_rl_device_separation_gpu_to_cpu(): """Test RSL-RL with GPU simulation and CPU RL (cross-device transfer).""" try: @@ -323,16 +312,6 @@ def test_rsl_rl_device_separation_gpu_to_cpu(): _test_rsl_rl_device_separation(sim_device="cuda:0", rl_device="cpu") -def test_rl_games_device_separation_gpu_to_gpu(): - """Test RL Games with GPU simulation and GPU RL (default configuration).""" - try: - import isaaclab_rl.rl_games # noqa: F401 - except ImportError: - pytest.skip("RL Games not installed") - - _test_rl_games_device_separation(sim_device="cuda:0", rl_device="cuda:0") - - def test_rl_games_device_separation_gpu_to_cpu(): """Test RL Games with GPU simulation and CPU RL (cross-device transfer).""" try: @@ -356,16 +335,6 @@ def test_sb3_device_separation_gpu(): _test_sb3_device_separation(sim_device="cuda:0") -def test_skrl_device_separation_gpu(): - """Test skrl with GPU simulation and GPU policy (matching devices).""" - try: - import skrl # noqa: F401 - except ImportError: - pytest.skip("skrl not installed") - - _test_skrl_device_separation(sim_device="cuda:0", rl_device="cuda:0") - - def test_skrl_device_separation_cpu_to_gpu(): """Test skrl with CPU simulation and GPU policy. diff --git a/source/isaaclab_tasks/test/core/test_runtime_compatibility.py b/source/isaaclab_tasks/test/core/test_runtime_compatibility.py index 46a3ab856f0f..194c622b3fe9 100644 --- a/source/isaaclab_tasks/test/core/test_runtime_compatibility.py +++ b/source/isaaclab_tasks/test/core/test_runtime_compatibility.py @@ -147,13 +147,6 @@ def test_ovphysx_plus_kit_visualizer_raises(): assert "Kit visualizer" in msg -def test_ovphysx_dict_args_plus_kit_visualizer_raises(): - """The dict launcher-args form must also reject OvPhysX with Kit visualization.""" - env_cfg = _resolve_with_presets("ovphysx,isaacsim_rtx") - with pytest.raises(ValueError, match=r"OvPhysX.*Kit visualizer"): - validate_runtime_compatibility(env_cfg, {"visualizer": "kit,newton"}) - - def test_ovphysx_plus_kit_physics_raises(): """Two physics configs cannot pull OvPhysX and Kit into the same process.""" mixed_cfg = argparse.Namespace( @@ -193,12 +186,6 @@ def test_ovphysx_plus_kit_camera_without_visualizer_raises(): # --------------------------------------------------------------------------- -def test_newton_plus_ovrtx_is_valid(): - """Newton physics + OVRTX renderer is the supported kitless combination.""" - env_cfg = _resolve_with_presets("newton,ovrtx") - validate_runtime_compatibility(env_cfg) - - def test_default_newton_plus_ovrtx_is_valid(): """The default Newton backend supports the default OVRTX renderer.""" env_cfg = _resolve_with_presets("ovrtx") @@ -310,15 +297,6 @@ def test_renderer_selector_physx_rtx_with_kit_visualizer_resolves_to_isaac_sim_b assert config_scan.needs_kit is True -def test_rtx_with_newton_is_valid_and_resolves_to_ovrtx(): - """The RTX preset chooses OVRTX when no Isaac Sim runtime is needed.""" - env_cfg = _resolve_with_presets("newton_mjwarp,rtx") - config_scan = validate_runtime_compatibility(env_cfg) - - assert isinstance(env_cfg.scene.tiled_camera.renderer_cfg, OVRTXRendererCfg) - assert config_scan.needs_kit is False - - def test_rtx_with_ovphysx_is_valid_and_resolves_to_ovrtx(): """The RTX preset chooses OVRTX for an OvPhysX kitless run.""" env_cfg = _resolve_with_presets("ovphysx,rtx") @@ -377,12 +355,6 @@ def test_livestream_rtx_injects_kit_before_auto_rtx_resolution(monkeypatch: pyte assert isinstance(env_cfg.scene.tiled_camera.renderer_cfg, IsaacRtxRendererCfg) -def test_newton_plus_isaacsim_rtx_is_valid(): - """Newton + Isaac RTX renderer is supported (RTX runs in Kit, Newton syncs to USD).""" - env_cfg = _resolve_with_presets("newton,isaacsim_rtx") - validate_runtime_compatibility(env_cfg) - - def test_kit_visualizer_with_isaacsim_rtx_is_valid(): """``--visualizer kit`` is fine as long as no OVRTX renderer is configured.""" env_cfg = _resolve_with_presets("newton,isaacsim_rtx") diff --git a/source/isaaclab_tasks/test/core/test_shadow_hand_camera_presets.py b/source/isaaclab_tasks/test/core/test_shadow_hand_camera_presets.py index 28708415aa23..e69536943746 100644 --- a/source/isaaclab_tasks/test/core/test_shadow_hand_camera_presets.py +++ b/source/isaaclab_tasks/test/core/test_shadow_hand_camera_presets.py @@ -82,27 +82,10 @@ def _validate_cfg(cfg) -> None: _VALID_COMBOS = [ # renderer_type, data_types, feature_extractor_enabled - # ── Non-warp renderers accept every data type ── - (None, ["rgb"], True), - (None, ["rgb", "depth", "semantic_segmentation"], True), - (None, ["albedo"], True), - (None, ["simple_shading_constant_diffuse"], True), - (None, ["simple_shading_diffuse_mdl"], True), - (None, ["simple_shading_full_mdl"], True), - (None, ["depth"], False), # depth-only OK when CNN disabled - ("isaac_rtx", ["rgb"], True), - ("isaac_rtx", ["albedo"], True), - ("isaac_rtx", ["simple_shading_full_mdl"], True), - ("isaac_rtx", ["rgb", "depth", "semantic_segmentation"], True), - ("isaac_rtx", ["depth"], False), - # ── Warp renderer: all published color, depth, and segmentation outputs are supported ── - ("newton_warp", ["rgb"], True), - ("newton_warp", ["rgba"], True), - ("newton_warp", ["rgb_hdr"], True), - ("newton_warp", ["albedo"], True), + (None, ["rgb", "depth", "semantic_segmentation"], True), # no renderer contract to check + ("isaac_rtx", ["simple_shading_full_mdl"], True), # RTX publishes simple-shading outputs + ("newton_warp", ["rgb", "depth", "semantic_segmentation"], True), # warp-published outputs ("newton_warp", ["depth"], False), # depth-only OK when CNN disabled - ("newton_warp", ["rgb", "depth"], True), # multiple supported types - ("newton_warp", ["rgb", "depth", "semantic_segmentation"], True), ] @@ -119,43 +102,9 @@ def test_valid_combinations_do_not_raise(renderer_type, data_types, enabled): _INVALID_COMBOS = [ # renderer_type, data_types, enabled, substring expected in error message # ── Warp does not support RTX simple-shading outputs ── - ( - "newton_warp", - ["simple_shading_constant_diffuse"], - True, - "simple_shading_constant_diffuse", - ), - ( - "newton_warp", - ["simple_shading_diffuse_mdl"], - True, - "simple_shading_diffuse_mdl", - ), - ( - "newton_warp", - ["simple_shading_full_mdl"], - True, - "simple_shading_full_mdl", - ), - # ── Depth-only with CNN enabled is not valid for training ── - ( - None, - ["depth"], - True, - "Depth-only", - ), - ( - "isaac_rtx", - ["depth"], - True, - "Depth-only", - ), - ( - "newton_warp", - ["depth"], - True, - "Depth-only", # depth is warp-supported but CNN can't train on it - ), + ("newton_warp", ["simple_shading_full_mdl"], True, "simple_shading_full_mdl"), + # ── Depth-only with CNN enabled is not valid for training (renderer-independent) ── + (None, ["depth"], True, "Depth-only"), ] @@ -177,33 +126,27 @@ def shadow_hand_camera_presets(): return collect_presets(ShadowHandCameraEnvCfg()) -_CAMERA_DATA_TYPE_PRESETS = [ - # preset_name, expected_data_types - ("default", ["rgb", "depth", "semantic_segmentation"]), - ("full", ["rgb", "depth", "semantic_segmentation"]), - ("rgb", ["rgb"]), - ("albedo", ["albedo"]), - ("simple_shading_constant_diffuse", ["simple_shading_constant_diffuse"]), - ("simple_shading_diffuse_mdl", ["simple_shading_diffuse_mdl"]), - ("simple_shading_full_mdl", ["simple_shading_full_mdl"]), - ("depth", ["depth"]), -] - - -@pytest.mark.parametrize("preset_name,expected_data_types", _CAMERA_DATA_TYPE_PRESETS) -def test_camera_presets_resolve_to_valid_configs(shadow_hand_camera_presets, preset_name, expected_data_types): - """Camera presets must be discoverable, request data, and have valid dimensions.""" +def test_camera_presets_resolve_to_valid_configs(shadow_hand_camera_presets): + """Camera presets must be discoverable, request their named data type, and have valid dimensions.""" camera_presets = shadow_hand_camera_presets["scene.tiled_camera"] - assert preset_name in camera_presets, f"Preset '{preset_name}' not found in tiled_camera presets" - resolved = camera_presets[preset_name] - assert resolved.data_types == expected_data_types, ( - f"Preset '{preset_name}': expected data_types={expected_data_types}, got {resolved.data_types}" - ) - assert len(resolved.data_types) > 0, ( - f"Camera preset '{preset_name}' has an empty data_types list — nothing would be rendered." - ) - assert resolved.width > 0, f"Camera preset '{preset_name}' has non-positive width: {resolved.width}" - assert resolved.height > 0, f"Camera preset '{preset_name}' has non-positive height: {resolved.height}" + assert set(camera_presets) == { + "default", + "full", + "rgb", + "albedo", + "simple_shading_constant_diffuse", + "simple_shading_diffuse_mdl", + "simple_shading_full_mdl", + "depth", + "semantic_segmentation", + } + for preset_name, resolved in camera_presets.items(): + if preset_name in ("default", "full"): + assert resolved.data_types == ["rgb", "depth", "semantic_segmentation"], preset_name + else: + assert resolved.data_types == [preset_name] + assert resolved.width > 0, f"Camera preset '{preset_name}' has non-positive width: {resolved.width}" + assert resolved.height > 0, f"Camera preset '{preset_name}' has non-positive height: {resolved.height}" # --------------------------------------------------------------------------- diff --git a/source/isaaclab_tasks/test/core/test_xr_camera_feed_presets.py b/source/isaaclab_tasks/test/core/test_xr_camera_feed_presets.py index cc3e5a180ddc..44d0206ea753 100644 --- a/source/isaaclab_tasks/test/core/test_xr_camera_feed_presets.py +++ b/source/isaaclab_tasks/test/core/test_xr_camera_feed_presets.py @@ -13,35 +13,15 @@ from isaaclab_tasks.contrib.locomanip_pick_place.locomanipulation_g1_env_cfg import LocomanipulationG1EnvCfg from isaaclab_tasks.contrib.pick_place.pickplace_gr1t2_env_cfg import PickPlaceGR1T2EnvCfg from isaaclab_tasks.utils.hydra import resolve_presets -from isaaclab_tasks.utils.presets import MultiBackendRendererCfg def test_xr_camera_reference_task_selects_recorded_camera(): """Reference tasks use a camera beneath a prim that inherits physical-body motion.""" cfg = PickPlaceGR1T2EnvCfg() + camera_name = cfg.isaac_teleop.xr_camera_feeds[0].camera_name - assert cfg.isaac_teleop.xr_camera_feeds[0].camera_name == "robot_pov_cam" - assert hasattr(cfg.observations.policy, "robot_pov_cam") - assert cfg.scene.robot_pov_cam.prim_path == "{ENV_REGEX_NS}/Robot/base_link/RobotPOVCam" - assert isinstance(cfg.scene.robot_pov_cam.renderer_cfg, MultiBackendRendererCfg) - assert cfg.isaac_teleop.xr_camera_feeds[0].enable_dlss_ray_reconstruction is True - assert cfg.isaac_teleop.xr_camera_feeds[0].dlss_exec_mode == "quality" - assert cfg.num_rerenders_on_reset == 3 - - -def test_locomanipulation_g1_recorded_camera_uses_calibration(): - """Locomanipulation G1 retains its calibrated camera for recorded observations.""" - cfg = LocomanipulationG1EnvCfg() - camera_cfg = cfg.scene.robot_pov_cam - - assert camera_cfg.prim_path == "{ENV_REGEX_NS}/Robot/torso_link/head_link/RobotHeadCam" - assert (camera_cfg.width, camera_cfg.height) == (640, 480) - assert camera_cfg.spawn.focal_length == 15.0 - assert camera_cfg.spawn.horizontal_aperture == 20.955 - assert camera_cfg.spawn.clipping_range == (0.1, 5.0) - assert camera_cfg.offset.pos == (0.04485, 0.0, 0.35325) - assert camera_cfg.offset.rot == (-0.62721, 0.62721, -0.32651, 0.32651) - assert camera_cfg.offset.convention == "ros" + assert hasattr(cfg.observations.policy, camera_name) + assert getattr(cfg.scene, camera_name).prim_path.startswith("{ENV_REGEX_NS}/Robot/") @pytest.mark.parametrize("env_cfg_type", [LocomanipulationG1EnvCfg, FixedBaseUpperBodyIKG1EnvCfg]) diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/newton-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png b/source/isaaclab_tasks/test/golden_images/cartpole/newton-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png deleted file mode 100644 index b493752ac15b..000000000000 --- a/source/isaaclab_tasks/test/golden_images/cartpole/newton-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:7d1136787f6ffb4378dc8e8844cdae6375d3b61ae59d490917a0679d2bfd9012 -size 415 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/newton-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png b/source/isaaclab_tasks/test/golden_images/cartpole/newton-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png deleted file mode 100644 index 550462854a70..000000000000 --- a/source/isaaclab_tasks/test/golden_images/cartpole/newton-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:5affa1d780fb03a97b7571435de3059bb339826047624cea2748a59f59fd7521 -size 460 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/newton-isaacsim_rtx_renderer-simple_shading_full_mdl.png b/source/isaaclab_tasks/test/golden_images/cartpole/newton-isaacsim_rtx_renderer-simple_shading_full_mdl.png deleted file mode 100644 index 9e48fdbf0eba..000000000000 --- a/source/isaaclab_tasks/test/golden_images/cartpole/newton-isaacsim_rtx_renderer-simple_shading_full_mdl.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:0c524f42a1123d3614e810a0295785b929299158b052b3060c5460374be10f77 -size 643 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-simple_shading_constant_diffuse.png b/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-simple_shading_constant_diffuse.png deleted file mode 100644 index b493752ac15b..000000000000 --- a/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-simple_shading_constant_diffuse.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:7d1136787f6ffb4378dc8e8844cdae6375d3b61ae59d490917a0679d2bfd9012 -size 415 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png b/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png deleted file mode 100644 index 550462854a70..000000000000 --- a/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:5affa1d780fb03a97b7571435de3059bb339826047624cea2748a59f59fd7521 -size 460 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-simple_shading_full_mdl.png b/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-simple_shading_full_mdl.png deleted file mode 100644 index 9e48fdbf0eba..000000000000 --- a/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-simple_shading_full_mdl.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:0c524f42a1123d3614e810a0295785b929299158b052b3060c5460374be10f77 -size 643 diff --git a/source/isaaclab_tasks/test/golden_images/franka_cable/newton-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png b/source/isaaclab_tasks/test/golden_images/franka_cable/newton-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png deleted file mode 100644 index 7d43167e8db1..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_cable/newton-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:97ec6ae5de43dcbf47b8d04e2847d36d9052b0ba6d243ebf207d7f67e92169db -size 7658 diff --git a/source/isaaclab_tasks/test/golden_images/franka_cable/newton-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png b/source/isaaclab_tasks/test/golden_images/franka_cable/newton-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png deleted file mode 100644 index 92f931d705ec..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_cable/newton-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:5eb2102aef0bd65df12b308a47c22bb9a2c0122ca1328fa926f8a12b3d47785d -size 7217 diff --git a/source/isaaclab_tasks/test/golden_images/franka_cable/newton-isaacsim_rtx_renderer-simple_shading_full_mdl.png b/source/isaaclab_tasks/test/golden_images/franka_cable/newton-isaacsim_rtx_renderer-simple_shading_full_mdl.png deleted file mode 100644 index 683cf70ad197..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_cable/newton-isaacsim_rtx_renderer-simple_shading_full_mdl.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:245fac29a1f973ac0dc8db23ab38ec23470b7ed2410f2e4e39f61c409eaee5e7 -size 7592 diff --git a/source/isaaclab_tasks/test/golden_images/franka_cable/newton-ovrtx_renderer-simple_shading_constant_diffuse.png b/source/isaaclab_tasks/test/golden_images/franka_cable/newton-ovrtx_renderer-simple_shading_constant_diffuse.png deleted file mode 100644 index cd585e9f0c3e..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_cable/newton-ovrtx_renderer-simple_shading_constant_diffuse.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:1a68e4bf42cc0595aad72aabf74d3ecdd0eaf5dcf5e472b8073d5f959492799e -size 7602 diff --git a/source/isaaclab_tasks/test/golden_images/franka_cable/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png b/source/isaaclab_tasks/test/golden_images/franka_cable/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png deleted file mode 100644 index 0ad3af8b0dda..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_cable/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:436e5c2059e2018198a0b2e7f6f26f34e737c2b9ba6a53383531cf714a3cb4f2 -size 8320 diff --git a/source/isaaclab_tasks/test/golden_images/franka_cable/newton-ovrtx_renderer-simple_shading_full_mdl.png b/source/isaaclab_tasks/test/golden_images/franka_cable/newton-ovrtx_renderer-simple_shading_full_mdl.png deleted file mode 100644 index 04733bd11768..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_cable/newton-ovrtx_renderer-simple_shading_full_mdl.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:77116866f07223b37874ec1f172e5ccf87b86c7dfea9e0583c90e9f24cef01d9 -size 7903 diff --git a/source/isaaclab_tasks/test/golden_images/franka_cloth/newton-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png b/source/isaaclab_tasks/test/golden_images/franka_cloth/newton-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png deleted file mode 100644 index 1935b32cc3e9..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_cloth/newton-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:84787b83bcecf43b647dd1703f996ace0ac5e5d4ad8bca7c2ab5587c0eed82e9 -size 6566 diff --git a/source/isaaclab_tasks/test/golden_images/franka_cloth/newton-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png b/source/isaaclab_tasks/test/golden_images/franka_cloth/newton-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png deleted file mode 100644 index b6a3f8d717cf..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_cloth/newton-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a6c1cf4f7e9a568ea0c03471a8c10d90a090a581ca19ce7ba531e0bb480c85e4 -size 6145 diff --git a/source/isaaclab_tasks/test/golden_images/franka_cloth/newton-isaacsim_rtx_renderer-simple_shading_full_mdl.png b/source/isaaclab_tasks/test/golden_images/franka_cloth/newton-isaacsim_rtx_renderer-simple_shading_full_mdl.png deleted file mode 100644 index a7dea80ae06b..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_cloth/newton-isaacsim_rtx_renderer-simple_shading_full_mdl.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:78fc0e45f017b884d55562fa0bacc0eeb1b04e27f3101f0dcad12421e360c76a -size 6584 diff --git a/source/isaaclab_tasks/test/golden_images/franka_cloth/newton-ovrtx_renderer-simple_shading_constant_diffuse.png b/source/isaaclab_tasks/test/golden_images/franka_cloth/newton-ovrtx_renderer-simple_shading_constant_diffuse.png deleted file mode 100644 index 9440f09b08bd..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_cloth/newton-ovrtx_renderer-simple_shading_constant_diffuse.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:23fb66275323fa31a4fa95626b5ff48af0b310497df9a88bd7ba2245e5c8979a -size 6565 diff --git a/source/isaaclab_tasks/test/golden_images/franka_cloth/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png b/source/isaaclab_tasks/test/golden_images/franka_cloth/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png deleted file mode 100644 index 7f70b027dcf5..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_cloth/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ef489d6d90619b5ecb36b6ad2311ea56898ddb123b38dd38f6df6f75723300a1 -size 7500 diff --git a/source/isaaclab_tasks/test/golden_images/franka_cloth/newton-ovrtx_renderer-simple_shading_full_mdl.png b/source/isaaclab_tasks/test/golden_images/franka_cloth/newton-ovrtx_renderer-simple_shading_full_mdl.png deleted file mode 100644 index 56c0923872b4..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_cloth/newton-ovrtx_renderer-simple_shading_full_mdl.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:8afcad4effc8fcdd4d9b37ded2b6185c477636739d3fb6e604a750b5d9201db7 -size 7209 diff --git a/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-newton_renderer-depth.png b/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-newton_renderer-depth.png deleted file mode 100644 index fa04bac814ee..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-newton_renderer-depth.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:470acb4e656612221f21700d8d1e40b1347e25a2d5ca9b3fca61ff6ce0e31134 -size 5977 diff --git a/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-newton_renderer-distance_to_camera.png b/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-newton_renderer-distance_to_camera.png deleted file mode 100644 index 192d74a485ec..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-newton_renderer-distance_to_camera.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:2eb5223e1d6b9e9eb5ffc09dd427d3a50fdcc67b18a47e0a89146176c18b98e5 -size 8257 diff --git a/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-newton_renderer-distance_to_image_plane.png b/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-newton_renderer-distance_to_image_plane.png deleted file mode 100644 index fa04bac814ee..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-newton_renderer-distance_to_image_plane.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:470acb4e656612221f21700d8d1e40b1347e25a2d5ca9b3fca61ff6ce0e31134 -size 5977 diff --git a/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-newton_renderer-instance_segmentation.png b/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-newton_renderer-instance_segmentation.png deleted file mode 100644 index 1e8f91b45570..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-newton_renderer-instance_segmentation.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ee80ba932fd60f0a101638d434a83774819f6736ab99872ee4476556cf15ad26 -size 1802 diff --git a/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-newton_renderer-normals.png b/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-newton_renderer-normals.png deleted file mode 100644 index 79b57bd63edc..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-newton_renderer-normals.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c8a62e0c2b6f5d3d4cdbeba0f8b36b52c7086cc8bb0860dfa8d91277eb055e60 -size 8742 diff --git a/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-newton_renderer-rgb.png b/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-newton_renderer-rgb.png deleted file mode 100644 index e8b9d93442de..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-newton_renderer-rgb.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:0017d34889b0e5fd4def9999ef77ecdb040af2c1cfaf0904a417b392c846ff1a -size 7819 diff --git a/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-newton_renderer-rgba.png b/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-newton_renderer-rgba.png deleted file mode 100644 index c7d4e01040f7..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-newton_renderer-rgba.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:36d29288bd15d9880011f3d4c83ddb0d5c1412248d2d2b96ab9a7122eada6ff1 -size 8499 diff --git a/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-newton_renderer-semantic_segmentation.png b/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-newton_renderer-semantic_segmentation.png deleted file mode 100644 index 1e8f91b45570..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-newton_renderer-semantic_segmentation.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ee80ba932fd60f0a101638d434a83774819f6736ab99872ee4476556cf15ad26 -size 1802 diff --git a/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-ovrtx_renderer-albedo.png b/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-ovrtx_renderer-albedo.png deleted file mode 100644 index 3b22df1ca507..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-ovrtx_renderer-albedo.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:2a78128a5510fb297c4988090699d00bdd334096b49a6f174b011dccf95c929b -size 7291 diff --git a/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-ovrtx_renderer-depth.png b/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-ovrtx_renderer-depth.png deleted file mode 100644 index 5e7bb4d7bd64..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-ovrtx_renderer-depth.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f215d7b1adae49bebf096aa75c2531b86942cfb9094dbe1d3bad0d54ee5e2d04 -size 5975 diff --git a/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-ovrtx_renderer-distance_to_camera.png b/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-ovrtx_renderer-distance_to_camera.png deleted file mode 100644 index 03be36e881d7..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-ovrtx_renderer-distance_to_camera.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:925d3969c8e33aa51ac6cb07a1aed1f833ed454043690bff8c0623e848fae66d -size 8255 diff --git a/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-ovrtx_renderer-distance_to_image_plane.png b/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-ovrtx_renderer-distance_to_image_plane.png deleted file mode 100644 index 5e7bb4d7bd64..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-ovrtx_renderer-distance_to_image_plane.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f215d7b1adae49bebf096aa75c2531b86942cfb9094dbe1d3bad0d54ee5e2d04 -size 5975 diff --git a/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-ovrtx_renderer-motion_vectors.png b/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-ovrtx_renderer-motion_vectors.png deleted file mode 100644 index 6e0253e2ab5f..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-ovrtx_renderer-motion_vectors.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:223217671a4eb1b43f433ed70b485b714613f49675d5c0193d37e908649f47fc -size 16694 diff --git a/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-ovrtx_renderer-normals.png b/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-ovrtx_renderer-normals.png deleted file mode 100644 index d5c9dfa6cec6..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-ovrtx_renderer-normals.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b86db285d58a5f2cb01a5fdb174a20dec5ad7f8bcee9d9b8a67536176b6d758c -size 8754 diff --git a/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-ovrtx_renderer-rgb.png b/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-ovrtx_renderer-rgb.png deleted file mode 100644 index fb3de0ddd37f..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-ovrtx_renderer-rgb.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:101884296c0eff55959869f3afa24f58c94d273b23ad4553af48adcd9829a275 -size 49242 diff --git a/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-ovrtx_renderer-rgba.png b/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-ovrtx_renderer-rgba.png deleted file mode 100644 index e2e1161439f3..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-ovrtx_renderer-rgba.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:165fcf73c524c1f86e20476e7477ef0e41abef6ccf35bb1533ad6398a4ce72c5 -size 55786 diff --git a/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-ovrtx_renderer-semantic_segmentation.png b/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-ovrtx_renderer-semantic_segmentation.png deleted file mode 100644 index 11248cfae170..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-ovrtx_renderer-semantic_segmentation.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:de962ba2ecf9c5752b38e11b002bb528901352404b935e29a877ffd5626cd483 -size 1796 diff --git a/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-ovrtx_renderer-simple_shading_constant_diffuse.png b/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-ovrtx_renderer-simple_shading_constant_diffuse.png deleted file mode 100644 index 3ee851e71eb9..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-ovrtx_renderer-simple_shading_constant_diffuse.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b0ede2bcced36c8ab6afb247cee8e91a7c47d17a11052a6fff83fb24cc956dc2 -size 6496 diff --git a/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-ovrtx_renderer-simple_shading_diffuse_mdl.png b/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-ovrtx_renderer-simple_shading_diffuse_mdl.png deleted file mode 100644 index 605f17413a60..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-ovrtx_renderer-simple_shading_diffuse_mdl.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:eacd6e605c946c92f24d282cf8b2e9e93e95e80e37b75068e3b8647c8741398a -size 7499 diff --git a/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-ovrtx_renderer-simple_shading_full_mdl.png b/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-ovrtx_renderer-simple_shading_full_mdl.png deleted file mode 100644 index f55b21b2e8d1..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-ovrtx_renderer-simple_shading_full_mdl.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a2778a8206218242e23758341a4fcb6a7d93f9ed6ec017faa44142b272003843 -size 7190 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/newton-isaacsim_rtx_renderer-albedo.png b/source/isaaclab_tasks/test/golden_images/franka_soft/newton-isaacsim_rtx_renderer-albedo.png deleted file mode 100644 index c3a300497e9b..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/newton-isaacsim_rtx_renderer-albedo.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:8e23d812b3bfb82419bdb465d163d68c2dbabfb4537187bf567ac72397419a4e -size 2679 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/newton-isaacsim_rtx_renderer-depth.png b/source/isaaclab_tasks/test/golden_images/franka_soft/newton-isaacsim_rtx_renderer-depth.png deleted file mode 100644 index adf55767e8c2..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/newton-isaacsim_rtx_renderer-depth.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:07f37bcdb600413d9d5f59d37fbbbd4cd41ad9dfb4e15ba0458e42aac2caee7d -size 5089 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/newton-isaacsim_rtx_renderer-distance_to_camera.png b/source/isaaclab_tasks/test/golden_images/franka_soft/newton-isaacsim_rtx_renderer-distance_to_camera.png deleted file mode 100644 index d3ea84b488ba..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/newton-isaacsim_rtx_renderer-distance_to_camera.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:15a5cade0f5efac2b98a3d2e023871b6badff8b8ffafde42215a44052e66f6fe -size 8322 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/newton-isaacsim_rtx_renderer-distance_to_image_plane.png b/source/isaaclab_tasks/test/golden_images/franka_soft/newton-isaacsim_rtx_renderer-distance_to_image_plane.png deleted file mode 100644 index 7f98f0dc927a..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/newton-isaacsim_rtx_renderer-distance_to_image_plane.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:160bab7dc933f7be617d2337b0739e91f4a9e27a01530ca0352f884c421ca530 -size 5117 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/newton-isaacsim_rtx_renderer-instance_id_segmentation_fast.png b/source/isaaclab_tasks/test/golden_images/franka_soft/newton-isaacsim_rtx_renderer-instance_id_segmentation_fast.png deleted file mode 100644 index 192cc182c7c9..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/newton-isaacsim_rtx_renderer-instance_id_segmentation_fast.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:de83cb6e8c93178717b6aaafa28bbb08209813d450e2caa255ae3fa91d2b7120 -size 3822 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/newton-isaacsim_rtx_renderer-instance_segmentation.png b/source/isaaclab_tasks/test/golden_images/franka_soft/newton-isaacsim_rtx_renderer-instance_segmentation.png deleted file mode 100644 index b44681334a96..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/newton-isaacsim_rtx_renderer-instance_segmentation.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:02a8f9f8b95c3264a8cad900a0e32e34af2941aba61de09438ca700703f0e516 -size 1815 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/newton-isaacsim_rtx_renderer-normals.png b/source/isaaclab_tasks/test/golden_images/franka_soft/newton-isaacsim_rtx_renderer-normals.png deleted file mode 100644 index b38d2f97a1e9..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/newton-isaacsim_rtx_renderer-normals.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:35ddea480426d7235954bec28a1899063e62a47f54da38d631b635dcc890e522 -size 9621 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/newton-isaacsim_rtx_renderer-rgb.png b/source/isaaclab_tasks/test/golden_images/franka_soft/newton-isaacsim_rtx_renderer-rgb.png deleted file mode 100644 index 2661c0cba010..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/newton-isaacsim_rtx_renderer-rgb.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:dff507736e49efcd657a94262a5c7656e4b3a4b61424c95dc640e7871fd8c3c4 -size 55279 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/newton-isaacsim_rtx_renderer-rgba.png b/source/isaaclab_tasks/test/golden_images/franka_soft/newton-isaacsim_rtx_renderer-rgba.png deleted file mode 100644 index 10be2c8d8926..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/newton-isaacsim_rtx_renderer-rgba.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:9874ad07cfa01c00be14763e5f5cb28be1b80ce8cce43631d46308c82978abb6 -size 62457 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/newton-isaacsim_rtx_renderer-semantic_segmentation.png b/source/isaaclab_tasks/test/golden_images/franka_soft/newton-isaacsim_rtx_renderer-semantic_segmentation.png deleted file mode 100644 index b44681334a96..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/newton-isaacsim_rtx_renderer-semantic_segmentation.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:02a8f9f8b95c3264a8cad900a0e32e34af2941aba61de09438ca700703f0e516 -size 1815 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/newton-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png b/source/isaaclab_tasks/test/golden_images/franka_soft/newton-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png deleted file mode 100644 index d91c2ffcfd2e..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/newton-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:3bf9fcc2da0c8672b07f3cb38f79c2d4bd8ea01b0d493f8383918b80ecb2ca43 -size 7142 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/newton-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png b/source/isaaclab_tasks/test/golden_images/franka_soft/newton-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png deleted file mode 100644 index 977ac185e033..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/newton-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c6db394b71c54855a7853701ce5abbe4f983f758fc36874d6da9a9705232919c -size 6892 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/newton-isaacsim_rtx_renderer-simple_shading_full_mdl.png b/source/isaaclab_tasks/test/golden_images/franka_soft/newton-isaacsim_rtx_renderer-simple_shading_full_mdl.png deleted file mode 100644 index 3eba414cd4b2..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/newton-isaacsim_rtx_renderer-simple_shading_full_mdl.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:fe8d1236abf442efe891671656679dbba38164e53cebb1aa5f5beacd72d42ba9 -size 7255 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/newton-ovrtx_renderer-simple_shading_constant_diffuse.png b/source/isaaclab_tasks/test/golden_images/franka_soft/newton-ovrtx_renderer-simple_shading_constant_diffuse.png deleted file mode 100644 index b61a61c21622..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/newton-ovrtx_renderer-simple_shading_constant_diffuse.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c7275f94528db7a0a25aa987084778c0dc47ac69f247c4733433e8ffd5311249 -size 7124 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png b/source/isaaclab_tasks/test/golden_images/franka_soft/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png deleted file mode 100644 index d2bbcafd2050..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:e330e94315b45830763b02a014676f7cf47a4fcb2bf4830bb935a913871bebce -size 7921 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/newton-ovrtx_renderer-simple_shading_full_mdl.png b/source/isaaclab_tasks/test/golden_images/franka_soft/newton-ovrtx_renderer-simple_shading_full_mdl.png deleted file mode 100644 index 90e505589e8d..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/newton-ovrtx_renderer-simple_shading_full_mdl.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:caef3158d703f80e13613c7a8b01e77915603b32b9c1fdd750a4798cc30f55f8 -size 7548 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-newton_renderer-depth.png b/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-newton_renderer-depth.png deleted file mode 100644 index b346fe593fcc..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-newton_renderer-depth.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:881c1feadb29ee50595d222e73b51994941c1643bcfbec5d997992b0a090ab14 -size 5915 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-newton_renderer-distance_to_camera.png b/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-newton_renderer-distance_to_camera.png deleted file mode 100644 index ed58cbfa752e..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-newton_renderer-distance_to_camera.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:4620001dc0688b1dda6a3f025670b22d0d42f3afdff4aa5b3915c19c6f2cfc96 -size 8387 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-newton_renderer-distance_to_image_plane.png b/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-newton_renderer-distance_to_image_plane.png deleted file mode 100644 index b346fe593fcc..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-newton_renderer-distance_to_image_plane.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:881c1feadb29ee50595d222e73b51994941c1643bcfbec5d997992b0a090ab14 -size 5915 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-newton_renderer-instance_segmentation.png b/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-newton_renderer-instance_segmentation.png deleted file mode 100644 index 0ec61d35adf9..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-newton_renderer-instance_segmentation.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:aeee6199362fa728ff9804a8637cdfc0f395797a31014c25e421774ebf0ee6d7 -size 1766 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-newton_renderer-normals.png b/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-newton_renderer-normals.png deleted file mode 100644 index 39bc7a5b5147..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-newton_renderer-normals.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:e560faa19e8906785ec462eebe92c33f3c793c521840f912f09cf510f556c6f1 -size 9827 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-newton_renderer-rgb.png b/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-newton_renderer-rgb.png deleted file mode 100644 index 265e1c88d8d9..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-newton_renderer-rgb.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:66fc549ba0f8fbca6c29709f41418f974ac14debc2b0df965d8633cfd69976ec -size 8099 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-newton_renderer-rgba.png b/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-newton_renderer-rgba.png deleted file mode 100644 index 385c56055586..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-newton_renderer-rgba.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:9504fe4277190e6e123ef6f5541eba589391e8d83eeb9fc6aa02c451b27931aa -size 8843 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-newton_renderer-semantic_segmentation.png b/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-newton_renderer-semantic_segmentation.png deleted file mode 100644 index 0ec61d35adf9..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-newton_renderer-semantic_segmentation.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:aeee6199362fa728ff9804a8637cdfc0f395797a31014c25e421774ebf0ee6d7 -size 1766 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-ovrtx_renderer-albedo.png b/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-ovrtx_renderer-albedo.png deleted file mode 100644 index aa24bfd4e45c..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-ovrtx_renderer-albedo.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b55c86d72ab0e9996385707a28d6a4ecaf03eeda80e808978c91ac3d492779c3 -size 7530 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-ovrtx_renderer-depth.png b/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-ovrtx_renderer-depth.png deleted file mode 100644 index eefc3bb4c383..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-ovrtx_renderer-depth.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:fe6a42541287ff8fc63af6a7aa529a081082ca63049461e006b5b41381f2a02b -size 6098 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-ovrtx_renderer-distance_to_camera.png b/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-ovrtx_renderer-distance_to_camera.png deleted file mode 100644 index 7a7082fdb167..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-ovrtx_renderer-distance_to_camera.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:939a740282a76e83d568b8b89b33f56b919ccfabea148f72eb9b741fee633c3a -size 8395 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-ovrtx_renderer-distance_to_image_plane.png b/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-ovrtx_renderer-distance_to_image_plane.png deleted file mode 100644 index 89488d71054a..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-ovrtx_renderer-distance_to_image_plane.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:6e7441a257509e2a16ebf9cc757b095e51234f76232cdad322e9956de316124d -size 5913 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-ovrtx_renderer-motion_vectors.png b/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-ovrtx_renderer-motion_vectors.png deleted file mode 100644 index b8e49481a185..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-ovrtx_renderer-motion_vectors.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a2cbd18cb00a1c3e55791fd7443c5ff4bf85cd63d2fd6fdb7339f4ddb0a3d1b3 -size 16963 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-ovrtx_renderer-normals.png b/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-ovrtx_renderer-normals.png deleted file mode 100644 index 306e8ec7d482..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-ovrtx_renderer-normals.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:78a6336c0a4bf5b3d328c5026afbaf527b74977c87c9a48e71a7e9323707be7e -size 9661 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-ovrtx_renderer-rgb.png b/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-ovrtx_renderer-rgb.png deleted file mode 100644 index ee8b3166a026..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-ovrtx_renderer-rgb.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f8a21265098b3d097495ee271c9cd02a17f9c9521d02ca23127e50ab3448b834 -size 49984 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-ovrtx_renderer-rgba.png b/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-ovrtx_renderer-rgba.png deleted file mode 100644 index 892678a509e4..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-ovrtx_renderer-rgba.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:87c06843198f4ec729f6e9c25c1dc0b8578b750acea617cc259dccd36e3ea3d7 -size 56253 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-ovrtx_renderer-semantic_segmentation.png b/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-ovrtx_renderer-semantic_segmentation.png deleted file mode 100644 index 0ec61d35adf9..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-ovrtx_renderer-semantic_segmentation.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:aeee6199362fa728ff9804a8637cdfc0f395797a31014c25e421774ebf0ee6d7 -size 1766 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-ovrtx_renderer-simple_shading_constant_diffuse.png b/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-ovrtx_renderer-simple_shading_constant_diffuse.png deleted file mode 100644 index df92e9ad3ab9..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-ovrtx_renderer-simple_shading_constant_diffuse.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:1886320258dedd7ca6cbe5130ac7d1bdf731c1b85491c9f8f2a58a0503f1cfe0 -size 6778 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-ovrtx_renderer-simple_shading_diffuse_mdl.png b/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-ovrtx_renderer-simple_shading_diffuse_mdl.png deleted file mode 100644 index f5d94dc9a520..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-ovrtx_renderer-simple_shading_diffuse_mdl.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:e52c23f2ed3f52e87fff33f932310a88a7542b8da7d9f1d47d8bc61babb28e22 -size 7642 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-ovrtx_renderer-simple_shading_full_mdl.png b/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-ovrtx_renderer-simple_shading_full_mdl.png deleted file mode 100644 index 64d2b029282c..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-ovrtx_renderer-simple_shading_full_mdl.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c3e1265e0ca38af1635fcf3da52679ae1f2e108723c657c4e0a47de774927ac4 -size 7174 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-albedo.png b/source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-albedo.png deleted file mode 100644 index 92ec2227d678..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-albedo.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:71fd16071477d0ebe85ac8cf3764693308c4c54ef9109a5ba17cb77b3d3d3ab7 -size 2696 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-depth.png b/source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-depth.png deleted file mode 100644 index c94b0489ddd5..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-depth.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:5cb1660244caffbc3af2b40f3f77969ff1b8cc89561c8746ee6d2cdd2e32725d -size 5113 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-distance_to_camera.png b/source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-distance_to_camera.png deleted file mode 100644 index 96203b89bb6d..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-distance_to_camera.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:d37fb452fca6fb74caa7ba08c723fbd7bfaa51cdc1cca1aed4f2d2d4f7ca51f0 -size 8353 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-distance_to_image_plane.png b/source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-distance_to_image_plane.png deleted file mode 100644 index b6bc62e2f669..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-distance_to_image_plane.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:41d5314259ef159a76abc4aca32928598dafe6080abff9abf580e9c880c2e2a4 -size 5115 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-instance_id_segmentation_fast.png b/source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-instance_id_segmentation_fast.png deleted file mode 100644 index 31e158fe949c..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-instance_id_segmentation_fast.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:9d23cce37577216f60dde7652ccb6a9f906eb2860f263d0850dcb22125baa545 -size 3911 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-instance_segmentation.png b/source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-instance_segmentation.png deleted file mode 100644 index b44681334a96..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-instance_segmentation.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:02a8f9f8b95c3264a8cad900a0e32e34af2941aba61de09438ca700703f0e516 -size 1815 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-motion_vectors.png b/source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-motion_vectors.png deleted file mode 100644 index a07255ab35b7..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-motion_vectors.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:baa34a95354a299f033bdefa6e7348f4675cccd0f98b8ba25a649364c78e0a22 -size 34359 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-normals.png b/source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-normals.png deleted file mode 100644 index 5bdbeeb38554..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-normals.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a483b051ffd9633cbaab1a6046ad4e59532e91b49b15bf9ca0fca5c3ddb1c336 -size 9675 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-rgb.png b/source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-rgb.png deleted file mode 100644 index fb78441c3f2d..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-rgb.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c5ac2f53ee85011b4e4dc04157dc8ab95115aa6b197efd51da9b01050dcbd4cd -size 54901 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-rgba.png b/source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-rgba.png deleted file mode 100644 index beff43c09822..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-rgba.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:804b7a5f277aaeb902bd844720eda8ba0fde9a9cf85aaee964fd5645f0904a91 -size 61809 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-semantic_segmentation.png b/source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-semantic_segmentation.png deleted file mode 100644 index b44681334a96..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-semantic_segmentation.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:02a8f9f8b95c3264a8cad900a0e32e34af2941aba61de09438ca700703f0e516 -size 1815 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png b/source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png deleted file mode 100644 index 2233b2d10f55..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:24b3780a9eb940cd973e3688c83852b6a224ad0ea22dbe0a253fd0ab685a0039 -size 7109 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png b/source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png deleted file mode 100644 index a36247b7628d..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:bdf16e649b9e59dc42ce28c343d01f55809c304922f5ed2682bc29fd67102b4a -size 7233 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-simple_shading_full_mdl.png b/source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-simple_shading_full_mdl.png deleted file mode 100644 index fa86aea85e46..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-simple_shading_full_mdl.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:2f198e83fb9293d41c5474df834d9766a1cf064d6cf58b9f47777f4af2fb1fa8 -size 7277 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/physx-newton_renderer-depth.png b/source/isaaclab_tasks/test/golden_images/franka_soft/physx-newton_renderer-depth.png deleted file mode 100644 index 17dc01ea8c58..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/physx-newton_renderer-depth.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:96eff03e1ca86f78e36f2c39898f862eece7a8877af45c3319c8425068d33e41 -size 5176 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/physx-newton_renderer-distance_to_camera.png b/source/isaaclab_tasks/test/golden_images/franka_soft/physx-newton_renderer-distance_to_camera.png deleted file mode 100644 index 1c4964d8fcf3..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/physx-newton_renderer-distance_to_camera.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a9b78e3a4721f9caf72949c2e4c1bba6c997ca88261dfdbbacd32e12d0317693 -size 6723 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/physx-newton_renderer-distance_to_image_plane.png b/source/isaaclab_tasks/test/golden_images/franka_soft/physx-newton_renderer-distance_to_image_plane.png deleted file mode 100644 index 17dc01ea8c58..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/physx-newton_renderer-distance_to_image_plane.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:96eff03e1ca86f78e36f2c39898f862eece7a8877af45c3319c8425068d33e41 -size 5176 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/physx-newton_renderer-normals.png b/source/isaaclab_tasks/test/golden_images/franka_soft/physx-newton_renderer-normals.png deleted file mode 100644 index 5ca141996aa2..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/physx-newton_renderer-normals.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:4ee6f97c79aad60f2de91f4e3a9c26c4bd18551250552cf807e21e9667de6a1d -size 9918 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/physx-newton_renderer-rgb.png b/source/isaaclab_tasks/test/golden_images/franka_soft/physx-newton_renderer-rgb.png deleted file mode 100644 index b727cd2e1436..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/physx-newton_renderer-rgb.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:7a99114523454a25debefa117475db33be8053c3d401455e99ee2fafc636d157 -size 8974 diff --git a/source/isaaclab_tasks/test/golden_images/franka_soft/physx-newton_renderer-rgba.png b/source/isaaclab_tasks/test/golden_images/franka_soft/physx-newton_renderer-rgba.png deleted file mode 100644 index fdd5f09c8143..000000000000 --- a/source/isaaclab_tasks/test/golden_images/franka_soft/physx-newton_renderer-rgba.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c8300edb29ea6da64ed88fffa098b698cd057ee841a377563d63cdd9f58b6395 -size 9726 diff --git a/source/isaaclab_tasks/test/golden_images/lift_kuka_hetero/newton-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png b/source/isaaclab_tasks/test/golden_images/lift_kuka_hetero/newton-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png deleted file mode 100755 index c311e2050035..000000000000 --- a/source/isaaclab_tasks/test/golden_images/lift_kuka_hetero/newton-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b8c8b2cf62adcd0aa99e93b0e3de9324558f7d63c318fdde6cc9bc489f4fdef3 -size 1535 diff --git a/source/isaaclab_tasks/test/golden_images/lift_kuka_hetero/newton-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png b/source/isaaclab_tasks/test/golden_images/lift_kuka_hetero/newton-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png deleted file mode 100644 index 5614aaed8a47..000000000000 --- a/source/isaaclab_tasks/test/golden_images/lift_kuka_hetero/newton-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:07fbb1e3a4f5f8a7fde1fd3c73f8acf63fafed076b8b0ab0d5c3cbf0d613764f -size 3094 diff --git a/source/isaaclab_tasks/test/golden_images/lift_kuka_hetero/newton-isaacsim_rtx_renderer-simple_shading_full_mdl.png b/source/isaaclab_tasks/test/golden_images/lift_kuka_hetero/newton-isaacsim_rtx_renderer-simple_shading_full_mdl.png deleted file mode 100644 index 8f05d477e1e7..000000000000 --- a/source/isaaclab_tasks/test/golden_images/lift_kuka_hetero/newton-isaacsim_rtx_renderer-simple_shading_full_mdl.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a6dbac5fee5af955f0bb6902cce4e8582895caf9d46b7327ad26de37d8d43b6b -size 3192 diff --git a/source/isaaclab_tasks/test/golden_images/lift_kuka_hetero/newton-ovrtx_renderer-simple_shading_constant_diffuse.png b/source/isaaclab_tasks/test/golden_images/lift_kuka_hetero/newton-ovrtx_renderer-simple_shading_constant_diffuse.png deleted file mode 100644 index 8a7e7f1bc6bc..000000000000 --- a/source/isaaclab_tasks/test/golden_images/lift_kuka_hetero/newton-ovrtx_renderer-simple_shading_constant_diffuse.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:2d6b61a08f44d446ac22ccd07a0afbcdabcf09cbc91aa45c3d1756631214d5ee -size 1671 diff --git a/source/isaaclab_tasks/test/golden_images/lift_kuka_hetero/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png b/source/isaaclab_tasks/test/golden_images/lift_kuka_hetero/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png deleted file mode 100644 index d2f5efbbcfe5..000000000000 --- a/source/isaaclab_tasks/test/golden_images/lift_kuka_hetero/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:e653f3bc0e04e62f4effe9f04dd20d7c6d074b16fd3e62218532b87ed062bf63 -size 4266 diff --git a/source/isaaclab_tasks/test/golden_images/lift_kuka_hetero/newton-ovrtx_renderer-simple_shading_full_mdl.png b/source/isaaclab_tasks/test/golden_images/lift_kuka_hetero/newton-ovrtx_renderer-simple_shading_full_mdl.png deleted file mode 100644 index 9299cdb71e1f..000000000000 --- a/source/isaaclab_tasks/test/golden_images/lift_kuka_hetero/newton-ovrtx_renderer-simple_shading_full_mdl.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:d6d649bfe78bc3f112d3f7d237f1a93c61997ce74d3ebdb185812b1a530f41bb -size 4331 diff --git a/source/isaaclab_tasks/test/golden_images/lift_kuka_hetero/physx-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png b/source/isaaclab_tasks/test/golden_images/lift_kuka_hetero/physx-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png deleted file mode 100755 index c311e2050035..000000000000 --- a/source/isaaclab_tasks/test/golden_images/lift_kuka_hetero/physx-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b8c8b2cf62adcd0aa99e93b0e3de9324558f7d63c318fdde6cc9bc489f4fdef3 -size 1535 diff --git a/source/isaaclab_tasks/test/golden_images/lift_kuka_hetero/physx-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png b/source/isaaclab_tasks/test/golden_images/lift_kuka_hetero/physx-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png deleted file mode 100644 index 5614aaed8a47..000000000000 --- a/source/isaaclab_tasks/test/golden_images/lift_kuka_hetero/physx-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:07fbb1e3a4f5f8a7fde1fd3c73f8acf63fafed076b8b0ab0d5c3cbf0d613764f -size 3094 diff --git a/source/isaaclab_tasks/test/golden_images/lift_kuka_hetero/physx-isaacsim_rtx_renderer-simple_shading_full_mdl.png b/source/isaaclab_tasks/test/golden_images/lift_kuka_hetero/physx-isaacsim_rtx_renderer-simple_shading_full_mdl.png deleted file mode 100644 index 8f05d477e1e7..000000000000 --- a/source/isaaclab_tasks/test/golden_images/lift_kuka_hetero/physx-isaacsim_rtx_renderer-simple_shading_full_mdl.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a6dbac5fee5af955f0bb6902cce4e8582895caf9d46b7327ad26de37d8d43b6b -size 3192 diff --git a/source/isaaclab_tasks/test/golden_images/lift_kuka_homo/newton-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png b/source/isaaclab_tasks/test/golden_images/lift_kuka_homo/newton-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png deleted file mode 100755 index 76288445614b..000000000000 --- a/source/isaaclab_tasks/test/golden_images/lift_kuka_homo/newton-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:360459ccbb8b172c8582e3e684d0c1bda960cfa3f0559690fe7f287ab28a086a -size 1469 diff --git a/source/isaaclab_tasks/test/golden_images/lift_kuka_homo/newton-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png b/source/isaaclab_tasks/test/golden_images/lift_kuka_homo/newton-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png deleted file mode 100644 index 7cfa183f8e9b..000000000000 --- a/source/isaaclab_tasks/test/golden_images/lift_kuka_homo/newton-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:1912d63f3aa412783276698a08e9a597968b690f0296b587ae17e2df33d8094e -size 3033 diff --git a/source/isaaclab_tasks/test/golden_images/lift_kuka_homo/newton-isaacsim_rtx_renderer-simple_shading_full_mdl.png b/source/isaaclab_tasks/test/golden_images/lift_kuka_homo/newton-isaacsim_rtx_renderer-simple_shading_full_mdl.png deleted file mode 100644 index f487dc023721..000000000000 --- a/source/isaaclab_tasks/test/golden_images/lift_kuka_homo/newton-isaacsim_rtx_renderer-simple_shading_full_mdl.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:33f2cb318dda2045669b4e3b4366e8bb896a886948e000094e8ae593aa93fc84 -size 3125 diff --git a/source/isaaclab_tasks/test/golden_images/lift_kuka_homo/newton-ovrtx_renderer-simple_shading_constant_diffuse.png b/source/isaaclab_tasks/test/golden_images/lift_kuka_homo/newton-ovrtx_renderer-simple_shading_constant_diffuse.png deleted file mode 100644 index 7c1b44deaee6..000000000000 --- a/source/isaaclab_tasks/test/golden_images/lift_kuka_homo/newton-ovrtx_renderer-simple_shading_constant_diffuse.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:581fce8f2190c364906fd4556aae8c9da4c703a08cddadef95efad74cfd90605 -size 1469 diff --git a/source/isaaclab_tasks/test/golden_images/lift_kuka_homo/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png b/source/isaaclab_tasks/test/golden_images/lift_kuka_homo/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png deleted file mode 100644 index 5c5712cd65be..000000000000 --- a/source/isaaclab_tasks/test/golden_images/lift_kuka_homo/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:d8249e1c87665a6ee5742dc1932f035393ce969c381162d97514483df1ac9233 -size 4211 diff --git a/source/isaaclab_tasks/test/golden_images/lift_kuka_homo/newton-ovrtx_renderer-simple_shading_full_mdl.png b/source/isaaclab_tasks/test/golden_images/lift_kuka_homo/newton-ovrtx_renderer-simple_shading_full_mdl.png deleted file mode 100644 index 93285deca6d2..000000000000 --- a/source/isaaclab_tasks/test/golden_images/lift_kuka_homo/newton-ovrtx_renderer-simple_shading_full_mdl.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:83a1339e8001401b55f6341efe7695f4752ccdb2b5ce9f349c5129ec3b8a89cc -size 4273 diff --git a/source/isaaclab_tasks/test/golden_images/lift_kuka_homo/ovphysx-ovrtx_renderer-simple_shading_constant_diffuse.png b/source/isaaclab_tasks/test/golden_images/lift_kuka_homo/ovphysx-ovrtx_renderer-simple_shading_constant_diffuse.png deleted file mode 100644 index 7c1b44deaee6..000000000000 --- a/source/isaaclab_tasks/test/golden_images/lift_kuka_homo/ovphysx-ovrtx_renderer-simple_shading_constant_diffuse.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:581fce8f2190c364906fd4556aae8c9da4c703a08cddadef95efad74cfd90605 -size 1469 diff --git a/source/isaaclab_tasks/test/golden_images/lift_kuka_homo/ovphysx-ovrtx_renderer-simple_shading_diffuse_mdl.png b/source/isaaclab_tasks/test/golden_images/lift_kuka_homo/ovphysx-ovrtx_renderer-simple_shading_diffuse_mdl.png deleted file mode 100644 index 5c5712cd65be..000000000000 --- a/source/isaaclab_tasks/test/golden_images/lift_kuka_homo/ovphysx-ovrtx_renderer-simple_shading_diffuse_mdl.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:d8249e1c87665a6ee5742dc1932f035393ce969c381162d97514483df1ac9233 -size 4211 diff --git a/source/isaaclab_tasks/test/golden_images/lift_kuka_homo/ovphysx-ovrtx_renderer-simple_shading_full_mdl.png b/source/isaaclab_tasks/test/golden_images/lift_kuka_homo/ovphysx-ovrtx_renderer-simple_shading_full_mdl.png deleted file mode 100644 index 93285deca6d2..000000000000 --- a/source/isaaclab_tasks/test/golden_images/lift_kuka_homo/ovphysx-ovrtx_renderer-simple_shading_full_mdl.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:83a1339e8001401b55f6341efe7695f4752ccdb2b5ce9f349c5129ec3b8a89cc -size 4273 diff --git a/source/isaaclab_tasks/test/golden_images/lift_kuka_homo/physx-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png b/source/isaaclab_tasks/test/golden_images/lift_kuka_homo/physx-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png deleted file mode 100755 index 76288445614b..000000000000 --- a/source/isaaclab_tasks/test/golden_images/lift_kuka_homo/physx-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:360459ccbb8b172c8582e3e684d0c1bda960cfa3f0559690fe7f287ab28a086a -size 1469 diff --git a/source/isaaclab_tasks/test/golden_images/lift_kuka_homo/physx-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png b/source/isaaclab_tasks/test/golden_images/lift_kuka_homo/physx-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png deleted file mode 100644 index 7cfa183f8e9b..000000000000 --- a/source/isaaclab_tasks/test/golden_images/lift_kuka_homo/physx-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:1912d63f3aa412783276698a08e9a597968b690f0296b587ae17e2df33d8094e -size 3033 diff --git a/source/isaaclab_tasks/test/golden_images/lift_kuka_homo/physx-isaacsim_rtx_renderer-simple_shading_full_mdl.png b/source/isaaclab_tasks/test/golden_images/lift_kuka_homo/physx-isaacsim_rtx_renderer-simple_shading_full_mdl.png deleted file mode 100644 index f487dc023721..000000000000 --- a/source/isaaclab_tasks/test/golden_images/lift_kuka_homo/physx-isaacsim_rtx_renderer-simple_shading_full_mdl.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:33f2cb318dda2045669b4e3b4366e8bb896a886948e000094e8ae593aa93fc84 -size 3125 diff --git a/source/isaaclab_tasks/test/golden_images/mpm_particles/newton-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png b/source/isaaclab_tasks/test/golden_images/mpm_particles/newton-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png deleted file mode 100644 index 6162e6828185..000000000000 --- a/source/isaaclab_tasks/test/golden_images/mpm_particles/newton-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:6379eaf9079d54e596ad2b096627d4c878f066e29b36af5989328b540be5c4bd -size 51436 diff --git a/source/isaaclab_tasks/test/golden_images/mpm_particles/newton-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png b/source/isaaclab_tasks/test/golden_images/mpm_particles/newton-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png deleted file mode 100644 index 2d32c73cdda8..000000000000 --- a/source/isaaclab_tasks/test/golden_images/mpm_particles/newton-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:622f1819fc2fbf5db714a0f1ef70de48f17ee43a1746df462f314ec2712fef16 -size 64209 diff --git a/source/isaaclab_tasks/test/golden_images/mpm_particles/newton-isaacsim_rtx_renderer-simple_shading_full_mdl.png b/source/isaaclab_tasks/test/golden_images/mpm_particles/newton-isaacsim_rtx_renderer-simple_shading_full_mdl.png deleted file mode 100644 index 1080ac6cb176..000000000000 --- a/source/isaaclab_tasks/test/golden_images/mpm_particles/newton-isaacsim_rtx_renderer-simple_shading_full_mdl.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c6ed52bcb9d929bae83ddd41ea6e0409ee6a3923f7f0b328e537db872f67e954 -size 78760 diff --git a/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Cartpole-Camera-Direct/default_physics-default_renderer-simple_shading_constant_diffuse.png b/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Cartpole-Camera-Direct/default_physics-default_renderer-simple_shading_constant_diffuse.png deleted file mode 100644 index b493752ac15b..000000000000 --- a/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Cartpole-Camera-Direct/default_physics-default_renderer-simple_shading_constant_diffuse.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:7d1136787f6ffb4378dc8e8844cdae6375d3b61ae59d490917a0679d2bfd9012 -size 415 diff --git a/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Cartpole-Camera-Direct/default_physics-default_renderer-simple_shading_diffuse_mdl.png b/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Cartpole-Camera-Direct/default_physics-default_renderer-simple_shading_diffuse_mdl.png deleted file mode 100644 index 550462854a70..000000000000 --- a/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Cartpole-Camera-Direct/default_physics-default_renderer-simple_shading_diffuse_mdl.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:5affa1d780fb03a97b7571435de3059bb339826047624cea2748a59f59fd7521 -size 460 diff --git a/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Cartpole-Camera-Direct/default_physics-default_renderer-simple_shading_full_mdl.png b/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Cartpole-Camera-Direct/default_physics-default_renderer-simple_shading_full_mdl.png deleted file mode 100644 index 9e48fdbf0eba..000000000000 --- a/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Cartpole-Camera-Direct/default_physics-default_renderer-simple_shading_full_mdl.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:0c524f42a1123d3614e810a0295785b929299158b052b3060c5460374be10f77 -size 643 diff --git a/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Reorient-Cube-Shadow-Camera/default_physics-default_renderer-depth.png b/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Reorient-Cube-Shadow-Camera/default_physics-default_renderer-depth.png deleted file mode 100644 index c229b583dfb7..000000000000 --- a/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Reorient-Cube-Shadow-Camera/default_physics-default_renderer-depth.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a37f6bca30bb2d093eb68186c601551d52aafe8ed19c6c090de149b3210d81a5 -size 3665 diff --git a/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Reorient-Cube-Shadow-Camera/default_physics-default_renderer-rgb.png b/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Reorient-Cube-Shadow-Camera/default_physics-default_renderer-rgb.png deleted file mode 100644 index eace991f49eb..000000000000 --- a/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Reorient-Cube-Shadow-Camera/default_physics-default_renderer-rgb.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b60263834743f0508a437281d36bd2f46296789b28ad930627fb72e406ab8700 -size 19962 diff --git a/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Reorient-Cube-Shadow-Camera/default_physics-default_renderer-rgba.png b/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Reorient-Cube-Shadow-Camera/default_physics-default_renderer-rgba.png deleted file mode 100644 index 7d6fa735693d..000000000000 --- a/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Reorient-Cube-Shadow-Camera/default_physics-default_renderer-rgba.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:8da49db87ab2afe4f2a32c9354a72fd1b5a32aaf76484a21f787b01c0a47197a -size 22127 diff --git a/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Reorient-Cube-Shadow-Camera/default_physics-default_renderer-semantic_segmentation.png b/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Reorient-Cube-Shadow-Camera/default_physics-default_renderer-semantic_segmentation.png deleted file mode 100644 index 4bad29d72ce6..000000000000 --- a/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Reorient-Cube-Shadow-Camera/default_physics-default_renderer-semantic_segmentation.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:75576f31118081f96b0cec2151ada7016794a933a035ea35665a752e8552b503 -size 1474 diff --git a/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Repose-Cube-Shadow-Vision-Direct-v0/default_physics-default_renderer-depth.png b/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Repose-Cube-Shadow-Vision-Direct-v0/default_physics-default_renderer-depth.png deleted file mode 100644 index df186c373970..000000000000 --- a/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Repose-Cube-Shadow-Vision-Direct-v0/default_physics-default_renderer-depth.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:95d738c3d959f788f8e1b74a37dda30f19de9f7875f25ed8dd77961edc01c704 -size 3660 diff --git a/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Repose-Cube-Shadow-Vision-Direct-v0/default_physics-default_renderer-rgb.png b/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Repose-Cube-Shadow-Vision-Direct-v0/default_physics-default_renderer-rgb.png deleted file mode 100644 index 1197faa87f8d..000000000000 --- a/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Repose-Cube-Shadow-Vision-Direct-v0/default_physics-default_renderer-rgb.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f906dc6e5107e6b768baf25f31f79f573cc352bcf0c5865134177adceca6d801 -size 20714 diff --git a/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Repose-Cube-Shadow-Vision-Direct-v0/default_physics-default_renderer-rgba.png b/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Repose-Cube-Shadow-Vision-Direct-v0/default_physics-default_renderer-rgba.png deleted file mode 100644 index 96989870a197..000000000000 --- a/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Repose-Cube-Shadow-Vision-Direct-v0/default_physics-default_renderer-rgba.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:eacd38c075e656cce6638ded4a386c01a1190042b7021c08bd8421bc0b31f90e -size 23058 diff --git a/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Repose-Cube-Shadow-Vision-Direct-v0/default_physics-default_renderer-semantic_segmentation.png b/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Repose-Cube-Shadow-Vision-Direct-v0/default_physics-default_renderer-semantic_segmentation.png deleted file mode 100644 index 4bad29d72ce6..000000000000 --- a/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Repose-Cube-Shadow-Vision-Direct-v0/default_physics-default_renderer-semantic_segmentation.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:75576f31118081f96b0cec2151ada7016794a933a035ea35665a752e8552b503 -size 1474 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png deleted file mode 100755 index 87053af66577..000000000000 --- a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f6b16d1bcecee60dd7a392f251dcf1566a7d778109ea3acf2b1cf856c5d92c6c -size 6750 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png deleted file mode 100644 index abedfaafd399..000000000000 --- a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:592ef63c67bc62362845c7654349e12336daa8dc221a1e22098d0a3a777dce87 -size 5934 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-isaacsim_rtx_renderer-simple_shading_full_mdl.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-isaacsim_rtx_renderer-simple_shading_full_mdl.png deleted file mode 100644 index 3224dcd631c5..000000000000 --- a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-isaacsim_rtx_renderer-simple_shading_full_mdl.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c65fb6d616f6d893b209f511313d09377f33c92f2ee4cb6de0b8872324e9372f -size 7284 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-simple_shading_constant_diffuse.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-simple_shading_constant_diffuse.png deleted file mode 100644 index 5fedae74248b..000000000000 --- a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-simple_shading_constant_diffuse.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:17e0be210fe458967bf7c89579ad8c95c42fa496d80336263a763398b11a3703 -size 6767 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png deleted file mode 100644 index db337919961b..000000000000 --- a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f60f90f91ce2b4d0f83abc25e747924997eebc3d4790bcd4bb29f4fd348b0ed2 -size 6351 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-simple_shading_full_mdl.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-simple_shading_full_mdl.png deleted file mode 100644 index 71b99d020848..000000000000 --- a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-simple_shading_full_mdl.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:564eafa2469ebb6fb09255b3d61275d8ace352d6530ab93a254bc7e2817c0bdf -size 7611 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand_yellow_bg/newton-ovrtx-rgb.png b/source/isaaclab_tasks/test/golden_images/shadow_hand_yellow_bg/newton-ovrtx-rgb.png deleted file mode 100644 index b0d901601f28..000000000000 --- a/source/isaaclab_tasks/test/golden_images/shadow_hand_yellow_bg/newton-ovrtx-rgb.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:45149f1f0315a39724e33ae9c0533776686aea69e7b221de341ee370418a4535 -size 34067 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand_yellow_bg/newton-ovrtx-rgba.png b/source/isaaclab_tasks/test/golden_images/shadow_hand_yellow_bg/newton-ovrtx-rgba.png deleted file mode 100644 index 66159c7a51fb..000000000000 --- a/source/isaaclab_tasks/test/golden_images/shadow_hand_yellow_bg/newton-ovrtx-rgba.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:4cc4e6774fdbec8e48676d7a7ce9ef7fb8c23036358c5adcee5ebffe18052ce3 -size 37608 diff --git a/source/isaaclab_tasks/test/rendering_test_utils.py b/source/isaaclab_tasks/test/rendering_test_utils.py index fff5230881b6..e74feb14b1bc 100644 --- a/source/isaaclab_tasks/test/rendering_test_utils.py +++ b/source/isaaclab_tasks/test/rendering_test_utils.py @@ -246,37 +246,6 @@ def _aov_capture_group(data_type: str) -> str: _KITLESS_STAGE_VARIANTS = ("legacy", "ovstage") -def make_xfail_rendering_params( - params: list[pytest.param], - expected_failures: dict[tuple[str, ...], str], -) -> list[pytest.param]: - """Mark selected rendering parameter combinations as expected failures. - - Args: - params: Rendering parameters containing physics backend, renderer, and data type values. - expected_failures: Mapping from parameter value tuples to expected-failure reasons. - - Returns: - Rendering parameters with non-strict ``xfail`` marks applied to matching combinations. - """ - marked_params = [] - for param in params: - reason = expected_failures.get(tuple(param.values)) - if reason is None: - marked_params.append(param) - continue - # Expected failures should run once and carry one unambiguous reason. - marks = [mark for mark in param.marks if mark.name not in ("flaky", "xfail")] - marked_params.append( - pytest.param( - *param.values, - id=param.id, - marks=[*marks, pytest.mark.xfail(reason=reason, strict=False)], - ) - ) - return marked_params - - def make_skip_rendering_params( params: list[pytest.param], expected_skips: dict[tuple[str, ...], str], @@ -416,9 +385,17 @@ def _make_sensor_data_type_params( ] +# RTX Minimal mode (the ``simple_shading_*`` outputs) is a per-render-product renderer setting that does not +# depend on the physics backend, so it is rendered with one physics backend per RTX renderer and only in the +# Cartpole and Shadow Hand scenes. Cartpole's flat materials look the same under every shading level, so the +# textured Shadow Hand scene is the golden that tells the MDL levels apart. Other scenes cover the remaining +# data types. +_NON_MINIMAL_SENSOR_DATA_TYPES = (*_STATIC_SENSOR_DATA_TYPES, *_TEMPORAL_SENSOR_DATA_TYPES) +_OVRTX_NON_MINIMAL_DATA_TYPES = tuple(dt for dt in _OVRTX_DATA_TYPES if dt not in _MINIMAL_SENSOR_DATA_TYPES) + PHYSICS_RENDERER_AOV_COMBINATIONS = [ - *_make_sensor_data_type_params("physx", "isaacsim_rtx"), - *_make_sensor_data_type_params("newton", "isaacsim_rtx"), + *_make_sensor_data_type_params("physx", "isaacsim_rtx", _NON_MINIMAL_SENSOR_DATA_TYPES), + *_make_sensor_data_type_params("newton", "isaacsim_rtx", _NON_MINIMAL_SENSOR_DATA_TYPES), *_make_sensor_data_type_params( "physx", "newton", _NEWTON_WARP_DATA_TYPES, flaky=False, renderer_label="newton_warp" ), @@ -426,18 +403,25 @@ def _make_sensor_data_type_params( PHYSICS_RENDERER_AOV_GROUPS = group_rendering_params(PHYSICS_RENDERER_AOV_COMBINATIONS) +MINIMAL_PHYSICS_RENDERER_AOV_GROUPS = group_rendering_params( + [ + *PHYSICS_RENDERER_AOV_COMBINATIONS, + *_make_sensor_data_type_params("physx", "isaacsim_rtx", _MINIMAL_SENSOR_DATA_TYPES), + ] +) + # MPM particles are simulated only by Newton's coupled MPM solver, so there is no PhysX or OVPhysX arm. # Only the RTX arm is covered: it draws the ``UsdGeom.Points`` clouds on the USD stage, whereas the Warp # rasterizer draws particles straight from Newton state as synthetic hits, which is a separate code path. MPM_PARTICLE_AOV_COMBINATIONS = [ - *_make_sensor_data_type_params("newton", "isaacsim_rtx"), + *_make_sensor_data_type_params("newton", "isaacsim_rtx", _NON_MINIMAL_SENSOR_DATA_TYPES), ] MPM_PARTICLE_AOV_GROUPS = group_rendering_params(MPM_PARTICLE_AOV_COMBINATIONS) KITLESS_PHYSICS_RENDERER_AOV_COMBINATIONS = [ - *_make_sensor_data_type_params("ovphysx", "ovrtx", _OVRTX_DATA_TYPES), - *_make_sensor_data_type_params("newton", "ovrtx", _OVRTX_DATA_TYPES), + *_make_sensor_data_type_params("ovphysx", "ovrtx", _OVRTX_NON_MINIMAL_DATA_TYPES), + *_make_sensor_data_type_params("newton", "ovrtx", _OVRTX_NON_MINIMAL_DATA_TYPES), *_make_sensor_data_type_params( "ovphysx", "newton", _NEWTON_WARP_DATA_TYPES, flaky=False, renderer_label="newton_warp" ), @@ -446,6 +430,11 @@ def _make_sensor_data_type_params( ), ] +MINIMAL_KITLESS_PHYSICS_RENDERER_AOV_COMBINATIONS = [ + *KITLESS_PHYSICS_RENDERER_AOV_COMBINATIONS, + *_make_sensor_data_type_params("ovphysx", "ovrtx", _MINIMAL_SENSOR_DATA_TYPES), +] + _VISUAL_MATERIAL_KITLESS_COMBINATIONS = [ pytest.param("newton", "ovrtx_renderer", id="newton-ovrtx", marks=_FLAKY_MARK), pytest.param("newton", "newton_renderer", id="newton-newton_warp"), @@ -458,8 +447,13 @@ def make_kitless_rendering_params_lift() -> list[pytest.param]: def make_kitless_rendering_params_franka() -> list[pytest.param]: - """Create kitless Franka rendering parameters.""" - params = make_kitless_rendering_params(KITLESS_PHYSICS_RENDERER_AOV_COMBINATIONS) + """Create kitless Franka rendering parameters. + + The Franka deformable (cloth, soft, cable) configs declare no OVPhysX preset, so OVPhysX is left out. + """ + params = make_kitless_rendering_params( + [param for param in KITLESS_PHYSICS_RENDERER_AOV_COMBINATIONS if param.values[0] != "ovphysx"] + ) params = [ ( pytest.param( @@ -2270,16 +2264,9 @@ def rendering_test_franka_soft( data_types: list[str], comparison_scores: list[dict], ) -> None: - if physics_backend == "physx" or renderer == "isaacsim_rtx_renderer": - pytest.skip("Random teardown hangs in the kit-based combinations (OMPE-101977).") - if renderer == "ovrtx_renderer" and "instance_segmentation" in data_types: pytest.skip("instance_segmentation crashes with the OVRTX renderer on franka_soft (NVBUG#6463802).") - # Native hang: the per-file CI runner kills the suite after 1000s with no pytest outcome. - if physics_backend == "ovphysx" and renderer == "ovrtx_renderer" and "depth" in data_types: - pytest.skip("OVPhysX + OVRTX depth hangs intermittently on franka_soft kitless CI (NVBUG#6564917).") - for data_type in data_types: _skip_if_newton_motion_vectors(physics_backend, data_type) diff --git a/source/isaaclab_tasks/test/test_curriculum.py b/source/isaaclab_tasks/test/test_curriculum.py index 89198a7d9226..15c3c52022ff 100644 --- a/source/isaaclab_tasks/test/test_curriculum.py +++ b/source/isaaclab_tasks/test/test_curriculum.py @@ -28,16 +28,15 @@ def test_uniform_only_sampler_is_uniform(): def test_sampler_probabilities_are_finite_nonnegative_and_normalized(): rates = torch.rand(50) - sampler = Sampler( - SamplerCfg( - strategies=[ - BetaSamplingStrategyCfg(target=0.66, kappa=1.0, weight=1.0), - UniformSamplingStrategyCfg(weight=0.1), - ], - eps=1e-3, - ), - rates, + cfg = SamplerCfg( + strategies=[ + BetaSamplingStrategyCfg(target=0.66, kappa=1.0, weight=1.0), + UniformSamplingStrategyCfg(weight=0.1), + ], + eps=1e-3, ) + sampler = cfg.class_type(cfg, rates) + assert sampler.names == ["beta", "uniform"] probs = sampler.probabilities() assert torch.isfinite(probs).all() assert (probs >= 0).all() @@ -47,19 +46,12 @@ def test_sampler_probabilities_are_finite_nonnegative_and_normalized(): def test_sampler_clamps_negative_strategy_weight(): """A negative strategy weight contributes no probability mass.""" rates = torch.rand(50) - positive = Sampler(SamplerCfg(strategies=[UniformSamplingStrategyCfg(weight=1.0)], eps=1e-6), rates).probabilities() - negative = Sampler( - SamplerCfg(strategies=[UniformSamplingStrategyCfg(weight=-1.0)], eps=1e-6), rates + beta = BetaSamplingStrategyCfg(target=0.66, kappa=1.0, weight=1.0) + beta_only = Sampler(SamplerCfg(strategies=[beta], eps=1e-6), rates).probabilities() + with_negative = Sampler( + SamplerCfg(strategies=[beta, UniformSamplingStrategyCfg(weight=-1.0)], eps=1e-6), rates ).probabilities() - torch.testing.assert_close(positive, negative) - - -def test_sampler_cfg_builds_runtime_sampler(): - rates = torch.rand(50) - cfg = SamplerCfg(strategies=[BetaSamplingStrategyCfg(target=0.5, kappa=2.0)]) - sampler = cfg.class_type(cfg, rates) - assert isinstance(sampler, Sampler) - assert sampler.names == ["beta"] + torch.testing.assert_close(with_negative, beta_only) def test_difficulty_scheduler_waits_for_accumulator_rates(): diff --git a/source/isaaclab_tasks/test/test_maybe_save_stage_golden.py b/source/isaaclab_tasks/test/test_maybe_save_stage_golden.py index 39764f287e48..559b0d75e93e 100644 --- a/source/isaaclab_tasks/test/test_maybe_save_stage_golden.py +++ b/source/isaaclab_tasks/test/test_maybe_save_stage_golden.py @@ -113,20 +113,6 @@ def test_maybe_save_stage_tolerates_small_transform_noise(golden_stage_dir: Path maybe_save_stage("cartpole", "physx", "isaacsim_rtx_renderer", "rgb", compare_golden=True) -def test_maybe_save_stage_fails_on_added_prim(golden_stage_dir: Path): - """maybe_save_stage fails when the exported stage adds a prim (structure change).""" - exported = {"text": _usda_with_robot((1.0, 2.0, 3.0))} - with mock.patch("isaaclab.sim.save_stage", return_value=True) as save_stage_mock: - save_stage_mock.side_effect = _mock_export(exported) - - with pytest.raises(pytest.fail.Exception, match="Golden stage not found"): - maybe_save_stage("cartpole", "physx", "isaacsim_rtx_renderer", "rgb", compare_golden=True) - - exported["text"] = _usda_with_robot((1.0, 2.0, 3.0), extra_prim=True) - with pytest.raises(pytest.fail.Exception, match="USD stage mismatch"): - maybe_save_stage("cartpole", "physx", "isaacsim_rtx_renderer", "rgb", compare_golden=True) - - def test_compare_golden_stage_reports_structure_and_transform_diffs(tmp_path: Path): """compare_golden_stage returns no problems when equal and flags added prims and moved transforms.""" golden = tmp_path / "golden.usda" @@ -197,16 +183,6 @@ def test_maybe_save_stage_noop_without_dump_or_compare(monkeypatch: pytest.Monke # --------------------------------------------------------------------------- -def test_maybe_save_stage_writes_to_save_stages_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - """maybe_save_stage writes a USDA file to ISAAC_LAB_SAVE_STAGES with the expected filename.""" - out_dir = tmp_path / "stages" - monkeypatch.setenv("ISAAC_LAB_SAVE_STAGES", str(out_dir)) - with mock.patch("isaaclab.sim.save_stage") as save_stage_mock: - save_stage_mock.side_effect = _mock_export(_usda_with_robot((1.0, 2.0, 3.0))) - maybe_save_stage("mytest", "physx", "rtx", "rgb") - assert (out_dir / "mytest-physx-rtx-rgb.usda").exists() - - def test_maybe_save_stage_save_stages_safe_test_name(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): """Slashes in test_name are replaced with underscores in the ISAAC_LAB_SAVE_STAGES filename.""" out_dir = tmp_path / "stages" diff --git a/source/isaaclab_tasks/test/test_parametrization_helpers.py b/source/isaaclab_tasks/test/test_parametrization_helpers.py index bf862e0e610d..9ed53c39ad73 100644 --- a/source/isaaclab_tasks/test/test_parametrization_helpers.py +++ b/source/isaaclab_tasks/test/test_parametrization_helpers.py @@ -11,20 +11,16 @@ import pytest import rendering_test_utils from rendering_test_utils import ( - KITLESS_PHYSICS_RENDERER_AOV_COMBINATIONS, attach_comparison_properties, generate_html_report, group_rendering_params, make_kitless_rendering_params, - make_kitless_rendering_params_franka, - make_kitless_rendering_params_lift, make_skip_rendering_params, - make_xfail_rendering_params, ) def test_group_rendering_params_groups_static_data_types_with_matching_marks() -> None: - """Static AOVs with the same rendering configuration and marks should share a case.""" + """Static AOVs with the same rendering configuration and marks should share a case, once per renderer.""" flaky = pytest.mark.flaky(max_runs=3, min_passes=1) params = [ pytest.param("physx", "isaacsim_rtx_renderer", "albedo", id="physx-rtx-albedo", marks=flaky), @@ -36,6 +32,8 @@ def test_group_rendering_params_groups_static_data_types_with_matching_marks() - id="physx-rtx-instance", marks=pytest.mark.xfail(reason="Known segmentation regression."), ), + pytest.param("physx", "newton_renderer", "rgb", id="physx-warp-rgb"), + pytest.param("physx", "newton_renderer", "depth", id="physx-warp-depth"), ] grouped = group_rendering_params(params) @@ -43,9 +41,14 @@ def test_group_rendering_params_groups_static_data_types_with_matching_marks() - assert [tuple(param.values) for param in grouped] == [ ("physx", "isaacsim_rtx_renderer", ["albedo", "normals"]), ("physx", "isaacsim_rtx_renderer", ["instance_segmentation"]), + ("physx", "newton_renderer", ["rgb", "depth"]), ] - assert [param.id for param in grouped] == ["physx-isaacsim_rtx_renderer-static", "physx-rtx-instance"] - assert [[mark.name for mark in param.marks] for param in grouped] == [["flaky"], ["xfail"]] + assert [param.id for param in grouped] == [ + "physx-isaacsim_rtx_renderer-static", + "physx-rtx-instance", + "physx-newton_renderer-static", + ] + assert [[mark.name for mark in param.marks] for param in grouped] == [["flaky"], ["xfail"], []] def test_group_rendering_params_isolates_temporal_and_minimal_data_types() -> None: @@ -84,24 +87,6 @@ def test_group_rendering_params_isolates_temporal_and_minimal_data_types() -> No ] -def test_group_rendering_params_groups_each_renderer() -> None: - """Every renderer should launch once with all of its supported AOVs.""" - params = [ - pytest.param("newton", "ovrtx_renderer", "albedo", id="newton-ovrtx-albedo"), - pytest.param("newton", "ovrtx_renderer", "normals", id="newton-ovrtx-normals"), - pytest.param("newton", "newton_renderer", "rgb", id="newton-warp-rgb"), - pytest.param("newton", "newton_renderer", "depth", id="newton-warp-depth"), - pytest.param("newton", "newton_renderer", "normals", id="newton-warp-normals"), - ] - - grouped = group_rendering_params(params) - - assert [tuple(param.values) for param in grouped] == [ - ("newton", "ovrtx_renderer", ["albedo", "normals"]), - ("newton", "newton_renderer", ["rgb", "depth", "normals"]), - ] - - @pytest.mark.parametrize( ("env_name", "renderer", "data_type", "expected"), [ @@ -118,24 +103,6 @@ def test_ovrtx_image_difference_threshold_is_capped( assert rendering_test_utils._max_different_pixels_percentage(env_name, renderer, data_type) == expected -@pytest.mark.parametrize( - ("renderer", "expected_steps"), - [ - ("ovrtx_renderer", 3), - ("isaacsim_rtx_renderer", 2), - ], -) -def test_motion_history_steps(renderer: str, expected_steps: int) -> None: - """OVRTX should receive one extra motion-history step.""" - env = Mock() - env.action_space.shape = (1,) - env.device = "cpu" - - rendering_test_utils.maybe_step_env_for_motion(env, renderer, "motion_vectors") - - assert env.step.call_count == expected_steps - - def test_make_kitless_rendering_params_expands_only_ovrtx() -> None: """OVStage variants should be emitted only for the OVRTX renderer.""" params = [ @@ -157,33 +124,6 @@ def test_make_kitless_rendering_params_expands_only_ovrtx() -> None: ] -def test_make_xfail_rendering_params_replaces_flaky_and_xfail_marks() -> None: - """Expected failures should run once with one current reason.""" - params = [ - pytest.param( - "ovstage", - "newton", - "ovrtx_renderer", - "albedo", - id="ovstage-newton-ovrtx-albedo", - marks=[ - pytest.mark.flaky(max_runs=3, min_passes=1), - pytest.mark.xfail(reason="Obsolete rendering regression.", strict=False), - ], - ) - ] - - marked = make_xfail_rendering_params( - params, - {("ovstage", "newton", "ovrtx_renderer", "albedo"): "Known rendering regression."}, - ) - - assert [mark.name for mark in marked[0].marks] == ["xfail"] - xfail_mark = marked[0].marks[0] - assert xfail_mark.kwargs["reason"] == "Known rendering regression." - assert xfail_mark.kwargs["strict"] is False - - def test_make_skip_rendering_params_overrides_xfail_and_flaky_marks() -> None: """Native-crash skips should override inherited retry and xfail marks.""" params = [ @@ -209,64 +149,6 @@ def test_make_skip_rendering_params_overrides_xfail_and_flaky_marks() -> None: assert marked[0].marks[0].kwargs["reason"] == "Native renderer crash." -def test_kitless_matrix_has_no_ovrtx_041_xfails() -> None: - """OVRTX 0.4.1 textured and motion AOVs should run without release xfails.""" - params = {param.id: param for param in KITLESS_PHYSICS_RENDERER_AOV_COMBINATIONS} - - for data_type in ("albedo", "simple_shading_diffuse_mdl", "simple_shading_full_mdl"): - for physics_backend in ("newton", "ovphysx"): - param = params[f"{physics_backend}-ovrtx-{data_type}"] - assert "xfail" not in [mark.name for mark in param.marks] - - expanded = {param.id: param for param in make_kitless_rendering_params(list(params.values()))} - assert "xfail" not in [mark.name for mark in expanded["ovstage-ovphysx-ovrtx-motion_vectors"].marks] - - -def test_lift_factory_retains_retries_without_native_crash_skips() -> None: - """Lift OVRTX MDL cases should run with the shared retry policy.""" - params = {param.id: param for param in make_kitless_rendering_params_lift()} - - for variant in ("legacy", "ovstage"): - for physics_backend in ("newton", "ovphysx"): - for data_type in ("simple_shading_diffuse_mdl", "simple_shading_full_mdl"): - param = params[f"{variant}-{physics_backend}-ovrtx-{data_type}"] - assert [mark.name for mark in param.marks] == ["flaky"] - - # Lift OVPhysX albedo passes, so it must not inherit an unrelated exemption. - assert "xfail" not in [mark.name for mark in params["legacy-ovphysx-ovrtx-albedo"].marks] - - -def test_franka_factory_marks_only_unsupported_instance_segmentation() -> None: - """Franka OVRTX grouping should isolate unsupported instance segmentation from valid AOVs.""" - params = {param.id: param for param in make_kitless_rendering_params_franka()} - - for variant in ("legacy", "ovstage"): - for physics_backend in ("newton", "ovphysx"): - motion_id = f"{variant}-{physics_backend}-ovrtx-motion_vectors" - assert [mark.name for mark in params[motion_id].marks] == ["flaky"] - - instance_id = f"{variant}-{physics_backend}-ovrtx-instance_segmentation" - assert [mark.name for mark in params[instance_id].marks] == ["skip"] - - grouped = {param.id: param for param in group_rendering_params(list(params.values()))} - for variant in ("legacy", "ovstage"): - valid_static = grouped[f"{variant}-newton-ovrtx_renderer-static"] - assert valid_static.values[-1] == [ - "rgb", - "albedo", - "semantic_segmentation", - "depth", - "distance_to_camera", - "distance_to_image_plane", - "normals", - ] - assert [mark.name for mark in valid_static.marks] == ["flaky"] - - unsupported = grouped[f"{variant}-newton-ovrtx-instance_segmentation"] - assert unsupported.values[-1] == ["instance_segmentation"] - assert [mark.name for mark in unsupported.marks] == ["skip"] - - def test_html_report_labels_xfail_and_xpass_outcomes(monkeypatch, tmp_path: Path) -> None: """Expected failures and unexpected passes should be distinct in HTML.""" reason = "Known rendering regression (NVBUG#1234567)." diff --git a/source/isaaclab_tasks/test/test_signals.py b/source/isaaclab_tasks/test/test_signals.py index 5e42d513c4b5..4637d6766a2f 100644 --- a/source/isaaclab_tasks/test/test_signals.py +++ b/source/isaaclab_tasks/test/test_signals.py @@ -18,33 +18,17 @@ def _score(cfg, rates: torch.Tensor) -> torch.Tensor: def test_beta_peaks_at_target(): - """Beta score maximum is at the configured success-rate target.""" + """Beta score maximum is at the configured success-rate target, and scores are non-negative over [0, 1].""" rates = torch.linspace(0.0, 1.0, 21) scores = _score(BetaSamplingStrategyCfg(target=0.5, kappa=4.0), rates) assert int(scores.argmax()) == 10 - - -def test_beta_uniform_input_returns_uniform_output(): - """Equal success rates produce equal Beta scores.""" - rates = torch.full((100,), 0.42) - scores = _score(BetaSamplingStrategyCfg(target=0.66, kappa=1.0), rates) - assert float(scores.std()) < 1e-7 - - -def test_beta_score_is_non_negative(): - """Beta scores are non-negative over the valid success-rate range.""" - scores = _score(BetaSamplingStrategyCfg(target=0.66, kappa=1.0), torch.rand(100)) assert (scores >= 0).all() def test_uniform_returns_ones(): - """Uniform scoring is independent of success rates.""" - rates = torch.rand(100) - assert torch.equal(_score(UniformSamplingStrategyCfg(), rates), torch.ones_like(rates)) - - -def test_uniform_preserves_dtype(): - """Uniform scoring writes into the caller-provided output dtype.""" + """Uniform scoring is independent of success rates and writes into the caller-provided output dtype.""" for dtype in (torch.float32, torch.float64): - rates = torch.rand(64, dtype=dtype) - assert _score(UniformSamplingStrategyCfg(), rates).dtype == dtype + rates = torch.rand(100, dtype=dtype) + scores = _score(UniformSamplingStrategyCfg(), rates) + assert scores.dtype == dtype + assert torch.equal(scores, torch.ones_like(rates)) From 49bd35fc265789f85283ded3909289b51c7f4b8a Mon Sep 17 00:00:00 2001 From: Mustafa H <34825877+StafaH@users.noreply.github.com> Date: Fri, 25 Sep 2026 02:10:32 -0700 Subject: [PATCH 09/10] [Tests] Update Newton tendon regression for explicit construction (#7985) # Description The fixed-tendon integration regression failed at `sim.reset()` after #8005 made Newton model construction explicit. It now calls `replicate(sim.get_clone_plan())` before reset, matching the other articulation tests. The original environment, event-manager, and tendon fixes from this PR were already merged through the stacked #7990. This update resolves the conflicts against current `develop`, preserves its newer tendon implementation and expanded regression, and leaves only the missing test setup call plus the required changelog marker. ## Validation - 21 checks passed: 6 articulation-event tests, 13 event-manager tests, the indexed-dictionary curriculum regression, and the GPU tendon regression. - The tendon regression passed with the pinned Newton 1.6.0 and MuJoCo Warp 3.12.0 on an RTX 5090. It covers stiffness, damping, position limits, and selected environment/tendon updates. - Before the setup call, the test failed because no Newton builder had been supplied. With the setup corrected, removing only `ModelFlags.TENDON_PROPERTIES` notification makes the test fail numerically: solver stiffness remains 0 instead of 12. - All formatting and pre-commit checks passed. ## Release backport - [x] Backport this pull request to the active release branch after it merges into `develop` --------- Co-authored-by: Octi Zhang --- .../changelog.d/tendon-regression-explicit-construction.skip | 1 + source/isaaclab_newton/test/assets/test_articulation.py | 1 + 2 files changed, 2 insertions(+) create mode 100644 source/isaaclab_newton/changelog.d/tendon-regression-explicit-construction.skip diff --git a/source/isaaclab_newton/changelog.d/tendon-regression-explicit-construction.skip b/source/isaaclab_newton/changelog.d/tendon-regression-explicit-construction.skip new file mode 100644 index 000000000000..8dc37aac9a4d --- /dev/null +++ b/source/isaaclab_newton/changelog.d/tendon-regression-explicit-construction.skip @@ -0,0 +1 @@ +Updated the fixed-tendon regression to construct the Newton model explicitly before starting simulation. diff --git a/source/isaaclab_newton/test/assets/test_articulation.py b/source/isaaclab_newton/test/assets/test_articulation.py index 02575521726c..d0731f63a222 100644 --- a/source/isaaclab_newton/test/assets/test_articulation.py +++ b/source/isaaclab_newton/test/assets/test_articulation.py @@ -1946,6 +1946,7 @@ def test_fixed_tendon_properties_reach_solver(sim, num_articulations, device, ar articulation, _ = generate_articulation( generate_articulation_cfg(articulation_type=articulation_type), num_articulations, device ) + replicate(sim.get_clone_plan()) sim.reset() shape = (num_articulations, articulation.num_fixed_tendons) limits = torch.tensor([-0.1, 0.2], device=device).expand(*shape, 2) From fbfd453d97c1880a468fd730f2bc7d6bfe6ecc05 Mon Sep 17 00:00:00 2001 From: Mustafa H <34825877+StafaH@users.noreply.github.com> Date: Fri, 25 Sep 2026 03:24:25 -0700 Subject: [PATCH 10/10] [Tests] Prune core isaaclab tests with the test-audit skill (#8022) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Core (`isaaclab`) test job: 32:18 → 22:40 (−30%) The core job (`pytest tools`, excluding `isaaclab_*` packages, as the three core CI shards run it) ran on the same machine with a warm Warp cache, once on `develop` and once on this PR. - **Timing fix.** In the worktree the two benchmark smoke files launched the system Python and failed fast. The pruned time is corrected with their 2:00 baseline duration; they are unchanged by this PR. - **Shared failures.** Six files fail on both sides only because of local tools and environment: video conversion, `nsys`, device constructors and visualizer markers. - **Fixed here.** The four asset interface files that fail on `develop` fail because of an OVPhysX fixture bug, which this PR fixes. | | `develop` | this PR | |---|---:|---:| | Core job wall time | 32:18 | **22:40** | | Test files | 312 | 289 | | Test functions | 3,069 | 2,229 | | Test lines | 97,369 | 81,275 | Changes outside test files: - `tools/test_settings.py` drops `TESTS_TO_SKIP` entries for five deleted files. - `docs/source/refs/contributing.rst` pointed at the deleted `deps/test_torch.py`; it now points at `utils/test_circular_buffer.py`. ## What changed Pruned with the `isaaclab-auditing-tests` skill (#8007), campaign mode. The work ran in seven lanes: - sim - assets - sensors - envs / managers / controllers / actuators - scene / cloner / renderers / markers - utils - CLI / app / benchmarks **Method.** Every test was marked keep, fix, merge or delete. Whenever a deletion's keeper does not carry the assertions verbatim, a deliberate production mutation proves the keeper fails. An independent review per lane restored every gap it found, each mutation-proven: - OSC variable-impedance masking - render interval in all three workflows - per-backend deprecated friction writer - CPU segmentation - ray-caster mesh cache - rendering-kit defaults - non-default `dt` - schema API application - joint drive type - file-log level - global torque reset - terrain origins - **Largest cuts:** - asset interface tests: mocked cases ~6,400 → ~1,050; about 114 getter tests became 4 table tests, with rows diffed mechanically - 10 OSC convergence sims, covered by the repaired unit-law test - manager tests moved off Kit - actuator and rendering cartesian products - SimulationContext 52 → 30 tests - **Tests that could not fail, now fixed:** - partial writes with a non-trivial joint/body ordering - alias equality compared by shape only - writer read-backs - schema validators that inspected only children - `test_reset` lifecycle checks - a camera test that compared a shared cache with itself - **Multi-GPU lane:** tests now use `DeviceScope.DEFAULT_CUDA` or `CPU_AND_DEFAULT_CUDA`, so no file enters the multi-GPU job unless it boots Kit with `resolve_test_sim_device()`. Merged with #7995. Its new renderer source-scan test was not carried over: the behavior it guards, SDP writing geometry into Fabric, is covered by `test_scene_data_geometry.py`. ## Product defects found (not fixed here) - When physics handles decimation, the render check only fires on exact multiples. For example, `render_interval=10` with `decimation=4` renders at half rate. This affects all four env classes. - PhysX-Fabric FrameView: after a USD parent move, whichever of the world/local reads comes second is stale. - The MJCF physics scene is written to payload layers, not under the default prim. - PhysX fixed-tendon position limits are `(N, T, 2)`, which contradicts the docstring and OVPhysX. - EventManager does not register class terms when created while the sim is already playing. - `FrameTransformer.__str__` swaps the frame count and names. - RigidObjectCollection `set_coms_index` raises KeyError on PhysX. ## Release backport - [x] Backport this pull request to the active release branch after it merges into `develop` ## Checklist - [x] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html) - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [x] My changes generate no new warnings - [x] I have added a changelog fragment under `source//changelog.d/` for every touched package - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Co-authored-by: Octi Zhang --- AGENTS.md | 5 + docs/source/refs/contributing.rst | 16 +- .../changelog.d/test-prune-core-tests.skip | 3 + .../actuators/test_actuator_collection.py | 18 +- .../isaaclab/test/actuators/test_dc_motor.py | 78 +- .../test/actuators/test_ideal_pd_actuator.py | 23 +- .../test/actuators/test_implicit_actuator.py | 69 +- .../test/app/test_app_launcher_argv.py | 744 ++++- .../test/app/test_argparser_launch.py | 90 - .../isaaclab/test/app/test_env_var_launch.py | 112 - source/isaaclab/test/app/test_kwarg_launch.py | 598 ---- .../isaaclab/test/app/test_loading_screen.py | 24 +- .../test/app/test_non_headless_launch.py | 18 +- .../assets/_articulation_iface_test_utils.py | 3 + ...igid_object_collection_iface_test_utils.py | 3 + .../assets/_rigid_object_iface_test_utils.py | 3 + .../test/assets/test_articulation_iface.py | 2538 ++++------------- .../test/assets/test_articulation_ordering.py | 245 +- .../test_articulation_ordering_iface.py | 135 +- .../test_articulation_ordering_kernels.py | 100 +- .../test/assets/test_asset_selector_cache.py | 14 - .../test/assets/test_iface_test_utils.py | 98 - .../test_rigid_object_collection_iface.py | 961 ++----- .../test/assets/test_rigid_object_iface.py | 947 ++---- source/isaaclab/test/benchmark/test_api.py | 140 +- .../benchmark/test_asset_script_layout.py | 22 - .../test/benchmark/test_asset_suite_cli.py | 55 +- .../benchmark/test_asset_suite_definitions.py | 66 +- .../benchmark/test_asset_suite_dispatch.py | 9 +- .../test_asset_suite_generator_semantics.py | 14 +- .../test_asset_suite_runtime_semantics.py | 119 +- .../test/benchmark/test_benchmark_core.py | 2 - .../isaaclab/test/benchmark/test_builders.py | 38 +- .../isaaclab/test/benchmark/test_capture.py | 97 +- .../test/benchmark/test_distributed.py | 2 - .../test/benchmark/test_method_benchmark.py | 36 +- .../isaaclab/test/benchmark/test_metrics.py | 37 +- source/isaaclab/test/benchmark/test_micro.py | 2 +- .../test/benchmark/test_multigpu_launcher.py | 7 +- .../test/benchmark/test_play_schema.py | 37 +- .../benchmark/test_ray_caster_workload.py | 10 +- .../isaaclab/test/benchmark/test_recorders.py | 436 +-- source/isaaclab/test/benchmark/test_schema.py | 52 +- .../benchmark/test_sensor_benchmark_cli.py | 38 - .../isaaclab/test/benchmark/test_stepping.py | 50 +- .../benchmark/test_train_checkpoint_path.py | 11 - .../test/cli/test_benchmark_entrypoint.py | 36 - source/isaaclab/test/cli/test_install.py | 29 - .../test/cli/test_install_command_parsing.py | 261 +- .../test/cli/test_install_commands.py | 248 +- .../test/cli/test_install_prebundle.py | 87 +- .../test_installed_workflow_entrypoints.py | 106 +- .../test/cli/test_leapp_entrypoint.py | 57 - .../cli/test_microbenchmark_entrypoint.py | 24 +- .../isaaclab/test/cli/test_misc_commands.py | 2 +- source/isaaclab/test/cli/test_ovrtx_log.py | 115 +- .../test/cli/test_source_package_metadata.py | 49 +- .../test_test_orchestrator_result_handling.py | 57 +- .../test_train_multigpu_command_building.py | 51 - .../test/cli/test_uv_run_pyproject.py | 13 +- .../test/cli/test_wheel_builder_metadata.py | 88 +- .../test/cloner/test_clone_plan_algebra.py | 110 +- .../test/cloner/test_replicate_session.py | 16 +- .../cloner/test_usd_replicate_ancestors.py | 29 +- .../test_usd_replicate_relationships.py | 13 - .../test/controllers/test_controller_utils.py | 480 +--- .../test/controllers/test_differential_ik.py | 13 +- .../pink_ik_g1_test_configs.json | 13 - .../pink_ik_gr1_test_configs.json | 13 - .../test/controllers/test_joint_impedance.py | 22 +- .../test/controllers/test_local_frame_task.py | 179 +- .../test_null_space_posture_task.py | 96 +- .../controllers/test_operational_space.py | 1090 ++----- .../isaaclab/test/controllers/test_pink_ik.py | 19 - .../controllers/test_pink_ik_components.py | 106 +- .../test/deps/isaacsim/check_camera.py | 19 - .../check_floating_base_made_fixed.py | 19 - .../deps/isaacsim/check_legged_robot_clone.py | 19 - .../test/deps/isaacsim/check_ref_count.py | 155 - .../isaacsim/check_rep_texture_randomizer.py | 19 - source/isaaclab/test/deps/test_scipy.py | 64 - source/isaaclab/test/deps/test_torch.py | 160 -- .../test/devices/test_device_constructors.py | 235 +- ...eck_manager_based_env_anymal_locomotion.py | 254 -- .../check_manager_based_env_floating_cube.py | 267 -- .../envs/test_action_state_recorder_term.py | 34 +- ...on_state_recorder_term_task_integration.py | 8 +- .../envs/test_diffik_jacobian_aliasing.py | 59 +- .../test/envs/test_direct_marl_env.py | 7 +- .../test/envs/test_env_rendering_logic.py | 176 +- .../test/envs/test_manager_based_env.py | 45 +- ...ased_rl_env_obs_spaces_task_integration.py | 69 +- source/isaaclab/test/envs/test_marl_utils.py | 19 +- .../envs/test_modify_env_param_curr_term.py | 4 +- .../test/envs/test_null_command_term.py | 4 +- .../test/envs/test_scale_randomization.py | 20 +- .../test/envs/test_stacked_image_mdp.py | 21 +- .../envs/test_stacked_image_obs_manager.py | 23 +- .../test/envs/test_texture_randomization.py | 58 +- .../isaaclab/test/envs/test_video_recorder.py | 17 - .../test/envs/test_viewer_cfg_deprecation.py | 35 +- .../test/envs/test_visual_material_events.py | 5 - .../test/managers/test_event_manager.py | 48 +- .../test/managers/test_manager_base.py | 110 +- .../test/managers/test_observation_manager.py | 386 +-- .../managers/test_observation_manager_unit.py | 205 -- .../test/managers/test_recorder_manager.py | 70 +- .../test_recorder_manager_integration.py | 7 +- .../test/managers/test_reward_manager.py | 44 +- .../test/managers/test_termination_manager.py | 46 +- .../markers/test_visualization_markers.py | 139 +- .../test_robot_load_performance.py | 11 +- .../renderers/test_camera_output_contract.py | 119 +- .../test_renderer_cfg_construction.py | 8 +- ..._object_rendering_contract_architecture.py | 84 - .../renderers/test_segmentation_colors.py | 7 - .../test_visual_material_pipeline.py | 82 +- .../test/scene/test_interactive_scene.py | 64 +- .../test/scene/test_visual_material_scene.py | 2 + .../scene_data/test_deformable_discovery.py | 33 +- .../scene_data/test_scene_data_geometry.py | 22 + .../scene_data/test_scene_data_transforms.py | 15 +- .../isaaclab/test/sensors/check_ray_caster.py | 2 +- source/isaaclab/test/sensors/test_camera.py | 441 +-- .../test_camera_opencv_distortion_ovrtx.py | 61 +- .../test_camera_pose_write_across_backends.py | 24 +- .../sensors/test_camera_ppisp_gaussian.py | 40 +- .../test_camera_ppisp_gaussian_newton.py | 25 - .../test_camera_ppisp_gaussian_ovrtx.py | 31 +- .../sensors/test_multi_mesh_ray_caster.py | 19 +- .../test_multi_mesh_ray_caster_camera.py | 328 +-- .../test/sensors/test_multi_tiled_camera.py | 94 +- .../test/sensors/test_opencv_distortion.py | 69 +- .../test/sensors/test_outdated_sensor.py | 13 +- .../isaaclab/test/sensors/test_ray_caster.py | 524 ---- .../test/sensors/test_ray_caster_camera.py | 410 +-- .../sensors/test_ray_caster_integration.py | 51 +- .../test/sensors/test_ray_caster_kernels.py | 107 +- .../test/sensors/test_ray_caster_patterns.py | 124 +- .../test/sensors/test_ray_caster_sensor.py | 232 +- .../isaaclab/test/sensors/test_sensor_base.py | 40 +- .../test/sensors/test_tiled_camera.py | 67 +- .../test/sensors/test_tiled_camera_env.py | 142 - .../sensors/test_update_ray_caster_kernel.py | 18 +- source/isaaclab/test/sim/check_meshes.py | 168 -- .../test/sim/frame_view_contract_utils.py | 180 +- .../test/sim/test_articulation_fragments.py | 155 +- .../test_build_simulation_context_headless.py | 25 +- ...st_build_simulation_context_nonheadless.py | 104 - source/isaaclab/test/sim/test_cloner.py | 40 +- .../test/sim/test_collision_fragments.py | 165 +- .../test/sim/test_joint_drive_fragments.py | 154 +- .../isaaclab/test/sim/test_mass_fragments.py | 132 +- .../test/sim/test_material_fragments.py | 123 +- .../test/sim/test_mesh_collision_fragments.py | 271 +- .../isaaclab/test/sim/test_mesh_converter.py | 98 +- .../isaaclab/test/sim/test_mjcf_converter.py | 50 +- ...test_newton_manager_visualization_state.py | 27 - .../test/sim/test_newton_model_utils.py | 92 - .../sim/test_physics_manager_lifecycle.py | 48 +- .../test/sim/test_rendering_kit_defaults.py | 12 +- .../test/sim/test_schema_fragment_parity.py | 18 +- .../test/sim/test_schema_fragments.py | 144 +- .../sim/test_schema_writer_nested_targets.py | 123 +- source/isaaclab/test/sim/test_schemas.py | 257 +- .../test/sim/test_schemas_deprecation.py | 16 +- source/isaaclab/test/sim/test_schemas_shim.py | 33 +- .../test/sim/test_simulation_context.py | 621 +--- .../test_simulation_context_visualizers.py | 35 +- .../test/sim/test_simulation_render_config.py | 8 +- .../test/sim/test_spawn_from_files.py | 124 +- source/isaaclab/test/sim/test_spawn_lights.py | 92 +- .../isaaclab/test/sim/test_spawn_materials.py | 30 - source/isaaclab/test/sim/test_spawn_meshes.py | 79 +- .../isaaclab/test/sim/test_spawn_sensors.py | 8 +- source/isaaclab/test/sim/test_spawn_shapes.py | 47 +- .../isaaclab/test/sim/test_spawn_wrappers.py | 58 +- .../isaaclab/test/sim/test_urdf_converter.py | 288 +- source/isaaclab/test/sim/test_utils_prims.py | 218 +- .../isaaclab/test/sim/test_utils_queries.py | 20 - .../isaaclab/test/sim/test_utils_semantics.py | 24 +- source/isaaclab/test/sim/test_utils_stage.py | 185 +- .../test/sim/test_utils_transforms.py | 345 +-- .../test/sim/test_views_xform_prim.py | 85 +- .../test/sim/test_visual_materials_kitless.py | 2 + .../test/terrains/test_terrain_generator.py | 32 +- .../test/terrains/test_terrain_importer.py | 203 +- .../test/test_scripts_torcharray_patterns.py | 75 +- .../test/utils/test_articulation_ordering.py | 66 +- source/isaaclab/test/utils/test_assets.py | 65 +- .../isaaclab/test/utils/test_backend_utils.py | 24 +- .../test/utils/test_circular_buffer.py | 62 +- .../isaaclab/test/utils/test_configclass.py | 278 +- .../isaaclab/test/utils/test_delay_buffer.py | 17 +- .../test/utils/test_device_selection.py | 52 +- source/isaaclab/test/utils/test_dict.py | 46 +- .../isaaclab/test/utils/test_episode_data.py | 10 +- .../utils/test_hdf5_dataset_file_handler.py | 3 +- source/isaaclab/test/utils/test_images.py | 49 +- .../utils/test_images_display_nonfinite.py | 30 - source/isaaclab/test/utils/test_logger.py | 434 +-- source/isaaclab/test/utils/test_math.py | 446 +-- source/isaaclab/test/utils/test_modifiers.py | 134 +- source/isaaclab/test/utils/test_noise.py | 23 +- source/isaaclab/test/utils/test_string.py | 6 - source/isaaclab/test/utils/test_timer.py | 209 +- source/isaaclab/test/utils/test_version.py | 99 +- .../test/utils/test_wrench_composer.py | 785 +---- .../utils/test_wrench_composer_integration.py | 210 +- .../utils/test_wrench_composer_vs_physx.py | 161 +- .../test/utils/warp/test_image_ops.py | 153 +- .../test/utils/warp/test_particle_mesh.py | 65 +- .../test/utils/warp/test_proxy_array.py | 173 +- .../test/visualizers/test_streaming_view.py | 118 +- .../test/visualizers/test_visualizer.py | 47 +- .../changelog.d/tendon-test-clone-plan.skip | 1 + .../benchmark-mock-optional-runtime.skip | 1 + .../fixtures/views/mock_ovphysx_bindings.py | 6 +- tools/test_settings.py | 5 - 219 files changed, 5235 insertions(+), 21283 deletions(-) create mode 100644 source/isaaclab/changelog.d/test-prune-core-tests.skip delete mode 100644 source/isaaclab/test/app/test_argparser_launch.py delete mode 100644 source/isaaclab/test/app/test_env_var_launch.py delete mode 100644 source/isaaclab/test/app/test_kwarg_launch.py delete mode 100644 source/isaaclab/test/assets/test_iface_test_utils.py delete mode 100644 source/isaaclab/test/benchmark/test_asset_script_layout.py delete mode 100644 source/isaaclab/test/benchmark/test_sensor_benchmark_cli.py delete mode 100644 source/isaaclab/test/cli/test_benchmark_entrypoint.py delete mode 100644 source/isaaclab/test/deps/isaacsim/check_camera.py delete mode 100644 source/isaaclab/test/deps/isaacsim/check_floating_base_made_fixed.py delete mode 100644 source/isaaclab/test/deps/isaacsim/check_legged_robot_clone.py delete mode 100644 source/isaaclab/test/deps/isaacsim/check_ref_count.py delete mode 100644 source/isaaclab/test/deps/isaacsim/check_rep_texture_randomizer.py delete mode 100644 source/isaaclab/test/deps/test_scipy.py delete mode 100644 source/isaaclab/test/deps/test_torch.py delete mode 100644 source/isaaclab/test/envs/check_manager_based_env_anymal_locomotion.py delete mode 100644 source/isaaclab/test/envs/check_manager_based_env_floating_cube.py delete mode 100644 source/isaaclab/test/managers/test_observation_manager_unit.py delete mode 100644 source/isaaclab/test/renderers/test_rigid_object_rendering_contract_architecture.py delete mode 100644 source/isaaclab/test/sensors/test_ray_caster.py delete mode 100644 source/isaaclab/test/sensors/test_tiled_camera_env.py delete mode 100644 source/isaaclab/test/sim/check_meshes.py delete mode 100644 source/isaaclab/test/sim/test_build_simulation_context_nonheadless.py delete mode 100644 source/isaaclab/test/utils/test_images_display_nonfinite.py create mode 100644 source/isaaclab_newton/changelog.d/tendon-test-clone-plan.skip create mode 100644 source/isaaclab_ov/changelog.d/benchmark-mock-optional-runtime.skip diff --git a/AGENTS.md b/AGENTS.md index 68dfe8bac59b..78a307848df3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,11 @@ - Keep related public symbols discoverable through consistent prefixes. - Keep joint-wrench sensor coverage separate from articulation control-joint selection. Reuse cached body bindings without changing the shared view's joint filters or creating a second view for sensing. +- Keep articulation ordering maps on articulation data; do not mirror maps or add cached ordering flags. +- Keep backend ownership on `SimulationContext`, using backend type and configuration rather than service + locators, resource keys, or separate renderer registries. +- Renderers consume geometry through `SceneDataProvider`. Keep Newton imports out of OVRTX renderers + and Fabric destination ownership and shadow remapping out of physics backends. - For external wrenches, follow the asset API's `is_global` boolean and `_b`/`_w` buffer naming. Keep frame conversion decisions in `WrenchComposer` and track pending contributions with plain booleans; do not introduce frame enums, content bitmasks, or a classification layer. diff --git a/docs/source/refs/contributing.rst b/docs/source/refs/contributing.rst index 82e8bd63e595..4faee8a5377e 100644 --- a/docs/source/refs/contributing.rst +++ b/docs/source/refs/contributing.rst @@ -713,10 +713,10 @@ Please make sure that you add tests for your changes. ./isaaclab.sh --test # or "./isaaclab.sh -t" # Run all tests in a particular file - uv run python -m pytest source/isaaclab/test/deps/test_torch.py + uv run python -m pytest source/isaaclab/test/utils/test_circular_buffer.py # Run a particular test - uv run python -m pytest source/isaaclab/test/deps/test_torch.py::test_array_slicing + uv run python -m pytest source/isaaclab/test/utils/test_circular_buffer.py::test_reset .. tab-item:: isaaclab.sh / isaaclab.bat @@ -726,10 +726,10 @@ Please make sure that you add tests for your changes. ./isaaclab.sh --test # or "./isaaclab.sh -t" # Run all tests in a particular file - ./isaaclab.sh -p -m pytest source/isaaclab/test/deps/test_torch.py + ./isaaclab.sh -p -m pytest source/isaaclab/test/utils/test_circular_buffer.py # Run a particular test - ./isaaclab.sh -p -m pytest source/isaaclab/test/deps/test_torch.py::test_array_slicing + ./isaaclab.sh -p -m pytest source/isaaclab/test/utils/test_circular_buffer.py::test_reset .. tab-item:: :icon:`fa-brands fa-windows` Windows :sync: windows @@ -744,10 +744,10 @@ Please make sure that you add tests for your changes. isaaclab.bat --test # or "isaaclab.bat -t" # Run all tests in a particular file - uv run python -m pytest source/isaaclab/test/deps/test_torch.py + uv run python -m pytest source/isaaclab/test/utils/test_circular_buffer.py # Run a particular test - uv run python -m pytest source/isaaclab/test/deps/test_torch.py::test_array_slicing + uv run python -m pytest source/isaaclab/test/utils/test_circular_buffer.py::test_reset .. tab-item:: isaaclab.sh / isaaclab.bat @@ -758,10 +758,10 @@ Please make sure that you add tests for your changes. isaaclab.bat --test # or "isaaclab.bat -t" # Run all tests in a particular file - isaaclab.bat -p -m pytest source/isaaclab/test/deps/test_torch.py + isaaclab.bat -p -m pytest source/isaaclab/test/utils/test_circular_buffer.py # Run a particular test - isaaclab.bat -p -m pytest source/isaaclab/test/deps/test_torch.py::test_array_slicing + isaaclab.bat -p -m pytest source/isaaclab/test/utils/test_circular_buffer.py::test_reset All of these commands exit with a nonzero code when tests fail, so a test failure fails the invoking shell or CI step as well. diff --git a/source/isaaclab/changelog.d/test-prune-core-tests.skip b/source/isaaclab/changelog.d/test-prune-core-tests.skip new file mode 100644 index 000000000000..d1dd160fc459 --- /dev/null +++ b/source/isaaclab/changelog.d/test-prune-core-tests.skip @@ -0,0 +1,3 @@ +Pruned redundant core tests (sim, scene, cloner, renderer, sensor, asset, env, manager, controller, actuator, utility, and CLI) and strengthened the kept assertions. +Preserved backend ownership guards, camera calibration batch rejection, and custom lidar field-of-view coverage in retained test files. +Checked batched quaternion rotation against SciPy and verified benchmark factory signatures without the optional OvPhysX runtime. diff --git a/source/isaaclab/test/actuators/test_actuator_collection.py b/source/isaaclab/test/actuators/test_actuator_collection.py index 3194bf099cb1..6e15d1ea2e62 100644 --- a/source/isaaclab/test/actuators/test_actuator_collection.py +++ b/source/isaaclab/test/actuators/test_actuator_collection.py @@ -610,8 +610,6 @@ def test_articulation_control_provides_common_forwarding_and_property_writes(): assert not FakeActuatorControl().native_actuator_path_active assert not control.native_actuator_path_active - control._native_actuator_path_active = True - assert control.native_actuator_path_active assert control.num_instances == articulation.num_instances assert control.num_joints == articulation.num_joints @@ -800,9 +798,6 @@ def test_overlapping_groups_are_rejected(): def test_collection_is_mapping_like_and_read_only(): - assert hasattr(actuator_api, "ActuatorTargetCommand") - assert hasattr(actuator_api, "ActuatorOutputCommand") - control = FakeActuatorControl() collection = ActuatorCollection({"all": _implicit_cfg()}, control) assert isinstance(collection.target_command, actuator_api.ActuatorTargetCommand) @@ -859,9 +854,6 @@ def test_multi_group_explicit_outputs_match_pd_formula(): def test_disjoint_implicit_groups_share_one_execution_batch(): - assert "is_implicit_model" in ImplicitActuator.__dict__.get("__annotations__", {}) - assert ImplicitActuator.__dict__["is_implicit_model"] is True - control = FakeActuatorControl(num_envs=1, joint_names=["joint_0", "joint_1", "joint_2", "joint_3"]) collection = ActuatorCollection( { @@ -875,12 +867,7 @@ def test_disjoint_implicit_groups_share_one_execution_batch(): control, ) - assert collection._execution_actuators == [] - executor = collection._implicit_executor - assert executor is not None - assert type(executor.actuator) is ImplicitActuator - assert executor.actuator is not collection["first"] - assert executor.group_names == ("first", "second") + assert collection._implicit_executor.group_names == ("first", "second") group = collection["first"] velocity_limit_snapshot = group.actuator_velocity_limit.clone() @@ -1031,9 +1018,6 @@ def test_native_execution_bypasses_lab_aggregation(monkeypatch): assert not [warning for warning in caught_warnings if warning.category is DeprecationWarning] - assert collection._implicit_executor is None - assert collection._execution_actuators == [] - def fail_compute(*args, **kwargs): raise AssertionError("Lab actuator execution must be bypassed") diff --git a/source/isaaclab/test/actuators/test_dc_motor.py b/source/isaaclab/test/actuators/test_dc_motor.py index 4af94d49b678..67ea950f99a9 100644 --- a/source/isaaclab/test/actuators/test_dc_motor.py +++ b/source/isaaclab/test/actuators/test_dc_motor.py @@ -7,58 +7,13 @@ import torch from isaaclab.actuators import DCMotorCfg +from isaaclab.utils.types import ArticulationActions pytestmark = pytest.mark.integration -@pytest.mark.parametrize("num_envs", [1, 2]) -@pytest.mark.parametrize("num_joints", [1, 2]) -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -def test_dc_motor_init_minimum(num_envs, num_joints, device): - joint_names = [f"joint_{d}" for d in range(num_joints)] - joint_ids = [d for d in range(num_joints)] - stiffness = 200 - damping = 10 - effort_limit = 60.0 - saturation_effort = 100.0 - velocity_limit = 50 - - actuator_cfg = DCMotorCfg( - joint_names_expr=joint_names, - stiffness=stiffness, - damping=damping, - actuator_effort_limit=effort_limit, - saturation_effort=saturation_effort, - actuator_velocity_limit=velocity_limit, - ) - # assume Articulation class: - # - finds joints (names and ids) associate with the provided joint_names_expr - - actuator = actuator_cfg.class_type( - actuator_cfg, - joint_names=joint_names, - joint_ids=joint_ids, - num_envs=num_envs, - device=device, - ) - - # check device and shape - torch.testing.assert_close(actuator.computed_effort, torch.zeros(num_envs, num_joints, device=device)) - torch.testing.assert_close(actuator.applied_effort, torch.zeros(num_envs, num_joints, device=device)) - torch.testing.assert_close( - actuator.actuator_effort_limit, - effort_limit * torch.ones(num_envs, num_joints, device=device), - ) - torch.testing.assert_close( - actuator.actuator_velocity_limit, velocity_limit * torch.ones(num_envs, num_joints, device=device) - ) - - -@pytest.mark.parametrize("num_envs", [1, 2]) -@pytest.mark.parametrize("num_joints", [1, 2]) -@pytest.mark.parametrize("device", ["cuda", "cpu"]) @pytest.mark.parametrize("test_point", range(20)) -def test_dc_motor_clip(num_envs, num_joints, device, test_point): +def test_dc_motor_clip(test_point): r"""Test the computation of the dc motor actuator 4 quadrant torque speed curve. torque_speed_pairs of interest: @@ -150,14 +105,14 @@ def test_dc_motor_clip(num_envs, num_joints, device, test_point): 40.0, # -9 ] + num_envs, num_joints, device = 2, 2, "cpu" joint_names = [f"joint_{d}" for d in range(num_joints)] joint_ids = [d for d in range(num_joints)] - stiffness = 200 - damping = 10 + # zero gains, so the computed effort is the feed-forward torque and only the motor model clips it actuator_cfg = DCMotorCfg( joint_names_expr=joint_names, - stiffness=stiffness, - damping=damping, + stiffness=0.0, + damping=0.0, actuator_effort_limit=effort_lim, actuator_velocity_limit=velocity_limit, saturation_effort=saturation_effort, @@ -173,25 +128,24 @@ def test_dc_motor_clip(num_envs, num_joints, device, test_point): damping=actuator_cfg.damping, ) - ts = torque_speed_pairs[test_point] - torque = ts[0] - speed = ts[1] - actuator._joint_vel[:] = speed * torch.ones(num_envs, num_joints, device=device) - effort = torque * torch.ones(num_envs, num_joints, device=device) - clipped_effort = actuator._clip_effort(effort) - torch.testing.assert_close( - expected_clipped_effort[test_point] * torch.ones(num_envs, num_joints, device=device), - clipped_effort, + torque, speed = torque_speed_pairs[test_point] + zeros = torch.zeros(num_envs, num_joints, device=device) + control_action = ArticulationActions( + joint_positions=zeros.clone(), joint_velocities=zeros.clone(), joint_efforts=torch.full_like(zeros, torque) ) + applied = actuator.compute(control_action, joint_pos=zeros, joint_vel=torch.full_like(zeros, speed)) + expected = torch.full_like(zeros, expected_clipped_effort[test_point]) + torch.testing.assert_close(actuator.applied_effort, expected) + torch.testing.assert_close(applied.joint_efforts, expected) -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -def test_dc_motor_clip_with_per_joint_saturation_effort(device): +def test_dc_motor_clip_with_per_joint_saturation_effort(): """Test that a per-joint ``saturation_effort`` gives each joint its own torque-speed curve. Joints behind different gear reductions belong to one actuator group but do not share a stall torque, e.g. the Unitree Go2 calf, which sits behind an extra knee reduction. """ + device = "cpu" joint_names = ["hip", "calf"] actuator_cfg = DCMotorCfg( joint_names_expr=joint_names, diff --git a/source/isaaclab/test/actuators/test_ideal_pd_actuator.py b/source/isaaclab/test/actuators/test_ideal_pd_actuator.py index e82bc2849e80..6d0a54a8167d 100644 --- a/source/isaaclab/test/actuators/test_ideal_pd_actuator.py +++ b/source/isaaclab/test/actuators/test_ideal_pd_actuator.py @@ -7,17 +7,20 @@ import torch from isaaclab.actuators import IdealPDActuatorCfg +from isaaclab.test.utils import test_devices from isaaclab.utils.types import ArticulationActions pytestmark = pytest.mark.integration +# Shapes broadcast the same scalar parameters, so one multi-env, multi-joint shape covers them. +NUM_ENVS, NUM_JOINTS = 2, 2 -@pytest.mark.parametrize("num_envs", [1, 2]) -@pytest.mark.parametrize("num_joints", [1, 2]) -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) + +@pytest.mark.parametrize("device", test_devices()) @pytest.mark.parametrize("usd_default", [False, True]) -def test_ideal_pd_actuator_init_minimum(num_envs, num_joints, device, usd_default): +def test_ideal_pd_actuator_init_minimum(device, usd_default): """Test initialization of ideal pd actuator with minimum configuration.""" + num_envs, num_joints = NUM_ENVS, NUM_JOINTS joint_names = [f"joint_{d}" for d in range(num_joints)] joint_ids = [d for d in range(num_joints)] @@ -71,19 +74,17 @@ def test_ideal_pd_actuator_init_minimum(num_envs, num_joints, device, usd_defaul torch.testing.assert_close(actuator.damping, damping_default * torch.ones(num_envs, num_joints, device=device)) -@pytest.mark.parametrize("num_envs", [1, 2]) -@pytest.mark.parametrize("num_joints", [1, 2]) -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("cfg_limit", [None, 300]) @pytest.mark.parametrize( "limit_name", ["actuator_effort_limit", "actuator_velocity_limit"], ) -def test_ideal_pd_actuator_init_limits(num_envs, num_joints, device, cfg_limit, limit_name): +def test_ideal_pd_actuator_init_limits(cfg_limit, limit_name): """Test that a cfg-provided limit wins over the constructor default for effort and velocity limits. Note Ideal PD actuator does not use velocity limits in computation, they are passed to physics via articulations. """ + num_envs, num_joints, device = NUM_ENVS, NUM_JOINTS, "cpu" # used as a standin for the usd default value read in by articulation. limit_default = 5000 @@ -113,12 +114,10 @@ def test_ideal_pd_actuator_init_limits(num_envs, num_joints, device, cfg_limit, ) -@pytest.mark.parametrize("num_envs", [1, 2]) -@pytest.mark.parametrize("num_joints", [1, 2]) -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("effort_lim", [None, 300]) -def test_ideal_pd_compute(num_envs, num_joints, device, effort_lim): +def test_ideal_pd_compute(effort_lim): """Test the computation of the ideal pd actuator.""" + num_envs, num_joints, device = NUM_ENVS, NUM_JOINTS, "cpu" joint_names = [f"joint_{d}" for d in range(num_joints)] joint_ids = [d for d in range(num_joints)] diff --git a/source/isaaclab/test/actuators/test_implicit_actuator.py b/source/isaaclab/test/actuators/test_implicit_actuator.py index c5017488cd03..1cb96d54a2a0 100644 --- a/source/isaaclab/test/actuators/test_implicit_actuator.py +++ b/source/isaaclab/test/actuators/test_implicit_actuator.py @@ -7,16 +7,19 @@ import torch from isaaclab.actuators import ImplicitActuatorCfg +from isaaclab.test.utils import test_devices pytestmark = pytest.mark.integration +# Shapes broadcast the same scalar parameters, so one multi-env, multi-joint shape covers them. +NUM_ENVS, NUM_JOINTS = 2, 2 -@pytest.mark.parametrize("num_envs", [1, 2]) -@pytest.mark.parametrize("num_joints", [1, 2]) -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) + +@pytest.mark.parametrize("device", test_devices()) @pytest.mark.parametrize("usd_default", [False, True]) -def test_implicit_actuator_init_minimum(num_envs, num_joints, device, usd_default): +def test_implicit_actuator_init_minimum(device, usd_default): """Test initialization of implicit actuator with minimum configuration.""" + num_envs, num_joints = NUM_ENVS, NUM_JOINTS joint_names = [f"joint_{d}" for d in range(num_joints)] joint_ids = [d for d in range(num_joints)] @@ -68,16 +71,14 @@ def test_implicit_actuator_init_minimum(num_envs, num_joints, device, usd_defaul torch.testing.assert_close(actuator.damping, damping_default * torch.ones(num_envs, num_joints, device=device)) -@pytest.mark.parametrize("num_envs", [1, 2]) -@pytest.mark.parametrize("num_joints", [1, 2]) -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("cfg_limit", [None, 300]) @pytest.mark.parametrize( "limit_name", ["joint_effort_limit", "actuator_velocity_limit"], ) -def test_implicit_actuator_init_limits(num_envs, num_joints, device, cfg_limit, limit_name): +def test_implicit_actuator_init_limits(cfg_limit, limit_name): """Test that a cfg-provided limit wins over the constructor default for effort and velocity limits.""" + num_envs, num_joints, device = NUM_ENVS, NUM_JOINTS, "cpu" # used as a standin for the usd default value read in by articulation. limit_default = 5000 @@ -107,33 +108,9 @@ def test_implicit_actuator_init_limits(num_envs, num_joints, device, cfg_limit, ) -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -def test_implicit_actuator_separate_rated_and_solver_effort_limits(device): - """A configured rated limit stays on the actuator while the solver keeps its own clamp.""" - joint_names = ["joint_0"] - actuator_cfg = ImplicitActuatorCfg( - joint_names_expr=joint_names, - stiffness=200, - damping=10, - actuator_effort_limit=87.0, - joint_effort_limit=870.0, - ) - actuator = actuator_cfg.class_type( - actuator_cfg, - joint_names=joint_names, - joint_ids=[0], - num_envs=2, - device=device, - stiffness=actuator_cfg.stiffness, - damping=actuator_cfg.damping, - ) - torch.testing.assert_close(actuator.actuator_effort_limit, 87.0 * torch.ones(2, 1, device=device)) - torch.testing.assert_close(actuator.joint_effort_limit, 870.0 * torch.ones(2, 1, device=device)) - - -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -def test_implicit_actuator_deprecated_effort_aliases_resolve_rated_and_solver(device): +def test_implicit_actuator_deprecated_effort_aliases_resolve_rated_and_solver(): """Deprecated ``effort_limit``/``effort_limit_sim`` map to the rated and solver limits.""" + device = "cpu" joint_names = ["joint_0"] actuator_cfg = ImplicitActuatorCfg( joint_names_expr=joint_names, @@ -156,29 +133,5 @@ def test_implicit_actuator_deprecated_effort_aliases_resolve_rated_and_solver(de torch.testing.assert_close(actuator.joint_effort_limit, 870.0 * torch.ones(2, 1, device=device)) -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -def test_implicit_actuator_deprecated_effort_limit_alone_reaches_solver(device): - """Without a separate solver clamp, the deprecated rated limit also reaches the solver.""" - joint_names = ["joint_0"] - actuator_cfg = ImplicitActuatorCfg( - joint_names_expr=joint_names, - stiffness=200, - damping=10, - effort_limit=87.0, - ) - with pytest.warns(DeprecationWarning): - actuator = actuator_cfg.class_type( - actuator_cfg, - joint_names=joint_names, - joint_ids=[0], - num_envs=2, - device=device, - stiffness=actuator_cfg.stiffness, - damping=actuator_cfg.damping, - ) - torch.testing.assert_close(actuator.actuator_effort_limit, 87.0 * torch.ones(2, 1, device=device)) - torch.testing.assert_close(actuator.joint_effort_limit, 87.0 * torch.ones(2, 1, device=device)) - - if __name__ == "__main__": pytest.main([__file__, "-v", "--maxfail=1"]) diff --git a/source/isaaclab/test/app/test_app_launcher_argv.py b/source/isaaclab/test/app/test_app_launcher_argv.py index 0f82f903e567..8496fd3f3212 100644 --- a/source/isaaclab/test/app/test_app_launcher_argv.py +++ b/source/isaaclab/test/app/test_app_launcher_argv.py @@ -3,13 +3,21 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Tests for the command-line arguments passed to Kit at startup.""" +"""Kitless tests for how :class:`AppLauncher` resolves arguments, environment variables, and Kit settings.""" +import argparse +import logging import sys +from types import SimpleNamespace import pytest +import isaaclab.app as app_module +import isaaclab.app.app_launcher as app_launcher_module +import isaaclab.app.sim_launcher as sim_launcher +import isaaclab.utils as utils_module from isaaclab.app.app_launcher import AppLauncher, _sanitize_sys_argv_for_kit +from isaaclab.app.sim_launcher import Scan, _ensure_livestream_kit_visualizer, _get_kit_runtime_sources from isaaclab.utils.renderers import ISAAC_RTX_SHOW_ALL_PARTITIONS_BY_DEFAULT_SETTING @@ -171,3 +179,737 @@ def test_explicit_spectator_setting_overrides_visualizer_default(monkeypatch): assert explicit_arg in launcher._kit_args assert f"--{ISAAC_RTX_SHOW_ALL_PARTITIONS_BY_DEFAULT_SETTING}=true" not in launcher._kit_args + + +@pytest.mark.parametrize( + ("argv", "expected"), + [ + pytest.param(["--kit_args", "--foo=/bar"], ["--kit_args=--foo=/bar"], id="space-separated-option-like"), + pytest.param(["--kit_args=--foo=/bar"], ["--kit_args=--foo=/bar"], id="equals-attached"), + # a token containing a space cannot be mistaken for an option by argparse + pytest.param(["--kit_args", "--foo=/bar --baz=1"], ["--kit_args", "--foo=/bar --baz=1"], id="value-with-space"), + pytest.param(["--kit_args", "foo.txt"], ["--kit_args", "foo.txt"], id="non-option-value"), + # argparse should still report the missing value normally + pytest.param( + ["--task", "Isaac-Cartpole-Direct", "--kit_args"], + ["--task", "Isaac-Cartpole-Direct", "--kit_args"], + id="trailing", + ), + pytest.param( + ["--kit_args", "--foo=/a", "--task", "X", "--kit_args", "--bar=/b"], + ["--kit_args=--foo=/a", "--task", "X", "--kit_args=--bar=/b"], + id="multiple-occurrences", + ), + pytest.param( + ["--task", "X", "--kit_args", "--foo=/bar", "--num_envs", "16"], + ["--task", "X", "--kit_args=--foo=/bar", "--num_envs", "16"], + id="surrounding-tokens", + ), + ], +) +def test_fuse_kit_args(argv: list[str], expected: list[str]): + """Fuse only ``--kit_args`` pairs whose value argparse would mistake for an option.""" + assert AppLauncher._fuse_kit_args(argv) == expected + + +def test_add_app_launcher_args_registers_every_launcher_option(): + """Every launcher config key except the removed render flags is a command-line option.""" + parser = argparse.ArgumentParser() + AppLauncher.add_app_launcher_args(parser) + + for name in AppLauncher._APPLAUNCHER_CFG_INFO.keys() - {"headless", "enable_cameras"}: + assert parser._option_string_actions[f"--{name}"] + + +def test_visualizer_alias_parsing(): + """Test that --viz alias maps to visualizer values, resolving the deprecated ``newton`` name.""" + parser = argparse.ArgumentParser() + AppLauncher.add_app_launcher_args(parser) + + with pytest.warns(DeprecationWarning, match="--viz 'newton' is deprecated"): + args = parser.parse_args(["--viz", "kit,newton"]) + assert args.visualizer == ["kit", "newton_gl"] + assert args.visualizer_explicit is True + + +@pytest.mark.parametrize("deprecated_arg", ["--headless", "--enable_cameras"]) +def test_deprecated_render_flags_are_rejected(deprecated_arg: str): + """Test that removed render flags are rejected by the parser.""" + parser = argparse.ArgumentParser() + AppLauncher.add_app_launcher_args(parser) + + with pytest.raises(SystemExit): + parser.parse_args([deprecated_arg]) + + +def test_help_on_parser_with_required_positionals(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture): + """Launcher arguments reach the help output of a script that takes required positionals. + + ``add_app_launcher_args`` probes the command line to check for name collisions. That probe + exits when a required argument is missing, which is the case for every tool script invoked + with ``--help``, so it must not take the parser down before the arguments are added. + """ + monkeypatch.setattr("sys.argv", ["convert_urdf.py", "--help"]) + parser = argparse.ArgumentParser() + parser.add_argument("input") + parser.add_argument("output") + + AppLauncher.add_app_launcher_args(parser) + + # the probe's own usage line must not leak to stderr ahead of the real help output + assert capsys.readouterr().err == "" + assert "--visualizer" in parser._option_string_actions + assert "--device" in parser._option_string_actions + + with pytest.raises(SystemExit) as excinfo: + parser.parse_args(["--help"]) + assert excinfo.value.code == 0 + assert "app_launcher arguments" in capsys.readouterr().out + + +@pytest.mark.parametrize("value", ["none", "None"]) +def test_visualizer_none_parsing(value: str): + parser = argparse.ArgumentParser() + AppLauncher.add_app_launcher_args(parser) + args = parser.parse_args(["--viz", value]) + assert args.visualizer is None + assert args.visualizer_explicit is True + + +@pytest.mark.parametrize( + ("launcher_args", "livestream_env"), + [ + pytest.param({"livestream": 1}, "0", id="kwarg"), + pytest.param(["--livestream", "1"], "0", id="namespace"), + pytest.param({}, "1", id="env"), + ], +) +def test_livestream_request_resolves_headless_livestream_launch( + launcher_args: dict | list[str], livestream_env: str, monkeypatch: pytest.MonkeyPatch +): + """Each livestream channel enables headless WebRTC streaming; a keyword argument supersedes ``LIVESTREAM``.""" + monkeypatch.setattr(sys, "argv", ["script.py"]) + monkeypatch.setenv("LIVESTREAM", livestream_env) + monkeypatch.setenv("HEADLESS", "0") + monkeypatch.delenv("XR", raising=False) + if isinstance(launcher_args, list): + parser = argparse.ArgumentParser() + AppLauncher.add_app_launcher_args(parser) + launcher_args = vars(parser.parse_args(launcher_args)) + launcher_args["device"] = "cpu" + launcher = AppLauncher.__new__(AppLauncher) + monkeypatch.setattr(launcher, "_resolve_experience_file", lambda _launcher_args: None) + + launcher._config_resolution(launcher_args) + + assert launcher._livestream == 1 + assert launcher._headless is True + assert "omni.kit.livestream.app" in launcher._livestream_args + + +def _resolve_kit_args( + monkeypatch: pytest.MonkeyPatch, launcher_args: dict, argv: list[str] | None = None +) -> tuple[list[str], list[str]]: + monkeypatch.setattr(sys, "argv", ["pytest", *(argv or [])]) + launcher = AppLauncher.__new__(AppLauncher) + launcher._xr = False + launcher._resolve_kit_args(launcher_args) + return sys.argv[1:], launcher._kit_args + + +@pytest.mark.parametrize("env_value, expected", [("0", "false"), ("1", "true")]) +def test_fabric_gpu_interop_env_adds_override(monkeypatch: pytest.MonkeyPatch, env_value: str, expected: str): + monkeypatch.setenv(app_launcher_module._FABRIC_GPU_INTEROP_ENV, env_value) + + kit_args, _ = _resolve_kit_args(monkeypatch, {}) + + assert kit_args == [ + f"--/physics/fabricUseGPUInterop={expected}", + "--/exts/isaacsim.core.simulation_manager/enable_default_callbacks=false", + ] + + +def test_fabric_gpu_interop_env_rejects_invalid_value(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv(app_launcher_module._FABRIC_GPU_INTEROP_ENV, "false") + + with pytest.raises(ValueError, match="Expected: 0 or 1"): + _resolve_kit_args(monkeypatch, {}) + + +def test_explicit_kit_setting_takes_precedence(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv(app_launcher_module._FABRIC_GPU_INTEROP_ENV, "0") + explicit_arg = "--/physics/fabricUseGPUInterop=true" + + kit_args, resolved_args = _resolve_kit_args(monkeypatch, {}, [explicit_arg]) + + assert kit_args == [ + explicit_arg, + "--/exts/isaacsim.core.simulation_manager/enable_default_callbacks=false", + ] + assert resolved_args == ["--/exts/isaacsim.core.simulation_manager/enable_default_callbacks=false"] + + +def test_explicit_kit_args_setting_takes_precedence(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv(app_launcher_module._FABRIC_GPU_INTEROP_ENV, "0") + explicit_arg = "--/physics/fabricUseGPUInterop=true" + + kit_args, resolved_args = _resolve_kit_args(monkeypatch, {"kit_args": explicit_arg}) + + assert kit_args == [ + explicit_arg, + "--/exts/isaacsim.core.simulation_manager/enable_default_callbacks=false", + ] + assert resolved_args == [ + explicit_arg, + "--/exts/isaacsim.core.simulation_manager/enable_default_callbacks=false", + ] + + +def test_simulation_manager_default_callbacks_disabled(monkeypatch: pytest.MonkeyPatch): + kit_args, resolved_args = _resolve_kit_args(monkeypatch, {}) + + assert kit_args == ["--/exts/isaacsim.core.simulation_manager/enable_default_callbacks=false"] + assert resolved_args == ["--/exts/isaacsim.core.simulation_manager/enable_default_callbacks=false"] + + +def test_explicit_simulation_manager_callback_setting_takes_precedence(monkeypatch: pytest.MonkeyPatch): + explicit_arg = "--/exts/isaacsim.core.simulation_manager/enable_default_callbacks=true" + + kit_args, resolved_args = _resolve_kit_args(monkeypatch, {"kit_args": explicit_arg}) + + assert kit_args == [explicit_arg] + assert resolved_args == [explicit_arg] + + +def test_make_physics_cfg_builds_core_vbd(): + from isaaclab_newton.physics import NewtonCfg, VBDSolverCfg + + physics_cfg = sim_launcher.make_physics_cfg("newton_vbd") + + assert isinstance(physics_cfg, NewtonCfg) + assert isinstance(physics_cfg.solver_cfg, VBDSolverCfg) + + +def test_livestream_injects_kit_visualizer_when_missing(): + args = argparse.Namespace(livestream=2, visualizer=None, visualizer_explicit=False) + + _ensure_livestream_kit_visualizer(args) + + assert args.visualizer == ["kit"] + + +def test_livestream_rejects_disabled_visualizers(): + args = argparse.Namespace(livestream=2, visualizer=None, visualizer_explicit=True) + + with pytest.raises(ValueError, match="Livestreaming requires the Kit visualizer"): + _ensure_livestream_kit_visualizer(args) + + +def test_explicit_experience_requires_isaac_sim_runtime(): + """An explicit Kit experience must override a kitless physics configuration.""" + scan = Scan( + resolved_physics_cfg=None, + effective_cfg=object(), + visualizer_intent={"has_any_visualizers": False, "has_kit_visualizer": False}, + has_ovrtx=False, + has_kit_camera=False, + has_kit_physics=False, + has_kitless_physics=True, + has_ovphysx_physics=False, + needs_kit=False, + ) + args = argparse.Namespace(experience="isaaclab.python.kit", visualizer=None) + + assert _get_kit_runtime_sources(scan, args) + + +_XR_KIT = {"xr": True, "visualizer": ["kit"], "visualizer_explicit": True} + + +@pytest.mark.parametrize( + "launcher_args, headless_env, livestream, expected_headless, expected_has_window", + [ + # XR with no CLI visualizer: headless even though the task config asks for a window. + ({"xr": True}, 0, 0, True, False), + # An explicit '--viz kit' is the only way to get a viewport alongside XR. + (_XR_KIT, 0, 0, False, True), + ({"xr": True, "visualizer": ["none"], "visualizer_explicit": True}, 0, 0, True, False), + # ...but an explicit '--viz kit' cannot override HEADLESS=1. + (_XR_KIT, 1, 0, True, False), + # Livestreaming forces headless yet still presents a window to the remote client. + (_XR_KIT, 0, 1, True, True), + ({}, 0, 1, True, True), + # Without XR or livestream the task config still decides. + ({}, 0, 0, False, True), + ], +) +def test_xr_without_explicit_windowed_visualizer_forces_headless( + launcher_args: dict, + headless_env: int, + livestream: int, + expected_headless: bool, + expected_has_window: bool, + monkeypatch: pytest.MonkeyPatch, +): + """Test that enabling XR runs headless unless a windowed visualizer is explicitly requested. + + A task config declaring a windowed visualizer must not leave ``--xr`` opening a window nobody + asked for, and ``HEADLESS=1`` / livestreaming must keep forcing headless even when one was + explicitly requested. Resolution is exercised directly to avoid launching Isaac Sim; what the + resolved state then publishes is asserted by + :func:`test_load_extensions_publishes_has_gui_setting`. + + ``has_window`` is asserted alongside because it is deliberately *not* the negation of + headless: livestreaming is headless but windowed. + """ + monkeypatch.setenv("HEADLESS", str(headless_env)) + # XR is read from the environment too, and is routinely exported by people working on this + # feature -- pin it so the parametrization is what decides. + monkeypatch.delenv("XR", raising=False) + launcher = AppLauncher.__new__(AppLauncher) + launcher._livestream = livestream + args = { + "visualizer_intent": {"has_any_visualizers": True, "has_kit_visualizer": True}, + **launcher_args, + } + + launcher._resolve_visualizer_settings(args) + launcher._resolve_xr_settings(args) + launcher._resolve_headless_settings(args, livestream_arg=-1, livestream_env=0) + + assert launcher._headless is expected_headless + assert launcher.has_window is expected_has_window + + +def test_launch_simulation_preserves_failure_exit_code(monkeypatch: pytest.MonkeyPatch): + close_args = {} + + class _FakeApp: + def close(self, *, exit_code: int = 0) -> None: + close_args["exit_code"] = exit_code + + class _FakeAppLauncher: + def __init__(self, _launcher_args): + self.app = _FakeApp() + + scan = sim_launcher.Scan( + resolved_physics_cfg=None, + effective_cfg=object(), + visualizer_intent={"has_any_visualizers": False, "has_kit_visualizer": False}, + has_ovrtx=False, + has_kit_camera=False, + has_kit_physics=True, + has_kitless_physics=False, + has_ovphysx_physics=False, + needs_kit=True, + ) + monkeypatch.setattr(sim_launcher, "scan", lambda cfg, physics: scan) + monkeypatch.setattr(sim_launcher, "_ensure_isaac_sim_available", lambda: None) + monkeypatch.setattr(app_module, "AppLauncher", _FakeAppLauncher) + monkeypatch.setattr(utils_module, "has_kit", lambda: False) + + with pytest.raises(RuntimeError, match="sentinel"): + with sim_launcher.launch_simulation(object(), argparse.Namespace()): + raise RuntimeError("sentinel") + + assert close_args == {"exit_code": 1} + + +def test_launch_simulation_auto_enables_kit_camera_without_launcher_args(monkeypatch: pytest.MonkeyPatch): + monkeypatch.delenv("LIVESTREAM", raising=False) + received_args = {} + + class _FakeApp: + def close(self) -> None: + pass + + class _FakeAppLauncher: + def __init__(self, launcher_args): + received_args.update(launcher_args) + self.app = _FakeApp() + + scan = sim_launcher.Scan( + resolved_physics_cfg=None, + effective_cfg=object(), + visualizer_intent={"has_any_visualizers": False, "has_kit_visualizer": False}, + has_ovrtx=False, + has_kit_camera=True, + has_kit_physics=False, + has_kitless_physics=False, + has_ovphysx_physics=False, + needs_kit=True, + ) + + def _scan(_cfg, launcher_args): + assert launcher_args == {} + return scan + + monkeypatch.setattr(sim_launcher, "scan", _scan) + monkeypatch.setattr(sim_launcher, "_ensure_isaac_sim_available", lambda: None) + monkeypatch.setattr(app_module, "AppLauncher", _FakeAppLauncher) + monkeypatch.setattr(utils_module, "has_kit", lambda: False) + + with sim_launcher.launch_simulation(object()): + pass + + assert received_args["enable_cameras"] is True + + +def test_deferred_cuda_device_synchronizes_torch_and_warp(monkeypatch: pytest.MonkeyPatch): + """The post-Kit device hook must synchronize both CUDA runtimes.""" + devices = [] + monkeypatch.setattr(app_launcher_module, "set_cuda_device", devices.append) + launcher = AppLauncher.__new__(AppLauncher) + launcher._deferred_cuda_device_id = 2 + + launcher._set_deferred_cuda_device() + + assert devices == [2] + + +def test_preloaded_torch_cuda_is_initialized_before_kit(monkeypatch: pytest.MonkeyPatch): + """Drain PyTorch's queued CUDA checks before Kit can change visible device indices.""" + init_calls = [] + torch = SimpleNamespace( + cuda=SimpleNamespace( + is_initialized=lambda: False, + init=lambda: init_calls.append(True), + ) + ) + monkeypatch.setitem(sys.modules, "torch", torch) + launcher = AppLauncher.__new__(AppLauncher) + launcher._deferred_cuda_device_id = 0 + + launcher._initialize_preloaded_torch_cuda() + + assert init_calls == [True] + + +def test_cuda_initialization_remains_deferred_when_torch_is_not_loaded(monkeypatch: pytest.MonkeyPatch): + """Do not import PyTorch solely to initialize CUDA before Kit.""" + monkeypatch.delitem(sys.modules, "torch", raising=False) + launcher = AppLauncher.__new__(AppLauncher) + launcher._deferred_cuda_device_id = 0 + + launcher._initialize_preloaded_torch_cuda() + + assert "torch" not in sys.modules + + +def test_limit_cpu_threads_forwarded_to_simulation_app(monkeypatch: pytest.MonkeyPatch): + """A SimulationApp thread limit must survive AppLauncher config resolution.""" + monkeypatch.setenv("HEADLESS", "0") + monkeypatch.setenv("LIVESTREAM", "0") + monkeypatch.setenv("XR", "0") + + launcher = AppLauncher.__new__(AppLauncher) + monkeypatch.setattr(launcher, "_resolve_experience_file", lambda _launcher_args: None) + + launcher._config_resolution({"headless": True, "device": "cpu", "limit_cpu_threads": 1}) + + assert launcher._sim_app_config["limit_cpu_threads"] == 1 + + +class _DummySettings: + def __init__(self): + self.values = {} + + def set_string(self, path: str, value: str) -> None: + self.values[path] = value + + def set_int(self, path: str, value: int) -> None: + self.values[path] = value + + def set_bool(self, path: str, value: bool) -> None: + self.values[path] = value + + +@pytest.mark.parametrize("deterministic", [True, False]) +def test_load_extensions_publishes_deterministic_setting(monkeypatch: pytest.MonkeyPatch, deterministic: bool): + """Publish ``/isaaclab/render/deterministic`` from ``_load_extensions``.""" + launcher = AppLauncher.__new__(AppLauncher) + launcher._deterministic_rendering = deterministic + launcher._python_logging_level = logging.ERROR + launcher._headless = True + launcher._livestream = 0 + launcher._enable_cameras = False + launcher._offscreen_render = False + launcher._render_viewport = False + launcher._xr = False + launcher._video_enabled = False + + settings = _DummySettings() + monkeypatch.setattr(app_launcher_module, "initialize_carb_settings", lambda: None) + monkeypatch.setattr(app_launcher_module, "get_settings_manager", lambda: settings) + monkeypatch.setattr(app_launcher_module, "apply_python_logging_level", lambda _level: None) + + launcher._load_extensions() + + assert settings.values["/isaaclab/render/deterministic"] is deterministic + + +@pytest.mark.parametrize( + ("headless", "livestream", "xr", "expected_has_gui", "expected_xr_auto_start"), + [ + pytest.param(False, 0, False, True, False, id="local-window"), + pytest.param(True, 0, False, False, False, id="headless"), + pytest.param(True, 1, False, True, False, id="livestream"), + pytest.param(True, 0, True, True, True, id="xr"), + # XR with a window: the operator starts the session, so it must not auto-start. + pytest.param(False, 0, True, True, False, id="xr-windowed"), + # ...but a windowless XR run must, however that windowless state was reached. + pytest.param(True, 1, True, True, True, id="xr-livestream"), + ], +) +def test_load_extensions_publishes_has_gui_setting( + monkeypatch: pytest.MonkeyPatch, + headless: bool, + livestream: int, + xr: bool, + expected_has_gui: bool, + expected_xr_auto_start: bool, +): + """Publish the GUI and XR auto-start state consumed by SimulationContext and the teleop stack. + + Asserted at the publication site rather than by recomputing the expression, so that changing + what is published fails here instead of silently agreeing with a restated formula. + """ + launcher = AppLauncher.__new__(AppLauncher) + launcher._deterministic_rendering = False + launcher._python_logging_level = logging.ERROR + launcher._headless = headless + launcher._livestream = livestream + launcher._enable_cameras = False + launcher._offscreen_render = False + launcher._render_viewport = False + launcher._xr = xr + launcher._video_enabled = False + + settings = _DummySettings() + monkeypatch.setattr(app_launcher_module, "initialize_carb_settings", lambda: None) + monkeypatch.setattr(app_launcher_module, "get_settings_manager", lambda: settings) + monkeypatch.setattr(app_launcher_module, "apply_python_logging_level", lambda _level: None) + + launcher._load_extensions() + + assert settings.values["/isaaclab/has_gui"] is expected_has_gui + assert settings.values["/isaaclab/xr/auto_start"] is expected_xr_auto_start + + +def test_set_visualizer_settings_stores_values(monkeypatch: pytest.MonkeyPatch): + settings = _DummySettings() + monkeypatch.setattr(app_launcher_module, "get_settings_manager", lambda: settings) + + launcher = AppLauncher.__new__(AppLauncher) + launcher._set_visualizer_settings({"visualizer": ["viser", "rerun"], "max_visible_envs": 0}) + + assert settings.values == { + "/isaaclab/visualizer/types": "viser rerun", + "/isaaclab/visualizer/explicit": False, + "/isaaclab/visualizer/disable_all": False, + "/isaaclab/visualizer/max_visible_envs": 0, + } + + +def test_set_visualizer_settings_rejects_negative_max_visible_envs( + monkeypatch: pytest.MonkeyPatch, +): + def _unexpected_settings_manager(): + raise AssertionError("settings manager should not be queried for invalid values") + + monkeypatch.setattr(app_launcher_module, "get_settings_manager", _unexpected_settings_manager) + + launcher = AppLauncher.__new__(AppLauncher) + with pytest.raises(ValueError, match="Invalid value for --max_visible_envs: -5"): + launcher._set_visualizer_settings({"visualizer": ["viser"], "max_visible_envs": -5}) + + +def test_set_visualizer_settings_suppresses_settings_manager_errors(monkeypatch: pytest.MonkeyPatch): + def _raise_settings_error(): + raise RuntimeError("settings unavailable") + + monkeypatch.setattr(app_launcher_module, "get_settings_manager", _raise_settings_error) + + launcher = AppLauncher.__new__(AppLauncher) + launcher._set_visualizer_settings({"visualizer": ["viser"], "max_visible_envs": 3}) + + +def test_parse_visualizer_csv_rejects_spaces_between_entries(): + with pytest.raises(argparse.ArgumentTypeError, match="spaces are not allowed"): + app_launcher_module.AppLauncher._parse_visualizer_csv("kit, newton_gl") + + +def test_resolve_visualizer_settings_rejects_none_with_others(): + launcher = AppLauncher.__new__(AppLauncher) + with pytest.raises(ValueError, match="'none' cannot be combined"): + launcher._resolve_visualizer_settings( + {"visualizer": ["none", "kit"], "visualizer_explicit": True}, + ) + + +def test_visualizer_csv_does_not_swallow_hydra_overrides(): + parser = argparse.ArgumentParser(add_help=False) + app_launcher_module.AppLauncher.add_app_launcher_args(parser) + + args, hydra_args = parser.parse_known_args( + ["--visualizer", "kit,newton_gl,rerun,viser", "presets=newton_mjwarp", "env.episode_length=10"] + ) + + assert args.visualizer == ["kit", "newton_gl", "rerun", "viser"] + assert hydra_args == ["presets=newton_mjwarp", "env.episode_length=10"] + + +def _resolve_headless_for_case(monkeypatch: pytest.MonkeyPatch, launcher_args: dict) -> tuple[bool, AppLauncher]: + monkeypatch.setenv("HEADLESS", "0") + launcher = AppLauncher.__new__(AppLauncher) + launcher._livestream = 0 + launcher._resolve_visualizer_settings(launcher_args) + launcher._resolve_headless_settings(launcher_args, livestream_arg=-1, livestream_env=0) + return launcher._headless, launcher + + +def test_matrix_cli_kit_newton_gl_with_custom_kit_cfg_intent_non_headless(monkeypatch: pytest.MonkeyPatch): + headless, launcher = _resolve_headless_for_case( + monkeypatch, + { + "visualizer": ["kit", "newton_gl"], + "visualizer_explicit": True, + "visualizer_intent": {"has_any_visualizers": True, "has_kit_visualizer": True}, + }, + ) + assert headless is False + assert launcher._cli_visualizer_types == ["kit", "newton_gl"] + + +def test_matrix_cli_rerun_with_custom_kit_cfg_intent_headless(monkeypatch: pytest.MonkeyPatch): + headless, launcher = _resolve_headless_for_case( + monkeypatch, + { + "visualizer": ["rerun"], + "visualizer_explicit": True, + "visualizer_intent": {"has_any_visualizers": True, "has_kit_visualizer": True}, + }, + ) + assert headless is True + assert launcher._cli_visualizer_types == ["rerun"] + + +def test_matrix_empty_dict_resolves_headless(monkeypatch: pytest.MonkeyPatch): + # tools that launch Kit only to reach an extension API pass no visualizer, and must stay headless + headless, _ = _resolve_headless_for_case(monkeypatch, {}) + assert headless is True + + +def test_matrix_viz_kit_dict_resolves_windowed(monkeypatch: pytest.MonkeyPatch): + # a Kit viewport only exists when the launcher is told to create it + headless, launcher = _resolve_headless_for_case(monkeypatch, {"visualizer": ["kit"]}) + assert headless is False + assert launcher._cli_visualizer_types == ["kit"] + + +@pytest.mark.parametrize("visualizer", [None, ["none"]]) +def test_matrix_viz_none_disables_all_and_headless(monkeypatch: pytest.MonkeyPatch, visualizer): + headless, launcher = _resolve_headless_for_case( + monkeypatch, + { + "visualizer": visualizer, + "visualizer_explicit": True, + "visualizer_intent": {"has_any_visualizers": True, "has_kit_visualizer": True}, + }, + ) + assert headless is True + assert launcher._cli_visualizer_disable_all is True + assert launcher._cli_visualizer_types == [] + + +def test_matrix_headless_flag_deprecated_takes_precedence(monkeypatch: pytest.MonkeyPatch): + headless, launcher = _resolve_headless_for_case( + monkeypatch, + { + "headless": True, + "headless_explicit": True, + "visualizer_intent": {"has_any_visualizers": True, "has_kit_visualizer": True}, + }, + ) + assert headless is True + assert launcher._cli_visualizer_types == [] + + +def test_no_cli_and_non_kit_cfg_visualizers_defaults_headless(monkeypatch: pytest.MonkeyPatch): + headless, _ = _resolve_headless_for_case( + monkeypatch, + {"visualizer_intent": {"has_any_visualizers": True, "has_kit_visualizer": False}}, + ) + assert headless is True + + +def test_invalid_visualizer_intent_rejected(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("HEADLESS", "0") + launcher = AppLauncher.__new__(AppLauncher) + with pytest.raises(ValueError, match="visualizer_intent"): + launcher._resolve_visualizer_settings({"visualizer_intent": {"has_any_visualizers": "yes"}}) + + +def _new_launcher_for_experience_check(): + launcher = AppLauncher.__new__(AppLauncher) + launcher._enable_cameras = False + launcher._headless = False + launcher._xr = False + launcher._deterministic_rendering = False + launcher.is_isaac_sim_version_5 = lambda: False + return launcher + + +def test_rejects_custom_experience_with_isaacsim_full_dependency_and_livestream( + tmp_path, monkeypatch: pytest.MonkeyPatch +): + experience = tmp_path / "merged.kit" + experience.write_text('[dependencies]\n"isaaclab.python" = {}\n"isaacsim.exp.full" = {}\n', encoding="utf-8") + monkeypatch.setenv("EXP_PATH", str(tmp_path)) + launcher = _new_launcher_for_experience_check() + launcher._livestream = 2 + + with pytest.raises(ValueError, match="depends on 'isaacsim.exp.full'"): + launcher._resolve_experience_file({"experience": str(experience)}) + + +def test_allows_isaacsim_full_streaming_experience_when_livestream_disabled(tmp_path, monkeypatch: pytest.MonkeyPatch): + experience = tmp_path / "isaacsim.exp.full.streaming.kit" + experience.write_text('[dependencies]\n"isaacsim.exp.full" = {}\n', encoding="utf-8") + monkeypatch.setenv("EXP_PATH", str(tmp_path)) + launcher = _new_launcher_for_experience_check() + launcher._livestream = 0 + + launcher._resolve_experience_file({"experience": str(experience)}) + + assert launcher._sim_experience_file == str(experience) + + +def test_constructor_reports_missing_isaac_sim(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(app_launcher_module, "SimulationApp", None) + + with pytest.raises(ImportError, match="requires the full Isaac Sim runtime"): + AppLauncher() + + +def test_is_available_reflects_simulation_app_presence(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(app_launcher_module, "SimulationApp", None) + assert AppLauncher.is_available() is False + + monkeypatch.setattr(app_launcher_module, "SimulationApp", object()) + assert AppLauncher.is_available() is True + + +def test_has_gui_reads_published_setting(): + from isaaclab.app.settings_manager import get_settings_manager + + settings = get_settings_manager() + original = settings.get("/isaaclab/has_gui") + try: + settings.set_bool("/isaaclab/has_gui", True) + assert AppLauncher.has_gui() is True + + settings.set_bool("/isaaclab/has_gui", False) + assert AppLauncher.has_gui() is False + finally: + settings.set_bool("/isaaclab/has_gui", bool(original)) diff --git a/source/isaaclab/test/app/test_argparser_launch.py b/source/isaaclab/test/app/test_argparser_launch.py deleted file mode 100644 index b082f9c075ad..000000000000 --- a/source/isaaclab/test/app/test_argparser_launch.py +++ /dev/null @@ -1,90 +0,0 @@ -# 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 - -import argparse - -import pytest - -from isaaclab.app import AppLauncher - -pytestmark = pytest.mark.integration - - -@pytest.mark.usefixtures("mocker") -def test_livestream_launch_with_argparser(mocker): - """Test launching with argparser arguments.""" - # Mock the parse_args method - mocker.patch("argparse.ArgumentParser.parse_args", return_value=argparse.Namespace(livestream=1)) - # create argparser - parser = argparse.ArgumentParser() - # add app launcher arguments - AppLauncher.add_app_launcher_args(parser) - # check that argparser has the mandatory arguments - for name in AppLauncher._APPLAUNCHER_CFG_INFO.keys() - {"headless", "enable_cameras"}: - assert parser._option_string_actions[f"--{name}"] - # parse args - mock_args = parser.parse_args() - # everything defaults to None - app_launcher = AppLauncher(mock_args) - app = app_launcher.app - assert app_launcher._livestream == 1 - assert app_launcher._headless is True - - # close the app on exit - app.close() - - -def test_visualizer_alias_parsing(): - """Test that --viz alias maps to visualizer values.""" - parser = argparse.ArgumentParser() - AppLauncher.add_app_launcher_args(parser) - - args = parser.parse_args(["--viz", "kit,newton"]) - assert args.visualizer == ["kit", "newton"] - assert args.visualizer_explicit is True - - -@pytest.mark.parametrize("deprecated_arg", ["--headless", "--enable_cameras"]) -def test_deprecated_render_flags_are_rejected(deprecated_arg: str): - """Test that removed render flags are rejected by the parser.""" - parser = argparse.ArgumentParser() - AppLauncher.add_app_launcher_args(parser) - - with pytest.raises(SystemExit): - parser.parse_args([deprecated_arg]) - - -def test_help_on_parser_with_required_positionals(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture): - """Launcher arguments reach the help output of a script that takes required positionals. - - ``add_app_launcher_args`` probes the command line to check for name collisions. That probe - exits when a required argument is missing, which is the case for every tool script invoked - with ``--help``, so it must not take the parser down before the arguments are added. - """ - monkeypatch.setattr("sys.argv", ["convert_urdf.py", "--help"]) - parser = argparse.ArgumentParser() - parser.add_argument("input") - parser.add_argument("output") - - AppLauncher.add_app_launcher_args(parser) - - # the probe's own usage line must not leak to stderr ahead of the real help output - assert capsys.readouterr().err == "" - assert "--visualizer" in parser._option_string_actions - assert "--device" in parser._option_string_actions - - with pytest.raises(SystemExit) as excinfo: - parser.parse_args(["--help"]) - assert excinfo.value.code == 0 - assert "app_launcher arguments" in capsys.readouterr().out - - -@pytest.mark.parametrize("value", ["none", "None"]) -def test_visualizer_none_parsing(value: str): - parser = argparse.ArgumentParser() - AppLauncher.add_app_launcher_args(parser) - args = parser.parse_args(["--viz", value]) - assert args.visualizer is None - assert args.visualizer_explicit is True diff --git a/source/isaaclab/test/app/test_env_var_launch.py b/source/isaaclab/test/app/test_env_var_launch.py deleted file mode 100644 index f40e6347b66e..000000000000 --- a/source/isaaclab/test/app/test_env_var_launch.py +++ /dev/null @@ -1,112 +0,0 @@ -# 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 - -import os -import sys - -import pytest - -import isaaclab.app.app_launcher as app_launcher_module -from isaaclab.app import AppLauncher - -pytestmark = pytest.mark.integration - - -@pytest.mark.usefixtures("mocker") -def test_livestream_launch_with_env_vars(mocker): - """Test launching with environment variables.""" - # Mock the environment variables - mocker.patch.dict(os.environ, {"LIVESTREAM": "1", "HEADLESS": "1"}) - # everything defaults to None - app_launcher = AppLauncher() - app = app_launcher.app - - from isaaclab.app.settings_manager import get_settings_manager - - settings = get_settings_manager() - assert settings.get("/app/window/enabled") is False - # Do not assert /app/livestream/enabled here: - # this key is owned by Kit extensions and is not guaranteed to be populated - # in all app startup paths/environments. AppLauncher behavior is driven by - # its resolved launch state and livestream args. - assert app_launcher._livestream == 1 - assert app_launcher._headless is True - assert "omni.kit.livestream.app" in app_launcher._livestream_args - - # close the app on exit - app.close() - - -def _resolve_kit_args( - monkeypatch: pytest.MonkeyPatch, launcher_args: dict, argv: list[str] | None = None -) -> tuple[list[str], list[str]]: - monkeypatch.setattr(sys, "argv", ["pytest", *(argv or [])]) - launcher = AppLauncher.__new__(AppLauncher) - launcher._resolve_kit_args(launcher_args) - return sys.argv[1:], launcher._kit_args - - -@pytest.mark.parametrize("env_value, expected", [("0", "false"), ("1", "true")]) -def test_fabric_gpu_interop_env_adds_override(monkeypatch: pytest.MonkeyPatch, env_value: str, expected: str): - monkeypatch.setenv(app_launcher_module._FABRIC_GPU_INTEROP_ENV, env_value) - - kit_args, _ = _resolve_kit_args(monkeypatch, {}) - - assert kit_args == [ - f"--/physics/fabricUseGPUInterop={expected}", - "--/exts/isaacsim.core.simulation_manager/enable_default_callbacks=false", - ] - - -def test_fabric_gpu_interop_env_rejects_invalid_value(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setenv(app_launcher_module._FABRIC_GPU_INTEROP_ENV, "false") - - with pytest.raises(ValueError, match="Expected: 0 or 1"): - _resolve_kit_args(monkeypatch, {}) - - -def test_explicit_kit_setting_takes_precedence(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setenv(app_launcher_module._FABRIC_GPU_INTEROP_ENV, "0") - explicit_arg = "--/physics/fabricUseGPUInterop=true" - - kit_args, resolved_args = _resolve_kit_args(monkeypatch, {}, [explicit_arg]) - - assert kit_args == [ - explicit_arg, - "--/exts/isaacsim.core.simulation_manager/enable_default_callbacks=false", - ] - assert resolved_args == ["--/exts/isaacsim.core.simulation_manager/enable_default_callbacks=false"] - - -def test_explicit_kit_args_setting_takes_precedence(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setenv(app_launcher_module._FABRIC_GPU_INTEROP_ENV, "0") - explicit_arg = "--/physics/fabricUseGPUInterop=true" - - kit_args, resolved_args = _resolve_kit_args(monkeypatch, {"kit_args": explicit_arg}) - - assert kit_args == [ - explicit_arg, - "--/exts/isaacsim.core.simulation_manager/enable_default_callbacks=false", - ] - assert resolved_args == [ - explicit_arg, - "--/exts/isaacsim.core.simulation_manager/enable_default_callbacks=false", - ] - - -def test_simulation_manager_default_callbacks_disabled(monkeypatch: pytest.MonkeyPatch): - kit_args, resolved_args = _resolve_kit_args(monkeypatch, {}) - - assert kit_args == ["--/exts/isaacsim.core.simulation_manager/enable_default_callbacks=false"] - assert resolved_args == ["--/exts/isaacsim.core.simulation_manager/enable_default_callbacks=false"] - - -def test_explicit_simulation_manager_callback_setting_takes_precedence(monkeypatch: pytest.MonkeyPatch): - explicit_arg = "--/exts/isaacsim.core.simulation_manager/enable_default_callbacks=true" - - kit_args, resolved_args = _resolve_kit_args(monkeypatch, {"kit_args": explicit_arg}) - - assert kit_args == [explicit_arg] - assert resolved_args == [explicit_arg] diff --git a/source/isaaclab/test/app/test_kwarg_launch.py b/source/isaaclab/test/app/test_kwarg_launch.py deleted file mode 100644 index 7acbd4d4071e..000000000000 --- a/source/isaaclab/test/app/test_kwarg_launch.py +++ /dev/null @@ -1,598 +0,0 @@ -# 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 - -import argparse -import logging -import sys -from types import SimpleNamespace - -import pytest -from isaaclab_newton.physics import NewtonCfg, VBDSolverCfg - -import isaaclab.app as app_module -import isaaclab.app.app_launcher as app_launcher_module -import isaaclab.app.sim_launcher as sim_launcher -import isaaclab.utils as utils_module -from isaaclab.app import AppLauncher -from isaaclab.app.sim_launcher import Scan, _ensure_livestream_kit_visualizer, _get_kit_runtime_sources - -pytestmark = pytest.mark.integration - - -def test_make_physics_cfg_builds_core_vbd(): - physics_cfg = sim_launcher.make_physics_cfg("newton_vbd") - - assert isinstance(physics_cfg, NewtonCfg) - assert isinstance(physics_cfg.solver_cfg, VBDSolverCfg) - - -@pytest.mark.usefixtures("mocker") -def test_livestream_launch_with_kwargs(mocker): - """Test launching with keyword arguments.""" - # everything defaults to None - app_launcher = AppLauncher(headless=True, livestream=1) - app = app_launcher.app - assert app_launcher._livestream == 1 - assert app_launcher._headless is True - - # close the app on exit - app.close() - - -def test_livestream_injects_kit_visualizer_when_missing(): - args = argparse.Namespace(livestream=2, visualizer=None, visualizer_explicit=False) - - _ensure_livestream_kit_visualizer(args) - - assert args.visualizer == ["kit"] - - -def test_livestream_rejects_disabled_visualizers(): - args = argparse.Namespace(livestream=2, visualizer=None, visualizer_explicit=True) - - with pytest.raises(ValueError, match="Livestreaming requires the Kit visualizer"): - _ensure_livestream_kit_visualizer(args) - - -def test_explicit_experience_requires_isaac_sim_runtime(): - """An explicit Kit experience must override a kitless physics configuration.""" - scan = Scan( - resolved_physics_cfg=None, - effective_cfg=object(), - visualizer_intent={"has_any_visualizers": False, "has_kit_visualizer": False}, - has_ovrtx=False, - has_kit_camera=False, - has_kit_physics=False, - has_kitless_physics=True, - has_ovphysx_physics=False, - needs_kit=False, - ) - args = argparse.Namespace(experience="isaaclab.python.kit", visualizer=None) - - assert _get_kit_runtime_sources(scan, args) - - -_XR_KIT = {"xr": True, "visualizer": ["kit"], "visualizer_explicit": True} - - -@pytest.mark.parametrize( - "launcher_args, headless_env, livestream, expected_headless, expected_has_window", - [ - # XR with no CLI visualizer: headless even though the task config asks for a window. - ({"xr": True}, 0, 0, True, False), - # An explicit '--viz kit' is the only way to get a viewport alongside XR. - (_XR_KIT, 0, 0, False, True), - ({"xr": True, "visualizer": ["none"], "visualizer_explicit": True}, 0, 0, True, False), - # ...but an explicit '--viz kit' cannot override HEADLESS=1. - (_XR_KIT, 1, 0, True, False), - # Livestreaming forces headless yet still presents a window to the remote client. - (_XR_KIT, 0, 1, True, True), - ({}, 0, 1, True, True), - # Without XR or livestream the task config still decides. - ({}, 0, 0, False, True), - ], -) -def test_xr_without_explicit_windowed_visualizer_forces_headless( - launcher_args: dict, - headless_env: int, - livestream: int, - expected_headless: bool, - expected_has_window: bool, - monkeypatch: pytest.MonkeyPatch, -): - """Test that enabling XR runs headless unless a windowed visualizer is explicitly requested. - - A task config declaring a windowed visualizer must not leave ``--xr`` opening a window nobody - asked for, and ``HEADLESS=1`` / livestreaming must keep forcing headless even when one was - explicitly requested. Resolution is exercised directly to avoid launching Isaac Sim; what the - resolved state then publishes is asserted by - :func:`test_load_extensions_publishes_has_gui_setting`. - - ``has_window`` is asserted alongside because it is deliberately *not* the negation of - headless: livestreaming is headless but windowed. - """ - monkeypatch.setenv("HEADLESS", str(headless_env)) - # XR is read from the environment too, and is routinely exported by people working on this - # feature -- pin it so the parametrization is what decides. - monkeypatch.delenv("XR", raising=False) - launcher = AppLauncher.__new__(AppLauncher) - launcher._livestream = livestream - args = { - "visualizer_intent": {"has_any_visualizers": True, "has_kit_visualizer": True}, - **launcher_args, - } - - launcher._resolve_visualizer_settings(args) - launcher._resolve_xr_settings(args) - launcher._resolve_headless_settings(args, livestream_arg=-1, livestream_env=0) - - assert launcher._headless is expected_headless - assert launcher.has_window is expected_has_window - - -def test_launch_simulation_preserves_failure_exit_code(monkeypatch: pytest.MonkeyPatch): - close_args = {} - - class _FakeApp: - def close(self, *, exit_code: int = 0) -> None: - close_args["exit_code"] = exit_code - - class _FakeAppLauncher: - def __init__(self, _launcher_args): - self.app = _FakeApp() - - scan = sim_launcher.Scan( - resolved_physics_cfg=None, - effective_cfg=object(), - visualizer_intent={"has_any_visualizers": False, "has_kit_visualizer": False}, - has_ovrtx=False, - has_kit_camera=False, - has_kit_physics=True, - has_kitless_physics=False, - has_ovphysx_physics=False, - needs_kit=True, - ) - monkeypatch.setattr(sim_launcher, "scan", lambda cfg, physics: scan) - monkeypatch.setattr(sim_launcher, "_ensure_isaac_sim_available", lambda: None) - monkeypatch.setattr(app_module, "AppLauncher", _FakeAppLauncher) - monkeypatch.setattr(utils_module, "has_kit", lambda: False) - - with pytest.raises(RuntimeError, match="sentinel"): - with sim_launcher.launch_simulation(object(), argparse.Namespace()): - raise RuntimeError("sentinel") - - assert close_args == {"exit_code": 1} - - -def test_launch_simulation_auto_enables_kit_camera_without_launcher_args(monkeypatch: pytest.MonkeyPatch): - monkeypatch.delenv("LIVESTREAM", raising=False) - received_args = {} - - class _FakeApp: - def close(self) -> None: - pass - - class _FakeAppLauncher: - def __init__(self, launcher_args): - received_args.update(launcher_args) - self.app = _FakeApp() - - scan = sim_launcher.Scan( - resolved_physics_cfg=None, - effective_cfg=object(), - visualizer_intent={"has_any_visualizers": False, "has_kit_visualizer": False}, - has_ovrtx=False, - has_kit_camera=True, - has_kit_physics=False, - has_kitless_physics=False, - has_ovphysx_physics=False, - needs_kit=True, - ) - - def _scan(_cfg, launcher_args): - assert launcher_args == {} - return scan - - monkeypatch.setattr(sim_launcher, "scan", _scan) - monkeypatch.setattr(sim_launcher, "_ensure_isaac_sim_available", lambda: None) - monkeypatch.setattr(app_module, "AppLauncher", _FakeAppLauncher) - monkeypatch.setattr(utils_module, "has_kit", lambda: False) - - with sim_launcher.launch_simulation(object()): - pass - - assert received_args["enable_cameras"] is True - - -def test_deferred_cuda_device_synchronizes_torch_and_warp(monkeypatch: pytest.MonkeyPatch): - """The post-Kit device hook must synchronize both CUDA runtimes.""" - devices = [] - monkeypatch.setattr(app_launcher_module, "set_cuda_device", devices.append) - launcher = AppLauncher.__new__(AppLauncher) - launcher._deferred_cuda_device_id = 2 - - launcher._set_deferred_cuda_device() - - assert devices == [2] - - -def test_preloaded_torch_cuda_is_initialized_before_kit(monkeypatch: pytest.MonkeyPatch): - """Drain PyTorch's queued CUDA checks before Kit can change visible device indices.""" - init_calls = [] - torch = SimpleNamespace( - cuda=SimpleNamespace( - is_initialized=lambda: False, - init=lambda: init_calls.append(True), - ) - ) - monkeypatch.setitem(sys.modules, "torch", torch) - launcher = AppLauncher.__new__(AppLauncher) - launcher._deferred_cuda_device_id = 0 - - launcher._initialize_preloaded_torch_cuda() - - assert init_calls == [True] - - -def test_cuda_initialization_remains_deferred_when_torch_is_not_loaded(monkeypatch: pytest.MonkeyPatch): - """Do not import PyTorch solely to initialize CUDA before Kit.""" - monkeypatch.delitem(sys.modules, "torch", raising=False) - launcher = AppLauncher.__new__(AppLauncher) - launcher._deferred_cuda_device_id = 0 - - launcher._initialize_preloaded_torch_cuda() - - assert "torch" not in sys.modules - - -def test_limit_cpu_threads_forwarded_to_simulation_app(monkeypatch: pytest.MonkeyPatch): - """A SimulationApp thread limit must survive AppLauncher config resolution.""" - monkeypatch.setenv("HEADLESS", "0") - monkeypatch.setenv("LIVESTREAM", "0") - monkeypatch.setenv("XR", "0") - - launcher = AppLauncher.__new__(AppLauncher) - monkeypatch.setattr(launcher, "_resolve_experience_file", lambda _launcher_args: None) - - launcher._config_resolution({"headless": True, "device": "cpu", "limit_cpu_threads": 1}) - - assert launcher._sim_app_config["limit_cpu_threads"] == 1 - - -class _DummySettings: - def __init__(self): - self.values = {} - - def set_string(self, path: str, value: str) -> None: - self.values[path] = value - - def set_int(self, path: str, value: int) -> None: - self.values[path] = value - - def set_bool(self, path: str, value: bool) -> None: - self.values[path] = value - - -@pytest.mark.parametrize("deterministic", [True, False]) -def test_load_extensions_publishes_deterministic_setting(monkeypatch: pytest.MonkeyPatch, deterministic: bool): - """Publish ``/isaaclab/render/deterministic`` from ``_load_extensions``.""" - launcher = AppLauncher.__new__(AppLauncher) - launcher._deterministic_rendering = deterministic - launcher._python_logging_level = logging.ERROR - launcher._headless = True - launcher._livestream = 0 - launcher._enable_cameras = False - launcher._offscreen_render = False - launcher._render_viewport = False - launcher._xr = False - launcher._video_enabled = False - - settings = _DummySettings() - monkeypatch.setattr(app_launcher_module, "initialize_carb_settings", lambda: None) - monkeypatch.setattr(app_launcher_module, "get_settings_manager", lambda: settings) - monkeypatch.setattr(app_launcher_module, "apply_python_logging_level", lambda _level: None) - - launcher._load_extensions() - - assert settings.values["/isaaclab/render/deterministic"] is deterministic - - -@pytest.mark.parametrize( - ("headless", "livestream", "xr", "expected_has_gui", "expected_xr_auto_start"), - [ - pytest.param(False, 0, False, True, False, id="local-window"), - pytest.param(True, 0, False, False, False, id="headless"), - pytest.param(True, 1, False, True, False, id="livestream"), - pytest.param(True, 0, True, True, True, id="xr"), - # XR with a window: the operator starts the session, so it must not auto-start. - pytest.param(False, 0, True, True, False, id="xr-windowed"), - # ...but a windowless XR run must, however that windowless state was reached. - pytest.param(True, 1, True, True, True, id="xr-livestream"), - ], -) -def test_load_extensions_publishes_has_gui_setting( - monkeypatch: pytest.MonkeyPatch, - headless: bool, - livestream: int, - xr: bool, - expected_has_gui: bool, - expected_xr_auto_start: bool, -): - """Publish the GUI and XR auto-start state consumed by SimulationContext and the teleop stack. - - Asserted at the publication site rather than by recomputing the expression, so that changing - what is published fails here instead of silently agreeing with a restated formula. - """ - launcher = AppLauncher.__new__(AppLauncher) - launcher._deterministic_rendering = False - launcher._python_logging_level = logging.ERROR - launcher._headless = headless - launcher._livestream = livestream - launcher._enable_cameras = False - launcher._offscreen_render = False - launcher._render_viewport = False - launcher._xr = xr - launcher._video_enabled = False - - settings = _DummySettings() - monkeypatch.setattr(app_launcher_module, "initialize_carb_settings", lambda: None) - monkeypatch.setattr(app_launcher_module, "get_settings_manager", lambda: settings) - monkeypatch.setattr(app_launcher_module, "apply_python_logging_level", lambda _level: None) - - launcher._load_extensions() - - assert settings.values["/isaaclab/has_gui"] is expected_has_gui - assert settings.values["/isaaclab/xr/auto_start"] is expected_xr_auto_start - - -def test_set_visualizer_settings_stores_values(monkeypatch: pytest.MonkeyPatch): - settings = _DummySettings() - monkeypatch.setattr(app_launcher_module, "get_settings_manager", lambda: settings) - - launcher = AppLauncher.__new__(AppLauncher) - launcher._set_visualizer_settings({"visualizer": ["viser", "rerun"], "max_visible_envs": 0}) - - assert settings.values == { - "/isaaclab/visualizer/types": "viser rerun", - "/isaaclab/visualizer/explicit": False, - "/isaaclab/visualizer/disable_all": False, - "/isaaclab/visualizer/max_visible_envs": 0, - } - - -def test_set_visualizer_settings_rejects_negative_max_visible_envs( - monkeypatch: pytest.MonkeyPatch, -): - def _unexpected_settings_manager(): - raise AssertionError("settings manager should not be queried for invalid values") - - monkeypatch.setattr(app_launcher_module, "get_settings_manager", _unexpected_settings_manager) - - launcher = AppLauncher.__new__(AppLauncher) - with pytest.raises(ValueError, match="Invalid value for --max_visible_envs: -5"): - launcher._set_visualizer_settings({"visualizer": ["viser"], "max_visible_envs": -5}) - - -def test_set_visualizer_settings_suppresses_settings_manager_errors(monkeypatch: pytest.MonkeyPatch): - def _raise_settings_error(): - raise RuntimeError("settings unavailable") - - monkeypatch.setattr(app_launcher_module, "get_settings_manager", _raise_settings_error) - - launcher = AppLauncher.__new__(AppLauncher) - launcher._set_visualizer_settings({"visualizer": ["viser"], "max_visible_envs": 3}) - - -def test_parse_visualizer_csv_accepts_comma_delimited_values(): - parsed = app_launcher_module.AppLauncher._parse_visualizer_csv("kit,newton_gl,rerun,viser") - assert parsed == ["kit", "newton_gl", "rerun", "viser"] - - -def test_parse_visualizer_csv_rejects_spaces_between_entries(): - with pytest.raises(argparse.ArgumentTypeError, match="spaces are not allowed"): - app_launcher_module.AppLauncher._parse_visualizer_csv("kit, newton_gl") - - -def test_resolve_visualizer_settings_rejects_none_with_others(): - launcher = AppLauncher.__new__(AppLauncher) - with pytest.raises(ValueError, match="'none' cannot be combined"): - launcher._resolve_visualizer_settings( - {"visualizer": ["none", "kit"], "visualizer_explicit": True}, - ) - - -def test_visualizer_csv_does_not_swallow_hydra_overrides(): - parser = argparse.ArgumentParser(add_help=False) - app_launcher_module.AppLauncher.add_app_launcher_args(parser) - - args, hydra_args = parser.parse_known_args( - ["--visualizer", "kit,newton_gl,rerun", "presets=newton_mjwarp", "env.episode_length=10"] - ) - - assert args.visualizer == ["kit", "newton_gl", "rerun"] - assert hydra_args == ["presets=newton_mjwarp", "env.episode_length=10"] - - -def _resolve_headless_for_case(monkeypatch: pytest.MonkeyPatch, launcher_args: dict) -> tuple[bool, AppLauncher]: - monkeypatch.setenv("HEADLESS", "0") - launcher = AppLauncher.__new__(AppLauncher) - launcher._livestream = 0 - launcher._resolve_visualizer_settings(launcher_args) - launcher._resolve_headless_settings(launcher_args, livestream_arg=-1, livestream_env=0) - return launcher._headless, launcher - - -def test_matrix_cli_kit_newton_gl_with_custom_kit_cfg_intent_non_headless(monkeypatch: pytest.MonkeyPatch): - headless, launcher = _resolve_headless_for_case( - monkeypatch, - { - "visualizer": ["kit", "newton_gl"], - "visualizer_explicit": True, - "visualizer_intent": {"has_any_visualizers": True, "has_kit_visualizer": True}, - }, - ) - assert headless is False - assert launcher._cli_visualizer_types == ["kit", "newton_gl"] - - -def test_matrix_cli_rerun_with_custom_kit_cfg_intent_headless(monkeypatch: pytest.MonkeyPatch): - headless, launcher = _resolve_headless_for_case( - monkeypatch, - { - "visualizer": ["rerun"], - "visualizer_explicit": True, - "visualizer_intent": {"has_any_visualizers": True, "has_kit_visualizer": True}, - }, - ) - assert headless is True - assert launcher._cli_visualizer_types == ["rerun"] - - -def test_matrix_no_cli_with_cfg_kit_newton_gl_non_headless(monkeypatch: pytest.MonkeyPatch): - headless, launcher = _resolve_headless_for_case( - monkeypatch, - { - "visualizer_intent": {"has_any_visualizers": True, "has_kit_visualizer": True}, - }, - ) - assert headless is False - assert launcher._cli_visualizer_explicit is False - - -def test_matrix_empty_dict_resolves_headless(monkeypatch: pytest.MonkeyPatch): - # tools that launch Kit only to reach an extension API pass no visualizer, and must stay headless - headless, _ = _resolve_headless_for_case(monkeypatch, {}) - assert headless is True - - -def test_matrix_viz_kit_dict_resolves_windowed(monkeypatch: pytest.MonkeyPatch): - # a Kit viewport only exists when the launcher is told to create it - headless, launcher = _resolve_headless_for_case(monkeypatch, {"visualizer": ["kit"]}) - assert headless is False - assert launcher._cli_visualizer_types == ["kit"] - - -@pytest.mark.parametrize("visualizer", [None, ["none"]]) -def test_matrix_viz_none_disables_all_and_headless(monkeypatch: pytest.MonkeyPatch, visualizer): - headless, launcher = _resolve_headless_for_case( - monkeypatch, - { - "visualizer": visualizer, - "visualizer_explicit": True, - "visualizer_intent": {"has_any_visualizers": True, "has_kit_visualizer": True}, - }, - ) - assert headless is True - assert launcher._cli_visualizer_disable_all is True - assert launcher._cli_visualizer_types == [] - - -def test_matrix_headless_flag_deprecated_takes_precedence(monkeypatch: pytest.MonkeyPatch): - headless, launcher = _resolve_headless_for_case( - monkeypatch, - { - "headless": True, - "headless_explicit": True, - "visualizer_intent": {"has_any_visualizers": True, "has_kit_visualizer": True}, - }, - ) - assert headless is True - assert launcher._cli_visualizer_types == [] - - -def test_no_cli_and_no_cfg_visualizers_defaults_headless(monkeypatch: pytest.MonkeyPatch): - headless, _ = _resolve_headless_for_case(monkeypatch, {}) - assert headless is True - - -def test_no_cli_and_non_kit_cfg_visualizers_defaults_headless(monkeypatch: pytest.MonkeyPatch): - headless, _ = _resolve_headless_for_case( - monkeypatch, - {"visualizer_intent": {"has_any_visualizers": True, "has_kit_visualizer": False}}, - ) - assert headless is True - - -def test_invalid_visualizer_intent_rejected(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setenv("HEADLESS", "0") - launcher = AppLauncher.__new__(AppLauncher) - with pytest.raises(ValueError, match="visualizer_intent"): - launcher._resolve_visualizer_settings({"visualizer_intent": {"has_any_visualizers": "yes"}}) - - -def _new_launcher_for_experience_check(): - launcher = AppLauncher.__new__(AppLauncher) - launcher._enable_cameras = False - launcher._headless = False - launcher._xr = False - launcher._deterministic_rendering = False - launcher.is_isaac_sim_version_5 = lambda: False - return launcher - - -def test_rejects_isaacsim_full_streaming_experience_with_livestream(tmp_path, monkeypatch: pytest.MonkeyPatch): - experience = tmp_path / "isaacsim.exp.full.streaming.kit" - experience.write_text('[dependencies]\n"isaacsim.exp.full" = {}\n', encoding="utf-8") - monkeypatch.setenv("EXP_PATH", str(tmp_path)) - launcher = _new_launcher_for_experience_check() - launcher._livestream = 2 - - with pytest.raises(ValueError, match="depends on 'isaacsim.exp.full'"): - launcher._resolve_experience_file({"experience": str(experience)}) - - -def test_rejects_custom_experience_with_isaacsim_full_dependency_and_livestream( - tmp_path, monkeypatch: pytest.MonkeyPatch -): - experience = tmp_path / "merged.kit" - experience.write_text('[dependencies]\n"isaaclab.python" = {}\n"isaacsim.exp.full" = {}\n', encoding="utf-8") - monkeypatch.setenv("EXP_PATH", str(tmp_path)) - launcher = _new_launcher_for_experience_check() - launcher._livestream = 2 - - with pytest.raises(ValueError, match="depends on 'isaacsim.exp.full'"): - launcher._resolve_experience_file({"experience": str(experience)}) - - -def test_allows_isaacsim_full_streaming_experience_when_livestream_disabled(tmp_path, monkeypatch: pytest.MonkeyPatch): - experience = tmp_path / "isaacsim.exp.full.streaming.kit" - experience.write_text('[dependencies]\n"isaacsim.exp.full" = {}\n', encoding="utf-8") - monkeypatch.setenv("EXP_PATH", str(tmp_path)) - launcher = _new_launcher_for_experience_check() - launcher._livestream = 0 - - launcher._resolve_experience_file({"experience": str(experience)}) - - assert launcher._sim_experience_file == str(experience) - - -def test_constructor_reports_missing_isaac_sim(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(app_launcher_module, "SimulationApp", None) - - with pytest.raises(ImportError, match="requires the full Isaac Sim runtime"): - AppLauncher() - - -def test_is_available_reflects_simulation_app_presence(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(app_launcher_module, "SimulationApp", None) - assert AppLauncher.is_available() is False - - monkeypatch.setattr(app_launcher_module, "SimulationApp", object()) - assert AppLauncher.is_available() is True - - -def test_has_gui_reads_published_setting(): - from isaaclab.app.settings_manager import get_settings_manager - - settings = get_settings_manager() - original = settings.get("/isaaclab/has_gui") - try: - settings.set_bool("/isaaclab/has_gui", True) - assert AppLauncher.has_gui() is True - - settings.set_bool("/isaaclab/has_gui", False) - assert AppLauncher.has_gui() is False - finally: - settings.set_bool("/isaaclab/has_gui", bool(original)) diff --git a/source/isaaclab/test/app/test_loading_screen.py b/source/isaaclab/test/app/test_loading_screen.py index 9f5f6f0c32b7..54c16312b0db 100644 --- a/source/isaaclab/test/app/test_loading_screen.py +++ b/source/isaaclab/test/app/test_loading_screen.py @@ -120,13 +120,10 @@ def test_shutdown_restores_output_when_live_rendering_fails(monkeypatch: pytest. restore.assert_called_once_with() -@pytest.mark.parametrize("platform", ["darwin", "win32"]) -def test_redirect_uses_stream_swapping_on_platforms_without_descriptor_redirection( - monkeypatch: pytest.MonkeyPatch, platform: str -): +def test_redirect_uses_stream_swapping_on_platforms_without_descriptor_redirection(monkeypatch: pytest.MonkeyPatch): original_stdout = _OpenStringIO() original_stderr = _OpenStringIO() - monkeypatch.setattr(loading_screen.sys, "platform", platform) + monkeypatch.setattr(loading_screen.sys, "platform", "win32") monkeypatch.setattr(loading_screen.sys, "stdout", original_stdout) monkeypatch.setattr(loading_screen.sys, "stderr", original_stderr) screen = loading_screen.LoadingScreen(1, enabled=True) @@ -140,20 +137,3 @@ def test_redirect_uses_stream_swapping_on_platforms_without_descriptor_redirecti assert screen._console is original_stdout assert loading_screen.sys.stdout is original_stdout assert loading_screen.sys.stderr is original_stderr - - -def test_static_logos_omit_the_tagline(): - assert all("trained" not in logo for logo in loading_screen.LoadingScreen(1)._logos) - - -def test_static_isaac_lab_logos_use_the_original_coloured_artwork(): - expected_colour = "\x1b[38;2;118;185;0m" - - assert expected_colour in loading_screen.LOGO - assert expected_colour in loading_screen.LOGO_WIDE - - -def test_static_nvidia_logo_uses_artwork(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(loading_screen.random, "choice", lambda pairs: pairs[1]) - - assert "▄▄" in loading_screen.LoadingScreen(1)._logos[0] diff --git a/source/isaaclab/test/app/test_non_headless_launch.py b/source/isaaclab/test/app/test_non_headless_launch.py index 3a095af5f09a..2187819b882c 100644 --- a/source/isaaclab/test/app/test_non_headless_launch.py +++ b/source/isaaclab/test/app/test_non_headless_launch.py @@ -4,7 +4,7 @@ # SPDX-License-Identifier: BSD-3-Clause """ -This script checks if the app can be launched with non-headless app and start the simulation. +This script checks that the ``isaaclab.python.kit`` experience launches and steps the simulation without hanging. """ """Launch Isaac Sim Simulator first.""" @@ -39,8 +39,8 @@ class SensorsSceneCfg(InteractiveSceneCfg): def run_simulator( sim: sim_utils.SimulationContext, -): - """Run the simulator.""" +) -> int: + """Run the simulator and return the number of steps taken.""" count = 0 @@ -49,20 +49,18 @@ def run_simulator( # perform step sim.step() count += 1 + return count @pytest.mark.isaacsim_ci -def test_non_headless_launch(): +def test_full_experience_launch_steps(): # Initialize the simulation context sim_cfg = sim_utils.SimulationCfg(dt=0.005) sim = sim_utils.SimulationContext(sim_cfg) # design scene scene_cfg = SensorsSceneCfg(num_envs=1, env_spacing=2.0) - scene = InteractiveScene(scene_cfg) - print(scene) + InteractiveScene(scene_cfg) # Play the simulator sim.reset() - # Now we are ready! - print("[INFO]: Setup complete...") - # Run the simulator - run_simulator(sim) + # the app must keep running for every requested step instead of stopping early + assert run_simulator(sim) == 100 diff --git a/source/isaaclab/test/assets/_articulation_iface_test_utils.py b/source/isaaclab/test/assets/_articulation_iface_test_utils.py index 8dfa6d01a685..b45a4f6e3642 100644 --- a/source/isaaclab/test/assets/_articulation_iface_test_utils.py +++ b/source/isaaclab/test/assets/_articulation_iface_test_utils.py @@ -54,9 +54,12 @@ from isaaclab_ov.assets.articulation.articulation import Articulation as OvPhysxArticulation from isaaclab_ov.assets.articulation.articulation_data import ArticulationData as OvPhysxArticulationData from isaaclab_ov.test.fixtures.views import MockOvPhysxBindingSet + from isaaclab_ov.physics.ovphysx_manager import OvPhysxManager, OvPhysxSceneDataBackend except ImportError as error: BACKEND_UNAVAILABLE_REASONS["ovphysx"] = f"{type(error).__name__}: {error}" else: + # Writers bump the scene-data transform version that ``initialize()`` would normally create. + OvPhysxManager._scene_data_backend = OvPhysxSceneDataBackend() BACKENDS.append("ovphysx") diff --git a/source/isaaclab/test/assets/_rigid_object_collection_iface_test_utils.py b/source/isaaclab/test/assets/_rigid_object_collection_iface_test_utils.py index 271b3dec8b93..034f35554da3 100644 --- a/source/isaaclab/test/assets/_rigid_object_collection_iface_test_utils.py +++ b/source/isaaclab/test/assets/_rigid_object_collection_iface_test_utils.py @@ -65,10 +65,13 @@ RigidObjectCollectionData as OvPhysxRigidObjectCollectionData, ) from isaaclab_ov.test.fixtures.views import MockOvPhysxBindingSet + from isaaclab_ov.physics.ovphysx_manager import OvPhysxManager, OvPhysxSceneDataBackend except ImportError: pass else: if hasattr(OvPhysxRigidObjectCollection, "_create_buffers"): + # Writers bump the scene-data transform version that ``initialize()`` would normally create. + OvPhysxManager._scene_data_backend = OvPhysxSceneDataBackend() BACKENDS.append("ovphysx") diff --git a/source/isaaclab/test/assets/_rigid_object_iface_test_utils.py b/source/isaaclab/test/assets/_rigid_object_iface_test_utils.py index 1d9149f2deb2..d5ca8d5e13ed 100644 --- a/source/isaaclab/test/assets/_rigid_object_iface_test_utils.py +++ b/source/isaaclab/test/assets/_rigid_object_iface_test_utils.py @@ -52,9 +52,12 @@ from isaaclab_ov.assets.rigid_object.rigid_object import RigidObject as OvPhysxRigidObject from isaaclab_ov.assets.rigid_object.rigid_object_data import RigidObjectData as OvPhysxRigidObjectData from isaaclab_ov.test.fixtures.views import MockOvPhysxBindingSet + from isaaclab_ov.physics.ovphysx_manager import OvPhysxManager, OvPhysxSceneDataBackend except ImportError: pass else: + # Writers bump the scene-data transform version that ``initialize()`` would normally create. + OvPhysxManager._scene_data_backend = OvPhysxSceneDataBackend() BACKENDS.append("ovphysx") diff --git a/source/isaaclab/test/assets/test_articulation_iface.py b/source/isaaclab/test/assets/test_articulation_iface.py index 3272734d275d..66c67660f28c 100644 --- a/source/isaaclab/test/assets/test_articulation_iface.py +++ b/source/isaaclab/test/assets/test_articulation_iface.py @@ -13,7 +13,9 @@ The setup is a bit convoluted so that we can run these tests without requiring Isaac Sim or GPU simulation. """ +import math import warnings +from unittest.mock import MagicMock import numpy as np import pytest @@ -21,27 +23,9 @@ import warp as wp from _articulation_iface_test_utils import BACKEND_UNAVAILABLE_REASONS, BACKENDS, get_articulation -pytestmark = pytest.mark.integration - +from isaaclab.test.utils import DeviceScope, test_devices -@pytest.fixture -def articulation_iface(request): - backend = request.getfixturevalue("backend") - num_instances = request.getfixturevalue("num_instances") - num_joints = request.getfixturevalue("num_joints") - num_bodies = request.getfixturevalue("num_bodies") - device = request.getfixturevalue("device") - try: - num_fixed_tendons = request.getfixturevalue("num_fixed_tendons") - except pytest.FixtureLookupError: - num_fixed_tendons = 0 - try: - num_spatial_tendons = request.getfixturevalue("num_spatial_tendons") - except pytest.FixtureLookupError: - num_spatial_tendons = 0 - return get_articulation( - backend, num_instances, num_joints, num_bodies, num_fixed_tendons, num_spatial_tendons, device - ) +pytestmark = pytest.mark.integration # --------------------------------------------------------------------------- @@ -58,15 +42,12 @@ def _check_proxy_array(arr, *, expected_shape: tuple, expected_dtype: type, name assert arr.dtype == expected_dtype, f"{name}: expected dtype {expected_dtype}, got {arr.dtype}" -# Common parametrize decorator for all interface tests +# Common parametrize decorators. Pure bookkeeping (counts, names, finders, aliases) runs on CPU only; +# getters and writers keep every test device because PhysX stages through CPU-pinned buffers on CUDA. _backends = pytest.mark.parametrize("backend", BACKENDS, indirect=False) -# We also need to provide the fixture params that articulation_iface reads: -_default_dims = pytest.mark.parametrize( - "num_instances, num_joints, num_bodies", - [(1, 1, 1), (1, 2, 2), (2, 6, 7), (100, 8, 13)], -) - -_default_devices = pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +_devices = pytest.mark.parametrize("device", test_devices(DeviceScope.CPU_AND_DEFAULT_CUDA)) +# One fixture with distinct instance, joint, and body counts so any swapped axis shows up. +_NUM_INSTANCES, _NUM_JOINTS, _NUM_BODIES = 2, 6, 7 _index_resolution_backends = pytest.mark.parametrize( "backend", [backend for backend in ("physx", "newton") if backend in BACKENDS], indirect=False ) @@ -118,7 +99,7 @@ def test_resolve_body_ids_handles_tensor_view_shape(self, backend): # --------------------------------------------------------------------------- -# Tests: Articulation properties +# Tests: Articulation properties and finders # --------------------------------------------------------------------------- @@ -126,114 +107,23 @@ class TestArticulationProperties: """Test that articulation properties return the correct types/values.""" @_backends - @_default_dims - @_default_devices - def test_num_instances(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - assert art.num_instances == num_instances - - @_backends - @_default_dims - @_default_devices - def test_num_joints(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - assert art.num_joints == num_joints - - @_backends - @_default_dims - @_default_devices - def test_num_bodies(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - assert art.num_bodies == num_bodies - - @_backends - @_default_dims - @_default_devices - def test_is_fixed_base(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - assert isinstance(art.is_fixed_base, bool) - - @_backends - @_default_dims - @_default_devices - def test_joint_names(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - names = art.joint_names - assert isinstance(names, list) - assert len(names) == num_joints - assert all(isinstance(n, str) for n in names) - - @_backends - @_default_dims - @_default_devices - def test_body_names(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - names = art.body_names - assert isinstance(names, list) - assert len(names) == num_bodies - assert all(isinstance(n, str) for n in names) - - @_backends - @_default_dims - @_default_devices - def test_data_returns_articulation_data( - self, backend, num_instances, num_joints, num_bodies, device, articulation_iface - ): + @pytest.mark.parametrize("is_fixed_base", [False, True], ids=["floating", "fixed"]) + def test_articulation_counts_names_and_finders(self, backend, is_fixed_base): from isaaclab.assets.articulation.base_articulation_data import BaseArticulationData - art, _ = articulation_iface - assert isinstance(art.data, BaseArticulationData) - - -# --------------------------------------------------------------------------- -# Tests: Articulation finder methods -# --------------------------------------------------------------------------- - - -class TestArticulationFinders: - """Test that finder methods return (list[int], list[str]) tuples.""" - - @_backends - @_default_dims - @_default_devices - def test_find_bodies_all(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - indices, names = art.find_bodies(".*") - assert isinstance(indices, list) and isinstance(names, list) - assert len(indices) == num_bodies - assert len(names) == num_bodies - assert all(isinstance(i, int) for i in indices) - assert all(isinstance(n, str) for n in names) - - @_backends - @_default_dims - @_default_devices - def test_find_joints_all(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - indices, names = art.find_joints(".*") - assert isinstance(indices, list) and isinstance(names, list) - assert len(indices) == num_joints - assert len(names) == num_joints - - @_backends - @_default_dims - @_default_devices - def test_find_bodies_single(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - first_body = art.body_names[0] - indices, names = art.find_bodies(first_body) - assert indices == [0] - assert names == [first_body] + art, _ = get_articulation( + backend, _NUM_INSTANCES, _NUM_JOINTS, _NUM_BODIES, device="cpu", is_fixed_base=is_fixed_base + ) - @_backends - @_default_dims - @_default_devices - def test_find_joints_single(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - first_joint = art.joint_names[0] - indices, names = art.find_joints(first_joint) - assert indices == [0] - assert names == [first_joint] + assert art.num_instances == _NUM_INSTANCES + assert art.num_joints == _NUM_JOINTS + assert art.num_bodies == _NUM_BODIES + assert art.is_fixed_base is is_fixed_base + for names, count in ((art.joint_names, _NUM_JOINTS), (art.body_names, _NUM_BODIES)): + assert isinstance(names, list) + assert len(names) == count + assert all(isinstance(n, str) for n in names) + assert isinstance(art.data, BaseArticulationData) class TestArticulationFinderReturnModes: @@ -241,17 +131,22 @@ class TestArticulationFinderReturnModes: @_production_backends @pytest.mark.parametrize( - "finder_name", - ["find_bodies", "find_joints", "find_fixed_tendons", "find_spatial_tendons"], + "finder_name, names_attr, count", + [ + ("find_bodies", "body_names", 4), + ("find_joints", "joint_names", 3), + ("find_fixed_tendons", "fixed_tendon_names", 2), + ("find_spatial_tendons", "spatial_tendon_names", 5), + ], ) - def test_finder_returns_legacy_list_or_cached_proxy(self, backend, finder_name): + def test_finder_returns_legacy_list_or_cached_proxy(self, backend, finder_name, names_attr, count): art, _ = get_articulation( backend, num_instances=2, - num_joints=2, - num_bodies=2, + num_joints=3, + num_bodies=4, num_fixed_tendons=2, - num_spatial_tendons=2, + num_spatial_tendons=5, device="cpu", ) if backend == "newton" and finder_name == "find_spatial_tendons": @@ -261,13 +156,20 @@ def test_finder_returns_legacy_list_or_cached_proxy(self, backend, finder_name): indices, names = finder(".*") proxy, proxy_names = finder(".*", as_proxy=True) - assert isinstance(indices, list) + assert isinstance(indices, list) and isinstance(names, list) + assert len(indices) == count + assert len(names) == count + assert all(isinstance(i, int) for i in indices) + assert all(isinstance(n, str) for n in names) assert indices == proxy.torch.tolist() assert names == proxy_names assert proxy is finder(".*", as_proxy=True)[0] assert proxy.dtype == wp.int32 assert str(proxy.device) == art.device + first_name = getattr(art, names_attr)[0] + assert finder(first_name) == ([0], [first_name]) + class TestFixedTendonTargetScheduling: """Commanding a tendon target is what schedules the write, not merely having tendons.""" @@ -285,13 +187,6 @@ def _articulation(backend): ) return art - @_production_backends - def test_carrying_tendons_alone_schedules_no_write(self, backend): - """An articulation that only carries tendons must not resubmit their properties each step.""" - art = self._articulation(backend) - assert art.num_fixed_tendons == 2 - assert art._fixed_tendon_target_dirty is False - @_production_backends def test_commanding_a_target_schedules_the_write(self, backend): """The target setter marks the write pending; a static offset write keeps its own contract.""" @@ -325,35 +220,24 @@ def test_a_solver_without_tendon_transmission_refuses_to_build_an_adapter(self): # --------------------------------------------------------------------------- -_non_mock_backends = pytest.mark.parametrize( - "backend", [backend for backend in BACKENDS if backend.lower() != "mock"], indirect=False -) - - class TestResolveMatchingNamesCache: """Test that resolve_matching_names caching returns correct, isolated results.""" - @_non_mock_backends - @pytest.mark.parametrize("num_instances, num_joints, num_bodies", [(2, 6, 7)]) - @_default_devices - def test_unmatched_regex_raises(self, backend, num_instances, num_joints, num_bodies, device): + @_backends + def test_unmatched_regex_raises(self, backend): """ValueError from resolve_matching_names propagates correctly.""" - art, _ = get_articulation(backend, num_instances, num_joints, num_bodies, device=device) + art, _ = get_articulation(backend, _NUM_INSTANCES, _NUM_JOINTS, _NUM_BODIES, device="cpu") with pytest.raises(ValueError): art.find_bodies("nonexistent_body_xyz") with pytest.raises(ValueError): art.find_joints("nonexistent_joint_xyz") @_backends - @pytest.mark.parametrize("num_instances, num_joints, num_bodies", [(2, 6, 7)]) - @_default_devices - def test_mutating_result_does_not_corrupt_cache( - self, backend, num_instances, num_joints, num_bodies, device, articulation_iface - ): + def test_mutating_result_does_not_corrupt_cache(self, backend): """Mutating returned lists must not affect future cached results.""" - art, _ = articulation_iface + art, _ = get_articulation(backend, _NUM_INSTANCES, _NUM_JOINTS, _NUM_BODIES, device="cpu") - for finder, expected_len in [("find_bodies", num_bodies), ("find_joints", num_joints)]: + for finder, expected_len in [("find_bodies", _NUM_BODIES), ("find_joints", _NUM_JOINTS)]: idx1, names1 = getattr(art, finder)(".*") assert len(idx1) == expected_len @@ -364,23 +248,10 @@ def test_mutating_result_does_not_corrupt_cache( assert len(idx2) == expected_len assert "corrupted" not in names2 - @_non_mock_backends - @pytest.mark.parametrize("num_instances, num_joints, num_bodies", [(2, 6, 7)]) - @_default_devices - def test_find_with_multiple_patterns(self, backend, num_instances, num_joints, num_bodies, device): - """Passing a list of regex patterns works correctly.""" - art, _ = get_articulation(backend, num_instances, num_joints, num_bodies, device=device) - idx, names = art.find_joints(["joint_0", "joint_1"]) - assert "joint_0" in names - assert "joint_1" in names - assert len(names) == 2 - - @_non_mock_backends - @pytest.mark.parametrize("num_instances, num_joints, num_bodies", [(2, 6, 7)]) - @_default_devices - def test_find_with_preserve_order(self, backend, num_instances, num_joints, num_bodies, device): - """preserve_order=True returns names in the order of the input patterns.""" - art, _ = get_articulation(backend, num_instances, num_joints, num_bodies, device=device) + @_backends + def test_find_with_preserve_order(self, backend): + """A list of patterns resolves each name; preserve_order=True keeps the pattern order.""" + art, _ = get_articulation(backend, _NUM_INSTANCES, _NUM_JOINTS, _NUM_BODIES, device="cpu") idx_fwd, names_fwd = art.find_joints(["joint_1", "joint_0"], preserve_order=True) assert names_fwd == ["joint_1", "joint_0"] @@ -389,351 +260,94 @@ def test_find_with_preserve_order(self, backend, num_instances, num_joints, num_ # --------------------------------------------------------------------------- -# Tests: ArticulationData root state properties -# --------------------------------------------------------------------------- - - -class TestArticulationDataRootState: - """Test data properties for root rigid body state.""" - - @_backends - @_default_dims - @_default_devices - def test_root_link_pose_w(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.root_link_pose_w, - expected_shape=(num_instances,), - expected_dtype=wp.transformf, - name="root_link_pose_w", - ) - - @_backends - @_default_dims - @_default_devices - def test_root_link_vel_w(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.root_link_vel_w, - expected_shape=(num_instances,), - expected_dtype=wp.spatial_vectorf, - name="root_link_vel_w", - ) - - @_backends - @_default_dims - @_default_devices - def test_root_com_pose_w(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.root_com_pose_w, - expected_shape=(num_instances,), - expected_dtype=wp.transformf, - name="root_com_pose_w", - ) - - @_backends - @_default_dims - @_default_devices - def test_root_com_vel_w(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.root_com_vel_w, - expected_shape=(num_instances,), - expected_dtype=wp.spatial_vectorf, - name="root_com_vel_w", - ) - - @_backends - @_default_dims - @_default_devices - def test_root_link_pos_w(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.root_link_pos_w, expected_shape=(num_instances,), expected_dtype=wp.vec3f, name="root_link_pos_w" - ) - - @_backends - @_default_dims - @_default_devices - def test_root_link_quat_w(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.root_link_quat_w, expected_shape=(num_instances,), expected_dtype=wp.quatf, name="root_link_quat_w" - ) - - @_backends - @_default_dims - @_default_devices - def test_root_link_lin_vel_w(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.root_link_lin_vel_w, - expected_shape=(num_instances,), - expected_dtype=wp.vec3f, - name="root_link_lin_vel_w", - ) - - @_backends - @_default_dims - @_default_devices - def test_root_link_ang_vel_w(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.root_link_ang_vel_w, - expected_shape=(num_instances,), - expected_dtype=wp.vec3f, - name="root_link_ang_vel_w", - ) - - @_backends - @_default_dims - @_default_devices - def test_root_com_pos_w(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.root_com_pos_w, expected_shape=(num_instances,), expected_dtype=wp.vec3f, name="root_com_pos_w" - ) - - @_backends - @_default_dims - @_default_devices - def test_root_com_quat_w(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.root_com_quat_w, expected_shape=(num_instances,), expected_dtype=wp.quatf, name="root_com_quat_w" - ) - - @_backends - @_default_dims - @_default_devices - def test_root_com_lin_vel_w(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.root_com_lin_vel_w, - expected_shape=(num_instances,), - expected_dtype=wp.vec3f, - name="root_com_lin_vel_w", - ) - - @_backends - @_default_dims - @_default_devices - def test_root_com_ang_vel_w(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.root_com_ang_vel_w, - expected_shape=(num_instances,), - expected_dtype=wp.vec3f, - name="root_com_ang_vel_w", - ) - - -# --------------------------------------------------------------------------- -# Tests: ArticulationData derived properties -# --------------------------------------------------------------------------- - - -class TestArticulationDataDerivedProperties: - """Test derived/computed data properties.""" - - @_backends - @_default_dims - @_default_devices - def test_projected_gravity_b(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.projected_gravity_b, - expected_shape=(num_instances,), - expected_dtype=wp.vec3f, - name="projected_gravity_b", - ) - - @_backends - @_default_dims - @_default_devices - def test_heading_w(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.heading_w, expected_shape=(num_instances,), expected_dtype=wp.float32, name="heading_w" - ) - - @_backends - @_default_dims - @_default_devices - def test_root_link_lin_vel_b(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.root_link_lin_vel_b, - expected_shape=(num_instances,), - expected_dtype=wp.vec3f, - name="root_link_lin_vel_b", - ) - - @_backends - @_default_dims - @_default_devices - def test_root_link_ang_vel_b(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.root_link_ang_vel_b, - expected_shape=(num_instances,), - expected_dtype=wp.vec3f, - name="root_link_ang_vel_b", - ) - - @_backends - @_default_dims - @_default_devices - def test_root_com_lin_vel_b(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.root_com_lin_vel_b, - expected_shape=(num_instances,), - expected_dtype=wp.vec3f, - name="root_com_lin_vel_b", - ) - - @_backends - @_default_dims - @_default_devices - def test_root_com_ang_vel_b(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.root_com_ang_vel_b, - expected_shape=(num_instances,), - expected_dtype=wp.vec3f, - name="root_com_ang_vel_b", - ) - - -# --------------------------------------------------------------------------- -# Tests: ArticulationData body state properties +# Tests: ArticulationData property contract # --------------------------------------------------------------------------- +# (property, shape kind, dtype). Shape kinds: "N" = (num_instances,), "NB" = (num_instances, num_bodies), +# "NB9" = (num_instances, num_bodies, 9), "NJ" = (num_instances, num_joints). +_ARTICULATION_DATA_PROPERTIES = [ + # root state + ("root_link_pose_w", "N", wp.transformf), + ("root_link_vel_w", "N", wp.spatial_vectorf), + ("root_com_pose_w", "N", wp.transformf), + ("root_com_vel_w", "N", wp.spatial_vectorf), + ("root_link_pos_w", "N", wp.vec3f), + ("root_link_quat_w", "N", wp.quatf), + ("root_link_lin_vel_w", "N", wp.vec3f), + ("root_link_ang_vel_w", "N", wp.vec3f), + ("root_com_pos_w", "N", wp.vec3f), + ("root_com_quat_w", "N", wp.quatf), + ("root_com_lin_vel_w", "N", wp.vec3f), + ("root_com_ang_vel_w", "N", wp.vec3f), + # derived + ("projected_gravity_b", "N", wp.vec3f), + ("heading_w", "N", wp.float32), + ("root_link_lin_vel_b", "N", wp.vec3f), + ("root_link_ang_vel_b", "N", wp.vec3f), + ("root_com_lin_vel_b", "N", wp.vec3f), + ("root_com_ang_vel_b", "N", wp.vec3f), + # body state + ("body_link_pose_w", "NB", wp.transformf), + ("body_link_vel_w", "NB", wp.spatial_vectorf), + ("body_com_pose_w", "NB", wp.transformf), + ("body_com_vel_w", "NB", wp.spatial_vectorf), + ("body_com_acc_w", "NB", wp.spatial_vectorf), + ("body_com_pose_b", "NB", wp.transformf), + ("body_mass", "NB", wp.float32), + ("body_inertia", "NB9", wp.float32), + ("body_link_pos_w", "NB", wp.vec3f), + ("body_link_quat_w", "NB", wp.quatf), + ("body_link_lin_vel_w", "NB", wp.vec3f), + ("body_link_ang_vel_w", "NB", wp.vec3f), + ("body_com_pos_w", "NB", wp.vec3f), + ("body_com_quat_w", "NB", wp.quatf), + ("body_com_pos_b", "NB", wp.vec3f), + ("body_com_quat_b", "NB", wp.quatf), + # joint state and properties + ("joint_pos", "NJ", wp.float32), + ("joint_vel", "NJ", wp.float32), + ("joint_acc", "NJ", wp.float32), + ("joint_stiffness", "NJ", wp.float32), + ("joint_damping", "NJ", wp.float32), + ("joint_armature", "NJ", wp.float32), + ("joint_friction_coeff", "NJ", wp.float32), + ("joint_pos_limits", "NJ", wp.vec2f), + ("joint_vel_limits", "NJ", wp.float32), + ("joint_effort_limits", "NJ", wp.float32), + ("soft_joint_pos_limits", "NJ", wp.vec2f), + # defaults and command targets + ("default_root_pose", "N", wp.transformf), + ("default_root_vel", "N", wp.spatial_vectorf), + ("default_joint_pos", "NJ", wp.float32), + ("default_joint_vel", "NJ", wp.float32), + ("joint_pos_target", "NJ", wp.float32), + ("joint_vel_target", "NJ", wp.float32), + ("joint_effort_target", "NJ", wp.float32), +] -class TestArticulationDataBodyState: - """Test data properties for all body states.""" - - @_backends - @_default_dims - @_default_devices - def test_body_link_pose_w(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.body_link_pose_w, - expected_shape=(num_instances, num_bodies), - expected_dtype=wp.transformf, - name="body_link_pose_w", - ) - - @_backends - @_default_dims - @_default_devices - def test_body_link_vel_w(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.body_link_vel_w, - expected_shape=(num_instances, num_bodies), - expected_dtype=wp.spatial_vectorf, - name="body_link_vel_w", - ) - - @_backends - @_default_dims - @_default_devices - def test_body_com_pose_w(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.body_com_pose_w, - expected_shape=(num_instances, num_bodies), - expected_dtype=wp.transformf, - name="body_com_pose_w", - ) - - @_backends - @_default_dims - @_default_devices - def test_body_com_vel_w(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.body_com_vel_w, - expected_shape=(num_instances, num_bodies), - expected_dtype=wp.spatial_vectorf, - name="body_com_vel_w", - ) - @_backends - @_default_dims - @_default_devices - def test_body_com_acc_w(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.body_com_acc_w, - expected_shape=(num_instances, num_bodies), - expected_dtype=wp.spatial_vectorf, - name="body_com_acc_w", - ) +class TestArticulationDataProperties: + """Test that every data property is a ProxyArray with the advertised shape and dtype.""" @_backends - @_default_dims - @_default_devices - def test_body_com_pose_b(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - if backend == "newton": - pytest.xfail("Newton only stores CoM position, not orientation") - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.body_com_pose_b, - expected_shape=(num_instances, num_bodies), - expected_dtype=wp.transformf, - name="body_com_pose_b", - ) - - @pytest.mark.skipif("physx" not in BACKENDS, reason="PhysX backend unavailable") - def test_physx_body_com_pose_b_is_cached_across_sim_timestamps(self): - art, view = get_articulation("physx", num_instances=2, num_joints=3, num_bodies=4, device="cpu") - - num_get_coms_calls = 0 - get_coms = view.get_coms - - def counted_get_coms(): - nonlocal num_get_coms_calls - num_get_coms_calls += 1 - return get_coms() - - view.get_coms = counted_get_coms - - art.data.update(dt=0.01) - art.data.body_com_pose_b - assert num_get_coms_calls == 1 - + @_devices + def test_articulation_data_property_contract(self, backend, device): + art, _ = get_articulation(backend, _NUM_INSTANCES, _NUM_JOINTS, _NUM_BODIES, device=device) art.data.update(dt=0.01) - art.data.body_com_pose_b - assert num_get_coms_calls == 1 + shapes = { + "N": (_NUM_INSTANCES,), + "NB": (_NUM_INSTANCES, _NUM_BODIES), + "NB9": (_NUM_INSTANCES, _NUM_BODIES, 9), + "NJ": (_NUM_INSTANCES, _NUM_JOINTS), + } + for name, shape_kind, dtype in _ARTICULATION_DATA_PROPERTIES: + if backend == "newton" and name == "body_com_pose_b": + # Newton stores a position-only COM and warns that the pose appends a unit quaternion. + with pytest.warns(UserWarning, match="unit quaternion"): + value = getattr(art.data, name) + else: + value = getattr(art.data, name) + _check_proxy_array(value, expected_shape=shapes[shape_kind], expected_dtype=dtype, name=name) @pytest.mark.skipif("physx" not in BACKENDS, reason="PhysX backend unavailable") def test_physx_set_coms_index_updates_body_com_pose_b_cache(self): @@ -779,67 +393,6 @@ def counted_get_coms(): assert num_get_coms_calls == 1 - @pytest.mark.skipif("physx" not in BACKENDS, reason="PhysX backend unavailable") - def test_physx_partial_set_coms_index_initializes_cold_body_com_pose_b_cache(self): - art, view = get_articulation("physx", num_instances=2, num_joints=3, num_bodies=4, device="cpu") - initial_coms = view.get_coms().numpy().copy() - - num_get_coms_calls = 0 - get_coms = view.get_coms - - def counted_get_coms(): - nonlocal num_get_coms_calls - num_get_coms_calls += 1 - return get_coms() - - view.get_coms = counted_get_coms - - coms = wp.zeros((1, 1), dtype=wp.transformf, device="cpu") - art.set_coms_index( - coms=coms, - env_ids=wp.array([0], dtype=wp.int32, device="cpu"), - body_ids=wp.array([0], dtype=wp.int32, device="cpu"), - ) - body_com_pose_b = art.data.body_com_pose_b.torch - - assert num_get_coms_calls == 1 - torch.testing.assert_close(body_com_pose_b[1, 1], torch.from_numpy(initial_coms[1, 1])) - - @pytest.mark.skipif("physx" not in BACKENDS, reason="PhysX backend unavailable") - def test_physx_set_coms_index_invalidates_body_com_pose_b_dependents(self): - art, _ = get_articulation("physx", num_instances=2, num_joints=3, num_bodies=4, device="cpu") - - art.data.update(dt=0.01) - dependent_buffers = [ - ("root_com_pose_w", art.data._root_com_pose_w), - ("root_com_vel_w", art.data._root_com_vel_w), - ("root_link_vel_w", art.data._root_link_vel_w), - ("body_com_pose_w", art.data._body_com_pose_w), - ("body_com_vel_w", art.data._body_com_vel_w), - ("body_link_vel_w", art.data._body_link_vel_w), - ("root_link_lin_vel_b", art.data._root_link_lin_vel_b), - ("root_link_ang_vel_b", art.data._root_link_ang_vel_b), - ("root_com_lin_vel_b", art.data._root_com_lin_vel_b), - ("root_com_ang_vel_b", art.data._root_com_ang_vel_b), - ("root_state_w", art.data._root_state_w), - ("root_link_state_w", art.data._root_link_state_w), - ("root_com_state_w", art.data._root_com_state_w), - ("body_state_w", art.data._body_state_w), - ("body_link_state_w", art.data._body_link_state_w), - ("body_com_state_w", art.data._body_com_state_w), - ("body_com_jacobian_w", art.data._body_com_jacobian_w), - ("mass_matrix", art.data._mass_matrix), - ("gravity_compensation_forces", art.data._gravity_compensation_forces), - ] - for _, buffer in dependent_buffers: - buffer.timestamp = art.data._sim_timestamp - - coms = wp.zeros((art.num_instances, art.num_bodies), dtype=wp.transformf, device="cpu") - art.set_coms_index(coms=coms, full_data=True) - - for name, buffer in dependent_buffers: - assert buffer.timestamp < art.data._sim_timestamp, name - @pytest.mark.skipif("physx" not in BACKENDS, reason="PhysX backend unavailable") def test_physx_identity_ordering_reuses_dynamics_proxy_array_across_refreshes(self): """With identity ordering (the default), ``body_com_jacobian_w``, ``mass_matrix``, and @@ -865,398 +418,67 @@ def test_physx_identity_ordering_reuses_dynamics_proxy_array_across_refreshes(se assert mass_matrix_wrapper_a is mass_matrix_wrapper_b assert gravity_wrapper_a is gravity_wrapper_b - @_backends - @_default_dims - @_default_devices - def test_body_mass(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.body_mass, expected_shape=(num_instances, num_bodies), expected_dtype=wp.float32, name="body_mass" - ) + @_production_backends + def test_actuator_compatibility_projections_are_stable(self, backend): + num_instances, num_joints = 2, 4 + art, _ = get_articulation(backend, num_instances, num_joints, 5, device="cpu") + soft_joint_vel_limits = torch.tensor([[0.5, 1.0, 1.5, 2.0], [2.5, 3.0, 3.5, 4.0]], dtype=torch.float32) + wp.copy(art.actuators._soft_joint_vel_limits, wp.from_torch(soft_joint_vel_limits)) + + with warnings.catch_warnings(record=True) as caught_warnings: + warnings.simplefilter("always") + soft_joint_vel_limits_data = art.data.soft_joint_vel_limits + soft_joint_vel_limits_repeat = art.data.soft_joint_vel_limits - @_backends - @_default_dims - @_default_devices - def test_body_inertia(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - art.data.update(dt=0.01) _check_proxy_array( - art.data.body_inertia, - expected_shape=(num_instances, num_bodies, 9), + soft_joint_vel_limits_data, + expected_shape=(num_instances, num_joints), expected_dtype=wp.float32, - name="body_inertia", - ) - - @_backends - @_default_dims - @_default_devices - def test_body_link_pos_w(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.body_link_pos_w, - expected_shape=(num_instances, num_bodies), - expected_dtype=wp.vec3f, - name="body_link_pos_w", - ) - - @_backends - @_default_dims - @_default_devices - def test_body_link_quat_w(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.body_link_quat_w, - expected_shape=(num_instances, num_bodies), - expected_dtype=wp.quatf, - name="body_link_quat_w", - ) - - @_backends - @_default_dims - @_default_devices - def test_body_link_lin_vel_w(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.body_link_lin_vel_w, - expected_shape=(num_instances, num_bodies), - expected_dtype=wp.vec3f, - name="body_link_lin_vel_w", - ) - - @_backends - @_default_dims - @_default_devices - def test_body_link_ang_vel_w(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.body_link_ang_vel_w, - expected_shape=(num_instances, num_bodies), - expected_dtype=wp.vec3f, - name="body_link_ang_vel_w", - ) - - @_backends - @_default_dims - @_default_devices - def test_body_com_pos_w(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.body_com_pos_w, - expected_shape=(num_instances, num_bodies), - expected_dtype=wp.vec3f, - name="body_com_pos_w", - ) - - @_backends - @_default_dims - @_default_devices - def test_body_com_quat_w(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.body_com_quat_w, - expected_shape=(num_instances, num_bodies), - expected_dtype=wp.quatf, - name="body_com_quat_w", - ) - - @_backends - @_default_dims - @_default_devices - def test_body_com_pos_b(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.body_com_pos_b, - expected_shape=(num_instances, num_bodies), - expected_dtype=wp.vec3f, - name="body_com_pos_b", - ) - - @_backends - @_default_dims - @_default_devices - def test_body_com_quat_b(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - if backend == "newton": - pytest.xfail("Newton only stores CoM position, not orientation") - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.body_com_quat_b, - expected_shape=(num_instances, num_bodies), - expected_dtype=wp.quatf, - name="body_com_quat_b", - ) - - -class TestArticulationDataJointState: - """Test data properties for joint state and joint properties.""" - - @_backends - @_default_dims - @_default_devices - def test_joint_pos(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.joint_pos, expected_shape=(num_instances, num_joints), expected_dtype=wp.float32, name="joint_pos" - ) - - @_backends - @_default_dims - @_default_devices - def test_joint_vel(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.joint_vel, expected_shape=(num_instances, num_joints), expected_dtype=wp.float32, name="joint_vel" - ) - - @_backends - @_default_dims - @_default_devices - def test_joint_acc(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.joint_acc, expected_shape=(num_instances, num_joints), expected_dtype=wp.float32, name="joint_acc" - ) - - @_backends - @_default_dims - @_default_devices - def test_joint_stiffness(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.joint_stiffness, - expected_shape=(num_instances, num_joints), - expected_dtype=wp.float32, - name="joint_stiffness", - ) - - @_backends - @_default_dims - @_default_devices - def test_joint_damping(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.joint_damping, - expected_shape=(num_instances, num_joints), - expected_dtype=wp.float32, - name="joint_damping", - ) - - @_backends - @_default_dims - @_default_devices - def test_joint_armature(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.joint_armature, - expected_shape=(num_instances, num_joints), - expected_dtype=wp.float32, - name="joint_armature", - ) - - @_backends - @_default_dims - @_default_devices - def test_joint_friction_coeff(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.joint_friction_coeff, - expected_shape=(num_instances, num_joints), - expected_dtype=wp.float32, - name="joint_friction_coeff", - ) - - @_backends - @_default_dims - @_default_devices - def test_joint_pos_limits(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.joint_pos_limits, - expected_shape=(num_instances, num_joints), - expected_dtype=wp.vec2f, - name="joint_pos_limits", - ) - - @_backends - @_default_dims - @_default_devices - def test_joint_vel_limits(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.joint_vel_limits, - expected_shape=(num_instances, num_joints), - expected_dtype=wp.float32, - name="joint_vel_limits", - ) - - @_backends - @_default_dims - @_default_devices - def test_joint_effort_limits(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.joint_effort_limits, - expected_shape=(num_instances, num_joints), - expected_dtype=wp.float32, - name="joint_effort_limits", - ) - - @_backends - @_default_dims - @_default_devices - def test_soft_joint_pos_limits(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.soft_joint_pos_limits, - expected_shape=(num_instances, num_joints), - expected_dtype=wp.vec2f, - name="soft_joint_pos_limits", + name="soft_joint_vel_limits", ) + torch.testing.assert_close(soft_joint_vel_limits_data.torch, soft_joint_vel_limits, rtol=0.0, atol=0.0) + assert soft_joint_vel_limits_data.warp.ptr == soft_joint_vel_limits_repeat.warp.ptr + assert soft_joint_vel_limits_data.warp.ptr == art.actuators._soft_joint_vel_limits.ptr + assert not [warning for warning in caught_warnings if warning.category is DeprecationWarning] # --------------------------------------------------------------------------- -# Tests: ArticulationData defaults and command targets +# Tests: Alias/shorthand properties # --------------------------------------------------------------------------- +_ARTICULATION_ALIASES = [ + ("root_pose_w", "root_link_pose_w"), + ("root_pos_w", "root_link_pos_w"), + ("root_quat_w", "root_link_quat_w"), + ("root_vel_w", "root_com_vel_w"), + ("root_lin_vel_w", "root_com_lin_vel_w"), + ("root_ang_vel_w", "root_com_ang_vel_w"), + ("body_pose_w", "body_link_pose_w"), + ("body_pos_w", "body_link_pos_w"), + ("body_quat_w", "body_link_quat_w"), + ("body_vel_w", "body_com_vel_w"), + ("body_lin_vel_w", "body_com_lin_vel_w"), + ("body_ang_vel_w", "body_com_ang_vel_w"), + ("joint_limits", "joint_pos_limits"), + ("joint_friction", "joint_friction_coeff"), +] -class TestArticulationDataDefaults: - """Test default state and command target properties.""" - - @_backends - @_default_dims - @_default_devices - def test_default_root_pose(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.default_root_pose, - expected_shape=(num_instances,), - expected_dtype=wp.transformf, - name="default_root_pose", - ) - - @_backends - @_default_dims - @_default_devices - def test_default_root_vel(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.default_root_vel, - expected_shape=(num_instances,), - expected_dtype=wp.spatial_vectorf, - name="default_root_vel", - ) - - @_backends - @_default_dims - @_default_devices - def test_default_joint_pos(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.default_joint_pos, - expected_shape=(num_instances, num_joints), - expected_dtype=wp.float32, - name="default_joint_pos", - ) - - @_backends - @_default_dims - @_default_devices - def test_default_joint_vel(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.default_joint_vel, - expected_shape=(num_instances, num_joints), - expected_dtype=wp.float32, - name="default_joint_vel", - ) - - @_backends - @_default_dims - @_default_devices - def test_joint_pos_target(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.joint_pos_target, - expected_shape=(num_instances, num_joints), - expected_dtype=wp.float32, - name="joint_pos_target", - ) - @_backends - @_default_dims - @_default_devices - def test_joint_vel_target(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.joint_vel_target, - expected_shape=(num_instances, num_joints), - expected_dtype=wp.float32, - name="joint_vel_target", - ) +class TestArticulationDataAliases: + """Test that alias properties return the values of their canonical counterparts.""" @_backends - @_default_dims - @_default_devices - def test_joint_effort_target(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface + def test_aliases_match_canonical_values(self, backend): + # Random mock state makes link and COM quantities differ, so a retargeted alias fails. + art, _ = get_articulation(backend, _NUM_INSTANCES, _NUM_JOINTS, _NUM_BODIES, device="cpu") art.data.update(dt=0.01) - _check_proxy_array( - art.data.joint_effort_target, - expected_shape=(num_instances, num_joints), - expected_dtype=wp.float32, - name="joint_effort_target", - ) - - @_production_backends - @pytest.mark.parametrize("num_instances, num_joints, num_bodies", [(2, 4, 5)]) - @pytest.mark.parametrize("device", ["cpu"]) - def test_actuator_compatibility_projections_are_stable( - self, backend, num_instances, num_joints, num_bodies, device, articulation_iface - ): - art, _ = articulation_iface - soft_joint_vel_limits = torch.tensor([[0.5, 1.0, 1.5, 2.0], [2.5, 3.0, 3.5, 4.0]], dtype=torch.float32) - wp.copy(art.actuators._soft_joint_vel_limits, wp.from_torch(soft_joint_vel_limits)) - - with warnings.catch_warnings(record=True) as caught_warnings: - warnings.simplefilter("always") - soft_joint_vel_limits_data = art.data.soft_joint_vel_limits - soft_joint_vel_limits_repeat = art.data.soft_joint_vel_limits + d = art.data - _check_proxy_array( - soft_joint_vel_limits_data, - expected_shape=(num_instances, num_joints), - expected_dtype=wp.float32, - name="soft_joint_vel_limits", - ) - torch.testing.assert_close(soft_joint_vel_limits_data.torch, soft_joint_vel_limits, rtol=0.0, atol=0.0) - assert soft_joint_vel_limits_data.warp.ptr == soft_joint_vel_limits_repeat.warp.ptr - assert soft_joint_vel_limits_data.warp.ptr == art.actuators._soft_joint_vel_limits.ptr - assert not [warning for warning in caught_warnings if warning.category is DeprecationWarning] + for alias, canonical in _ARTICULATION_ALIASES: + alias_value, canonical_value = getattr(d, alias), getattr(d, canonical) + assert alias_value.shape == canonical_value.shape, alias + assert alias_value.dtype == canonical_value.dtype, alias + assert torch.equal(alias_value.torch, canonical_value.torch), alias # --------------------------------------------------------------------------- @@ -1309,6 +531,38 @@ def _make_data_warp(shape: tuple, device: str, wp_dtype=wp.float32) -> wp.array: return wp.from_torch(t.contiguous(), dtype=wp_dtype) +def _make_payload_torch(shape: tuple, device: str, wp_dtype=wp.float32) -> torch.Tensor: + """Create valid torch data whose entries differ per element, for writer read-back checks. + + Transforms get distinct positions and a fixed 90-degree rotation about Z; ``vec2f`` limits get + distinct ``[-v, v]`` pairs. + """ + count = math.prod(shape) + values = torch.arange(1, count + 1, dtype=torch.float32, device=device).reshape(shape) + 0.25 + if wp_dtype == wp.float32: + return values + if wp_dtype == wp.vec2f: + return torch.stack((-values, values), dim=-1) + if wp_dtype == wp.spatial_vectorf: + return values.unsqueeze(-1) + torch.arange(6, dtype=torch.float32, device=device) / 10.0 + if wp_dtype == wp.transformf: + data = torch.zeros((*shape, 7), dtype=torch.float32, device=device) + data[..., :3] = values.unsqueeze(-1) + torch.arange(3, dtype=torch.float32, device=device) / 10.0 + data[..., 5] = data[..., 6] = 2.0**-0.5 + return data + raise ValueError(f"Unsupported payload dtype: {wp_dtype}") + + +def _make_payload_warp(shape: tuple, device: str, wp_dtype=wp.float32) -> wp.array: + """Create the per-element distinct payload of :func:`_make_payload_torch` as a warp array.""" + return wp.from_torch(_make_payload_torch(shape, device, wp_dtype).contiguous(), dtype=wp_dtype) + + +def _assert_reads_back(value, expected: torch.Tensor, name: str) -> None: + """Assert a data getter (ProxyArray) returns the values a writer just wrote.""" + torch.testing.assert_close(value.torch, expected, atol=1e-5, rtol=1e-5, msg=lambda msg: f"{name}: {msg}") + + def _make_com_data(backend: str, shape: tuple[int, ...], device: str) -> wp.array: """Create backend-compatible center-of-mass test data.""" if backend == "newton": @@ -1372,17 +626,21 @@ def _make_item_mask(total: int, selected: list[int], device: str) -> wp.array: return wp.array(mask_np, dtype=wp.bool, device=device) -# --------------------------------------------------------------------------- -# Tests: Articulation operations -# --------------------------------------------------------------------------- - - # --------------------------------------------------------------------------- # Tests: Root writers — torch/warp × index/mask × all/subset × negative # --------------------------------------------------------------------------- -_ROOT_POSE_METHODS = ["root_pose", "root_link_pose", "root_com_pose"] -_ROOT_VEL_METHODS = ["root_velocity", "root_link_velocity", "root_com_velocity"] +# writer suffix -> data getter that reads the written quantity back +_ROOT_POSE_METHODS = { + "root_pose": "root_link_pose_w", + "root_link_pose": "root_link_pose_w", + "root_com_pose": "root_com_pose_w", +} +_ROOT_VEL_METHODS = { + "root_velocity": "root_com_vel_w", + "root_link_velocity": "root_link_vel_w", + "root_com_velocity": "root_com_vel_w", +} class TestArticulationWritersRoot: @@ -1482,6 +740,13 @@ def test_set_coms_invalidates_same_timestamp_dependents(self, backend, setter_ki ("root_com_vel_w", "_root_com_vel_w"), ("body_com_vel_w", "_body_com_vel_w"), ] + if backend == "physx": + # COM changes also move the COM Jacobian and the generalized dynamics. + common_pairs += [ + ("body_com_jacobian_w", "_body_com_jacobian_w"), + ("mass_matrix", "_mass_matrix"), + ("gravity_compensation_forces", "_gravity_compensation_forces"), + ] state_buffer_suffix = "_buf" if backend == "ovphysx" else "" common_pairs += [ ("root_state_w", f"_root_state_w{state_buffer_suffix}"), @@ -1495,6 +760,9 @@ def test_set_coms_invalidates_same_timestamp_dependents(self, backend, setter_ki buffers = _prime_timestamped_properties(art.data, common_pairs) if backend == "newton": buffers += _prime_timestamped_properties(art.data, [("body_com_pose_b", "_body_com_pose_b")]) + if backend == "ovphysx" and art.data._body_com_vel_w_backend is not None: + art.data._body_com_vel_w_backend.timestamp = art.data._sim_timestamp + buffers.append(("_body_com_vel_w_backend", art.data._body_com_vel_w_backend)) coms = _make_com_data(backend, (art.num_instances, art.num_bodies), "cpu") def set_coms() -> None: @@ -1518,13 +786,11 @@ def set_coms() -> None: # -- index variants -- @_backends - @_default_dims - @_default_devices + @_devices @pytest.mark.parametrize("method_suffix", _ROOT_POSE_METHODS) - def test_write_root_pose_to_sim_index( - self, backend, num_instances, num_joints, num_bodies, device, articulation_iface, method_suffix - ): - art, _ = articulation_iface + def test_write_root_pose_to_sim_index(self, backend, device, method_suffix): + art, _ = get_articulation(backend, _NUM_INSTANCES, _NUM_JOINTS, _NUM_BODIES, device=device) + num_instances = _NUM_INSTANCES art.data.update(dt=0.01) method = getattr(art, f"write_{method_suffix}_to_sim_index") @@ -1532,8 +798,12 @@ def test_write_root_pose_to_sim_index( method(root_pose=_make_data_torch((num_instances,), device, wp.transformf)) # torch, subset method(root_pose=_make_data_torch((1,), device, wp.transformf), env_ids=_make_env_ids(device, True)) - # warp, all envs - method(root_pose=_make_data_warp((num_instances,), device, wp.transformf)) + # warp, all envs: the matching getter reads the written poses back + method(root_pose=_make_payload_warp((num_instances,), device, wp.transformf)) + getter = _ROOT_POSE_METHODS[method_suffix] + _assert_reads_back( + getattr(art.data, getter), _make_payload_torch((num_instances,), device, wp.transformf), getter + ) # warp, subset method(root_pose=_make_data_warp((1,), device, wp.transformf), env_ids=_make_env_ids(device, True)) # negative: bad torch shape @@ -1544,13 +814,11 @@ def test_write_root_pose_to_sim_index( method(root_pose=_make_bad_data_warp((num_instances,), device, wp.transformf)) @_backends - @_default_dims - @_default_devices + @_devices @pytest.mark.parametrize("method_suffix", _ROOT_VEL_METHODS) - def test_write_root_velocity_to_sim_index( - self, backend, num_instances, num_joints, num_bodies, device, articulation_iface, method_suffix - ): - art, _ = articulation_iface + def test_write_root_velocity_to_sim_index(self, backend, device, method_suffix): + art, _ = get_articulation(backend, _NUM_INSTANCES, _NUM_JOINTS, _NUM_BODIES, device=device) + num_instances = _NUM_INSTANCES art.data.update(dt=0.01) method = getattr(art, f"write_{method_suffix}_to_sim_index") @@ -1558,8 +826,12 @@ def test_write_root_velocity_to_sim_index( method(root_velocity=_make_data_torch((num_instances,), device, wp.spatial_vectorf)) # torch, subset method(root_velocity=_make_data_torch((1,), device, wp.spatial_vectorf), env_ids=_make_env_ids(device, True)) - # warp, all envs - method(root_velocity=_make_data_warp((num_instances,), device, wp.spatial_vectorf)) + # warp, all envs: the matching getter reads the written velocities back + method(root_velocity=_make_payload_warp((num_instances,), device, wp.spatial_vectorf)) + getter = _ROOT_VEL_METHODS[method_suffix] + _assert_reads_back( + getattr(art.data, getter), _make_payload_torch((num_instances,), device, wp.spatial_vectorf), getter + ) # warp, subset method(root_velocity=_make_data_warp((1,), device, wp.spatial_vectorf), env_ids=_make_env_ids(device, True)) # negative: bad torch shape @@ -1572,13 +844,11 @@ def test_write_root_velocity_to_sim_index( # -- mask variants -- @_backends - @_default_dims - @_default_devices + @_devices @pytest.mark.parametrize("method_suffix", _ROOT_POSE_METHODS) - def test_write_root_pose_to_sim_mask( - self, backend, num_instances, num_joints, num_bodies, device, articulation_iface, method_suffix - ): - art, _ = articulation_iface + def test_write_root_pose_to_sim_mask(self, backend, device, method_suffix): + art, _ = get_articulation(backend, _NUM_INSTANCES, _NUM_JOINTS, _NUM_BODIES, device=device) + num_instances = _NUM_INSTANCES art.data.update(dt=0.01) method = getattr(art, f"write_{method_suffix}_to_sim_mask") @@ -1589,8 +859,12 @@ def test_write_root_pose_to_sim_mask( root_pose=_make_data_torch((num_instances,), device, wp.transformf), env_mask=_make_env_mask(num_instances, device, True), ) - # warp, no mask - method(root_pose=_make_data_warp((num_instances,), device, wp.transformf)) + # warp, no mask: the matching getter reads the written poses back + method(root_pose=_make_payload_warp((num_instances,), device, wp.transformf)) + getter = _ROOT_POSE_METHODS[method_suffix] + _assert_reads_back( + getattr(art.data, getter), _make_payload_torch((num_instances,), device, wp.transformf), getter + ) # warp, partial mask method( root_pose=_make_data_warp((num_instances,), device, wp.transformf), @@ -1604,13 +878,11 @@ def test_write_root_pose_to_sim_mask( method(root_pose=_make_bad_data_warp((num_instances,), device, wp.transformf)) @_backends - @_default_dims - @_default_devices + @_devices @pytest.mark.parametrize("method_suffix", _ROOT_VEL_METHODS) - def test_write_root_velocity_to_sim_mask( - self, backend, num_instances, num_joints, num_bodies, device, articulation_iface, method_suffix - ): - art, _ = articulation_iface + def test_write_root_velocity_to_sim_mask(self, backend, device, method_suffix): + art, _ = get_articulation(backend, _NUM_INSTANCES, _NUM_JOINTS, _NUM_BODIES, device=device) + num_instances = _NUM_INSTANCES art.data.update(dt=0.01) method = getattr(art, f"write_{method_suffix}_to_sim_mask") @@ -1621,8 +893,12 @@ def test_write_root_velocity_to_sim_mask( root_velocity=_make_data_torch((num_instances,), device, wp.spatial_vectorf), env_mask=_make_env_mask(num_instances, device, True), ) - # warp, no mask - method(root_velocity=_make_data_warp((num_instances,), device, wp.spatial_vectorf)) + # warp, no mask: the matching getter reads the written velocities back + method(root_velocity=_make_payload_warp((num_instances,), device, wp.spatial_vectorf)) + getter = _ROOT_VEL_METHODS[method_suffix] + _assert_reads_back( + getattr(art.data, getter), _make_payload_torch((num_instances,), device, wp.spatial_vectorf), getter + ) # warp, partial mask method( root_velocity=_make_data_warp((num_instances,), device, wp.spatial_vectorf), @@ -1636,65 +912,62 @@ def test_write_root_velocity_to_sim_mask( method(root_velocity=_make_bad_data_warp((num_instances,), device, wp.spatial_vectorf)) -@_backends -@pytest.mark.parametrize("num_instances, num_joints, num_bodies", [(2, 4, 5)]) -@pytest.mark.parametrize("device", ["cpu"]) -def test_deprecated_joint_friction_writers(backend, num_instances, num_joints, num_bodies, device, articulation_iface): - """The deprecated joint friction writers forward to the index writer on every backend.""" - art, _ = articulation_iface - friction = torch.full((num_instances, num_joints), 0.5, device=device) - with pytest.warns(DeprecationWarning): - art.write_joint_friction_coefficient_to_sim(friction) - with pytest.warns(DeprecationWarning): - art.write_joint_friction_to_sim(friction) +@pytest.mark.parametrize("backend", BACKENDS) +def test_deprecated_joint_friction_writers(backend): + """The deprecated joint friction writers warn and forward to the index writer on every backend.""" + num_instances, num_joints = 2, 4 + art, _ = get_articulation(backend, num_instances, num_joints, 5, device="cpu") + for writer_name, value in (("write_joint_friction_coefficient_to_sim", 0.5), ("write_joint_friction_to_sim", 0.25)): + friction = torch.full((num_instances, num_joints), value) + with pytest.warns(DeprecationWarning): + getattr(art, writer_name)(friction) + _assert_reads_back(art.data.joint_friction_coeff, friction, writer_name) # --------------------------------------------------------------------------- # Tests: Joint writers — torch/warp × index/mask × all/subset × negative # --------------------------------------------------------------------------- -# (method_name, kwarg_name, wp_dtype, accepts_float) +# (method_name, kwarg_name, wp_dtype, accepts_float, read-back getter) _JOINT_METHODS = [ - ("write_joint_position_to_sim", "position", wp.float32, False), - ("write_joint_velocity_to_sim", "velocity", wp.float32, False), - ("write_joint_stiffness_to_sim", "stiffness", wp.float32, True), - ("write_joint_damping_to_sim", "damping", wp.float32, True), - ("write_joint_position_limit_to_sim", "limits", wp.vec2f, True), - ("write_joint_velocity_limit_to_sim", "limits", wp.float32, True), - ("write_joint_effort_limit_to_sim", "limits", wp.float32, True), - ("write_joint_armature_to_sim", "armature", wp.float32, True), - ("write_joint_friction_coefficient_to_sim", "joint_friction_coeff", wp.float32, False), - ("set_joint_position_target", "target", wp.float32, False), - ("set_joint_velocity_target", "target", wp.float32, False), - ("set_joint_effort_target", "target", wp.float32, False), + ("write_joint_position_to_sim", "position", wp.float32, False, "joint_pos"), + ("write_joint_velocity_to_sim", "velocity", wp.float32, False, "joint_vel"), + ("write_joint_stiffness_to_sim", "stiffness", wp.float32, True, "joint_stiffness"), + ("write_joint_damping_to_sim", "damping", wp.float32, True, "joint_damping"), + ("write_joint_position_limit_to_sim", "limits", wp.vec2f, True, "joint_pos_limits"), + ("write_joint_velocity_limit_to_sim", "limits", wp.float32, True, "joint_vel_limits"), + ("write_joint_effort_limit_to_sim", "limits", wp.float32, True, "joint_effort_limits"), + ("write_joint_armature_to_sim", "armature", wp.float32, True, "joint_armature"), + ("write_joint_friction_coefficient_to_sim", "joint_friction_coeff", wp.float32, False, "joint_friction_coeff"), + ("set_joint_position_target", "target", wp.float32, False, "actuators.target_command.position"), + ("set_joint_velocity_target", "target", wp.float32, False, "actuators.target_command.velocity"), + ("set_joint_effort_target", "target", wp.float32, False, "actuators.target_command.effort"), ] +def _read_joint_writer_target(art, getter: str): + """Resolve a read-back getter: a data property, or a dotted path on the articulation.""" + if "." not in getter: + return getattr(art.data, getter) + value = art + for attr in getter.split("."): + value = getattr(value, attr) + return value + + class TestArticulationWritersJoint: """Test joint writers/setters with all input combinations.""" @_backends - @_default_dims - @_default_devices + @_devices @pytest.mark.parametrize( - "method_base, kwarg, wp_dtype, accepts_float", + "method_base, kwarg, wp_dtype, accepts_float, getter", _JOINT_METHODS, ids=[m[0] for m in _JOINT_METHODS], ) - def test_joint_writer_index( - self, - backend, - num_instances, - num_joints, - num_bodies, - device, - articulation_iface, - method_base, - kwarg, - wp_dtype, - accepts_float, - ): - art, _ = articulation_iface + def test_joint_writer_index(self, backend, device, method_base, kwarg, wp_dtype, accepts_float, getter): + art, _ = get_articulation(backend, _NUM_INSTANCES, _NUM_JOINTS, _NUM_BODIES, device=device) + num_instances, num_joints = _NUM_INSTANCES, _NUM_JOINTS art.data.update(dt=0.01) method = getattr(art, f"{method_base}_index") sub_j = min(2, num_joints) @@ -1710,8 +983,13 @@ def test_joint_writer_index( "env_ids": _make_env_ids(device, True), } ) - # warp, all envs + all joints - method(**{kwarg: _make_data_warp((num_instances, num_joints), device, wp_dtype)}) + # warp, all envs + all joints: the matching getter reads the written values back + method(**{kwarg: _make_payload_warp((num_instances, num_joints), device, wp_dtype)}) + _assert_reads_back( + _read_joint_writer_target(art, getter), + _make_payload_torch((num_instances, num_joints), device, wp_dtype), + getter, + ) # warp, subset method( **{ @@ -1735,27 +1013,15 @@ def test_joint_writer_index( method(**{kwarg: _make_bad_data_warp((num_instances, num_joints), device, wp_dtype)}) @_backends - @_default_dims - @_default_devices + @_devices @pytest.mark.parametrize( - "method_base, kwarg, wp_dtype, accepts_float", + "method_base, kwarg, wp_dtype, accepts_float, getter", _JOINT_METHODS, ids=[m[0] for m in _JOINT_METHODS], ) - def test_joint_writer_mask( - self, - backend, - num_instances, - num_joints, - num_bodies, - device, - articulation_iface, - method_base, - kwarg, - wp_dtype, - accepts_float, - ): - art, _ = articulation_iface + def test_joint_writer_mask(self, backend, device, method_base, kwarg, wp_dtype, accepts_float, getter): + art, _ = get_articulation(backend, _NUM_INSTANCES, _NUM_JOINTS, _NUM_BODIES, device=device) + num_instances, num_joints = _NUM_INSTANCES, _NUM_JOINTS art.data.update(dt=0.01) method = getattr(art, f"{method_base}_mask") sub_joint_sel = list(range(min(2, num_joints))) @@ -1770,8 +1036,13 @@ def test_joint_writer_mask( "env_mask": _make_env_mask(num_instances, device, True), } ) - # warp, no mask - method(**{kwarg: _make_data_warp((num_instances, num_joints), device, wp_dtype)}) + # warp, no mask: the matching getter reads the written values back + method(**{kwarg: _make_payload_warp((num_instances, num_joints), device, wp_dtype)}) + _assert_reads_back( + _read_joint_writer_target(art, getter), + _make_payload_torch((num_instances, num_joints), device, wp_dtype), + getter, + ) # warp, partial env_mask + joint_mask method( **{ @@ -1795,701 +1066,226 @@ def test_joint_writer_mask( # Tests: Body writers — torch/warp × index/mask × all/subset × negative # --------------------------------------------------------------------------- -# (method_name, kwarg_name, wp_dtype, trailing_dim) +# (method_name, kwarg_name, read-back getter) _BODY_METHODS = [ - ("set_masses", "masses", wp.float32, 0), - ("set_coms", "coms", wp.transformf, 7), - ("set_inertias", "inertias", wp.float32, 9), + ("set_masses", "masses", "body_mass"), + ("set_coms", "coms", "body_com_pose_b"), + ("set_inertias", "inertias", "body_inertia"), ] +def _body_writer_layout(backend: str, method_base: str) -> tuple[type, int, str]: + """Return the warp dtype, torch trailing size, and read-back getter of a body property writer.""" + if method_base == "set_masses": + return wp.float32, 0, "body_mass" + if method_base == "set_inertias": + return wp.float32, 9, "body_inertia" + if backend == "newton": + # Newton stores the COM as a position only. + return wp.vec3f, 3, "body_com_pos_b" + return wp.transformf, 7, "body_com_pose_b" + + +def _make_body_torch(shape: tuple[int, int], device: str, wp_dtype: type, trailing: int, payload: bool = False): + """Create body-property torch data: valid ones by default, or per-element distinct values.""" + full_shape = (*shape, trailing) if trailing else shape + if payload: + data = torch.arange(1, math.prod(full_shape) + 1, dtype=torch.float32, device=device).reshape(full_shape) + else: + data = torch.ones(full_shape, device=device, dtype=torch.float32) + if wp_dtype == wp.transformf: + data[..., 3:6] = 0.0 + data[..., 6] = 1.0 + if not payload: + data[..., :3] = 0.0 + return data + + +def _make_body_warp(shape: tuple[int, int], device: str, wp_dtype: type, trailing: int, payload: bool = False): + """Create body-property data as a warp array (structured dtypes collapse the trailing dim).""" + t = _make_body_torch(shape, device, wp_dtype, trailing, payload).contiguous() + return wp.from_torch(t, dtype=wp.float32 if wp_dtype == wp.float32 else wp_dtype) + + class TestArticulationWritersBody: """Test body property writers/setters with all input combinations.""" @_backends - @_default_dims - @_default_devices - @pytest.mark.parametrize( - "method_base, kwarg, wp_dtype, trailing", - _BODY_METHODS, - ids=[m[0] for m in _BODY_METHODS], - ) - def test_body_writer_index( - self, - backend, - num_instances, - num_joints, - num_bodies, - device, - articulation_iface, - method_base, - kwarg, - wp_dtype, - trailing, - ): - if backend == "newton" and method_base == "set_coms": - pytest.xfail("Newton only stores CoM position, not orientation") - art, _ = articulation_iface + @_devices + @pytest.mark.parametrize("method_base, kwarg", [m[:2] for m in _BODY_METHODS], ids=[m[0] for m in _BODY_METHODS]) + def test_body_writer_index(self, backend, device, method_base, kwarg): + art, _ = get_articulation(backend, _NUM_INSTANCES, _NUM_JOINTS, _NUM_BODIES, device=device) + num_instances, num_bodies = _NUM_INSTANCES, _NUM_BODIES + wp_dtype, trailing, getter = _body_writer_layout(backend, method_base) art.data.update(dt=0.01) method = getattr(art, f"{method_base}_index") - - # For inertias, the shape is (N, B, 9) always (no structured warp type) - # For coms, torch shape is (N, B, 7), warp shape is (N, B) transformf - # For masses, shape is (N, B) - - def _torch_shape(n_envs, n_bods): - if trailing: - return (n_envs, n_bods, trailing) - return (n_envs, n_bods) - - def _warp_shape(n_envs, n_bods): - return (n_envs, n_bods) - - def _make_torch(n_envs, n_bods): - shape = _torch_shape(n_envs, n_bods) - data = torch.ones(shape, device=device, dtype=torch.float32) - if wp_dtype == wp.transformf: - data[..., :3] = 0.0 - data[..., 3:6] = 0.0 - data[..., 6] = 1.0 - return data - - def _make_warp(n_envs, n_bods): - t = _make_torch(n_envs, n_bods) - if wp_dtype == wp.transformf: - return wp.from_torch(t.contiguous(), dtype=wp.transformf) - return wp.from_torch(t.contiguous(), dtype=wp.float32) - sub_b = min(2, num_bodies) sub_body_ids = list(range(sub_b)) # torch, all envs + all bodies - method(**{kwarg: _make_torch(num_instances, num_bodies)}) + method(**{kwarg: _make_body_torch((num_instances, num_bodies), device, wp_dtype, trailing)}) # torch, subset method( **{ - kwarg: _make_torch(1, sub_b), + kwarg: _make_body_torch((1, sub_b), device, wp_dtype, trailing), "body_ids": sub_body_ids, "env_ids": _make_env_ids(device, True), } ) - # warp, all envs + all bodies - method(**{kwarg: _make_warp(num_instances, num_bodies)}) + # warp, all envs + all bodies: the matching getter reads the written values back + method(**{kwarg: _make_body_warp((num_instances, num_bodies), device, wp_dtype, trailing, payload=True)}) + expected = _make_body_torch((num_instances, num_bodies), device, wp_dtype, trailing, payload=True) + _assert_reads_back(getattr(art.data, getter), expected, getter) # warp, subset method( **{ - kwarg: _make_warp(1, sub_b), + kwarg: _make_body_warp((1, sub_b), device, wp_dtype, trailing), "body_ids": sub_body_ids, "env_ids": _make_env_ids(device, True), } ) # negative: bad torch shape (extra env) with pytest.raises((AssertionError, RuntimeError)): - method(**{kwarg: _make_torch(num_instances + 1, num_bodies)}) + method(**{kwarg: _make_body_torch((num_instances + 1, num_bodies), device, wp_dtype, trailing)}) # negative: bad warp shape with pytest.raises((AssertionError, RuntimeError)): - method(**{kwarg: _make_warp(num_instances + 1, num_bodies)}) + method(**{kwarg: _make_body_warp((num_instances + 1, num_bodies), device, wp_dtype, trailing)}) @_backends - @_default_dims - @_default_devices - @pytest.mark.parametrize( - "method_base, kwarg, wp_dtype, trailing", - _BODY_METHODS, - ids=[m[0] for m in _BODY_METHODS], - ) - def test_body_writer_mask( - self, - backend, - num_instances, - num_joints, - num_bodies, - device, - articulation_iface, - method_base, - kwarg, - wp_dtype, - trailing, - ): - if backend == "newton" and method_base == "set_coms": - pytest.xfail("Newton only stores CoM position, not orientation") - art, _ = articulation_iface + @_devices + @pytest.mark.parametrize("method_base, kwarg", [m[:2] for m in _BODY_METHODS], ids=[m[0] for m in _BODY_METHODS]) + def test_body_writer_mask(self, backend, device, method_base, kwarg): + art, _ = get_articulation(backend, _NUM_INSTANCES, _NUM_JOINTS, _NUM_BODIES, device=device) + num_instances, num_bodies = _NUM_INSTANCES, _NUM_BODIES + wp_dtype, trailing, getter = _body_writer_layout(backend, method_base) art.data.update(dt=0.01) method = getattr(art, f"{method_base}_mask") - - def _torch_shape(n_envs, n_bods): - if trailing: - return (n_envs, n_bods, trailing) - return (n_envs, n_bods) - - def _make_torch(n_envs, n_bods): - shape = _torch_shape(n_envs, n_bods) - data = torch.ones(shape, device=device, dtype=torch.float32) - if wp_dtype == wp.transformf: - data[..., :3] = 0.0 - data[..., 3:6] = 0.0 - data[..., 6] = 1.0 - return data - - def _make_warp(n_envs, n_bods): - t = _make_torch(n_envs, n_bods) - if wp_dtype == wp.transformf: - return wp.from_torch(t.contiguous(), dtype=wp.transformf) - return wp.from_torch(t.contiguous(), dtype=wp.float32) - sub_body_sel = list(range(min(2, num_bodies))) # torch, no mask - method(**{kwarg: _make_torch(num_instances, num_bodies)}) + method(**{kwarg: _make_body_torch((num_instances, num_bodies), device, wp_dtype, trailing)}) # torch, partial env_mask + body_mask method( **{ - kwarg: _make_torch(num_instances, num_bodies), + kwarg: _make_body_torch((num_instances, num_bodies), device, wp_dtype, trailing), "body_mask": _make_item_mask(num_bodies, sub_body_sel, device), "env_mask": _make_env_mask(num_instances, device, True), } ) - # warp, no mask - method(**{kwarg: _make_warp(num_instances, num_bodies)}) + # warp, no mask: the matching getter reads the written values back + method(**{kwarg: _make_body_warp((num_instances, num_bodies), device, wp_dtype, trailing, payload=True)}) + expected = _make_body_torch((num_instances, num_bodies), device, wp_dtype, trailing, payload=True) + _assert_reads_back(getattr(art.data, getter), expected, getter) # warp, partial env_mask + body_mask method( **{ - kwarg: _make_warp(num_instances, num_bodies), + kwarg: _make_body_warp((num_instances, num_bodies), device, wp_dtype, trailing), "body_mask": _make_item_mask(num_bodies, sub_body_sel, device), "env_mask": _make_env_mask(num_instances, device, True), } ) # negative: bad torch shape with pytest.raises((AssertionError, RuntimeError)): - method(**{kwarg: _make_torch(num_instances + 1, num_bodies)}) + method(**{kwarg: _make_body_torch((num_instances + 1, num_bodies), device, wp_dtype, trailing)}) # negative: bad warp shape with pytest.raises((AssertionError, RuntimeError)): - method(**{kwarg: _make_warp(num_instances + 1, num_bodies)}) + method(**{kwarg: _make_body_warp((num_instances + 1, num_bodies), device, wp_dtype, trailing)}) # --------------------------------------------------------------------------- -# Tests: Alias/shorthand properties -# --------------------------------------------------------------------------- - - -class TestArticulationDataAliases: - """Test that alias properties return the same shape/dtype as their canonical counterparts.""" - - @_backends - @_default_dims - @_default_devices - def test_root_aliases(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - """root_pose_w == root_link_pose_w, root_vel_w == root_com_vel_w, etc.""" - art, _ = articulation_iface - art.data.update(dt=0.01) - d = art.data - - assert d.root_pose_w.shape == d.root_link_pose_w.shape - assert d.root_pose_w.dtype == d.root_link_pose_w.dtype - assert d.root_pos_w.shape == d.root_link_pos_w.shape - assert d.root_quat_w.shape == d.root_link_quat_w.shape - - assert d.root_vel_w.shape == d.root_com_vel_w.shape - assert d.root_vel_w.dtype == d.root_com_vel_w.dtype - assert d.root_lin_vel_w.shape == d.root_com_lin_vel_w.shape - assert d.root_ang_vel_w.shape == d.root_com_ang_vel_w.shape - - @_backends - @_default_dims - @_default_devices - def test_body_aliases(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - art.data.update(dt=0.01) - d = art.data - - assert d.body_pose_w.shape == d.body_link_pose_w.shape - assert d.body_pos_w.shape == d.body_link_pos_w.shape - assert d.body_quat_w.shape == d.body_link_quat_w.shape - assert d.body_vel_w.shape == d.body_com_vel_w.shape - assert d.body_lin_vel_w.shape == d.body_com_lin_vel_w.shape - assert d.body_ang_vel_w.shape == d.body_com_ang_vel_w.shape - - @_backends - @_default_dims - @_default_devices - def test_joint_aliases(self, backend, num_instances, num_joints, num_bodies, device, articulation_iface): - art, _ = articulation_iface - art.data.update(dt=0.01) - d = art.data - - assert d.joint_limits.shape == d.joint_pos_limits.shape - assert d.joint_friction.shape == d.joint_friction_coeff.shape - - -# --------------------------------------------------------------------------- -# Tendon tests — parametrize, properties, finders, data, writers +# Tendon tests — counts, names, data, writers # --------------------------------------------------------------------------- # Newton's fixed-tendon solver integration is covered in isaaclab_newton/test/assets/test_articulation.py. # This fixture matrix also requires spatial tendons and PhysX-specific properties. _tendon_backends = pytest.mark.parametrize("backend", [b for b in BACKENDS if b != "newton"], indirect=False) +# The first row covers the zero-spatial-tendon edge; the second has distinct counts on every axis. _tendon_dims = pytest.mark.parametrize( "num_instances, num_joints, num_bodies, num_fixed_tendons, num_spatial_tendons", [ (1, 2, 2, 1, 0), # fixed only (2, 6, 7, 3, 2), # both types - (100, 8, 13, 4, 3), # large, both types ], ) +# (property, tendon kind, dtype) +_TENDON_DATA_PROPERTIES = [ + ("fixed_tendon_stiffness", "fixed", wp.float32), + ("fixed_tendon_damping", "fixed", wp.float32), + ("fixed_tendon_limit_stiffness", "fixed", wp.float32), + ("fixed_tendon_rest_length", "fixed", wp.float32), + ("fixed_tendon_offset", "fixed", wp.float32), + ("fixed_tendon_pos_limits", "fixed", wp.vec2f), + ("spatial_tendon_stiffness", "spatial", wp.float32), + ("spatial_tendon_damping", "spatial", wp.float32), + ("spatial_tendon_limit_stiffness", "spatial", wp.float32), + ("spatial_tendon_offset", "spatial", wp.float32), +] -class TestArticulationTendonProperties: - """Test that tendon-related articulation properties return the correct types/values.""" - - @_tendon_backends - @_tendon_dims - @_default_devices - def test_num_fixed_tendons( - self, - backend, - num_instances, - num_joints, - num_bodies, - num_fixed_tendons, - num_spatial_tendons, - device, - articulation_iface, - ): - art, _ = articulation_iface - assert art.num_fixed_tendons == num_fixed_tendons - - @_tendon_backends - @_tendon_dims - @_default_devices - def test_num_spatial_tendons( - self, - backend, - num_instances, - num_joints, - num_bodies, - num_fixed_tendons, - num_spatial_tendons, - device, - articulation_iface, - ): - art, _ = articulation_iface - assert art.num_spatial_tendons == num_spatial_tendons - - @_tendon_backends - @_tendon_dims - @_default_devices - def test_fixed_tendon_names( - self, - backend, - num_instances, - num_joints, - num_bodies, - num_fixed_tendons, - num_spatial_tendons, - device, - articulation_iface, - ): - art, _ = articulation_iface - names = art.fixed_tendon_names - assert isinstance(names, list) - assert len(names) == num_fixed_tendons - assert all(isinstance(n, str) for n in names) - - @_tendon_backends - @_tendon_dims - @_default_devices - def test_spatial_tendon_names( - self, - backend, - num_instances, - num_joints, - num_bodies, - num_fixed_tendons, - num_spatial_tendons, - device, - articulation_iface, - ): - art, _ = articulation_iface - names = art.spatial_tendon_names - assert isinstance(names, list) - assert len(names) == num_spatial_tendons - assert all(isinstance(n, str) for n in names) - - -class TestArticulationTendonFinders: - """Test that tendon finder methods return (list[int], list[str]) tuples.""" - - @_tendon_backends - @_tendon_dims - @_default_devices - def test_find_fixed_tendons_all( - self, - backend, - num_instances, - num_joints, - num_bodies, - num_fixed_tendons, - num_spatial_tendons, - device, - articulation_iface, - ): - art, _ = articulation_iface - if num_fixed_tendons == 0: - pytest.skip("No fixed tendons configured") - indices, names = art.find_fixed_tendons(".*") - assert isinstance(indices, list) and isinstance(names, list) - assert len(indices) == num_fixed_tendons - assert len(names) == num_fixed_tendons - assert all(isinstance(i, int) for i in indices) - assert all(isinstance(n, str) for n in names) - - @_tendon_backends - @_tendon_dims - @_default_devices - def test_find_fixed_tendons_single( - self, - backend, - num_instances, - num_joints, - num_bodies, - num_fixed_tendons, - num_spatial_tendons, - device, - articulation_iface, - ): - art, _ = articulation_iface - if num_fixed_tendons == 0: - pytest.skip("No fixed tendons configured") - first = art.fixed_tendon_names[0] - indices, names = art.find_fixed_tendons(first) - assert indices == [0] - assert names == [first] - - @_tendon_backends - @_tendon_dims - @_default_devices - def test_find_spatial_tendons_all( - self, - backend, - num_instances, - num_joints, - num_bodies, - num_fixed_tendons, - num_spatial_tendons, - device, - articulation_iface, - ): - art, _ = articulation_iface - if num_spatial_tendons == 0: - pytest.skip("No spatial tendons configured") - indices, names = art.find_spatial_tendons(".*") - assert isinstance(indices, list) and isinstance(names, list) - assert len(indices) == num_spatial_tendons - assert len(names) == num_spatial_tendons - - @_tendon_backends - @_tendon_dims - @_default_devices - def test_find_spatial_tendons_single( - self, - backend, - num_instances, - num_joints, - num_bodies, - num_fixed_tendons, - num_spatial_tendons, - device, - articulation_iface, - ): - art, _ = articulation_iface - if num_spatial_tendons == 0: - pytest.skip("No spatial tendons configured") - first = art.spatial_tendon_names[0] - indices, names = art.find_spatial_tendons(first) - assert indices == [0] - assert names == [first] - - -class TestArticulationDataTendonState: - """Test data properties for tendon state (fixed and spatial).""" - - # -- Fixed tendon data properties -- - - @_tendon_backends - @_tendon_dims - @_default_devices - def test_fixed_tendon_stiffness( - self, - backend, - num_instances, - num_joints, - num_bodies, - num_fixed_tendons, - num_spatial_tendons, - device, - articulation_iface, - ): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.fixed_tendon_stiffness, - expected_shape=(num_instances, num_fixed_tendons), - expected_dtype=wp.float32, - name="fixed_tendon_stiffness", - ) - - @_tendon_backends - @_tendon_dims - @_default_devices - def test_fixed_tendon_damping( - self, - backend, - num_instances, - num_joints, - num_bodies, - num_fixed_tendons, - num_spatial_tendons, - device, - articulation_iface, - ): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.fixed_tendon_damping, - expected_shape=(num_instances, num_fixed_tendons), - expected_dtype=wp.float32, - name="fixed_tendon_damping", - ) - - @_tendon_backends - @_tendon_dims - @_default_devices - def test_fixed_tendon_limit_stiffness( - self, - backend, - num_instances, - num_joints, - num_bodies, - num_fixed_tendons, - num_spatial_tendons, - device, - articulation_iface, - ): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.fixed_tendon_limit_stiffness, - expected_shape=(num_instances, num_fixed_tendons), - expected_dtype=wp.float32, - name="fixed_tendon_limit_stiffness", - ) - - @_tendon_backends - @_tendon_dims - @_default_devices - def test_fixed_tendon_rest_length( - self, - backend, - num_instances, - num_joints, - num_bodies, - num_fixed_tendons, - num_spatial_tendons, - device, - articulation_iface, - ): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.fixed_tendon_rest_length, - expected_shape=(num_instances, num_fixed_tendons), - expected_dtype=wp.float32, - name="fixed_tendon_rest_length", - ) - - @_tendon_backends - @_tendon_dims - @_default_devices - def test_fixed_tendon_offset( - self, - backend, - num_instances, - num_joints, - num_bodies, - num_fixed_tendons, - num_spatial_tendons, - device, - articulation_iface, - ): - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.fixed_tendon_offset, - expected_shape=(num_instances, num_fixed_tendons), - expected_dtype=wp.float32, - name="fixed_tendon_offset", - ) - - @_tendon_backends - @_tendon_dims - @_default_devices - def test_fixed_tendon_pos_limits( - self, - backend, - num_instances, - num_joints, - num_bodies, - num_fixed_tendons, - num_spatial_tendons, - device, - articulation_iface, - ): - art, _ = articulation_iface - art.data.update(dt=0.01) - from isaaclab.utils.warp import ProxyArray - - arr = art.data.fixed_tendon_pos_limits - assert isinstance(arr, ProxyArray), f"fixed_tendon_pos_limits: expected ProxyArray, got {type(arr)}" - if num_fixed_tendons == 0: - # When no tendons, shape is (N, 0, 2) float32 - assert arr.shape == (num_instances, 0, 2) - assert arr.dtype == wp.float32 - else: - # PhysX returns (N, T, 2) float32; Mock returns (N, T) vec2f - assert arr.shape in ((num_instances, num_fixed_tendons), (num_instances, num_fixed_tendons, 2)) - assert arr.dtype in (wp.vec2f, wp.float32) - - # -- Spatial tendon data properties -- - - @_tendon_backends - @_tendon_dims - @_default_devices - def test_spatial_tendon_stiffness( - self, - backend, - num_instances, - num_joints, - num_bodies, - num_fixed_tendons, - num_spatial_tendons, - device, - articulation_iface, - ): - art, _ = articulation_iface - if num_spatial_tendons == 0: - pytest.skip("No spatial tendons configured") - art.data.update(dt=0.01) - _check_proxy_array( - art.data.spatial_tendon_stiffness, - expected_shape=(num_instances, num_spatial_tendons), - expected_dtype=wp.float32, - name="spatial_tendon_stiffness", - ) - - @_tendon_backends - @_tendon_dims - @_default_devices - def test_spatial_tendon_damping( - self, - backend, - num_instances, - num_joints, - num_bodies, - num_fixed_tendons, - num_spatial_tendons, - device, - articulation_iface, - ): - art, _ = articulation_iface - if num_spatial_tendons == 0: - pytest.skip("No spatial tendons configured") - art.data.update(dt=0.01) - _check_proxy_array( - art.data.spatial_tendon_damping, - expected_shape=(num_instances, num_spatial_tendons), - expected_dtype=wp.float32, - name="spatial_tendon_damping", - ) - @_tendon_backends - @_tendon_dims - @_default_devices - def test_spatial_tendon_limit_stiffness( - self, - backend, - num_instances, - num_joints, - num_bodies, - num_fixed_tendons, - num_spatial_tendons, - device, - articulation_iface, - ): - art, _ = articulation_iface - if num_spatial_tendons == 0: - pytest.skip("No spatial tendons configured") - art.data.update(dt=0.01) - _check_proxy_array( - art.data.spatial_tendon_limit_stiffness, - expected_shape=(num_instances, num_spatial_tendons), - expected_dtype=wp.float32, - name="spatial_tendon_limit_stiffness", - ) +class TestArticulationTendons: + """Test tendon counts, names, and data properties.""" @_tendon_backends @_tendon_dims - @_default_devices - def test_spatial_tendon_offset( - self, - backend, - num_instances, - num_joints, - num_bodies, - num_fixed_tendons, - num_spatial_tendons, - device, - articulation_iface, + @_devices + def test_articulation_tendon_contract( + self, backend, num_instances, num_joints, num_bodies, num_fixed_tendons, num_spatial_tendons, device ): - art, _ = articulation_iface - if num_spatial_tendons == 0: - pytest.skip("No spatial tendons configured") - art.data.update(dt=0.01) - _check_proxy_array( - art.data.spatial_tendon_offset, - expected_shape=(num_instances, num_spatial_tendons), - expected_dtype=wp.float32, - name="spatial_tendon_offset", + art, _ = get_articulation( + backend, num_instances, num_joints, num_bodies, num_fixed_tendons, num_spatial_tendons, device ) - - -# --------------------------------------------------------------------------- -# Tests: Fixed tendon writers — torch/warp × index/mask × all/subset × negative -# --------------------------------------------------------------------------- - -# (method_name, kwarg_name, wp_dtype, accepts_float) + counts = {"fixed": num_fixed_tendons, "spatial": num_spatial_tendons} + assert art.num_fixed_tendons == num_fixed_tendons + assert art.num_spatial_tendons == num_spatial_tendons + for names, count in ( + (art.fixed_tendon_names, num_fixed_tendons), + (art.spatial_tendon_names, num_spatial_tendons), + ): + assert isinstance(names, list) + assert len(names) == count + assert all(isinstance(n, str) for n in names) + + art.data.update(dt=0.01) + for name, kind, dtype in _TENDON_DATA_PROPERTIES: + if counts[kind] == 0: + continue + expected_shape = (num_instances, counts[kind]) + if backend == "physx" and name == "fixed_tendon_pos_limits": + # Known inconsistency: PhysX exposes (N, T, 2) float32 although its docstring and OVPhysX + # advertise (N, T) vec2f. Pin the current layout so a change on either side is noticed. + expected_shape, dtype = (*expected_shape, 2), wp.float32 + _check_proxy_array(getattr(art.data, name), expected_shape=expected_shape, expected_dtype=dtype, name=name) + + +# (method_name, kwarg_name, read-back data property) _FIXED_TENDON_METHODS = [ - ("set_fixed_tendon_stiffness", "stiffness", wp.float32, True), - ("set_fixed_tendon_damping", "damping", wp.float32, True), - ("set_fixed_tendon_limit_stiffness", "limit_stiffness", wp.float32, True), - ("set_fixed_tendon_rest_length", "rest_length", wp.float32, True), - ("set_fixed_tendon_offset", "offset", wp.float32, True), - ("set_fixed_tendon_position_limit", "limit", wp.vec2f, True), + ("set_fixed_tendon_stiffness", "stiffness", "fixed_tendon_stiffness"), + ("set_fixed_tendon_damping", "damping", "fixed_tendon_damping"), + ("set_fixed_tendon_limit_stiffness", "limit_stiffness", "fixed_tendon_limit_stiffness"), + ("set_fixed_tendon_rest_length", "rest_length", "fixed_tendon_rest_length"), + ("set_fixed_tendon_offset", "offset", "fixed_tendon_offset"), + ("set_fixed_tendon_position_limit", "limit", "fixed_tendon_pos_limits"), +] +_SPATIAL_TENDON_METHODS = [ + ("set_spatial_tendon_stiffness", "stiffness", "spatial_tendon_stiffness"), + ("set_spatial_tendon_damping", "damping", "spatial_tendon_damping"), + ("set_spatial_tendon_limit_stiffness", "limit_stiffness", "spatial_tendon_limit_stiffness"), + ("set_spatial_tendon_offset", "offset", "spatial_tendon_offset"), ] +_TENDON_METHODS = [("fixed", *m) for m in _FIXED_TENDON_METHODS] + [("spatial", *m) for m in _SPATIAL_TENDON_METHODS] -class TestArticulationWritersFixedTendon: - """Test fixed tendon writers/setters with all input combinations.""" +class TestArticulationWritersTendon: + """Test tendon writers/setters with all input combinations.""" @_tendon_backends @_tendon_dims - @_default_devices - @pytest.mark.parametrize( - "method_base, kwarg, wp_dtype, accepts_float", - _FIXED_TENDON_METHODS, - ids=[m[0] for m in _FIXED_TENDON_METHODS], - ) - def test_fixed_tendon_writer_index( + @_devices + @pytest.mark.parametrize("selection", ["index", "mask"]) + @pytest.mark.parametrize("kind, method_base, kwarg, getter", _TENDON_METHODS, ids=[m[1] for m in _TENDON_METHODS]) + def test_tendon_writer( self, backend, num_instances, @@ -2498,356 +1294,100 @@ def test_fixed_tendon_writer_index( num_fixed_tendons, num_spatial_tendons, device, - articulation_iface, + selection, + kind, method_base, kwarg, - wp_dtype, - accepts_float, + getter, ): - art, _ = articulation_iface - if num_fixed_tendons == 0: - pytest.skip("No fixed tendons configured") - art.data.update(dt=0.01) - method = getattr(art, f"{method_base}_index") - sub_t = min(2, num_fixed_tendons) - sub_tendon_ids = list(range(sub_t)) - - # torch, all envs + all tendons - method(**{kwarg: _make_data_torch((num_instances, num_fixed_tendons), device, wp_dtype)}) - # torch, subset envs + subset tendons - method( - **{ - kwarg: _make_data_torch((1, sub_t), device, wp_dtype), - "fixed_tendon_ids": sub_tendon_ids, - "env_ids": _make_env_ids(device, True), - } - ) - # warp, all envs + all tendons - method(**{kwarg: _make_data_warp((num_instances, num_fixed_tendons), device, wp_dtype)}) - # warp, subset - method( - **{ - kwarg: _make_data_warp((1, sub_t), device, wp_dtype), - "fixed_tendon_ids": sub_tendon_ids, - "env_ids": _make_env_ids(device, True), - } + num_tendons = num_fixed_tendons if kind == "fixed" else num_spatial_tendons + if num_tendons == 0: + pytest.skip(f"No {kind} tendons configured") + art, _ = get_articulation( + backend, num_instances, num_joints, num_bodies, num_fixed_tendons, num_spatial_tendons, device ) - # float scalar (only for accepts_float methods, and NOT for vec2f) - if accepts_float and wp_dtype != wp.vec2f: - method(**{kwarg: 1.0}) - # float scalar for vec2f should raise ValueError - if accepts_float and wp_dtype == wp.vec2f: - with pytest.raises((ValueError, TypeError)): - method(**{kwarg: 1.0}) - # negative: bad torch shape - with pytest.raises((AssertionError, RuntimeError)): - method(**{kwarg: _make_bad_data_torch((num_instances, num_fixed_tendons), device, wp_dtype)}) - # negative: bad warp shape - with pytest.raises((AssertionError, RuntimeError)): - method(**{kwarg: _make_bad_data_warp((num_instances, num_fixed_tendons), device, wp_dtype)}) - - @_tendon_backends - @_tendon_dims - @_default_devices - @pytest.mark.parametrize( - "method_base, kwarg, wp_dtype, accepts_float", - _FIXED_TENDON_METHODS, - ids=[m[0] for m in _FIXED_TENDON_METHODS], - ) - def test_fixed_tendon_writer_mask( - self, - backend, - num_instances, - num_joints, - num_bodies, - num_fixed_tendons, - num_spatial_tendons, - device, - articulation_iface, - method_base, - kwarg, - wp_dtype, - accepts_float, - ): - art, _ = articulation_iface - if num_fixed_tendons == 0: - pytest.skip("No fixed tendons configured") art.data.update(dt=0.01) - method = getattr(art, f"{method_base}_mask") - sub_tendon_sel = list(range(min(2, num_fixed_tendons))) - - # torch, no mask - method(**{kwarg: _make_data_torch((num_instances, num_fixed_tendons), device, wp_dtype)}) - # torch, partial env_mask + tendon_mask - method( - **{ - kwarg: _make_data_torch((num_instances, num_fixed_tendons), device, wp_dtype), - "fixed_tendon_mask": _make_item_mask(num_fixed_tendons, sub_tendon_sel, device), - "env_mask": _make_env_mask(num_instances, device, True), - } - ) - # warp, no mask - method(**{kwarg: _make_data_warp((num_instances, num_fixed_tendons), device, wp_dtype)}) - # warp, partial env_mask + tendon_mask - method( - **{ - kwarg: _make_data_warp((num_instances, num_fixed_tendons), device, wp_dtype), - "fixed_tendon_mask": _make_item_mask(num_fixed_tendons, sub_tendon_sel, device), + method = getattr(art, f"{method_base}_{selection}") + wp_dtype = wp.vec2f if getter == "fixed_tendon_pos_limits" else wp.float32 + full_shape = (num_instances, num_tendons) + sub_t = min(2, num_tendons) + sub_tendons = list(range(sub_t)) + if selection == "index": + subset = {f"{kind}_tendon_ids": sub_tendons, "env_ids": _make_env_ids(device, True)} + subset_shape = (1, sub_t) + else: + subset = { + f"{kind}_tendon_mask": _make_item_mask(num_tendons, sub_tendons, device), "env_mask": _make_env_mask(num_instances, device, True), } - ) - # float scalar (only for accepts_float methods, and NOT for vec2f) - if accepts_float and wp_dtype != wp.vec2f: - method(**{kwarg: 1.0}) - # negative: bad torch shape - with pytest.raises((AssertionError, RuntimeError)): - method(**{kwarg: _make_bad_data_torch((num_instances, num_fixed_tendons), device, wp_dtype)}) - # negative: bad warp shape - with pytest.raises((AssertionError, RuntimeError)): - method(**{kwarg: _make_bad_data_warp((num_instances, num_fixed_tendons), device, wp_dtype)}) - - -# --------------------------------------------------------------------------- -# Tests: Spatial tendon writers — torch/warp × index/mask × all/subset × negative -# --------------------------------------------------------------------------- - -_SPATIAL_TENDON_METHODS = [ - ("set_spatial_tendon_stiffness", "stiffness", wp.float32, True), - ("set_spatial_tendon_damping", "damping", wp.float32, True), - ("set_spatial_tendon_limit_stiffness", "limit_stiffness", wp.float32, True), - ("set_spatial_tendon_offset", "offset", wp.float32, True), -] - - -class TestArticulationWritersSpatialTendon: - """Test spatial tendon writers/setters with all input combinations.""" - - @_tendon_backends - @_tendon_dims - @_default_devices - @pytest.mark.parametrize( - "method_base, kwarg, wp_dtype, accepts_float", - _SPATIAL_TENDON_METHODS, - ids=[m[0] for m in _SPATIAL_TENDON_METHODS], - ) - def test_spatial_tendon_writer_index( - self, - backend, - num_instances, - num_joints, - num_bodies, - num_fixed_tendons, - num_spatial_tendons, - device, - articulation_iface, - method_base, - kwarg, - wp_dtype, - accepts_float, - ): - art, _ = articulation_iface - if num_spatial_tendons == 0: - pytest.skip("No spatial tendons configured") - art.data.update(dt=0.01) - method = getattr(art, f"{method_base}_index") - sub_t = min(2, num_spatial_tendons) - sub_tendon_ids = list(range(sub_t)) + subset_shape = full_shape # torch, all envs + all tendons - method(**{kwarg: _make_data_torch((num_instances, num_spatial_tendons), device, wp_dtype)}) - # torch, subset envs + subset tendons - method( - **{ - kwarg: _make_data_torch((1, sub_t), device, wp_dtype), - "spatial_tendon_ids": sub_tendon_ids, - "env_ids": _make_env_ids(device, True), - } - ) - # warp, all envs + all tendons - method(**{kwarg: _make_data_warp((num_instances, num_spatial_tendons), device, wp_dtype)}) + method(**{kwarg: _make_data_torch(full_shape, device, wp_dtype)}) + # torch, subset + method(**{kwarg: _make_data_torch(subset_shape, device, wp_dtype)}, **subset) + # warp, all envs + all tendons: the matching data property reads the written values back + method(**{kwarg: _make_payload_warp(full_shape, device, wp_dtype)}) + _assert_reads_back(getattr(art.data, getter), _make_payload_torch(full_shape, device, wp_dtype), getter) # warp, subset - method( - **{ - kwarg: _make_data_warp((1, sub_t), device, wp_dtype), - "spatial_tendon_ids": sub_tendon_ids, - "env_ids": _make_env_ids(device, True), - } - ) - # float scalar - if accepts_float: - method(**{kwarg: 1.0}) - # negative: bad torch shape - with pytest.raises((AssertionError, RuntimeError)): - method(**{kwarg: _make_bad_data_torch((num_instances, num_spatial_tendons), device, wp_dtype)}) - # negative: bad warp shape - with pytest.raises((AssertionError, RuntimeError)): - method(**{kwarg: _make_bad_data_warp((num_instances, num_spatial_tendons), device, wp_dtype)}) - - @_tendon_backends - @_tendon_dims - @_default_devices - @pytest.mark.parametrize( - "method_base, kwarg, wp_dtype, accepts_float", - _SPATIAL_TENDON_METHODS, - ids=[m[0] for m in _SPATIAL_TENDON_METHODS], - ) - def test_spatial_tendon_writer_mask( - self, - backend, - num_instances, - num_joints, - num_bodies, - num_fixed_tendons, - num_spatial_tendons, - device, - articulation_iface, - method_base, - kwarg, - wp_dtype, - accepts_float, - ): - art, _ = articulation_iface - if num_spatial_tendons == 0: - pytest.skip("No spatial tendons configured") - art.data.update(dt=0.01) - method = getattr(art, f"{method_base}_mask") - sub_tendon_sel = list(range(min(2, num_spatial_tendons))) - - # torch, no mask - method(**{kwarg: _make_data_torch((num_instances, num_spatial_tendons), device, wp_dtype)}) - # torch, partial env_mask + tendon_mask - method( - **{ - kwarg: _make_data_torch((num_instances, num_spatial_tendons), device, wp_dtype), - "spatial_tendon_mask": _make_item_mask(num_spatial_tendons, sub_tendon_sel, device), - "env_mask": _make_env_mask(num_instances, device, True), - } - ) - # warp, no mask - method(**{kwarg: _make_data_warp((num_instances, num_spatial_tendons), device, wp_dtype)}) - # warp, partial env_mask + tendon_mask - method( - **{ - kwarg: _make_data_warp((num_instances, num_spatial_tendons), device, wp_dtype), - "spatial_tendon_mask": _make_item_mask(num_spatial_tendons, sub_tendon_sel, device), - "env_mask": _make_env_mask(num_instances, device, True), - } - ) - # float scalar - if accepts_float: + method(**{kwarg: _make_data_warp(subset_shape, device, wp_dtype)}, **subset) + # Position limits require a lower/upper pair; other properties accept scalar values. + if wp_dtype == wp.vec2f: + with pytest.raises((ValueError, TypeError)): + method(**{kwarg: 1.0}) + else: method(**{kwarg: 1.0}) # negative: bad torch shape with pytest.raises((AssertionError, RuntimeError)): - method(**{kwarg: _make_bad_data_torch((num_instances, num_spatial_tendons), device, wp_dtype)}) + method(**{kwarg: _make_bad_data_torch(full_shape, device, wp_dtype)}) # negative: bad warp shape with pytest.raises((AssertionError, RuntimeError)): - method(**{kwarg: _make_bad_data_warp((num_instances, num_spatial_tendons), device, wp_dtype)}) - - -# --------------------------------------------------------------------------- -# Tests: Tendon write-to-sim smoke tests -# --------------------------------------------------------------------------- - - -class TestArticulationWritersTendonToSim: - """Smoke test write_*_tendon_properties_to_sim_index/mask methods.""" + method(**{kwarg: _make_bad_data_warp(full_shape, device, wp_dtype)}) @_tendon_backends - @_tendon_dims - @_default_devices - def test_write_fixed_tendon_properties_to_sim_index( - self, - backend, - num_instances, - num_joints, - num_bodies, - num_fixed_tendons, - num_spatial_tendons, - device, - articulation_iface, - ): - art, _ = articulation_iface - if num_fixed_tendons == 0: - pytest.skip("No fixed tendons configured") - art.data.update(dt=0.01) - # all envs - art.write_fixed_tendon_properties_to_sim_index() - # subset envs - art.write_fixed_tendon_properties_to_sim_index(env_ids=_make_env_ids(device, True)) - art.write_fixed_tendon_properties_to_sim_index(fixed_tendon_ids=wp.array([0], dtype=wp.int64, device=device)) - - @_tendon_backends - @_tendon_dims - @_default_devices - def test_write_fixed_tendon_properties_to_sim_mask( - self, - backend, - num_instances, - num_joints, - num_bodies, - num_fixed_tendons, - num_spatial_tendons, - device, - articulation_iface, - ): - art, _ = articulation_iface - if num_fixed_tendons == 0: - pytest.skip("No fixed tendons configured") + @_devices + @pytest.mark.parametrize("selection", ["index", "mask"]) + @pytest.mark.parametrize("kind", ["fixed", "spatial"]) + def test_write_tendon_properties_to_sim_selects_envs(self, backend, device, selection, kind): + """Pushing tendon properties writes the selected environments to the backend.""" + num_instances = 3 + art, raw_backend = get_articulation(backend, num_instances, 2, 2, 2, 2, device) art.data.update(dt=0.01) - # no mask - art.write_fixed_tendon_properties_to_sim_mask() - # partial env mask - art.write_fixed_tendon_properties_to_sim_mask(env_mask=_make_env_mask(num_instances, device, True)) - art.write_fixed_tendon_properties_to_sim_mask(fixed_tendon_mask=_make_item_mask(num_fixed_tendons, [0], device)) + env_writes = [] + if backend == "physx": - @_tendon_backends - @_tendon_dims - @_default_devices - def test_write_spatial_tendon_properties_to_sim_index( - self, - backend, - num_instances, - num_joints, - num_bodies, - num_fixed_tendons, - num_spatial_tendons, - device, - articulation_iface, - ): - art, _ = articulation_iface - if num_spatial_tendons == 0: - pytest.skip("No spatial tendons configured") - art.data.update(dt=0.01) - # all envs - art.write_spatial_tendon_properties_to_sim_index() - # subset envs - art.write_spatial_tendon_properties_to_sim_index(env_ids=_make_env_ids(device, True)) - art.write_spatial_tendon_properties_to_sim_index( - spatial_tendon_ids=wp.array([0], dtype=wp.int64, device=device) - ) + def capture(*args, indices=None, **kwargs): + env_writes.append(indices.numpy().tolist()) - @_tendon_backends - @_tendon_dims - @_default_devices - def test_write_spatial_tendon_properties_to_sim_mask( - self, - backend, - num_instances, - num_joints, - num_bodies, - num_fixed_tendons, - num_spatial_tendons, - device, - articulation_iface, - ): - art, _ = articulation_iface - if num_spatial_tendons == 0: - pytest.skip("No spatial tendons configured") - art.data.update(dt=0.01) - # no mask - art.write_spatial_tendon_properties_to_sim_mask() - # partial env mask - art.write_spatial_tendon_properties_to_sim_mask(env_mask=_make_env_mask(num_instances, device, True)) - art.write_spatial_tendon_properties_to_sim_mask( - spatial_tendon_mask=_make_item_mask(num_spatial_tendons, [0], device) - ) + setattr(raw_backend, f"set_{kind}_tendon_properties", MagicMock(side_effect=capture)) + else: + from isaaclab_ov import tensor_types as TT + + prefix = "FIXED_TENDON" if kind == "fixed" else "SPATIAL_TENDON" + tendon_types = {getattr(TT, name) for name in dir(TT) if name.startswith(prefix)} + set_attribute = art._root_view.set_attribute + + def capture(name, values, *, indices=None, mask=None): + if name in tendon_types: + if mask is not None: + env_writes.append(np.flatnonzero(mask.numpy()).tolist()) + else: + env_writes.append(list(range(num_instances)) if indices is None else indices.numpy().tolist()) + set_attribute(name, values, indices=indices, mask=mask) + + object.__setattr__(art._root_view, "set_attribute", capture) + write = getattr(art, f"write_{kind}_tendon_properties_to_sim_{selection}") + + for selected_envs in (list(range(num_instances)), [0, 2]): + env_writes.clear() + if selection == "index": + write( + env_ids=None if len(selected_envs) == num_instances else torch.tensor(selected_envs, device=device) + ) + else: + mask = None + if len(selected_envs) < num_instances: + mask = wp.array([i in selected_envs for i in range(num_instances)], dtype=wp.bool, device=device) + write(env_mask=mask) + assert env_writes, "no tendon properties were written" + assert all(envs == selected_envs for envs in env_writes), env_writes diff --git a/source/isaaclab/test/assets/test_articulation_ordering.py b/source/isaaclab/test/assets/test_articulation_ordering.py index 8b30273f431f..4a4946ece980 100644 --- a/source/isaaclab/test/assets/test_articulation_ordering.py +++ b/source/isaaclab/test/assets/test_articulation_ordering.py @@ -64,7 +64,6 @@ def __init__(self, cfg): from isaaclab.assets.articulation.articulation_cfg import ArticulationCfg from isaaclab.assets.articulation.base_articulation import BaseArticulation -from isaaclab.assets.articulation.base_articulation_data import BaseArticulationData if _inserted_sim_stub: sys.modules.pop("isaaclab.sim", None) @@ -146,20 +145,6 @@ def test_articulation_element_kind_registry_enforces_singletons() -> None: ) -def test_articulation_cfg_accepts_optional_ordering_fields() -> None: - """Configure explicit or symbolic public articulation ordering.""" - explicit_joint_order = ("shoulder", "elbow", "wrist") - cfg = ArticulationCfg( - prim_path="/World/Robot", - actuators={}, - joint_ordering=explicit_joint_order, - body_ordering="mjwarp", - ) - - assert cfg.joint_ordering == explicit_joint_order - assert cfg.body_ordering == "mjwarp" - - def test_apply_articulation_ordering_preset_sets_joint_and_body_ordering() -> None: """Apply one ergonomic ordering preset to both joints and bodies.""" cfg = ArticulationCfg(prim_path="/World/Robot", actuators={}) @@ -282,50 +267,16 @@ class _Articulation: assert user_names == ("base", "left_foot", "right_foot") -@pytest.mark.parametrize("backend_name", ["physx", "ovphysx"]) -def test_physx_ordering_helper_uses_same_backend_identity_without_discovery( - monkeypatch: pytest.MonkeyPatch, backend_name: str +@pytest.mark.parametrize( + ("convention", "builder_name"), + [ + ("physx", "_get_physx_names_from_newton_usd_builder"), + ("mjwarp", "_get_mjwarp_names_from_newton_usd_builder"), + ], +) +def test_backend_native_orderings_declaration_drives_identity_fast_path( + monkeypatch: pytest.MonkeyPatch, convention: str, builder_name: str ) -> None: - """Return PhysX and OVPhysX backend names without metadata discovery.""" - - class _Articulation: - __backend_name__ = backend_name - __backend_native_orderings__ = ("physx",) - backend_joint_names = ("shoulder", "elbow", "wrist") - backend_body_names = ("base", "arm", "hand") - - monkeypatch.setattr( - ordering_resolvers, - "_get_physx_names_from_newton_usd_builder", - lambda _: pytest.fail("same-backend resolution must not invoke the USD builder"), - ) - - articulation = _Articulation() - assert get_articulation_name_ordering(articulation, "physx", kind="joint") == articulation.backend_joint_names - assert get_articulation_name_ordering(articulation, "physx", kind="body") == articulation.backend_body_names - - -def test_mjwarp_ordering_helper_uses_newton_identity_without_discovery(monkeypatch: pytest.MonkeyPatch) -> None: - """Return Newton backend names without metadata discovery.""" - - class _Articulation: - __backend_name__ = "newton" - __backend_native_orderings__ = ("mjwarp",) - backend_joint_names = ("knee", "hip", "ankle") - backend_body_names = ("base", "thigh", "foot") - - monkeypatch.setattr( - ordering_resolvers, - "_get_mjwarp_names_from_newton_usd_builder", - lambda _: pytest.fail("same-backend resolution must not invoke the USD builder"), - ) - - articulation = _Articulation() - assert get_articulation_name_ordering(articulation, "mjwarp", kind="joint") == articulation.backend_joint_names - assert get_articulation_name_ordering(articulation, "mjwarp", kind="body") == articulation.backend_body_names - - -def test_backend_native_orderings_declaration_drives_identity_fast_path(monkeypatch: pytest.MonkeyPatch) -> None: """A backend self-declares the conventions its native order satisfies. The resolver keys the same-backend identity fast path off @@ -335,36 +286,20 @@ def test_backend_native_orderings_declaration_drives_identity_fast_path(monkeypa class _Articulation: __backend_name__ = "fourth_backend" - __backend_native_orderings__ = ("physx",) + __backend_native_orderings__ = (convention,) _ordering_convention_name_cache: dict = {} backend_joint_names = ("shoulder", "elbow") backend_body_names = ("base", "arm") monkeypatch.setattr( ordering_resolvers, - "_get_physx_names_from_newton_usd_builder", + builder_name, lambda _: pytest.fail("a declared native convention must not invoke the USD builder"), ) articulation = _Articulation() - assert get_articulation_name_ordering(articulation, "physx", kind="joint") == articulation.backend_joint_names - assert get_articulation_name_ordering(articulation, "physx", kind="body") == articulation.backend_body_names - - -def test_undeclared_convention_bypasses_identity_fast_path(monkeypatch: pytest.MonkeyPatch) -> None: - """A convention absent from ``__backend_native_orderings__`` falls through to discovery.""" - - class _Articulation: - __backend_name__ = "fourth_backend" - __backend_native_orderings__ = ("physx",) - _ordering_convention_name_cache: dict = {} - backend_joint_names = ("shoulder", "elbow") - backend_body_names = ("base", "arm") - - monkeypatch.setattr(ordering_resolvers, "_get_mjwarp_names_from_newton_usd_builder", lambda _: None) - - with pytest.raises(NotImplementedError, match="Unable to resolve 'mjwarp'"): - get_articulation_name_ordering(_Articulation(), "mjwarp", kind="joint") + assert get_articulation_name_ordering(articulation, convention, kind="joint") == articulation.backend_joint_names + assert get_articulation_name_ordering(articulation, convention, kind="body") == articulation.backend_body_names @pytest.mark.parametrize( @@ -383,6 +318,8 @@ def test_mjwarp_ordering_helper_reports_actionable_cross_backend_failure( class _Articulation: __backend_name__ = "physx" + # A convention absent from the native declaration bypasses the identity fast path. + __backend_native_orderings__ = ("physx",) _ordering_convention_name_cache: dict = {} backend_joint_names = ("hip", "knee") backend_body_names = ("base", "foot") @@ -1017,43 +954,6 @@ def test_multi_dof_name_normalization_keeps_ambiguous_spellings() -> None: assert ordering_resolvers._match_backend_joint_name_spellings(convention_names, backend_names) == convention_names -def test_base_articulation_data_property_uses_base_data_contract() -> None: - """Annotate the abstract property with the type implemented by every backend.""" - return_annotation = BaseArticulation.data.fget.__annotations__["return"] - assert return_annotation == "BaseArticulationData" - - -def test_base_articulation_keeps_none_ordering_on_default_path() -> None: - """Keep the default public ordering path free of ordering maps.""" - apply_calls = [] - data = types.SimpleNamespace( - joint_ordering=None, - body_ordering=None, - joint_names=None, - body_names=None, - _apply_ordering_maps_after_resolve=lambda: apply_calls.append("called"), - ) - articulation = types.SimpleNamespace( - __backend_name__="mock", - cfg=types.SimpleNamespace(joint_ordering=None, body_ordering=None), - data=data, - backend_joint_names=["hip", "knee"], - backend_body_names=["base", "foot"], - device="cpu", - ) - articulation._resolve_axis_ordering = BaseArticulation._resolve_axis_ordering.__get__(articulation) - - BaseArticulation._resolve_and_install_ordering_maps(articulation) - - assert data.joint_ordering is None - assert data.body_ordering is None - assert data.joint_names == ["hip", "knee"] - assert data.body_names == ["base", "foot"] - # The install site always lets the data container reconcile its staging; - # backend hooks are no-ops when no ordering is or was active. - assert apply_calls == ["called"] - - class _LegacyArticulation(BaseArticulation): """Old-style backend that predates the ordering introspection properties.""" @@ -1187,10 +1087,25 @@ def test_map_ids_to_backend_handles_slice_selectors() -> None: all_items = slice(None) assert identity.map_joint_ids_to_backend(all_items) is all_items assert identity.map_body_ids_to_backend(all_items) is all_items + # The default (``None``) ordering returns list selectors unchanged as well. + ids = [2, 0, 1] + assert identity.map_joint_ids_to_backend(ids) is ids + assert identity.map_body_ids_to_backend(ids) is ids def test_ordering_map_helpers_pick_axis_direction_and_identity_fallback() -> None: """Return the exact map object per axis and direction, or the backend identity buffer.""" + from isaaclab.assets.articulation.base_articulation import BaseArticulation + from isaaclab.assets.articulation.base_articulation_data import BaseArticulationData + + # Ordering state has one owner; mirrored maps and cached flags must not return. + for owner, names in ( + (BaseArticulation, ("_cache_ordering_maps", "_reset_and_cache_ordering_maps")), + (BaseArticulationData, ("_install_ordering_flags", "_has_joint_ordering", "_has_body_ordering")), + ): + for name in names: + assert not hasattr(owner, name), f"{owner.__name__}.{name} duplicates ordering state" + permuted = _make_map_ids_articulation(permuted=True) joint_ordering = permuted._data.joint_ordering body_ordering = permuted._data.body_ordering @@ -1242,28 +1157,6 @@ def test_floating_base_body_ordering_accepts_root_relocation() -> None: assert articulation.data.body_names == ["foot", "base"] -def test_explicit_backend_order_normalizes_to_none() -> None: - """Normalize orderings that resolve to backend order to ``None`` instead of identity maps.""" - articulation = _make_ordering_resolution_articulation( - body_ordering=("base", "foot"), - is_fixed_base=True, - ) - articulation.cfg.joint_ordering = ("joint",) - - BaseArticulation._resolve_and_install_ordering_maps(articulation) - - assert articulation.data.joint_ordering is None - assert articulation.data.body_ordering is None - assert articulation.data.joint_names == ["joint"] - assert articulation.data.body_names == ["base", "foot"] - - -def test_base_articulation_data_defines_optional_ordering_maps() -> None: - """Expose optional ordering maps on articulation data containers.""" - assert hasattr(BaseArticulationData, "joint_ordering") - assert hasattr(BaseArticulationData, "body_ordering") - - def test_build_articulation_name_map_builds_permutation_indices_and_device_maps() -> None: """Build CPU and device maps for an explicit user ordering permutation.""" name_map = build_articulation_name_map( @@ -1322,81 +1215,3 @@ def test_build_articulation_name_map_returns_none_for_identity() -> None: is None ) assert build_articulation_name_map(kind="joint", backend_names=backend_names, user_names=None, device="cpu") is None - - permuted = build_articulation_name_map( - kind="joint", backend_names=backend_names, user_names=("j_c", "j_a", "j_b"), device="cpu" - ) - assert permuted is not None - assert permuted.user_to_backend_indices == (2, 0, 1) - assert permuted.backend_to_user_indices == (1, 2, 0) - assert not hasattr(permuted, "is_identity") - assert not hasattr(permuted, "user_names") - - -def test_ordering_state_lives_only_on_data() -> None: - """No mirrored ordering flags or maps exist on the asset or data classes.""" - from isaaclab.assets.articulation.base_articulation import BaseArticulation - from isaaclab.assets.articulation.base_articulation_data import BaseArticulationData - - for owner, names in ( - (BaseArticulation, ("_cache_ordering_maps", "_reset_and_cache_ordering_maps")), - (BaseArticulationData, ("_install_ordering_flags", "_has_joint_ordering", "_has_body_ordering")), - ): - for name in names: - assert not hasattr(owner, name), f"{owner.__name__}.{name} should be deleted" - - -_NONIDENTITY_ORDERING = types.SimpleNamespace( - user_to_backend_indices=(0, 2, 1), - backend_to_user_indices=(0, 2, 1), -) - - -class _MapBodyIdsSurface: - """Minimal surface exercising the real body-id translation method.""" - - map_body_ids_to_backend = BaseArticulation.map_body_ids_to_backend - - def __init__(self, body_ordering): - self.body_ordering = body_ordering - - -class _MapJointIdsSurface: - """Minimal surface exercising the real joint-id translation method.""" - - map_joint_ids_to_backend = BaseArticulation.map_joint_ids_to_backend - - def __init__(self, joint_ordering): - self.joint_ordering = joint_ordering - - -def test_map_body_ids_to_backend_returns_input_unchanged_for_default_ordering() -> None: - """A ``None`` body ordering returns the input object unchanged. - - ``None`` covers identity-configured orderings too: assets normalize - identity maps away at install time, so this is the only fast-path state. - """ - asset = _MapBodyIdsSurface(None) - body_ids = [2, 0, 1] - assert asset.map_body_ids_to_backend(body_ids) is body_ids - - -def test_map_body_ids_to_backend_permutes_ids_for_nonidentity_ordering() -> None: - """A nonidentity body ordering gathers public IDs into backend order.""" - asset = _MapBodyIdsSurface(_NONIDENTITY_ORDERING) - # user_to_backend_indices == (0, 2, 1): public 1 -> backend 2, public 2 -> backend 1 - assert asset.map_body_ids_to_backend([1, 2]) == [2, 1] - - -def test_map_joint_ids_to_backend_returns_input_unchanged_for_default_ordering() -> None: - """A ``None`` (default) joint ordering returns the input object unchanged.""" - asset = _MapJointIdsSurface(None) - joint_ids = [2, 0, 1] - assert asset.map_joint_ids_to_backend(joint_ids) is joint_ids - - -def test_map_joint_ids_to_backend_permutes_ids_for_nonidentity_ordering() -> None: - """A nonidentity joint ordering gathers public IDs into backend order.""" - asset = _MapJointIdsSurface(_NONIDENTITY_ORDERING) - # user_to_backend_indices == (0, 2, 1): public 1 -> backend 2, public 2 -> backend 1 - assert asset.map_joint_ids_to_backend([1, 2]) == [2, 1] diff --git a/source/isaaclab/test/assets/test_articulation_ordering_iface.py b/source/isaaclab/test/assets/test_articulation_ordering_iface.py index d5bad9e6fcd0..6a60e9a81731 100644 --- a/source/isaaclab/test/assets/test_articulation_ordering_iface.py +++ b/source/isaaclab/test/assets/test_articulation_ordering_iface.py @@ -46,12 +46,17 @@ def _make_body_ordering_backend_data(num_instances: int, num_bodies: int) -> tup return root_pose, root_vel, link_pose, com_pose_b, body_com_vel, body_acc -def _install_test_body_ordering(art) -> np.ndarray: - """Install a non-identity body ordering that preserves a fixed root body.""" - if art.is_fixed_base: - body_ordering = (art.backend_body_names[0], *reversed(art.backend_body_names[1:])) +def _install_test_body_ordering(art, mode: str = "reversed") -> np.ndarray: + """Install a reversed or cyclic body ordering that preserves a fixed root body.""" + names = art.backend_body_names + movable = names[1:] if art.is_fixed_base else names + if mode == "reversed": + movable = tuple(reversed(movable)) + elif mode == "cyclic": + movable = (movable[-1], *movable[:-1]) else: - body_ordering = tuple(reversed(art.backend_body_names)) + raise ValueError(f"Unsupported body ordering mode: {mode}") + body_ordering = (names[0], *movable) if art.is_fixed_base else tuple(movable) art.cfg = art.cfg.replace(body_ordering=body_ordering) # Re-resolve and re-stage the ordering maps exactly as backend initialization # does after a config change installs a new ordering on an already-initialized @@ -61,9 +66,9 @@ def _install_test_body_ordering(art) -> np.ndarray: return np.asarray(art.body_ordering.user_to_backend_indices, dtype=np.int64) -def _install_reversed_joint_ordering(art) -> np.ndarray: - """Install a reversed public joint ordering on an already constructed articulation.""" - art.cfg = art.cfg.replace(joint_ordering=tuple(reversed(art.backend_joint_names))) +def _install_test_joint_ordering(art, mode: str = "reversed") -> np.ndarray: + """Install a reversed or cyclic public joint ordering on an already constructed articulation.""" + art.cfg = art.cfg.replace(joint_ordering=_joint_ordering_for_mode(mode, art.num_joints)) # Re-resolve and re-stage the ordering maps exactly as backend initialization # does after a config change installs a new ordering on an already-initialized # articulation. @@ -1062,39 +1067,6 @@ def test_ovphysx_ordered_root_and_body_velocity_share_one_backend_read(self) -> link_velocity_reads = [call for call in binding_read.call_args_list if call.args[0] == TT.LINK_VELOCITY] assert len(link_velocity_reads) == 1 - @_requires_ovphysx - def test_ovphysx_com_write_invalidates_all_dependent_caches_under_ordering(self): - """Invalidate every public and backend cache derived from OVPhysX COM poses.""" - art, _ = get_articulation("ovphysx", 2, 1, 3, device="cpu", body_ordering=("body_2", "body_1", "body_0")) - cache_names = ( - "_root_com_pose_w", - "_root_com_vel_w", - "_root_link_vel_w", - "_body_com_pose_w", - "_body_com_vel_w", - "_body_com_vel_w_backend", - "_body_link_vel_w", - "_root_link_lin_vel_b", - "_root_link_ang_vel_b", - "_root_com_lin_vel_b", - "_root_com_ang_vel_b", - "_root_state_w_buf", - "_root_link_state_w_buf", - "_root_com_state_w_buf", - "_body_state_w_buf", - "_body_link_state_w_buf", - "_body_com_state_w_buf", - ) - for cache_name in cache_names: - getattr(art.data, cache_name).timestamp = art.data._sim_timestamp - - coms = np.zeros((art.num_instances, art.num_bodies, 7), dtype=np.float32) - coms[..., 6] = 1.0 - art.set_coms_index(coms=wp.array(coms, dtype=wp.transformf, device=art.device)) - - for cache_name in cache_names: - assert getattr(art.data, cache_name).timestamp == -1.0, cache_name - @_dynamics_ordering_backends @pytest.mark.parametrize("num_instances, num_joints, num_bodies", [(2, 3, 4)]) @pytest.mark.parametrize("device", ["cpu"]) @@ -1119,8 +1091,9 @@ def test_ordering_reorders_public_dynamics_quantities( ) _set_dynamics_ordering_backend_data(backend, identity_art, identity_raw, *raw_dynamics_data) _set_dynamics_ordering_backend_data(backend, ordered_art, ordered_raw, *raw_dynamics_data) - body_user_to_backend = _install_test_body_ordering(ordered_art) - joint_user_to_backend = _install_reversed_joint_ordering(ordered_art) + # Cyclic orderings are not involutions, so a user_to_backend/backend_to_user swap shows up. + body_user_to_backend = _install_test_body_ordering(ordered_art, "cyclic") + joint_user_to_backend = _install_test_joint_ordering(ordered_art, "cyclic") identity_art.data.update(dt=0.01) ordered_art.data.update(dt=0.01) @@ -1255,6 +1228,10 @@ def test_backends_allocate_shadows_only_for_nonidentity_ordering(self, backend: expected_ordering = ordering_mode == "reversed" assert (art.joint_ordering is not None) is expected_ordering assert (art.body_ordering is not None) is expected_ordering + if not expected_ordering: + # Default and explicit backend orders keep the backend names as the public names. + assert art.joint_names == list(art.backend_joint_names) + assert art.body_names == list(art.backend_body_names) # The asset ordering properties delegate to the single source on data. assert art.joint_ordering is art.data.joint_ordering assert art.body_ordering is art.data.body_ordering @@ -1460,6 +1437,8 @@ def test_duplicate_body_ids_preserve_omitted_backend_com(self, backend: str, ord backend_seed = _make_backend_com_poses(num_instances, num_bodies) _seed_backend_com_poses(backend, art, raw_backend, backend_seed) np.testing.assert_array_equal(_read_backend_com_poses(backend, art, raw_backend), backend_seed) + if backend == "physx": + raw_backend.get_coms = MagicMock(wraps=raw_backend.get_coms) user_to_backend = _ordering_user_to_backend(art.body_ordering, num_bodies) duplicate_body_ids = [0, 1, 1, 2] @@ -1471,6 +1450,10 @@ def test_duplicate_body_ids_preserve_omitted_backend_com(self, backend: str, ord coms=wp.array(payload, dtype=wp.transformf, device=art.device), body_ids=wp.array(duplicate_body_ids, dtype=wp.int32, device=art.device), ) + public_after = art.data.body_com_pose_b.torch.detach().cpu().numpy() + if backend == "physx": + # The cold partial write seeds untouched bodies with one backend read; the public read reuses it. + assert raw_backend.get_coms.call_count == 1 expected_backend = backend_seed.copy() for env_offset, env_id in enumerate(env_ids): @@ -1482,7 +1465,6 @@ def test_duplicate_body_ids_preserve_omitted_backend_com(self, backend: str, ord backend_after[:, user_to_backend[omitted_public_body_id]], backend_seed[:, user_to_backend[omitted_public_body_id]], ) - public_after = art.data.body_com_pose_b.torch.detach().cpu().numpy() np.testing.assert_array_equal(public_after, expected_backend[:, user_to_backend]) @_physx_ovphysx_backends @@ -1672,25 +1654,25 @@ def test_floating_root_writers_match_identity_after_body_reordering( class TestArticulationOrderingWriteParity: """Test that partial joint writes preserve unselected backend state.""" - # Pairwise covering array for the partial-write matrix. The full cartesian is - # backend(3) x ordering(2) x selection(2) x operation(3) x coverage(2) = 72 cases. - # These 12 rows retain every backend x ordering x selection triple and every - # cross-pair involving operation or coverage. + # Each backend selects the joint map separately in every ordered writer (position, velocity, state x + # index, mask), so every ordered writer gets one row. The ordering is cyclic: an involution such as + # 'reversed' on three joints is its own inverse and cannot tell user_to_backend from backend_to_user. + # Unordered rows keep one index and one mask writer per backend; coverage alternates across rows. @pytest.mark.parametrize( ("backend", "ordering_mode", "selection", "operation", "coverage"), [ - _backend_param("physx", "none", "index", "state", "all_envs_one_item"), - _backend_param("physx", "none", "mask", "position", "one_env_all_items"), - _backend_param("physx", "reversed", "index", "position", "one_env_all_items"), - _backend_param("physx", "reversed", "mask", "velocity", "all_envs_one_item"), - _backend_param("ovphysx", "none", "index", "position", "all_envs_one_item"), - _backend_param("ovphysx", "none", "mask", "position", "one_env_all_items"), - _backend_param("ovphysx", "reversed", "index", "velocity", "all_envs_one_item"), - _backend_param("ovphysx", "reversed", "mask", "state", "one_env_all_items"), - _backend_param("newton", "none", "index", "state", "all_envs_one_item"), - _backend_param("newton", "none", "mask", "velocity", "all_envs_one_item"), - _backend_param("newton", "reversed", "index", "position", "one_env_all_items"), - _backend_param("newton", "reversed", "mask", "velocity", "one_env_all_items"), + row + for backend in ("physx", "ovphysx", "newton") + for row in ( + _backend_param(backend, "none", "index", "state", "all_envs_one_item"), + _backend_param(backend, "none", "mask", "position", "one_env_all_items"), + _backend_param(backend, "cyclic", "index", "position", "one_env_all_items"), + _backend_param(backend, "cyclic", "index", "velocity", "all_envs_one_item"), + _backend_param(backend, "cyclic", "index", "state", "one_env_all_items"), + _backend_param(backend, "cyclic", "mask", "position", "all_envs_one_item"), + _backend_param(backend, "cyclic", "mask", "velocity", "one_env_all_items"), + _backend_param(backend, "cyclic", "mask", "state", "all_envs_one_item"), + ) ], ) def test_partial_joint_write_preserves_backend_rows( @@ -1821,7 +1803,7 @@ def test_stale_partial_joint_write_preserves_newer_backend_rows(self, backend: s def test_ovphysx_partial_effort_target_write_preserves_unselected_backend_rows(self) -> None: """A partial effort-target write must not leak the last applied torque onto unselected joints. - ``write_data_to_sim`` pushes the actuator-computed ``_applied_torque`` (which can differ + ``write_data_to_sim`` pushes the processed effort command (which can differ from the raw commanded target, e.g. once explicit actuators clip or otherwise transform it) to the ``DOF_ACTUATION_FORCE`` binding. That push must use its own backend-order scratch buffer rather than ``_joint_effort_target_backend``, because the latter is also the staging @@ -1852,7 +1834,7 @@ def test_ovphysx_partial_effort_target_write_preserves_unselected_backend_rows(s # Simulate an actuator model computing an applied torque that differs from the raw # target, then run one full simulation step. applied_torque = raw_targets + 100.0 - art.data._applied_torque.assign(wp.array(applied_torque, dtype=wp.float32, device=art.device)) + art.actuators._joint_effort_target_sim.assign(wp.array(applied_torque, dtype=wp.float32, device=art.device)) art.write_data_to_sim() pushed_after_step = raw_backend.bindings[TT.DOF_ACTUATION_FORCE]._data.copy() @@ -1880,7 +1862,7 @@ class TestArticulationDataJointState: def test_ovphysx_joint_acceleration_differences_public_order_velocities(self): """Finite-difference OVPhysX joint velocity entirely in public joint order.""" art, raw_backend = get_articulation("ovphysx", 2, 3, 2, device="cpu") - user_to_backend = _install_reversed_joint_ordering(art) + user_to_backend = _install_test_joint_ordering(art) from isaaclab_ov import tensor_types as TT first = np.asarray([[1.0, 2.0, 4.0], [10.0, 20.0, 40.0]], dtype=np.float32) @@ -1909,12 +1891,17 @@ def test_ovphysx_ordered_joint_state_is_cached_per_sim_timestamp(self): joint_ordering=("joint_2", "joint_1", "joint_0"), ) art.data.update(0.01) + binding_read = MagicMock(wraps=art.data._binding_read) + art.data._binding_read = binding_read + from isaaclab_ov import tensor_types as TT - art.data.joint_pos.torch.clone() - art.data.joint_vel.torch.clone() + for _ in range(2): + art.data.joint_pos.torch.clone() + art.data.joint_vel.torch.clone() - assert art.data._joint_pos_buf.timestamp == art.data._sim_timestamp - assert art.data._joint_vel_buf.timestamp == art.data._sim_timestamp + read_types = [call.args[0] for call in binding_read.call_args_list] + assert read_types.count(TT.DOF_POSITION) == 1 + assert read_types.count(TT.DOF_VELOCITY) == 1 @_non_mock_backends @pytest.mark.parametrize("num_instances, num_joints, num_bodies", [(2, 3, 2)]) @@ -2116,9 +2103,10 @@ def test_ovphysx_implicit_targets_are_written_in_backend_order(self, ordering_mo position = np.arange(num_instances * num_joints, dtype=np.float32).reshape(num_instances, num_joints) velocity = position + 100.0 effort = position + 200.0 - art.data._joint_pos_target.assign(wp.array(position, dtype=wp.float32, device=art.device)) - art.data._joint_vel_target.assign(wp.array(velocity, dtype=wp.float32, device=art.device)) - art.data._applied_torque.assign(wp.array(effort, dtype=wp.float32, device=art.device)) + # Seed the actuator collection's submitted command buffers (no actuator groups recompute them). + art.actuators._joint_pos_target.assign(wp.array(position, dtype=wp.float32, device=art.device)) + art.actuators._joint_vel_target.assign(wp.array(velocity, dtype=wp.float32, device=art.device)) + art.actuators._joint_effort_target_sim.assign(wp.array(effort, dtype=wp.float32, device=art.device)) object.__setattr__(art, "_has_implicit_actuators", True) object.__setattr__(art, "_can_write_effort", True) @@ -2242,11 +2230,14 @@ class TestArticulationWritersJoint: """Test joint writers/setters with all input combinations.""" @_non_mock_backends + @pytest.mark.parametrize("ordering_mode", ["reversed", "cyclic"]) @pytest.mark.parametrize("selection", ["index", "mask"]) - def test_reversed_joint_ordering_routes_property_writes_to_backend(self, backend: str, selection: str): + def test_reversed_joint_ordering_routes_property_writes_to_backend( + self, backend: str, ordering_mode: str, selection: str + ): """Route partial public property writes to matching backend joints.""" num_instances, num_joints, num_bodies = 2, 4, 2 - joint_ordering = tuple(f"joint_{index}" for index in reversed(range(num_joints))) + joint_ordering = _joint_ordering_for_mode(ordering_mode, num_joints) art, raw_backend = get_articulation( backend, num_instances, diff --git a/source/isaaclab/test/assets/test_articulation_ordering_kernels.py b/source/isaaclab/test/assets/test_articulation_ordering_kernels.py index 2e35fecffbf3..a914da2e8f80 100644 --- a/source/isaaclab/test/assets/test_articulation_ordering_kernels.py +++ b/source/isaaclab/test/assets/test_articulation_ordering_kernels.py @@ -12,8 +12,6 @@ from isaaclab.assets.articulation.ordering_kernels import ( launch_reorder_joint_targets_user_to_backend, reorder_2d_backend_to_user, - reorder_2d_user_to_backend, - reorder_3d_backend_to_user, reorder_body_state_backend_to_user, reorder_generalized_vector_backend_to_user, reorder_joint_state_backend_to_user, @@ -59,58 +57,6 @@ def test_reorder_2d_backend_to_user_gathers_user_axis() -> None: np.testing.assert_allclose(user_data.numpy(), expected) -def test_reorder_2d_user_to_backend_scatters_backend_axis() -> None: - """Reorder a 2-D public user buffer into backend order.""" - user_data = wp.array( - np.asarray( - [ - [12.0, 10.0, 11.0], - [22.0, 20.0, 21.0], - ], - dtype=np.float32, - ), - dtype=wp.float32, - device="cpu", - ) - backend_to_user = wp.array(np.asarray([1, 2, 0], dtype=np.int32), dtype=wp.int32, device="cpu") - backend_data = wp.zeros_like(user_data) - - wp.launch( - reorder_2d_user_to_backend, - dim=backend_data.shape, - inputs=[user_data, backend_to_user], - outputs=[backend_data], - device="cpu", - ) - - expected = np.asarray([[10.0, 11.0, 12.0], [20.0, 21.0, 22.0]], dtype=np.float32) - np.testing.assert_allclose(backend_data.numpy(), expected) - - -def test_reorder_3d_backend_to_user_gathers_user_axis() -> None: - """Reorder a 3-D backend buffer whose second axis is the public user axis.""" - backend_data_np = np.asarray( - [ - [[100.0, 101.0], [110.0, 111.0], [120.0, 121.0]], - [[200.0, 201.0], [210.0, 211.0], [220.0, 221.0]], - ], - dtype=np.float32, - ) - backend_data = wp.array(backend_data_np, dtype=wp.float32, device="cpu") - user_to_backend = wp.array(np.asarray([1, 2, 0], dtype=np.int32), dtype=wp.int32, device="cpu") - user_data = wp.zeros_like(backend_data) - - wp.launch( - reorder_3d_backend_to_user, - dim=user_data.shape, - inputs=[backend_data, user_to_backend], - outputs=[user_data], - device="cpu", - ) - - np.testing.assert_allclose(user_data.numpy(), backend_data_np[:, [1, 2, 0], :]) - - def test_reorder_joint_state_backend_to_user_gathers_position_and_velocity() -> None: """Fuse the two joint-state publish gathers under a single shared joint map.""" backend_pos_np = np.asarray([[10.0, 11.0, 12.0], [20.0, 21.0, 22.0]], dtype=np.float32) @@ -340,12 +286,17 @@ def expected_with_selected_value(source: np.ndarray, value: float) -> np.ndarray def test_write_float_scalar_user_to_backend_with_indices_updates_user_and_backend_buffers() -> None: - """Fuse indexed scalar writes into user and backend-order buffers.""" + """Fuse indexed scalar writes into user and backend-order buffers. + + The outputs are torch tensors so the launch wrapper's torch adaptation is covered as well. + """ + import torch + env_ids = wp.array(np.asarray([0, 2], dtype=np.int32), dtype=wp.int32, device="cpu") user_ids = wp.array(np.asarray([2, 0], dtype=np.int32), dtype=wp.int32, device="cpu") user_to_backend = wp.array(np.asarray([1, 2, 0], dtype=np.int32), dtype=wp.int32, device="cpu") - user_data = wp.zeros((3, 3), dtype=wp.float32, device="cpu") - backend_data = wp.zeros((3, 3), dtype=wp.float32, device="cpu") + user_data = torch.zeros((3, 3), dtype=torch.float32, device="cpu") + backend_data = torch.zeros((3, 3), dtype=torch.float32, device="cpu") write_float_user_to_backend_with_indices( 4.5, env_ids, user_ids, user_to_backend, True, False, user_data, backend_data, device="cpu" @@ -598,16 +549,6 @@ def test_write_3d_user_to_backend_updates_component_buffers(selection: str) -> N np.testing.assert_allclose(backend_data.numpy(), expected_backend) -def test_directional_reorder_names_alias_the_gather_kernels() -> None: - """The four direction names are aliases: one kernel body per rank.""" - from isaaclab.assets.articulation import ordering_kernels as k - - assert k.reorder_2d_backend_to_user is k.gather_2d - assert k.reorder_2d_user_to_backend is k.gather_2d - assert k.reorder_3d_backend_to_user is k.gather_3d - assert k.reorder_3d_user_to_backend is k.gather_3d - - def test_write_2d_wrapper_adapts_torch_vec3_inputs() -> None: """The launch wrapper accepts torch tensors and folds trailing vec3 dims.""" import torch @@ -640,25 +581,6 @@ def test_write_2d_wrapper_adapts_torch_vec3_inputs() -> None: assert torch.equal(backend_data[:, [2, 0, 1], :], input_data) -def test_write_float_wrapper_accepts_scalar_input() -> None: - """The float wrapper passes Python scalars straight to the scalar overload.""" - import torch - - from isaaclab.assets.articulation import ordering_kernels as k - - user_data = torch.zeros((2, 3), dtype=torch.float32, device="cpu") - backend_data = torch.zeros((2, 3), dtype=torch.float32, device="cpu") - env_ids = wp.array([0, 1], dtype=wp.int32, device="cpu") - user_ids = wp.array([1], dtype=wp.int32, device="cpu") - user_to_backend = wp.array([2, 0, 1], dtype=wp.int32, device="cpu") - - k.write_float_user_to_backend_with_indices( - 7.5, env_ids, user_ids, user_to_backend, True, False, user_data, backend_data, device="cpu" - ) - assert user_data[0, 1].item() == 7.5 and user_data[1, 1].item() == 7.5 - assert backend_data[0, 0].item() == 7.5 # user index 1 -> backend index 0 - - @pytest.mark.parametrize("env_dtype", [wp.int32, wp.int64]) @pytest.mark.parametrize("joint_dtype", [wp.int32, wp.int64]) def test_joint_state_writer_accepts_selector_widths(env_dtype: type, joint_dtype: type) -> None: @@ -688,13 +610,11 @@ def test_joint_state_writer_accepts_selector_widths(env_dtype: type, joint_dtype np.testing.assert_array_equal(backend_position.numpy(), [[120.0, 121.0, 0.0], [110.0, 111.0, 0.0]]) -@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) @pytest.mark.parametrize("num_base_dofs", [0, 6]) @pytest.mark.parametrize("correct_signs", [False, True]) -def test_generalized_force_ordering_with_optional_signs(device, num_base_dofs, correct_signs): +def test_generalized_force_ordering_with_optional_signs(num_base_dofs, correct_signs): """Preserve floating-base forces and optionally correct actuated-joint signs.""" - if device.startswith("cuda") and not wp.is_cuda_available(): - pytest.skip("CUDA is unavailable") + device = "cpu" # the kernel body is device-independent joint_order = np.array([2, 0, 1], dtype=np.int32) signs = np.array([1, -1, 1], dtype=np.int32) values = np.arange(1, 2 * (num_base_dofs + len(joint_order)) + 1, dtype=np.float32).reshape(2, -1) diff --git a/source/isaaclab/test/assets/test_asset_selector_cache.py b/source/isaaclab/test/assets/test_asset_selector_cache.py index 61ab00fdbf51..ff51078085f0 100644 --- a/source/isaaclab/test/assets/test_asset_selector_cache.py +++ b/source/isaaclab/test/assets/test_asset_selector_cache.py @@ -115,20 +115,6 @@ def test_clear_releases_cached_entries(): assert first.warp.ptr != second.warp.ptr -def test_legacy_list_mode_is_default(): - asset = _make_asset() - - implicit = asset._resolve_finder_indices( - [2, 5], - domain="joint", - legacy_type="list", - ) - explicit = _find(asset, [2, 5], as_proxy=False) - - assert implicit == explicit == [2, 5] - assert implicit is not explicit - - def test_legacy_tensor_mode_is_int32_on_asset_device(): asset = _make_asset() diff --git a/source/isaaclab/test/assets/test_iface_test_utils.py b/source/isaaclab/test/assets/test_iface_test_utils.py deleted file mode 100644 index 883105ee4d02..000000000000 --- a/source/isaaclab/test/assets/test_iface_test_utils.py +++ /dev/null @@ -1,98 +0,0 @@ -# Copyright (c) 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 - -import os -import subprocess -import sys -from pathlib import Path - -import pytest - -_ASSET_TEST_DIR = Path(__file__).parent -_IFACE_UTIL_MODULES = ( - "_articulation_iface_test_utils", - "_rigid_object_iface_test_utils", - "_rigid_object_collection_iface_test_utils", -) - - -def _run_probe(script: str) -> subprocess.CompletedProcess[str]: - env = os.environ.copy() - env.pop("EXP_PATH", None) - env.pop("LD_PRELOAD", None) - return subprocess.run( - [sys.executable, "-c", script], - cwd=_ASSET_TEST_DIR, - env=env, - capture_output=True, - text=True, - timeout=60, - ) - - -def test_iface_utilities_share_one_bootstrap_module() -> None: - script = f""" -import importlib -import sys - -sys.path.insert(0, {_ASSET_TEST_DIR.as_posix()!r}) -modules = [importlib.import_module(name) for name in {_IFACE_UTIL_MODULES!r}] -boot = importlib.import_module("_iface_test_boot") -assert all(module.simulation_app is boot.simulation_app for module in modules) -""" - - result = _run_probe(script) - - assert result.returncode == 0, result.stdout + result.stderr - - -def test_iface_utilities_import_without_physx_package() -> None: - script = f""" -import builtins -import importlib -import sys - -sys.path.insert(0, {_ASSET_TEST_DIR.as_posix()!r}) -real_import = builtins.__import__ - -def blocked_import(name, *args, **kwargs): - if name == "isaaclab_physx" or name.startswith("isaaclab_physx."): - raise ModuleNotFoundError(name) - return real_import(name, *args, **kwargs) - -builtins.__import__ = blocked_import -modules = [importlib.import_module(name) for name in {_IFACE_UTIL_MODULES!r}] -assert all(not any(backend.lower() == "physx" for backend in module.BACKENDS) for module in modules) -""" - - result = _run_probe(script) - - assert result.returncode == 0, result.stdout + result.stderr - - -@pytest.mark.parametrize("module_name", _IFACE_UTIL_MODULES) -def test_iface_utility_handles_no_available_backends(module_name: str) -> None: - script = f""" -import builtins -import importlib -import sys - -sys.path.insert(0, {_ASSET_TEST_DIR.as_posix()!r}) -real_import = builtins.__import__ - -def guarded_import(name, globals=None, locals=None, fromlist=(), level=0): - backend_prefixes = ("isaaclab_physx.", "isaaclab_newton.", "isaaclab_ov.") - if name == "ovphysx" or name.startswith(backend_prefixes): - raise ModuleNotFoundError("backend dependency unavailable", name="backend_dependency") - return real_import(name, globals, locals, fromlist, level) - -builtins.__import__ = guarded_import -module = importlib.import_module({module_name!r}) -assert module.BACKENDS == [] -""" - - result = _run_probe(script) - - assert result.returncode == 0, result.stdout + result.stderr diff --git a/source/isaaclab/test/assets/test_rigid_object_collection_iface.py b/source/isaaclab/test/assets/test_rigid_object_collection_iface.py index b91e8d1b3128..8d52f5de2518 100644 --- a/source/isaaclab/test/assets/test_rigid_object_collection_iface.py +++ b/source/isaaclab/test/assets/test_rigid_object_collection_iface.py @@ -14,35 +14,41 @@ The setup is a bit convoluted so that we can run these tests without requiring Isaac Sim or GPU simulation. """ +import math +from unittest.mock import MagicMock, patch + import numpy as np import pytest import torch import warp as wp from _rigid_object_collection_iface_test_utils import BACKENDS, get_rigid_object_collection +from isaaclab.test.utils import DeviceScope, test_devices + pytestmark = pytest.mark.integration +# One fixture with distinct instance and body counts so any swapped axis shows up. +_NUM_INSTANCES, _NUM_BODIES = 2, 3 + @pytest.fixture -def collection_iface(request): - backend = request.getfixturevalue("backend") - num_instances = request.getfixturevalue("num_instances") - num_bodies = request.getfixturevalue("num_bodies") - device = request.getfixturevalue("device") - result = get_rigid_object_collection(backend, num_instances, num_bodies, device) - if backend != "newton": - yield result - return - # The Newton collection's ``body_link_pose_w`` triggers ``_ensure_fk_fresh()`` -> - # ``NewtonManager.forward()``, which runs ``eval_fk`` against a live simulation state. The mocked - # interface has no such state (``_state_0`` is ``None``), so stub ``forward()`` to a no-op for the - # test body; the mock view supplies the cached pose data directly. - from unittest.mock import patch - - from isaaclab_newton.physics import NewtonManager - - with patch.object(NewtonManager, "forward"): - yield result +def make_collection(monkeypatch): + """Build a mocked collection; Newton gets a no-op ``forward()``. + + The Newton collection's ``body_link_pose_w`` triggers ``_ensure_fk_fresh()`` -> + ``NewtonManager.forward()``, which runs ``eval_fk`` against a live simulation state. The mocked + interface has no such state (``_state_0`` is ``None``), so stub ``forward()`` to a no-op; the mock + view supplies the cached pose data directly. + """ + + def _make(backend: str, device: str): + if backend == "newton": + from isaaclab_newton.physics import NewtonManager + + monkeypatch.setattr(NewtonManager, "forward", MagicMock()) + return get_rigid_object_collection(backend, _NUM_INSTANCES, _NUM_BODIES, device) + + return _make # --------------------------------------------------------------------------- @@ -59,13 +65,10 @@ def _check_proxy_array(arr, *, expected_shape: tuple, expected_dtype: type, name assert arr.dtype == expected_dtype, f"{name}: expected dtype {expected_dtype}, got {arr.dtype}" -# Common parametrize decorators +# Common parametrize decorators. Pure bookkeeping (counts, names, finders, aliases) runs on CPU only; +# getters and writers keep every test device because PhysX stages through CPU-pinned buffers on CUDA. _backends = pytest.mark.parametrize("backend", BACKENDS, indirect=False) -_default_dims = pytest.mark.parametrize("num_instances", [1, 2, 100]) - -_default_bodies = pytest.mark.parametrize("num_bodies", [1, 3]) - -_default_devices = pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +_devices = pytest.mark.parametrize("device", test_devices(DeviceScope.CPU_AND_DEFAULT_CUDA)) _index_resolution_backends = pytest.mark.parametrize( "backend", [backend for backend in ("physx", "newton") if backend in BACKENDS], indirect=False ) @@ -112,6 +115,32 @@ def _make_data_warp(shape: tuple, device: str, wp_dtype=wp.float32) -> wp.array: return wp.from_torch(t.contiguous(), dtype=wp_dtype) +def _make_payload_torch(shape: tuple, device: str, wp_dtype=wp.float32) -> torch.Tensor: + """Create valid torch data whose entries differ per element, for writer read-back checks. + + Transforms get distinct positions and a fixed 90-degree rotation about Z. + """ + values = torch.arange(1, math.prod(shape) + 1, dtype=torch.float32, device=device).reshape(shape) + 0.25 + if wp_dtype == wp.spatial_vectorf: + return values.unsqueeze(-1) + torch.arange(6, dtype=torch.float32, device=device) / 10.0 + if wp_dtype == wp.transformf: + data = torch.zeros((*shape, 7), dtype=torch.float32, device=device) + data[..., :3] = values.unsqueeze(-1) + torch.arange(3, dtype=torch.float32, device=device) / 10.0 + data[..., 5] = data[..., 6] = 2.0**-0.5 + return data + raise ValueError(f"Unsupported payload dtype: {wp_dtype}") + + +def _make_payload_warp(shape: tuple, device: str, wp_dtype=wp.float32) -> wp.array: + """Create the per-element distinct payload of :func:`_make_payload_torch` as a warp array.""" + return wp.from_torch(_make_payload_torch(shape, device, wp_dtype).contiguous(), dtype=wp_dtype) + + +def _assert_reads_back(value, expected: torch.Tensor, name: str) -> None: + """Assert a data getter (ProxyArray) returns the values a writer just wrote.""" + torch.testing.assert_close(value.torch, expected, atol=1e-5, rtol=1e-5, msg=lambda msg: f"{name}: {msg}") + + def _make_com_data(backend: str, shape: tuple[int, ...], device: str) -> wp.array: """Create backend-compatible center-of-mass test data.""" if backend == "newton": @@ -219,12 +248,9 @@ class TestCollectionViewReshape: """Test backend-specific view reshape helpers.""" @_reshape_3d_backends - @_default_devices + @_devices @pytest.mark.parametrize("dtype", [torch.float32, torch.float64]) def test_reshape_data_to_view_3d_accepts_torch_tensor(self, backend, device, dtype): - if device.startswith("cuda") and not torch.cuda.is_available(): - pytest.skip("CUDA is not available") - num_instances = 2 num_bodies = 3 data_dim = 4 @@ -241,6 +267,15 @@ def test_reshape_data_to_view_3d_accepts_torch_tensor(self, backend, device, dty assert view.is_contiguous() torch.testing.assert_close(view, data.permute(1, 0, 2).reshape(num_bodies * num_instances, data_dim)) + if dtype == torch.float32: + # Warp inputs keep returning a warp array with the same body-major layout. + warp_view = obj.reshape_data_to_view_3d(wp.from_torch(data, dtype=wp.float32), data_dim, device=device) + assert isinstance(warp_view, wp.array) + assert warp_view.shape == (num_bodies * num_instances, data_dim) + assert warp_view.dtype == wp.float32 + assert str(warp_view.device) == device + torch.testing.assert_close(wp.to_torch(warp_view), view) + @_reshape_3d_backends @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA is not available") def test_reshape_data_to_view_3d_moves_torch_tensor_to_requested_device(self, backend): @@ -259,32 +294,9 @@ def test_reshape_data_to_view_3d_moves_torch_tensor_to_requested_device(self, ba assert view.device.type == "cpu" torch.testing.assert_close(view, data.permute(1, 0, 2).reshape(num_bodies * num_instances, data_dim).cpu()) - @_reshape_3d_backends - @_default_devices - def test_reshape_data_to_view_3d_keeps_warp_array_behavior(self, backend, device): - if device.startswith("cuda") and not torch.cuda.is_available(): - pytest.skip("CUDA is not available") - - num_instances = 2 - num_bodies = 3 - data_dim = 4 - obj, _ = get_rigid_object_collection(backend, num_instances=num_instances, num_bodies=num_bodies, device=device) - data = torch.arange(num_instances * num_bodies * data_dim, dtype=torch.float32, device=device).reshape( - num_instances, num_bodies, data_dim - ) - - torch_view = obj.reshape_data_to_view_3d(data, data_dim, device=device) - warp_view = obj.reshape_data_to_view_3d(wp.from_torch(data, dtype=wp.float32), data_dim, device=device) - - assert isinstance(warp_view, wp.array) - assert warp_view.shape == (num_bodies * num_instances, data_dim) - assert warp_view.dtype == wp.float32 - assert str(warp_view.device) == device - torch.testing.assert_close(wp.to_torch(warp_view), torch_view) - # --------------------------------------------------------------------------- -# Tests: Collection properties +# Tests: Collection properties and finders # --------------------------------------------------------------------------- @@ -292,74 +304,20 @@ class TestCollectionProperties: """Test that collection properties return the correct types/values.""" @_backends - @_default_dims - @_default_bodies - @_default_devices - def test_num_instances(self, backend, num_instances, num_bodies, device, collection_iface): - obj, _ = collection_iface - assert obj.num_instances == num_instances - - @_backends - @_default_dims - @_default_bodies - @_default_devices - def test_num_bodies(self, backend, num_instances, num_bodies, device, collection_iface): - obj, _ = collection_iface - assert obj.num_bodies == num_bodies - - @_backends - @_default_dims - @_default_bodies - @_default_devices - def test_body_names(self, backend, num_instances, num_bodies, device, collection_iface): - obj, _ = collection_iface - names = obj.body_names - assert isinstance(names, list) - assert len(names) == num_bodies - assert all(isinstance(n, str) for n in names) - - @_backends - @_default_dims - @_default_bodies - @_default_devices - def test_data_returns_collection_data(self, backend, num_instances, num_bodies, device, collection_iface): + def test_collection_counts_and_names(self, backend, make_collection): from isaaclab.assets.rigid_object_collection.base_rigid_object_collection_data import ( BaseRigidObjectCollectionData, ) - obj, _ = collection_iface - assert isinstance(obj.data, BaseRigidObjectCollectionData) - - -# --------------------------------------------------------------------------- -# Tests: Collection finder methods -# --------------------------------------------------------------------------- - + obj, _ = make_collection(backend, "cpu") -class TestCollectionFinders: - """Test that finder methods return correct results.""" - - @_backends - @_default_dims - @_default_bodies - @_default_devices - def test_find_bodies_all(self, backend, num_instances, num_bodies, device, collection_iface): - obj, _ = collection_iface - mask, names = obj.find_bodies(".*") - assert isinstance(mask, torch.Tensor) + assert obj.num_instances == _NUM_INSTANCES + assert obj.num_bodies == _NUM_BODIES + names = obj.body_names assert isinstance(names, list) - assert len(names) == num_bodies - - @_backends - @_default_dims - @_default_bodies - @_default_devices - def test_find_bodies_single(self, backend, num_instances, num_bodies, device, collection_iface): - obj, _ = collection_iface - first_body = obj.body_names[0] - mask, names = obj.find_bodies(first_body) - assert len(names) == 1 - assert names == [first_body] + assert len(names) == _NUM_BODIES + assert all(isinstance(n, str) for n in names) + assert isinstance(obj.data, BaseRigidObjectCollectionData) class TestCollectionFinderReturnModes: @@ -374,12 +332,18 @@ def test_find_bodies_returns_legacy_tensor_or_cached_proxy(self, backend): assert isinstance(indices, torch.Tensor) assert indices.dtype == torch.int32 + assert isinstance(names, list) + assert len(names) == 3 assert indices.tolist() == proxy.torch.tolist() assert names == proxy_names assert proxy is collection.find_bodies(".*", as_proxy=True)[0] assert proxy.dtype == wp.int32 assert str(proxy.device) == collection.device + first_body = collection.body_names[0] + _, first_names = collection.find_bodies(first_body) + assert first_names == [first_body] + @_production_backends def test_find_objects_forwards_return_mode_with_alias_warning(self, backend): collection, _ = get_rigid_object_collection(backend, num_instances=2, num_bodies=3, device="cpu") @@ -391,442 +355,78 @@ def test_find_objects_forwards_return_mode_with_alias_warning(self, backend): # --------------------------------------------------------------------------- -# Tests: Body state properties -# --------------------------------------------------------------------------- - - -class TestCollectionDataBodyState: - """Test data properties for body state.""" - - @_backends - @_default_dims - @_default_bodies - @_default_devices - def test_body_link_pose_w(self, backend, num_instances, num_bodies, device, collection_iface): - obj, _ = collection_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.body_link_pose_w, - expected_shape=(num_instances, num_bodies), - expected_dtype=wp.transformf, - name="body_link_pose_w", - ) - - @_backends - @_default_dims - @_default_bodies - @_default_devices - def test_body_link_vel_w(self, backend, num_instances, num_bodies, device, collection_iface): - obj, _ = collection_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.body_link_vel_w, - expected_shape=(num_instances, num_bodies), - expected_dtype=wp.spatial_vectorf, - name="body_link_vel_w", - ) - - @_backends - @_default_dims - @_default_bodies - @_default_devices - def test_body_com_pose_w(self, backend, num_instances, num_bodies, device, collection_iface): - obj, _ = collection_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.body_com_pose_w, - expected_shape=(num_instances, num_bodies), - expected_dtype=wp.transformf, - name="body_com_pose_w", - ) - - @_backends - @_default_dims - @_default_bodies - @_default_devices - def test_body_com_vel_w(self, backend, num_instances, num_bodies, device, collection_iface): - obj, _ = collection_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.body_com_vel_w, - expected_shape=(num_instances, num_bodies), - expected_dtype=wp.spatial_vectorf, - name="body_com_vel_w", - ) - - @_backends - @_default_dims - @_default_bodies - @_default_devices - def test_body_com_acc_w(self, backend, num_instances, num_bodies, device, collection_iface): - obj, _ = collection_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.body_com_acc_w, - expected_shape=(num_instances, num_bodies), - expected_dtype=wp.spatial_vectorf, - name="body_com_acc_w", - ) - - @_backends - @_default_dims - @_default_bodies - @_default_devices - def test_body_com_pose_b(self, backend, num_instances, num_bodies, device, collection_iface): - obj, _ = collection_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.body_com_pose_b, - expected_shape=(num_instances, num_bodies), - expected_dtype=wp.transformf, - name="body_com_pose_b", - ) - - -# --------------------------------------------------------------------------- -# Tests: Sliced properties -# --------------------------------------------------------------------------- - - -class TestCollectionDataSliced: - """Test sliced data properties.""" - - @_backends - @_default_dims - @_default_bodies - @_default_devices - def test_body_link_pos_w(self, backend, num_instances, num_bodies, device, collection_iface): - obj, _ = collection_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.body_link_pos_w, - expected_shape=(num_instances, num_bodies), - expected_dtype=wp.vec3f, - name="body_link_pos_w", - ) - - @_backends - @_default_dims - @_default_bodies - @_default_devices - def test_body_link_quat_w(self, backend, num_instances, num_bodies, device, collection_iface): - obj, _ = collection_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.body_link_quat_w, - expected_shape=(num_instances, num_bodies), - expected_dtype=wp.quatf, - name="body_link_quat_w", - ) - - @_backends - @_default_dims - @_default_bodies - @_default_devices - def test_body_link_lin_vel_w(self, backend, num_instances, num_bodies, device, collection_iface): - obj, _ = collection_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.body_link_lin_vel_w, - expected_shape=(num_instances, num_bodies), - expected_dtype=wp.vec3f, - name="body_link_lin_vel_w", - ) - - @_backends - @_default_dims - @_default_bodies - @_default_devices - def test_body_link_ang_vel_w(self, backend, num_instances, num_bodies, device, collection_iface): - obj, _ = collection_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.body_link_ang_vel_w, - expected_shape=(num_instances, num_bodies), - expected_dtype=wp.vec3f, - name="body_link_ang_vel_w", - ) - - @_backends - @_default_dims - @_default_bodies - @_default_devices - def test_body_com_pos_w(self, backend, num_instances, num_bodies, device, collection_iface): - obj, _ = collection_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.body_com_pos_w, - expected_shape=(num_instances, num_bodies), - expected_dtype=wp.vec3f, - name="body_com_pos_w", - ) - - @_backends - @_default_dims - @_default_bodies - @_default_devices - def test_body_com_quat_w(self, backend, num_instances, num_bodies, device, collection_iface): - obj, _ = collection_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.body_com_quat_w, - expected_shape=(num_instances, num_bodies), - expected_dtype=wp.quatf, - name="body_com_quat_w", - ) - - @_backends - @_default_dims - @_default_bodies - @_default_devices - def test_body_com_lin_vel_w(self, backend, num_instances, num_bodies, device, collection_iface): - obj, _ = collection_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.body_com_lin_vel_w, - expected_shape=(num_instances, num_bodies), - expected_dtype=wp.vec3f, - name="body_com_lin_vel_w", - ) - - @_backends - @_default_dims - @_default_bodies - @_default_devices - def test_body_com_ang_vel_w(self, backend, num_instances, num_bodies, device, collection_iface): - obj, _ = collection_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.body_com_ang_vel_w, - expected_shape=(num_instances, num_bodies), - expected_dtype=wp.vec3f, - name="body_com_ang_vel_w", - ) - - @_backends - @_default_dims - @_default_bodies - @_default_devices - def test_body_com_lin_acc_w(self, backend, num_instances, num_bodies, device, collection_iface): - obj, _ = collection_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.body_com_lin_acc_w, - expected_shape=(num_instances, num_bodies), - expected_dtype=wp.vec3f, - name="body_com_lin_acc_w", - ) - - @_backends - @_default_dims - @_default_bodies - @_default_devices - def test_body_com_ang_acc_w(self, backend, num_instances, num_bodies, device, collection_iface): - obj, _ = collection_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.body_com_ang_acc_w, - expected_shape=(num_instances, num_bodies), - expected_dtype=wp.vec3f, - name="body_com_ang_acc_w", - ) - - @_backends - @_default_dims - @_default_bodies - @_default_devices - def test_body_com_pos_b(self, backend, num_instances, num_bodies, device, collection_iface): - obj, _ = collection_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.body_com_pos_b, - expected_shape=(num_instances, num_bodies), - expected_dtype=wp.vec3f, - name="body_com_pos_b", - ) - - @_backends - @_default_dims - @_default_bodies - @_default_devices - def test_body_com_quat_b(self, backend, num_instances, num_bodies, device, collection_iface): - obj, _ = collection_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.body_com_quat_b, - expected_shape=(num_instances, num_bodies), - expected_dtype=wp.quatf, - name="body_com_quat_b", - ) - - -# --------------------------------------------------------------------------- -# Tests: Derived properties +# Tests: Collection data property contract # --------------------------------------------------------------------------- +# (property, shape kind, dtype). Shape kinds: "NB" = (num_instances, num_bodies), +# "NB9" = (num_instances, num_bodies, 9). +_COLLECTION_DATA_PROPERTIES = [ + # body state + ("body_link_pose_w", "NB", wp.transformf), + ("body_link_vel_w", "NB", wp.spatial_vectorf), + ("body_com_pose_w", "NB", wp.transformf), + ("body_com_vel_w", "NB", wp.spatial_vectorf), + ("body_com_acc_w", "NB", wp.spatial_vectorf), + ("body_com_pose_b", "NB", wp.transformf), + # sliced + ("body_link_pos_w", "NB", wp.vec3f), + ("body_link_quat_w", "NB", wp.quatf), + ("body_link_lin_vel_w", "NB", wp.vec3f), + ("body_link_ang_vel_w", "NB", wp.vec3f), + ("body_com_pos_w", "NB", wp.vec3f), + ("body_com_quat_w", "NB", wp.quatf), + ("body_com_lin_vel_w", "NB", wp.vec3f), + ("body_com_ang_vel_w", "NB", wp.vec3f), + ("body_com_lin_acc_w", "NB", wp.vec3f), + ("body_com_ang_acc_w", "NB", wp.vec3f), + ("body_com_pos_b", "NB", wp.vec3f), + ("body_com_quat_b", "NB", wp.quatf), + # derived + ("projected_gravity_b", "NB", wp.vec3f), + ("heading_w", "NB", wp.float32), + ("body_link_lin_vel_b", "NB", wp.vec3f), + ("body_link_ang_vel_b", "NB", wp.vec3f), + ("body_com_lin_vel_b", "NB", wp.vec3f), + ("body_com_ang_vel_b", "NB", wp.vec3f), + # mass properties + ("body_mass", "NB", wp.float32), + ("body_inertia", "NB9", wp.float32), + # defaults + ("default_body_pose", "NB", wp.transformf), + ("default_body_vel", "NB", wp.spatial_vectorf), +] -class TestCollectionDataDerived: - """Test derived/computed data properties.""" - - @_backends - @_default_dims - @_default_bodies - @_default_devices - def test_projected_gravity_b(self, backend, num_instances, num_bodies, device, collection_iface): - obj, _ = collection_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.projected_gravity_b, - expected_shape=(num_instances, num_bodies), - expected_dtype=wp.vec3f, - name="projected_gravity_b", - ) - - @_backends - @_default_dims - @_default_bodies - @_default_devices - def test_heading_w(self, backend, num_instances, num_bodies, device, collection_iface): - obj, _ = collection_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.heading_w, expected_shape=(num_instances, num_bodies), expected_dtype=wp.float32, name="heading_w" - ) - - @_backends - @_default_dims - @_default_bodies - @_default_devices - def test_body_link_lin_vel_b(self, backend, num_instances, num_bodies, device, collection_iface): - obj, _ = collection_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.body_link_lin_vel_b, - expected_shape=(num_instances, num_bodies), - expected_dtype=wp.vec3f, - name="body_link_lin_vel_b", - ) - - @_backends - @_default_dims - @_default_bodies - @_default_devices - def test_body_link_ang_vel_b(self, backend, num_instances, num_bodies, device, collection_iface): - obj, _ = collection_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.body_link_ang_vel_b, - expected_shape=(num_instances, num_bodies), - expected_dtype=wp.vec3f, - name="body_link_ang_vel_b", - ) - - @_backends - @_default_dims - @_default_bodies - @_default_devices - def test_body_com_lin_vel_b(self, backend, num_instances, num_bodies, device, collection_iface): - obj, _ = collection_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.body_com_lin_vel_b, - expected_shape=(num_instances, num_bodies), - expected_dtype=wp.vec3f, - name="body_com_lin_vel_b", - ) - - @_backends - @_default_dims - @_default_bodies - @_default_devices - def test_body_com_ang_vel_b(self, backend, num_instances, num_bodies, device, collection_iface): - obj, _ = collection_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.body_com_ang_vel_b, - expected_shape=(num_instances, num_bodies), - expected_dtype=wp.vec3f, - name="body_com_ang_vel_b", - ) - - -# --------------------------------------------------------------------------- -# Tests: Mass properties -# --------------------------------------------------------------------------- - - -class TestCollectionDataMass: - """Test body mass/inertia properties.""" - - @_backends - @_default_dims - @_default_bodies - @_default_devices - def test_body_mass(self, backend, num_instances, num_bodies, device, collection_iface): - obj, _ = collection_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.body_mass, expected_shape=(num_instances, num_bodies), expected_dtype=wp.float32, name="body_mass" - ) - - @_backends - @_default_dims - @_default_bodies - @_default_devices - def test_body_inertia(self, backend, num_instances, num_bodies, device, collection_iface): - obj, _ = collection_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.body_inertia, - expected_shape=(num_instances, num_bodies, 9), - expected_dtype=wp.float32, - name="body_inertia", - ) - - -# --------------------------------------------------------------------------- -# Tests: Default state properties -# --------------------------------------------------------------------------- - - -class TestCollectionDataDefaults: - """Test default state properties.""" - @_backends - @_default_dims - @_default_bodies - @_default_devices - def test_default_body_pose(self, backend, num_instances, num_bodies, device, collection_iface): - obj, _ = collection_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.default_body_pose, - expected_shape=(num_instances, num_bodies), - expected_dtype=wp.transformf, - name="default_body_pose", - ) +class TestCollectionDataProperties: + """Test that every data property is a ProxyArray with the advertised shape and dtype.""" @_backends - @_default_dims - @_default_bodies - @_default_devices - def test_default_body_vel(self, backend, num_instances, num_bodies, device, collection_iface): - obj, _ = collection_iface + @_devices + def test_collection_data_property_contract(self, backend, device, make_collection): + obj, _ = make_collection(backend, device) obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.default_body_vel, - expected_shape=(num_instances, num_bodies), - expected_dtype=wp.spatial_vectorf, - name="default_body_vel", - ) + shapes = {"NB": (_NUM_INSTANCES, _NUM_BODIES), "NB9": (_NUM_INSTANCES, _NUM_BODIES, 9)} + for name, shape_kind, dtype in _COLLECTION_DATA_PROPERTIES: + _check_proxy_array( + getattr(obj.data, name), expected_shape=shapes[shape_kind], expected_dtype=dtype, name=name + ) # --------------------------------------------------------------------------- # Tests: Body pose/velocity writers # --------------------------------------------------------------------------- -_BODY_POSE_METHODS = ["body_pose", "body_link_pose", "body_com_pose"] -_BODY_VEL_METHODS = ["body_velocity", "body_com_velocity", "body_link_velocity"] +# writer suffix -> data getter that reads the written quantity back +_BODY_POSE_METHODS = { + "body_pose": "body_link_pose_w", + "body_link_pose": "body_link_pose_w", + "body_com_pose": "body_com_pose_w", +} +_BODY_VEL_METHODS = { + "body_velocity": "body_com_vel_w", + "body_com_velocity": "body_com_vel_w", + "body_link_velocity": "body_link_vel_w", +} class TestCollectionCacheInvalidation: @@ -898,8 +498,6 @@ def set_coms() -> None: obj.set_coms_mask(coms=coms) if backend == "newton": - from unittest.mock import patch - from isaaclab_newton.physics import NewtonManager with patch.object(NewtonManager, "add_model_change"): @@ -915,14 +513,11 @@ class TestCollectionWritersPose: # -- index variants for pose -- @_backends - @_default_dims - @_default_bodies - @_default_devices + @_devices @pytest.mark.parametrize("method_suffix", _BODY_POSE_METHODS) - def test_write_body_pose_to_sim_index( - self, backend, num_instances, num_bodies, device, collection_iface, method_suffix - ): - obj, _ = collection_iface + def test_write_body_pose_to_sim_index(self, backend, device, method_suffix, make_collection): + obj, _ = make_collection(backend, device) + num_instances, num_bodies = _NUM_INSTANCES, _NUM_BODIES obj.data.update(dt=0.01) method = getattr(obj, f"write_{method_suffix}_to_sim_index") @@ -940,8 +535,11 @@ def test_write_body_pose_to_sim_index( env_ids=_make_env_ids(device, True), body_ids=_make_body_ids(device, [0]), ) - # warp, all envs + all bodies - method(body_poses=_make_data_warp((num_instances, num_bodies), device, wp.transformf)) + # warp, all envs + all bodies: the matching getter reads the written poses back + method(body_poses=_make_payload_warp((num_instances, num_bodies), device, wp.transformf)) + getter = _BODY_POSE_METHODS[method_suffix] + expected = _make_payload_torch((num_instances, num_bodies), device, wp.transformf) + _assert_reads_back(getattr(obj.data, getter), expected, getter) # warp, subset method( body_poses=_make_data_warp((1, 1), device, wp.transformf), @@ -958,14 +556,11 @@ def test_write_body_pose_to_sim_index( # -- index variants for velocity -- @_backends - @_default_dims - @_default_bodies - @_default_devices + @_devices @pytest.mark.parametrize("method_suffix", _BODY_VEL_METHODS) - def test_write_body_velocity_to_sim_index( - self, backend, num_instances, num_bodies, device, collection_iface, method_suffix - ): - obj, _ = collection_iface + def test_write_body_velocity_to_sim_index(self, backend, device, method_suffix, make_collection): + obj, _ = make_collection(backend, device) + num_instances, num_bodies = _NUM_INSTANCES, _NUM_BODIES obj.data.update(dt=0.01) method = getattr(obj, f"write_{method_suffix}_to_sim_index") @@ -987,8 +582,11 @@ def test_write_body_velocity_to_sim_index( env_ids=_make_env_ids(device, True), body_ids=_make_body_ids(device, [0]), ) - # warp, all envs + all bodies - method(body_velocities=_make_data_warp((num_instances, num_bodies), device, wp.spatial_vectorf)) + # warp, all envs + all bodies: the matching getter reads the written velocities back + method(body_velocities=_make_payload_warp((num_instances, num_bodies), device, wp.spatial_vectorf)) + getter = _BODY_VEL_METHODS[method_suffix] + expected = _make_payload_torch((num_instances, num_bodies), device, wp.spatial_vectorf) + _assert_reads_back(getattr(obj.data, getter), expected, getter) # warp, subset method( body_velocities=_make_data_warp((1, 1), device, wp.spatial_vectorf), @@ -1005,14 +603,11 @@ def test_write_body_velocity_to_sim_index( # -- mask variants for pose -- @_backends - @_default_dims - @_default_bodies - @_default_devices + @_devices @pytest.mark.parametrize("method_suffix", _BODY_POSE_METHODS) - def test_write_body_pose_to_sim_mask( - self, backend, num_instances, num_bodies, device, collection_iface, method_suffix - ): - obj, _ = collection_iface + def test_write_body_pose_to_sim_mask(self, backend, device, method_suffix, make_collection): + obj, _ = make_collection(backend, device) + num_instances, num_bodies = _NUM_INSTANCES, _NUM_BODIES obj.data.update(dt=0.01) method = getattr(obj, f"write_{method_suffix}_to_sim_mask") @@ -1034,8 +629,11 @@ def test_write_body_pose_to_sim_mask( env_mask=_make_env_mask(num_instances, device, True), body_mask=_make_item_mask(num_bodies, [0], device), ) - # warp, no mask - method(body_poses=_make_data_warp((num_instances, num_bodies), device, wp.transformf)) + # warp, no mask: the matching getter reads the written poses back + method(body_poses=_make_payload_warp((num_instances, num_bodies), device, wp.transformf)) + getter = _BODY_POSE_METHODS[method_suffix] + expected = _make_payload_torch((num_instances, num_bodies), device, wp.transformf) + _assert_reads_back(getattr(obj.data, getter), expected, getter) # warp, partial env_mask method( body_poses=_make_data_warp((num_instances, num_bodies), device, wp.transformf), @@ -1051,14 +649,11 @@ def test_write_body_pose_to_sim_mask( # -- mask variants for velocity -- @_backends - @_default_dims - @_default_bodies - @_default_devices + @_devices @pytest.mark.parametrize("method_suffix", _BODY_VEL_METHODS) - def test_write_body_velocity_to_sim_mask( - self, backend, num_instances, num_bodies, device, collection_iface, method_suffix - ): - obj, _ = collection_iface + def test_write_body_velocity_to_sim_mask(self, backend, device, method_suffix, make_collection): + obj, _ = make_collection(backend, device) + num_instances, num_bodies = _NUM_INSTANCES, _NUM_BODIES obj.data.update(dt=0.01) method = getattr(obj, f"write_{method_suffix}_to_sim_mask") @@ -1074,8 +669,11 @@ def test_write_body_velocity_to_sim_mask( body_velocities=_make_data_torch((num_instances, num_bodies), device, wp.spatial_vectorf), body_mask=_make_item_mask(num_bodies, [0], device), ) - # warp, no mask - method(body_velocities=_make_data_warp((num_instances, num_bodies), device, wp.spatial_vectorf)) + # warp, no mask: the matching getter reads the written velocities back + method(body_velocities=_make_payload_warp((num_instances, num_bodies), device, wp.spatial_vectorf)) + getter = _BODY_VEL_METHODS[method_suffix] + expected = _make_payload_torch((num_instances, num_bodies), device, wp.spatial_vectorf) + _assert_reads_back(getattr(obj.data, getter), expected, getter) # warp, partial env_mask method( body_velocities=_make_data_warp((num_instances, num_bodies), device, wp.spatial_vectorf), @@ -1093,194 +691,157 @@ def test_write_body_velocity_to_sim_mask( # Tests: Body property setters # --------------------------------------------------------------------------- -_BODY_METHODS = [ - ("set_masses", "masses", wp.float32, 0), - ("set_coms", "coms", wp.transformf, 7), - ("set_inertias", "inertias", wp.float32, 9), -] +_BODY_METHODS = [("set_masses", "masses"), ("set_coms", "coms"), ("set_inertias", "inertias")] + + +def _body_writer_layout(backend: str, method_base: str) -> tuple[type, int, str]: + """Return the warp dtype, torch trailing size, and read-back getter of a body property writer.""" + if method_base == "set_masses": + return wp.float32, 0, "body_mass" + if method_base == "set_inertias": + return wp.float32, 9, "body_inertia" + if backend == "newton": + # Newton stores the COM as a position only. + return wp.vec3f, 3, "body_com_pos_b" + return wp.transformf, 7, "body_com_pose_b" + + +def _make_body_torch(shape: tuple[int, int], device: str, wp_dtype: type, trailing: int, payload: bool = False): + """Create body-property torch data: valid ones by default, or per-element distinct values.""" + full_shape = (*shape, trailing) if trailing else shape + if payload: + data = torch.arange(1, math.prod(full_shape) + 1, dtype=torch.float32, device=device).reshape(full_shape) + else: + data = torch.ones(full_shape, device=device, dtype=torch.float32) + if wp_dtype == wp.transformf: + data[..., 3:6] = 0.0 + data[..., 6] = 1.0 + if not payload: + data[..., :3] = 0.0 + return data + + +def _make_body_warp(shape: tuple[int, int], device: str, wp_dtype: type, trailing: int, payload: bool = False): + """Create body-property data as a warp array (structured dtypes collapse the trailing dim).""" + t = _make_body_torch(shape, device, wp_dtype, trailing, payload).contiguous() + return wp.from_torch(t, dtype=wp.float32 if wp_dtype == wp.float32 else wp_dtype) class TestCollectionWritersBody: """Test body property writers/setters with all input combinations.""" @_backends - @_default_dims - @_default_bodies - @_default_devices - @pytest.mark.parametrize( - "method_base, kwarg, wp_dtype, trailing", - _BODY_METHODS, - ids=[m[0] for m in _BODY_METHODS], - ) - def test_body_writer_index( - self, backend, num_instances, num_bodies, device, collection_iface, method_base, kwarg, wp_dtype, trailing - ): - if backend == "newton" and method_base == "set_coms": - pytest.xfail("Newton set_coms expects vec3f (position only), not transformf (pose)") - obj, _ = collection_iface + @_devices + @pytest.mark.parametrize("method_base, kwarg", _BODY_METHODS, ids=[m[0] for m in _BODY_METHODS]) + def test_body_writer_index(self, backend, device, method_base, kwarg, make_collection): + obj, _ = make_collection(backend, device) + num_instances, num_bodies = _NUM_INSTANCES, _NUM_BODIES + wp_dtype, trailing, getter = _body_writer_layout(backend, method_base) obj.data.update(dt=0.01) method = getattr(obj, f"{method_base}_index") - - def _torch_shape(n_envs, n_bods): - if trailing: - return (n_envs, n_bods, trailing) - return (n_envs, n_bods) - - def _make_torch(n_envs, n_bods): - shape = _torch_shape(n_envs, n_bods) - data = torch.ones(shape, device=device, dtype=torch.float32) - if wp_dtype == wp.transformf: - data[..., :3] = 0.0 - data[..., 3:6] = 0.0 - data[..., 6] = 1.0 - return data - - def _make_warp(n_envs, n_bods): - t = _make_torch(n_envs, n_bods) - if wp_dtype == wp.transformf: - return wp.from_torch(t.contiguous(), dtype=wp.transformf) - return wp.from_torch(t.contiguous(), dtype=wp.float32) - sub_body_ids = [0] # torch, all envs + all bodies - method(**{kwarg: _make_torch(num_instances, num_bodies)}) + method(**{kwarg: _make_body_torch((num_instances, num_bodies), device, wp_dtype, trailing)}) # torch, subset method( **{ - kwarg: _make_torch(1, 1), + kwarg: _make_body_torch((1, 1), device, wp_dtype, trailing), "body_ids": sub_body_ids, "env_ids": _make_env_ids(device, True), } ) - # warp, all envs + all bodies - method(**{kwarg: _make_warp(num_instances, num_bodies)}) + # warp, all envs + all bodies: the matching getter reads the written values back + method(**{kwarg: _make_body_warp((num_instances, num_bodies), device, wp_dtype, trailing, payload=True)}) + expected = _make_body_torch((num_instances, num_bodies), device, wp_dtype, trailing, payload=True) + _assert_reads_back(getattr(obj.data, getter), expected, getter) # warp, subset method( **{ - kwarg: _make_warp(1, 1), + kwarg: _make_body_warp((1, 1), device, wp_dtype, trailing), "body_ids": sub_body_ids, "env_ids": _make_env_ids(device, True), } ) # negative: bad torch shape (extra env) with pytest.raises((AssertionError, RuntimeError)): - method(**{kwarg: _make_torch(num_instances + 1, num_bodies)}) + method(**{kwarg: _make_body_torch((num_instances + 1, num_bodies), device, wp_dtype, trailing)}) # negative: bad warp shape with pytest.raises((AssertionError, RuntimeError)): - method(**{kwarg: _make_warp(num_instances + 1, num_bodies)}) + method(**{kwarg: _make_body_warp((num_instances + 1, num_bodies), device, wp_dtype, trailing)}) @_backends - @_default_dims - @_default_bodies - @_default_devices - @pytest.mark.parametrize( - "method_base, kwarg, wp_dtype, trailing", - _BODY_METHODS, - ids=[m[0] for m in _BODY_METHODS], - ) - def test_body_writer_mask( - self, backend, num_instances, num_bodies, device, collection_iface, method_base, kwarg, wp_dtype, trailing - ): - if backend == "newton" and method_base == "set_coms": - pytest.xfail("Newton set_coms expects vec3f (position only), not transformf (pose)") - obj, _ = collection_iface + @_devices + @pytest.mark.parametrize("method_base, kwarg", _BODY_METHODS, ids=[m[0] for m in _BODY_METHODS]) + def test_body_writer_mask(self, backend, device, method_base, kwarg, make_collection): + obj, _ = make_collection(backend, device) + num_instances, num_bodies = _NUM_INSTANCES, _NUM_BODIES + wp_dtype, trailing, getter = _body_writer_layout(backend, method_base) obj.data.update(dt=0.01) method = getattr(obj, f"{method_base}_mask") - - def _torch_shape(n_envs, n_bods): - if trailing: - return (n_envs, n_bods, trailing) - return (n_envs, n_bods) - - def _make_torch(n_envs, n_bods): - shape = _torch_shape(n_envs, n_bods) - data = torch.ones(shape, device=device, dtype=torch.float32) - if wp_dtype == wp.transformf: - data[..., :3] = 0.0 - data[..., 3:6] = 0.0 - data[..., 6] = 1.0 - return data - - def _make_warp(n_envs, n_bods): - t = _make_torch(n_envs, n_bods) - if wp_dtype == wp.transformf: - return wp.from_torch(t.contiguous(), dtype=wp.transformf) - return wp.from_torch(t.contiguous(), dtype=wp.float32) - sub_body_sel = [0] # torch, no mask - method(**{kwarg: _make_torch(num_instances, num_bodies)}) + method(**{kwarg: _make_body_torch((num_instances, num_bodies), device, wp_dtype, trailing)}) # torch, partial env_mask + body_mask method( **{ - kwarg: _make_torch(num_instances, num_bodies), + kwarg: _make_body_torch((num_instances, num_bodies), device, wp_dtype, trailing), "body_mask": _make_item_mask(num_bodies, sub_body_sel, device), "env_mask": _make_env_mask(num_instances, device, True), } ) - # warp, no mask - method(**{kwarg: _make_warp(num_instances, num_bodies)}) + # warp, no mask: the matching getter reads the written values back + method(**{kwarg: _make_body_warp((num_instances, num_bodies), device, wp_dtype, trailing, payload=True)}) + expected = _make_body_torch((num_instances, num_bodies), device, wp_dtype, trailing, payload=True) + _assert_reads_back(getattr(obj.data, getter), expected, getter) # warp, partial env_mask + body_mask method( **{ - kwarg: _make_warp(num_instances, num_bodies), + kwarg: _make_body_warp((num_instances, num_bodies), device, wp_dtype, trailing), "body_mask": _make_item_mask(num_bodies, sub_body_sel, device), "env_mask": _make_env_mask(num_instances, device, True), } ) # negative: bad torch shape with pytest.raises((AssertionError, RuntimeError)): - method(**{kwarg: _make_torch(num_instances + 1, num_bodies)}) + method(**{kwarg: _make_body_torch((num_instances + 1, num_bodies), device, wp_dtype, trailing)}) # negative: bad warp shape with pytest.raises((AssertionError, RuntimeError)): - method(**{kwarg: _make_warp(num_instances + 1, num_bodies)}) + method(**{kwarg: _make_body_warp((num_instances + 1, num_bodies), device, wp_dtype, trailing)}) # --------------------------------------------------------------------------- # Tests: Alias/shorthand properties # --------------------------------------------------------------------------- +_COLLECTION_ALIASES = [ + ("body_pose_w", "body_link_pose_w"), + ("body_pos_w", "body_link_pos_w"), + ("body_quat_w", "body_link_quat_w"), + ("body_vel_w", "body_com_vel_w"), + ("body_lin_vel_w", "body_com_lin_vel_w"), + ("body_ang_vel_w", "body_com_ang_vel_w"), + ("body_acc_w", "body_com_acc_w"), + ("body_lin_acc_w", "body_com_lin_acc_w"), + ("body_ang_acc_w", "body_com_ang_acc_w"), + ("com_pos_b", "body_com_pos_b"), + ("com_quat_b", "body_com_quat_b"), +] + class TestCollectionDataAliases: - """Test that alias properties return the same shape/dtype as their canonical counterparts.""" + """Test that alias properties return the values of their canonical counterparts.""" @_backends - @_default_dims - @_default_bodies - @_default_devices - def test_body_aliases(self, backend, num_instances, num_bodies, device, collection_iface): - obj, _ = collection_iface + def test_aliases_match_canonical_values(self, backend, make_collection): + # Random mock state makes link and COM quantities differ, so a retargeted alias fails. + obj, _ = make_collection(backend, "cpu") obj.data.update(dt=0.01) d = obj.data - # Pose aliases - assert d.body_pose_w.shape == d.body_link_pose_w.shape - assert d.body_pose_w.dtype == d.body_link_pose_w.dtype - assert d.body_pos_w.shape == d.body_link_pos_w.shape - assert d.body_pos_w.dtype == d.body_link_pos_w.dtype - assert d.body_quat_w.shape == d.body_link_quat_w.shape - assert d.body_quat_w.dtype == d.body_link_quat_w.dtype - - # Velocity aliases - assert d.body_vel_w.shape == d.body_com_vel_w.shape - assert d.body_vel_w.dtype == d.body_com_vel_w.dtype - assert d.body_lin_vel_w.shape == d.body_com_lin_vel_w.shape - assert d.body_lin_vel_w.dtype == d.body_com_lin_vel_w.dtype - assert d.body_ang_vel_w.shape == d.body_com_ang_vel_w.shape - assert d.body_ang_vel_w.dtype == d.body_com_ang_vel_w.dtype - - # Acceleration aliases - assert d.body_acc_w.shape == d.body_com_acc_w.shape - assert d.body_acc_w.dtype == d.body_com_acc_w.dtype - assert d.body_lin_acc_w.shape == d.body_com_lin_acc_w.shape - assert d.body_lin_acc_w.dtype == d.body_com_lin_acc_w.dtype - assert d.body_ang_acc_w.shape == d.body_com_ang_acc_w.shape - assert d.body_ang_acc_w.dtype == d.body_com_ang_acc_w.dtype - - # CoM body frame aliases - assert d.com_pos_b.shape == d.body_com_pos_b.shape - assert d.com_pos_b.dtype == d.body_com_pos_b.dtype - assert d.com_quat_b.shape == d.body_com_quat_b.shape - assert d.com_quat_b.dtype == d.body_com_quat_b.dtype + for alias, canonical in _COLLECTION_ALIASES: + alias_value, canonical_value = getattr(d, alias), getattr(d, canonical) + assert alias_value.shape == canonical_value.shape, alias + assert alias_value.dtype == canonical_value.dtype, alias + assert torch.equal(alias_value.torch, canonical_value.torch), alias diff --git a/source/isaaclab/test/assets/test_rigid_object_iface.py b/source/isaaclab/test/assets/test_rigid_object_iface.py index f3dba59bd843..15ab45148cc7 100644 --- a/source/isaaclab/test/assets/test_rigid_object_iface.py +++ b/source/isaaclab/test/assets/test_rigid_object_iface.py @@ -13,6 +13,7 @@ The setup is a bit convoluted so that we can run these tests without requiring Isaac Sim or GPU simulation. """ +import math from unittest.mock import MagicMock, patch import numpy as np @@ -21,19 +22,11 @@ import warp as wp from _rigid_object_iface_test_utils import BACKENDS, get_rigid_object -from isaaclab.utils.math import quat_apply +from isaaclab.test.utils import DeviceScope, test_devices pytestmark = pytest.mark.integration -@pytest.fixture -def rigid_object_iface(request): - backend = request.getfixturevalue("backend") - num_instances = request.getfixturevalue("num_instances") - device = request.getfixturevalue("device") - return get_rigid_object(backend, num_instances, device) - - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -48,11 +41,11 @@ def _check_proxy_array(arr, *, expected_shape: tuple, expected_dtype: type, name assert arr.dtype == expected_dtype, f"{name}: expected dtype {expected_dtype}, got {arr.dtype}" -# Common parametrize decorators +# Common parametrize decorators. Pure bookkeeping (counts, names, finders, aliases) runs on CPU only; +# getters and writers keep every test device because PhysX stages through CPU-pinned buffers on CUDA. _backends = pytest.mark.parametrize("backend", BACKENDS, indirect=False) -_default_dims = pytest.mark.parametrize("num_instances", [1, 2, 100]) - -_default_devices = pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +_devices = pytest.mark.parametrize("device", test_devices(DeviceScope.CPU_AND_DEFAULT_CUDA)) +_NUM_INSTANCES = 2 _index_resolution_backends = pytest.mark.parametrize( "backend", [backend for backend in ("physx", "newton") if backend in BACKENDS], indirect=False ) @@ -82,7 +75,7 @@ def test_resolve_env_ids_handles_tensor_view_shape(self, backend): # --------------------------------------------------------------------------- -# Tests: RigidObject properties +# Tests: RigidObject properties and finders # --------------------------------------------------------------------------- @@ -90,70 +83,20 @@ class TestRigidObjectProperties: """Test that rigid object properties return the correct types/values.""" @_backends - @_default_dims - @_default_devices - def test_num_instances(self, backend, num_instances, device, rigid_object_iface): - obj, _ = rigid_object_iface - assert obj.num_instances == num_instances + def test_rigid_object_counts_and_names(self, backend): + from isaaclab.assets.rigid_object.base_rigid_object_data import BaseRigidObjectData - @_backends - @_default_dims - @_default_devices - def test_num_bodies(self, backend, num_instances, device, rigid_object_iface): - obj, _ = rigid_object_iface - assert obj.num_bodies == 1 + obj, _ = get_rigid_object(backend, _NUM_INSTANCES, device="cpu") - @_backends - @_default_dims - @_default_devices - def test_body_names(self, backend, num_instances, device, rigid_object_iface): - obj, _ = rigid_object_iface + assert obj.num_instances == _NUM_INSTANCES + assert obj.num_bodies == 1 names = obj.body_names assert isinstance(names, list) assert len(names) == 1 assert all(isinstance(n, str) for n in names) - - @_backends - @_default_dims - @_default_devices - def test_data_returns_rigid_object_data(self, backend, num_instances, device, rigid_object_iface): - from isaaclab.assets.rigid_object.base_rigid_object_data import BaseRigidObjectData - - obj, _ = rigid_object_iface assert isinstance(obj.data, BaseRigidObjectData) -# --------------------------------------------------------------------------- -# Tests: RigidObject finder methods -# --------------------------------------------------------------------------- - - -class TestRigidObjectFinders: - """Test that finder methods return (list[int], list[str]) tuples.""" - - @_backends - @_default_dims - @_default_devices - def test_find_bodies_all(self, backend, num_instances, device, rigid_object_iface): - obj, _ = rigid_object_iface - indices, names = obj.find_bodies(".*") - assert isinstance(indices, list) and isinstance(names, list) - assert len(indices) == 1 - assert len(names) == 1 - assert all(isinstance(i, int) for i in indices) - assert all(isinstance(n, str) for n in names) - - @_backends - @_default_dims - @_default_devices - def test_find_bodies_single(self, backend, num_instances, device, rigid_object_iface): - obj, _ = rigid_object_iface - first_body = obj.body_names[0] - indices, names = obj.find_bodies(first_body) - assert indices == [0] - assert names == [first_body] - - class TestRigidObjectFinderReturnModes: """Test finder return modes on production rigid-object backends.""" @@ -164,480 +107,121 @@ def test_find_bodies_returns_legacy_list_or_cached_proxy(self, backend): indices, names = obj.find_bodies(".*") proxy, proxy_names = obj.find_bodies(".*", as_proxy=True) - assert isinstance(indices, list) + assert isinstance(indices, list) and isinstance(names, list) + assert len(indices) == 1 + assert len(names) == 1 + assert all(isinstance(i, int) for i in indices) + assert all(isinstance(n, str) for n in names) assert indices == proxy.torch.tolist() assert names == proxy_names assert proxy is obj.find_bodies(".*", as_proxy=True)[0] assert proxy.dtype == wp.int32 assert str(proxy.device) == obj.device - -# --------------------------------------------------------------------------- -# Tests: RigidObjectData root state properties -# --------------------------------------------------------------------------- - - -class TestRigidObjectDataRootState: - """Test data properties for root rigid body state.""" - - @_backends - @_default_dims - @_default_devices - def test_root_link_pose_w(self, backend, num_instances, device, rigid_object_iface): - obj, _ = rigid_object_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.root_link_pose_w, - expected_shape=(num_instances,), - expected_dtype=wp.transformf, - name="root_link_pose_w", - ) - - @_backends - @_default_dims - @_default_devices - def test_root_link_vel_w(self, backend, num_instances, device, rigid_object_iface): - obj, _ = rigid_object_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.root_link_vel_w, - expected_shape=(num_instances,), - expected_dtype=wp.spatial_vectorf, - name="root_link_vel_w", - ) - - @_backends - @_default_dims - @_default_devices - def test_root_com_pose_w(self, backend, num_instances, device, rigid_object_iface): - obj, _ = rigid_object_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.root_com_pose_w, - expected_shape=(num_instances,), - expected_dtype=wp.transformf, - name="root_com_pose_w", - ) - - @_backends - @_default_dims - @_default_devices - def test_root_com_vel_w(self, backend, num_instances, device, rigid_object_iface): - obj, _ = rigid_object_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.root_com_vel_w, - expected_shape=(num_instances,), - expected_dtype=wp.spatial_vectorf, - name="root_com_vel_w", - ) - - @_backends - @_default_dims - @_default_devices - def test_root_link_pos_w(self, backend, num_instances, device, rigid_object_iface): - obj, _ = rigid_object_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.root_link_pos_w, expected_shape=(num_instances,), expected_dtype=wp.vec3f, name="root_link_pos_w" - ) - - @_backends - @_default_dims - @_default_devices - def test_root_link_quat_w(self, backend, num_instances, device, rigid_object_iface): - obj, _ = rigid_object_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.root_link_quat_w, expected_shape=(num_instances,), expected_dtype=wp.quatf, name="root_link_quat_w" - ) - - @_backends - @_default_dims - @_default_devices - def test_root_link_lin_vel_w(self, backend, num_instances, device, rigid_object_iface): - obj, _ = rigid_object_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.root_link_lin_vel_w, - expected_shape=(num_instances,), - expected_dtype=wp.vec3f, - name="root_link_lin_vel_w", - ) - - @_backends - @_default_dims - @_default_devices - def test_root_link_ang_vel_w(self, backend, num_instances, device, rigid_object_iface): - obj, _ = rigid_object_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.root_link_ang_vel_w, - expected_shape=(num_instances,), - expected_dtype=wp.vec3f, - name="root_link_ang_vel_w", - ) - - @_backends - @_default_dims - @_default_devices - def test_root_com_pos_w(self, backend, num_instances, device, rigid_object_iface): - obj, _ = rigid_object_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.root_com_pos_w, expected_shape=(num_instances,), expected_dtype=wp.vec3f, name="root_com_pos_w" - ) - - @_backends - @_default_dims - @_default_devices - def test_root_com_quat_w(self, backend, num_instances, device, rigid_object_iface): - obj, _ = rigid_object_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.root_com_quat_w, expected_shape=(num_instances,), expected_dtype=wp.quatf, name="root_com_quat_w" - ) - - @_backends - @_default_dims - @_default_devices - def test_root_com_lin_vel_w(self, backend, num_instances, device, rigid_object_iface): - obj, _ = rigid_object_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.root_com_lin_vel_w, - expected_shape=(num_instances,), - expected_dtype=wp.vec3f, - name="root_com_lin_vel_w", - ) - - @_backends - @_default_dims - @_default_devices - def test_root_com_ang_vel_w(self, backend, num_instances, device, rigid_object_iface): - obj, _ = rigid_object_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.root_com_ang_vel_w, - expected_shape=(num_instances,), - expected_dtype=wp.vec3f, - name="root_com_ang_vel_w", - ) + first_body = obj.body_names[0] + assert obj.find_bodies(first_body) == ([0], [first_body]) # --------------------------------------------------------------------------- -# Tests: RigidObjectData derived properties +# Tests: RigidObjectData property contract # --------------------------------------------------------------------------- +# (property, shape kind, dtype). Shape kinds: "N" = (num_instances,), "N1" = (num_instances, 1), +# "N19" = (num_instances, 1, 9). +_RIGID_OBJECT_DATA_PROPERTIES = [ + # root state + ("root_link_pose_w", "N", wp.transformf), + ("root_link_vel_w", "N", wp.spatial_vectorf), + ("root_com_pose_w", "N", wp.transformf), + ("root_com_vel_w", "N", wp.spatial_vectorf), + ("root_link_pos_w", "N", wp.vec3f), + ("root_link_quat_w", "N", wp.quatf), + ("root_link_lin_vel_w", "N", wp.vec3f), + ("root_link_ang_vel_w", "N", wp.vec3f), + ("root_com_pos_w", "N", wp.vec3f), + ("root_com_quat_w", "N", wp.quatf), + ("root_com_lin_vel_w", "N", wp.vec3f), + ("root_com_ang_vel_w", "N", wp.vec3f), + # derived + ("projected_gravity_b", "N", wp.vec3f), + ("heading_w", "N", wp.float32), + ("root_link_lin_vel_b", "N", wp.vec3f), + ("root_link_ang_vel_b", "N", wp.vec3f), + ("root_com_lin_vel_b", "N", wp.vec3f), + ("root_com_ang_vel_b", "N", wp.vec3f), + # body state + ("body_link_pose_w", "N1", wp.transformf), + ("body_link_vel_w", "N1", wp.spatial_vectorf), + ("body_com_pose_w", "N1", wp.transformf), + ("body_com_vel_w", "N1", wp.spatial_vectorf), + ("body_com_acc_w", "N1", wp.spatial_vectorf), + ("body_com_pose_b", "N1", wp.transformf), + ("body_mass", "N1", wp.float32), + ("body_inertia", "N19", wp.float32), + ("body_link_pos_w", "N1", wp.vec3f), + ("body_link_quat_w", "N1", wp.quatf), + ("body_link_lin_vel_w", "N1", wp.vec3f), + ("body_link_ang_vel_w", "N1", wp.vec3f), + ("body_com_pos_w", "N1", wp.vec3f), + ("body_com_quat_w", "N1", wp.quatf), + ("body_com_pos_b", "N1", wp.vec3f), + ("body_com_quat_b", "N1", wp.quatf), + # defaults + ("default_root_pose", "N", wp.transformf), + ("default_root_vel", "N", wp.spatial_vectorf), +] -class TestRigidObjectDataDerivedProperties: - """Test derived/computed data properties.""" - - @_backends - @_default_dims - @_default_devices - def test_projected_gravity_b(self, backend, num_instances, device, rigid_object_iface): - obj, _ = rigid_object_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.projected_gravity_b, - expected_shape=(num_instances,), - expected_dtype=wp.vec3f, - name="projected_gravity_b", - ) - - @_backends - @_default_dims - @_default_devices - def test_heading_w(self, backend, num_instances, device, rigid_object_iface): - obj, _ = rigid_object_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.heading_w, expected_shape=(num_instances,), expected_dtype=wp.float32, name="heading_w" - ) - - @_backends - @_default_dims - @_default_devices - def test_root_link_lin_vel_b(self, backend, num_instances, device, rigid_object_iface): - obj, _ = rigid_object_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.root_link_lin_vel_b, - expected_shape=(num_instances,), - expected_dtype=wp.vec3f, - name="root_link_lin_vel_b", - ) - - @_backends - @_default_dims - @_default_devices - def test_root_link_ang_vel_b(self, backend, num_instances, device, rigid_object_iface): - obj, _ = rigid_object_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.root_link_ang_vel_b, - expected_shape=(num_instances,), - expected_dtype=wp.vec3f, - name="root_link_ang_vel_b", - ) - @_backends - @_default_dims - @_default_devices - def test_root_com_lin_vel_b(self, backend, num_instances, device, rigid_object_iface): - obj, _ = rigid_object_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.root_com_lin_vel_b, - expected_shape=(num_instances,), - expected_dtype=wp.vec3f, - name="root_com_lin_vel_b", - ) +class TestRigidObjectDataProperties: + """Test that every data property is a ProxyArray with the advertised shape and dtype.""" @_backends - @_default_dims - @_default_devices - def test_root_com_ang_vel_b(self, backend, num_instances, device, rigid_object_iface): - obj, _ = rigid_object_iface + @_devices + def test_rigid_object_data_property_contract(self, backend, device): + obj, _ = get_rigid_object(backend, _NUM_INSTANCES, device) obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.root_com_ang_vel_b, - expected_shape=(num_instances,), - expected_dtype=wp.vec3f, - name="root_com_ang_vel_b", - ) + shapes = {"N": (_NUM_INSTANCES,), "N1": (_NUM_INSTANCES, 1), "N19": (_NUM_INSTANCES, 1, 9)} + for name, shape_kind, dtype in _RIGID_OBJECT_DATA_PROPERTIES: + _check_proxy_array( + getattr(obj.data, name), expected_shape=shapes[shape_kind], expected_dtype=dtype, name=name + ) # --------------------------------------------------------------------------- -# Tests: RigidObjectData body state properties +# Tests: Alias/shorthand properties # --------------------------------------------------------------------------- - -class TestRigidObjectDataBodyState: - """Test data properties for all body states.""" - - @_backends - @_default_dims - @_default_devices - def test_body_link_pose_w(self, backend, num_instances, device, rigid_object_iface): - obj, _ = rigid_object_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.body_link_pose_w, - expected_shape=(num_instances, 1), - expected_dtype=wp.transformf, - name="body_link_pose_w", - ) - - @_backends - @_default_dims - @_default_devices - def test_body_link_vel_w(self, backend, num_instances, device, rigid_object_iface): - obj, _ = rigid_object_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.body_link_vel_w, - expected_shape=(num_instances, 1), - expected_dtype=wp.spatial_vectorf, - name="body_link_vel_w", - ) - - @_backends - @_default_dims - @_default_devices - def test_body_com_pose_w(self, backend, num_instances, device, rigid_object_iface): - obj, _ = rigid_object_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.body_com_pose_w, - expected_shape=(num_instances, 1), - expected_dtype=wp.transformf, - name="body_com_pose_w", - ) - - @_backends - @_default_dims - @_default_devices - def test_body_com_vel_w(self, backend, num_instances, device, rigid_object_iface): - obj, _ = rigid_object_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.body_com_vel_w, - expected_shape=(num_instances, 1), - expected_dtype=wp.spatial_vectorf, - name="body_com_vel_w", - ) - - @_backends - @_default_dims - @_default_devices - def test_body_com_acc_w(self, backend, num_instances, device, rigid_object_iface): - obj, _ = rigid_object_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.body_com_acc_w, - expected_shape=(num_instances, 1), - expected_dtype=wp.spatial_vectorf, - name="body_com_acc_w", - ) - - @_backends - @_default_dims - @_default_devices - def test_body_com_pose_b(self, backend, num_instances, device, rigid_object_iface): - obj, _ = rigid_object_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.body_com_pose_b, - expected_shape=(num_instances, 1), - expected_dtype=wp.transformf, - name="body_com_pose_b", - ) - - @_backends - @_default_dims - @_default_devices - def test_body_mass(self, backend, num_instances, device, rigid_object_iface): - obj, _ = rigid_object_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.body_mass, expected_shape=(num_instances, 1), expected_dtype=wp.float32, name="body_mass" - ) - - @_backends - @_default_dims - @_default_devices - def test_body_inertia(self, backend, num_instances, device, rigid_object_iface): - obj, _ = rigid_object_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.body_inertia, expected_shape=(num_instances, 1, 9), expected_dtype=wp.float32, name="body_inertia" - ) - - @_backends - @_default_dims - @_default_devices - def test_body_link_pos_w(self, backend, num_instances, device, rigid_object_iface): - obj, _ = rigid_object_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.body_link_pos_w, expected_shape=(num_instances, 1), expected_dtype=wp.vec3f, name="body_link_pos_w" - ) - - @_backends - @_default_dims - @_default_devices - def test_body_link_quat_w(self, backend, num_instances, device, rigid_object_iface): - obj, _ = rigid_object_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.body_link_quat_w, - expected_shape=(num_instances, 1), - expected_dtype=wp.quatf, - name="body_link_quat_w", - ) - - @_backends - @_default_dims - @_default_devices - def test_body_link_lin_vel_w(self, backend, num_instances, device, rigid_object_iface): - obj, _ = rigid_object_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.body_link_lin_vel_w, - expected_shape=(num_instances, 1), - expected_dtype=wp.vec3f, - name="body_link_lin_vel_w", - ) - - @_backends - @_default_dims - @_default_devices - def test_body_link_ang_vel_w(self, backend, num_instances, device, rigid_object_iface): - obj, _ = rigid_object_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.body_link_ang_vel_w, - expected_shape=(num_instances, 1), - expected_dtype=wp.vec3f, - name="body_link_ang_vel_w", - ) - - @_backends - @_default_dims - @_default_devices - def test_body_com_pos_w(self, backend, num_instances, device, rigid_object_iface): - obj, _ = rigid_object_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.body_com_pos_w, expected_shape=(num_instances, 1), expected_dtype=wp.vec3f, name="body_com_pos_w" - ) - - @_backends - @_default_dims - @_default_devices - def test_body_com_quat_w(self, backend, num_instances, device, rigid_object_iface): - obj, _ = rigid_object_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.body_com_quat_w, expected_shape=(num_instances, 1), expected_dtype=wp.quatf, name="body_com_quat_w" - ) - - @_backends - @_default_dims - @_default_devices - def test_body_com_pos_b(self, backend, num_instances, device, rigid_object_iface): - obj, _ = rigid_object_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.body_com_pos_b, expected_shape=(num_instances, 1), expected_dtype=wp.vec3f, name="body_com_pos_b" - ) - - @_backends - @_default_dims - @_default_devices - def test_body_com_quat_b(self, backend, num_instances, device, rigid_object_iface): - obj, _ = rigid_object_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.body_com_quat_b, expected_shape=(num_instances, 1), expected_dtype=wp.quatf, name="body_com_quat_b" - ) - - -# --------------------------------------------------------------------------- -# Tests: RigidObjectData defaults -# --------------------------------------------------------------------------- +_RIGID_OBJECT_ALIASES = [ + ("root_pose_w", "root_link_pose_w"), + ("root_pos_w", "root_link_pos_w"), + ("root_quat_w", "root_link_quat_w"), + ("root_vel_w", "root_com_vel_w"), + ("root_lin_vel_w", "root_com_lin_vel_w"), + ("root_ang_vel_w", "root_com_ang_vel_w"), + ("body_pose_w", "body_link_pose_w"), + ("body_pos_w", "body_link_pos_w"), + ("body_quat_w", "body_link_quat_w"), + ("body_vel_w", "body_com_vel_w"), + ("body_lin_vel_w", "body_com_lin_vel_w"), + ("body_ang_vel_w", "body_com_ang_vel_w"), +] -class TestRigidObjectDataDefaults: - """Test default state properties.""" +class TestRigidObjectDataAliases: + """Test that alias properties return the values of their canonical counterparts.""" @_backends - @_default_dims - @_default_devices - def test_default_root_pose(self, backend, num_instances, device, rigid_object_iface): - obj, _ = rigid_object_iface + def test_aliases_match_canonical_values(self, backend): + # Random mock state makes link and COM quantities differ, so a retargeted alias fails. + obj, _ = get_rigid_object(backend, _NUM_INSTANCES, device="cpu") obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.default_root_pose, - expected_shape=(num_instances,), - expected_dtype=wp.transformf, - name="default_root_pose", - ) + d = obj.data - @_backends - @_default_dims - @_default_devices - def test_default_root_vel(self, backend, num_instances, device, rigid_object_iface): - obj, _ = rigid_object_iface - obj.data.update(dt=0.01) - _check_proxy_array( - obj.data.default_root_vel, - expected_shape=(num_instances,), - expected_dtype=wp.spatial_vectorf, - name="default_root_vel", - ) + for alias, canonical in _RIGID_OBJECT_ALIASES: + alias_value, canonical_value = getattr(d, alias), getattr(d, canonical) + assert alias_value.shape == canonical_value.shape, alias + assert alias_value.dtype == canonical_value.dtype, alias + assert torch.equal(alias_value.torch, canonical_value.torch), alias # --------------------------------------------------------------------------- @@ -679,6 +263,32 @@ def _make_data_warp(shape: tuple, device: str, wp_dtype=wp.float32) -> wp.array: return wp.from_torch(t.contiguous(), dtype=wp_dtype) +def _make_payload_torch(shape: tuple, device: str, wp_dtype=wp.float32) -> torch.Tensor: + """Create valid torch data whose entries differ per element, for writer read-back checks. + + Transforms get distinct positions and a fixed 90-degree rotation about Z. + """ + values = torch.arange(1, math.prod(shape) + 1, dtype=torch.float32, device=device).reshape(shape) + 0.25 + if wp_dtype == wp.spatial_vectorf: + return values.unsqueeze(-1) + torch.arange(6, dtype=torch.float32, device=device) / 10.0 + if wp_dtype == wp.transformf: + data = torch.zeros((*shape, 7), dtype=torch.float32, device=device) + data[..., :3] = values.unsqueeze(-1) + torch.arange(3, dtype=torch.float32, device=device) / 10.0 + data[..., 5] = data[..., 6] = 2.0**-0.5 + return data + raise ValueError(f"Unsupported payload dtype: {wp_dtype}") + + +def _make_payload_warp(shape: tuple, device: str, wp_dtype=wp.float32) -> wp.array: + """Create the per-element distinct payload of :func:`_make_payload_torch` as a warp array.""" + return wp.from_torch(_make_payload_torch(shape, device, wp_dtype).contiguous(), dtype=wp_dtype) + + +def _assert_reads_back(value, expected: torch.Tensor, name: str) -> None: + """Assert a data getter (ProxyArray) returns the values a writer just wrote.""" + torch.testing.assert_close(value.torch, expected, atol=1e-5, rtol=1e-5, msg=lambda msg: f"{name}: {msg}") + + def _make_com_data(backend: str, shape: tuple[int, ...], device: str) -> wp.array: """Create backend-compatible center-of-mass test data.""" if backend == "newton": @@ -743,8 +353,17 @@ def _make_item_mask(total: int, selected: list[int], device: str) -> wp.array: # Tests: Root writers — torch/warp × index/mask × all/subset × negative # --------------------------------------------------------------------------- -_ROOT_POSE_METHODS = ["root_pose", "root_link_pose", "root_com_pose"] -_ROOT_VEL_METHODS = ["root_velocity", "root_link_velocity", "root_com_velocity"] +# writer suffix -> data getter that reads the written quantity back +_ROOT_POSE_METHODS = { + "root_pose": "root_link_pose_w", + "root_link_pose": "root_link_pose_w", + "root_com_pose": "root_com_pose_w", +} +_ROOT_VEL_METHODS = { + "root_velocity": "root_com_vel_w", + "root_link_velocity": "root_link_vel_w", + "root_com_velocity": "root_com_vel_w", +} class TestRigidObjectCacheInvalidation: @@ -816,8 +435,6 @@ def set_coms() -> None: obj.set_coms_mask(coms=coms) if backend == "newton": - from unittest.mock import patch - from isaaclab_newton.physics import NewtonManager with patch.object(NewtonManager, "add_model_change"): @@ -833,11 +450,11 @@ class TestRigidObjectWritersRoot: # -- index variants -- @_backends - @_default_dims - @_default_devices + @_devices @pytest.mark.parametrize("method_suffix", _ROOT_POSE_METHODS) - def test_write_root_pose_to_sim_index(self, backend, num_instances, device, rigid_object_iface, method_suffix): - obj, _ = rigid_object_iface + def test_write_root_pose_to_sim_index(self, backend, device, method_suffix): + num_instances = _NUM_INSTANCES + obj, _ = get_rigid_object(backend, num_instances, device) obj.data.update(dt=0.01) method = getattr(obj, f"write_{method_suffix}_to_sim_index") @@ -845,8 +462,12 @@ def test_write_root_pose_to_sim_index(self, backend, num_instances, device, rigi method(root_pose=_make_data_torch((num_instances,), device, wp.transformf)) # torch, subset method(root_pose=_make_data_torch((1,), device, wp.transformf), env_ids=_make_env_ids(device, True)) - # warp, all envs - method(root_pose=_make_data_warp((num_instances,), device, wp.transformf)) + # warp, all envs: the matching getter reads the written poses back + method(root_pose=_make_payload_warp((num_instances,), device, wp.transformf)) + getter = _ROOT_POSE_METHODS[method_suffix] + _assert_reads_back( + getattr(obj.data, getter), _make_payload_torch((num_instances,), device, wp.transformf), getter + ) # warp, subset method(root_pose=_make_data_warp((1,), device, wp.transformf), env_ids=_make_env_ids(device, True)) # negative: bad torch shape @@ -857,11 +478,11 @@ def test_write_root_pose_to_sim_index(self, backend, num_instances, device, rigi method(root_pose=_make_bad_data_warp((num_instances,), device, wp.transformf)) @_backends - @_default_dims - @_default_devices + @_devices @pytest.mark.parametrize("method_suffix", _ROOT_VEL_METHODS) - def test_write_root_velocity_to_sim_index(self, backend, num_instances, device, rigid_object_iface, method_suffix): - obj, _ = rigid_object_iface + def test_write_root_velocity_to_sim_index(self, backend, device, method_suffix): + num_instances = _NUM_INSTANCES + obj, _ = get_rigid_object(backend, num_instances, device) obj.data.update(dt=0.01) method = getattr(obj, f"write_{method_suffix}_to_sim_index") @@ -869,8 +490,12 @@ def test_write_root_velocity_to_sim_index(self, backend, num_instances, device, method(root_velocity=_make_data_torch((num_instances,), device, wp.spatial_vectorf)) # torch, subset method(root_velocity=_make_data_torch((1,), device, wp.spatial_vectorf), env_ids=_make_env_ids(device, True)) - # warp, all envs - method(root_velocity=_make_data_warp((num_instances,), device, wp.spatial_vectorf)) + # warp, all envs: the matching getter reads the written velocities back + method(root_velocity=_make_payload_warp((num_instances,), device, wp.spatial_vectorf)) + getter = _ROOT_VEL_METHODS[method_suffix] + _assert_reads_back( + getattr(obj.data, getter), _make_payload_torch((num_instances,), device, wp.spatial_vectorf), getter + ) # warp, subset method(root_velocity=_make_data_warp((1,), device, wp.spatial_vectorf), env_ids=_make_env_ids(device, True)) # negative: bad torch shape @@ -883,11 +508,11 @@ def test_write_root_velocity_to_sim_index(self, backend, num_instances, device, # -- mask variants -- @_backends - @_default_dims - @_default_devices + @_devices @pytest.mark.parametrize("method_suffix", _ROOT_POSE_METHODS) - def test_write_root_pose_to_sim_mask(self, backend, num_instances, device, rigid_object_iface, method_suffix): - obj, _ = rigid_object_iface + def test_write_root_pose_to_sim_mask(self, backend, device, method_suffix): + num_instances = _NUM_INSTANCES + obj, _ = get_rigid_object(backend, num_instances, device) obj.data.update(dt=0.01) method = getattr(obj, f"write_{method_suffix}_to_sim_mask") @@ -898,8 +523,12 @@ def test_write_root_pose_to_sim_mask(self, backend, num_instances, device, rigid root_pose=_make_data_torch((num_instances,), device, wp.transformf), env_mask=_make_env_mask(num_instances, device, True), ) - # warp, no mask - method(root_pose=_make_data_warp((num_instances,), device, wp.transformf)) + # warp, no mask: the matching getter reads the written poses back + method(root_pose=_make_payload_warp((num_instances,), device, wp.transformf)) + getter = _ROOT_POSE_METHODS[method_suffix] + _assert_reads_back( + getattr(obj.data, getter), _make_payload_torch((num_instances,), device, wp.transformf), getter + ) # warp, partial mask method( root_pose=_make_data_warp((num_instances,), device, wp.transformf), @@ -913,11 +542,11 @@ def test_write_root_pose_to_sim_mask(self, backend, num_instances, device, rigid method(root_pose=_make_bad_data_warp((num_instances,), device, wp.transformf)) @_backends - @_default_dims - @_default_devices + @_devices @pytest.mark.parametrize("method_suffix", _ROOT_VEL_METHODS) - def test_write_root_velocity_to_sim_mask(self, backend, num_instances, device, rigid_object_iface, method_suffix): - obj, _ = rigid_object_iface + def test_write_root_velocity_to_sim_mask(self, backend, device, method_suffix): + num_instances = _NUM_INSTANCES + obj, _ = get_rigid_object(backend, num_instances, device) obj.data.update(dt=0.01) method = getattr(obj, f"write_{method_suffix}_to_sim_mask") @@ -928,8 +557,12 @@ def test_write_root_velocity_to_sim_mask(self, backend, num_instances, device, r root_velocity=_make_data_torch((num_instances,), device, wp.spatial_vectorf), env_mask=_make_env_mask(num_instances, device, True), ) - # warp, no mask - method(root_velocity=_make_data_warp((num_instances,), device, wp.spatial_vectorf)) + # warp, no mask: the matching getter reads the written velocities back + method(root_velocity=_make_payload_warp((num_instances,), device, wp.spatial_vectorf)) + getter = _ROOT_VEL_METHODS[method_suffix] + _assert_reads_back( + getattr(obj.data, getter), _make_payload_torch((num_instances,), device, wp.spatial_vectorf), getter + ) # warp, partial mask method( root_velocity=_make_data_warp((num_instances,), device, wp.spatial_vectorf), @@ -947,22 +580,66 @@ def test_write_root_velocity_to_sim_mask(self, backend, num_instances, device, r # Tests: Body writers — torch/warp × index/mask × all/subset × negative # --------------------------------------------------------------------------- -# (method_name, kwarg_name, wp_dtype, trailing_dim) -_BODY_METHODS = [ - ("set_masses", "masses", wp.float32, 0), - ("set_coms", "coms", wp.transformf, 7), - ("set_inertias", "inertias", wp.float32, 9), -] +_BODY_METHODS = [("set_masses", "masses"), ("set_coms", "coms"), ("set_inertias", "inertias")] + + +def _body_writer_layout(backend: str, method_base: str) -> tuple[type, int, str | None]: + """Return the warp dtype, torch trailing size, and read-back getter of a body property writer.""" + if method_base == "set_masses": + return wp.float32, 0, "body_mass" + if method_base == "set_inertias": + return wp.float32, 9, "body_inertia" + if backend == "newton": + # Newton stores the COM as a position only. + return wp.vec3f, 3, "body_com_pos_b" + if backend == "physx": + # PhysX re-reads the COM from its view after a write, and the mocked view drops writes. + return wp.transformf, 7, None + return wp.transformf, 7, "body_com_pose_b" + + +def _make_body_torch(shape: tuple[int, int], device: str, wp_dtype: type, trailing: int, payload: bool = False): + """Create body-property torch data: valid ones by default, or per-element distinct values.""" + full_shape = (*shape, trailing) if trailing else shape + if payload: + data = torch.arange(1, math.prod(full_shape) + 1, dtype=torch.float32, device=device).reshape(full_shape) + else: + data = torch.ones(full_shape, device=device, dtype=torch.float32) + if wp_dtype == wp.transformf: + data[..., 3:6] = 0.0 + data[..., 6] = 1.0 + if not payload: + data[..., :3] = 0.0 + return data + + +def _make_body_warp(shape: tuple[int, int], device: str, wp_dtype: type, trailing: int, payload: bool = False): + """Create body-property data as a warp array (structured dtypes collapse the trailing dim).""" + t = _make_body_torch(shape, device, wp_dtype, trailing, payload).contiguous() + return wp.from_torch(t, dtype=wp.float32 if wp_dtype == wp.float32 else wp_dtype) class TestRigidObjectWritersBody: """Test body property writers/setters with all input combinations.""" @_production_backends - @_default_devices - def test_external_wrench_frames(self, backend, device): + def test_external_wrench_frames(self, backend, monkeypatch): """Forward local and world wrenches through the real writer in each backend's frame.""" + device = "cpu" # the composer and wrench-packing kernels are device-independent obj, raw_backend = get_rigid_object(backend, num_instances=2, device=device) + # Seed a known 90-degree rotation about Z so the local-frame expectation is independent of production. + root_pose = torch.tensor( + [[1.0, 2.0, 3.0, 0.0, 0.0, 2.0**-0.5, 2.0**-0.5], [4.0, 5.0, 6.0, 0.0, 0.0, 2.0**-0.5, 2.0**-0.5]], + device=device, + ) + if backend == "newton": + from isaaclab_newton.physics import NewtonManager + + # The mocked view has no state for forward kinematics; seed the body pose FK would publish below. + monkeypatch.setattr(NewtonManager, "forward", MagicMock()) + obj.write_root_link_pose_to_sim_index(root_pose=root_pose) + if backend == "newton": + obj.data._sim_bind_body_link_pose_w.assign(wp.from_torch(root_pose, dtype=wp.transformf)) composer = obj.permanent_wrench_composer forces = torch.arange(1.0, 7.0, device=device).reshape(2, 1, 3) torques = forces + 10.0 @@ -985,193 +662,99 @@ def test_external_wrench_frames(self, backend, device): actual_torque = call["torque_data"].numpy().reshape(2, 1, 3) else: if not is_global: - quat = obj.data.body_link_quat_w.torch - expected_force, expected_torque = quat_apply(quat, forces), quat_apply(quat, torques) + # A 90-degree Z rotation maps body (x, y, z) to world (-y, x, z). + expected_force = torch.stack((-forces[..., 1], forces[..., 0], forces[..., 2]), dim=-1) + expected_torque = torch.stack((-torques[..., 1], torques[..., 0], torques[..., 2]), dim=-1) if backend == "newton": packed = obj.data._sim_bind_body_external_wrench.numpy() else: from isaaclab_ov import tensor_types as TT packed = raw_backend.bindings[TT.RIGID_BODY_WRENCH]._data.reshape(2, 1, 9) - np.testing.assert_allclose(packed[..., 6:9], obj.data.body_link_pos_w.warp.numpy()) + np.testing.assert_allclose(packed[..., 6:9], root_pose[:, None, :3].numpy()) actual_force, actual_torque = packed[..., :3], packed[..., 3:6] np.testing.assert_allclose(actual_force, expected_force.cpu().numpy(), atol=1e-5, rtol=1e-5) np.testing.assert_allclose(actual_torque, expected_torque.cpu().numpy(), atol=1e-5, rtol=1e-5) @_backends - @_default_dims - @_default_devices - @pytest.mark.parametrize( - "method_base, kwarg, wp_dtype, trailing", - _BODY_METHODS, - ids=[m[0] for m in _BODY_METHODS], - ) - def test_body_writer_index( - self, backend, num_instances, device, rigid_object_iface, method_base, kwarg, wp_dtype, trailing - ): - if backend == "newton" and method_base == "set_coms": - pytest.xfail("Newton set_coms expects vec3f (position only), not transformf (pose)") - obj, _ = rigid_object_iface + @_devices + @pytest.mark.parametrize("method_base, kwarg", _BODY_METHODS, ids=[m[0] for m in _BODY_METHODS]) + def test_body_writer_index(self, backend, device, method_base, kwarg): + num_instances, num_bodies = _NUM_INSTANCES, 1 + obj, _ = get_rigid_object(backend, num_instances, device) + wp_dtype, trailing, getter = _body_writer_layout(backend, method_base) obj.data.update(dt=0.01) - num_bodies = 1 method = getattr(obj, f"{method_base}_index") - - def _torch_shape(n_envs, n_bods): - if trailing: - return (n_envs, n_bods, trailing) - return (n_envs, n_bods) - - def _make_torch(n_envs, n_bods): - shape = _torch_shape(n_envs, n_bods) - data = torch.ones(shape, device=device, dtype=torch.float32) - if wp_dtype == wp.transformf: - data[..., :3] = 0.0 - data[..., 3:6] = 0.0 - data[..., 6] = 1.0 - return data - - def _make_warp(n_envs, n_bods): - t = _make_torch(n_envs, n_bods) - if wp_dtype == wp.transformf: - return wp.from_torch(t.contiguous(), dtype=wp.transformf) - return wp.from_torch(t.contiguous(), dtype=wp.float32) - sub_b = 1 # rigid object always has 1 body sub_body_ids = [0] # torch, all envs + all bodies - method(**{kwarg: _make_torch(num_instances, num_bodies)}) + method(**{kwarg: _make_body_torch((num_instances, num_bodies), device, wp_dtype, trailing)}) # torch, subset method( **{ - kwarg: _make_torch(1, sub_b), + kwarg: _make_body_torch((1, sub_b), device, wp_dtype, trailing), "body_ids": sub_body_ids, "env_ids": _make_env_ids(device, True), } ) - # warp, all envs + all bodies - method(**{kwarg: _make_warp(num_instances, num_bodies)}) + # warp, all envs + all bodies: the matching getter reads the written values back + method(**{kwarg: _make_body_warp((num_instances, num_bodies), device, wp_dtype, trailing, payload=True)}) + if getter is not None: + expected = _make_body_torch((num_instances, num_bodies), device, wp_dtype, trailing, payload=True) + _assert_reads_back(getattr(obj.data, getter), expected, getter) # warp, subset method( **{ - kwarg: _make_warp(1, sub_b), + kwarg: _make_body_warp((1, sub_b), device, wp_dtype, trailing), "body_ids": sub_body_ids, "env_ids": _make_env_ids(device, True), } ) # negative: bad torch shape (extra env) with pytest.raises((AssertionError, RuntimeError)): - method(**{kwarg: _make_torch(num_instances + 1, num_bodies)}) + method(**{kwarg: _make_body_torch((num_instances + 1, num_bodies), device, wp_dtype, trailing)}) # negative: bad warp shape with pytest.raises((AssertionError, RuntimeError)): - method(**{kwarg: _make_warp(num_instances + 1, num_bodies)}) + method(**{kwarg: _make_body_warp((num_instances + 1, num_bodies), device, wp_dtype, trailing)}) @_backends - @_default_dims - @_default_devices - @pytest.mark.parametrize( - "method_base, kwarg, wp_dtype, trailing", - _BODY_METHODS, - ids=[m[0] for m in _BODY_METHODS], - ) - def test_body_writer_mask( - self, backend, num_instances, device, rigid_object_iface, method_base, kwarg, wp_dtype, trailing - ): - if backend == "newton" and method_base == "set_coms": - pytest.xfail("Newton set_coms expects vec3f (position only), not transformf (pose)") - obj, _ = rigid_object_iface + @_devices + @pytest.mark.parametrize("method_base, kwarg", _BODY_METHODS, ids=[m[0] for m in _BODY_METHODS]) + def test_body_writer_mask(self, backend, device, method_base, kwarg): + num_instances, num_bodies = _NUM_INSTANCES, 1 + obj, _ = get_rigid_object(backend, num_instances, device) + wp_dtype, trailing, getter = _body_writer_layout(backend, method_base) obj.data.update(dt=0.01) - num_bodies = 1 method = getattr(obj, f"{method_base}_mask") - - def _torch_shape(n_envs, n_bods): - if trailing: - return (n_envs, n_bods, trailing) - return (n_envs, n_bods) - - def _make_torch(n_envs, n_bods): - shape = _torch_shape(n_envs, n_bods) - data = torch.ones(shape, device=device, dtype=torch.float32) - if wp_dtype == wp.transformf: - data[..., :3] = 0.0 - data[..., 3:6] = 0.0 - data[..., 6] = 1.0 - return data - - def _make_warp(n_envs, n_bods): - t = _make_torch(n_envs, n_bods) - if wp_dtype == wp.transformf: - return wp.from_torch(t.contiguous(), dtype=wp.transformf) - return wp.from_torch(t.contiguous(), dtype=wp.float32) - sub_body_sel = [0] # torch, no mask - method(**{kwarg: _make_torch(num_instances, num_bodies)}) + method(**{kwarg: _make_body_torch((num_instances, num_bodies), device, wp_dtype, trailing)}) # torch, partial env_mask + body_mask method( **{ - kwarg: _make_torch(num_instances, num_bodies), + kwarg: _make_body_torch((num_instances, num_bodies), device, wp_dtype, trailing), "body_mask": _make_item_mask(num_bodies, sub_body_sel, device), "env_mask": _make_env_mask(num_instances, device, True), } ) - # warp, no mask - method(**{kwarg: _make_warp(num_instances, num_bodies)}) + # warp, no mask: the matching getter reads the written values back + method(**{kwarg: _make_body_warp((num_instances, num_bodies), device, wp_dtype, trailing, payload=True)}) + if getter is not None: + expected = _make_body_torch((num_instances, num_bodies), device, wp_dtype, trailing, payload=True) + _assert_reads_back(getattr(obj.data, getter), expected, getter) # warp, partial env_mask + body_mask method( **{ - kwarg: _make_warp(num_instances, num_bodies), + kwarg: _make_body_warp((num_instances, num_bodies), device, wp_dtype, trailing), "body_mask": _make_item_mask(num_bodies, sub_body_sel, device), "env_mask": _make_env_mask(num_instances, device, True), } ) # negative: bad torch shape with pytest.raises((AssertionError, RuntimeError)): - method(**{kwarg: _make_torch(num_instances + 1, num_bodies)}) + method(**{kwarg: _make_body_torch((num_instances + 1, num_bodies), device, wp_dtype, trailing)}) # negative: bad warp shape with pytest.raises((AssertionError, RuntimeError)): - method(**{kwarg: _make_warp(num_instances + 1, num_bodies)}) - - -# --------------------------------------------------------------------------- -# Tests: Alias/shorthand properties -# --------------------------------------------------------------------------- - - -class TestRigidObjectDataAliases: - """Test that alias properties return the same shape/dtype as their canonical counterparts.""" - - @_backends - @_default_dims - @_default_devices - def test_root_aliases(self, backend, num_instances, device, rigid_object_iface): - """root_pose_w == root_link_pose_w, root_vel_w == root_com_vel_w, etc.""" - obj, _ = rigid_object_iface - obj.data.update(dt=0.01) - d = obj.data - - assert d.root_pose_w.shape == d.root_link_pose_w.shape - assert d.root_pose_w.dtype == d.root_link_pose_w.dtype - assert d.root_pos_w.shape == d.root_link_pos_w.shape - assert d.root_quat_w.shape == d.root_link_quat_w.shape - - assert d.root_vel_w.shape == d.root_com_vel_w.shape - assert d.root_vel_w.dtype == d.root_com_vel_w.dtype - assert d.root_lin_vel_w.shape == d.root_com_lin_vel_w.shape - assert d.root_ang_vel_w.shape == d.root_com_ang_vel_w.shape - - @_backends - @_default_dims - @_default_devices - def test_body_aliases(self, backend, num_instances, device, rigid_object_iface): - obj, _ = rigid_object_iface - obj.data.update(dt=0.01) - d = obj.data - - assert d.body_pose_w.shape == d.body_link_pose_w.shape - assert d.body_pos_w.shape == d.body_link_pos_w.shape - assert d.body_quat_w.shape == d.body_link_quat_w.shape - assert d.body_vel_w.shape == d.body_com_vel_w.shape - assert d.body_lin_vel_w.shape == d.body_com_lin_vel_w.shape - assert d.body_ang_vel_w.shape == d.body_com_ang_vel_w.shape + method(**{kwarg: _make_body_warp((num_instances + 1, num_bodies), device, wp_dtype, trailing)}) diff --git a/source/isaaclab/test/benchmark/test_api.py b/source/isaaclab/test/benchmark/test_api.py index e1bd364ca683..f60041caf55f 100644 --- a/source/isaaclab/test/benchmark/test_api.py +++ b/source/isaaclab/test/benchmark/test_api.py @@ -8,12 +8,10 @@ import sys from pathlib import Path from types import SimpleNamespace -from typing import get_type_hints import pytest import isaaclab -import isaaclab.benchmark as benchmark from isaaclab.benchmark import ( BenchmarkLauncherConfig, BenchmarkOutputConfig, @@ -21,10 +19,6 @@ BenchmarkResult, BenchmarkRuntimeRequest, BenchmarkTrainingRequest, - PlayBundle, - RuntimeBundle, - StartupBundle, - TrainingBundle, dispatch, ) from isaaclab.benchmark.entrypoints import startup @@ -258,23 +252,6 @@ def test_success_check_rejects_unsupported_backend() -> None: dispatch._request_argv(request) -def test_request_translates_parser_exit_to_value_error(monkeypatch) -> None: - request = BenchmarkRuntimeRequest(task="Isaac-Cartpole-Direct") - module = SimpleNamespace(run=lambda argv: (_ for _ in ()).throw(SystemExit(2))) - monkeypatch.setattr(dispatch.importlib, "import_module", lambda module_name: module) - - with pytest.raises(ValueError, match="rejected the benchmark request"): - dispatch.run_benchmark_request(request) - - -def test_kit_args_fuses_option_like_value() -> None: - assert dispatch._fuse_kit_args(["--kit_args", "--ext-folder=/tmp/extensions", "--device", "cpu"]) == [ - "--kit_args=--ext-folder=/tmp/extensions", - "--device", - "cpu", - ] - - def test_startup_source_prefixes_include_installed_package_root() -> None: package_root = Path(isaaclab.__file__).resolve().parent @@ -322,36 +299,19 @@ def test_cli_dispatch_fuses_option_like_kit_args(monkeypatch) -> None: module = SimpleNamespace(run=lambda argv: received.extend(argv)) monkeypatch.setattr(dispatch.importlib, "import_module", lambda module_name: module) - assert dispatch.run_benchmark_cli(["runtime", "--kit_args", "--ext-folder=/tmp/extensions"]) == 0 - assert received == ["--kit_args=--ext-folder=/tmp/extensions"] - - -def test_backend_entrypoints_register_environment_cleanup_before_wrapping() -> None: - """All RL backend entrypoints compile and register cleanup before wrapping.""" - backends_root = Path(__file__).parents[2] / "isaaclab" / "benchmark" / "entrypoints" / "backends" - entrypoints = [ - backends_root / backend / f"benchmark_{mode}_{backend}.py" - for backend in ("rl_games", "rsl_rl", "sb3", "skrl") - for mode in ("train", "play") - ] - - for entrypoint in entrypoints: - source = entrypoint.read_text() - compile(source, str(entrypoint), "exec") - creation_index = max(source.find("gym.make("), source.find("common.create_isaaclab_env(")) - cleanup_index = source.find("cleanup.callback(lambda: env.close())", creation_index) - wrapper_indices = [ - source.find(wrapper, creation_index) for wrapper in ("common.wrap_record_video(env", "VecEnvWrapper(env") - ] - first_wrapper_index = min(index for index in wrapper_indices if index >= 0) + assert dispatch.run_benchmark_cli(["runtime", "--kit_args", "--ext-folder=/tmp/extensions", "--device", "cpu"]) == 0 + assert received == ["--kit_args=--ext-folder=/tmp/extensions", "--device", "cpu"] - assert creation_index >= 0, entrypoint - assert cleanup_index > creation_index, entrypoint - assert first_wrapper_index > cleanup_index, entrypoint +@pytest.mark.parametrize("workflow", ["training", "play"]) +@pytest.mark.parametrize("backend", ["rl_games", "rsl_rl", "sb3", "skrl"]) +def test_backend_entrypoints_close_environment_when_wrapping_fails( + workflow: str, backend: str, monkeypatch, tmp_path +) -> None: + """Every RL backend entrypoint closes the created environment when a later setup step fails.""" -def test_late_bound_environment_cleanup_closes_base_when_wrapping_fails() -> None: - """The cleanup callback retains the base environment when assignment fails.""" + class WrappingFailed(Exception): + pass class _Environment: closed = False @@ -359,72 +319,36 @@ class _Environment: def close(self) -> None: self.closed = True - def fail_to_wrap(environment: _Environment) -> _Environment: - raise RuntimeError("wrapper construction failed") + def __getattr__(self, name: str): + raise WrappingFailed(name) - base_environment = _Environment() - with pytest.raises(RuntimeError, match="wrapper construction failed"): - with contextlib.ExitStack() as cleanup: - env = base_environment - cleanup.callback(lambda: env.close()) - env = fail_to_wrap(env) + entrypoint = importlib.import_module(dispatch._workflow_module(workflow, backend)) + monkeypatch.setattr(entrypoint.common, "resolve_play_checkpoint", lambda *args: "/tmp/checkpoint") - assert base_environment.closed + import isaaclab.app as app + @contextlib.contextmanager + def launch_simulation(env_cfg, args): + yield -@pytest.mark.parametrize( - ("helper", "bundle_type"), - [ - (benchmark.run_runtime_benchmark, RuntimeBundle), - (benchmark.run_startup_benchmark, StartupBundle), - (benchmark.run_training_benchmark, TrainingBundle), - (benchmark.run_play_benchmark, PlayBundle), - ], -) -def test_workflow_helpers_return_bundle_specific_results(helper, bundle_type) -> None: - """Workflow helpers expose their concrete bundle type to static type checkers.""" - assert get_type_hints(helper)["return"] == BenchmarkResult[bundle_type] + created: list[_Environment] = [] + def create_environment(*args, **kwargs): + created.append(_Environment()) + return created[-1] -def test_scoped_backend_state_restores_values_after_exception() -> None: - """Backend-global settings are restored when an in-process benchmark fails.""" - import torch + monkeypatch.setattr(app, "launch_simulation", launch_simulation) + monkeypatch.setattr(entrypoint.common, "create_isaaclab_env", create_environment) + monkeypatch.chdir(tmp_path) - from isaaclab_rl.entrypoints.common import preserve_attribute, scoped_torch_backend_flags + argv = ["--task", "Isaac-Cartpole-Direct", "--output_path", str(tmp_path)] + if workflow == "play": + argv += ["--checkpoint", "/tmp/checkpoint"] + with pytest.raises(WrappingFailed): + entrypoint.run(argv) - original = ( - torch.backends.cuda.matmul.allow_tf32, - torch.backends.cudnn.allow_tf32, - torch.backends.cudnn.deterministic, - torch.backends.cudnn.benchmark, - ) - holder = SimpleNamespace(value="original") - - with pytest.raises(RuntimeError, match="failed"): - with ( - scoped_torch_backend_flags( - cuda_matmul_allow_tf32=True, - cudnn_allow_tf32=True, - cudnn_deterministic=False, - cudnn_benchmark=False, - ), - preserve_attribute(holder, "value"), - ): - holder.value = "temporary" - assert torch.backends.cuda.matmul.allow_tf32 is True - assert torch.backends.cudnn.allow_tf32 is True - assert torch.backends.cudnn.deterministic is False - assert torch.backends.cudnn.benchmark is False - assert holder.value == "temporary" - raise RuntimeError("failed") - - assert ( - torch.backends.cuda.matmul.allow_tf32, - torch.backends.cudnn.allow_tf32, - torch.backends.cudnn.deterministic, - torch.backends.cudnn.benchmark, - ) == original - assert holder.value == "original" + assert len(created) == 1 + assert created[0].closed def test_invalid_rsl_request_preserves_torch_backend_state() -> None: diff --git a/source/isaaclab/test/benchmark/test_asset_script_layout.py b/source/isaaclab/test/benchmark/test_asset_script_layout.py deleted file mode 100644 index 1c70203be380..000000000000 --- a/source/isaaclab/test/benchmark/test_asset_script_layout.py +++ /dev/null @@ -1,22 +0,0 @@ -# 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 the consolidated asset benchmark script layout.""" - -from pathlib import Path - - -def test_each_backend_retains_exactly_three_asset_scripts() -> None: - """Each backend should retain one combined script per asset concept.""" - root = Path(__file__).resolve().parents[4] - expected = { - "benchmark_articulation.py", - "benchmark_rigid_object.py", - "benchmark_rigid_object_collection.py", - } - - for package in ("isaaclab_physx", "isaaclab_newton", "isaaclab_ov"): - scripts = {path.name for path in (root / "source" / package / "benchmark" / "assets").glob("*.py")} - assert scripts == expected diff --git a/source/isaaclab/test/benchmark/test_asset_suite_cli.py b/source/isaaclab/test/benchmark/test_asset_suite_cli.py index d2ca513749c9..95478d8c23f1 100644 --- a/source/isaaclab/test/benchmark/test_asset_suite_cli.py +++ b/source/isaaclab/test/benchmark/test_asset_suite_cli.py @@ -13,7 +13,17 @@ import isaaclab.benchmark.asset_suites.cli as cli -def test_script_cli_builds_one_combined_request(monkeypatch, tmp_path) -> None: +@pytest.mark.parametrize( + ("script_variant", "component", "variant_argv", "expected_variant"), + [ + ("physx", "rigid_object_collection", [], "physx"), + # The factory-selected exact variant should override the wrapper default. + ("newton_mjwarp", "articulation", ["--physics_variant", "newton_kamino"], "newton_kamino"), + ], +) +def test_script_cli_builds_one_combined_request( + monkeypatch, tmp_path, script_variant: str, component: str, variant_argv: list[str], expected_variant: str +) -> None: """An asset script should dispatch one method-and-data request.""" adapter = SimpleNamespace( default_num_bodies=4, @@ -22,15 +32,21 @@ def test_script_cli_builds_one_combined_request(monkeypatch, tmp_path) -> None: generator_overrides={}, ) captured = {} - monkeypatch.setattr(cli, "get_asset_benchmark_adapter", lambda physics, component: adapter) + adapter_calls = [] + monkeypatch.setattr( + cli, + "get_asset_benchmark_adapter", + lambda physics, component: adapter_calls.append((physics, component)) or adapter, + ) monkeypatch.setattr( cli, "run_asset_benchmark", lambda request, selected: captured.update(request=request, adapter=selected) or () ) result = cli.run_asset_benchmark_cli( - "physx", - "rigid_object_collection", + script_variant, + component, [ + *variant_argv, "--num_iterations", "3", "--warmup_steps", @@ -48,42 +64,15 @@ def test_script_cli_builds_one_combined_request(monkeypatch, tmp_path) -> None: ) assert result == () + assert adapter_calls == [(expected_variant, component)] assert captured["adapter"] is adapter - assert captured["request"].physics_variant == "physx" + assert captured["request"].physics_variant == expected_variant assert captured["request"].config.num_bodies == 4 assert captured["request"].config.num_joints == 0 assert captured["request"].output_path == Path(tmp_path) assert captured["request"].launcher_args is None -def test_script_cli_uses_explicit_exact_physics_variant(monkeypatch, tmp_path) -> None: - """The factory-selected exact variant should override the wrapper default.""" - adapter = SimpleNamespace( - default_num_bodies=1, - default_num_joints=2, - capabilities=frozenset(), - generator_overrides={}, - ) - captured = {} - adapter_calls = [] - monkeypatch.setattr( - cli, - "get_asset_benchmark_adapter", - lambda physics, component: adapter_calls.append((physics, component)) or adapter, - ) - monkeypatch.setattr(cli, "run_asset_benchmark", lambda request, selected: captured.update(request=request) or ()) - - cli.run_asset_benchmark_cli( - "newton_mjwarp", - "articulation", - ["--physics_variant", "newton_kamino", "--output_dir", str(tmp_path), "--device", "cpu"], - include_app_launcher_args=False, - ) - - assert adapter_calls == [("newton_kamino", "articulation")] - assert captured["request"].physics_variant == "newton_kamino" - - @pytest.mark.parametrize( ("option", "value", "message"), [ diff --git a/source/isaaclab/test/benchmark/test_asset_suite_definitions.py b/source/isaaclab/test/benchmark/test_asset_suite_definitions.py index aef5fa57762e..58eae45e7974 100644 --- a/source/isaaclab/test/benchmark/test_asset_suite_definitions.py +++ b/source/isaaclab/test/benchmark/test_asset_suite_definitions.py @@ -11,70 +11,40 @@ import torch import warp as wp -from isaaclab.benchmark.asset_suites import get_asset_benchmark_suite, resolve_method_benchmarks +from isaaclab.benchmark.asset_suites import ( + get_asset_benchmark_adapter, + get_asset_benchmark_suite, + resolve_method_benchmarks, +) from isaaclab.benchmark.method_benchmark import MethodBenchmarkRunnerConfig pytestmark = pytest.mark.benchmark @pytest.mark.parametrize( - ("component", "physics", "capabilities", "definition_count", "workload_count"), + ("component", "physics", "definition_count", "workload_count"), ( - ("articulation", "physx", frozenset({"tensor_fill"}), 30, 190), - ( - "articulation", - "newton", - frozenset({"warp_mask", "tensor_fill", "mask_fill"}), - 50, - 210, - ), - ("articulation", "ovphysx", frozenset({"warp_mask"}), 50, 210), - ( - "rigid_object", - "physx", - frozenset({"physx_legacy_state", "tensor_fill"}), - 13, - 77, - ), - ( - "rigid_object", - "newton", - frozenset({"warp_mask", "tensor_fill", "mask_fill"}), - 15, - 59, - ), - ("rigid_object", "ovphysx", frozenset({"warp_mask"}), 15, 59), - ( - "rigid_object_collection", - "physx", - frozenset({"physx_legacy_state", "tensor_fill"}), - 13, - 113, - ), - ( - "rigid_object_collection", - "newton", - frozenset({"warp_mask", "tensor_fill", "mask_fill"}), - 15, - 75, - ), - ("rigid_object_collection", "ovphysx", frozenset({"warp_mask"}), 15, 75), + ("articulation", "physx", 30, 190), + ("articulation", "newton_mjwarp", 50, 210), + ("articulation", "ovphysx", 50, 210), + # PhysX rigid-object generator replacements drop the body selectors. + ("rigid_object", "physx", 13, 65), + ("rigid_object", "newton_mjwarp", 15, 59), + ("rigid_object", "ovphysx", 15, 59), + ("rigid_object_collection", "physx", 13, 113), + ("rigid_object_collection", "newton_mjwarp", 15, 75), + ("rigid_object_collection", "ovphysx", 15, 75), ), ) def test_method_manifests_preserve_backend_workloads( component: str, physics: str, - capabilities: frozenset[str], definition_count: int, workload_count: int, ) -> None: - """Capability resolution should produce every declared method workload.""" + """The shipped backend adapters should produce every declared method workload.""" suite = get_asset_benchmark_suite(component) - adapter = SimpleNamespace( - physics=physics, - capabilities=capabilities, - generator_overrides={}, - ) + adapter = get_asset_benchmark_adapter(physics, component) definitions = resolve_method_benchmarks(suite, adapter) diff --git a/source/isaaclab/test/benchmark/test_asset_suite_dispatch.py b/source/isaaclab/test/benchmark/test_asset_suite_dispatch.py index 3088f0464395..2f5453be7d4f 100644 --- a/source/isaaclab/test/benchmark/test_asset_suite_dispatch.py +++ b/source/isaaclab/test/benchmark/test_asset_suite_dispatch.py @@ -44,7 +44,8 @@ def fake_import_module(module_name: str): @pytest.mark.parametrize( ("physics", "component", "message"), ( - ("unknown", "articulation", "Unsupported asset physics selector"), + # Unknown exact variants must fail rather than silently normalizing to a family. + ("newton_unknown", "articulation", "Unsupported asset physics selector"), ("physx", "unknown", "Unsupported asset component"), ), ) @@ -70,9 +71,3 @@ def test_exact_physics_variant_is_preserved_separately_from_family(variant, fami assert adapter.physics == family assert adapter.physics_variant == variant - - -def test_unsupported_exact_physics_variant_is_explicit() -> None: - """Unknown exact variants should fail rather than silently normalizing to a family.""" - with pytest.raises(ValueError, match="Unsupported asset physics selector"): - get_asset_benchmark_adapter("newton_unknown", "articulation") diff --git a/source/isaaclab/test/benchmark/test_asset_suite_generator_semantics.py b/source/isaaclab/test/benchmark/test_asset_suite_generator_semantics.py index 443b4238ca52..6c2a911cc88a 100644 --- a/source/isaaclab/test/benchmark/test_asset_suite_generator_semantics.py +++ b/source/isaaclab/test/benchmark/test_asset_suite_generator_semantics.py @@ -99,10 +99,10 @@ def test_tensor_fill_preserves_item_selector_width() -> None: assert inputs["body_ids"].shape == (4,) -@pytest.mark.parametrize("physics", ("physx", "newton_mjwarp", "ovphysx")) -def test_articulation_position_limits_preserve_lower_upper_ordering(physics) -> None: +def test_articulation_position_limits_preserve_lower_upper_ordering() -> None: """Joint-limit inputs should always place non-positive lower bounds before non-negative upper bounds.""" - adapter = get_asset_benchmark_adapter(physics, "articulation") + # No backend overrides the suite-owned joint generators; OVPhysX resolves both the indexed and mask variants. + adapter = get_asset_benchmark_adapter("ovphysx", "articulation") definitions = resolve_method_benchmarks(get_asset_benchmark_suite("articulation"), adapter) limit_definitions = [ definition @@ -118,7 +118,6 @@ def test_articulation_position_limits_preserve_lower_upper_ordering(physics) -> assert torch.all(limits[..., 1] >= 0) -@pytest.mark.parametrize("physics", ("physx", "newton_mjwarp", "ovphysx")) @pytest.mark.parametrize( ("method_name", "field_name", "expected_scale"), ( @@ -131,15 +130,16 @@ def test_articulation_position_limits_preserve_lower_upper_ordering(physics) -> ), ) def test_articulation_joint_parameter_generators_preserve_workload_scales( - monkeypatch, physics, method_name, field_name, expected_scale + monkeypatch, method_name, field_name, expected_scale ) -> None: - """Every backend and available input mode should preserve its workload parameter scale.""" + """Every available input mode should preserve its workload parameter scale.""" def ones(*shape, **kwargs): return torch.ones(*shape, device=kwargs.get("device"), dtype=kwargs.get("dtype")) monkeypatch.setattr(asset_generators.torch, "rand", ones) - adapter = get_asset_benchmark_adapter(physics, "articulation") + # No backend overrides the suite-owned joint generators; OVPhysX resolves both the indexed and mask variants. + adapter = get_asset_benchmark_adapter("ovphysx", "articulation") definitions = resolve_method_benchmarks(get_asset_benchmark_suite("articulation"), adapter) matching = [ definition for definition in definitions if definition.method_name in {method_name, f"{method_name}_mask"} diff --git a/source/isaaclab/test/benchmark/test_asset_suite_runtime_semantics.py b/source/isaaclab/test/benchmark/test_asset_suite_runtime_semantics.py index 17066c994469..ec3f3e2eb450 100644 --- a/source/isaaclab/test/benchmark/test_asset_suite_runtime_semantics.py +++ b/source/isaaclab/test/benchmark/test_asset_suite_runtime_semantics.py @@ -6,6 +6,7 @@ """Regression tests for backend-specific asset data setup and refresh behavior.""" import importlib +import inspect import sys from dataclasses import replace from types import ModuleType, SimpleNamespace @@ -183,12 +184,8 @@ def test_ovphysx_data_targets_are_independent_and_properties_preflight_when_avai resolve_property_benchmarks, ) - try: - ovphysx_runtime._load_runtime_symbols() - except ModuleNotFoundError as exc: - if exc.name == "ovphysx": - pytest.skip("optional ovphysx runtime is not installed") - raise + pytest.importorskip("ovphysx", reason="optional ovphysx runtime is not installed") + ovphysx_runtime._load_runtime_symbols() for component in ("articulation", "rigid_object", "rigid_object_collection"): config = replace( @@ -219,62 +216,6 @@ def test_ovphysx_data_targets_are_independent_and_properties_preflight_when_avai assert data_target._sim_timestamp == previous_timestamp + 1.0 -def test_newton_articulation_data_target_restores_model_dynamics_and_ordering(monkeypatch) -> None: - """Dedicated Newton articulation data should allocate against configured model dimensions and apply ordering.""" - model = SimpleNamespace() - view = SimpleNamespace(randomized=False) - - def set_random_mock_data() -> None: - view.randomized = True - - view.set_random_mock_data = set_random_mock_data - - class FakeView: - def __new__(cls, **kwargs): - view.kwargs = kwargs - return view - - class FakeData: - def __init__(self, root_view, device): - self.root_view = root_view - self.device = device - self.ordering_applied = False - self._jacobian_body_user_to_backend = None - self._sim_timestamp = 0.0 - self._fk_timestamp = 0.0 - - def _apply_ordering_maps_after_resolve(self) -> None: - self.ordering_applied = True - self._jacobian_body_user_to_backend = object() - - monkeypatch.setattr(newton_runtime, "MockNewtonArticulationView", FakeView, raising=False) - monkeypatch.setattr(newton_runtime, "NewtonArticulationData", FakeData, raising=False) - monkeypatch.setattr( - newton_runtime, - "NewtonSimulationManager", - SimpleNamespace(get_model=lambda: model), - raising=False, - ) - create_target = getattr(newton_runtime, "_create_articulation_data_target", None) - - assert create_target is not None - data, refresh = create_target(_CONFIG) - - assert model.articulation_count == 2 - assert model.max_joints_per_articulation == 3 - assert model.max_dofs_per_articulation == 10 - assert model.joint_dof_count == 20 - assert model.body_count == 6 - assert view.randomized - assert callable(view.eval_jacobian) - assert callable(view.eval_mass_matrix) - assert data.ordering_applied - assert data._jacobian_body_user_to_backend is not None - refresh(_CONFIG) - assert data._sim_timestamp == 1.0 - assert data._fk_timestamp == 1.0 - - def test_newton_articulation_open_targets_constructs_real_method_and_data_targets(monkeypatch) -> None: """The combined Newton path should configure model dimensions before constructing either data target.""" from isaaclab.benchmark.asset_suites import get_asset_benchmark_adapter @@ -286,13 +227,18 @@ def test_newton_articulation_open_targets_constructs_real_method_and_data_target with newton_runtime.open_asset_targets(adapter, request, _CONFIG, _CONFIG) as targets: assert targets.method_target._data._jacobian_buf.shape == (2, 3, 6, 10) assert targets.data_target._jacobian_buf.shape == (2, 3, 6, 10) + # The dedicated data target applies joint/body ordering and installs the dynamics stubs. + assert targets.data_target._jacobian_body_user_to_backend is not None + assert targets.data_target.mass_matrix is not None + sim_timestamp = targets.data_target._sim_timestamp + fk_timestamp = targets.data_target._fk_timestamp + targets.refresh_data(_CONFIG) + assert targets.data_target._sim_timestamp == sim_timestamp + 1.0 + assert targets.data_target._fk_timestamp == fk_timestamp + 1.0 def test_physx_cpu_boundary_reuses_int32_scratch_for_int64_env_ids(monkeypatch) -> None: - module_name = "isaaclab_physx.assets.articulation.articulation" - monkeypatch.delitem(sys.modules, module_name, raising=False) physx_runtime._load_runtime_symbols() - assert sys.modules[module_name].Articulation is physx_runtime.Articulation zeros = wp.zeros empty = wp.empty monkeypatch.setattr(wp, "zeros", lambda *args, **kwargs: zeros(*args, **(kwargs | {"pinned": False}))) @@ -303,7 +249,15 @@ def test_physx_cpu_boundary_reuses_int32_scratch_for_int64_env_ids(monkeypatch) sim_env_ids = wp.array([1, 0], dtype=wp.int32, device="cpu") assert target._get_cpu_env_ids(env_ids, sim_env_ids).ptr == sim_env_ids.ptr + + # The PhysX CPU API receives int32 simulation ids even when the caller passes int64 env ids. + set_masses_indices = [] + monkeypatch.setattr(target.root_view, "set_masses", lambda masses, indices=None: set_masses_indices.append(indices)) target.set_masses_index(masses=wp.ones((2, 1), dtype=wp.float32, device="cpu"), env_ids=env_ids) + (indices,) = set_masses_indices + assert indices.dtype == wp.int32 + assert str(indices.device) == "cpu" + np.testing.assert_array_equal(indices.numpy(), [1, 0]) def test_physx_collection_reuses_bounded_flat_view_id_scratch(monkeypatch) -> None: @@ -348,17 +302,6 @@ def test_physx_collection_reuses_bounded_flat_view_id_scratch(monkeypatch) -> No assert calls == ["copy", ("sync", "cuda:0")] -@pytest.mark.parametrize("runtime", (physx_runtime, newton_runtime)) -def test_open_targets_preserves_setup_errors(monkeypatch, runtime) -> None: - _hide_app_launcher(monkeypatch) - monkeypatch.setattr(runtime, "_load_runtime_symbols", lambda: (_ for _ in ()).throw(RuntimeError("setup"))) - request = SimpleNamespace(launcher_args=None, check_shapes=True) - - with pytest.raises(RuntimeError, match="setup"): - with runtime.open_asset_targets(None, request, _CONFIG, _CONFIG): - pass - - @pytest.mark.parametrize( ("factory_name", "module_name", "class_name"), ( @@ -383,25 +326,19 @@ def test_open_targets_preserves_setup_errors(monkeypatch, runtime) -> None: def test_ovphysx_factories_use_exported_binding_set_signature( monkeypatch, factory_name, module_name, class_name ) -> None: - """Every OVPhysX target factory should call the exported binding constructor compatibly.""" + """Factory calls match the exported binding constructor without requiring the optional runtime.""" + monkeypatch.setitem(sys.modules, "ovphysx", None) class ConstructorAccepted(Exception): pass + from isaaclab_ov.test.fixtures import MockOvPhysxBindingSet + + binding_set_signature = inspect.signature(MockOvPhysxBindingSet) + class StrictBindingSet: - def __init__( - self, - num_instances, - num_joints, - num_bodies, - is_fixed_base=False, - joint_names=None, - body_names=None, - num_fixed_tendons=0, - num_spatial_tendons=0, - *, - asset_kind="articulation", - ): + def __init__(self, *args, **kwargs): + binding_set_signature.bind(*args, **kwargs) raise ConstructorAccepted if module_name is not None: @@ -425,7 +362,7 @@ def __init__( @pytest.mark.parametrize("read_mode", ("numpy", "structured_warp", "view_preflight")) def test_mock_ovphysx_binding_reads_latest_data_without_optional_runtime(monkeypatch, read_mode) -> None: """Repository mock reads should support every consumer mode and reflect subsequent writes.""" - monkeypatch.setitem(sys.modules, "isaaclab_ov.tensor_types", SimpleNamespace()) + monkeypatch.setitem(sys.modules, "ovphysx", None) bindings_module = importlib.import_module("isaaclab_ov.test.fixtures.views.mock_ovphysx_bindings") binding = bindings_module.MockTensorBinding(tensor_type=0, shape=(2, 7), count=2) first = np.arange(14, dtype=np.float32).reshape(2, 7) diff --git a/source/isaaclab/test/benchmark/test_benchmark_core.py b/source/isaaclab/test/benchmark/test_benchmark_core.py index 5e8061cb310d..8ae19d1bedb1 100644 --- a/source/isaaclab/test/benchmark/test_benchmark_core.py +++ b/source/isaaclab/test/benchmark/test_benchmark_core.py @@ -187,8 +187,6 @@ def test_benchmark_collects_metadata_measurements_and_writes_json(tmp_path): assert output_path.exists() assert benchmark.benchmark_name == "my_workflow" - assert benchmark._use_recorders is False - assert not hasattr(benchmark, "_manual_recorders") or benchmark._manual_recorders is None assert data["benchmark_info"]["workflow_name"] == "my_workflow" assert "timestamp" in data["benchmark_info"] assert data["runtime"]["metric1"] == 10.0 diff --git a/source/isaaclab/test/benchmark/test_builders.py b/source/isaaclab/test/benchmark/test_builders.py index 71b2b9294926..0a1a74eadcb9 100644 --- a/source/isaaclab/test/benchmark/test_builders.py +++ b/source/isaaclab/test/benchmark/test_builders.py @@ -17,7 +17,6 @@ Hardware, MeanStd, Resources, - RuntimeBundle, StartupTime, TrainingBundle, Versions, @@ -94,7 +93,7 @@ def test_build_runtime_aggregates(): assert rt.iterations_completed == 3 assert rt.total_wall_time_s == pytest.approx(4.0) assert rt.total_fps.peak == pytest.approx(95.0) - assert rt.iterations_per_s.mean > 0 + assert rt.iterations_per_s.mean == pytest.approx((1.0 + 1.0 + 0.5) / 3) def test_build_runtime_uses_effective_aggregate_throughput_when_requested(): @@ -239,7 +238,7 @@ def test_build_runtime_rejects_non_positive_environment_step_times(environment_s @pytest.mark.parametrize( ("simulation_step_times_s", "simulation_step_calls"), - [([0.5], None), (None, 1), ([0.5], 1)], + [([0.5], None), (None, 1)], ) def test_build_runtime_rejects_simulation_timing_without_environment_timing( simulation_step_times_s, simulation_step_calls @@ -320,12 +319,11 @@ def test_build_learning_includes_success_rate_curve(): assert learning.success_rate.series_per_iter == pytest.approx([0.1, 0.5, 0.9]) -@pytest.mark.parametrize("success_rate_series", [None, []]) -def test_build_learning_omits_absent_success_rate_curve(success_rate_series): +def test_build_learning_omits_absent_success_rate_curve(): learning = builders.build_learning( reward_series=[1.0], ep_length_series=[10.0], - success_rate_series=success_rate_series, + success_rate_series=[], ema_alpha=0.1, ) @@ -345,31 +343,3 @@ def test_build_learning_keep_series_false(): assert learning.success_rate is not None assert learning.success_rate.final_raw == pytest.approx(0.75) assert learning.success_rate.series_per_iter is None - - -def test_build_runtime_bundle_no_learning(tmp_path): - run = builders.build_run_identity( - run_id="x", - framework=None, - config=builders.build_run_config("newton_mjwarp"), - task="t", - seed=0, - start_utc="2026-04-22T13:15:00+00:00", - end_utc="2026-04-22T13:15:10+00:00", - num_envs=16, - ) - rt = builders.build_runtime( - startup_time_s=StartupTime(1.0, 2.0, 0.5), - iteration_times_s=[1.0], - collection_fps=[100.0], - total_fps=[100.0], - steps_per_iteration=24, - ) - b = builders.build_runtime_bundle( - run=run, versions=_versions(), hardware=_hardware(), runtime=rt, resources=_resources() - ) - assert isinstance(b, RuntimeBundle) - p = os.path.join(tmp_path, "runtime.json") - write_bundle_file(b, p) - with open(p) as fh: - assert json.load(fh)["run"]["framework"] is None diff --git a/source/isaaclab/test/benchmark/test_capture.py b/source/isaaclab/test/benchmark/test_capture.py index 34d903149649..30398c785b61 100644 --- a/source/isaaclab/test/benchmark/test_capture.py +++ b/source/isaaclab/test/benchmark/test_capture.py @@ -47,19 +47,6 @@ def test_capture_versions_renames_and_defaults(): StringMetadata(name="mujoco_warp_version", data="0.0.4"), StringMetadata(name="stable_baselines3_version", data="2.3.0"), DictMetadata(name="dev", data={"commit_hash": "abc123", "branch": "develop", "dirty": True}), - ] - bm = _Bm({"VersionInfo": _Rec(MeasurementData(measurements=[], metadata=md, artefacts=[]))}) - v = capture_versions(bm) - assert isinstance(v, Versions) - assert v.isaaclab == "4.6.8" and v.torch == "2.5.1" - assert v.mjwarp == "0.0.4" - assert v.sb3 == "2.3.0" - assert v.git_commit == "abc123" and v.git_branch == "develop" and v.git_dirty is True - assert v.isaacsim is None - - -def test_capture_versions_preserves_runtime_packages(): - md = [ StringMetadata(name="numpy_version", data="2.4.4"), StringMetadata(name="isaaclab_newton_version", data="1.0.2"), StringMetadata(name="isaaclab_physx_version", data="2.0.1"), @@ -74,21 +61,25 @@ def test_capture_versions_preserves_runtime_packages(): StringMetadata(name="isaaclab_release_version", data="3.0.0"), ] bm = _Bm({"VersionInfo": _Rec(MeasurementData(measurements=[], metadata=md, artefacts=[]))}) - - versions = capture_versions(bm) - - assert versions.numpy == "2.4.4" - assert versions.isaaclab_newton == "1.0.2" - assert versions.isaaclab_physx == "2.0.1" - assert versions.isaaclab_ov == "0.4.6" - assert versions.isaaclab_tasks == "8.0.1" - assert versions.isaaclab_rl == "0.6.1" - assert versions.ovrtx is None - assert versions.ovphysx == "3.0.5" - assert versions.mujoco == "3.8.1" - assert versions.cuda_bindings == "12.9.4" - assert versions.usd_core == "25.11" - assert versions.isaaclab_release == "3.0.0" + v = capture_versions(bm) + assert isinstance(v, Versions) + assert v.isaaclab == "4.6.8" and v.torch == "2.5.1" + assert v.mjwarp == "0.0.4" + assert v.sb3 == "2.3.0" + assert v.git_commit == "abc123" and v.git_branch == "develop" and v.git_dirty is True + assert v.isaacsim is None + assert v.numpy == "2.4.4" + assert v.isaaclab_newton == "1.0.2" + assert v.isaaclab_physx == "2.0.1" + assert v.isaaclab_ov == "0.4.6" + assert v.isaaclab_tasks == "8.0.1" + assert v.isaaclab_rl == "0.6.1" + assert v.ovrtx is None + assert v.ovphysx == "3.0.5" + assert v.mujoco == "3.8.1" + assert v.cuda_bindings == "12.9.4" + assert v.usd_core == "25.11" + assert v.isaaclab_release == "3.0.0" def test_capture_resources_peaks(): @@ -164,7 +155,12 @@ def test_capture_hardware(): metadata=[ DictMetadata( name="gpu_devices", - data={"0": {"name": "H100", "total_memory_gb": 80.0, "compute_capability": "9.0"}}, + # Numeric keys must be ordered 0, 2, 10, not lexically. + data={ + "10": {"name": "H100-10", "total_memory_gb": 80.0, "compute_capability": "9.0"}, + "2": {"name": "H100-2", "total_memory_gb": 80.0, "compute_capability": "9.0"}, + "0": {"name": "H100", "total_memory_gb": 80.0, "compute_capability": "9.0"}, + }, ), ], artefacts=[], @@ -189,7 +185,8 @@ def test_capture_hardware(): ) h = capture_hardware(_Bm({"GPUInfo": gpu, "CPUInfo": cpu, "MemoryInfo": mem})) assert isinstance(h, Hardware) - assert h.gpu_devices[0].name == "H100" and h.gpu_devices[0].mem_gb == pytest.approx(80.0) + assert [d.name for d in h.gpu_devices] == ["H100", "H100-2", "H100-10"] + assert h.gpu_devices[0].mem_gb == pytest.approx(80.0) assert h.gpu_devices[0].compute_cap == "9.0" assert h.cpu_name == "EPYC" and h.cpu_count == 64 and h.ram_gb == pytest.approx(512.0) assert isinstance(h.hostname, str) and h.hostname @@ -197,14 +194,22 @@ def test_capture_hardware(): def test_capture_handles_missing_recorders(): bm = _Bm(None) - assert isinstance(capture_versions(bm), Versions) - assert isinstance(capture_hardware(bm), Hardware) - assert isinstance(capture_resources(bm), Resources) + versions = capture_versions(bm) + assert versions.isaaclab == "unknown" + assert versions.torch == "unknown" + assert versions.git_commit is None and versions.git_dirty is False + hardware = capture_hardware(bm) + assert hardware.gpu_devices == [] + assert hardware.cpu_name == "unknown" and hardware.cpu_count == 0 and hardware.ram_gb == 0.0 + resources = capture_resources(bm) + assert resources.devices == {} + assert resources.ram_gb.mean == 0.0 and resources.gpu_mem_gb.mean == 0.0 def test_synth_run_id(): rid = synth_run_id("rsl_rl", "physx", "Isaac-Ant-Direct-v0", 42, "20260612-150000") - assert "rsl_rl" in rid and "physx" in rid and "42" in rid + assert rid == "rsl_rl_physx_Isaac-Ant-Direct-v0_20260612-150000_seed42" + assert synth_run_id(None, "physx", "Isaac-Ant-Direct-v0", 42, "20260612-150000").startswith("runtime_") def test_run_config_uses_concrete_backend_configuration(): @@ -270,27 +275,3 @@ def test_capture_resources_peak_clamped_to_mean_when_peak_row_absent(): r = capture_resources(_Bm({"GPUInfo": gpu, "CPUInfo": cpu, "MemoryInfo": mem})) assert r.gpu_mem_gb.peak == pytest.approx(10.0) assert r.ram_gb.peak == pytest.approx(10.0) - - -def test_capture_hardware_gpu_devices_sorted_by_numeric_index(): - # Keys "10", "2", "0" should be returned in numeric order 0, 2, 10 — not lexical "0","10","2". - gpu = _Rec( - MeasurementData( - measurements=[], - metadata=[ - DictMetadata( - name="gpu_devices", - data={ - "10": {"name": "H100-10", "total_memory_gb": 80.0, "compute_capability": "9.0"}, - "2": {"name": "H100-2", "total_memory_gb": 80.0, "compute_capability": "9.0"}, - "0": {"name": "H100-0", "total_memory_gb": 80.0, "compute_capability": "9.0"}, - }, - ), - ], - artefacts=[], - ) - ) - bm = _Bm({"GPUInfo": gpu}) - h = capture_hardware(bm) - names = [d.name for d in h.gpu_devices] - assert names == ["H100-0", "H100-2", "H100-10"] diff --git a/source/isaaclab/test/benchmark/test_distributed.py b/source/isaaclab/test/benchmark/test_distributed.py index e42d855ed435..c6cf862372b4 100644 --- a/source/isaaclab/test/benchmark/test_distributed.py +++ b/source/isaaclab/test/benchmark/test_distributed.py @@ -116,9 +116,7 @@ def test_unsupported_options_are_rejected_for_distributed_runs( @pytest.mark.parametrize( "argv", [ - [], ["--video"], - ["--distributed"], ["--distributed", "--capture_env_sensors", "0"], ["--distributed", "--check_success"], ], diff --git a/source/isaaclab/test/benchmark/test_method_benchmark.py b/source/isaaclab/test/benchmark/test_method_benchmark.py index ff6d02a16e8b..da76e9698f11 100644 --- a/source/isaaclab/test/benchmark/test_method_benchmark.py +++ b/source/isaaclab/test/benchmark/test_method_benchmark.py @@ -5,13 +5,12 @@ """Tests for method-level micro-benchmark sampling.""" -import inspect -from dataclasses import fields +import json from unittest.mock import patch import pytest -from isaaclab.benchmark import MethodBenchmarkDefinition, MethodBenchmarkRunner, MethodBenchmarkRunnerConfig +from isaaclab.benchmark import MethodBenchmarkRunner, MethodBenchmarkRunnerConfig pytestmark = pytest.mark.benchmark @@ -41,17 +40,20 @@ def test_config_accepts_zero_joints_for_rigid_assets() -> None: def test_runner_records_exact_physics_variant_in_workflow_metadata(tmp_path) -> None: """Exact backend selectors should remain distinguishable in output metadata.""" - with patch("isaaclab.benchmark.method_benchmark.BaseIsaacLabBenchmark.__init__") as base_init: - MethodBenchmarkRunner( - benchmark_name="asset_benchmark", - config=MethodBenchmarkRunnerConfig(device="cpu"), - output_path=str(tmp_path), - use_recorders=False, - physics_variant="newton_kamino", - ) + runner = MethodBenchmarkRunner( + benchmark_name="asset_benchmark", + config=MethodBenchmarkRunnerConfig(device="cpu"), + output_path=str(tmp_path), + use_recorders=False, + physics_variant="newton_kamino", + ) - metadata = base_init.call_args.kwargs["workflow_metadata"]["metadata"] - assert {"name": "physics_variant", "data": "newton_kamino"} in metadata + (output_file,) = runner.finalize() + with open(output_file) as f: + phases = json.load(f) + info = next(phase for phase in phases if phase["phase_name"] == "benchmark_info") + metadata = {entry["name"]: entry["data"] for entry in info["metadata"]} + assert metadata["asset_benchmark benchmark_info physics_variant"] == "newton_kamino" def _runner(*, num_iterations: int = 3, warmup_steps: int = 0) -> MethodBenchmarkRunner: @@ -171,14 +173,6 @@ def clock() -> int: ] -def test_method_dependency_surface_is_not_published() -> None: - """The unused method dependency arguments should not become public API.""" - definition_fields = {field.name for field in fields(MethodBenchmarkDefinition)} - - assert "dependencies" not in definition_fields - assert "dependencies" not in inspect.signature(MethodBenchmarkRunner.run_benchmarks).parameters - - def test_method_benchmark_uses_sample_standard_deviation() -> None: """Method statistics should match the regular benchmark convention.""" runner = _runner(num_iterations=2) diff --git a/source/isaaclab/test/benchmark/test_metrics.py b/source/isaaclab/test/benchmark/test_metrics.py index f78d22cc2dd2..e1a7bbf19c90 100644 --- a/source/isaaclab/test/benchmark/test_metrics.py +++ b/source/isaaclab/test/benchmark/test_metrics.py @@ -8,7 +8,6 @@ import pytest from isaaclab.benchmark.metrics import ( - RL_LIBRARY_DESCRIPTORS, SUCCESS_RATE_LOG_TAGS, SuccessRateTracker, check_convergence, @@ -22,29 +21,6 @@ from isaaclab.benchmark.schema import MeanStd -@pytest.mark.parametrize( - ("framework", "tfevents_pattern", "reward_tag", "ep_length_tag"), - [ - ("rsl_rl", "events*", "Train/mean_reward", "Train/mean_episode_length"), - ("rl_games", "summaries/events*", "rewards/iter", "episode_lengths/iter"), - ("skrl", "events*", "Reward / Total reward (mean)", "Episode / Total timesteps (mean)"), - ("sb3", "PPO_*/events*", "rollout/ep_rew_mean", "rollout/ep_len_mean"), - ], -) -def test_rl_library_descriptors( - framework: str, - tfevents_pattern: str, - reward_tag: str, - ep_length_tag: str, -): - descriptor = RL_LIBRARY_DESCRIPTORS[framework] - - assert descriptor.framework == framework - assert descriptor.tfevents_pattern == tfevents_pattern - assert descriptor.reward_tag == reward_tag - assert descriptor.ep_length_tag == ep_length_tag - - def test_mean_std_peak_computes_peak(): ms = mean_std_peak([1.0, 2.0, 3.0]) assert isinstance(ms, MeanStd) @@ -65,12 +41,8 @@ def test_mean_std_empty_is_zero(): def test_ema_matches_manual(): - series = [0.0, 10.0, 10.0] - a = 0.5 - e = series[0] - for x in series[1:]: - e = a * x + (1 - a) * e - assert ema(series, a) == pytest.approx(e) + # e0 = 2.0; e1 = 0.25 * 10 + 0.75 * 2.0 = 4.0; e2 = 0.25 * 10 + 0.75 * 4.0 = 5.5 + assert ema([2.0, 10.0, 10.0], 0.25) == pytest.approx(5.5) def test_ema_empty_is_zero(): @@ -147,9 +119,10 @@ def item(self): def test_success_rate_tracker_no_data_end_iteration_returns_none(): - t = SuccessRateTracker(threshold=0.5, window=1, num_steps_per_env=1) + t = SuccessRateTracker(threshold=0.5, window=1, num_steps_per_env=2) t.record_step({"log": {}}) - assert t._step_count == 1 + # A step without success data still advances the iteration step count. + assert t.at_iteration_boundary is False result = t.end_iteration() assert result is None assert t.history == [] diff --git a/source/isaaclab/test/benchmark/test_micro.py b/source/isaaclab/test/benchmark/test_micro.py index 6f580610c79c..7fe43225b375 100644 --- a/source/isaaclab/test/benchmark/test_micro.py +++ b/source/isaaclab/test/benchmark/test_micro.py @@ -69,7 +69,7 @@ def synchronize() -> None: def operation() -> None: nonlocal now_ns, pending_ns now_ns += 20 - pending_ns = 30 + pending_ns += 30 sample = measure_latency(operation=operation, synchronize=synchronize, clock_ns=lambda: now_ns) diff --git a/source/isaaclab/test/benchmark/test_multigpu_launcher.py b/source/isaaclab/test/benchmark/test_multigpu_launcher.py index a0a7d047018d..4cced1cca0eb 100644 --- a/source/isaaclab/test/benchmark/test_multigpu_launcher.py +++ b/source/isaaclab/test/benchmark/test_multigpu_launcher.py @@ -79,11 +79,14 @@ def test_multi_node_rendezvous_options_reach_torchrun(): def test_dry_run_prints_a_shell_parsable_command(capsys: pytest.CaptureFixture[str]): """``--dry_run`` reports the exact command instead of launching workers.""" - status = multigpu.run_multigpu_benchmark_cli("startup", ["--dry_run", "--num_gpus", "2", "--task", "X"]) + # A spaced value must be quoted so the printed command splits back into the same tokens. + argv = ["--num_gpus", "2", "--task", "X", "--kit_args", "--foo=/bar --baz=1"] + status = multigpu.run_multigpu_benchmark_cli("startup", ["--dry_run", *argv]) assert status == 0 tokens = shlex.split(capsys.readouterr().out) - assert tokens == _command("startup", ["--num_gpus", "2", "--task", "X"]) + assert tokens == _command("startup", argv) + assert "--foo=/bar --baz=1" in tokens def test_underscore_workflow_dispatches_to_multigpu_launcher(monkeypatch: pytest.MonkeyPatch): diff --git a/source/isaaclab/test/benchmark/test_play_schema.py b/source/isaaclab/test/benchmark/test_play_schema.py index be9f91e2e0cc..08c92d6a1541 100644 --- a/source/isaaclab/test/benchmark/test_play_schema.py +++ b/source/isaaclab/test/benchmark/test_play_schema.py @@ -95,7 +95,8 @@ def _resources() -> Resources: def _minimal_play_bundle() -> PlayBundle: - return PlayBundle( + # Built through the public builder; ``video_path`` is omitted to exercise its None default. + return builders.build_play_bundle( run=_run_identity(), versions=_versions(), hardware=_hardware(), @@ -105,7 +106,6 @@ def _minimal_play_bundle() -> PlayBundle: reward=MeanStd(mean=1823.4, std=44.0, peak=1980.0), ep_length=MeanStd(mean=987.0, std=12.0, peak=1000.0), checkpoint_path="logs/rsl_rl/ant/2026-04-22_13-15-00/model_499.pt", - video_path=None, ) @@ -128,30 +128,6 @@ def test_play_bundle_round_trip(tmp_path): assert "learning" not in data -def test_build_play_bundle_passes_fields_through(): - """build_play_bundle forwards every field unchanged onto the PlayBundle.""" - reward = MeanStd(mean=12.0, std=1.0, peak=15.0) - ep_length = MeanStd(mean=100.0, std=5.0, peak=120.0) - b = builders.build_play_bundle( - run=_run_identity(), - versions=_versions(), - hardware=_hardware(), - runtime=_runtime(), - resources=_resources(), - success_rate=0.7, - reward=reward, - ep_length=ep_length, - checkpoint_path="/tmp/m.pt", - ) - assert isinstance(b, PlayBundle) - assert b.run.framework == "rsl_rl" - assert b.success_rate == pytest.approx(0.7) - assert b.reward is reward - assert b.ep_length is ep_length - assert b.checkpoint_path == "/tmp/m.pt" - assert b.video_path is None - - class _PlayEnv: """Minimal fake env: env 0 finishes a 2-step episode on the 2nd step, env 1 never finishes.""" @@ -186,13 +162,4 @@ def test_run_play_loop_aggregates_episodes(): assert reward.mean == pytest.approx(2.0) assert ep_length.mean == pytest.approx(2.0) assert success_rate == pytest.approx(1.0) - - -def test_run_play_loop_steps_env_in_inference_mode(): - """run_play_loop keeps environment stepping in inference mode.""" - from isaaclab.benchmark.stepping import run_play_loop - - env = _PlayEnv() - run_play_loop(env, policy=lambda obs: torch.zeros(2, 1), num_steps=1) - assert env.inference_mode_enabled diff --git a/source/isaaclab/test/benchmark/test_ray_caster_workload.py b/source/isaaclab/test/benchmark/test_ray_caster_workload.py index 89a60b6df592..0b453ec595c8 100644 --- a/source/isaaclab/test/benchmark/test_ray_caster_workload.py +++ b/source/isaaclab/test/benchmark/test_ray_caster_workload.py @@ -5,9 +5,8 @@ """Tests for shared ray-caster workload geometry.""" -import math - from isaaclab.benchmark.sensor_suites import rough_terrain_size +from isaaclab.cloner import grid_transforms def test_default_rough_terrain_covers_every_environment_ray_grid() -> None: @@ -18,9 +17,10 @@ def test_default_rough_terrain_covers_every_environment_ray_grid() -> None: terrain_size = rough_terrain_size(num_envs, env_spacing, ray_grid_size) - columns = math.ceil(math.sqrt(num_envs)) - rows = math.ceil(num_envs / columns) - required_extent = max(columns - 1, rows - 1) * env_spacing + ray_grid_size + # Derive the extent from the environment origins the scene actually lays out. + positions, _ = grid_transforms(num_envs, env_spacing) + xy_span = positions[:, :2].max(axis=0) - positions[:, :2].min(axis=0) + required_extent = float(xy_span.max()) + ray_grid_size assert terrain_size >= required_extent diff --git a/source/isaaclab/test/benchmark/test_recorders.py b/source/isaaclab/test/benchmark/test_recorders.py index b99c3d36d296..0706a59dc256 100644 --- a/source/isaaclab/test/benchmark/test_recorders.py +++ b/source/isaaclab/test/benchmark/test_recorders.py @@ -32,37 +32,6 @@ def recorder(self): """Create a CPUInfoRecorder fixture.""" return CPUInfoRecorder() - def test_initialization(self, recorder): - """Test that CPUInfoRecorder initializes correctly.""" - assert recorder._cpu_hardware_info is not None - assert recorder._cpu_runtime_info is not None - assert recorder._mean == 0 - assert recorder._std == 0 - assert recorder._n == 0 - assert recorder._m2 == 0 - - def test_get_initial_data_structure(self, recorder): - """Test that get_initial_data returns correct structure.""" - data = recorder.get_initial_data() - assert "cpu_metadata" in data - assert "physical_cores" in data["cpu_metadata"] - assert "name" in data["cpu_metadata"] - - def test_get_initial_data_values(self, recorder): - """Test that get_initial_data returns valid values.""" - data = recorder.get_initial_data() - assert isinstance(data["cpu_metadata"]["physical_cores"], int) - assert data["cpu_metadata"]["physical_cores"] > 0 - assert isinstance(data["cpu_metadata"]["name"], str) - - def test_update_increments_count(self, recorder): - """Test that update increments the sample count.""" - assert recorder._n == 0 - recorder.update() - assert recorder._n == 1 - recorder.update() - assert recorder._n == 2 - def test_get_runtime_data_after_updates(self, recorder): """Test that get_runtime_data returns stats after updates.""" for _ in range(5): @@ -74,27 +43,10 @@ def test_get_runtime_data_after_updates(self, recorder): assert "std" in data["cpu_utilization"] assert "n" in data["cpu_utilization"] assert data["cpu_utilization"]["n"] == 5 - - def test_runtime_data_types(self, recorder): - """Test that runtime data has correct types.""" - for _ in range(3): - recorder.update() - - data = recorder.get_runtime_data() assert isinstance(data["cpu_utilization"]["mean"], float) assert isinstance(data["cpu_utilization"]["std"], float) assert isinstance(data["cpu_utilization"]["n"], int) - def test_get_data_returns_measurement_data(self, recorder): - """Test that get_data returns a MeasurementData object.""" - for _ in range(3): - recorder.update() - - data = recorder.get_data() - assert isinstance(data, MeasurementData) - assert len(data.measurements) == 3 - assert len(data.metadata) == 2 - def test_get_data_measurement_names(self, recorder): """Test that get_data returns measurements with correct names.""" for _ in range(3): @@ -105,14 +57,11 @@ def test_get_data_measurement_names(self, recorder): assert "CPU Utilization" in names assert "CPU Utilization std" in names assert "CPU Utilization n" in names - - def test_get_data_metadata_names(self, recorder): - """Test that get_data returns metadata with correct names.""" - recorder.update() - data = recorder.get_data() - names = [m.name for m in data.metadata] - assert "cpu_name" in names - assert "physical_cores" in names + assert len(data.measurements) == 3 + metadata = {m.name: m.data for m in data.metadata} + assert len(data.metadata) == 2 + assert isinstance(metadata["physical_cores"], int) and metadata["physical_cores"] > 0 + assert isinstance(metadata["cpu_name"], str) # ============================================================================== @@ -128,63 +77,6 @@ def recorder(self): """Create a GPUInfoRecorder fixture.""" return GPUInfoRecorder() - def test_initialization(self, recorder): - """Test that GPUInfoRecorder initializes correctly.""" - assert recorder._gpu_hardware_info is not None - assert recorder._gpu_runtime_info is not None - # These are now lists (one entry per GPU) - assert isinstance(recorder._mem_mean, list) - assert isinstance(recorder._mem_n, list) - assert isinstance(recorder._util_mean, list) - assert isinstance(recorder._util_n, list) - # No utilization peak tracking - assert not hasattr(recorder, "_util_peak") - - def test_get_initial_data_structure(self, recorder): - """Test that get_initial_data returns correct structure.""" - data = recorder.get_initial_data() - assert "gpu_metadata" in data - assert "available" in data["gpu_metadata"] - - def test_get_initial_data_with_gpu(self, recorder): - """Test hardware info when GPU is available.""" - data = recorder.get_initial_data() - if data["gpu_metadata"]["available"]: - assert "devices" in data["gpu_metadata"] - assert "device_count" in data["gpu_metadata"] - assert "current_device" in data["gpu_metadata"] - # Check first device has expected fields - assert len(data["gpu_metadata"]["devices"]) > 0 - device = data["gpu_metadata"]["devices"][0] - assert "name" in device - assert "total_memory_gb" in device - assert "compute_capability" in device - assert "multi_processor_count" in device - - def test_multiple_gpu_info(self, recorder): - """Test that all GPUs are recorded.""" - data = recorder.get_initial_data() - if not data["gpu_metadata"]["available"]: - pytest.skip("GPU not available") - - device_count = data["gpu_metadata"]["device_count"] - assert len(data["gpu_metadata"]["devices"]) == device_count - # Each device should have an index - for i, device in enumerate(data["gpu_metadata"]["devices"]): - assert device["index"] == i - - def test_update_increments_count(self, recorder): - """Test that update increments the sample count.""" - data = recorder.get_initial_data() - if not data["gpu_metadata"]["available"]: - pytest.skip("GPU not available") - - assert all(n == 0 for n in recorder._mem_n) - recorder.update() - assert all(n == 1 for n in recorder._mem_n) - recorder.update() - assert all(n == 2 for n in recorder._mem_n) - def test_get_runtime_data_after_updates(self, recorder): """Test that get_runtime_data returns stats after updates.""" data = recorder.get_initial_data() @@ -203,32 +95,9 @@ def test_get_runtime_data_after_updates(self, recorder): assert "memory_used_std_bytes" in device_runtime assert "memory_n" in device_runtime assert device_runtime["memory_n"] == 5 - - def test_runtime_data_types(self, recorder): - """Test that runtime data has correct types.""" - data = recorder.get_initial_data() - if not data["gpu_metadata"]["available"]: - pytest.skip("GPU not available") - - for _ in range(3): - recorder.update() - - runtime_data = recorder.get_runtime_data() - device_runtime = runtime_data["gpu_utilization"]["devices"][0] assert isinstance(device_runtime["memory_used_mean_bytes"], float) assert isinstance(device_runtime["memory_used_std_bytes"], float) assert isinstance(device_runtime["memory_n"], int) - - def test_memory_values_non_negative(self, recorder): - """Test that memory values are non-negative.""" - data = recorder.get_initial_data() - if not data["gpu_metadata"]["available"]: - pytest.skip("GPU not available") - - for _ in range(5): - recorder.update() - - runtime_data = recorder.get_runtime_data() for device_runtime in runtime_data["gpu_utilization"]["devices"]: assert device_runtime["memory_used_mean_bytes"] >= 0 assert device_runtime["memory_used_std_bytes"] >= 0 @@ -253,15 +122,6 @@ def test_get_data_returns_measurement_data(self, recorder): assert f"{prefix}Utilization peak" not in names # 4 metadata entries: device_count, current_device, cuda_version, gpu_devices dict assert len(measurement_data.metadata) == 4 - - def test_get_data_metadata_names(self, recorder): - """Test that get_data returns metadata with correct names.""" - data = recorder.get_initial_data() - if not data["gpu_metadata"]["available"]: - pytest.skip("GPU not available") - - recorder.update() - measurement_data = recorder.get_data() names = [m.name for m in measurement_data.metadata] # Global metadata assert "gpu_device_count" in names @@ -290,6 +150,7 @@ def test_get_data_devices_dict_structure(self, recorder): assert gpu_devices is not None device_count = data["gpu_metadata"]["device_count"] assert len(gpu_devices) == device_count + assert sorted(gpu_devices, key=int) == [str(i) for i in range(device_count)] # Check first device has expected hardware fields device_0 = gpu_devices["0"] @@ -298,33 +159,6 @@ def test_get_data_devices_dict_structure(self, recorder): assert "compute_capability" in device_0 assert "multi_processor_count" in device_0 - def test_mem_peak_is_zero_before_any_record(self, monkeypatch): - """Peak memory row for device 0 exists and is 0.0 before any update.""" - import torch - - from isaaclab.benchmark.recorders.record_gpu_info import GPUInfoRecorder - - monkeypatch.setattr(torch.cuda, "is_available", lambda: True) - monkeypatch.setattr(torch.cuda, "device_count", lambda: 1) - monkeypatch.setattr(torch.cuda, "current_device", lambda: 0) - - class _FakeProps: - name = "FakeGPU" - total_memory = 80 * 1024**3 - major = 9 - minor = 0 - multi_processor_count = 132 - - monkeypatch.setattr(torch.cuda, "get_device_properties", lambda i: _FakeProps()) - - rec = GPUInfoRecorder() - data = rec.get_data() - peaks = [m for m in data.measurements if "peak" in m.name.lower() and "GPU" in m.name] - # At minimum there should be a GPU memory peak row for device 0. - mem_peak_rows = [m for m in peaks if "Memory" in m.name] - assert mem_peak_rows, f"expected a GPU memory peak row, got names: {[m.name for m in data.measurements]}" - assert mem_peak_rows[0].value == 0.0 - def test_mem_peak_tracks_running_max(self, monkeypatch): """Feed the recorder a scripted memory sequence; peak must match the max.""" import torch @@ -345,6 +179,9 @@ class _FakeProps: monkeypatch.setattr(torch.cuda, "get_device_properties", lambda i: _FakeProps()) rec = GPUInfoRecorder() + mem_peak_rows = [m for m in rec.get_data().measurements if "Memory" in m.name and "peak" in m.name.lower()] + assert mem_peak_rows, "expected a GPU memory peak row before any update" + assert mem_peak_rows[0].value == 0.0 # Bypass nvml / nvidia-smi entirely and drive memory_allocated. scripted_mem = iter([10 * 1024**3, 50 * 1024**3, 30 * 1024**3]) # 10 GB, 50 GB, 30 GB @@ -375,40 +212,6 @@ def recorder(self): """Create a MemoryInfoRecorder fixture.""" return MemoryInfoRecorder() - def test_initialization(self, recorder): - """Test that MemoryInfoRecorder initializes correctly.""" - assert recorder._memory_hardware_info is not None - assert recorder._memory_runtime_info is not None - assert recorder._rss_mean == 0 - assert recorder._rss_n == 0 - assert recorder._vms_mean == 0 - assert recorder._vms_n == 0 - assert recorder._uss_mean == 0 - assert recorder._uss_n == 0 - - def test_get_initial_data_structure(self, recorder): - """Test that get_initial_data returns correct structure.""" - data = recorder.get_initial_data() - assert "memory_metadata" in data - assert "total_ram_gb" in data["memory_metadata"] - - def test_get_initial_data_values(self, recorder): - """Test that get_initial_data returns valid values.""" - data = recorder.get_initial_data() - assert isinstance(data["memory_metadata"]["total_ram_gb"], float) - assert data["memory_metadata"]["total_ram_gb"] > 0 - - def test_update_increments_count(self, recorder): - """Test that update increments the sample count.""" - assert recorder._rss_n == 0 - assert recorder._vms_n == 0 - recorder.update() - assert recorder._rss_n == 1 - assert recorder._vms_n == 1 - recorder.update() - assert recorder._rss_n == 2 - assert recorder._vms_n == 2 - def test_get_runtime_data_after_updates(self, recorder): """Test that get_runtime_data returns stats after updates.""" for _ in range(5): @@ -427,51 +230,17 @@ def test_get_runtime_data_after_updates(self, recorder): # Check counts assert data["memory_utilization"]["rss_n"] == 5 assert data["memory_utilization"]["vms_n"] == 5 - - def test_runtime_data_types(self, recorder): - """Test that runtime data has correct types.""" - for _ in range(3): - recorder.update() - - data = recorder.get_runtime_data() assert isinstance(data["memory_utilization"]["rss_mean"], float) assert isinstance(data["memory_utilization"]["rss_std"], float) assert isinstance(data["memory_utilization"]["rss_n"], int) assert isinstance(data["memory_utilization"]["vms_mean"], float) assert isinstance(data["memory_utilization"]["vms_std"], float) assert isinstance(data["memory_utilization"]["vms_n"], int) - - def test_memory_values_positive(self, recorder): - """Test that memory values are positive.""" - for _ in range(5): - recorder.update() - - data = recorder.get_runtime_data() assert data["memory_utilization"]["rss_mean"] > 0 assert data["memory_utilization"]["vms_mean"] > 0 - - def test_std_non_negative(self, recorder): - """Test that standard deviation values are non-negative.""" - for _ in range(5): - recorder.update() - - data = recorder.get_runtime_data() assert data["memory_utilization"]["rss_std"] >= 0 assert data["memory_utilization"]["vms_std"] >= 0 - def test_get_data_returns_measurement_data(self, recorder): - """Test that get_data returns a MeasurementData object.""" - for _ in range(3): - recorder.update() - - data = recorder.get_data() - assert isinstance(data, MeasurementData) - # 8 measurements for RSS and VMS (mean, std, peak, n for each) - # Plus potentially 4 more for USS if available (mean, std, peak, n) - assert len(data.measurements) >= 8 - assert len(data.measurements) <= 12 - assert len(data.metadata) == 1 - def test_get_data_measurement_names(self, recorder): """Test that get_data returns measurements with correct names.""" for _ in range(3): @@ -487,28 +256,14 @@ def test_get_data_measurement_names(self, recorder): assert "System Memory VMS" in names assert "System Memory VMS std" in names assert "System Memory VMS n" in names - # USS measurements may be present depending on platform - # We don't assert their presence since they're platform-dependent - - def test_get_data_metadata_names(self, recorder): - """Test that get_data returns metadata with correct names.""" - recorder.update() - data = recorder.get_data() - names = [m.name for m in data.metadata] - assert "total_ram_gb" in names - - def test_rss_peak_is_zero_before_any_record(self): - """Test that RSS peak is 0.0 before any update has been called.""" - from isaaclab.benchmark.recorders.record_memory_info import MemoryInfoRecorder - - rec = MemoryInfoRecorder() - data = rec.get_data() - peak_rows = [m for m in data.measurements if m.name == "System Memory RSS peak"] - assert peak_rows, "expected a 'System Memory RSS peak' SingleMeasurement" - assert peak_rows[0].value == 0.0 + # USS measurements may be present depending on platform (mean, std, peak, n) + assert 8 <= len(data.measurements) <= 12 + metadata = {m.name: m.data for m in data.metadata} + assert len(data.metadata) == 1 + assert isinstance(metadata["total_ram_gb"], float) and metadata["total_ram_gb"] > 0 def test_rss_peak_tracks_running_max(self, monkeypatch): - """Test that RSS peak tracks the running maximum across updates.""" + """Test that RSS peak tracks the running maximum and Welford stats match the samples.""" import psutil from isaaclab.benchmark.recorders.record_memory_info import MemoryInfoRecorder @@ -529,9 +284,22 @@ def _fake_memory_info(self): # noqa: ARG001 — bound method, self is the proce monkeypatch.setattr(psutil.Process, "memory_info", _fake_memory_info) rec = MemoryInfoRecorder() - for _ in scripted_values: + rss_peak = next(m for m in rec.get_data().measurements if m.name == "System Memory RSS peak") + assert rss_peak.value == 0.0 + + rec.update() + runtime = rec.get_runtime_data()["memory_utilization"] + # A single sample has no spread. + assert runtime["rss_std"] == 0 + assert runtime["vms_std"] == 0 + for _ in scripted_values[1:]: rec.update() + # Mean of 100/200/150 GiB is 150 GiB; the sample standard deviation is 50 GiB. + runtime = rec.get_runtime_data()["memory_utilization"] + assert runtime["rss_mean"] == pytest.approx(150 * 1024**3) + assert runtime["rss_std"] == pytest.approx(50 * 1024**3) + data = rec.get_data() rss_peak = next(m for m in data.measurements if m.name == "System Memory RSS peak") # The recorder emits GB; input was in bytes. 200 GiB -> 200.0 after rounding. @@ -540,34 +308,6 @@ def _fake_memory_info(self): # noqa: ARG001 — bound method, self is the proce vms_peak = next(m for m in data.measurements if m.name == "System Memory VMS peak") assert vms_peak.value == 200.0 - def test_rss_peak_does_not_decrease(self, monkeypatch): - """Test that RSS peak does not decrease when memory usage drops.""" - import psutil - - from isaaclab.benchmark.recorders.record_memory_info import MemoryInfoRecorder - - # Decreasing sequence — peak is set by the first sample and then stays. - scripted_values = [300 * 1024**3, 50 * 1024**3, 25 * 1024**3] - scripted_iter = iter(scripted_values) - - class _FakeMemInfo: - def __init__(self, rss): - self.rss = rss - self.vms = rss - - def _fake_memory_info(self): # noqa: ARG001 - return _FakeMemInfo(next(scripted_iter)) - - monkeypatch.setattr(psutil.Process, "memory_info", _fake_memory_info) - - rec = MemoryInfoRecorder() - for _ in scripted_values: - rec.update() - - data = rec.get_data() - rss_peak = next(m for m in data.measurements if m.name == "System Memory RSS peak") - assert rss_peak.value == 300.0 - # ============================================================================== # VersionInfoRecorder Tests @@ -582,37 +322,21 @@ def recorder(self): """Create a VersionInfoRecorder fixture.""" return VersionInfoRecorder() - def test_initialization(self, recorder): - """Test that VersionInfoRecorder initializes correctly.""" - assert recorder._version_info is not None - assert recorder._dev_info is not None - - def test_get_initial_data_structure(self, recorder): - """Test that get_initial_data returns correct structure.""" - data = recorder.get_initial_data() - assert "version_metadata" in data - assert "dev" in data - - def test_captures_core_versions(self, recorder): - """Test that core package versions are captured.""" - data = recorder.get_initial_data() - versions = data["version_metadata"] - # These should always be available in the test environment - assert "torch" in versions - assert "numpy" in versions - assert "isaaclab" in versions - - def test_captures_renderer_runtime_versions(self, monkeypatch): - versions_by_distribution = {"ovrtx": "0.3.1", "ovphysx": "0.5.9"} + @pytest.mark.parametrize("versions_by_distribution", [{"ovrtx": "0.3.1", "ovphysx": "0.5.9"}, {}]) + def test_captures_renderer_runtime_versions(self, monkeypatch, versions_by_distribution): + """Renderer runtime versions are recorded when installed and null otherwise.""" monkeypatch.setattr( VersionInfoRecorder, "_get_pkg_version", lambda _self, distribution: versions_by_distribution.get(distribution), ) - versions = VersionInfoRecorder().get_initial_data()["version_metadata"] - assert versions["ovrtx"] == "0.3.1" - assert versions["ovphysx"] == "0.5.9" + recorder = VersionInfoRecorder() + versions = recorder.get_initial_data()["version_metadata"] + metadata = {entry.name: entry.data for entry in recorder.get_data().metadata} + for distribution in ("ovrtx", "ovphysx"): + assert versions[distribution] == versions_by_distribution.get(distribution) + assert metadata[f"{distribution}_version"] == versions_by_distribution.get(distribution) def test_captures_active_kit_versions(self, monkeypatch, tmp_path): """Test that versions are captured from an active Kit runtime.""" @@ -711,19 +435,6 @@ def import_without_kit(name, globals=None, locals=None, fromlist=(), level=0): assert versions["kit"] is None assert versions["isaacsim"] is None - def test_records_null_renderer_runtime_versions_when_unavailable(self, monkeypatch): - """Test that unavailable renderer runtime versions are null.""" - monkeypatch.setattr(VersionInfoRecorder, "_get_pkg_version", lambda _self, _distribution: None) - - recorder = VersionInfoRecorder() - versions = recorder.get_initial_data()["version_metadata"] - metadata = {entry.name: entry.data for entry in recorder.get_data().metadata} - - assert versions["ovrtx"] is None - assert versions["ovphysx"] is None - assert metadata["ovrtx_version"] is None - assert metadata["ovphysx_version"] is None - def test_version_values_are_strings_or_null(self, recorder): """Test that version values are strings or null for runtime packages.""" data = recorder.get_initial_data() @@ -735,20 +446,12 @@ def test_version_values_are_strings_or_null(self, recorder): assert isinstance(version, str) assert len(version) > 0 - def test_git_info_structure(self, recorder): - """Test that git info has expected fields when available.""" - data = recorder.get_initial_data() - dev = data["dev"] - # If git info is available, check structure - if dev: - # At least one of these should be present if git is available - possible_keys = ["commit_hash", "commit_hash_short", "branch", "commit_date", "dirty"] - assert any(key in dev for key in possible_keys) - def test_commit_hash_format(self, recorder): - """Test that commit hash has correct format when available.""" + """Test that git info carries the fields capture reads, with a well-formed commit hash.""" data = recorder.get_initial_data() dev = data["dev"] + if dev: + assert {"commit_hash", "branch", "dirty"} <= dev.keys() if "commit_hash" in dev: # Full hash should be 40 hex characters assert len(dev["commit_hash"]) == 40 @@ -757,30 +460,10 @@ def test_commit_hash_format(self, recorder): # Short hash should be 8 characters assert len(dev["commit_hash_short"]) == 8 - def test_update_is_noop(self, recorder): - """Test that update doesn't change anything.""" - data_before = recorder.get_initial_data() - recorder.update() - data_after = recorder.get_initial_data() - assert data_before == data_after - - def test_get_runtime_data_is_empty(self, recorder): - """Test that runtime data is empty (versions don't change).""" - data = recorder.get_runtime_data() - assert data == {} - - def test_get_data_returns_measurement_data(self, recorder): - """Test that get_data returns a MeasurementData object.""" - data = recorder.get_data() - assert isinstance(data, MeasurementData) - # No measurements, only metadata - assert len(data.measurements) == 0 - # Should have metadata for versions + dev info - assert len(data.metadata) > 0 - def test_get_data_metadata_names(self, recorder): """Test that get_data returns metadata with version names.""" data = recorder.get_data() + assert len(data.measurements) == 0 names = [m.name for m in data.metadata] # Check that version suffixes are present assert "torch_version" in names @@ -793,38 +476,3 @@ def test_get_data_metadata_names(self, recorder): dev_meta = next(m for m in data.metadata if m.name == "dev") assert hasattr(dev_meta, "data") assert isinstance(dev_meta.data, dict) - - -# ============================================================================== -# Welford's Algorithm Verification Tests -# ============================================================================== - - -class TestWelfordAlgorithm: - """Tests to verify Welford's algorithm implementation in recorders.""" - - def test_memory_recorder_welford_convergence(self): - """Test that MemoryInfoRecorder's Welford implementation produces stable results.""" - recorder = MemoryInfoRecorder() - - # Run many updates - for _ in range(100): - recorder.update() - - data = recorder.get_runtime_data() - - # Mean should be positive (process is using memory) - assert data["memory_utilization"]["rss_mean"] > 0 - - # Std should be defined after multiple samples - assert data["memory_utilization"]["rss_n"] == 100 - - def test_single_update_std_is_zero(self): - """Test that std is zero after a single update (no variance with one sample).""" - recorder = MemoryInfoRecorder() - recorder.update() - - data = recorder.get_runtime_data() - # With n=1, std should be 0 (or undefined, but we initialize to 0) - assert data["memory_utilization"]["rss_std"] == 0 - assert data["memory_utilization"]["vms_std"] == 0 diff --git a/source/isaaclab/test/benchmark/test_schema.py b/source/isaaclab/test/benchmark/test_schema.py index 95688ce24bf2..60815c4bb1d3 100644 --- a/source/isaaclab/test/benchmark/test_schema.py +++ b/source/isaaclab/test/benchmark/test_schema.py @@ -223,29 +223,10 @@ def test_runtime_bundle_round_trip(tmp_path): assert data["run"]["framework"] is None assert data["run"]["max_iterations"] is None assert "learning" not in data + assert data["extra"] is None assert data["resources"]["gpu_mem_gb"]["peak"] == pytest.approx(19.2) -def test_training_bundle_without_series(tmp_path): - bundle = dataclasses.replace( - _minimal_training_bundle(), - learning=Learning( - ema_alpha=0.05, - reward=LearningCurve(final_raw=1.0, final_ema=1.0, series_per_iter=None), - ep_length=LearningCurve(final_raw=1.0, final_ema=1.0, series_per_iter=None), - success_rate=LearningCurve(final_raw=0.8, final_ema=0.7, series_per_iter=None), - ), - ) - path = os.path.join(tmp_path, "training.json") - write_bundle_file(bundle, path) - with open(path) as f: - data = json.load(f) - assert data["learning"]["reward"]["series_per_iter"] is None - assert data["learning"]["ep_length"]["series_per_iter"] is None - assert data["learning"]["success_rate"]["series_per_iter"] is None - assert data["learning"]["success_rate"]["final_raw"] == pytest.approx(0.8) - - def test_startup_bundle_reuses_run_identity(tmp_path): """StartupBundle reuses RunIdentity with num_envs/max_iterations/framework unset.""" bundle = StartupBundle( @@ -280,12 +261,12 @@ def test_startup_bundle_reuses_run_identity(tmp_path): def test_mean_std_rejects_peak_below_mean(): - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="peak"): MeanStd(mean=10.0, std=1.0, peak=5.0) def test_run_identity_rejects_negative_duration(): - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="duration_s"): RunIdentity( run_id="x", framework=None, @@ -334,33 +315,6 @@ def _boom(*a, **k): assert not os.path.exists(path + ".tmp") -def test_extra_field_round_trips(tmp_path): - """The free-form `extra` mapping round-trips with scalar values; defaults to None.""" - bundle = dataclasses.replace( - _minimal_training_bundle(), - extra={"grad_norm": 0.42, "note": "warmup", "stable": True, "restarts": 2}, - ) - path = os.path.join(tmp_path, "training.json") - write_bundle_file(bundle, path) - with open(path) as f: - data = json.load(f) - assert data["extra"] == {"grad_norm": 0.42, "note": "warmup", "stable": True, "restarts": 2} - - # default is None and serialises to JSON null - rt = RuntimeBundle( - run=_run_identity(framework=None, max_iterations=None), - versions=_versions(), - hardware=_hardware(), - runtime=_runtime(), - resources=_resources(), - ) - path2 = os.path.join(tmp_path, "runtime.json") - write_bundle_file(rt, path2) - with open(path2) as f: - data2 = json.load(f) - assert data2["extra"] is None - - def test_run_config_presets_round_trip(tmp_path): """RunConfig.presets is an open-ended token list; defaults to [] and round-trips.""" assert RunConfig(physics_backend="physx").presets == [] diff --git a/source/isaaclab/test/benchmark/test_sensor_benchmark_cli.py b/source/isaaclab/test/benchmark/test_sensor_benchmark_cli.py deleted file mode 100644 index 3348ceef344e..000000000000 --- a/source/isaaclab/test/benchmark/test_sensor_benchmark_cli.py +++ /dev/null @@ -1,38 +0,0 @@ -# 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 sensor micro-benchmark argument contracts.""" - -import subprocess -import sys -from pathlib import Path - -import pytest - -_REPOSITORY_ROOT = Path(__file__).parents[4] -_BACKENDS = ("isaaclab_physx", "isaaclab_newton", "isaaclab_ov") - - -def _script_args(backend: str, script: str, *args: str) -> list[str]: - command = [ - sys.executable, - str(_REPOSITORY_ROOT / "source" / backend / "benchmark" / "sensors" / f"benchmark_{script}.py"), - ] - command.extend(args) - return command - - -@pytest.mark.parametrize("backend", _BACKENDS) -def test_ray_caster_entrypoint_rejects_unknown_terrain_workload(backend: str) -> None: - """An unknown terrain workload should fail cleanly before simulator startup.""" - result = subprocess.run( - _script_args(backend, "ray_caster", "--terrain", "unknown"), - capture_output=True, - check=False, - text=True, - ) - - assert result.returncode == 2 - assert "invalid choice" in result.stderr diff --git a/source/isaaclab/test/benchmark/test_stepping.py b/source/isaaclab/test/benchmark/test_stepping.py index 364d361140c8..a3421b7d6f76 100644 --- a/source/isaaclab/test/benchmark/test_stepping.py +++ b/source/isaaclab/test/benchmark/test_stepping.py @@ -198,7 +198,7 @@ def test_run_runtime_loop_can_skip_reset(): assert not env.reset_called and env.steps == 2 -@pytest.mark.parametrize("num_steps", [0, 1, 50]) +@pytest.mark.parametrize("num_steps", [0, 1]) def test_run_runtime_warmup_runs_exact_requested_steps(num_steps: int): env = _Env() @@ -212,10 +212,12 @@ def test_run_runtime_warmup_runs_exact_requested_steps(num_steps: int): def test_environment_step_timer_measures_env_step_without_simulation_timing(): env = _Env() - with EnvironmentStepTimingRecorder(env) as timer: - run_runtime_loop(env, num_steps=2, reset=False) + with EnvironmentStepTimingRecorder(env, warmup_steps=2) as timer: + run_runtime_loop(env, num_steps=5, reset=False) - assert len(timer.step_times_s) == 2 + # All five steps run, but the first two are excluded from the recorded timings. + assert env.steps == 5 + assert len(timer.step_times_s) == 3 assert timer.simulation_step_times_s is None assert timer.simulation_step_calls is None assert "step" not in vars(env) @@ -238,17 +240,6 @@ def test_environment_step_timer_measures_only_step_calls(): assert "step" not in vars(env.unwrapped.sim) -def test_environment_step_timer_excludes_warmup_steps_host_return(): - env = _Env() - - with EnvironmentStepTimingRecorder(env, warmup_steps=2) as timer: - run_runtime_loop(env, num_steps=5, reset=False) - - # All five steps run, but the first two are excluded from the recorded timings. - assert env.steps == 5 - assert len(timer.step_times_s) == 3 - - def test_environment_step_timer_warmup_keeps_simulation_accounting_consistent(): env = _Env() recorder = EnvironmentStepTimingRecorder(env, measure_synchronized_step_breakdown=True, warmup_steps=1) @@ -270,35 +261,6 @@ def test_environment_step_timer_warmup_keeps_simulation_accounting_consistent(): assert recorder.simulation_step_calls == 6 -def test_environment_step_timer_synchronizes_before_environment_and_simulation_steps(monkeypatch): - import warp as wp - - events = [] - env = _Env() - env_step = env.step - simulation_step = env.unwrapped.sim.step - - def logged_environment_step(actions): - events.append("environment") - return env_step(actions) - - def logged_simulation_step(): - events.append("simulation") - return simulation_step() - - env.step = logged_environment_step - env.unwrapped.sim.step = logged_simulation_step - monkeypatch.setattr(wp, "synchronize", lambda: events.append("synchronize")) - - with EnvironmentStepTimingRecorder(env, measure_synchronized_step_breakdown=True): - env.step(None) - - assert events[:2] == ["synchronize", "environment"] - simulation_indices = [index for index, event in enumerate(events) if event == "simulation"] - assert simulation_indices - assert all(events[index - 1] == "synchronize" for index in simulation_indices) - - def test_environment_step_timer_excludes_pending_work_before_step(monkeypatch): import warp as wp diff --git a/source/isaaclab/test/benchmark/test_train_checkpoint_path.py b/source/isaaclab/test/benchmark/test_train_checkpoint_path.py index 5447c133c2d3..1e4a87656037 100644 --- a/source/isaaclab/test/benchmark/test_train_checkpoint_path.py +++ b/source/isaaclab/test/benchmark/test_train_checkpoint_path.py @@ -32,17 +32,6 @@ def test_resolve_training_checkpoint_path_matches_backend_filename( assert training._resolve_training_checkpoint_path(str(run_dir), backend) == str(checkpoint) -def test_resolve_training_checkpoint_path_uses_natural_order(tmp_path: Path) -> None: - run_dir = tmp_path / "run" - checkpoint_dir = run_dir / "nn" - checkpoint_dir.mkdir(parents=True) - (checkpoint_dir / "last_Cartpole_ep_9.pth").touch() - checkpoint = checkpoint_dir / "last_Cartpole_ep_10.pth" - checkpoint.touch() - - assert training._resolve_training_checkpoint_path(str(run_dir), "rl_games") == str(checkpoint) - - def test_resolve_training_checkpoint_path_returns_none_without_checkpoint(tmp_path: Path) -> None: run_dir = tmp_path / "run" run_dir.mkdir() diff --git a/source/isaaclab/test/cli/test_benchmark_entrypoint.py b/source/isaaclab/test/cli/test_benchmark_entrypoint.py deleted file mode 100644 index b013e69cbd0b..000000000000 --- a/source/isaaclab/test/cli/test_benchmark_entrypoint.py +++ /dev/null @@ -1,36 +0,0 @@ -# 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 the unified benchmark console entry point.""" - -import sys -from unittest import mock - -import pytest - -import isaaclab.cli as cli - - -@pytest.mark.parametrize("command", ["runtime", "startup", "training", "play"]) -def test_benchmark_dispatches_in_process(command): - """Test that ``isaaclab benchmark`` forwards arguments to the library dispatcher.""" - args = [command, "--help"] - - with ( - mock.patch.object(sys, "argv", ["isaaclab", "benchmark", *args]), - mock.patch("isaaclab.benchmark.run_benchmark_cli", return_value=0) as run_benchmark, - ): - cli.cli() - - run_benchmark.assert_called_once_with(args) - - -def test_benchmark_propagates_nonzero_dispatch_status(): - """Test that a benchmark dispatcher failure becomes the CLI exit status.""" - with mock.patch("isaaclab.benchmark.run_benchmark_cli", return_value=2): - with pytest.raises(SystemExit) as exc_info: - cli.benchmark(["training"]) - - assert exc_info.value.code == 2 diff --git a/source/isaaclab/test/cli/test_install.py b/source/isaaclab/test/cli/test_install.py index 08f64e7ebc55..3c42873f39b8 100644 --- a/source/isaaclab/test/cli/test_install.py +++ b/source/isaaclab/test/cli/test_install.py @@ -172,21 +172,6 @@ def test_run_python_command_rejects_downloaded_isaac_sim_with_virtual_environmen class TestGetPipCommand: """Tests for :func:`get_pip_command`.""" - def test_returns_uv_pip_in_venv_without_pip_module(self, tmp_path): - """When VIRTUAL_ENV is set, uv is on PATH, and pip module is missing, return uv pip.""" - fake_python = str(tmp_path / "python") - - with ( - mock.patch.dict(os.environ, {"VIRTUAL_ENV": str(tmp_path)}), - mock.patch("isaaclab.cli.utils.shutil.which", return_value="/usr/bin/uv"), - mock.patch( - "isaaclab.cli.utils.subprocess.run", - return_value=subprocess.CompletedProcess(args=[], returncode=1), - ), - ): - result = get_pip_command(python_exe=fake_python) - assert result == ["uv", "pip"] - def test_returns_uv_pip_in_venv_with_uv(self, tmp_path): """When VIRTUAL_ENV is set and uv is on PATH, always return uv pip.""" fake_python = str(tmp_path / "python") @@ -209,20 +194,6 @@ def test_returns_python_pip_without_uv(self, tmp_path): result = get_pip_command(python_exe=fake_python) assert result == [fake_python, "-m", "pip"] - def test_returns_python_pip_in_conda_without_uv(self, tmp_path): - """When in a conda env and uv is not available, return python -m pip.""" - fake_python = str(tmp_path / "python") - - env = os.environ.copy() - env.pop("VIRTUAL_ENV", None) - env["CONDA_PREFIX"] = str(tmp_path) - with ( - mock.patch.dict(os.environ, env, clear=True), - mock.patch("isaaclab.cli.utils.shutil.which", return_value=None), - ): - result = get_pip_command(python_exe=fake_python) - assert result == [fake_python, "-m", "pip"] - # --------------------------------------------------------------------------- # extract_python_exe diff --git a/source/isaaclab/test/cli/test_install_command_parsing.py b/source/isaaclab/test/cli/test_install_command_parsing.py index aef4d22766e5..03e8e8487e56 100644 --- a/source/isaaclab/test/cli/test_install_command_parsing.py +++ b/source/isaaclab/test/cli/test_install_command_parsing.py @@ -19,9 +19,7 @@ from isaaclab.cli.commands.install import ( CORE_ISAACLAB_SUBMODULES, - MANUAL_EXTRA_FEATURES, OPTIONAL_ISAACLAB_SUBMODULES, - VALID_EXTRA_FEATURES, _install_extra_feature, _install_ov_extra_dependencies, command_install, @@ -41,58 +39,24 @@ def _optional_submodule_packages() -> list[str]: # --------------------------------------------------------------------------- -class TestSplitInstallItems: - """Tests for split_install_items().""" - - def test_single_token(self): - assert split_install_items("newton") == ["newton"] - - def test_two_plain_tokens(self): - assert split_install_items("newton,mimic") == ["newton", "mimic"] - - def test_token_with_selector(self): - assert split_install_items("rl[rsl-rl]") == ["rl[rsl-rl]"] - - def test_comma_inside_brackets_not_split(self): - assert split_install_items("rl[rsl-rl,skrl]") == ["rl[rsl-rl,skrl]"] - - def test_mixed_tokens(self): - result = split_install_items("newton,rl[rsl-rl],mimic") - assert result == ["newton", "rl[rsl-rl]", "mimic"] - - def test_whitespace_stripped(self): - assert split_install_items("newton , mimic") == ["newton", "mimic"] - - def test_empty_string(self): - assert split_install_items("") == [] - - def test_all_special_value(self): - assert split_install_items("all") == ["all"] - - def test_core_special_value(self): - assert split_install_items("core") == ["core"] - - def test_none_special_value_alias(self): - # back-compat: "none" is the old name for "core" - assert split_install_items("none") == ["none"] - - def test_visualizer_with_selector(self): - assert split_install_items("visualizer[rerun]") == ["visualizer[rerun]"] - - def test_multiple_selectors_mixed(self): - result = split_install_items("mimic,visualizer[rerun],rl[rsl-rl]") - assert result == ["mimic", "visualizer[rerun]", "rl[rsl-rl]"] - - def test_nested_brackets_depth(self): - # Depth > 1 should not split on commas. - result = split_install_items("contrib[a[b,c]]") - assert result == ["contrib[a[b,c]]"] - - def test_missing_closing_bracket_not_split(self): - # A malformed token with no closing ']' should come through as one item; - # the install dispatcher is responsible for emitting the warning. - result = split_install_items("rl[rsl-rl") - assert result == ["rl[rsl-rl"] +@pytest.mark.parametrize( + ("install_type", "expected"), + [ + ("newton", ["newton"]), + (" assets , tasks , rl ", ["assets", "tasks", "rl"]), + # Commas inside brackets do not split; the outer comma does. + ("visualizers[rerun,newton],tasks", ["visualizers[rerun,newton]", "tasks"]), + # Depth > 1 does not split, and splitting resumes after the nested bracket closes. + ("a[b[c,d],e],f", ["a[b[c,d],e]", "f"]), + ("", []), + ("assets,tasks,", ["assets", "tasks"]), + # A malformed token with no closing ']' comes through as one item; the + # install dispatcher is responsible for emitting the warning. + ("rl[rsl-rl", ["rl[rsl-rl"]), + ], +) +def test_split_install_items(install_type, expected): + assert split_install_items(install_type) == expected # --------------------------------------------------------------------------- @@ -103,45 +67,17 @@ def test_missing_closing_bracket_not_split(self): class TestInstallConstants: """Sanity checks for module-level install constants.""" - def test_core_submodules_starts_with_isaaclab(self): + def test_submodule_tables_cover_every_source_package(self, source_checkout_root): + source_packages = { + path.parent.name for path in (source_checkout_root / "source").glob("isaaclab*/pyproject.toml") + } + optional_packages = set(_optional_submodule_packages()) assert CORE_ISAACLAB_SUBMODULES[0] == "isaaclab", ( "isaaclab must be first so dependents resolve against the local copy" ) - - def test_core_submodules_contains_expected_packages(self): - expected = { - "isaaclab", - "isaaclab_ppisp", - "isaaclab_assets", - "isaaclab_contrib", - "isaaclab_experimental", - "isaaclab_newton", - "isaaclab_ov", - "isaaclab_physx", - "isaaclab_rl", - "isaaclab_tasks", - "isaaclab_tasks_experimental", - "isaaclab_visualizers", - } - assert set(CORE_ISAACLAB_SUBMODULES) == expected - - def test_optional_submodules_contains_expected_packages(self): - assert set(OPTIONAL_ISAACLAB_SUBMODULES.keys()) == {"mimic", "teleop"} - assert OPTIONAL_ISAACLAB_SUBMODULES["mimic"] == ("isaaclab_teleop", "isaaclab_mimic") - assert OPTIONAL_ISAACLAB_SUBMODULES["teleop"] == ("isaaclab_teleop",) - - def test_valid_extra_features(self): - expected = {"contrib", "newton", "ov", "rl", "tetrahedralization", "visualizer"} - assert expected == VALID_EXTRA_FEATURES - - def test_manual_extra_features_subset_of_valid(self): - assert MANUAL_EXTRA_FEATURES <= VALID_EXTRA_FEATURES - - def test_manual_extra_features(self): - assert {"contrib", "ov", "tetrahedralization"} == MANUAL_EXTRA_FEATURES - - def test_no_overlap_between_optional_submodules_and_extra_features(self): - assert not (set(OPTIONAL_ISAACLAB_SUBMODULES.keys()) & VALID_EXTRA_FEATURES) + assert set(CORE_ISAACLAB_SUBMODULES) | optional_packages == source_packages + assert set(OPTIONAL_ISAACLAB_SUBMODULES) == {"mimic", "teleop"} + assert "isaaclab_teleop" in OPTIONAL_ISAACLAB_SUBMODULES["mimic"] def test_optional_submodules_not_in_core(self): core_names = set(CORE_ISAACLAB_SUBMODULES) @@ -185,6 +121,11 @@ def test_ov_selector_installs_matching_root_extra(selector, expected_extra): f"{_INSTALL_MODULE}._maybe_uninstall_prebundled_torch", f"{_INSTALL_MODULE}._ensure_pink_ik_dependencies_installed", f"{_INSTALL_MODULE}._repoint_prebundle_packages", + # Prebundle integrity checks probe for Isaac Sim (subprocesses) and scan the host's real OV cache. + f"{_INSTALL_MODULE}._find_dangling_prebundle_symlinks", + f"{_INSTALL_MODULE}._assert_no_new_dangling_prebundle_symlinks", + f"{_INSTALL_MODULE}._ensure_newton", + f"{_INSTALL_MODULE}._install_centralized_dependencies", f"{_INSTALL_MODULE}.command_editor", f"{_INSTALL_MODULE}.get_pip_command", f"{_INSTALL_MODULE}.extract_python_exe", @@ -229,41 +170,22 @@ def _run(self, install_type: str): return mocks - def test_wraps_dependency_installs_with_arm_cmake_compatibility(self): - mocks = self._run("all") - mocks["_arm_cmake_policy_compatibility"].assert_called_once_with() - # --- "all" --- def test_all_installs_core_plus_optional_submodules(self): mocks = self._run("all") + mocks["_arm_cmake_policy_compatibility"].assert_called_once_with() installed = mocks["_install_isaaclab_submodules"].call_args[0][0] + assert installed[0] == "isaaclab" # Core set must be present. for pkg in CORE_ISAACLAB_SUBMODULES: assert pkg in installed, f"Expected {pkg} in submodules for 'all'" # Optional submodules must be present. for pkg in _optional_submodule_packages(): assert pkg in installed, f"Expected {pkg} (optional) in submodules for 'all'" - - def test_all_installs_auto_extra_features_not_manual(self): - mocks = self._run("all") + # Only the automatic extra features; manual ones (contrib, ov, tetrahedralization) are opt-in. called_features = {c.args[0] for c in mocks["_install_extra_feature"].call_args_list} - expected = VALID_EXTRA_FEATURES - MANUAL_EXTRA_FEATURES - assert called_features == expected, f"'all' should install {expected}, got {called_features}" - - def test_all_does_not_install_optional_submodule_extras(self): - mocks = self._run("all") - mocks["_install_optional_submodule_extra_dependencies"].assert_not_called() - - def test_all_does_not_install_manual_extra_dependencies(self): - mocks = self._run("all") - called_features = {c.args[0] for c in mocks["_install_extra_feature"].call_args_list} - assert "contrib" not in called_features - assert "tetrahedralization" not in called_features - assert "ov" not in called_features - - def test_all_does_not_call_install_isaacsim(self): - mocks = self._run("all") + assert called_features == {"newton", "rl", "visualizer"} mocks["_install_isaacsim"].assert_not_called() # --- "core" --- @@ -272,17 +194,8 @@ def test_core_installs_only_core_submodules(self): mocks = self._run("core") installed = mocks["_install_isaaclab_submodules"].call_args[0][0] assert set(installed) == set(CORE_ISAACLAB_SUBMODULES) - - def test_core_installs_no_extra_features(self): - mocks = self._run("core") mocks["_install_extra_feature"].assert_not_called() - def test_core_does_not_install_optional_submodules(self): - mocks = self._run("core") - installed = mocks["_install_isaaclab_submodules"].call_args[0][0] - for pkg in _optional_submodule_packages(): - assert pkg not in installed - def test_none_is_alias_for_core(self): # back-compat: "none" is the old name for "core" mocks_none = self._run("none") @@ -294,27 +207,22 @@ def test_none_is_alias_for_core(self): # --- extra features --- - def test_newton_installs_core_plus_newton_extra(self): - mocks = self._run("newton") + @pytest.mark.parametrize( + ("install_type", "feature", "selector"), + [ + ("newton", "newton", ""), + ("rl[rsl-rl]", "rl", "rsl-rl"), + ("visualizer[rerun]", "visualizer", "rerun"), + ("tetrahedralization", "tetrahedralization", ""), + ("contrib", "contrib", ""), + ("ov", "ov", ""), + ], + ) + def test_extra_feature_installs_core_plus_feature(self, install_type, feature, selector): + mocks = self._run(install_type) installed = mocks["_install_isaaclab_submodules"].call_args[0][0] assert set(installed) == set(CORE_ISAACLAB_SUBMODULES) - mocks["_install_extra_feature"].assert_called_once_with("newton", "") - - def test_rl_with_selector(self): - mocks = self._run("rl[rsl-rl]") - mocks["_install_extra_feature"].assert_called_once_with("rl", "rsl-rl") - - def test_rl_without_selector(self): - mocks = self._run("rl") - mocks["_install_extra_feature"].assert_called_once_with("rl", "") - - def test_visualizer_with_selector(self): - mocks = self._run("visualizer[rerun]") - mocks["_install_extra_feature"].assert_called_once_with("visualizer", "rerun") - - def test_tetrahedralization_dispatches_manual_extra_feature(self): - mocks = self._run("tetrahedralization") - mocks["_install_extra_feature"].assert_called_once_with("tetrahedralization", "") + mocks["_install_extra_feature"].assert_called_once_with(feature, selector) def test_tetrahedralization_installs_root_extra(self): with patch(f"{_INSTALL_MODULE}._install_root_extra") as install_root_extra: @@ -322,21 +230,7 @@ def test_tetrahedralization_installs_root_extra(self): install_root_extra.assert_called_once_with("tetrahedralization") - # --- manual extra features and optional submodules --- - - def test_contrib_without_selector_dispatches_manual_extra_feature(self): - mocks = self._run("contrib") - installed = mocks["_install_isaaclab_submodules"].call_args[0][0] - assert set(installed) == set(CORE_ISAACLAB_SUBMODULES) - mocks["_install_extra_feature"].assert_called_once_with("contrib", "") - mocks["_install_optional_submodule_extra_dependencies"].assert_not_called() - - def test_contrib_with_selector_dispatches_manual_extra_feature(self): - mocks = self._run("contrib[rlinf]") - installed = mocks["_install_isaaclab_submodules"].call_args[0][0] - assert set(installed) == set(CORE_ISAACLAB_SUBMODULES) - mocks["_install_extra_feature"].assert_called_once_with("contrib", "rlinf") - mocks["_install_optional_submodule_extra_dependencies"].assert_not_called() + # --- optional submodules --- def test_mimic_adds_mimic_to_submodules(self): mocks = self._run("mimic") @@ -345,23 +239,6 @@ def test_mimic_adds_mimic_to_submodules(self): mocks["_install_extra_feature"].assert_not_called() mocks["_install_optional_submodule_extra_dependencies"].assert_not_called() - def test_ov_without_selector_dispatches_manual_extra_feature(self): - mocks = self._run("ov") - mocks["_install_extra_feature"].assert_called_once_with("ov", "") - mocks["_install_optional_submodule_extra_dependencies"].assert_not_called() - - def test_ov_with_selector_dispatches_manual_extra_feature(self): - mocks = self._run("ov[ovrtx]") - mocks["_install_extra_feature"].assert_called_once_with("ov", "ovrtx") - mocks["_install_optional_submodule_extra_dependencies"].assert_not_called() - - def test_teleop_adds_teleop_to_submodules(self): - mocks = self._run("teleop") - installed = mocks["_install_isaaclab_submodules"].call_args[0][0] - assert "isaaclab_teleop" in installed - mocks["_install_extra_feature"].assert_not_called() - mocks["_install_optional_submodule_extra_dependencies"].assert_not_called() - # --- combined tokens --- def test_newton_and_rl_rsl_rl(self): @@ -371,17 +248,19 @@ def test_newton_and_rl_rsl_rl(self): assert ("newton", "") in features assert ("rl", "rsl-rl") in features - def test_mimic_and_newton(self): - mocks = self._run("mimic,newton") + def test_teleop_alone(self): + mocks = self._run("teleop") installed = mocks["_install_isaaclab_submodules"].call_args[0][0] - assert "isaaclab_mimic" in installed - mocks["_install_extra_feature"].assert_called_once_with("newton", "") + assert "isaaclab_teleop" in installed + assert "isaaclab_mimic" not in installed + mocks["_install_extra_feature"].assert_not_called() def test_mimic_and_teleop(self): mocks = self._run("mimic,teleop") installed = mocks["_install_isaaclab_submodules"].call_args[0][0] assert "isaaclab_mimic" in installed - assert "isaaclab_teleop" in installed + # Both submodules pull isaaclab_teleop; it must be installed once. + assert installed.count("isaaclab_teleop") == 1 mocks["_install_extra_feature"].assert_not_called() # --- isaacsim token --- @@ -389,9 +268,6 @@ def test_mimic_and_teleop(self): def test_isaacsim_token_triggers_isaacsim_install(self): mocks = self._run("isaacsim") mocks["_install_isaacsim"].assert_called_once() - - def test_isaacsim_still_installs_core_submodules(self): - mocks = self._run("isaacsim") installed = mocks["_install_isaaclab_submodules"].call_args[0][0] assert set(installed) == set(CORE_ISAACLAB_SUBMODULES) @@ -409,12 +285,6 @@ def test_malformed_bracket_token_emits_warning_and_installs_core(self): # No extra feature should be installed. mocks["_install_extra_feature"].assert_not_called() - def test_newton_with_selector_still_dispatches(self): - # The selector is forwarded to _install_extra_feature which emits the warning - # internally (that function is mocked here; the warning itself is tested separately). - mocks = self._run("newton[sim]") - mocks["_install_extra_feature"].assert_called_once_with("newton", "sim") - # --- unknown token --- def test_unknown_token_emits_warning_and_installs_core(self): @@ -426,20 +296,3 @@ def test_unknown_token_emits_warning_and_installs_core(self): # Core submodules still installed. installed = mocks["_install_isaaclab_submodules"].call_args[0][0] assert set(installed) == set(CORE_ISAACLAB_SUBMODULES) - - # --- isaaclab is always first --- - - def test_isaaclab_is_first_in_submodules_for_all(self): - mocks = self._run("all") - installed = mocks["_install_isaaclab_submodules"].call_args[0][0] - assert installed[0] == "isaaclab" - - def test_isaaclab_is_first_in_submodules_for_core(self): - mocks = self._run("core") - installed = mocks["_install_isaaclab_submodules"].call_args[0][0] - assert installed[0] == "isaaclab" - - def test_isaaclab_is_first_when_mimic_added(self): - mocks = self._run("mimic") - installed = mocks["_install_isaaclab_submodules"].call_args[0][0] - assert installed[0] == "isaaclab" diff --git a/source/isaaclab/test/cli/test_install_commands.py b/source/isaaclab/test/cli/test_install_commands.py index d39249244d34..cbc38a21711d 100644 --- a/source/isaaclab/test/cli/test_install_commands.py +++ b/source/isaaclab/test/cli/test_install_commands.py @@ -12,6 +12,7 @@ import os import subprocess +import sys from contextlib import contextmanager from pathlib import Path from unittest import mock @@ -20,7 +21,6 @@ import isaaclab.cli.commands.install as install_cmd from isaaclab.cli.commands.install import ( - _PREBUNDLE_REPOINT_PACKAGES, _ensure_cuda_torch, _install_isaacsim, _maybe_uninstall_prebundled_torch, @@ -307,46 +307,22 @@ def test_warns_when_declared_dependency_missing_from_metadata(self, tmp_path): class TestTorchProbe: """Tests for :func:`_torch_first_on_sys_path_is_prebundle`. - The function shells out to ``python_exe -c `` and interprets the - subprocess exit code: 1 → prebundle is first; 0 → it is not. + Runs the real probe subprocess with a fake ``torch`` package placed first on + ``PYTHONPATH``, which the probe only receives through the forwarded ``env``. """ - def test_returns_true_when_prebundle_first(self, tmp_path): - """Probe exits 1 → the first torch on sys.path is under a pip_prebundle directory.""" - with mock.patch("isaaclab.cli.commands.install.run_command", return_value=_cp(returncode=1)): - result = _torch_first_on_sys_path_is_prebundle( - str(tmp_path / "python"), - env={"PYTHONPATH": "/fake/extsDeprecated/pip_prebundle"}, - ) - assert result is True - - def test_returns_false_when_site_packages_first(self, tmp_path): - """Probe exits 0 → the first torch on sys.path is in regular site-packages.""" - with mock.patch("isaaclab.cli.commands.install.run_command", return_value=_cp(returncode=0)): - result = _torch_first_on_sys_path_is_prebundle( - str(tmp_path / "python"), - env={"PYTHONPATH": "/conda/lib/python3.12/site-packages"}, - ) - assert result is False + @pytest.mark.parametrize( + ("torch_parent", "expected"), + [("exts/omni.isaac.ml_archive/pip_prebundle", True), ("site-packages", False)], + ids=["prebundle_first", "site_packages_first"], + ) + def test_reports_whether_first_torch_is_prebundled(self, tmp_path, torch_parent, expected): + torch_root = tmp_path / torch_parent + (torch_root / "torch").mkdir(parents=True) + (torch_root / "torch" / "__init__.py").write_text("") + env = {**os.environ, "PYTHONPATH": str(torch_root)} - def test_returns_false_when_torch_not_found_anywhere(self, tmp_path): - """Probe exits 0 (no torch on sys.path at all) → returns False.""" - with mock.patch("isaaclab.cli.commands.install.run_command", return_value=_cp(returncode=0)): - result = _torch_first_on_sys_path_is_prebundle( - str(tmp_path / "python"), - env={}, - ) - assert result is False - - def test_passes_env_to_subprocess(self, tmp_path): - """The custom env dict is forwarded to run_command.""" - env_sent = {"PYTHONPATH": "/some/path"} - with mock.patch("isaaclab.cli.commands.install.run_command", return_value=_cp(0)) as mock_run: - _torch_first_on_sys_path_is_prebundle(str(tmp_path / "python"), env=env_sent) - call_kwargs = mock_run.call_args - assert call_kwargs.kwargs.get("env") == env_sent or ( - len(call_kwargs.args) > 1 and call_kwargs.args[1] == env_sent - ) + assert _torch_first_on_sys_path_is_prebundle(sys.executable, env=env) is expected # --------------------------------------------------------------------------- @@ -425,14 +401,12 @@ class TestEnsureCudaTorch: """Tests for :func:`_ensure_cuda_torch` across architectures and environment types. Combinations tested: - - Architecture: x86 and ARM both use cu130 - Pip command: ``python -m pip`` (venv/conda/kit) vs ``uv pip`` (uv venv) - Torch state: already installed at correct version; wrong CUDA tag; not installed """ - @pytest.mark.parametrize("arm", [False, True], ids=["x86", "arm"]) - def test_skips_install_when_correct_version_present(self, tmp_path, arm): - """A matching CUDA 13.0 build does not trigger an install on either architecture.""" + def test_skips_install_when_correct_version_present(self, tmp_path): + """A matching CUDA 13.0 build does not trigger an install.""" py = str(tmp_path / "python") pip_cmd = [py, "-m", "pip"] pip_show_out = "Name: torch\nVersion: 2.12.0+cu130\n" @@ -440,7 +414,6 @@ def test_skips_install_when_correct_version_present(self, tmp_path, arm): with ( mock.patch("isaaclab.cli.commands.install.extract_python_exe", return_value=py), mock.patch("isaaclab.cli.commands.install.get_pip_command", return_value=pip_cmd), - mock.patch("isaaclab.cli.commands.install.is_arm", return_value=arm), mock.patch("isaaclab.cli.commands.install.run_command", return_value=_cp(0, pip_show_out)) as mock_run, ): _ensure_cuda_torch() @@ -449,9 +422,8 @@ def test_skips_install_when_correct_version_present(self, tmp_path, arm): assert mock_run.call_count == 1 assert "show" in mock_run.call_args[0][0] - @pytest.mark.parametrize("arm", [False, True], ids=["x86", "arm"]) - def test_installs_cu130_when_torch_missing(self, tmp_path, arm): - """Missing torch is installed from the CUDA 13.0 index on either architecture.""" + def test_installs_cu130_when_torch_missing(self, tmp_path): + """Missing torch is installed from the CUDA 13.0 index.""" py = str(tmp_path / "python") pip_cmd = [py, "-m", "pip"] calls: list[list[str]] = [] @@ -463,7 +435,6 @@ def _run(cmd, **kwargs): with ( mock.patch("isaaclab.cli.commands.install.extract_python_exe", return_value=py), mock.patch("isaaclab.cli.commands.install.get_pip_command", return_value=pip_cmd), - mock.patch("isaaclab.cli.commands.install.is_arm", return_value=arm), mock.patch("isaaclab.cli.commands.install.run_command", side_effect=_run), ): _ensure_cuda_torch() @@ -473,9 +444,8 @@ def _run(cmd, **kwargs): assert "https://download.pytorch.org/whl/cu130" in combined assert "torch" in combined - @pytest.mark.parametrize("arm", [False, True], ids=["x86", "arm"]) - def test_reinstalls_when_wrong_cuda_tag(self, tmp_path, arm): - """An existing CUDA 12.6 build is replaced with CUDA 13.0 on either architecture.""" + def test_reinstalls_when_wrong_cuda_tag(self, tmp_path): + """An existing CUDA 12.6 build is replaced with CUDA 13.0.""" py = str(tmp_path / "python") pip_cmd = [py, "-m", "pip"] calls: list[list[str]] = [] @@ -488,7 +458,6 @@ def _run(cmd, **kwargs): with ( mock.patch("isaaclab.cli.commands.install.extract_python_exe", return_value=py), mock.patch("isaaclab.cli.commands.install.get_pip_command", return_value=pip_cmd), - mock.patch("isaaclab.cli.commands.install.is_arm", return_value=arm), mock.patch("isaaclab.cli.commands.install.run_command", side_effect=_run), ): _ensure_cuda_torch() @@ -547,26 +516,6 @@ def _run(cmd, **kwargs): assert "-y" in uninstall_calls[0], "pip uninstall must include -y" assert py in uninstall_calls[0], "Expected python exe in pip command" - def test_kit_python_uses_python_sh_as_pip_prefix(self, tmp_path): - """With Isaac Sim's kit Python, python.sh is the executable prefix in the pip command.""" - python_sh = str(tmp_path / "_isaac_sim" / "python.sh") - pip_cmd = [python_sh, "-m", "pip"] - calls: list[list[str]] = [] - - def _run(cmd, **kwargs): - calls.append(list(cmd)) - return _cp(0, "") - - with ( - mock.patch("isaaclab.cli.commands.install.extract_python_exe", return_value=python_sh), - mock.patch("isaaclab.cli.commands.install.get_pip_command", return_value=pip_cmd), - mock.patch("isaaclab.cli.commands.install.is_arm", return_value=False), - mock.patch("isaaclab.cli.commands.install.run_command", side_effect=_run), - ): - _ensure_cuda_torch() - - assert calls[0][0] == python_sh - # --------------------------------------------------------------------------- # _repoint_prebundle_packages — Isaac Sim install method × venv type @@ -584,6 +533,15 @@ class TestRePointPrebundlePackages: # ---- shared fixtures / helpers ------------------------------------------ + @pytest.fixture(autouse=True) + def _isolate_home(self, tmp_path, monkeypatch): + """Keep prebundle discovery away from the host's real ``~/.local/share/ov`` cache.""" + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_DATA_HOME", str(home / ".local" / "share")) + monkeypatch.setenv("XDG_CACHE_HOME", str(home / ".cache")) + def _sim_with_prebundle(self, base: Path, packages: list[str]) -> tuple[Path, Path]: """Create a minimal fake Isaac Sim tree containing a pip_prebundle dir. @@ -634,7 +592,8 @@ def test_no_op_when_no_pip_prebundle_dirs(self, tmp_path): with self._patch(isaacsim_path, site_pkgs, py): _repoint_prebundle_packages() - assert not (site_pkgs.parent / "pip_prebundle" / "torch").exists() + assert list(isaacsim_path.rglob("*")) == [] + assert (site_pkgs / "torch").is_dir() and not (site_pkgs / "torch").is_symlink() # ---- local _isaac_sim symlink (local build) ------------------------------ @@ -739,112 +698,6 @@ def test_raises_when_prebundled_torch_not_neutralized(self, tmp_path): with pytest.raises(RuntimeError, match="neutralize"): _repoint_prebundle_packages() - # ---- pip-installed isaacsim (path found via import probe) ---------------- - - def test_pip_isaacsim_symlinks_torch(self, tmp_path): - """pip-installed isaacsim: extract_isaacsim_path() returns its directory and torch is repointed.""" - # With pip-installed isaacsim the path may be inside site-packages rather than a symlink - # at the repo root, but _repoint_prebundle_packages treats it identically. - isaacsim_path, prebundle = self._sim_with_prebundle(tmp_path / "pip_isaacsim", ["torch"]) - site_pkgs = _make_site_packages(tmp_path / "env", ["torch"]) - py = str(tmp_path / "env" / "bin" / "python") - - with self._patch(isaacsim_path, site_pkgs, py): - _repoint_prebundle_packages() - - assert (prebundle / "torch").is_symlink() - assert (prebundle / "torch").resolve() == (site_pkgs / "torch").resolve() - - def test_pip_isaacsim_skips_nvidia_without_cudnn(self, tmp_path): - """pip-installed isaacsim + no cudnn in site-packages → nvidia prebundle preserved.""" - isaacsim_path, prebundle = self._sim_with_prebundle(tmp_path / "pip_isaacsim", ["nvidia"]) - # site-packages has nvidia/ but without a cudnn sub-package - site_pkgs = _make_site_packages(tmp_path / "env", ["nvidia"]) - py = str(tmp_path / "env" / "bin" / "python") - - with self._patch(isaacsim_path, site_pkgs, py): - _repoint_prebundle_packages() - - assert not (prebundle / "nvidia").is_symlink(), "nvidia must not be repointed without cudnn" - - # ---- different venv types ------------------------------------------------ - - def test_uv_venv_repoints_torch_using_venv_site_packages(self, tmp_path): - """uv venv: site-packages inside VIRTUAL_ENV is used as the symlink target.""" - venv_site = tmp_path / "env_uv" / "lib" / "python3.12" / "site-packages" - venv_site.mkdir(parents=True) - (venv_site / "torch").mkdir() - isaacsim_path, prebundle = self._sim_with_prebundle(tmp_path / "sim", ["torch"]) - py = str(tmp_path / "env_uv" / "bin" / "python") - - with ( - mock.patch("isaaclab.cli.commands.install.extract_isaacsim_path", return_value=isaacsim_path), - mock.patch("isaaclab.cli.commands.install.extract_python_exe", return_value=py), - mock.patch("isaaclab.cli.commands.install.is_windows", return_value=False), - mock.patch("isaaclab.cli.commands.install.run_command", return_value=_cp(0, str(venv_site))), - ): - _repoint_prebundle_packages() - - assert (prebundle / "torch").is_symlink() - assert (prebundle / "torch").resolve() == (venv_site / "torch").resolve() - - def test_conda_repoints_torch_using_conda_site_packages(self, tmp_path): - """conda env: site-packages inside CONDA_PREFIX is used as the symlink target.""" - conda_site = tmp_path / "conda" / "lib" / "python3.12" / "site-packages" - conda_site.mkdir(parents=True) - (conda_site / "torch").mkdir() - isaacsim_path, prebundle = self._sim_with_prebundle(tmp_path / "sim", ["torch"]) - py = str(tmp_path / "conda" / "bin" / "python") - - with ( - mock.patch("isaaclab.cli.commands.install.extract_isaacsim_path", return_value=isaacsim_path), - mock.patch("isaaclab.cli.commands.install.extract_python_exe", return_value=py), - mock.patch("isaaclab.cli.commands.install.is_windows", return_value=False), - mock.patch("isaaclab.cli.commands.install.run_command", return_value=_cp(0, str(conda_site))), - ): - _repoint_prebundle_packages() - - assert (prebundle / "torch").is_symlink() - assert (prebundle / "torch").resolve() == (conda_site / "torch").resolve() - - def test_conda_repoints_nvidia_when_full_cuda_torch_installed(self, tmp_path): - """conda env with nvidia-cudnn-cu12 installed: nvidia/ is repointed because cudnn subdir exists.""" - conda_site = tmp_path / "conda" / "lib" / "python3.12" / "site-packages" - conda_site.mkdir(parents=True) - (conda_site / "nvidia").mkdir() - (conda_site / "nvidia" / "cudnn").mkdir() - (conda_site / "nvidia" / "cublas").mkdir() - isaacsim_path, prebundle = self._sim_with_prebundle(tmp_path / "sim", ["nvidia"]) - py = str(tmp_path / "conda" / "bin" / "python") - - with ( - mock.patch("isaaclab.cli.commands.install.extract_isaacsim_path", return_value=isaacsim_path), - mock.patch("isaaclab.cli.commands.install.extract_python_exe", return_value=py), - mock.patch("isaaclab.cli.commands.install.is_windows", return_value=False), - mock.patch("isaaclab.cli.commands.install.run_command", return_value=_cp(0, str(conda_site))), - ): - _repoint_prebundle_packages() - - assert (prebundle / "nvidia").is_symlink() - - def test_conda_skips_nvidia_when_no_cudnn(self, tmp_path): - """conda env without CUDA torch: site-packages/nvidia lacks cudnn → nvidia not repointed.""" - conda_site = tmp_path / "conda" / "lib" / "python3.12" / "site-packages" - conda_site.mkdir(parents=True) - (conda_site / "nvidia").mkdir() # exists but no cudnn inside - isaacsim_path, prebundle = self._sim_with_prebundle(tmp_path / "sim", ["nvidia"]) - py = str(tmp_path / "conda" / "bin" / "python") - - with ( - mock.patch("isaaclab.cli.commands.install.extract_isaacsim_path", return_value=isaacsim_path), - mock.patch("isaaclab.cli.commands.install.extract_python_exe", return_value=py), - mock.patch("isaaclab.cli.commands.install.is_windows", return_value=False), - mock.patch("isaaclab.cli.commands.install.run_command", return_value=_cp(0, str(conda_site))), - ): - _repoint_prebundle_packages() - - assert not (prebundle / "nvidia").is_symlink() - # ---- multiple prebundle directories ------------------------------------- def test_repoints_across_multiple_prebundle_dirs(self, tmp_path): @@ -955,44 +808,29 @@ def test_skips_gracefully_when_site_packages_probe_fails(self, tmp_path): assert not (prebundle / "torch").is_symlink(), "No symlink should be created when probe fails" - # ---- all packages in the repoint list are covered ----------------------- - - @pytest.mark.parametrize("pkg_name", [p for p in _PREBUNDLE_REPOINT_PACKAGES if p != "nvidia"]) - def test_all_non_nvidia_packages_are_repointed(self, tmp_path, pkg_name): - """Every non-nvidia entry in _PREBUNDLE_REPOINT_PACKAGES is repointed when it exists.""" - isaacsim_path, prebundle = self._sim_with_prebundle(tmp_path / "sim", [pkg_name]) - site_pkgs = _make_site_packages(tmp_path / "env", [pkg_name]) - py = str(tmp_path / "env" / "bin" / "python") - - with self._patch(isaacsim_path, site_pkgs, py): - _repoint_prebundle_packages() - - assert (prebundle / pkg_name).is_symlink(), f"{pkg_name} should be repointed" - assert (prebundle / pkg_name).resolve() == (site_pkgs / pkg_name).resolve() - class TestInstallRootExtraExcludesIsaacSim: - """The ``teleop`` extra lists Isaac Sim for uv, but pip must never resolve it inline.""" + """A root extra may list Isaac Sim for uv, but pip must never resolve it inline.""" - def test_root_extra_dependencies_exclude_isaacsim(self, source_checkout_root: Path): + def test_install_root_extra_omits_isaacsim_from_the_pip_command(self, tmp_path): """pip has no override mechanism, so isaacsim + isaacteleop in one pass cannot resolve.""" - dependencies = install_cmd._root_extra_dependencies("teleop") - - assert not any(d.startswith("isaacsim") for d in dependencies) - assert any(d.startswith("isaacteleop") for d in dependencies) - - def test_install_root_extra_omits_isaacsim_from_the_pip_command(self, source_checkout_root: Path, tmp_path): - """``./isaaclab.sh -i teleop`` must not hand Isaac Sim to pip alongside Isaac Teleop.""" python_exe = str(tmp_path / "python") pip_cmd = [python_exe, "-m", "pip"] + root_pyproject = { + "project": { + "optional-dependencies": { + "teleop": ["isaacsim[all,extscache]==6.1.0.0", "isaacteleop[cloudxr]==1.0.0", "isaaclab_teleop"] + } + } + } with ( + mock.patch("isaaclab.cli.commands.install._load_root_pyproject", return_value=root_pyproject), mock.patch("isaaclab.cli.commands.install.extract_python_exe", return_value=python_exe), mock.patch("isaaclab.cli.commands.install.get_pip_command", return_value=pip_cmd), mock.patch("isaaclab.cli.commands.install.run_command") as mock_run, ): install_cmd._install_root_extra("teleop") - installed = " ".join(" ".join(call.args[0]) for call in mock_run.call_args_list) - assert "isaacsim[all,extscache]" not in installed - assert "isaacteleop" in installed + mock_run.assert_called_once() + assert mock_run.call_args.args[0] == [*pip_cmd, "install", "isaacteleop[cloudxr]==1.0.0"] diff --git a/source/isaaclab/test/cli/test_install_prebundle.py b/source/isaaclab/test/cli/test_install_prebundle.py index eeae9653651d..bc1200dc802d 100644 --- a/source/isaaclab/test/cli/test_install_prebundle.py +++ b/source/isaaclab/test/cli/test_install_prebundle.py @@ -3,11 +3,10 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Tests for prebundle probe, _split_install_items, and prebundle dist integrity. +"""Tests for prebundle dist integrity. -Supplements test_install_commands.py with tests that verify the probe -script text, the comma-separated install item parser, and the -snapshot/assert pair guarding Isaac Sim prebundles against pip removals. +Supplements test_install_commands.py with tests for the snapshot/assert pair +guarding Isaac Sim prebundles against pip removals. """ import shutil @@ -18,77 +17,10 @@ from isaaclab.cli.commands.install import ( _assert_no_new_dangling_prebundle_symlinks, _find_dangling_prebundle_symlinks, - _torch_first_on_sys_path_is_prebundle, - split_install_items, ) pytestmark = pytest.mark.unit -# --------------------------------------------------------------------------- -# split_install_items -# --------------------------------------------------------------------------- - - -class TestSplitInstallItems: - """Tests for :func:`split_install_items`.""" - - def test_single_item(self): - assert split_install_items("assets") == ["assets"] - - def test_comma_separated(self): - assert split_install_items("assets,tasks,rl") == ["assets", "tasks", "rl"] - - def test_with_spaces(self): - assert split_install_items(" assets , tasks , rl ") == ["assets", "tasks", "rl"] - - def test_brackets_preserved(self): - """Commas inside brackets should not split.""" - assert split_install_items("visualizers[rerun,newton],tasks") == [ - "visualizers[rerun,newton]", - "tasks", - ] - - def test_nested_brackets(self): - assert split_install_items("a[b[c,d],e],f") == ["a[b[c,d],e]", "f"] - - def test_empty_string(self): - assert split_install_items("") == [] - - def test_trailing_comma(self): - assert split_install_items("assets,tasks,") == ["assets", "tasks"] - - def test_single_with_extra(self): - assert split_install_items("visualizers[all]") == ["visualizers[all]"] - - -# --------------------------------------------------------------------------- -# _torch_first_on_sys_path_is_prebundle — probe script verification -# --------------------------------------------------------------------------- - - -class TestTorchProbeScriptContent: - """Verify that the probe script checks for 'pip_prebundle' not 'extsDeprecated'.""" - - def test_probe_script_checks_pip_prebundle(self): - """The inline Python probe must use 'pip_prebundle' as its path indicator.""" - import subprocess - - captured_cmd = None - - def fake_run(cmd, *, env=None, check=False, capture_output=False, text=False): - nonlocal captured_cmd - captured_cmd = cmd - return subprocess.CompletedProcess(args=cmd, returncode=0) - - with mock.patch("isaaclab.cli.commands.install.run_command", side_effect=fake_run): - _torch_first_on_sys_path_is_prebundle("/fake/python", env={}) - - assert captured_cmd is not None - probe_script = captured_cmd[2] # [python_exe, "-c", probe] - assert "pip_prebundle" in probe_script, "Probe must check for 'pip_prebundle'" - assert "extsDeprecated" not in probe_script, "Probe must NOT check only for 'extsDeprecated'" - - # --------------------------------------------------------------------------- # prebundle dangling-symlink integrity # --------------------------------------------------------------------------- @@ -114,12 +46,6 @@ def _make_prebundles(self, tmp_path): (services / "packaging" / "__init__.py").symlink_to(core / "packaging" / "__init__.py") return core, services - def test_intact_farm_has_no_dangling_links(self, tmp_path): - core, services = self._make_prebundles(tmp_path) - with mock.patch("isaaclab.cli.commands.install._discover_prebundle_dirs", return_value={core, services}): - assert _find_dangling_prebundle_symlinks() == set() - _assert_no_new_dangling_prebundle_symlinks(set()) - def test_raises_when_symlink_target_deleted(self, tmp_path): """Deleting the shared copy must fail the install, naming the broken link.""" core, services = self._make_prebundles(tmp_path) @@ -145,6 +71,7 @@ def test_routine_dist_replacement_passes(self, tmp_path): (core / "six.py").write_text("") with mock.patch("isaaclab.cli.commands.install._discover_prebundle_dirs", return_value={core, services}): before = _find_dangling_prebundle_symlinks() + assert before == set() (core / "six.py").unlink() _assert_no_new_dangling_prebundle_symlinks(before) @@ -158,5 +85,9 @@ def test_non_package_dangles_warn_but_pass(self, tmp_path): core, services = self._make_prebundles(tmp_path) (services / "WHEEL").symlink_to(core / "gone-WHEEL") (services / "test_module.py").symlink_to(core / "gone-test.py") - with mock.patch("isaaclab.cli.commands.install._discover_prebundle_dirs", return_value={core, services}): + with ( + mock.patch("isaaclab.cli.commands.install._discover_prebundle_dirs", return_value={core, services}), + mock.patch("isaaclab.cli.commands.install.print_warning") as mock_warning, + ): _assert_no_new_dangling_prebundle_symlinks(set()) + mock_warning.assert_called_once() diff --git a/source/isaaclab/test/cli/test_installed_workflow_entrypoints.py b/source/isaaclab/test/cli/test_installed_workflow_entrypoints.py index a2d790cbeb1e..d6c211ab496d 100644 --- a/source/isaaclab/test/cli/test_installed_workflow_entrypoints.py +++ b/source/isaaclab/test/cli/test_installed_workflow_entrypoints.py @@ -65,36 +65,72 @@ def test_editor_option_uses_cli_dispatcher(): editor.assert_called_once_with(["--isaac_path", "/sim", "--verbose"]) -@pytest.mark.parametrize("option", ["--vscode", "--generate-vscode-settings"]) -def test_removed_editor_options_are_rejected(option): - """Removed editor setup options must not remain as hidden compatibility paths.""" - with mock.patch.object(sys, "argv", ["isaaclab", option]), pytest.raises(SystemExit, match="2"): - cli.cli() +_RL_ENTRYPOINTS = "isaaclab_rl.entrypoints" +_WORKFLOW_ARGS = ["--task", "Example"] +_LEAPP_DEPLOY_ARGS = ["--task", "Isaac-Cartpole", "--pipeline", "exported/Isaac-Cartpole.yaml", "physics=newton_mjwarp"] @pytest.mark.parametrize( - ("command", "runner"), + ("argv", "module", "runner", "runner_args", "status"), [ - (cli.train, "run_train_cli"), - (cli.play, "run_play_cli"), - (cli.train_multigpu, "run_train_multigpu_cli"), - (cli.zero_agent, "run_zero_agent_cli"), - (cli.random_agent, "run_random_agent_cli"), + (["train", *_WORKFLOW_ARGS], _RL_ENTRYPOINTS, "run_train_cli", _WORKFLOW_ARGS, 0), + (["train", *_WORKFLOW_ARGS], _RL_ENTRYPOINTS, "run_train_cli", _WORKFLOW_ARGS, 3), + (["play", *_WORKFLOW_ARGS], _RL_ENTRYPOINTS, "run_play_cli", _WORKFLOW_ARGS, 3), + (["train_multigpu", *_WORKFLOW_ARGS], _RL_ENTRYPOINTS, "run_train_multigpu_cli", _WORKFLOW_ARGS, 3), + (["zero_agent", *_WORKFLOW_ARGS], _RL_ENTRYPOINTS, "run_zero_agent_cli", _WORKFLOW_ARGS, 3), + (["random_agent", *_WORKFLOW_ARGS], _RL_ENTRYPOINTS, "run_random_agent_cli", _WORKFLOW_ARGS, 3), + (["benchmark", "training", "--help"], "isaaclab.benchmark", "run_benchmark_cli", ["training", "--help"], 3), + ( + ["microbenchmark", "--component", "articulation", "physics=physx"], + "isaaclab.benchmark", + "run_microbenchmark_cli", + ["--component", "articulation", "physics=physx"], + 3, + ), + ( + ["leapp", "export", "--rl_library", "rsl_rl", *_WORKFLOW_ARGS], + _RL_ENTRYPOINTS, + "run_export_cli", + ["--rl_library", "rsl_rl", *_WORKFLOW_ARGS], + 3, + ), + ( + ["leapp", "deploy", *_LEAPP_DEPLOY_ARGS], + "isaaclab.cli.commands.deploy", + "command_deploy_leapp", + _LEAPP_DEPLOY_ARGS, + 3, + ), + ], + ids=[ + "train-success", + "train", + "play", + "train_multigpu", + "zero_agent", + "random_agent", + "benchmark", + "microbenchmark", + "leapp-export", + "leapp-deploy", ], ) -def test_workflow_commands_dispatch_to_installed_entrypoints(command, runner): - """Workflow commands must not depend on scripts from a source checkout.""" - args = ["--task", "Example"] - with mock.patch(f"isaaclab_rl.entrypoints.{runner}", return_value=0) as run: - command(args) - - run.assert_called_once_with(args) - +def test_cli_subcommand_dispatches_and_propagates_status(argv, module, runner, runner_args, status): + """Subcommands forward their arguments in-process and turn a nonzero result into the exit status.""" + run = mock.Mock(return_value=status) + with ( + mock.patch.dict(sys.modules, {module: mock.Mock(**{runner: run})}), + mock.patch.object(cli, "_load_external_tasks"), + mock.patch.object(sys, "argv", ["isaaclab", *argv]), + ): + if status: + with pytest.raises(SystemExit) as exc_info: + cli.cli() + assert exc_info.value.code == status + else: + cli.cli() -def test_workflow_command_propagates_failure_status(): - """A nonzero in-process result must remain the console command's exit status.""" - with mock.patch("isaaclab_rl.entrypoints.run_train_cli", return_value=2), pytest.raises(SystemExit, match="2"): - cli.train([]) + run.assert_called_once_with(runner_args) def test_demo_catalog_lists_packaged_demos(capsys): @@ -120,12 +156,6 @@ def test_example_catalog_lists_packaged_examples(capsys): assert "teapot-fill" not in output -def test_browse_is_not_a_program_command(): - """Program selection lives in Newton GL, not in a separate CLI mode.""" - with pytest.raises(SystemExit, match="2"): - cli.demo(["browse"]) - - def _visualizer(visualizer_type: str) -> mock.Mock: """Return a visualizer stand-in of the given type.""" visualizer = mock.Mock() @@ -211,24 +241,6 @@ def test_program_catalog_resolves_paths(catalog, directory): assert all(program.path.is_file() for program in catalog) -def test_integration_examples_use_root_example_paths(): - """Integration examples must use paths relative to the root examples directory.""" - paths_by_name = {program.name: program.relative_path for program in programs.EXAMPLES} - assert paths_by_name["arl-robot-1"] == "examples/arl_robot_1.py" - assert paths_by_name["haply-teleoperation"] == "examples/haply_teleoperation.py" - assert paths_by_name["newton-dominoes"] == "examples/newton_viewer_dominoes.py" - assert paths_by_name["ppisp-camera"] == "examples/sensors/ppisp_camera.py" - assert paths_by_name["tactile-sensor"] == "examples/sensors/tacsl_sensor.py" - - -def test_newton_raycast_scenes_share_one_example(): - """Newton ray-cast variants must stay behind one focused example entry.""" - paths_by_name = {program.name: program.relative_path for program in programs.EXAMPLES} - assert paths_by_name["newton-raycast"] == "examples/sensors/newton_raycast.py" - assert "newton-raycast-heightfield" not in paths_by_name - assert "newton-raycast-moving-geometry" not in paths_by_name - - @pytest.mark.parametrize("catalog", [programs.DEMOS, programs.EXAMPLES]) def test_program_catalog_names_are_unique(catalog): """Each CLI program name must select exactly one module.""" diff --git a/source/isaaclab/test/cli/test_leapp_entrypoint.py b/source/isaaclab/test/cli/test_leapp_entrypoint.py index aba9a959b9eb..efba0b42ab9c 100644 --- a/source/isaaclab/test/cli/test_leapp_entrypoint.py +++ b/source/isaaclab/test/cli/test_leapp_entrypoint.py @@ -8,12 +8,9 @@ import os import subprocess import sys -from unittest import mock import pytest -import isaaclab.cli as cli - pytestmark = pytest.mark.unit @@ -28,57 +25,3 @@ def test_deploy_module_does_not_import_pxr_before_app_launch(): text=True, ) assert result.returncode == 0, result.stderr - - -def test_export_dispatches_in_process(): - """``isaaclab leapp export`` forwards arguments to the library dispatcher.""" - args = ["--rl_library", "rsl_rl", "--task", "Isaac-Cartpole"] - - with ( - mock.patch.object(sys, "argv", ["isaaclab", "leapp", "export", *args]), - mock.patch("isaaclab_rl.entrypoints.run_export_cli", return_value=0) as run_export, - ): - cli.cli() - - run_export.assert_called_once_with(args) - - -def test_export_propagates_nonzero_dispatch_status(): - """Export failures become the CLI process status.""" - with mock.patch("isaaclab_rl.entrypoints.run_export_cli", return_value=1): - with pytest.raises(SystemExit) as exc_info: - cli.leapp(["export", "--rl_library", "rsl_rl"]) - - assert exc_info.value.code == 1 - - -def test_deploy_dispatches_in_process(): - """``isaaclab leapp deploy`` forwards LEAPP deployment arguments.""" - args = [ - "--task", - "Isaac-Cartpole", - "--pipeline", - "exported/Isaac-Cartpole.yaml", - "physics=newton_mjwarp", - ] - - deploy = mock.Mock(return_value=0) - deploy_module = mock.Mock(command_deploy_leapp=deploy) - with ( - mock.patch.object(sys, "argv", ["isaaclab", "leapp", "deploy", *args]), - mock.patch.dict(sys.modules, {"isaaclab.cli.commands.deploy": deploy_module}), - ): - cli.cli() - - deploy.assert_called_once_with(args) - - -def test_deploy_propagates_nonzero_status(): - """Deployment failures become the CLI process status.""" - deploy_module = mock.Mock() - deploy_module.command_deploy_leapp.return_value = 3 - with mock.patch.dict(sys.modules, {"isaaclab.cli.commands.deploy": deploy_module}): - with pytest.raises(SystemExit) as exc_info: - cli.leapp(["deploy", "--task", "Isaac-Cartpole", "--pipeline", "policy.yaml"]) - - assert exc_info.value.code == 3 diff --git a/source/isaaclab/test/cli/test_microbenchmark_entrypoint.py b/source/isaaclab/test/cli/test_microbenchmark_entrypoint.py index f3dad8a12e58..722a85567208 100644 --- a/source/isaaclab/test/cli/test_microbenchmark_entrypoint.py +++ b/source/isaaclab/test/cli/test_microbenchmark_entrypoint.py @@ -5,13 +5,11 @@ """Tests for the discoverable micro-benchmark command.""" -import sys from pathlib import Path from unittest import mock import pytest -import isaaclab.cli as cli from isaaclab.benchmark.microbenchmark import MicrobenchmarkFactory, run_microbenchmark_cli @@ -59,6 +57,15 @@ def test_factory_resolves_exact_physics_variant( assert command.component == component +def test_every_discoverable_workload_resolves_to_a_packaged_script() -> None: + """Every advertised physics/component pair must launch an existing entrypoint.""" + factory = MicrobenchmarkFactory() + for physics in MicrobenchmarkFactory.physics_variants(): + for component in MicrobenchmarkFactory.components(): + script = factory.build_command(physics, component, []).script + assert script.is_file(), f"{physics}/{component}: missing {script}" + + def test_factory_rejects_unknown_variant_without_substitution() -> None: """Unknown variants should fail instead of silently selecting a related solver.""" with pytest.raises(ValueError, match="newton_unknown"): @@ -89,16 +96,3 @@ def test_microbenchmark_cli_launches_selected_command() -> None: assert script.name == "benchmark_joint_wrench.py" assert args == ["--physics_variant", "newton_kamino", "--num_steps", "2"] assert run_python.call_args.kwargs == {"check": True} - - -def test_top_level_cli_dispatches_microbenchmark() -> None: - """``isaaclab microbenchmark`` should forward arguments to its dispatcher.""" - args = ["--component", "articulation", "physics=physx", "--num_iterations", "2"] - - with ( - mock.patch.object(sys, "argv", ["isaaclab", "microbenchmark", *args]), - mock.patch("isaaclab.benchmark.run_microbenchmark_cli", return_value=0) as run_microbenchmark, - ): - cli.cli() - - run_microbenchmark.assert_called_once_with(args) diff --git a/source/isaaclab/test/cli/test_misc_commands.py b/source/isaaclab/test/cli/test_misc_commands.py index 13f1dca6d4be..af6f10f9edb3 100644 --- a/source/isaaclab/test/cli/test_misc_commands.py +++ b/source/isaaclab/test/cli/test_misc_commands.py @@ -111,7 +111,7 @@ def test_build_isaacsim_links_incremental_build_without_packaging(tmp_path): @pytest.mark.parametrize( ("sys_platform", "machine", "target"), [ - ("linux", "x86_64", "linux-x86_64"), + # linux-x86_64 is covered end to end by test_build_isaacsim_links_incremental_build_without_packaging. ("linux", "aarch64", "linux-aarch64"), ("win32", "AMD64", "windows-x86_64"), ], diff --git a/source/isaaclab/test/cli/test_ovrtx_log.py b/source/isaaclab/test/cli/test_ovrtx_log.py index 40fa36042fa3..22c9ff1e7cf0 100644 --- a/source/isaaclab/test/cli/test_ovrtx_log.py +++ b/source/isaaclab/test/cli/test_ovrtx_log.py @@ -20,8 +20,6 @@ import textwrap from pathlib import Path -import pytest - _REPO_ROOT = Path(__file__).resolve().parents[4] _INNER_CONFTEST = ( @@ -58,24 +56,6 @@ def reopen(): _log() ''' -_STDOUT_RENDERER = '''\ -import os - -_FD = [] - - -def _fd(): - """Open pytest's stdout once and keep the descriptor, as the removed /dev/stdout override did.""" - if not _FD: - _FD.append(os.open("/dev/stdout", os.O_WRONLY)) - return _FD[0] - - -def render(tag, count=1): - """Append ``count`` renderer log lines tagged ``tag``.""" - os.write(_fd(), f"[omni.rtx] {tag}-line\\n".encode() * count) -''' - def _load_collector_module(): """Import ``tools/ovrtx_log.py`` the way the repo-root conftest loads it.""" @@ -135,65 +115,6 @@ def test_collector_is_loaded_into_every_session(request) -> None: assert request.config.pluginmanager.hasplugin("tools.ovrtx_log") -def test_retained_handle_on_pytest_stdout_writes_nul_blocks(tmp_path: Path) -> None: - """Pin the failure this collector exists to avoid. - - Pointing the renderer at ``/dev/stdout`` hands it a second handle on the temporary file pytest captures - file descriptor 1 into. Pytest rewinds and truncates that file between tests while the renderer's own - offset keeps advancing, so the next native write lands past the end and the gap reads back as NUL bytes. - """ - if os.name == "nt": - pytest.skip("/dev/stdout is POSIX-only") - - output = _run_inner_pytest( - tmp_path, - _STDOUT_RENDERER, - """ - def test_alpha(): - render("alpha", 40) - assert False - - - def test_beta(): - render("beta") - assert False - """, - ) - - assert b"\x00" in output - - -def test_replayed_log_is_free_of_nul_blocks_and_attributed_per_test(tmp_path: Path) -> None: - """A renderer logging to the claimed file keeps pytest's capture intact and its output readable. - - The same retained-handle writer as above, pointed at the log the collector claims for the process: no - descriptor of pytest's is shared, so nothing is written past the end of the capture file, and each - test's failure carries the renderer output produced while it ran. - """ - output = _run_inner_pytest( - tmp_path, - _FAKE_RENDERER, - """ - def test_alpha(): - render("alpha", 40) - assert False - - - def test_beta(): - render("beta") - assert False - """, - ) - - assert b"\x00" not in output - # Reported against the test's name, the same string the saved copy is named after. - assert b"----- OVRTX renderer log: test_alpha -----" in output - assert b"----- OVRTX renderer log: test_beta -----" in output - # Replayed once each: a repeat would mean one test was credited with another's output. - assert output.count(b"alpha-line") == 40 - assert output.count(b"beta-line") == 1 - - def test_replay_restarts_when_the_log_is_rewritten(tmp_path: Path) -> None: """A log re-opened mid-session is replayed whole rather than from an offset it no longer has.""" output = _run_inner_pytest( @@ -250,8 +171,14 @@ def test_saved_log_is_written_per_test_when_a_directory_is_named(tmp_path: Path) This is the copy that survives the caps: the replay and the crash report both quote a bounded tail, so a run whose renderer log outgrows the cap is only diagnosable from the saved file. What is saved is that test's own range of a log the renderer keeps appending to for the lifetime of the process. + + The renderer is a retained-handle writer pointed at the log the collector claims for the process: no + descriptor of pytest's is shared, so nothing is written past the end of the capture file (NUL blocks). + One path is shared by every session on the machine, so the session first claims it by dropping a + previous owner's bytes; otherwise offsets would be measured against them. """ save_dir = tmp_path / "ovrtx-logs" + _inner_log_path(tmp_path).write_bytes(b"[omni.rtx] stale-line\n" * 10) output = _run_inner_pytest( tmp_path, @@ -269,8 +196,16 @@ def test_beta(): save_dir=save_dir, ) + assert b"\x00" not in output + assert b"stale-line" not in output + # Reported against the test's name, the same string the saved copy is named after. + assert b"----- OVRTX renderer log: test_alpha -----" in output + assert b"----- OVRTX renderer log: test_beta -----" in output + # Replayed once each: a repeat would mean one test was credited with another's output. + assert output.count(b"beta-line") == 1 saved = sorted(path.name for path in save_dir.iterdir()) assert saved == ["test_alpha.0", "test_beta.0"] + assert b"stale-line" not in (save_dir / "test_alpha.0" / "ovrtx_renderer.log").read_bytes() # The saved copy is unbounded where the replay is capped, and the replay still happens alongside it. assert (save_dir / "test_alpha.0" / "ovrtx_renderer.log").read_bytes().count(b"alpha-line") == 40 assert output.count(b"alpha-line") == 40 @@ -365,28 +300,6 @@ def test_gamma_builds_no_renderer(): assert sorted(path.name for path in save_dir.iterdir()) == ["test_alpha.0"] -def test_log_left_behind_by_a_previous_owner_is_discarded(tmp_path: Path) -> None: - """One path is shared by every session on the machine, so a session starts by claiming it. - - Without dropping what is already there, the offset recorded before the renderer rewrites the file is - measured against a previous session's bytes, and the start of this session's log is skipped. - """ - _inner_log_path(tmp_path).write_bytes(b"[omni.rtx] stale-line\n" * 10) - - output = _run_inner_pytest( - tmp_path, - _FAKE_RENDERER, - """ - def test_alpha(): - render("alpha", 40) - assert False - """, - ) - - assert b"stale-line" not in output - assert output.count(b"alpha-line") == 40 - - def test_missing_log_reports_nothing(tmp_path: Path) -> None: """Every test that never builds a renderer must add nothing to its report.""" collector = _load_collector_module() diff --git a/source/isaaclab/test/cli/test_source_package_metadata.py b/source/isaaclab/test/cli/test_source_package_metadata.py index 29d602884d5c..636ad143277b 100644 --- a/source/isaaclab/test/cli/test_source_package_metadata.py +++ b/source/isaaclab/test/cli/test_source_package_metadata.py @@ -7,6 +7,7 @@ from __future__ import annotations +import re from pathlib import Path import pytest @@ -27,29 +28,25 @@ def test_resolved_torch_stack_supports_blackwell(source_checkout_root: Path, nam assert all(package["source"]["registry"] == "https://download.pytorch.org/whl/cu130" for package in packages) -def test_isaaclab_uses_one_standalone_usd_provider(source_checkout_root: Path): - """Isaac Lab must install only the USD provider shared with its importer dependencies. - - ``usd-core`` and ``usd-exchange`` each install a complete ``pxr`` into the same directory, so - a second provider silently overwrites the first and removing either one leaves ``pxr`` broken. - Nothing detects that, because the two are separate distributions. - """ - with (source_checkout_root / "pyproject.toml").open("rb") as f: - pyproject = tomllib.load(f) +def _requirement_name(requirement: str) -> str: + """Return the normalized distribution name of a requirement string.""" + return re.split(r"[\s\[<>=!~;@]", requirement, maxsplit=1)[0].lower() - dependencies = pyproject["project"]["dependencies"] - usd_providers = [ - dependency - for dependency in dependencies - if dependency.startswith("usd-core") or dependency.startswith("usd-exchange") - ] - assert usd_providers == ["usd-exchange==2.3.0"] +def _root_project(source_checkout_root: Path) -> dict: + """Load the ``[project]`` table of the root ``pyproject.toml``.""" + with (source_checkout_root / "pyproject.toml").open("rb") as f: + return tomllib.load(f)["project"] def test_resolved_environment_has_no_second_usd_provider(source_checkout_root: Path): - """No dependency may pull ``usd-core`` back in behind an extra. + """Isaac Lab must install only the USD provider shared with its importer dependencies. + + ``usd-core`` and ``usd-exchange`` each install a complete ``pxr`` into the same directory, so + a second provider silently overwrites the first and removing either one leaves ``pxr`` broken. + Nothing detects that, because the two are separate distributions. + No dependency may pull ``usd-core`` back in behind an extra either: ``newton[importers]``, ``mujoco[usd]`` and ``warp-lang[examples]`` all require it, so selecting any of them would reinstate the overlap that the direct dependencies avoid. Checking the lock catches that, where checking ``pyproject.toml`` alone would not. @@ -57,6 +54,9 @@ def test_resolved_environment_has_no_second_usd_provider(source_checkout_root: P with (source_checkout_root / "uv.lock").open("rb") as f: lock = tomllib.load(f) + direct_names = [_requirement_name(dep) for dep in _root_project(source_checkout_root)["dependencies"]] + assert [name for name in direct_names if name in ("usd-core", "usd-exchange")] == ["usd-exchange"] + locked = {package["name"] for package in lock["package"]} assert "usd-core" not in locked @@ -65,13 +65,8 @@ def test_resolved_environment_has_no_second_usd_provider(source_checkout_root: P def test_standalone_importers_are_opt_in(source_checkout_root: Path): """Standalone URDF/MJCF importers must not constrain the base environment.""" - with (source_checkout_root / "pyproject.toml").open("rb") as f: - pyproject = tomllib.load(f) - - project = pyproject["project"] - assert "isaacsim-asset-isolated==6.1.0.0" not in project["dependencies"] - assert "tinyobjloader==2.0.0rc13" not in project["dependencies"] - assert project["optional-dependencies"]["importers"] == [ - "isaacsim-asset-isolated==6.1.0.0", - "tinyobjloader==2.0.0rc13", - ] + project = _root_project(source_checkout_root) + importers = {"isaacsim-asset-isolated", "tinyobjloader"} + + assert not {_requirement_name(dep) for dep in project["dependencies"]} & importers + assert {_requirement_name(dep) for dep in project["optional-dependencies"]["importers"]} == importers diff --git a/source/isaaclab/test/cli/test_test_orchestrator_result_handling.py b/source/isaaclab/test/cli/test_test_orchestrator_result_handling.py index e364064dd788..98a501f70580 100644 --- a/source/isaaclab/test/cli/test_test_orchestrator_result_handling.py +++ b/source/isaaclab/test/cli/test_test_orchestrator_result_handling.py @@ -453,50 +453,6 @@ def _capture(*_args, report_file: str, **_kwargs): assert not was_failure -def test_crash_journal_path_is_absolute(monkeypatch, tmp_path: Path) -> None: - """The journal path handed to the test subprocess must not depend on the current directory. - - The repo-root ``conftest.py`` reopens this path on every journal write, from inside the test - process. A relative path would resolve against whatever directory the test happens to be in, - so a test using ``monkeypatch.chdir`` would write its verdicts to a journal under the - temporary directory and, once teardown restored the cwd, resume writing to this one — leaving - a test that ran and passed looking like it was never reached. - """ - orchestrator = _load_orchestrator_module() - test_file = tmp_path / "test_sample.py" - test_file.write_text("def test_present():\n pass\n", encoding="utf-8") - journal_paths: list[str] = [] - - def _capture(_cmd, _timeout, env, *, report_file: str, **_kwargs): - journal_paths.append(env[orchestrator.JOURNAL_ENV_VAR]) - _write_partial_junit_report(report_file) - return 0, b"", b"", "", 0.1, "" - - monkeypatch.setattr(orchestrator.ovrtx_log, "LOG_PATH", str(tmp_path / "ovrtx_renderer.log")) - monkeypatch.setattr(orchestrator, "capture_test_output_with_timeout", _capture) - monkeypatch.chdir(tmp_path) - context = orchestrator._PassContext( - test_file=str(test_file), - file_name=test_file.name, - workspace_root=str(tmp_path), - ci_marker=None, - timeout=10, - startup_deadline=1, - env={}, - inject_shard_select=False, - pytest_targets=[str(test_file)], - ) - - orchestrator._run_one_pass(context, k_expr=None, suffix="") - - assert len(journal_paths) == 1 - journal_path = Path(journal_paths[0]) - assert journal_path.is_absolute() - # pytest only creates the report directory in ``pytest_sessionfinish``, which a crashed run - # never reaches, so the directory has to exist before the subprocess starts journaling. - assert journal_path.parent.is_dir() - - def test_artifact_paths_handed_to_the_subprocess_are_uploadable(monkeypatch, tmp_path: Path) -> None: """Each pass must tell the test process where to save renderer logs and stack dumps. @@ -506,14 +462,20 @@ def test_artifact_paths_handed_to_the_subprocess_are_uploadable(monkeypatch, tmp are absolute for the journal's reason: the log is saved from a fixture and the dump file is opened at plugin load, so a test using ``monkeypatch.chdir`` would otherwise leave either under the temporary directory. + + The repo-root ``conftest.py`` likewise reopens the crash journal path on every journal write, from + inside the test process, so a relative journal path would send a chdir-ing test's later verdicts to + another file and leave a test that ran and passed looking like it was never reached. """ orchestrator = _load_orchestrator_module() test_file = tmp_path / "test_sample.py" test_file.write_text("def test_present():\n pass\n", encoding="utf-8") + journal_paths: list[str] = [] log_dirs: list[str] = [] dump_paths: list[str] = [] def _capture(_cmd, _timeout, env, *, report_file: str, **_kwargs): + journal_paths.append(env[orchestrator.JOURNAL_ENV_VAR]) log_dirs.append(env[orchestrator.ovrtx_log.LOG_DIR_ENV_VAR]) dump_paths.append(env[orchestrator.hang_dump.DUMP_PATH_ENV_VAR]) _write_partial_junit_report(report_file) @@ -536,6 +498,13 @@ def _capture(_cmd, _timeout, env, *, report_file: str, **_kwargs): orchestrator._run_one_pass(context, k_expr=None, suffix="") + assert len(journal_paths) == 1 + journal_path = Path(journal_paths[0]) + assert journal_path.is_absolute() + # pytest only creates the report directory in ``pytest_sessionfinish``, which a crashed run + # never reaches, so the directory has to exist before the subprocess starts journaling. + assert journal_path.parent.is_dir() + assert len(log_dirs) == 1 log_dir = Path(log_dirs[0]) assert log_dir.is_absolute() diff --git a/source/isaaclab/test/cli/test_train_multigpu_command_building.py b/source/isaaclab/test/cli/test_train_multigpu_command_building.py index b045bef6c5ae..6dfee24c8953 100644 --- a/source/isaaclab/test/cli/test_train_multigpu_command_building.py +++ b/source/isaaclab/test/cli/test_train_multigpu_command_building.py @@ -64,51 +64,9 @@ def _parse_as_training_script(child_argv: list[str], monkeypatch: pytest.MonkeyP return args.kit_args -class TestFuseKitArgs: - """Unit tests for :meth:`AppLauncher._fuse_kit_args`.""" - - def test_space_separated_option_like_value_is_fused(self): - assert AppLauncher._fuse_kit_args(["--kit_args", "--foo=/bar"]) == ["--kit_args=--foo=/bar"] - - def test_equals_attached_value_is_unchanged(self): - argv = ["--kit_args=--foo=/bar"] - assert AppLauncher._fuse_kit_args(argv) == argv - - def test_value_with_space_is_unchanged(self): - # a token containing a space cannot be mistaken for an option by argparse - argv = ["--kit_args", "--foo=/bar --baz=1"] - assert AppLauncher._fuse_kit_args(argv) == argv - - def test_non_option_value_is_unchanged(self): - argv = ["--kit_args", "foo.txt"] - assert AppLauncher._fuse_kit_args(argv) == argv - - def test_trailing_kit_args_is_unchanged(self): - # argparse should still report the missing value normally - argv = ["--task", "Isaac-Cartpole-Direct", "--kit_args"] - assert AppLauncher._fuse_kit_args(argv) == argv - - def test_multiple_occurrences_are_all_fused(self): - argv = ["--kit_args", "--foo=/a", "--task", "X", "--kit_args", "--bar=/b"] - assert AppLauncher._fuse_kit_args(argv) == ["--kit_args=--foo=/a", "--task", "X", "--kit_args=--bar=/b"] - - def test_surrounding_tokens_are_preserved(self): - argv = ["--task", "X", "--kit_args", "--foo=/bar", "--num_envs", "16"] - assert AppLauncher._fuse_kit_args(argv) == ["--task", "X", "--kit_args=--foo=/bar", "--num_envs", "16"] - - class TestAddAppLauncherArgsNormalization: """Integration tests for the ``sys.argv`` normalization in ``add_app_launcher_args``.""" - def test_space_separated_single_kit_arg_parses(self, monkeypatch): - monkeypatch.setattr(sys, "argv", ["prog", "--task", "X", "--kit_args", "--foo=/bar"]) - parser = argparse.ArgumentParser() - parser.add_argument("--task", type=str, default=None) - AppLauncher.add_app_launcher_args(parser) - args, unknown = parser.parse_known_args() - assert args.kit_args == "--foo=/bar" - assert unknown == [] - def test_unknown_leftovers_are_preserved(self, monkeypatch): monkeypatch.setattr(sys, "argv", ["prog", "--task", "X", "--kit_args", "--foo=/bar", "env.param=1"]) parser = argparse.ArgumentParser() @@ -122,15 +80,6 @@ def test_unknown_leftovers_are_preserved(self, monkeypatch): class TestKitArgsForwarding: """Tests for forwarding ``--kit_args`` through the multi-GPU launcher.""" - def test_space_separated_kit_args_forwarded_verbatim(self): - command = _build_command(["--task", "Isaac-Cartpole-Direct", "--kit_args", "--foo=/bar"]) - index = command.index("--kit_args") - assert command[index + 1] == "--foo=/bar" - - def test_equals_attached_kit_args_forwarded_unchanged(self): - command = _build_command(["--task", "Isaac-Cartpole-Direct", "--kit_args=--foo=/bar"]) - assert "--kit_args=--foo=/bar" in command - def test_multi_token_kit_args_value_forwarded_as_single_token(self): command = _build_command(["--task", "Isaac-Cartpole-Direct", "--kit_args", "--foo=/bar --baz=1"]) index = command.index("--kit_args") diff --git a/source/isaaclab/test/cli/test_uv_run_pyproject.py b/source/isaaclab/test/cli/test_uv_run_pyproject.py index febde0362989..95662410f01a 100644 --- a/source/isaaclab/test/cli/test_uv_run_pyproject.py +++ b/source/isaaclab/test/cli/test_uv_run_pyproject.py @@ -65,16 +65,12 @@ def test_uv_run_exposes_centralized_feature_extras(source_checkout_root: Path): assert "rtx" not in optional_dependencies # Concrete third-party deps live in the extras (not subpackage self-references). - # ``ov`` installs both Omniverse backends; ``ovphysx`` / ``ovrtx`` select one. - # Every Omniverse extra carries ``ovstage``, which both backends need. + # ``ov`` installs both Omniverse backends and ``ovstage``, which both backends need. + # The single-backend ``ovphysx`` / ``ovrtx`` extras are checked against the pinned versions below. assert any(dep.startswith("skrl") for dep in optional_dependencies["skrl"]) assert any(dep.startswith("ovphysx") for dep in optional_dependencies["ov"]) assert any(dep.startswith("ovrtx") for dep in optional_dependencies["ov"]) assert any(dep.startswith("ovstage") for dep in optional_dependencies["ov"]) - assert any(dep.startswith("ovphysx") for dep in optional_dependencies["ovphysx"]) - assert any(dep.startswith("ovstage") for dep in optional_dependencies["ovphysx"]) - assert any(dep.startswith("ovrtx") for dep in optional_dependencies["ovrtx"]) - assert any(dep.startswith("ovstage") for dep in optional_dependencies["ovrtx"]) def test_all_extra_aggregates_curated_ov_rl_and_visualizer_extras(source_checkout_root: Path): @@ -130,9 +126,6 @@ def test_version_single_source_matches_literal_pins(source_checkout_root: Path): optional = pyproject["project"]["optional-dependencies"] overrides = pyproject["tool"]["uv"]["override-dependencies"] - assert versions["ovphysx"] == "0.6.3" - assert "omniverseclient==2.74.0" in dependencies - # Isaac Sim extra mirrors the table; it is the only place the wheel is pinned. assert optional["isaacsim"] == [f"isaacsim[all,extscache]=={versions['isaacsim']}"] @@ -152,7 +145,7 @@ def spec(package: str) -> str: # either by carrying the literal range, or by referencing the ``resolve-ov-pins`` # action output, which reads the pin from this same table. Never a bare ``ovrtx``. build_workflow = (source_checkout_root / ".github/workflows/build.yaml").read_text(encoding="utf-8") - assert "ovphysx==0.4.13" not in build_workflow + assert all(pin == spec("ovphysx") for pin in re.findall(r"ovphysx==[\w.+]+", build_workflow)) ovrtx_install_lines = [ line.strip() for line in build_workflow.splitlines() if "extra-pip-packages:" in line and "ovrtx" in line ] diff --git a/source/isaaclab/test/cli/test_wheel_builder_metadata.py b/source/isaaclab/test/cli/test_wheel_builder_metadata.py index 52e6e57a51b1..798c1a57caaf 100644 --- a/source/isaaclab/test/cli/test_wheel_builder_metadata.py +++ b/source/isaaclab/test/cli/test_wheel_builder_metadata.py @@ -7,6 +7,7 @@ from __future__ import annotations +import re import subprocess import sys from importlib import util @@ -46,6 +47,17 @@ def _generate_wheel_pyproject(source_checkout_root: Path, tmp_path: Path) -> dic return tomllib.load(f) +@pytest.fixture(scope="module") +def generated_wheel_project(source_checkout_root: Path, tmp_path_factory) -> dict: + """Return the ``[project]`` table generated once for this module.""" + return _generate_wheel_pyproject(source_checkout_root, tmp_path_factory.mktemp("wheel"))["project"] + + +def _requirement_name(requirement: str) -> str: + """Return the normalized distribution name of a requirement string.""" + return re.split(r"[\s\[<>=!~;@]", requirement, maxsplit=1)[0].lower() + + def _generate_uv_overrides(source_checkout_root: Path, tmp_path: Path) -> list[str]: """Run ``gen_uv_overrides.py`` against the root pyproject and return its requirements.""" output = tmp_path / "uv-overrides.txt" @@ -61,18 +73,16 @@ def _generate_uv_overrides(source_checkout_root: Path, tmp_path: Path) -> list[s return output.read_text(encoding="utf-8").splitlines() -def test_wheel_builder_drops_workspace_members(source_checkout_root: Path, tmp_path): +def test_wheel_builder_drops_workspace_members(generated_wheel_project: dict): """The generated wheel metadata must not depend on the bundled ``isaaclab*`` packages.""" - generated = _generate_wheel_pyproject(source_checkout_root, tmp_path) - dependencies = generated["project"]["dependencies"] + dependencies = generated_wheel_project["dependencies"] assert not [dep for dep in dependencies if dep.lower().startswith("isaaclab")] -def test_wheel_builder_includes_template_generator_dependencies(source_checkout_root: Path, tmp_path): +def test_wheel_builder_includes_template_generator_dependencies(generated_wheel_project: dict): """The generated wheel must install everything required by the project generator.""" - generated = _generate_wheel_pyproject(source_checkout_root, tmp_path) - dependencies = set(generated["project"]["dependencies"]) + dependencies = set(generated_wheel_project["dependencies"]) assert {"Jinja2", "rich"} <= dependencies assert "InquirerPy" not in dependencies @@ -93,27 +103,18 @@ def test_wheel_console_delegates_to_the_full_isaaclab_cli(source_checkout_root: cli.assert_called_once_with() -def test_wheel_console_uses_compatibility_dispatcher(source_checkout_root: Path, tmp_path): +def test_wheel_console_uses_compatibility_dispatcher(generated_wheel_project: dict): """The generated console script must preserve legacy installed-wheel options.""" - generated = _generate_wheel_pyproject(source_checkout_root, tmp_path) - - assert generated["project"]["scripts"]["isaaclab"] == "isaaclab.__main__:main" - - -def test_wheel_builder_includes_isaacsim_extra(source_checkout_root: Path, tmp_path): - """The ``isaacsim`` extra must ship in the generated wheel metadata.""" - generated = _generate_wheel_pyproject(source_checkout_root, tmp_path) - optional_dependencies = generated["project"]["optional-dependencies"] + assert generated_wheel_project["scripts"]["isaaclab"] == "isaaclab.__main__:main" - assert "isaacsim" in optional_dependencies - assert any(dep.startswith("isaacsim[") for dep in optional_dependencies["isaacsim"]) - -def test_wheel_builder_omits_integrations_without_published_wheels(source_checkout_root: Path, tmp_path): +def test_wheel_builder_omits_integrations_without_published_wheels( + source_checkout_root: Path, generated_wheel_project: dict +): """Git-only RL-Games and Robomimic requirements must stay out of published metadata.""" with (source_checkout_root / "pyproject.toml").open("rb") as f: source_project = tomllib.load(f)["project"] - generated_project = _generate_wheel_pyproject(source_checkout_root, tmp_path)["project"] + generated_project = generated_wheel_project # Source checkouts retain the integrations through their Git requirements. assert any(dep.startswith("rl-games @ git+") for dep in source_project["optional-dependencies"]["rl-games"]) @@ -128,23 +129,21 @@ def test_wheel_builder_omits_integrations_without_published_wheels(source_checko assert not any(dep.startswith("robomimic") for dep in generated_requirements) -def test_wheel_builder_keeps_standalone_importers_explicit(source_checkout_root: Path, tmp_path): - """The wheel must expose standalone importers only through their explicit extra.""" - generated = _generate_wheel_pyproject(source_checkout_root, tmp_path) - project = generated["project"] +def test_wheel_builder_keeps_explicit_extras_opt_in(source_checkout_root: Path, generated_wheel_project: dict): + """Isaac Sim, standalone importers, and PyTetWild ship only through their explicit root extras.""" + with (source_checkout_root / "pyproject.toml").open("rb") as f: + root_optional = tomllib.load(f)["project"]["optional-dependencies"] + generated_optional = generated_wheel_project["optional-dependencies"] - assert "isaacsim-asset-isolated==6.1.0.0" not in project["dependencies"] - assert "tinyobjloader==2.0.0rc13" not in project["dependencies"] - assert project["optional-dependencies"]["importers"] == [ - "isaacsim-asset-isolated==6.1.0.0", - "tinyobjloader==2.0.0rc13", - ] + for extra in ("isaacsim", "importers", "tetrahedralization"): + assert generated_optional[extra] == root_optional[extra] + dependency_names = {_requirement_name(dep) for dep in generated_wheel_project["dependencies"]} + assert not dependency_names & {"isaacsim", "isaacsim-asset-isolated", "tinyobjloader", "pytetwild"} -def test_wheel_builder_expands_all_extra_into_concrete_requirements(source_checkout_root: Path, tmp_path): +def test_wheel_builder_expands_all_extra_into_concrete_requirements(generated_wheel_project: dict): """``isaaclab[all]`` must contain concrete curated requirements.""" - generated = _generate_wheel_pyproject(source_checkout_root, tmp_path) - optional_dependencies = generated["project"]["optional-dependencies"] + optional_dependencies = generated_wheel_project["optional-dependencies"] all_extra = optional_dependencies["all"] assert not any(dep.lower().startswith("isaaclab") for dep in all_extra) @@ -165,35 +164,20 @@ def test_wheel_builder_expands_all_extra_into_concrete_requirements(source_check assert not any(dep.startswith(prefix) for dep in all_extra), f"'{prefix}' must not be in the 'all' extra" -def test_wheel_builder_rsl_rl_pin_matches_root_pyproject(source_checkout_root: Path, tmp_path): +def test_wheel_builder_rsl_rl_pin_matches_root_pyproject(source_checkout_root: Path, generated_wheel_project: dict): """The bundled wheel metadata must install the RSL-RL version declared at the root.""" expected_pin = _root_rsl_rl_pin(source_checkout_root) - generated = _generate_wheel_pyproject(source_checkout_root, tmp_path) # RSL-RL is a core dependency (default training library) and also exposed as an extra. - core_pins = [dep for dep in generated["project"]["dependencies"] if dep.startswith("rsl-rl-lib==")] + core_pins = [dep for dep in generated_wheel_project["dependencies"] if dep.startswith("rsl-rl-lib==")] assert core_pins == [expected_pin] - optional_dependencies = generated["project"]["optional-dependencies"] + optional_dependencies = generated_wheel_project["optional-dependencies"] # RSL-RL is also exposed through its own ``rsl-rl`` extra. rsl_rl_pins = [dep for dep in optional_dependencies["rsl-rl"] if dep.startswith("rsl-rl-lib==")] assert rsl_rl_pins == [expected_pin] -def test_wheel_builder_keeps_tetrahedralization_explicit(source_checkout_root: Path, tmp_path): - """The generated wheel must expose PyTetWild only through its explicit extra.""" - generated = _generate_wheel_pyproject(source_checkout_root, tmp_path) - project = generated["project"] - optional_dependencies = project["optional-dependencies"] - - assert not any(dep.startswith("pytetwild") for dep in project["dependencies"]) - assert optional_dependencies["tetrahedralization"] == ["pytetwild[all]>=0.3.0,<0.4"] - for name, deps in optional_dependencies.items(): - if name == "tetrahedralization": - continue - assert not any(dep.startswith("pytetwild") for dep in deps) - - def test_wheel_builder_uv_overrides_match_root_pyproject(source_checkout_root: Path, tmp_path): """The wheel resolver override file must mirror the root uv overrides exactly.""" with (source_checkout_root / "pyproject.toml").open("rb") as f: diff --git a/source/isaaclab/test/cloner/test_clone_plan_algebra.py b/source/isaaclab/test/cloner/test_clone_plan_algebra.py index ab51ea1043c4..2c705e328081 100644 --- a/source/isaaclab/test/cloner/test_clone_plan_algebra.py +++ b/source/isaaclab/test/cloner/test_clone_plan_algebra.py @@ -42,14 +42,9 @@ def test_path_relative_to(): assert cloner.path.relative_to("/World/envs/env_0/Robot", root) == "" assert cloner.path.relative_to("/World/envs/env_0/RobotArm", root) is None assert cloner.path.relative_to("/World/ground", root) is None - - -def test_path_rebase(): - """rebase swaps a boundary-aligned root prefix, not a substring.""" - assert ( - cloner.path.rebase("/World/envs/env_0/Robot/base", "/World/envs/env_0", "/World/envs/env_5") - == "/World/envs/env_5/Robot/base" - ) + # The stage root is not a segment: stripping it keeps the leading slash. + assert cloner.path.relative_to("/World/envs/env_0", "/") == "/World/envs/env_0" + assert cloner.path.relative_to("/", "/") == "" def test_expand_env_regex_ns_preserves_regex_quantifiers(): @@ -60,11 +55,6 @@ def test_expand_env_regex_ns_preserves_regex_quantifiers(): assert cloner.expand_env_regex_ns(path_expr, "/World/scenes/scene_{}") == ( r"/World/scenes/scene_[^/]+/Robot/link_[0-9]{2}" ) - # boundary-safe: str.replace would corrupt this, rebase leaves it unchanged - assert ( - cloner.path.rebase("/World/envs/env_0X/Robot", "/World/envs/env_0", "/World/envs/env_5") - == "/World/envs/env_0X/Robot" - ) # (path, root) pairs spanning the ordinary cases plus the stage root and trailing slashes. @@ -95,7 +85,7 @@ def test_path_law_rebase_swaps_only_the_root(path, root, dst_root): if tail is None: assert rebased == path else: - assert rebased == (dst_root.rstrip("/") + tail) or "/" + assert rebased == ((dst_root.rstrip("/") + tail) or "/") assert cloner.path.rebase(path, root, root) == path @@ -131,14 +121,6 @@ def test_path_law_template_split(path_expr, template): assert cloner.path.relativize(path_expr, template) == matched.suffix -def test_path_stage_root_is_not_a_segment(): - """Rebasing off and onto "/" does not introduce an empty segment.""" - assert cloner.path.relative_to("/World/envs/env_0", "/") == "/World/envs/env_0" - assert cloner.path.relative_to("/", "/") == "" - assert cloner.path.rebase("/World/Robot", "/", "/Scene") == "/Scene/World/Robot" - assert cloner.path.rebase("/World/Robot", "/World", "/") == "/Robot" - - ## # Clone plans used by the query tests. Each covers a distinct shape of the source/clone # relation, and the law tests below run over all of them. @@ -181,7 +163,8 @@ def _wide_env_id_plan() -> ClonePlan: "nested_prototype": _plan( ("/World/envs/env_0/Robot", "/World/envs/env_0/Robot/wrist/Camera"), ("/World/envs/env_{}/Robot", "/World/envs/env_{}/Robot/wrist/Camera"), - [[True, True, True, True], [True, True, True, True]], + # The nested row skips env 1, so only the nearest (nested) owner can decide that env. + [[True, True, True, True], [True, False, True, True]], ), "distinct_env_root": _plan( ("/World/source/Robot",), @@ -203,29 +186,8 @@ def test_path_env_ids(): partial = _robot_plan((True, True, False, True)) assert cloner.query.path_env_ids(partial, "/World/envs/env_0/Robot/base") == (0, 1, 3) assert cloner.query.path_env_ids(_robot_plan(), "/World/ground") == () - - -def test_path_to_clone(): - """path_to_clone returns a source path's clone, or None when unowned / not mapped.""" - plan = _robot_plan((True, True, False, True)) - assert cloner.query.path_to_clone(plan, "/World/envs/env_0/Robot/base", 3) == "/World/envs/env_3/Robot/base" - assert cloner.query.path_to_clone(plan, "/World/envs/env_0/Robot/base", 2) is None - assert cloner.query.path_to_clone(plan, "/World/ground", 0) is None - - -def test_path_to_clone_heterogeneous_selects_env_owning_row(): - """In a heterogeneous plan, a source path only reaches envs its own variant row populates.""" - plan = PLANS["two_variants"] - # env 2 draws Object from the row-1 variant, so the row-0 prototype does not reach it. - assert cloner.query.path_to_clone(plan, "/World/envs/env_0/Object/base", 2) is None - assert cloner.query.path_to_clone(plan, "/World/envs/env_2/Object/base", 2) == "/World/envs/env_2/Object/base" - - -def test_path_to_clone_nested_prototype_uses_nearest_source(): - """A path inside a nested prototype is cloned by the nested row, not its ancestor.""" - plan = PLANS["nested_prototype"] - path = "/World/envs/env_0/Robot/wrist/Camera/lens" - assert cloner.query.path_to_clone(plan, path, 1) == "/World/envs/env_1/Robot/wrist/Camera/lens" + # An unowned path has no clone; owned paths are covered by the Q1/Q2 laws below. + assert cloner.query.path_to_clone(partial, "/World/ground", 0) is None def test_path_to_source_nested_templates_pick_most_specific(): @@ -265,11 +227,11 @@ def test_path_to_source_ambiguous_templates_raise(): def test_path_to_source_merges_same_template_rows(): """Heterogeneous source rows sharing one destination template resolve through a single owner.""" # One logical asset cloned from two source variants onto the same destination template. - # Neither row alone covers all envs; row 0 -> envs (0, 2), row 1 -> envs (1, 3). + # Neither row alone covers all envs; row 0 -> envs (0, 2), row 1 -> env (1); env 3 is covered by neither. plan = _plan( ("/World/envs/env_0/Object", "/World/envs/env_1/Object"), ("/World/envs/env_{}/Object", "/World/envs/env_{}/Object"), - [[True, False, True, False], [False, True, False, True]], + [[True, False, True, False], [False, True, False, False]], ) # Without an env id, the first populated row represents the asset. @@ -277,23 +239,10 @@ def test_path_to_source_merges_same_template_rows(): assert resolved == ("/World/envs/env_0/Object", "/World/envs/env_[^/]+/Object", "/Body/Camera") # With an env id, the variant that actually populates that env is reported. - resolved = cloner.query.path_to_source(plan, "/World/envs/env_[^/]+/Object/Body/Camera", env_id=3) + resolved = cloner.query.path_to_source(plan, "/World/envs/env_[^/]+/Object/Body/Camera", env_id=1) assert resolved == ("/World/envs/env_1/Object", "/World/envs/env_[^/]+/Object", "/Body/Camera") - -def test_path_to_source_partial_coverage_returns(): - """Partial-env coverage is allowed: env 3 uncovered by either row does not raise.""" - # Row 0 -> envs (0, 2), row 1 -> env (1); env 3 is covered by neither row. - plan = _plan( - ("/World/envs/env_0/Object", "/World/envs/env_1/Object"), - ("/World/envs/env_{}/Object", "/World/envs/env_{}/Object"), - [[True, False, True, False], [False, True, False, False]], - ) - - resolved = cloner.query.path_to_source(plan, "/World/envs/env_[^/]+/Object/Body/Camera") - assert resolved == ("/World/envs/env_0/Object", "/World/envs/env_[^/]+/Object", "/Body/Camera") - - # No row populates env 3, so resolving for that env reports nothing. + # Partial-env coverage is allowed: no row populates env 3, so resolving for it reports nothing. assert cloner.query.path_to_source(plan, "/World/envs/env_[^/]+/Object/Body/Camera", env_id=3) is None @@ -308,8 +257,8 @@ def test_path_to_source_inactive_rows_return_none(): assert cloner.query.path_to_source(plan, "/World/envs/env_[^/]+/Object/Body") is None -def test_iter_sources_yields_nearest_owner(): - """ClonePlan rows can be matched by destination path expression.""" +def test_iter_sources_yields_each_populating_row(): + """Every variant row behind a destination template is yielded with only the envs it populates.""" plan = _plan( ("/World/envs/env_0/Object", "/World/envs/env_1/Object"), ("/World/envs/env_{}/Object", "/World/envs/env_{}/Object"), @@ -334,24 +283,6 @@ def test_iter_sources_yields_nearest_owner(): ] -def test_iter_sources_reports_only_the_envs_a_row_populates(): - """A row covering part of the envs is a source for those envs only.""" - plan = _plan( - ("/World/envs/env_2/Object",), - ("/World/envs/env_{}/Object",), - [[False, False, True, True]], - ) - - assert list(cloner.query.iter_sources(plan, "/World/envs/env_[^/]+/Object/Body/Camera")) == [ - ( - "/World/envs/env_2/Object", - "/World/envs/env_{}/Object", - "/World/envs/env_2/Object/Body/Camera", - (2, 3), - ) - ] - - def test_iter_sources_skips_rows_without_envs(): """A nearer template populating no env does not hide the populated ancestor owning the path.""" plan = _plan( @@ -541,19 +472,6 @@ def test_query_agrees_across_duplicate_source_rows(): assert (cloner.query.path_to_clone(plan, path, env_id) is not None) == (env_id in reached) -def test_query_and_path_are_real_modules(): - """``cloner.path``/``cloner.query`` import as modules, not just package attributes.""" - import isaaclab.cloner.path # noqa: PLC0415 - import isaaclab.cloner.query # noqa: PLC0415 - from isaaclab.cloner.path import under # noqa: PLC0415 - from isaaclab.cloner.query import path_to_source # noqa: PLC0415 - - assert isaaclab.cloner.path.__name__ == "isaaclab.cloner.path" - assert isaaclab.cloner.query.__name__ == "isaaclab.cloner.query" - assert under is cloner.path.under - assert path_to_source is cloner.query.path_to_source - - def test_cloner_imports_without_kit(): """Importing the package in a clean interpreter must not drag in pxr. diff --git a/source/isaaclab/test/cloner/test_replicate_session.py b/source/isaaclab/test/cloner/test_replicate_session.py index 3c576e8660d2..db0243130d97 100644 --- a/source/isaaclab/test/cloner/test_replicate_session.py +++ b/source/isaaclab/test/cloner/test_replicate_session.py @@ -142,13 +142,6 @@ def test_camera_registers_rendering_before_planning_and_shares_the_plan(simulati assert all(received is plan for _, received in simulation.calls) -def test_context_routing_requires_every_asset_row(simulation): - """An incomplete cfg-row mapping must fail instead of silently dropping an asset.""" - cfg = SimpleNamespace(cloning_contexts=None) - with pytest.raises(KeyError): - clone_plan._context_rows((cfg,), {}, {0}) - - @pytest.mark.parametrize("valid_set", [np.asarray([["0"]]), np.asarray([[0 + 1j]])]) def test_make_clone_plan_rejects_non_integer_combinations(valid_set): """Prototype indices must be integer data rather than values NumPy can coerce to integers.""" @@ -160,11 +153,14 @@ def test_make_clone_plan_rejects_non_integer_combinations(valid_set): make_clone_plan((cfg,), 2, 1.0, valid_set=valid_set) -def test_grid_transforms_always_returns_float32(): - """NumPy scalar inputs do not widen the public transform arrays.""" - positions, orientations = clone_plan.grid_transforms(2, np.float64(1.0)) +def test_grid_transforms_centers_a_float32_grid(): + """Three envs fill a centered 2x2 grid row by row, and NumPy scalar inputs do not widen the arrays.""" + positions, orientations = clone_plan.grid_transforms(3, np.float64(2.0)) assert positions.dtype == orientations.dtype == np.float32 + # Hand-computed: rows run along -x, columns along +y, both centered on the origin. + np.testing.assert_array_equal(positions, [[1.0, -1.0, 0.0], [1.0, 1.0, 0.0], [-1.0, -1.0, 0.0]]) + np.testing.assert_array_equal(orientations, [[0.0, 0.0, 0.0, 1.0]] * 3) def test_replicate_dispatches_the_same_plan_in_priority_order(simulation): diff --git a/source/isaaclab/test/cloner/test_usd_replicate_ancestors.py b/source/isaaclab/test/cloner/test_usd_replicate_ancestors.py index 6b0b5113fbb9..767570ffd10a 100644 --- a/source/isaaclab/test/cloner/test_usd_replicate_ancestors.py +++ b/source/isaaclab/test/cloner/test_usd_replicate_ancestors.py @@ -20,15 +20,17 @@ def _make_stage_with_source(source_path: str) -> Usd.Stage: def test_usd_replicate_defines_nested_destination_ancestors(): - """Copied prims under a nested scope compose as defined prims in target envs.""" + """Copied prims under a nested scope compose as defined prims, keeping ancestors a target env already defines.""" stage = _make_stage_with_source("/World/envs/env_0/Groceries/Object") stage.DefinePrim("/World/envs/env_1", "Xform") + for prefix in Sdf.Path("/World/envs/env_2/Groceries").GetPrefixes(): + stage.DefinePrim(prefix, "Xform") usd_replicate( stage, sources=["/World/envs/env_0/Groceries/Object"], destinations=["/World/envs/env_{}/Groceries/Object"], - env_ids=np.asarray([0, 1], dtype=np.int64), + env_ids=np.asarray([0, 1, 2], dtype=np.int64), ) copied_scope = stage.GetPrimAtPath("/World/envs/env_1/Groceries") @@ -36,21 +38,8 @@ def test_usd_replicate_defines_nested_destination_ancestors(): assert copied_scope.IsDefined(), "intermediate ancestor must compose as a defined prim" assert copied_prim.IsDefined(), "copied prim must compose as a defined prim" - -def test_usd_replicate_keeps_existing_ancestor_specs(): - """Ancestors already defined in the target env are left untouched.""" - stage = _make_stage_with_source("/World/envs/env_0/Groceries/Object") - for prefix in Sdf.Path("/World/envs/env_1/Groceries").GetPrefixes(): - stage.DefinePrim(prefix, "Xform") - - usd_replicate( - stage, - sources=["/World/envs/env_0/Groceries/Object"], - destinations=["/World/envs/env_{}/Groceries/Object"], - env_ids=np.asarray([0, 1], dtype=np.int64), - ) - - scope = stage.GetPrimAtPath("/World/envs/env_1/Groceries") - assert scope.IsDefined() - assert scope.GetTypeName() == "Xform" - assert stage.GetPrimAtPath("/World/envs/env_1/Groceries/Object").IsDefined() + # An ancestor already defined in the target env is left untouched. + existing_scope = stage.GetPrimAtPath("/World/envs/env_2/Groceries") + assert existing_scope.IsDefined() + assert existing_scope.GetTypeName() == "Xform" + assert stage.GetPrimAtPath("/World/envs/env_2/Groceries/Object").IsDefined() diff --git a/source/isaaclab/test/cloner/test_usd_replicate_relationships.py b/source/isaaclab/test/cloner/test_usd_replicate_relationships.py index 6efc7dcb9ad5..5f5cbfa6afa9 100644 --- a/source/isaaclab/test/cloner/test_usd_replicate_relationships.py +++ b/source/isaaclab/test/cloner/test_usd_replicate_relationships.py @@ -5,9 +5,6 @@ """Relationship-path contracts for heterogeneous USD replication.""" -import ast -from pathlib import Path - import numpy as np from pxr import Sdf, Usd @@ -51,13 +48,3 @@ def test_asset_clone_uses_native_relationship_path_mapping() -> None: assert tuple(cloned_input.connectionPathList.explicitItems) == (Sdf.Path("/World/envs/env_2/Robot/source.output"),) cloned_body = layer.GetRelationshipAtPath("/World/envs/env_2/Robot/joint.physics:body1") assert cloned_body.targetPathList.explicitItems == [Sdf.Path("/World/envs/env_2/Robot/target")] - - -def test_usd_replicate_keeps_native_copy_spec_path_semantics() -> None: - module = Path(__file__).parents[2] / "isaaclab" / "cloner" / "usd.py" - calls = [ - node - for node in ast.walk(ast.parse(module.read_text())) - if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr == "CopySpec" - ] - assert calls and all(len(call.args) == 4 and not call.keywords for call in calls) diff --git a/source/isaaclab/test/controllers/test_controller_utils.py b/source/isaaclab/test/controllers/test_controller_utils.py index baa64c4c66c7..095ec8ac9c9d 100644 --- a/source/isaaclab/test/controllers/test_controller_utils.py +++ b/source/isaaclab/test/controllers/test_controller_utils.py @@ -29,6 +29,7 @@ convert_usd_to_urdf, resolve_rmpflow_path, ) +from isaaclab.test.utils import DeviceScope, test_devices from isaaclab.utils.assets import ISAACLAB_NUCLEUS_DIR, retrieve_file_path from isaaclab.utils.io.torchscript import load_torchscript_model @@ -234,61 +235,25 @@ def test_franka_rmpflow_config_resolves_motion_generation_path(): # ============================================================================= -# Test cases for change_revolute_to_fixed function +# Test cases for change_revolute_to_fixed and change_revolute_to_fixed_regex functions # ============================================================================= -def test_single_joint_conversion(test_urdf_file, mock_urdf_content): - """Test converting a single revolute joint to fixed.""" - # Test converting shoulder_to_elbow joint - fixed_joints = ["shoulder_to_elbow"] - change_revolute_to_fixed(test_urdf_file, fixed_joints) - - # Read the modified file - with open(test_urdf_file) as f: - modified_content = f.read() - - # Check that the joint was converted - assert '' in modified_content - assert '' not in modified_content - - # Check that other revolute joints remain unchanged - assert '' in modified_content - assert '' in modified_content - - -def test_multiple_joints_conversion(test_urdf_file, mock_urdf_content): - """Test converting multiple revolute joints to fixed.""" - # Test converting multiple joints - fixed_joints = ["base_to_shoulder", "elbow_to_wrist"] - change_revolute_to_fixed(test_urdf_file, fixed_joints) - - # Read the modified file - with open(test_urdf_file) as f: - modified_content = f.read() - - # Check that both joints were converted - assert '' in modified_content - assert '' in modified_content - assert '' not in modified_content - assert '' not in modified_content - - # Check that the middle joint remains unchanged - assert '' in modified_content - +def _read(path: str) -> str: + with open(path) as f: + return f.read() -def test_non_existent_joint(test_urdf_file, mock_urdf_content): - """Test behavior when trying to convert a non-existent joint.""" - # Try to convert a joint that doesn't exist - fixed_joints = ["non_existent_joint"] - change_revolute_to_fixed(test_urdf_file, fixed_joints) - # Read the modified file - with open(test_urdf_file) as f: - modified_content = f.read() +@pytest.mark.parametrize("convert", [change_revolute_to_fixed, change_revolute_to_fixed_regex]) +def test_single_joint_conversion(convert, test_urdf_file, mock_urdf_content): + """Converting one revolute joint changes only its type and preserves the rest of the file.""" + convert(test_urdf_file, ["shoulder_to_elbow"]) - # Check that the file content remains unchanged - assert modified_content == mock_urdf_content + expected = mock_urdf_content.replace( + '', '' + ) + assert expected != mock_urdf_content + assert _read(test_urdf_file) == expected def test_mixed_existent_and_non_existent_joints(test_urdf_file, mock_urdf_content): @@ -297,117 +262,36 @@ def test_mixed_existent_and_non_existent_joints(test_urdf_file, mock_urdf_conten fixed_joints = ["base_to_shoulder", "non_existent_joint", "elbow_to_wrist"] change_revolute_to_fixed(test_urdf_file, fixed_joints) - # Read the modified file - with open(test_urdf_file) as f: - modified_content = f.read() + modified_content = _read(test_urdf_file) # Check that existent joints were converted assert '' in modified_content assert '' in modified_content + assert '' not in modified_content + assert '' not in modified_content + + # Check that the joint between them remains unchanged + assert '' in modified_content # Check that non-existent joint didn't cause issues assert '' not in modified_content -def test_already_fixed_joint(test_urdf_file, mock_urdf_content): - """Test behavior when trying to convert an already fixed joint.""" - # Try to convert a joint that is already fixed - fixed_joints = ["wrist_to_gripper"] - change_revolute_to_fixed(test_urdf_file, fixed_joints) - - # Read the modified file - with open(test_urdf_file) as f: - modified_content = f.read() - - # Check that the file content remains unchanged (no conversion happened) - assert modified_content == mock_urdf_content - - -def test_empty_joints_list(test_urdf_file, mock_urdf_content): - """Test behavior when passing an empty list of joints.""" - # Try to convert with empty list - fixed_joints = [] +@pytest.mark.parametrize("fixed_joints", [["non_existent_joint"], [], ["wrist_to_gripper"]]) +def test_non_existent_joint(fixed_joints, test_urdf_file, mock_urdf_content): + """Missing, empty, and already-fixed joint lists leave the file unchanged.""" change_revolute_to_fixed(test_urdf_file, fixed_joints) - # Read the modified file - with open(test_urdf_file) as f: - modified_content = f.read() - - # Check that the file content remains unchanged - assert modified_content == mock_urdf_content + assert _read(test_urdf_file) == mock_urdf_content -def test_file_not_found(test_urdf_file): +@pytest.mark.parametrize("convert", [change_revolute_to_fixed, change_revolute_to_fixed_regex]) +def test_file_not_found(convert, test_urdf_file): """Test behavior when URDF file doesn't exist.""" non_existent_path = os.path.join(os.path.dirname(test_urdf_file), "non_existent.urdf") - fixed_joints = ["base_to_shoulder"] - # Should raise FileNotFoundError with pytest.raises(FileNotFoundError): - change_revolute_to_fixed(non_existent_path, fixed_joints) - - -def test_preserve_other_content(test_urdf_file): - """Test that other content in the URDF file is preserved.""" - fixed_joints = ["shoulder_to_elbow"] - change_revolute_to_fixed(test_urdf_file, fixed_joints) - - # Read the modified file - with open(test_urdf_file) as f: - modified_content = f.read() - - # Check that other content is preserved - assert '' in modified_content - assert '' in modified_content - assert '' in modified_content - assert '' in modified_content - assert '' in modified_content - assert '' in modified_content - - # Check that the fixed joint remains unchanged - assert '' in modified_content - - -def test_joint_attributes_preserved(test_urdf_file): - """Test that joint attributes other than type are preserved.""" - fixed_joints = ["base_to_shoulder"] - change_revolute_to_fixed(test_urdf_file, fixed_joints) - - # Read the modified file - with open(test_urdf_file) as f: - modified_content = f.read() - - # Check that the joint was converted but other attributes preserved - assert '' in modified_content - assert '' in modified_content - assert '' in modified_content - assert '' in modified_content - assert '' in modified_content - assert '' in modified_content - - -# ============================================================================= -# Test cases for change_revolute_to_fixed_regex function -# ============================================================================= - - -def test_regex_single_joint_conversion(test_urdf_file, mock_urdf_content): - """Test converting a single revolute joint to fixed using regex pattern.""" - # Test converting shoulder_to_elbow joint using exact match - fixed_joints = ["shoulder_to_elbow"] - change_revolute_to_fixed_regex(test_urdf_file, fixed_joints) - - # Read the modified file - with open(test_urdf_file) as f: - modified_content = f.read() - - # Check that the joint was converted - assert '' in modified_content - assert '' not in modified_content - - # Check that other revolute joints remain unchanged - assert '' in modified_content - assert '' in modified_content + convert(non_existent_path, ["base_to_shoulder"]) def test_regex_pattern_matching(test_urdf_file, mock_urdf_content): @@ -416,9 +300,7 @@ def test_regex_pattern_matching(test_urdf_file, mock_urdf_content): fixed_joints = [r".*to.*"] change_revolute_to_fixed_regex(test_urdf_file, fixed_joints) - # Read the modified file - with open(test_urdf_file) as f: - modified_content = f.read() + modified_content = _read(test_urdf_file) # Check that all joints with "to" in the name were converted assert '' in modified_content @@ -435,9 +317,7 @@ def test_regex_multiple_patterns(test_urdf_file, mock_urdf_content): fixed_joints = [r"^base.*", r".*wrist$"] change_revolute_to_fixed_regex(test_urdf_file, fixed_joints) - # Read the modified file - with open(test_urdf_file) as f: - modified_content = f.read() + modified_content = _read(test_urdf_file) # Check that matching joints were converted assert '' in modified_content @@ -447,164 +327,12 @@ def test_regex_multiple_patterns(test_urdf_file, mock_urdf_content): assert '' in modified_content -def test_regex_case_sensitive_matching(test_urdf_file, mock_urdf_content): - """Test that regex matching is case sensitive.""" - # Test with uppercase pattern that won't match lowercase joint names - fixed_joints = [r".*TO.*"] - change_revolute_to_fixed_regex(test_urdf_file, fixed_joints) - - # Read the modified file - with open(test_urdf_file) as f: - modified_content = f.read() - - # Check that no joints were converted (case sensitive) - assert modified_content == mock_urdf_content - - -def test_regex_partial_word_matching(test_urdf_file, mock_urdf_content): - """Test converting joints using partial word matching.""" - # Test converting joints that contain "shoulder" in their name - fixed_joints = [r".*shoulder.*"] +@pytest.mark.parametrize("fixed_joints", [[r"^nonexistent.*"], [], [r".*TO.*"], [r".*gripper.*"]]) +def test_regex_no_matches(fixed_joints, test_urdf_file, mock_urdf_content): + """Unmatched, empty, case-mismatched, and already-fixed patterns leave the file unchanged.""" change_revolute_to_fixed_regex(test_urdf_file, fixed_joints) - # Read the modified file - with open(test_urdf_file) as f: - modified_content = f.read() - - # Check that shoulder-related joints were converted - assert '' in modified_content - assert '' in modified_content - - # Check that other joints remain unchanged - assert '' in modified_content - - -def test_regex_no_matches(test_urdf_file, mock_urdf_content): - """Test behavior when regex patterns don't match any joints.""" - # Test with pattern that won't match any joint names - fixed_joints = [r"^nonexistent.*"] - change_revolute_to_fixed_regex(test_urdf_file, fixed_joints) - - # Read the modified file - with open(test_urdf_file) as f: - modified_content = f.read() - - # Check that the file content remains unchanged - assert modified_content == mock_urdf_content - - -def test_regex_empty_patterns_list(test_urdf_file, mock_urdf_content): - """Test behavior when passing an empty list of regex patterns.""" - # Try to convert with empty list - fixed_joints = [] - change_revolute_to_fixed_regex(test_urdf_file, fixed_joints) - - # Read the modified file - with open(test_urdf_file) as f: - modified_content = f.read() - - # Check that the file content remains unchanged - assert modified_content == mock_urdf_content - - -def test_regex_file_not_found(test_urdf_file): - """Test behavior when URDF file doesn't exist for regex function.""" - non_existent_path = os.path.join(os.path.dirname(test_urdf_file), "non_existent.urdf") - fixed_joints = [r".*to.*"] - - # Should raise FileNotFoundError - with pytest.raises(FileNotFoundError): - change_revolute_to_fixed_regex(non_existent_path, fixed_joints) - - -def test_regex_preserve_other_content(test_urdf_file): - """Test that other content in the URDF file is preserved with regex function.""" - fixed_joints = [r".*shoulder.*"] - change_revolute_to_fixed_regex(test_urdf_file, fixed_joints) - - # Read the modified file - with open(test_urdf_file) as f: - modified_content = f.read() - - # Check that other content is preserved - assert '' in modified_content - assert '' in modified_content - assert '' in modified_content - assert '' in modified_content - assert '' in modified_content - assert '' in modified_content - - # Check that the fixed joint remains unchanged - assert '' in modified_content - - -def test_regex_joint_attributes_preserved(test_urdf_file): - """Test that joint attributes other than type are preserved with regex function.""" - fixed_joints = [r"^base.*"] - change_revolute_to_fixed_regex(test_urdf_file, fixed_joints) - - # Read the modified file - with open(test_urdf_file) as f: - modified_content = f.read() - - # Check that the joint was converted but other attributes preserved - assert '' in modified_content - assert '' in modified_content - assert '' in modified_content - assert '' in modified_content - assert '' in modified_content - assert '' in modified_content - - -def test_regex_complex_pattern(test_urdf_file, mock_urdf_content): - """Test converting joints using a complex regex pattern.""" - # Test converting joints that have "to" and end with a word starting with "w" - fixed_joints = [r".*to.*w.*"] - change_revolute_to_fixed_regex(test_urdf_file, fixed_joints) - - # Read the modified file - with open(test_urdf_file) as f: - modified_content = f.read() - - # Check that matching joints were converted - assert '' in modified_content - assert '' in modified_content - - # Check that non-matching joints remain unchanged - assert '' in modified_content - - -def test_regex_already_fixed_joint(test_urdf_file, mock_urdf_content): - """Test behavior when regex pattern matches an already fixed joint.""" - # Try to convert joints that contain "gripper" (which is already fixed) - fixed_joints = [r".*gripper.*"] - change_revolute_to_fixed_regex(test_urdf_file, fixed_joints) - - # Read the modified file - with open(test_urdf_file) as f: - modified_content = f.read() - - # Check that the file content remains unchanged (no conversion happened) - assert modified_content == mock_urdf_content - - -def test_regex_special_characters(test_urdf_file, mock_urdf_content): - """Test regex patterns with special characters.""" - # Test with pattern that includes special regex characters - fixed_joints = [r".*to.*"] # This should match joints with "to" - change_revolute_to_fixed_regex(test_urdf_file, fixed_joints) - - # Read the modified file - with open(test_urdf_file) as f: - modified_content = f.read() - - # Check that joints with "to" were converted - assert '' in modified_content - assert '' in modified_content - assert '' in modified_content - - # Check that the fixed joint remains unchanged - assert '' in modified_content + assert _read(test_urdf_file) == mock_urdf_content # ============================================================================= @@ -619,21 +347,10 @@ def policy_model_path(): return retrieve_file_path(_policy_path) -def test_load_torchscript_model_success(policy_model_path): +@pytest.mark.parametrize("device", test_devices(DeviceScope.CPU_AND_DEFAULT_CUDA)) +def test_load_torchscript_model_success(policy_model_path, device): """Test successful loading of a TorchScript model.""" - model = load_torchscript_model(policy_model_path) - - # Check that model was loaded successfully - assert model is not None - assert isinstance(model, torch.nn.Module) - - # Check that model is in evaluation mode - assert model.training is False - - -def test_load_torchscript_model_cpu_device(policy_model_path): - """Test loading TorchScript model on CPU device.""" - model = load_torchscript_model(policy_model_path, device="cpu") + model = load_torchscript_model(policy_model_path, device=device) # Check that model was loaded successfully assert model is not None @@ -643,22 +360,6 @@ def test_load_torchscript_model_cpu_device(policy_model_path): assert model.training is False -def test_load_torchscript_model_cuda_device(policy_model_path): - """Test loading TorchScript model on CUDA device if available.""" - if torch.cuda.is_available(): - model = load_torchscript_model(policy_model_path, device="cuda") - - # Check that model was loaded successfully - assert model is not None - assert isinstance(model, torch.nn.Module) - - # Check that model is in evaluation mode - assert model.training is False - else: - # Skip test if CUDA is not available - pytest.skip("CUDA not available") - - def test_load_torchscript_model_file_not_found(): """Test behavior when TorchScript model file doesn't exist.""" non_existent_path = "non_existent_model.pt" @@ -668,107 +369,10 @@ def test_load_torchscript_model_file_not_found(): load_torchscript_model(non_existent_path) -def test_load_torchscript_model_invalid_file(): - """Test behavior when trying to load an invalid TorchScript file.""" - # Create a temporary file with invalid content - import tempfile - - with tempfile.NamedTemporaryFile(suffix=".pt", delete=False) as temp_file: - temp_file.write(b"invalid torchscript content") - temp_file_path = temp_file.name - - try: - # Should handle the error gracefully and return None - model = load_torchscript_model(temp_file_path) - assert model is None - finally: - # Clean up the temporary file - os.unlink(temp_file_path) - - -def test_load_torchscript_model_empty_file(): - """Test behavior when trying to load an empty TorchScript file.""" - # Create a temporary empty file - import tempfile - - with tempfile.NamedTemporaryFile(suffix=".pt", delete=False) as temp_file: - temp_file_path = temp_file.name - - try: - # Should handle the error gracefully and return None - model = load_torchscript_model(temp_file_path) - assert model is None - finally: - # Clean up the temporary file - os.unlink(temp_file_path) - - -def test_load_torchscript_model_different_device_mapping(policy_model_path): - """Test loading model with different device mapping.""" - # Test with specific device mapping - model = load_torchscript_model(policy_model_path, device="cpu") - - # Check that model was loaded successfully - assert model is not None - assert isinstance(model, torch.nn.Module) - - -def test_load_torchscript_model_evaluation_mode(policy_model_path): - """Test that loaded model is in evaluation mode.""" - model = load_torchscript_model(policy_model_path) - - # Check that model is in evaluation mode - assert model.training is False - - # Verify we can set it to training mode and back - model.train() - assert model.training is True - model.eval() - assert model.training is False - - -def test_load_torchscript_model_inference_capability(policy_model_path): - """Test that loaded model can perform inference.""" - model = load_torchscript_model(policy_model_path) - - # Check that model was loaded successfully - assert model is not None +@pytest.mark.parametrize("content", [b"invalid torchscript content", b""]) +def test_load_torchscript_model_invalid_file(content, tmp_path): + """Invalid or empty TorchScript files are handled gracefully and return None.""" + model_path = tmp_path / "model.pt" + model_path.write_bytes(content) - # Try to create a dummy input tensor (actual input shape depends on the model) - # This is a basic test to ensure the model can handle tensor inputs - try: - # Create a dummy input tensor (adjust size based on expected input) - dummy_input = torch.randn(1, 75) # Adjust dimensions as needed - - # Try to run inference (this might fail if input shape is wrong, but shouldn't crash) - with torch.no_grad(): - try: - output = model(dummy_input) - # If successful, check that output is a tensor - assert isinstance(output, torch.Tensor) - except (RuntimeError, ValueError): - # Expected if input shape doesn't match model expectations - # This is acceptable for this test - pass - except Exception: - # If model doesn't accept this input format, that's okay for this test - # The main goal is to ensure the model loads without crashing - pass - - -def test_load_torchscript_model_error_handling(): - """Test error handling when loading fails.""" - # Create a temporary file that will cause a loading error - import tempfile - - with tempfile.NamedTemporaryFile(suffix=".pt", delete=False) as temp_file: - temp_file.write(b"definitely not a torchscript model") - temp_file_path = temp_file.name - - try: - # Should handle the error gracefully and return None - model = load_torchscript_model(temp_file_path) - assert model is None - finally: - # Clean up the temporary file - os.unlink(temp_file_path) + assert load_torchscript_model(str(model_path)) is None diff --git a/source/isaaclab/test/controllers/test_differential_ik.py b/source/isaaclab/test/controllers/test_differential_ik.py index 33f9ebb5e613..9e433e85eab3 100644 --- a/source/isaaclab/test/controllers/test_differential_ik.py +++ b/source/isaaclab/test/controllers/test_differential_ik.py @@ -22,6 +22,7 @@ from isaaclab import cloner from isaaclab.assets import Articulation from isaaclab.controllers import DifferentialIKController, DifferentialIKControllerCfg +from isaaclab.test.utils import DeviceScope, test_devices from isaaclab.utils.math import ( # isort:skip compute_pose_error, @@ -315,16 +316,18 @@ def test_adaptive_dls_damps_singularity(): assert dq.norm().item() < dq_min.norm().item() -@pytest.mark.parametrize("gain", [0.0, 1.0]) -def test_joint_limit_avoidance_inactive_when_disabled_or_without_limits(gain: float): +@pytest.mark.parametrize("gain, with_limits", [(0.0, True), (1.0, False)]) +def test_joint_limit_avoidance_inactive_when_disabled_or_without_limits(gain: float, with_limits: bool): """Avoidance is a no-op when disabled (gain 0) or before joint limits are provided.""" ee_pos = torch.zeros(1, 3) ee_quat = torch.tensor([_ID_QUAT]) command = torch.tensor([[0.0, 0.0, 0.0] + _ID_QUAT]) - joint_pos = torch.linspace(-0.5, 0.5, _NUM_JOINTS).unsqueeze(0) + # joints near their limits, so an active avoidance term would move them + joint_pos = torch.linspace(-0.95, 0.95, _NUM_JOINTS).unsqueeze(0) + limits = (torch.full((_NUM_JOINTS,), -1.0), torch.full((_NUM_JOINTS,), 1.0)) if with_limits else None cfg = _make_cfg("adaptive_dls", joint_limit_avoidance_gain=gain) - actual = _compute(cfg, ee_pos, ee_quat, torch.ones(1, 6, _NUM_JOINTS), joint_pos, command) + actual = _compute(cfg, ee_pos, ee_quat, torch.ones(1, 6, _NUM_JOINTS), joint_pos, command, limits) torch.testing.assert_close(actual, joint_pos) @@ -370,7 +373,7 @@ def test_orientation_weight_and_joint_limit_avoidance_match_reference(): ## -@pytest.mark.parametrize("device", ["cpu"] + (["cuda:0"] if torch.cuda.is_available() else [])) +@pytest.mark.parametrize("device", test_devices(DeviceScope.CPU_AND_DEFAULT_CUDA)) def test_joint_limits_accept_float64_cpu_tensors_and_later_updates(device: str): """Float64 CPU limits are accepted before the first compute and can be updated afterwards.""" cfg = _make_cfg("trans", joint_limit_avoidance_gain=0.2) diff --git a/source/isaaclab/test/controllers/test_ik_configs/pink_ik_g1_test_configs.json b/source/isaaclab/test/controllers/test_ik_configs/pink_ik_g1_test_configs.json index 62ae4c8dd339..da49ffe611a7 100644 --- a/source/isaaclab/test/controllers/test_ik_configs/pink_ik_g1_test_configs.json +++ b/source/isaaclab/test/controllers/test_ik_configs/pink_ik_g1_test_configs.json @@ -20,19 +20,6 @@ "repeat": 2, "requires_waist_bending": false }, - "horizontal_small_movement": { - "left_hand_pose": [ - [-0.18, 0.1, 0.8, 0.0, 0.0, 0.7071, 0.7071], - [-0.19, 0.1, 0.8, 0.0, 0.0, 0.7071, 0.7071] - ], - "right_hand_pose": [ - [0.18, 0.1, 0.8, 0.0, 0.0, 0.7071, 0.7071], - [0.19, 0.1, 0.8, 0.0, 0.0, 0.7071, 0.7071] - ], - "allowed_steps_per_motion": 15, - "repeat": 2, - "requires_waist_bending": false - }, "stay_still": { "left_hand_pose": [ [-0.18, 0.1, 0.8, 0.0, 0.0, 0.7071, 0.7071], diff --git a/source/isaaclab/test/controllers/test_ik_configs/pink_ik_gr1_test_configs.json b/source/isaaclab/test/controllers/test_ik_configs/pink_ik_gr1_test_configs.json index c666198517e4..861ba5ef51d6 100644 --- a/source/isaaclab/test/controllers/test_ik_configs/pink_ik_gr1_test_configs.json +++ b/source/isaaclab/test/controllers/test_ik_configs/pink_ik_gr1_test_configs.json @@ -46,19 +46,6 @@ "repeat": 2, "requires_waist_bending": false }, - "horizontal_small_movement": { - "left_hand_pose": [ - [-0.23, 0.28, 1.1, 0.5, -0.5, 0.5, 0.5], - [-0.22, 0.32, 1.1, 0.5, -0.5, 0.5, 0.5] - ], - "right_hand_pose": [ - [0.23, 0.28, 1.1, 0.5, -0.5, 0.5, 0.5], - [0.22, 0.32, 1.1, 0.5, -0.5, 0.5, 0.5] - ], - "allowed_steps_per_motion": 8, - "repeat": 2, - "requires_waist_bending": false - }, "forward_waist_bending_movement": { "left_hand_pose": [ [-0.23, 0.28, 1.1, 0.5, -0.5, 0.5, 0.5], diff --git a/source/isaaclab/test/controllers/test_joint_impedance.py b/source/isaaclab/test/controllers/test_joint_impedance.py index 9b4f935a1e4a..85081b5a9b36 100644 --- a/source/isaaclab/test/controllers/test_joint_impedance.py +++ b/source/isaaclab/test/controllers/test_joint_impedance.py @@ -19,11 +19,18 @@ _NUM_DOF = 7 -@pytest.mark.parametrize("dtype", [torch.float32, torch.float64]) -@pytest.mark.parametrize("mode", ["fixed", "variable_kp", "variable"]) -@pytest.mark.parametrize("command_type", ["p_abs", "p_rel"]) -@pytest.mark.parametrize("inertial", [False, True]) -@pytest.mark.parametrize("gravity", [False, True]) +# The branches are independent and additive, so each mode rotates across every value of the other axes. +@pytest.mark.parametrize( + "mode, command_type, inertial, gravity, dtype", + [ + ("fixed", "p_abs", False, False, torch.float32), + ("fixed", "p_rel", True, True, torch.float64), + ("variable_kp", "p_rel", False, True, torch.float32), + ("variable_kp", "p_abs", True, False, torch.float64), + ("variable", "p_abs", True, True, torch.float32), + ("variable", "p_rel", False, False, torch.float64), + ], +) def test_compute_matches_impedance_law( mode: str, command_type: str, inertial: bool, gravity: bool, dtype: torch.dtype ) -> None: @@ -40,10 +47,11 @@ def test_compute_matches_impedance_law( damping_ratio=damping_ratio, dof_pos_offset=[offset] * _NUM_DOF, ) + # tight enough that some desired positions are clipped to the joint limits limits = torch.stack( [ - -3.0 * torch.ones(_NUM_ROBOTS, _NUM_DOF, device=device), - 3.0 * torch.ones(_NUM_ROBOTS, _NUM_DOF, device=device), + -0.3 * torch.ones(_NUM_ROBOTS, _NUM_DOF, device=device), + 0.3 * torch.ones(_NUM_ROBOTS, _NUM_DOF, device=device), ], dim=-1, ) diff --git a/source/isaaclab/test/controllers/test_local_frame_task.py b/source/isaaclab/test/controllers/test_local_frame_task.py index f96269c1e562..d5fd99a74caf 100644 --- a/source/isaaclab/test/controllers/test_local_frame_task.py +++ b/source/isaaclab/test/controllers/test_local_frame_task.py @@ -70,67 +70,13 @@ def test_initialization(local_frame_task): assert local_frame_task.transform_target_to_base is None -def test_initialization_with_sequence_costs(): - """Test initialization with sequence costs.""" - task = LocalFrameTask( - frame="link_1", - base_link_frame_name="base_link", - position_cost=[1.0, 1.0, 1.0], - orientation_cost=[1.0, 1.0, 1.0], - lm_damping=0.1, - gain=2.0, - ) - - assert task.frame == "link_1" - assert task.base_link_frame_name == "base_link" - assert np.allclose(task.cost[:3], [1.0, 1.0, 1.0]) - assert np.allclose(task.cost[3:], [1.0, 1.0, 1.0]) - assert task.lm_damping == 0.1 - assert task.gain == 2.0 - +def test_set_target_from_configuration(pink_config): + """A target taken from the configuration is the current pose relative to the base link, so the error is zero.""" + task = LocalFrameTask(frame="link_2", base_link_frame_name="link_1", position_cost=1.0, orientation_cost=1.0) + task.set_target_from_configuration(pink_config) -def test_inheritance_from_frame_task(local_frame_task): - """Test that LocalFrameTask properly inherits from FrameTask.""" - from pink.tasks.frame_task import FrameTask - - # Check inheritance - assert isinstance(local_frame_task, FrameTask) - - # Check that we can call parent class methods - assert hasattr(local_frame_task, "compute_error") - assert hasattr(local_frame_task, "compute_jacobian") - - -def test_set_target(local_frame_task): - """Test setting target with a transform.""" - # Create a test transform - target_transform = pin.SE3.Identity() - target_transform.translation = np.array([0.1, 0.2, 0.3]) - target_transform.rotation = pin.exp3(np.array([0.1, 0.0, 0.0])) - - # Set the target - local_frame_task.set_target(target_transform) - - # Check that target was set correctly - assert local_frame_task.transform_target_to_base is not None - assert isinstance(local_frame_task.transform_target_to_base, pin.SE3) - - # Check that it's a copy (not the same object) - assert local_frame_task.transform_target_to_base is not target_transform - - # Check that values match - assert np.allclose(local_frame_task.transform_target_to_base.translation, target_transform.translation) - assert np.allclose(local_frame_task.transform_target_to_base.rotation, target_transform.rotation) - - -def test_set_target_from_configuration(local_frame_task, pink_config): - """Test setting target from a robot configuration.""" - # Set target from configuration - local_frame_task.set_target_from_configuration(pink_config) - - # Check that target was set - assert local_frame_task.transform_target_to_base is not None - assert isinstance(local_frame_task.transform_target_to_base, pin.SE3) + assert isinstance(task.transform_target_to_base, pin.SE3) + assert np.allclose(task.compute_error(pink_config), 0.0, atol=1e-10) def test_set_target_from_configuration_wrong_type(local_frame_task): @@ -149,12 +95,10 @@ def test_compute_error_with_target_set(local_frame_task, pink_config): # Compute error error = local_frame_task.compute_error(pink_config) - # Check that error is computed correctly + # At q = 0, link_2 sits at (0, 0, 0.45) in the base link with identity orientation, so the target + # expressed in the link_2 frame is the translation (0.1, 0.2, 0.3 - 0.45). assert isinstance(error, np.ndarray) - assert error.shape == (6,) # 6D error (3 position + 3 orientation) - - # Error should not be all zeros (unless target exactly matches current pose) - # This is a reasonable assumption for a random target + np.testing.assert_allclose(error, [0.1, 0.2, -0.15, 0.0, 0.0, 0.0], atol=1e-10) def test_compute_error_without_target(local_frame_task, pink_config): @@ -173,24 +117,6 @@ def test_compute_error_wrong_configuration_type(local_frame_task): local_frame_task.compute_error("not_a_configuration") -def test_compute_jacobian_with_target_set(local_frame_task, pink_config): - """Test computing Jacobian when target is set.""" - # Set a target - target_transform = pin.SE3.Identity() - target_transform.translation = np.array([0.1, 0.2, 0.3]) - local_frame_task.set_target(target_transform) - - # Compute Jacobian - jacobian = local_frame_task.compute_jacobian(pink_config) - - # Check that Jacobian is computed correctly - assert isinstance(jacobian, np.ndarray) - assert jacobian.shape == (6, 2) # 6 rows (error), 2 columns (controlled joints) - - # Jacobian should not be all zeros - assert not np.allclose(jacobian, 0.0) - - def test_compute_jacobian_without_target(local_frame_task, pink_config): """Test that compute_jacobian raises error when no target is set.""" with pytest.raises(Exception, match="no target set for frame 'link_2'"): @@ -256,37 +182,6 @@ def test_error_zero_at_target_pose(local_frame_task, pink_config): assert np.allclose(error, 0.0, atol=1e-10) -def test_different_frames(pink_config): - """Test LocalFrameTask with different frame names.""" - # Test with link_1 frame - task_link1 = LocalFrameTask( - frame="link_1", - base_link_frame_name="base_link", - position_cost=1.0, - orientation_cost=1.0, - ) - - # Set target and compute error - target_transform = pin.SE3.Identity() - target_transform.translation = np.array([0.1, 0.0, 0.0]) - task_link1.set_target(target_transform) - - error_link1 = task_link1.compute_error(pink_config) - assert error_link1.shape == (6,) - - # Test with base_link frame - task_base = LocalFrameTask( - frame="base_link", - base_link_frame_name="base_link", - position_cost=1.0, - orientation_cost=1.0, - ) - - task_base.set_target(target_transform) - error_base = task_base.compute_error(pink_config) - assert error_base.shape == (6,) - - def test_different_base_frames(pink_config): """Test LocalFrameTask with different base frame names.""" # Test with base_link as base frame @@ -314,6 +209,9 @@ def test_different_base_frames(pink_config): error_link1_base = task_link1_base.compute_error(pink_config) assert error_link1_base.shape == (6,) + # The same target relative to a different base link is a different pose + assert not np.allclose(error_base_base, error_link1_base) + def test_sequence_cost_parameters(): """Test LocalFrameTask with sequence cost parameters.""" @@ -332,45 +230,6 @@ def test_sequence_cost_parameters(): assert task.gain == 2.0 -def test_error_magnitude_consistency(local_frame_task, pink_config): - """Test that error computation produces reasonable results.""" - # Set a small target offset - small_target = pin.SE3.Identity() - small_target.translation = np.array([0.01, 0.01, 0.01]) - local_frame_task.set_target(small_target) - - error_small = local_frame_task.compute_error(pink_config) - - # Set a large target offset - large_target = pin.SE3.Identity() - large_target.translation = np.array([0.5, 0.5, 0.5]) - local_frame_task.set_target(large_target) - - error_large = local_frame_task.compute_error(pink_config) - - # Both errors should be finite and reasonable - assert np.all(np.isfinite(error_small)) - assert np.all(np.isfinite(error_large)) - assert not np.allclose(error_small, error_large) # Different targets should produce different errors - - -def test_jacobian_structure(local_frame_task, pink_config): - """Test that Jacobian has the correct structure.""" - # Set a target - target_transform = pin.SE3.Identity() - target_transform.translation = np.array([0.1, 0.2, 0.3]) - local_frame_task.set_target(target_transform) - - # Compute Jacobian - jacobian = local_frame_task.compute_jacobian(pink_config) - - # Check structure - assert jacobian.shape == (6, 2) # 6 error dimensions, 2 controlled joints - - # Check that Jacobian is not all zeros (basic functionality check) - assert not np.allclose(jacobian, 0.0) - - def test_multiple_target_updates(local_frame_task, pink_config): """Test that multiple target updates work correctly.""" # Set first target @@ -391,20 +250,6 @@ def test_multiple_target_updates(local_frame_task, pink_config): assert not np.allclose(error1, error2) -def test_inheritance_behavior(local_frame_task): - """Test that LocalFrameTask properly overrides parent class methods.""" - # Check that the class has the expected methods - assert hasattr(local_frame_task, "set_target") - assert hasattr(local_frame_task, "set_target_from_configuration") - assert hasattr(local_frame_task, "compute_error") - assert hasattr(local_frame_task, "compute_jacobian") - - # Check that these are the overridden methods, not the parent ones - assert local_frame_task.set_target.__qualname__ == "LocalFrameTask.set_target" - assert local_frame_task.compute_error.__qualname__ == "LocalFrameTask.compute_error" - assert local_frame_task.compute_jacobian.__qualname__ == "LocalFrameTask.compute_jacobian" - - def test_target_copying_behavior(local_frame_task): """Test that target transforms are properly copied.""" # Create a target transform diff --git a/source/isaaclab/test/controllers/test_null_space_posture_task.py b/source/isaaclab/test/controllers/test_null_space_posture_task.py index b6e84621380f..9d08dded1a11 100644 --- a/source/isaaclab/test/controllers/test_null_space_posture_task.py +++ b/source/isaaclab/test/controllers/test_null_space_posture_task.py @@ -72,76 +72,6 @@ def tasks(self): ), ] - def test_null_space_jacobian_zero_end_effector_velocity( - self, robot_configuration, tasks, joint_configurations, num_joints - ): - """Test that velocities projected through null space Jacobian result in zero end-effector velocity.""" - # Set specific joint configuration - robot_configuration.q = joint_configurations["random"] - - # Set frame task target to a specific position in workspace - frame_task = tasks[0] - # Create pin.SE3 from position and quaternion - position = np.array([0.5, 0.3, 0.8]) # x, y, z - quaternion = pin.Quaternion(0.0, 0.0, 0.0, 1.0) # x, y, z, w (identity quaternion) - target_pose = pin.SE3(quaternion, position) - frame_task.set_target(target_pose) - - # Set null space posture task target - null_space_task = tasks[1] - target_posture = np.zeros(num_joints) - null_space_task.set_target(target_posture) - - # Get the null space Jacobian - null_space_jacobian = null_space_task.compute_jacobian(robot_configuration) - - # Get the end-effector Jacobian - frame_task_jacobian = frame_task.compute_jacobian(robot_configuration) - - # Test multiple random velocities in null space - for _ in range(10): - # Generate random joint velocity - random_velocity = np.random.randn(num_joints) * 0.1 - - # Project through null space Jacobian - null_space_velocity = null_space_jacobian @ random_velocity - - # Compute resulting end-effector velocity - ee_velocity = frame_task_jacobian @ null_space_velocity - - # The end-effector velocity should be approximately zero - assert np.allclose(ee_velocity, np.zeros(6), atol=1e-7), f"End-effector velocity not zero: {ee_velocity}" - - def test_null_space_jacobian_properties(self, robot_configuration, tasks, joint_configurations, num_joints): - """Test mathematical properties of the null space Jacobian.""" - # Set specific joint configuration - robot_configuration.q = joint_configurations["random"] - - # Set frame task target - frame_task = tasks[0] - # Create pin.SE3 from position and quaternion - position = np.array([0.3, 0.4, 0.6]) - quaternion = pin.Quaternion(0.707, 0.0, 0.0, 0.707) # w, x, y, z (90-degree rotation around X) - target_pose = pin.SE3(quaternion, position) - frame_task.set_target(target_pose) - - # Set null space posture task target - null_space_task = tasks[1] - target_posture = np.zeros(num_joints) - target_posture[0:5] = [0.1, -0.1, 0.2, -0.2, 0.0] # Set first 5 joints (controlled joints) - null_space_task.set_target(target_posture) - - # Get Jacobians - null_space_jacobian = null_space_task.compute_jacobian(robot_configuration) - ee_jacobian = robot_configuration.get_frame_jacobian("left_hand_pitch_link") - - # Test: N * J^T should be approximately zero (null space property) - # where N is the null space projector and J is the end-effector Jacobian - null_space_projection = null_space_jacobian @ ee_jacobian.T - assert np.allclose(null_space_projection, np.zeros_like(null_space_projection), atol=1e-7), ( - f"Null space projection of end-effector Jacobian not zero: {null_space_projection}" - ) - def test_null_space_jacobian_identity_when_no_frame_tasks( self, robot_configuration, joint_configurations, num_joints ): @@ -225,10 +155,11 @@ def test_compute_error_without_target(self, robot_configuration, joint_configura with pytest.raises(ValueError, match="No posture target has been set"): null_space_task.compute_error(robot_configuration) - def test_joint_masking(self, robot_configuration, joint_configurations, num_joints): - """Test that joint mask correctly filters only controlled joints.""" - - controlled_joint_names = ["waist_pitch_joint", "left_shoulder_pitch_joint", "left_elbow_pitch_joint"] + @pytest.mark.parametrize( + "controlled_joint_names", [["waist_pitch_joint", "left_shoulder_pitch_joint", "left_elbow_pitch_joint"], []] + ) + def test_joint_masking(self, robot_configuration, joint_configurations, num_joints, controlled_joint_names): + """Test that joint mask correctly filters only controlled joints, and zeroes the error when none are.""" # Create task with specific controlled joints null_space_task = NullSpacePostureTask( @@ -263,23 +194,6 @@ def test_joint_masking(self, robot_configuration, joint_configurations, num_join f"Joint mask not working correctly: expected {expected_error}, got {error}" ) - def test_empty_controlled_joints(self, robot_configuration, joint_configurations, num_joints): - """Test behavior when controlled_joints is empty.""" - null_space_task = NullSpacePostureTask( - NullSpacePostureTaskCfg(cost=1.0, controlled_frames=["left_hand_pitch_link"], controlled_joints=[]) - ) - - current_config = joint_configurations["sequential"] - target_config = np.zeros(num_joints) - - robot_configuration.q = current_config - null_space_task.set_target(target_config) - - # Error should be all zeros - error = null_space_task.compute_error(robot_configuration) - expected_error = np.zeros(num_joints) - assert np.allclose(error, expected_error), f"Error should be zero when no joints controlled: {error}" - def test_set_target_from_configuration(self, robot_configuration, joint_configurations): """Test set_target_from_configuration method.""" null_space_task = NullSpacePostureTask( diff --git a/source/isaaclab/test/controllers/test_operational_space.py b/source/isaaclab/test/controllers/test_operational_space.py index 7c4f16935df6..4d4cad6c5b11 100644 --- a/source/isaaclab/test/controllers/test_operational_space.py +++ b/source/isaaclab/test/controllers/test_operational_space.py @@ -17,7 +17,6 @@ import numpy as np import pytest import torch -from flaky import flaky pytestmark = pytest.mark.arm_ci @@ -50,6 +49,7 @@ compute_pose_error, matrix_from_quat, quat_apply_inverse, + quat_from_matrix, quat_inv, subtract_frame_transforms, ) @@ -157,22 +157,6 @@ def sim(): ], device=sim.device, ) - kp_set = torch.tensor( - [ - [200.0, 200.0, 200.0, 200.0, 200.0, 200.0], - [240.0, 240.0, 240.0, 240.0, 240.0, 240.0], - [160.0, 160.0, 160.0, 160.0, 160.0, 160.0], - ], - device=sim.device, - ) - d_ratio_set = torch.tensor( - [ - [2.0, 2.0, 2.0, 2.0, 2.0, 2.0], - [2.2, 2.2, 2.2, 2.2, 2.2, 2.2], - [1.8, 1.8, 1.8, 1.8, 1.8, 1.8], - ], - device=sim.device, - ) # Format: [x, y, z, qx, qy, qz, qw, force_x, force_y, force_z, torque_x, torque_y, torque_z] ee_goal_hybrid_set_b = torch.tensor( [ @@ -210,14 +194,8 @@ def sim(): target_rel_pose_set_b = torch.cat([ee_goal_rel_pos_set, ee_goal_rel_axisangle_set], dim=-1) # Define goals for the arm [force_xyz + torque_xyz] target_abs_wrench_set = ee_goal_abs_wrench_set_b.clone() - # Define goals for the arm [xyz + quat_xyzw] and variable kp [kp_xyz + kp_rot_xyz] - target_abs_pose_variable_kp_set = torch.cat([target_abs_pose_set_b, kp_set], dim=-1) - # Define goals for the arm [xyz + quat_xyzw] and the variable imp. [kp_xyz + kp_rot_xyz + d_xyz + d_rot_xyz] - target_abs_pose_variable_set = torch.cat([target_abs_pose_set_b, kp_set, d_ratio_set], dim=-1) # Define goals for the arm pose [xyz + quat_xyzw] and wrench [force_xyz + torque_xyz] target_hybrid_set_b = ee_goal_hybrid_set_b.clone() - # Define goals for the arm pose, and wrench, and kp - target_hybrid_variable_kp_set = torch.cat([target_hybrid_set_b, kp_set], dim=-1) # Define goals for the arm pose [xyz + quat_xyzw] in root and and wrench [force_xyz + torque_xyz] in task frame target_hybrid_set_tilted = torch.cat([ee_goal_pose_set_tilted_b, ee_goal_wrench_set_tilted_task], dim=-1) @@ -236,10 +214,7 @@ def sim(): target_rel_pos_set, target_rel_pose_set_b, target_abs_wrench_set, - target_abs_pose_variable_kp_set, - target_abs_pose_variable_set, target_hybrid_set_b, - target_hybrid_variable_kp_set, target_hybrid_set_tilted, frame, ) @@ -249,106 +224,6 @@ def sim(): sim.clear_instance() -@pytest.mark.isaacsim_ci -def test_franka_pose_abs_without_inertial_decoupling(sim): - """Test absolute pose control with fixed impedance and without inertial dynamics decoupling.""" - ( - sim_context, - num_envs, - robot_cfg, - ee_marker, - goal_marker, - contact_forces, - _, - target_abs_pose_set_b, - _, - _, - _, - _, - _, - _, - _, - _, - frame, - ) = sim - - robot = Articulation(cfg=robot_cfg) - osc_cfg = OperationalSpaceControllerCfg( - target_types=["pose_abs"], - impedance_mode="fixed", - inertial_dynamics_decoupling=False, - gravity_compensation=False, - motion_stiffness_task=[400.0, 400.0, 400.0, 100.0, 100.0, 100.0], - motion_damping_ratio_task=[5.0, 5.0, 5.0, 0.001, 0.001, 0.001], - ) - osc = OperationalSpaceController(osc_cfg, num_envs=num_envs, device=sim_context.device) - - _run_op_space_controller( - robot, - osc, - "panda_hand", - ["panda_joint.*"], - target_abs_pose_set_b, - sim_context, - num_envs, - ee_marker, - goal_marker, - contact_forces, - frame, - ) - - -@pytest.mark.isaacsim_ci -def test_franka_pose_abs_with_partial_inertial_decoupling(sim): - """Test absolute pose control with fixed impedance and partial inertial dynamics decoupling.""" - ( - sim_context, - num_envs, - robot_cfg, - ee_marker, - goal_marker, - contact_forces, - _, - target_abs_pose_set_b, - _, - _, - _, - _, - _, - _, - _, - _, - frame, - ) = sim - - robot = Articulation(cfg=robot_cfg) - osc_cfg = OperationalSpaceControllerCfg( - target_types=["pose_abs"], - impedance_mode="fixed", - inertial_dynamics_decoupling=True, - partial_inertial_dynamics_decoupling=True, - gravity_compensation=False, - motion_stiffness_task=1000.0, - motion_damping_ratio_task=1.0, - ) - osc = OperationalSpaceController(osc_cfg, num_envs=num_envs, device=sim_context.device) - - _run_op_space_controller( - robot, - osc, - "panda_hand", - ["panda_joint.*"], - target_abs_pose_set_b, - sim_context, - num_envs, - ee_marker, - goal_marker, - contact_forces, - frame, - rotation_tolerance=0.12, - ) - - @pytest.mark.isaacsim_ci def test_franka_pose_abs_fixed_impedance_with_gravity_compensation(sim): """Test absolute pose control with fixed impedance, gravity compensation, and inertial dynamics decoupling.""" @@ -366,9 +241,6 @@ def test_franka_pose_abs_fixed_impedance_with_gravity_compensation(sim): _, _, _, - _, - _, - _, frame, ) = sim @@ -400,549 +272,9 @@ def test_franka_pose_abs_fixed_impedance_with_gravity_compensation(sim): ) -@pytest.mark.isaacsim_ci -def test_franka_pose_abs(sim): - """Test absolute pose control with fixed impedance and inertial dynamics decoupling.""" - ( - sim_context, - num_envs, - robot_cfg, - ee_marker, - goal_marker, - contact_forces, - _, - target_abs_pose_set_b, - _, - _, - _, - _, - _, - _, - _, - _, - frame, - ) = sim - - robot = Articulation(cfg=robot_cfg) - osc_cfg = OperationalSpaceControllerCfg( - target_types=["pose_abs"], - impedance_mode="fixed", - inertial_dynamics_decoupling=True, - partial_inertial_dynamics_decoupling=False, - gravity_compensation=False, - motion_stiffness_task=500.0, - motion_damping_ratio_task=1.0, - ) - osc = OperationalSpaceController(osc_cfg, num_envs=num_envs, device=sim_context.device) - - _run_op_space_controller( - robot, - osc, - "panda_hand", - ["panda_joint.*"], - target_abs_pose_set_b, - sim_context, - num_envs, - ee_marker, - goal_marker, - contact_forces, - frame, - ) - - -@pytest.mark.isaacsim_ci -def test_franka_pose_rel(sim): - """Test relative pose control with fixed impedance and inertial dynamics decoupling.""" - ( - sim_context, - num_envs, - robot_cfg, - ee_marker, - goal_marker, - contact_forces, - _, - _, - _, - target_rel_pose_set_b, - _, - _, - _, - _, - _, - _, - frame, - ) = sim - - robot = Articulation(cfg=robot_cfg) - osc_cfg = OperationalSpaceControllerCfg( - target_types=["pose_rel"], - impedance_mode="fixed", - inertial_dynamics_decoupling=True, - partial_inertial_dynamics_decoupling=False, - gravity_compensation=False, - motion_stiffness_task=500.0, - motion_damping_ratio_task=1.0, - ) - osc = OperationalSpaceController(osc_cfg, num_envs=num_envs, device=sim_context.device) - - _run_op_space_controller( - robot, - osc, - "panda_hand", - ["panda_joint.*"], - target_rel_pose_set_b, - sim_context, - num_envs, - ee_marker, - goal_marker, - contact_forces, - frame, - ) - - -@pytest.mark.isaacsim_ci -def test_franka_pose_abs_variable_impedance(sim): - """Test absolute pose control with variable impedance and inertial dynamics decoupling.""" - ( - sim_context, - num_envs, - robot_cfg, - ee_marker, - goal_marker, - contact_forces, - _, - _, - _, - _, - _, - _, - target_abs_pose_variable_set, - _, - _, - _, - frame, - ) = sim - - robot = Articulation(cfg=robot_cfg) - osc_cfg = OperationalSpaceControllerCfg( - target_types=["pose_abs"], - impedance_mode="variable", - inertial_dynamics_decoupling=True, - partial_inertial_dynamics_decoupling=False, - gravity_compensation=False, - ) - osc = OperationalSpaceController(osc_cfg, num_envs=num_envs, device=sim_context.device) - - _run_op_space_controller( - robot, - osc, - "panda_hand", - ["panda_joint.*"], - target_abs_pose_variable_set, - sim_context, - num_envs, - ee_marker, - goal_marker, - contact_forces, - frame, - ) - - -@pytest.mark.isaacsim_ci -def test_franka_wrench_abs_open_loop(sim): - """Test open loop absolute force control.""" - ( - sim_context, - num_envs, - robot_cfg, - ee_marker, - goal_marker, - contact_forces, - _, - _, - _, - _, - target_abs_wrench_set, - _, - _, - _, - _, - _, - frame, - ) = sim - - robot = Articulation(cfg=robot_cfg) - - obstacle_spawn_cfg = sim_utils.CuboidCfg( - size=(0.7, 0.7, 0.01), - collision_props=sim_utils.UsdPhysicsCollisionCfg(), - visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(1.0, 0.0, 0.0), opacity=0.1), - rigid_props=sim_utils.UsdPhysicsRigidBodyCfg(kinematic_enabled=True), - activate_contact_sensors=True, - ) - obstacle_spawn_cfg.func( - "/World/envs/env_[^/]+/obstacle1", - obstacle_spawn_cfg, - translation=(0.2, 0.0, 0.93), - orientation=(0.0, -0.1736, 0.0, 0.9848), - ) - obstacle_spawn_cfg.func( - "/World/envs/env_[^/]+/obstacle2", - obstacle_spawn_cfg, - translation=(0.2, 0.35, 0.7), - orientation=(0.707, 0.0, 0.0, 0.707), - ) - obstacle_spawn_cfg.func( - "/World/envs/env_[^/]+/obstacle3", - obstacle_spawn_cfg, - translation=(0.55, 0.0, 0.7), - orientation=(0.0, 0.707, 0.0, 0.707), - ) - contact_forces_cfg = ContactSensorCfg( - prim_path="{ENV_REGEX_NS}/obstacle[^/]*", - update_period=0.0, - history_length=50, - debug_vis=False, - force_threshold=0.1, - ) - contact_forces = ContactSensor(contact_forces_cfg) - - osc_cfg = OperationalSpaceControllerCfg( - target_types=["wrench_abs"], - motion_control_axes_task=[0, 0, 0, 0, 0, 0], - contact_wrench_control_axes_task=[1, 1, 1, 1, 1, 1], - ) - osc = OperationalSpaceController(osc_cfg, num_envs=num_envs, device=sim_context.device) - - _run_op_space_controller( - robot, - osc, - "panda_hand", - ["panda_joint.*"], - target_abs_wrench_set, - sim_context, - num_envs, - ee_marker, - goal_marker, - contact_forces, - frame, - ) - - -@pytest.mark.isaacsim_ci -def test_franka_wrench_abs_closed_loop(sim): - """Test closed loop absolute force control.""" - ( - sim_context, - num_envs, - robot_cfg, - ee_marker, - goal_marker, - contact_forces, - _, - _, - _, - _, - target_abs_wrench_set, - _, - _, - _, - _, - _, - frame, - ) = sim - - robot = Articulation(cfg=robot_cfg) - - obstacle_spawn_cfg = sim_utils.CuboidCfg( - size=(0.7, 0.7, 0.01), - collision_props=sim_utils.UsdPhysicsCollisionCfg(), - visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(1.0, 0.0, 0.0), opacity=0.1), - rigid_props=sim_utils.UsdPhysicsRigidBodyCfg(kinematic_enabled=True), - activate_contact_sensors=True, - ) - obstacle_spawn_cfg.func( - "/World/envs/env_[^/]+/obstacle1", - obstacle_spawn_cfg, - translation=(0.2, 0.0, 0.93), - orientation=(0.0, -0.1736, 0.0, 0.9848), - ) - obstacle_spawn_cfg.func( - "/World/envs/env_[^/]+/obstacle2", - obstacle_spawn_cfg, - translation=(0.2, 0.35, 0.7), - orientation=(0.707, 0.0, 0.0, 0.707), - ) - obstacle_spawn_cfg.func( - "/World/envs/env_[^/]+/obstacle3", - obstacle_spawn_cfg, - translation=(0.55, 0.0, 0.7), - orientation=(0.0, 0.707, 0.0, 0.707), - ) - contact_forces_cfg = ContactSensorCfg( - prim_path="{ENV_REGEX_NS}/obstacle[^/]*", - update_period=0.0, - history_length=2, - debug_vis=False, - force_threshold=0.1, - ) - contact_forces = ContactSensor(contact_forces_cfg) - - osc_cfg = OperationalSpaceControllerCfg( - target_types=["wrench_abs"], - contact_wrench_stiffness_task=[ - 0.2, - 0.2, - 0.2, - 0.0, - 0.0, - 0.0, - ], # Zero torque feedback as we cannot contact torque - motion_control_axes_task=[0, 0, 0, 0, 0, 0], - contact_wrench_control_axes_task=[1, 1, 1, 1, 1, 1], - ) - osc = OperationalSpaceController(osc_cfg, num_envs=num_envs, device=sim_context.device) - - _run_op_space_controller( - robot, - osc, - "panda_hand", - ["panda_joint.*"], - target_abs_wrench_set, - sim_context, - num_envs, - ee_marker, - goal_marker, - contact_forces, - frame, - ) - - -@pytest.mark.isaacsim_ci -def test_franka_hybrid_decoupled_motion(sim): - """Test hybrid control with fixed impedance and partial inertial dynamics decoupling.""" - ( - sim_context, - num_envs, - robot_cfg, - ee_marker, - goal_marker, - contact_forces, - _, - _, - _, - _, - _, - _, - _, - target_hybrid_set_b, - _, - _, - frame, - ) = sim - - robot = Articulation(cfg=robot_cfg) - - obstacle_spawn_cfg = sim_utils.CuboidCfg( - size=(1.0, 1.0, 0.01), - collision_props=sim_utils.UsdPhysicsCollisionCfg(), - visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(1.0, 0.0, 0.0), opacity=0.1), - rigid_props=sim_utils.UsdPhysicsRigidBodyCfg(kinematic_enabled=True), - activate_contact_sensors=True, - ) - obstacle_spawn_cfg.func( - "/World/envs/env_[^/]+/obstacle1", - obstacle_spawn_cfg, - translation=(target_hybrid_set_b[0, 0] + 0.05, 0.0, 0.7), - orientation=(0.0, 0.707, 0.0, 0.707), - ) - contact_forces_cfg = ContactSensorCfg( - prim_path="{ENV_REGEX_NS}/obstacle[^/]*", - update_period=0.0, - history_length=2, - debug_vis=False, - force_threshold=0.1, - ) - contact_forces = ContactSensor(contact_forces_cfg) - - osc_cfg = OperationalSpaceControllerCfg( - target_types=["pose_abs", "wrench_abs"], - impedance_mode="fixed", - inertial_dynamics_decoupling=True, - partial_inertial_dynamics_decoupling=True, - gravity_compensation=False, - motion_stiffness_task=300.0, - motion_damping_ratio_task=1.0, - contact_wrench_stiffness_task=[0.1, 0.0, 0.0, 0.0, 0.0, 0.0], - motion_control_axes_task=[0, 1, 1, 1, 1, 1], - contact_wrench_control_axes_task=[1, 0, 0, 0, 0, 0], - ) - osc = OperationalSpaceController(osc_cfg, num_envs=num_envs, device=sim_context.device) - - _run_op_space_controller( - robot, - osc, - "panda_leftfinger", - ["panda_joint.*"], - target_hybrid_set_b, - sim_context, - num_envs, - ee_marker, - goal_marker, - contact_forces, - frame, - ) - - -@pytest.mark.isaacsim_ci -@flaky(max_runs=3, min_passes=1) -def test_franka_hybrid_variable_kp_impedance(sim): - """Test hybrid control with variable kp impedance and inertial dynamics decoupling.""" - ( - sim_context, - num_envs, - robot_cfg, - ee_marker, - goal_marker, - contact_forces, - _, - _, - _, - _, - _, - _, - _, - target_hybrid_set_b, - target_hybrid_variable_kp_set, - _, - frame, - ) = sim - - robot = Articulation(cfg=robot_cfg) - - obstacle_spawn_cfg = sim_utils.CuboidCfg( - size=(1.0, 1.0, 0.01), - collision_props=sim_utils.UsdPhysicsCollisionCfg(), - visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(1.0, 0.0, 0.0), opacity=0.1), - rigid_props=sim_utils.UsdPhysicsRigidBodyCfg(kinematic_enabled=True), - activate_contact_sensors=True, - ) - obstacle_spawn_cfg.func( - "/World/envs/env_[^/]+/obstacle1", - obstacle_spawn_cfg, - translation=(target_hybrid_set_b[0, 0] + 0.05, 0.0, 0.7), - orientation=(0.0, 0.707, 0.0, 0.707), - ) - contact_forces_cfg = ContactSensorCfg( - prim_path="{ENV_REGEX_NS}/obstacle[^/]*", - update_period=0.0, - history_length=2, - debug_vis=False, - force_threshold=0.1, - ) - contact_forces = ContactSensor(contact_forces_cfg) - - osc_cfg = OperationalSpaceControllerCfg( - target_types=["pose_abs", "wrench_abs"], - impedance_mode="variable_kp", - inertial_dynamics_decoupling=True, - partial_inertial_dynamics_decoupling=False, - gravity_compensation=False, - motion_damping_ratio_task=0.8, - contact_wrench_stiffness_task=[0.1, 0.0, 0.0, 0.0, 0.0, 0.0], - motion_control_axes_task=[0, 1, 1, 1, 1, 1], - contact_wrench_control_axes_task=[1, 0, 0, 0, 0, 0], - ) - osc = OperationalSpaceController(osc_cfg, num_envs=num_envs, device=sim_context.device) - - # Use more convergence steps for hybrid control which is less precise - _run_op_space_controller( - robot, - osc, - "panda_leftfinger", - ["panda_joint.*"], - target_hybrid_variable_kp_set, - sim_context, - num_envs, - ee_marker, - goal_marker, - contact_forces, - frame, - convergence_steps=750, - ) - - -@pytest.mark.isaacsim_ci -def test_task_frame_conversion_preserves_absolute_target(): - """A rounded pose command must resolve to the same target through either reference frame.""" - osc_cfg = OperationalSpaceControllerCfg(target_types=["pose_abs"]) - osc = OperationalSpaceController(osc_cfg, num_envs=1, device="cpu") - target_b = torch.tensor([[0.5, -0.4, 0.6, 0.707, 0.0, 0.0, 0.707]]) - command = target_b.clone() - resolved_targets = [] - for frame in ("root", "task"): - converted_command, task_frame_pose_b = _convert_to_task_frame(osc, command, target_b, frame) - osc.set_command(converted_command, current_task_frame_pose_b=task_frame_pose_b) - resolved_targets.append(osc.desired_ee_pose_b.clone()) - - torch.testing.assert_close(resolved_targets[0], resolved_targets[1], atol=1e-6, rtol=0.0) - torch.testing.assert_close(command, target_b, atol=0.0, rtol=0.0) - - -@pytest.mark.isaacsim_ci -def test_franka_taskframe_pose_abs(sim): - """Test absolute pose control in task frame with fixed impedance and inertial dynamics decoupling.""" - ( - sim_context, - num_envs, - robot_cfg, - ee_marker, - goal_marker, - contact_forces, - _, - target_abs_pose_set_b, - _, - _, - _, - _, - _, - _, - _, - _, - frame, - ) = sim - - robot = Articulation(cfg=robot_cfg) - frame = "task" - osc_cfg = OperationalSpaceControllerCfg( - target_types=["pose_abs"], - impedance_mode="fixed", - inertial_dynamics_decoupling=True, - partial_inertial_dynamics_decoupling=False, - gravity_compensation=False, - motion_stiffness_task=500.0, - motion_damping_ratio_task=1.0, - ) - osc = OperationalSpaceController(osc_cfg, num_envs=num_envs, device=sim_context.device) - - _run_op_space_controller( - robot, - osc, - "panda_hand", - ["panda_joint.*"], - target_abs_pose_set_b, - sim_context, - num_envs, - ee_marker, - goal_marker, - contact_forces, - frame, - ) - - -@pytest.mark.isaacsim_ci -def test_franka_taskframe_pose_rel(sim): - """Test relative pose control in task frame with fixed impedance and inertial dynamics decoupling.""" +@pytest.mark.isaacsim_ci +def test_franka_pose_abs(sim): + """Test absolute pose control with fixed impedance and inertial dynamics decoupling.""" ( sim_context, num_envs, @@ -951,10 +283,7 @@ def test_franka_taskframe_pose_rel(sim): goal_marker, contact_forces, _, - _, - _, - target_rel_pose_set_b, - _, + target_abs_pose_set_b, _, _, _, @@ -964,9 +293,8 @@ def test_franka_taskframe_pose_rel(sim): ) = sim robot = Articulation(cfg=robot_cfg) - frame = "task" osc_cfg = OperationalSpaceControllerCfg( - target_types=["pose_rel"], + target_types=["pose_abs"], impedance_mode="fixed", inertial_dynamics_decoupling=True, partial_inertial_dynamics_decoupling=False, @@ -981,7 +309,7 @@ def test_franka_taskframe_pose_rel(sim): osc, "panda_hand", ["panda_joint.*"], - target_rel_pose_set_b, + target_abs_pose_set_b, sim_context, num_envs, ee_marker, @@ -992,8 +320,8 @@ def test_franka_taskframe_pose_rel(sim): @pytest.mark.isaacsim_ci -def test_franka_taskframe_hybrid(sim): - """Test hybrid control in task frame with fixed impedance and inertial dynamics decoupling.""" +def test_franka_wrench_abs_closed_loop(sim): + """Test closed loop absolute force control.""" ( sim_context, num_envs, @@ -1005,20 +333,16 @@ def test_franka_taskframe_hybrid(sim): _, _, _, + target_abs_wrench_set, _, _, - _, - _, - _, - target_hybrid_set_tilted, frame, ) = sim robot = Articulation(cfg=robot_cfg) - frame = "task" obstacle_spawn_cfg = sim_utils.CuboidCfg( - size=(2.0, 1.5, 0.01), + size=(0.7, 0.7, 0.01), collision_props=sim_utils.UsdPhysicsCollisionCfg(), visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(1.0, 0.0, 0.0), opacity=0.1), rigid_props=sim_utils.UsdPhysicsRigidBodyCfg(kinematic_enabled=True), @@ -1027,8 +351,20 @@ def test_franka_taskframe_hybrid(sim): obstacle_spawn_cfg.func( "/World/envs/env_[^/]+/obstacle1", obstacle_spawn_cfg, - translation=(target_hybrid_set_tilted[0, 0] + 0.085, 0.0, 0.3), - orientation=(0.0, -0.3826834324, 0.0, 0.9238795325), + translation=(0.2, 0.0, 0.93), + orientation=(0.0, -0.1736, 0.0, 0.9848), + ) + obstacle_spawn_cfg.func( + "/World/envs/env_[^/]+/obstacle2", + obstacle_spawn_cfg, + translation=(0.2, 0.35, 0.7), + orientation=(0.707, 0.0, 0.0, 0.707), + ) + obstacle_spawn_cfg.func( + "/World/envs/env_[^/]+/obstacle3", + obstacle_spawn_cfg, + translation=(0.55, 0.0, 0.7), + orientation=(0.0, 0.707, 0.0, 0.707), ) contact_forces_cfg = ContactSensorCfg( prim_path="{ENV_REGEX_NS}/obstacle[^/]*", @@ -1040,25 +376,26 @@ def test_franka_taskframe_hybrid(sim): contact_forces = ContactSensor(contact_forces_cfg) osc_cfg = OperationalSpaceControllerCfg( - target_types=["pose_abs", "wrench_abs"], - impedance_mode="fixed", - inertial_dynamics_decoupling=True, - partial_inertial_dynamics_decoupling=False, - gravity_compensation=False, - motion_stiffness_task=400.0, - motion_damping_ratio_task=1.0, - contact_wrench_stiffness_task=[0.0, 0.0, 0.1, 0.0, 0.0, 0.0], - motion_control_axes_task=[1, 1, 0, 1, 1, 1], - contact_wrench_control_axes_task=[0, 0, 1, 0, 0, 0], + target_types=["wrench_abs"], + contact_wrench_stiffness_task=[ + 0.2, + 0.2, + 0.2, + 0.0, + 0.0, + 0.0, + ], # Zero torque feedback as we cannot contact torque + motion_control_axes_task=[0, 0, 0, 0, 0, 0], + contact_wrench_control_axes_task=[1, 1, 1, 1, 1, 1], ) osc = OperationalSpaceController(osc_cfg, num_envs=num_envs, device=sim_context.device) _run_op_space_controller( robot, osc, - "panda_leftfinger", + "panda_hand", ["panda_joint.*"], - target_hybrid_set_tilted, + target_abs_wrench_set, sim_context, num_envs, ee_marker, @@ -1069,8 +406,8 @@ def test_franka_taskframe_hybrid(sim): @pytest.mark.isaacsim_ci -def test_franka_pose_abs_without_inertial_decoupling_with_nullspace_centering(sim): - """Test absolute pose control with fixed impedance and nullspace centerin but without inertial decoupling.""" +def test_franka_hybrid_decoupled_motion(sim): + """Test hybrid control with fixed impedance and partial inertial dynamics decoupling.""" ( sim_context, num_envs, @@ -1079,36 +416,59 @@ def test_franka_pose_abs_without_inertial_decoupling_with_nullspace_centering(si goal_marker, contact_forces, _, - target_abs_pose_set_b, - _, - _, - _, _, _, _, _, + target_hybrid_set_b, _, frame, ) = sim robot = Articulation(cfg=robot_cfg) + + obstacle_spawn_cfg = sim_utils.CuboidCfg( + size=(1.0, 1.0, 0.01), + collision_props=sim_utils.UsdPhysicsCollisionCfg(), + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(1.0, 0.0, 0.0), opacity=0.1), + rigid_props=sim_utils.UsdPhysicsRigidBodyCfg(kinematic_enabled=True), + activate_contact_sensors=True, + ) + obstacle_spawn_cfg.func( + "/World/envs/env_[^/]+/obstacle1", + obstacle_spawn_cfg, + translation=(target_hybrid_set_b[0, 0] + 0.05, 0.0, 0.7), + orientation=(0.0, 0.707, 0.0, 0.707), + ) + contact_forces_cfg = ContactSensorCfg( + prim_path="{ENV_REGEX_NS}/obstacle[^/]*", + update_period=0.0, + history_length=2, + debug_vis=False, + force_threshold=0.1, + ) + contact_forces = ContactSensor(contact_forces_cfg) + osc_cfg = OperationalSpaceControllerCfg( - target_types=["pose_abs"], + target_types=["pose_abs", "wrench_abs"], impedance_mode="fixed", - inertial_dynamics_decoupling=False, + inertial_dynamics_decoupling=True, + partial_inertial_dynamics_decoupling=True, gravity_compensation=False, - motion_stiffness_task=[400.0, 400.0, 400.0, 100.0, 100.0, 100.0], - motion_damping_ratio_task=[5.0, 5.0, 5.0, 0.001, 0.001, 0.001], - nullspace_control="position", + motion_stiffness_task=300.0, + motion_damping_ratio_task=1.0, + contact_wrench_stiffness_task=[0.1, 0.0, 0.0, 0.0, 0.0, 0.0], + motion_control_axes_task=[0, 1, 1, 1, 1, 1], + contact_wrench_control_axes_task=[1, 0, 0, 0, 0, 0], ) osc = OperationalSpaceController(osc_cfg, num_envs=num_envs, device=sim_context.device) _run_op_space_controller( robot, osc, - "panda_hand", + "panda_leftfinger", ["panda_joint.*"], - target_abs_pose_set_b, + target_hybrid_set_b, sim_context, num_envs, ee_marker, @@ -1119,8 +479,23 @@ def test_franka_pose_abs_without_inertial_decoupling_with_nullspace_centering(si @pytest.mark.isaacsim_ci -def test_franka_pose_abs_with_partial_inertial_decoupling_nullspace_centering(sim): - """Test absolute pose control with fixed impedance, partial inertial decoupling and nullspace centering.""" +def test_task_frame_conversion_preserves_absolute_target(): + """A rounded pose command must resolve to the same target through either reference frame.""" + osc_cfg = OperationalSpaceControllerCfg(target_types=["pose_abs"]) + osc = OperationalSpaceController(osc_cfg, num_envs=1, device="cpu") + target_b = torch.tensor([[0.5, -0.4, 0.6, 0.707, 0.0, 0.0, 0.707]]) + resolved_targets = [] + for frame in ("root", "task"): + converted_command, task_frame_pose_b = _convert_to_task_frame(osc, target_b, target_b, frame) + osc.set_command(converted_command, current_task_frame_pose_b=task_frame_pose_b) + resolved_targets.append(osc.desired_ee_pose_b.clone()) + + torch.testing.assert_close(resolved_targets[0], resolved_targets[1], atol=1e-6, rtol=0.0) + + +@pytest.mark.isaacsim_ci +def test_franka_taskframe_pose_rel(sim): + """Test relative pose control in task frame with fixed impedance and inertial dynamics decoupling.""" ( sim_context, num_envs, @@ -1129,12 +504,9 @@ def test_franka_pose_abs_with_partial_inertial_decoupling_nullspace_centering(si goal_marker, contact_forces, _, - target_abs_pose_set_b, - _, - _, - _, _, _, + target_rel_pose_set_b, _, _, _, @@ -1142,16 +514,15 @@ def test_franka_pose_abs_with_partial_inertial_decoupling_nullspace_centering(si ) = sim robot = Articulation(cfg=robot_cfg) + frame = "task" osc_cfg = OperationalSpaceControllerCfg( - target_types=["pose_abs"], + target_types=["pose_rel"], impedance_mode="fixed", inertial_dynamics_decoupling=True, - partial_inertial_dynamics_decoupling=True, + partial_inertial_dynamics_decoupling=False, gravity_compensation=False, - motion_stiffness_task=1000.0, + motion_stiffness_task=500.0, motion_damping_ratio_task=1.0, - nullspace_control="position", - nullspace_stiffness=1.0, ) osc = OperationalSpaceController(osc_cfg, num_envs=num_envs, device=sim_context.device) @@ -1160,20 +531,19 @@ def test_franka_pose_abs_with_partial_inertial_decoupling_nullspace_centering(si osc, "panda_hand", ["panda_joint.*"], - target_abs_pose_set_b, + target_rel_pose_set_b, sim_context, num_envs, ee_marker, goal_marker, contact_forces, frame, - rotation_tolerance=0.12, ) @pytest.mark.isaacsim_ci -def test_franka_pose_abs_with_nullspace_centering(sim): - """Test absolute pose control with fixed impedance, inertial decoupling and nullspace centering.""" +def test_franka_taskframe_hybrid(sim): + """Test hybrid control in task frame with fixed impedance and inertial dynamics decoupling.""" ( sim_context, num_envs, @@ -1182,38 +552,60 @@ def test_franka_pose_abs_with_nullspace_centering(sim): goal_marker, contact_forces, _, - target_abs_pose_set_b, - _, - _, - _, _, _, _, _, _, + target_hybrid_set_tilted, frame, ) = sim robot = Articulation(cfg=robot_cfg) + frame = "task" + + obstacle_spawn_cfg = sim_utils.CuboidCfg( + size=(2.0, 1.5, 0.01), + collision_props=sim_utils.UsdPhysicsCollisionCfg(), + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(1.0, 0.0, 0.0), opacity=0.1), + rigid_props=sim_utils.UsdPhysicsRigidBodyCfg(kinematic_enabled=True), + activate_contact_sensors=True, + ) + obstacle_spawn_cfg.func( + "/World/envs/env_[^/]+/obstacle1", + obstacle_spawn_cfg, + translation=(target_hybrid_set_tilted[0, 0] + 0.085, 0.0, 0.3), + orientation=(0.0, -0.3826834324, 0.0, 0.9238795325), + ) + contact_forces_cfg = ContactSensorCfg( + prim_path="{ENV_REGEX_NS}/obstacle[^/]*", + update_period=0.0, + history_length=2, + debug_vis=False, + force_threshold=0.1, + ) + contact_forces = ContactSensor(contact_forces_cfg) + osc_cfg = OperationalSpaceControllerCfg( - target_types=["pose_abs"], + target_types=["pose_abs", "wrench_abs"], impedance_mode="fixed", inertial_dynamics_decoupling=True, partial_inertial_dynamics_decoupling=False, gravity_compensation=False, - motion_stiffness_task=500.0, + motion_stiffness_task=400.0, motion_damping_ratio_task=1.0, - nullspace_control="position", - nullspace_stiffness=1.0, + contact_wrench_stiffness_task=[0.0, 0.0, 0.1, 0.0, 0.0, 0.0], + motion_control_axes_task=[1, 1, 0, 1, 1, 1], + contact_wrench_control_axes_task=[0, 0, 1, 0, 0, 0], ) osc = OperationalSpaceController(osc_cfg, num_envs=num_envs, device=sim_context.device) _run_op_space_controller( robot, osc, - "panda_hand", + "panda_leftfinger", ["panda_joint.*"], - target_abs_pose_set_b, + target_hybrid_set_tilted, sim_context, num_envs, ee_marker, @@ -1224,8 +616,8 @@ def test_franka_pose_abs_with_nullspace_centering(sim): @pytest.mark.isaacsim_ci -def test_franka_taskframe_hybrid_with_nullspace_centering(sim): - """Test hybrid control in task frame with fixed impedance, inertial decoupling and nullspace centering.""" +def test_franka_pose_abs_with_nullspace_centering(sim): + """Test absolute pose control with fixed impedance, inertial decoupling and nullspace centering.""" ( sim_context, num_envs, @@ -1234,64 +626,35 @@ def test_franka_taskframe_hybrid_with_nullspace_centering(sim): goal_marker, contact_forces, _, + target_abs_pose_set_b, _, _, _, _, _, - _, - _, - _, - target_hybrid_set_tilted, frame, ) = sim robot = Articulation(cfg=robot_cfg) - frame = "task" - - obstacle_spawn_cfg = sim_utils.CuboidCfg( - size=(2.0, 1.5, 0.01), - collision_props=sim_utils.UsdPhysicsCollisionCfg(), - visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(1.0, 0.0, 0.0), opacity=0.1), - rigid_props=sim_utils.UsdPhysicsRigidBodyCfg(kinematic_enabled=True), - activate_contact_sensors=True, - ) - obstacle_spawn_cfg.func( - "/World/envs/env_[^/]+/obstacle1", - obstacle_spawn_cfg, - translation=(target_hybrid_set_tilted[0, 0] + 0.085, 0.0, 0.3), - orientation=(0.0, -0.3826834324, 0.0, 0.9238795325), - ) - contact_forces_cfg = ContactSensorCfg( - prim_path="{ENV_REGEX_NS}/obstacle[^/]*", - update_period=0.0, - history_length=2, - debug_vis=False, - force_threshold=0.1, - ) - contact_forces = ContactSensor(contact_forces_cfg) - osc_cfg = OperationalSpaceControllerCfg( - target_types=["pose_abs", "wrench_abs"], + target_types=["pose_abs"], impedance_mode="fixed", inertial_dynamics_decoupling=True, partial_inertial_dynamics_decoupling=False, gravity_compensation=False, - motion_stiffness_task=400.0, + motion_stiffness_task=500.0, motion_damping_ratio_task=1.0, - contact_wrench_stiffness_task=[0.0, 0.0, 0.1, 0.0, 0.0, 0.0], - motion_control_axes_task=[1, 1, 0, 1, 1, 1], - contact_wrench_control_axes_task=[0, 0, 1, 0, 0, 0], nullspace_control="position", + nullspace_stiffness=1.0, ) osc = OperationalSpaceController(osc_cfg, num_envs=num_envs, device=sim_context.device) _run_op_space_controller( robot, osc, - "panda_leftfinger", + "panda_hand", ["panda_joint.*"], - target_hybrid_set_tilted, + target_abs_pose_set_b, sim_context, num_envs, ee_marker, @@ -1365,9 +728,8 @@ class _FloatingBaseOscEnvCfg(ManagerBasedEnvCfg): @pytest.mark.isaacsim_ci -@pytest.mark.parametrize("feedback_source", ["test_helper", "action"]) -def test_franka_velocity_feedback_matches_jacobian(sim, feedback_source): - """Both OSC callers must measure velocity at the link origin used by the Jacobian.""" +def test_franka_velocity_feedback_matches_jacobian(sim): + """The OSC action term must measure velocity at the link origin used by the Jacobian.""" sim_context, num_envs, robot_cfg, *_ = sim robot = Articulation(cfg=robot_cfg) sim_context.reset() @@ -1387,22 +749,18 @@ def test_franka_velocity_feedback_matches_jacobian(sim, feedback_source): atol=1e-4, rtol=1e-4, ) - if feedback_source == "test_helper": - states = _update_states(robot, ee_frame_idx, arm_joint_ids, sim_context, None, num_envs) - jacobian_b, _, _, _, ee_vel_b, _, _, _, _, joint_vel = states - else: - env = SimpleNamespace(scene={"robot": robot}, sim=sim_context, num_envs=num_envs, device=sim_context.device) - action_cfg = OperationalSpaceControllerActionCfg( - asset_name="robot", - joint_names=["panda_joint.*"], - body_name="panda_hand", - controller_cfg=OperationalSpaceControllerCfg(target_types=["pose_abs"]), - ) - action_term = OperationalSpaceControllerAction(action_cfg, env) - action_term._compute_ee_jacobian() - action_term._compute_ee_velocity() - jacobian_b, ee_vel_b = action_term._jacobian_b, action_term._ee_vel_b - joint_vel = robot.data.joint_vel.torch[:, arm_joint_ids] + env = SimpleNamespace(scene={"robot": robot}, sim=sim_context, num_envs=num_envs, device=sim_context.device) + action_cfg = OperationalSpaceControllerActionCfg( + asset_name="robot", + joint_names=["panda_joint.*"], + body_name="panda_hand", + controller_cfg=OperationalSpaceControllerCfg(target_types=["pose_abs"]), + ) + action_term = OperationalSpaceControllerAction(action_cfg, env) + action_term._compute_ee_jacobian() + action_term._compute_ee_velocity() + jacobian_b, ee_vel_b = action_term._jacobian_b, action_term._ee_vel_b + joint_vel = robot.data.joint_vel.torch[:, arm_joint_ids] # With a stationary fixed base, the link twist must equal J(q) * q_dot. expected_vel_b = torch.bmm(jacobian_b, joint_vel.unsqueeze(-1)).squeeze(-1) @@ -1449,8 +807,8 @@ def test_floating_base_osc_action_term_indexing(): term_mass = action_term._mass_matrix.clone() term_gravity = action_term._gravity.clone() - # --- 5. Manually extract using the CORRECT indices (what the fix does) --- - jacobi_joint_idx = action_term._jacobi_joint_idx + # --- 5. Manually extract using the CORRECT indices: arm joints shifted past the floating-base DoFs --- + jacobi_joint_idx = [joint_id + robot.num_base_dofs for joint_id in robot.find_joints(_G1_ARM_JOINT_NAMES)[0]] full_mass_matrix = robot.data.mass_matrix.torch full_gravity = robot.data.gravity_compensation_forces.torch @@ -1506,7 +864,8 @@ def _random_quat(generator: torch.Generator) -> torch.Tensor: def _reference_efforts( - controller: OperationalSpaceController, + cfg: OperationalSpaceControllerCfg, + command: torch.Tensor, task_frame_pose_b: torch.Tensor | None, jacobian_b: torch.Tensor, ee_pose_b: torch.Tensor, @@ -1520,44 +879,89 @@ def _reference_efforts( ) -> torch.Tensor: """Evaluate the operational-space law independently in Torch. - Gains and selection axes are rotated from the task frame into the root frame here, so the reference is - independent of the controller's own frame handling. + Gains, selection axes, and targets are built here from the config and the raw command, and rotated from the + task frame into the root frame, so the reference is independent of the controller's own state. """ - cfg = controller.cfg num_envs, _, num_dof = jacobian_b.shape joint_efforts = torch.zeros(num_envs, num_dof) - rot_task_b = ( - torch.eye(3).expand(num_envs, 3, 3) if task_frame_pose_b is None else matrix_from_quat(task_frame_pose_b[:, 3:]) - ) - rot_b_task = rot_task_b.mT + if task_frame_pose_b is None: + task_frame_pose_b = torch.tensor([0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0]).repeat(num_envs, 1) + frame_pos_b = task_frame_pose_b[:, :3] + rot_task_b = matrix_from_quat(task_frame_pose_b[:, 3:]) - def to_root_frame(axis_values: torch.Tensor) -> torch.Tensor: - """Block-rotate a per-axis task-frame diagonal into a root-frame 6x6 matrix.""" - task = axis_values + def to_root_frame(axis_values) -> torch.Tensor: + """Block-rotate per-axis task-frame values into a root-frame 6x6 matrix.""" + task = torch.diag_embed(torch.as_tensor(axis_values, dtype=torch.float).expand(num_envs, 6)) root = torch.zeros_like(task) - root[:, 0:3, 0:3] = rot_task_b @ task[:, 0:3, 0:3] @ rot_b_task - root[:, 3:6, 3:6] = rot_task_b @ task[:, 3:6, 3:6] @ rot_b_task + root[:, 0:3, 0:3] = rot_task_b @ task[:, 0:3, 0:3] @ rot_task_b.mT + root[:, 3:6, 3:6] = rot_task_b @ task[:, 3:6, 3:6] @ rot_task_b.mT return root + def to_matrix(axis_angle: torch.Tensor) -> torch.Tensor: + """Rodrigues rotation through the matrix exponential of the skew-symmetric matrix.""" + skew = torch.zeros(num_envs, 3, 3) + skew[:, 0, 1], skew[:, 0, 2], skew[:, 1, 2] = -axis_angle[:, 2], axis_angle[:, 1], -axis_angle[:, 0] + return torch.linalg.matrix_exp(skew - skew.mT) + + # split the command into targets and impedance parameters + target_sizes = [7 if target_type == "pose_abs" else 6 for target_type in cfg.target_types] + impedance_size = {"fixed": 0, "variable_kp": 6, "variable": 12}[cfg.impedance_mode] + *targets, impedance = torch.split(command, [*target_sizes, impedance_size], dim=-1) + if cfg.impedance_mode == "fixed": + stiffness = torch.as_tensor(cfg.motion_stiffness_task, dtype=torch.float).expand(num_envs, 6) + else: + stiffness = impedance[:, :6].clamp(*cfg.motion_stiffness_limits_task) + if cfg.impedance_mode == "variable": + damping_ratio = impedance[:, 6:].clamp(*cfg.motion_damping_ratio_limits_task) + else: + damping_ratio = torch.as_tensor(cfg.motion_damping_ratio_task, dtype=torch.float).expand(num_envs, 6) + motion_axes = torch.tensor(cfg.motion_control_axes_task, dtype=torch.float) + motion_p_gains = motion_axes * stiffness + motion_d_gains = 2.0 * motion_p_gains.sqrt() * damping_ratio + + # resolve the targets in the root frame + desired_ee_pose_b = None + desired_ee_wrench_b = None + for target_type, target in zip(cfg.target_types, targets): + if target_type == "wrench_abs": + force_b = (rot_task_b @ target[:, :3].unsqueeze(-1)).squeeze(-1) + torque_b = (rot_task_b @ target[:, 3:].unsqueeze(-1)).squeeze(-1) + torch.cross( + frame_pos_b, force_b, dim=-1 + ) + desired_ee_wrench_b = torch.cat([force_b, torque_b], dim=-1) + continue + if target_type == "pose_abs": + desired_pos_task = target[:, :3] + desired_rot_task = matrix_from_quat(target[:, 3:] / target[:, 3:].norm(dim=-1, keepdim=True)) + else: + # pose_rel: displace the current end-effector pose expressed in the task frame + ee_pos_task = (rot_task_b.mT @ (ee_pose_b[:, :3] - frame_pos_b).unsqueeze(-1)).squeeze(-1) + ee_rot_task = rot_task_b.mT @ matrix_from_quat(ee_pose_b[:, 3:]) + desired_pos_task = ee_pos_task + target[:, :3] + desired_rot_task = to_matrix(target[:, 3:]) @ ee_rot_task + desired_pos_b = frame_pos_b + (rot_task_b @ desired_pos_task.unsqueeze(-1)).squeeze(-1) + desired_quat_b = quat_from_matrix(rot_task_b @ desired_rot_task) + desired_ee_pose_b = torch.cat([desired_pos_b, desired_quat_b], dim=-1) + os_mass_matrix_b = torch.zeros(num_envs, 6, 6) mass_matrix_inv = None - if controller.desired_ee_pose_b is not None: + if desired_ee_pose_b is not None: pose_error_b = torch.cat( compute_pose_error( ee_pose_b[:, :3], ee_pose_b[:, 3:], - controller.desired_ee_pose_b[:, :3], - controller.desired_ee_pose_b[:, 3:], + desired_ee_pose_b[:, :3], + desired_ee_pose_b[:, 3:], rot_error_type="axis_angle", ), dim=-1, ) - des_ee_acc_b = to_root_frame(controller._motion_p_gains_task) @ pose_error_b.unsqueeze(-1) + to_root_frame( - controller._motion_d_gains_task - ) @ (-ee_vel_b).unsqueeze(-1) - selection_motion_b = to_root_frame(controller._selection_matrix_motion_task) + des_ee_acc_b = to_root_frame(motion_p_gains) @ pose_error_b.unsqueeze(-1) + to_root_frame(motion_d_gains) @ ( + -ee_vel_b + ).unsqueeze(-1) + selection_motion_b = to_root_frame(motion_axes) if cfg.inertial_dynamics_decoupling: mass_matrix_inv = torch.inverse(mass_matrix) if cfg.partial_inertial_dynamics_decoupling: @@ -1575,17 +979,19 @@ def to_root_frame(axis_values: torch.Tensor) -> torch.Tensor: os_command_forces_b = selection_motion_b @ os_command_forces_b joint_efforts += (jacobian_b.mT @ os_command_forces_b).squeeze(-1) - if controller.desired_ee_wrench_b is not None: + if desired_ee_wrench_b is not None: + force_axes = torch.tensor(cfg.contact_wrench_control_axes_task, dtype=torch.float) if cfg.contact_wrench_stiffness_task is not None: measured_wrench_b = torch.zeros(num_envs, 6) measured_wrench_b[:, 0:3] = ee_force_b - measured_wrench_b[:, 3:6] = controller.desired_ee_wrench_b[:, 3:6] - wrench_command_b = controller.desired_ee_wrench_b.unsqueeze(-1) + to_root_frame( - controller._contact_wrench_p_gains_task - ) @ (controller.desired_ee_wrench_b - measured_wrench_b).unsqueeze(-1) + measured_wrench_b[:, 3:6] = desired_ee_wrench_b[:, 3:6] + contact_wrench_p_gains = force_axes * torch.as_tensor(cfg.contact_wrench_stiffness_task, dtype=torch.float) + wrench_command_b = desired_ee_wrench_b.unsqueeze(-1) + to_root_frame(contact_wrench_p_gains) @ ( + desired_ee_wrench_b - measured_wrench_b + ).unsqueeze(-1) else: - wrench_command_b = controller.desired_ee_wrench_b.unsqueeze(-1) - selection_force_b = to_root_frame(controller._selection_matrix_force_task) + wrench_command_b = desired_ee_wrench_b.unsqueeze(-1) + selection_force_b = to_root_frame(force_axes) joint_efforts += (jacobian_b.mT @ selection_force_b @ wrench_command_b).squeeze(-1) if cfg.gravity_compensation: @@ -1597,9 +1003,10 @@ def to_root_frame(axis_values: torch.Tensor) -> torch.Tensor: else: jacobian_pinv_transpose = torch.pinverse(jacobian_b).mT nullspace_jacobian_transpose = torch.eye(n=num_dof) - jacobian_b.mT @ jacobian_pinv_transpose + nullspace_p_gain = cfg.nullspace_stiffness + nullspace_d_gain = 2.0 * nullspace_p_gain**0.5 * cfg.nullspace_damping_ratio joint_acc_nullspace = ( - controller._nullspace_p_gain * (nullspace_joint_pos_target - joint_pos) - + controller._nullspace_d_gain * (-joint_vel) + nullspace_p_gain * (nullspace_joint_pos_target - joint_pos) + nullspace_d_gain * (-joint_vel) ).unsqueeze(-1) joint_efforts += (nullspace_jacobian_transpose @ mass_matrix @ joint_acc_nullspace).squeeze(-1) @@ -1609,6 +1016,7 @@ def to_root_frame(axis_values: torch.Tensor) -> torch.Tensor: _SCENARIOS = { "pose_abs": dict(target_types=["pose_abs"]), "pose_rel": dict(target_types=["pose_rel"]), + "pose_rel_task_frame": dict(target_types=["pose_rel"], task_frame=True), "pose_abs_task_frame": dict(target_types=["pose_abs"], task_frame=True), "pose_abs_decoupled": dict(target_types=["pose_abs"], inertial_dynamics_decoupling=True, task_frame=True), "pose_abs_partial_decoupled": dict( @@ -1631,6 +1039,12 @@ def to_root_frame(axis_values: torch.Tensor) -> torch.Tensor: inertial_dynamics_decoupling=True, partial_inertial_dynamics_decoupling=True, ), + "wrench_only": dict( + target_types=["wrench_abs"], + motion_control_axes_task=(0, 0, 0, 0, 0, 0), + contact_wrench_control_axes_task=(1, 1, 1, 1, 1, 1), + task_frame=True, + ), "wrench_open_loop": dict( target_types=["pose_abs", "wrench_abs"], motion_control_axes_task=(1, 1, 0, 1, 1, 1), @@ -1663,6 +1077,22 @@ def to_root_frame(axis_values: torch.Tensor) -> torch.Tensor: ), "variable_kp": dict(target_types=["pose_abs"], impedance_mode="variable_kp", task_frame=True), "variable": dict(target_types=["pose_abs"], impedance_mode="variable", task_frame=True), + "hybrid_variable_kp_decoupled": dict( + target_types=["pose_abs", "wrench_abs"], + impedance_mode="variable_kp", + motion_control_axes_task=(0, 1, 1, 1, 1, 1), + contact_wrench_control_axes_task=(1, 0, 0, 0, 0, 0), + inertial_dynamics_decoupling=True, + task_frame=True, + ), + "hybrid_variable_decoupled": dict( + target_types=["pose_abs", "wrench_abs"], + impedance_mode="variable", + motion_control_axes_task=(0, 1, 1, 1, 1, 1), + contact_wrench_control_axes_task=(1, 0, 0, 0, 0, 0), + inertial_dynamics_decoupling=True, + task_frame=True, + ), } @@ -1711,7 +1141,7 @@ def test_compute_matches_operational_space_law(scenario_name: str) -> None: command.append(torch.rand(_NUM_ENVS, 6, generator=generator) * 2.0) command = torch.cat(command, dim=-1) - controller.set_command(command, current_ee_pose_b=ee_pose_b, current_task_frame_pose_b=task_frame_pose_b) + controller.set_command(command.clone(), current_ee_pose_b=ee_pose_b, current_task_frame_pose_b=task_frame_pose_b) actual = controller.compute( jacobian_b=jacobian_b, current_ee_pose_b=ee_pose_b, @@ -1724,7 +1154,8 @@ def test_compute_matches_operational_space_law(scenario_name: str) -> None: nullspace_joint_pos_target=nullspace_target, ) reference = _reference_efforts( - controller, + cfg, + command, task_frame_pose_b, jacobian_b, ee_pose_b, @@ -1757,9 +1188,6 @@ def test_inertial_decoupling_damps_near_singular_directions(device, partial_iner nullspace_stiffness=1.0, ) controller = OperationalSpaceController(cfg, num_envs=4, device=device) - # Solve for forces each step; do not reintroduce cached inverse matrices or duplicate inertia state. - assert not hasattr(controller, "_mass_matrix_inv") - assert not hasattr(controller, "_os_mass_matrix_b") pose = torch.zeros(4, 7, device=device) pose[:, -1] = 1.0 controller.set_command(pose) diff --git a/source/isaaclab/test/controllers/test_pink_ik.py b/source/isaaclab/test/controllers/test_pink_ik.py index 5af72dadbbbd..6c0ecc710328 100644 --- a/source/isaaclab/test/controllers/test_pink_ik.py +++ b/source/isaaclab/test/controllers/test_pink_ik.py @@ -175,7 +175,6 @@ def test_setup(env_and_cfg): "test_name", [ "horizontal_movement", - "horizontal_small_movement", "stay_still", "forward_waist_bending_movement", "vertical_movement", @@ -188,7 +187,6 @@ def test_movement_types(test_setup, test_name): env_cfg = test_setup["env_cfg"] if test_name not in test_cfg["tests"]: - print(f"Skipping {test_name} test for {env_cfg.__class__.__name__} environment (test not defined)...") pytest.skip(f"Test {test_name} not defined for {env_cfg.__class__.__name__}") return @@ -199,14 +197,9 @@ def test_movement_types(test_setup, test_name): waist_enabled = is_waist_enabled(env_cfg) if requires_waist_bending and not waist_enabled: - print( - f"Skipping {test_name} test because it requires waist bending but waist is not enabled in" - f" {env_cfg.__class__.__name__}..." - ) pytest.skip(f"Test {test_name} requires waist bending but waist is not enabled") return - print(f"Running {test_name} test...") run_movement_test(test_setup, test_config, test_cfg) @@ -262,7 +255,6 @@ def run_movement_test(test_setup, test_config, test_cfg, aux_function=None): # Check convergence and verify errors if steps_in_phase % check_interval == 0: - print("Computing errors...") errors = compute_errors( test_setup, env, @@ -271,7 +263,6 @@ def run_movement_test(test_setup, test_config, test_cfg, aux_function=None): test_setup["left_eef_urdf_link_name"], test_setup["right_eef_urdf_link_name"], ) - print_debug_info(errors, test_counter) test_params = test_setup["test_params"] if test_params["check_errors"]: verify_errors(errors, test_setup, test_params) @@ -281,7 +272,6 @@ def run_movement_test(test_setup, test_config, test_cfg, aux_function=None): if curr_pose_idx == 0: test_counter += 1 if test_counter > test_config["repeat"]: - print("Test completed successfully") break # After the first phase, switch to normal interval if phase == "initial": @@ -429,12 +419,3 @@ def verify_errors(errors, test_setup, tolerances): f" ({tolerances['rotation']:.6f})" ), ) - - -def print_debug_info(errors, test_counter): - """Print debug information about the current state.""" - print(f"\nTest iteration {test_counter + 1}:") - for hand in ["left", "right"]: - print(f"Measured {hand} hand position error:", errors[f"{hand}_pos_error"]) - print(f"Measured {hand} hand rotation error:", errors[f"{hand}_rot_error"]) - print(f"Measured {hand} hand PD error:", errors[f"{hand}_pd_error"]) diff --git a/source/isaaclab/test/controllers/test_pink_ik_components.py b/source/isaaclab/test/controllers/test_pink_ik_components.py index 0a7bad66ce22..8dca1995e1bb 100644 --- a/source/isaaclab/test/controllers/test_pink_ik_components.py +++ b/source/isaaclab/test/controllers/test_pink_ik_components.py @@ -46,24 +46,6 @@ def pink_config(self, urdf_path, mesh_path, controlled_joint_names): forward_kinematics=True, ) - def test_initialization(self, pink_config, controlled_joint_names): - """Test proper initialization of PinkKinematicsConfiguration.""" - # Check that controlled joint names are stored correctly - assert pink_config._controlled_joint_names == controlled_joint_names - - # Check that both full and controlled models are created - assert pink_config.full_model is not None - assert pink_config.controlled_model is not None - assert pink_config.full_data is not None - assert pink_config.controlled_data is not None - - # Check that configuration vectors are initialized - assert pink_config.full_q is not None - assert pink_config.controlled_q is not None - - # Check that the controlled model has the same number or fewer joints than the full model - assert pink_config.controlled_model.nq == pink_config.full_model.nq - def test_joint_names_properties(self, pink_config): """Test joint name properties.""" # Test controlled joint names in pinocchio order @@ -93,7 +75,6 @@ def test_update_with_valid_configuration(self, pink_config): pink_config.update(new_q) # Check that the configuration was updated - print(pink_config.full_q) assert not np.allclose(pink_config.full_q, initial_q) assert np.allclose(pink_config.full_q, new_q) @@ -165,10 +146,9 @@ def test_multiple_controlled_joints(self, urdf_path, mesh_path): # Check that controlled model has correct number of joints assert pink_config.controlled_model.nq == len(controlled_joint_names) - def test_no_controlled_joints(self, urdf_path, mesh_path): - """Test configuration with no controlled joints.""" - controlled_joint_names = [] - + @pytest.mark.parametrize("controlled_joint_names", [[], ["nonexistent_joint"]]) + def test_no_controlled_joints(self, urdf_path, mesh_path, controlled_joint_names): + """Empty or unknown controlled joint names lock every joint.""" pink_config = PinkKinematicsConfiguration( urdf_path=str(urdf_path), mesh_path=mesh_path, @@ -211,49 +191,6 @@ def test_transform_consistency(self, pink_config): # Transforms should be different assert not np.allclose(transform_1.homogeneous, transform_2.homogeneous) - def test_inheritance_from_configuration(self, pink_config): - """Test that PinkKinematicsConfiguration properly inherits from Pink Configuration.""" - from pink.configuration import Configuration - - # Check inheritance - assert isinstance(pink_config, Configuration) - - # Check that we can call parent class methods - assert hasattr(pink_config, "update") - assert hasattr(pink_config, "get_transform_frame_to_world") - - def test_controlled_joint_indices_calculation(self, pink_config): - """Test that controlled joint indices are calculated correctly.""" - # Check that controlled joint indices are valid - assert len(pink_config._controlled_joint_indices) == len(pink_config._controlled_joint_names) - - # Check that all indices are within bounds - for idx in pink_config._controlled_joint_indices: - assert 0 <= idx < len(pink_config._all_joint_names) - - # Check that indices correspond to controlled joint names - for i, idx in enumerate(pink_config._controlled_joint_indices): - joint_name = pink_config._all_joint_names[idx] - assert joint_name in pink_config._controlled_joint_names - - def test_full_model_integrity(self, pink_config): - """Test that the full model maintains integrity.""" - # Check that full model has all joints - assert pink_config.full_model.nq > 0 - assert len(pink_config.full_model.names) > 1 # More than just "universe" - - def test_controlled_model_integrity(self, pink_config): - """Test that the controlled model maintains integrity.""" - # Check that controlled model has correct number of joints - assert pink_config.controlled_model.nq == len(pink_config._controlled_joint_names) - - def test_configuration_vector_consistency(self, pink_config): - """Test that configuration vectors are consistent between full and controlled models.""" - # Check that controlled_q is a subset of full_q - controlled_indices = pink_config._controlled_joint_indices - for i, idx in enumerate(controlled_indices): - assert np.isclose(pink_config.controlled_q[i], pink_config.full_q[idx]) - def test_error_handling_invalid_urdf(self, mesh_path, controlled_joint_names): """Test error handling with invalid URDF path.""" with pytest.raises(Exception): # Should raise some exception for invalid URDF @@ -263,33 +200,20 @@ def test_error_handling_invalid_urdf(self, mesh_path, controlled_joint_names): controlled_joint_names=controlled_joint_names, ) - def test_error_handling_invalid_joint_names(self, urdf_path, mesh_path): - """Test error handling with invalid joint names.""" - invalid_joint_names = ["nonexistent_joint"] - - # This should not raise an error, but the controlled model should have 0 joints - pink_config = PinkKinematicsConfiguration( - urdf_path=str(urdf_path), - mesh_path=mesh_path, - controlled_joint_names=invalid_joint_names, - ) - - assert pink_config.controlled_model.nq == 0 - assert len(pink_config.controlled_q) == 0 - - def test_undercontrolled_kinematics_model(self, urdf_path, mesh_path): + @pytest.mark.parametrize("controlled, locked", [("joint_1", "joint_2"), ("joint_2", "joint_1")]) + def test_undercontrolled_kinematics_model(self, urdf_path, mesh_path, controlled, locked): """Test that the fixed joint to world is properly handled.""" test_model = PinkKinematicsConfiguration( urdf_path=str(urdf_path), mesh_path=mesh_path, - controlled_joint_names=["joint_1"], + controlled_joint_names=[controlled], copy_data=True, forward_kinematics=True, ) # Check that the controlled model only includes the revolute joints - assert "joint_1" in test_model.controlled_joint_names_pinocchio_order - assert "joint_2" not in test_model.controlled_joint_names_pinocchio_order + assert controlled in test_model.controlled_joint_names_pinocchio_order + assert locked not in test_model.controlled_joint_names_pinocchio_order assert len(test_model.controlled_joint_names_pinocchio_order) == 1 # Only the two revolute joints # Check that the full configuration has more elements than controlled @@ -297,10 +221,18 @@ def test_undercontrolled_kinematics_model(self, urdf_path, mesh_path): assert len(test_model.full_q) == len(test_model.all_joint_names_pinocchio_order) assert len(test_model.controlled_q) == len(test_model.controlled_joint_names_pinocchio_order) + # A full configuration update forwards the controlled joint's own value to the reduced model + all_names = test_model.all_joint_names_pinocchio_order + full_q = 0.1 * np.arange(1, len(all_names) + 1) + test_model.update(full_q) + np.testing.assert_allclose(test_model.q, full_q[[all_names.index(controlled)]]) + -@pytest.mark.parametrize("fixed_base", [False, True]) -@pytest.mark.parametrize("disable_gravity", [False, True]) -@pytest.mark.parametrize("robot_name", ["GR1T2_HIGH_PD_CFG", "G1_INSPIRE_FTP_CFG"]) +# The robot config only supplies ``disable_gravity``, which each row overrides, so one robot covers the branches. +@pytest.mark.parametrize( + "fixed_base, disable_gravity, robot_name", + [(False, False, "GR1T2_HIGH_PD_CFG"), (True, False, "GR1T2_HIGH_PD_CFG"), (False, True, "GR1T2_HIGH_PD_CFG")], +) def test_action_gravity_compensation_with_migrated_robot_configs(fixed_base, disable_gravity, robot_name): """The Pink robot configs retain direct gravity access and the action's effort targets.""" from types import SimpleNamespace diff --git a/source/isaaclab/test/deps/isaacsim/check_camera.py b/source/isaaclab/test/deps/isaacsim/check_camera.py deleted file mode 100644 index ee2c9d5adf28..000000000000 --- a/source/isaaclab/test/deps/isaacsim/check_camera.py +++ /dev/null @@ -1,19 +0,0 @@ -# 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 - -"""Historic camera episodic-reset reproducer (retired). - -This script previously depended on Isaac Sim core extensions that Isaac Sim now carries under -``source/deprecated`` in the Isaac Sim repository. It has been retired from Isaac Lab to avoid -depending on those extension IDs and import paths. - -Use :class:`~isaaclab.sim.SimulationContext`, Isaac Lab sensors, and ``isaacsim.core.experimental.*`` -APIs when debugging rendering and camera pipelines. -""" - -raise RuntimeError( - "This standalone reproducer was retired; it depended on deprecated Isaac Sim core extensions. " - "Use Isaac Lab :mod:`isaaclab.sim` and ``isaacsim.core.experimental.*`` instead." -) diff --git a/source/isaaclab/test/deps/isaacsim/check_floating_base_made_fixed.py b/source/isaaclab/test/deps/isaacsim/check_floating_base_made_fixed.py deleted file mode 100644 index f03012610e97..000000000000 --- a/source/isaaclab/test/deps/isaacsim/check_floating_base_made_fixed.py +++ /dev/null @@ -1,19 +0,0 @@ -# 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 - -"""Historic floating-base / fixed-base reproducer (retired). - -This script previously depended on Isaac Sim core extensions that Isaac Sim now carries under -``source/deprecated`` in the Isaac Sim repository. It has been retired from Isaac Lab to avoid -depending on those extension IDs and import paths. - -Use :class:`~isaaclab.sim.SimulationContext` and ``isaacsim.core.experimental.prims.Articulation`` -for articulation-level debugging. -""" - -raise RuntimeError( - "This standalone reproducer was retired; it depended on deprecated Isaac Sim core extensions. " - "Use Isaac Lab :mod:`isaaclab.sim` and ``isaacsim.core.experimental.*`` instead." -) diff --git a/source/isaaclab/test/deps/isaacsim/check_legged_robot_clone.py b/source/isaaclab/test/deps/isaacsim/check_legged_robot_clone.py deleted file mode 100644 index 54b13a2f0c1c..000000000000 --- a/source/isaaclab/test/deps/isaacsim/check_legged_robot_clone.py +++ /dev/null @@ -1,19 +0,0 @@ -# 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 - -"""Historic legged-robot cloning reproducer (retired). - -This script previously depended on Isaac Sim core extensions that Isaac Sim now carries under -``source/deprecated`` in the Isaac Sim repository. It has been retired from Isaac Lab to avoid -depending on those extension IDs and import paths. - -Use :class:`~isaaclab.sim.SimulationContext`, :class:`isaacsim.core.cloner.GridCloner`, and -``isaacsim.core.experimental.prims.Articulation`` for cloning workflows. -""" - -raise RuntimeError( - "This standalone reproducer was retired; it depended on deprecated Isaac Sim core extensions. " - "Use Isaac Lab :mod:`isaaclab.sim` and ``isaacsim.core.experimental.*`` instead." -) diff --git a/source/isaaclab/test/deps/isaacsim/check_ref_count.py b/source/isaaclab/test/deps/isaacsim/check_ref_count.py deleted file mode 100644 index aab8f3b9a988..000000000000 --- a/source/isaaclab/test/deps/isaacsim/check_ref_count.py +++ /dev/null @@ -1,155 +0,0 @@ -# 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 - -""" -This script demonstrates reference count of the robot view in Isaac Sim. - -When we make a class instance, the reference count of the class instance should always be 1. -However, in this script, the reference count of the robot view is 2 after the class is created. -This causes a memory leak in the Isaac Sim simulator and the robot view is not garbage collected. - -The issue is observed with torch 2.2 and Isaac Sim 4.0. It works fine with torch 2.0.1 and Isaac Sim 2023.1. -It can be resolved by uncommenting the line that creates a dummy tensor in the main function. - -To reproduce the issue, run this script and check the reference count of the robot view. - -For more details, please check: https://github.com/isaac-sim/IsaacLab/issues/639 -""" - -"""Launch Isaac Sim Simulator first.""" - -import contextlib - -with contextlib.suppress(ModuleNotFoundError): - import isaacsim # noqa: F401 - -from isaacsim import SimulationApp - -# launch omniverse app -simulation_app = SimulationApp({"headless": True}) - -"""Rest everything follows.""" - -import ctypes -import gc -import logging - -import torch # noqa: F401 - -import carb - -from isaaclab.sim.utils import enable_extension - -enable_extension("isaacsim.core.experimental.prims") - -from isaacsim.core.experimental.prims import Articulation - -import isaaclab.sim.utils.nucleus as nucleus_utils -import isaaclab.sim.utils.prims as prim_utils -from isaaclab.sim import SimulationCfg, SimulationContext - -# import logger -logger = logging.getLogger(__name__) - - -# check nucleus connection -if nucleus_utils.get_assets_root_path() is None: - msg = ( - "Unable to perform Nucleus login on Omniverse. Assets root path is not set.\n" - "\tPlease check: https://docs.omniverse.nvidia.com/app_isaacsim/app_isaacsim/overview.html#omniverse-nucleus" - ) - logger.error(msg) - raise RuntimeError(msg) - - -ISAAC_NUCLEUS_DIR = f"{nucleus_utils.get_assets_root_path()}/Isaac" -"""Path to the `Isaac` directory on the NVIDIA Nucleus Server.""" - -ISAACLAB_NUCLEUS_DIR = f"{ISAAC_NUCLEUS_DIR}/IsaacLab" -"""Path to the `Isaac/IsaacLab` directory on the NVIDIA Nucleus Server.""" - - -""" -Classes -""" - - -class AnymalArticulation: - """Anymal articulation class.""" - - def __init__(self): - """Initialize the Anymal articulation class.""" - # resolve asset - usd_path = f"{ISAACLAB_NUCLEUS_DIR}/Robots/ANYbotics/ANYmal-C/anymal_c.usd" - # add asset - print("Loading robot from: ", usd_path) - prim_utils.create_prim("/World/Robot", usd_path=usd_path, translation=(0.0, 0.0, 0.6)) - - # Resolve robot prim paths - root_prim_path = "/World/Robot/base" - # Setup robot - self.view = Articulation(root_prim_path) - - def __del__(self): - """Delete the Anymal articulation class.""" - print("Deleting the Anymal view.") - self.view = None - - -""" -Main -""" - - -def main(): - """Spawns the ANYmal robot and clones it using Isaac Sim Cloner API.""" - - carb.settings.get_settings().set_bool("/persistent/omnihydra/useSceneGraphInstancing", True) - - # Load kit helper - sim = SimulationContext(cfg=SimulationCfg(dt=0.005, device="cuda:0")) - - # Create a dummy tensor for testing - # Uncommenting the following line will yield a reference count of 1 for the robot (as desired) - # dummy_tensor = torch.zeros(1, device="cuda:0") - - # Robot - robot = AnymalArticulation() - - print("Reference count of the robot view: ", ctypes.c_long.from_address(id(robot)).value) - print("Referrers of the robot view: ", gc.get_referrers(robot)) - print("---" * 10) - - # Play the simulator - sim.reset() - - print("Reference count of the robot view: ", ctypes.c_long.from_address(id(robot)).value) - print("Referrers of the robot view: ", gc.get_referrers(robot)) - print("---" * 10) - - print("Reference count of the robot view: ", ctypes.c_long.from_address(id(robot)).value) - print("Referrers of the robot view: ", gc.get_referrers(robot)) - print("---" * 10) - - # Stop the simulator - sim.stop() - - print("Reference count of the robot view: ", ctypes.c_long.from_address(id(robot)).value) - print("Referrers of the robot view: ", gc.get_referrers(robot)) - print("---" * 10) - - # Clean up - SimulationContext.clear_instance() - - print("Reference count of the robot view: ", ctypes.c_long.from_address(id(robot)).value) - print("Referrers of the robot view: ", gc.get_referrers(robot)) - print("---" * 10) - - -if __name__ == "__main__": - # run the main function - main() - # close sim app - simulation_app.close() diff --git a/source/isaaclab/test/deps/isaacsim/check_rep_texture_randomizer.py b/source/isaaclab/test/deps/isaacsim/check_rep_texture_randomizer.py deleted file mode 100644 index dd3875994e38..000000000000 --- a/source/isaaclab/test/deps/isaacsim/check_rep_texture_randomizer.py +++ /dev/null @@ -1,19 +0,0 @@ -# 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 - -"""Historic replicator texture randomization reproducer (retired). - -This script previously depended on Isaac Sim core extensions that Isaac Sim now carries under -``source/deprecated`` in the Isaac Sim repository. It has been retired from Isaac Lab to avoid -depending on those extension IDs and import paths. - -Use :class:`~isaaclab.sim.SimulationContext`, :class:`isaacsim.core.cloner.GridCloner`, and -``isaacsim.core.experimental.*`` when experimenting with Omniverse Replicator workflows. -""" - -raise RuntimeError( - "This standalone reproducer was retired; it depended on deprecated Isaac Sim core extensions. " - "Use Isaac Lab :mod:`isaaclab.sim` and ``isaacsim.core.experimental.*`` instead." -) diff --git a/source/isaaclab/test/deps/test_scipy.py b/source/isaaclab/test/deps/test_scipy.py deleted file mode 100644 index c91b79d80ff4..000000000000 --- a/source/isaaclab/test/deps/test_scipy.py +++ /dev/null @@ -1,64 +0,0 @@ -# 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 - -# isort: off -import warnings -import pytest - -warnings.filterwarnings("ignore", category=DeprecationWarning) -# isort: on - -import numpy as np -import scipy.interpolate as interpolate - -pytestmark = [pytest.mark.unit, pytest.mark.arm_ci] - - -def test_interpolation(): - """Test scipy interpolation 2D method.""" - # parameters - size = (10.0, 12.0) - horizontal_scale = 0.1 - vertical_scale = 0.005 - downsampled_scale = 0.2 - noise_range = (-0.02, 0.1) - noise_step = 0.02 - # switch parameters to discrete units - # -- horizontal scale - width_pixels = int(size[0] / horizontal_scale) - length_pixels = int(size[1] / horizontal_scale) - # -- downsampled scale - width_downsampled = int(size[0] / downsampled_scale) - length_downsampled = int(size[1] / downsampled_scale) - # -- height - height_min = int(noise_range[0] / vertical_scale) - height_max = int(noise_range[1] / vertical_scale) - height_step = int(noise_step / vertical_scale) - - # create range of heights possible - height_range = np.arange(height_min, height_max + height_step, height_step) - # sample heights randomly from the range along a grid - height_field_downsampled = np.random.choice(height_range, size=(width_downsampled, length_downsampled)) - # create interpolation function for the sampled heights - x = np.linspace(0, size[0] * horizontal_scale, width_downsampled) - y = np.linspace(0, size[1] * horizontal_scale, length_downsampled) - - # interpolate the sampled heights to obtain the height field - x_upsampled = np.linspace(0, size[0] * horizontal_scale, width_pixels) - y_upsampled = np.linspace(0, size[1] * horizontal_scale, length_pixels) - # -- method 1: RegularGridInterpolator (replacing deprecated interp2d) - func_RegularGridInterpolator = interpolate.RegularGridInterpolator((x, y), height_field_downsampled, method="cubic") - xx_upsampled, yy_upsampled = np.meshgrid(x_upsampled, y_upsampled, indexing="ij", sparse=True) - z_upsampled_RegularGridInterpolator = func_RegularGridInterpolator((xx_upsampled, yy_upsampled)) - # -- method 2: RectBivariateSpline (alternate to interp2d) - func_RectBiVariate = interpolate.RectBivariateSpline(x, y, height_field_downsampled) - z_upsampled_RectBivariant = func_RectBiVariate(x_upsampled, y_upsampled) - - # check if the interpolated height field is the same as the sampled height field - np.testing.assert_allclose(z_upsampled_RegularGridInterpolator, z_upsampled_RectBivariant, atol=1e-2, rtol=1e-2) - np.testing.assert_allclose(z_upsampled_RectBivariant, z_upsampled_RegularGridInterpolator, atol=1e-2, rtol=1e-2) - np.testing.assert_allclose( - z_upsampled_RegularGridInterpolator, z_upsampled_RegularGridInterpolator, atol=1e-2, rtol=1e-2 - ) diff --git a/source/isaaclab/test/deps/test_torch.py b/source/isaaclab/test/deps/test_torch.py deleted file mode 100644 index 438890d50102..000000000000 --- a/source/isaaclab/test/deps/test_torch.py +++ /dev/null @@ -1,160 +0,0 @@ -# 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 - -import pytest -import torch -import torch.utils.benchmark as benchmark - -from isaaclab.test.utils import test_devices - -pytestmark = [pytest.mark.unit, pytest.mark.arm_ci] - - -@pytest.mark.parametrize("device", test_devices()) -def test_array_slicing(device): - """Check that using ellipsis and slices work for torch tensors.""" - - size = (400, 300, 5) - my_tensor = torch.rand(size, device=device) - - assert my_tensor[..., 0].shape == (400, 300) - assert my_tensor[:, :, 0].shape == (400, 300) - assert my_tensor[slice(None), slice(None), 0].shape == (400, 300) - with pytest.raises(IndexError): - my_tensor[..., ..., 0] - - assert my_tensor[0, ...].shape == (300, 5) - assert my_tensor[0, :, :].shape == (300, 5) - assert my_tensor[0, slice(None), slice(None)].shape == (300, 5) - assert my_tensor[0, ..., ...].shape == (300, 5) - - assert my_tensor[..., 0, 0].shape == (400,) - assert my_tensor[slice(None), 0, 0].shape == (400,) - assert my_tensor[:, 0, 0].shape == (400,) - - -@pytest.mark.parametrize("device", test_devices()) -def test_array_circular(device): - """Check circular buffer implementation in torch.""" - - size = (10, 30, 5) - my_tensor = torch.rand(size, device=device) - - # roll up the tensor without cloning - my_tensor_1 = my_tensor.clone() - my_tensor_1[:, 1:, :] = my_tensor_1[:, :-1, :] - my_tensor_1[:, 0, :] = my_tensor[:, -1, :] - # check that circular buffer works as expected - error = torch.max(torch.abs(my_tensor_1 - my_tensor.roll(1, dims=1))) - assert error.item() != 0.0 - assert not torch.allclose(my_tensor_1, my_tensor.roll(1, dims=1)) - - # roll up the tensor with cloning - my_tensor_2 = my_tensor.clone() - my_tensor_2[:, 1:, :] = my_tensor_2[:, :-1, :].clone() - my_tensor_2[:, 0, :] = my_tensor[:, -1, :] - # check that circular buffer works as expected - error = torch.max(torch.abs(my_tensor_2 - my_tensor.roll(1, dims=1))) - assert error.item() == 0.0 - assert torch.allclose(my_tensor_2, my_tensor.roll(1, dims=1)) - - # roll up the tensor with detach operation - my_tensor_3 = my_tensor.clone() - my_tensor_3[:, 1:, :] = my_tensor_3[:, :-1, :].detach() - my_tensor_3[:, 0, :] = my_tensor[:, -1, :] - # check that circular buffer works as expected - error = torch.max(torch.abs(my_tensor_3 - my_tensor.roll(1, dims=1))) - assert error.item() != 0.0 - assert not torch.allclose(my_tensor_3, my_tensor.roll(1, dims=1)) - - # roll up the tensor with roll operation - my_tensor_4 = my_tensor.clone() - my_tensor_4 = my_tensor_4.roll(1, dims=1) - my_tensor_4[:, 0, :] = my_tensor[:, -1, :] - # check that circular buffer works as expected - error = torch.max(torch.abs(my_tensor_4 - my_tensor.roll(1, dims=1))) - assert error.item() == 0.0 - assert torch.allclose(my_tensor_4, my_tensor.roll(1, dims=1)) - - -@pytest.mark.parametrize("device", test_devices()) -def test_array_circular_copy(device): - """Check that circular buffer implementation in torch is copying data.""" - - size = (10, 30, 5) - my_tensor = torch.rand(size, device=device) - my_tensor_clone = my_tensor.clone() - - # roll up the tensor - my_tensor_1 = my_tensor.clone() - my_tensor_1[:, 1:, :] = my_tensor_1[:, :-1, :].clone() - my_tensor_1[:, 0, :] = my_tensor[:, -1, :] - # change the source tensor - my_tensor[:, 0, :] = 1000 - # check that circular buffer works as expected - assert not torch.allclose(my_tensor_1, my_tensor.roll(1, dims=1)) - assert torch.allclose(my_tensor_1, my_tensor_clone.roll(1, dims=1)) - - -@pytest.mark.parametrize("device", test_devices()) -def test_array_multi_indexing(device): - """Check multi-indexing works for torch tensors.""" - - size = (400, 300, 5) - my_tensor = torch.rand(size, device=device) - - # this fails since array indexing cannot be broadcasted!! - with pytest.raises(IndexError): - my_tensor[[0, 1, 2, 3], [0, 1, 2, 3, 4]] - - -@pytest.mark.parametrize("device", test_devices()) -def test_array_single_indexing(device): - """Check how indexing effects the returned tensor.""" - - size = (400, 300, 5) - my_tensor = torch.rand(size, device=device) - - # obtain a slice of the tensor - my_slice = my_tensor[0, ...] - assert my_slice.untyped_storage().data_ptr() == my_tensor.untyped_storage().data_ptr() - - # obtain a slice over ranges - my_slice = my_tensor[0:2, ...] - assert my_slice.untyped_storage().data_ptr() == my_tensor.untyped_storage().data_ptr() - - # obtain a slice over list - my_slice = my_tensor[[0, 1], ...] - assert my_slice.untyped_storage().data_ptr() != my_tensor.untyped_storage().data_ptr() - - # obtain a slice over tensor - my_slice = my_tensor[torch.tensor([0, 1]), ...] - assert my_slice.untyped_storage().data_ptr() != my_tensor.untyped_storage().data_ptr() - - -@pytest.mark.parametrize("device", test_devices()) -def test_logical_or(device): - """Test bitwise or operation.""" - - size = (400, 300, 5) - my_tensor_1 = torch.rand(size, device=device) > 0.5 - my_tensor_2 = torch.rand(size, device=device) < 0.5 - - # check the speed of logical or - timer_logical_or = benchmark.Timer( - stmt="torch.logical_or(my_tensor_1, my_tensor_2)", - globals={"my_tensor_1": my_tensor_1, "my_tensor_2": my_tensor_2}, - ) - timer_bitwise_or = benchmark.Timer( - stmt="my_tensor_1 | my_tensor_2", globals={"my_tensor_1": my_tensor_1, "my_tensor_2": my_tensor_2} - ) - - print("Time for logical or:", timer_logical_or.timeit(number=1000)) - print("Time for bitwise or:", timer_bitwise_or.timeit(number=1000)) - # check that logical or works as expected - output_logical_or = torch.logical_or(my_tensor_1, my_tensor_2) - output_bitwise_or = my_tensor_1 | my_tensor_2 - - assert torch.allclose(output_logical_or, output_bitwise_or) diff --git a/source/isaaclab/test/devices/test_device_constructors.py b/source/isaaclab/test/devices/test_device_constructors.py index 25e5ecc62e4d..52854b517659 100644 --- a/source/isaaclab/test/devices/test_device_constructors.py +++ b/source/isaaclab/test/devices/test_device_constructors.py @@ -82,148 +82,88 @@ def mock_environment(mocker): """ -Test keyboard devices. +Test keyboard, gamepad, and spacemouse devices. """ -def test_se2keyboard_constructors(mock_environment, mocker): - """Test constructor for Se2Keyboard.""" - # Test config-based constructor - config = Se2KeyboardCfg( - v_x_sensitivity=0.9, - v_y_sensitivity=0.5, - omega_z_sensitivity=1.2, - ) - device_mod = importlib.import_module("isaaclab.devices.keyboard.se2_keyboard") - mocker.patch.dict("sys.modules", {"carb": mock_environment["carb"], "omni": mock_environment["omni"]}) - mocker.patch.object(device_mod, "carb", mock_environment["carb"]) - mocker.patch.object(device_mod, "omni", mock_environment["omni"]) - - keyboard = Se2Keyboard(config) - - # Verify configuration was applied correctly - assert keyboard.v_x_sensitivity == 0.9 - assert keyboard.v_y_sensitivity == 0.5 - assert keyboard.omega_z_sensitivity == 1.2 - - # Test advance() returns expected type - result = keyboard.advance() - assert isinstance(result, torch.Tensor) - assert result.shape == (3,) # (v_x, v_y, omega_z) - - -def test_se3keyboard_constructors(mock_environment, mocker): - """Test constructor for Se3Keyboard.""" - # Test config-based constructor - config = Se3KeyboardCfg( - pos_sensitivity=0.5, - rot_sensitivity=0.9, - ) - device_mod = importlib.import_module("isaaclab.devices.keyboard.se3_keyboard") - mocker.patch.dict("sys.modules", {"carb": mock_environment["carb"], "omni": mock_environment["omni"]}) - mocker.patch.object(device_mod, "carb", mock_environment["carb"]) - mocker.patch.object(device_mod, "omni", mock_environment["omni"]) - - keyboard = Se3Keyboard(config) - - # Verify configuration was applied correctly - assert keyboard.pos_sensitivity == 0.5 - assert keyboard.rot_sensitivity == 0.9 - - # Test advance() returns expected type - result = keyboard.advance() - assert isinstance(result, torch.Tensor) - assert result.shape == (7,) # (pos_x, pos_y, pos_z, rot_x, rot_y, rot_z, gripper) - - -""" -Test gamepad devices. -""" - - -def test_se2gamepad_constructors(mock_environment, mocker): - """Test constructor for Se2Gamepad.""" - # Test config-based constructor - config = Se2GamepadCfg( - v_x_sensitivity=1.1, - v_y_sensitivity=0.6, - omega_z_sensitivity=1.2, - dead_zone=0.02, - ) - device_mod = importlib.import_module("isaaclab.devices.gamepad.se2_gamepad") - mocker.patch.dict("sys.modules", {"carb": mock_environment["carb"], "omni": mock_environment["omni"]}) - mocker.patch.object(device_mod, "carb", mock_environment["carb"]) - mocker.patch.object(device_mod, "omni", mock_environment["omni"]) - - gamepad = Se2Gamepad(config) - - # Verify configuration was applied correctly - assert gamepad.v_x_sensitivity == 1.1 - assert gamepad.v_y_sensitivity == 0.6 - assert gamepad.omega_z_sensitivity == 1.2 - assert gamepad.dead_zone == 0.02 - - # Test advance() returns expected type - result = gamepad.advance() - assert isinstance(result, torch.Tensor) - assert result.shape == (3,) # (v_x, v_y, omega_z) - - -def test_se3gamepad_constructors(mock_environment, mocker): - """Test constructor for Se3Gamepad.""" - # Test config-based constructor - config = Se3GamepadCfg( - pos_sensitivity=1.1, - rot_sensitivity=1.7, - dead_zone=0.02, - ) - device_mod = importlib.import_module("isaaclab.devices.gamepad.se3_gamepad") - mocker.patch.dict("sys.modules", {"carb": mock_environment["carb"], "omni": mock_environment["omni"]}) - mocker.patch.object(device_mod, "carb", mock_environment["carb"]) - mocker.patch.object(device_mod, "omni", mock_environment["omni"]) - - gamepad = Se3Gamepad(config) - - # Verify configuration was applied correctly - assert gamepad.pos_sensitivity == 1.1 - assert gamepad.rot_sensitivity == 1.7 - assert gamepad.dead_zone == 0.02 - - # Test advance() returns expected type - result = gamepad.advance() - assert isinstance(result, torch.Tensor) - assert result.shape == (7,) # (pos_x, pos_y, pos_z, rot_x, rot_y, rot_z, gripper) - - -""" -Test spacemouse devices. -""" - - -def test_se2spacemouse_constructors(mock_environment, mocker): - """Test constructor for Se2SpaceMouse.""" - # Test config-based constructor - config = Se2SpaceMouseCfg( - v_x_sensitivity=0.9, - v_y_sensitivity=0.5, - omega_z_sensitivity=1.2, - ) - device_mod = importlib.import_module("isaaclab.devices.spacemouse.se2_spacemouse") - mocker.patch.dict("sys.modules", {"hid": mock_environment["hid"]}) - mocker.patch.object(device_mod, "hid", mock_environment["hid"]) +@pytest.mark.parametrize( + "module_name,device_cls,cfg,patched_modules,report,expected_shape", + [ + ( + "isaaclab.devices.keyboard.se2_keyboard", + Se2Keyboard, + Se2KeyboardCfg(v_x_sensitivity=0.9, v_y_sensitivity=0.5, omega_z_sensitivity=1.2), + ("carb", "omni"), + None, + 3, # (v_x, v_y, omega_z) + ), + ( + "isaaclab.devices.keyboard.se3_keyboard", + Se3Keyboard, + Se3KeyboardCfg(pos_sensitivity=0.5, rot_sensitivity=0.9), + ("carb", "omni"), + None, + 7, # (pos_x, pos_y, pos_z, rot_x, rot_y, rot_z, gripper) + ), + ( + "isaaclab.devices.gamepad.se2_gamepad", + Se2Gamepad, + Se2GamepadCfg(v_x_sensitivity=1.1, v_y_sensitivity=0.6, omega_z_sensitivity=1.2, dead_zone=0.02), + ("carb", "omni"), + None, + 3, + ), + ( + "isaaclab.devices.gamepad.se3_gamepad", + Se3Gamepad, + Se3GamepadCfg(pos_sensitivity=1.1, rot_sensitivity=1.7, dead_zone=0.02), + ("carb", "omni"), + None, + 7, + ), + ( + "isaaclab.devices.spacemouse.se2_spacemouse", + Se2SpaceMouse, + Se2SpaceMouseCfg(v_x_sensitivity=0.9, v_y_sensitivity=0.5, omega_z_sensitivity=1.2), + ("hid",), + [1, 0, 0, 0, 0], + 3, + ), + ( + "isaaclab.devices.spacemouse.se3_spacemouse", + Se3SpaceMouse, + Se3SpaceMouseCfg(pos_sensitivity=0.5, rot_sensitivity=0.9), + ("hid",), + [1, 0, 0, 0, 0, 0, 0], + 7, + ), + ], + ids=["se2_keyboard", "se3_keyboard", "se2_gamepad", "se3_gamepad", "se2_spacemouse", "se3_spacemouse"], +) +def test_device_constructs_and_advance_shape( + mock_environment, mocker, module_name, device_cls, cfg, patched_modules, report, expected_shape +): + """Each device applies its config and ``advance()`` returns a command of its documented size.""" + device_mod = importlib.import_module(module_name) + mocker.patch.dict("sys.modules", {name: mock_environment[name] for name in patched_modules}) + for name in patched_modules: + mocker.patch.object(device_mod, name, mock_environment[name]) - spacemouse = Se2SpaceMouse(config) + device = device_cls(cfg) # Verify configuration was applied correctly - assert spacemouse.v_x_sensitivity == 0.9 - assert spacemouse.v_y_sensitivity == 0.5 - assert spacemouse.omega_z_sensitivity == 1.2 + for field in ("v_x_sensitivity", "v_y_sensitivity", "omega_z_sensitivity", "pos_sensitivity", "rot_sensitivity"): + if hasattr(cfg, field): + assert getattr(device, field) == getattr(cfg, field) + if hasattr(cfg, "dead_zone"): + assert device.dead_zone == cfg.dead_zone # Test advance() returns expected type - mock_environment["device"].read.return_value = [1, 0, 0, 0, 0] - result = spacemouse.advance() + if report is not None: + mock_environment["device"].read.return_value = report + result = device.advance() assert isinstance(result, torch.Tensor) - assert result.shape == (3,) # (v_x, v_y, omega_z) + assert result.shape == (expected_shape,) @pytest.mark.parametrize( @@ -332,30 +272,6 @@ def test_spacemouse_not_found_error_lists_enumerated_devices(mock_environment, m assert "/dev/bus/usb" in message -def test_se3spacemouse_constructors(mock_environment, mocker): - """Test constructor for Se3SpaceMouse.""" - # Test config-based constructor - config = Se3SpaceMouseCfg( - pos_sensitivity=0.5, - rot_sensitivity=0.9, - ) - device_mod = importlib.import_module("isaaclab.devices.spacemouse.se3_spacemouse") - mocker.patch.dict("sys.modules", {"hid": mock_environment["hid"]}) - mocker.patch.object(device_mod, "hid", mock_environment["hid"]) - - spacemouse = Se3SpaceMouse(config) - - # Verify configuration was applied correctly - assert spacemouse.pos_sensitivity == 0.5 - assert spacemouse.rot_sensitivity == 0.9 - - # Test advance() returns expected type - mock_environment["device"].read.return_value = [1, 0, 0, 0, 0, 0, 0] - result = spacemouse.advance() - assert isinstance(result, torch.Tensor) - assert result.shape == (7,) # (pos_x, pos_y, pos_z, rot_x, rot_y, rot_z, gripper) - - def test_se3spacemouse_destructor_handles_partial_initialization(): """The destructor must tolerate construction failing before the listener thread exists.""" spacemouse = Se3SpaceMouse.__new__(Se3SpaceMouse) @@ -455,11 +371,6 @@ def test_haply_constructors(mock_environment, mocker): } haply.feedback_force = {"x": 0.0, "y": 0.0, "z": 0.0} - # Verify configuration was applied correctly - assert haply.websocket_uri == "ws://localhost:10001" - assert haply.pos_sensitivity == 1.5 - assert haply.data_rate == 250.0 - # Test advance() returns expected type result = haply.advance() assert isinstance(result, torch.Tensor) diff --git a/source/isaaclab/test/envs/check_manager_based_env_anymal_locomotion.py b/source/isaaclab/test/envs/check_manager_based_env_anymal_locomotion.py deleted file mode 100644 index 07dc3d73bd14..000000000000 --- a/source/isaaclab/test/envs/check_manager_based_env_anymal_locomotion.py +++ /dev/null @@ -1,254 +0,0 @@ -# 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 - -""" -This script demonstrates the environment concept that combines a scene with an action, -observation and event manager for a quadruped robot. - -A locomotion policy is loaded and used to control the robot. This shows how to use the -environment with a policy. -""" - -"""Launch Isaac Sim Simulator first.""" - - -import argparse - -from isaaclab.app import AppLauncher - -# add argparse arguments -parser = argparse.ArgumentParser(description="This script demonstrates how to use the concept of an Environment.") -parser.add_argument("--num_envs", type=int, default=64, help="Number of environments to spawn.") - -# append AppLauncher cli args -AppLauncher.add_app_launcher_args(parser) -# parse the arguments -args_cli = parser.parse_args() - -# launch omniverse app -app_launcher = AppLauncher(args_cli) -simulation_app = app_launcher.app - -"""Rest everything follows.""" - -import torch - -import isaaclab.envs.mdp as mdp -import isaaclab.sim as sim_utils -from isaaclab.assets import ArticulationCfg, AssetBaseCfg -from isaaclab.envs import ManagerBasedEnv, ManagerBasedEnvCfg -from isaaclab.managers import EventTermCfg as EventTerm -from isaaclab.managers import ObservationGroupCfg as ObsGroup -from isaaclab.managers import ObservationTermCfg as ObsTerm -from isaaclab.managers import SceneEntityCfg -from isaaclab.scene import InteractiveSceneCfg -from isaaclab.sensors import RayCasterCfg, patterns -from isaaclab.terrains import TerrainImporterCfg -from isaaclab.utils import configclass -from isaaclab.utils.assets import ISAACLAB_NUCLEUS_DIR, NVIDIA_NUCLEUS_DIR, check_file_path, read_file -from isaaclab.utils.noise import UniformNoiseCfg as Unoise - -## -# Pre-defined configs -## -from isaaclab.terrains.config.rough import ROUGH_TERRAINS_CFG # isort: skip -from isaaclab_assets.robots.anymal import ANYMAL_C_CFG # isort: skip - - -## -# Scene definition -## - - -@configclass -class MySceneCfg(InteractiveSceneCfg): - """Example scene configuration.""" - - # add terrain - terrain = TerrainImporterCfg( - prim_path="/World/ground", - terrain_type="generator", - terrain_generator=ROUGH_TERRAINS_CFG, - physics_material=sim_utils.RigidBodyMaterialCfg( - friction_combine_mode="multiply", - restitution_combine_mode="multiply", - static_friction=1.0, - dynamic_friction=1.0, - ), - visual_material=sim_utils.MdlFileCfg( - mdl_path=f"{ISAACLAB_NUCLEUS_DIR}/Materials/TilesMarbleSpiderWhiteBrickBondHoned/TilesMarbleSpiderWhiteBrickBondHoned.mdl", - project_uvw=True, - texture_scale=(0.25, 0.25), - ), - debug_vis=False, - ) - - # add robot - robot: ArticulationCfg = ANYMAL_C_CFG.replace(prim_path="{ENV_REGEX_NS}/Robot") - - # sensors - height_scanner = RayCasterCfg( - prim_path="{ENV_REGEX_NS}/Robot/base", - offset=RayCasterCfg.OffsetCfg(pos=(0.0, 0.0, 20.0)), - ray_alignment="yaw", - pattern_cfg=patterns.GridPatternCfg(resolution=0.1, size=[1.6, 1.0]), - debug_vis=True, - mesh_prim_paths=["/World/ground"], - ) - - # lights - sky_light = AssetBaseCfg( - prim_path="/World/skyLight", - spawn=sim_utils.DomeLightCfg( - intensity=900.0, - texture_file=f"{NVIDIA_NUCLEUS_DIR}/Assets/Skies/Cloudy/kloofendal_48d_partly_cloudy_4k.hdr", - visible_in_primary_ray=False, - ), - ) - - -## -# MDP settings -## - - -def constant_commands(env: ManagerBasedEnv) -> torch.Tensor: - """The generated command from the command generator.""" - return torch.tensor([[1, 0, 0]], device=env.device).repeat(env.num_envs, 1) - - -@configclass -class ActionsCfg: - """Action specifications for the MDP.""" - - joint_pos = mdp.JointPositionActionCfg(asset_name="robot", joint_names=[".*"], scale=0.5, use_default_offset=True) - - -@configclass -class ObservationsCfg: - """Observation specifications for the MDP.""" - - @configclass - class PolicyCfg(ObsGroup): - """Observations for policy group.""" - - # observation terms (order preserved) - base_lin_vel = ObsTerm(func=mdp.base_lin_vel, noise=Unoise(n_min=-0.1, n_max=0.1)) - base_ang_vel = ObsTerm(func=mdp.base_ang_vel, noise=Unoise(n_min=-0.2, n_max=0.2)) - projected_gravity = ObsTerm( - func=mdp.projected_gravity, - noise=Unoise(n_min=-0.05, n_max=0.05), - ) - velocity_commands = ObsTerm(func=constant_commands) - joint_pos = ObsTerm(func=mdp.joint_pos_rel, noise=Unoise(n_min=-0.01, n_max=0.01)) - joint_vel = ObsTerm(func=mdp.joint_vel_rel, noise=Unoise(n_min=-1.5, n_max=1.5)) - actions = ObsTerm(func=mdp.last_action) - height_scan = ObsTerm( - func=mdp.height_scan, - params={"sensor_cfg": SceneEntityCfg("height_scanner")}, - noise=Unoise(n_min=-0.1, n_max=0.1), - clip=(-1.0, 1.0), - ) - - def __post_init__(self): - self.enable_corruption = True - self.concatenate_terms = True - - # observation groups - policy: PolicyCfg = PolicyCfg() - - -@configclass -class EventCfg: - """Configuration for events.""" - - reset_base = EventTerm( - func=mdp.reset_root_state_uniform, - mode="reset", - params={ - "pose_range": {"x": (-0.5, 0.5), "y": (-0.5, 0.5), "yaw": (-3.14, 3.14)}, - "velocity_range": { - "x": (-0.5, 0.5), - "y": (-0.5, 0.5), - "z": (-0.5, 0.5), - "roll": (-0.5, 0.5), - "pitch": (-0.5, 0.5), - "yaw": (-0.5, 0.5), - }, - }, - ) - - -## -# Environment configuration -## - - -@configclass -class QuadrupedEnvCfg(ManagerBasedEnvCfg): - """Configuration for the locomotion velocity-tracking environment.""" - - # Scene settings - scene: MySceneCfg = MySceneCfg(num_envs=args_cli.num_envs, env_spacing=2.5, replicate_physics=True) - # Basic settings - observations: ObservationsCfg = ObservationsCfg() - actions: ActionsCfg = ActionsCfg() - events: EventCfg = EventCfg() - - def __post_init__(self): - """Post initialization.""" - # general settings - self.decimation = 4 - self.episode_length_s = 20.0 - # simulation settings - self.sim.dt = 0.005 - # update sensor update periods - # we tick all the sensors based on the smallest update period (physics update period) - if self.scene.height_scanner is not None: - self.scene.height_scanner.update_period = self.decimation * self.sim.dt - - -def main(): - """Main function.""" - - # setup base environment - env = ManagerBasedEnv(cfg=QuadrupedEnvCfg()) - obs, _ = env.reset() - - # load level policy - policy_path = ISAACLAB_NUCLEUS_DIR + "/Policies/ANYmal-C/HeightScan/policy.pt" - - # check if policy file exists - if not check_file_path(policy_path): - raise FileNotFoundError(f"Policy file '{policy_path}' does not exist.") - file_bytes = read_file(policy_path) - # jit load the policy - locomotion_policy = torch.jit.load(file_bytes) - locomotion_policy.to(env.device) - locomotion_policy.eval() - - # simulate physics - count = 0 - while simulation_app.is_running(): - with torch.inference_mode(): - # reset - if count % 1000 == 0: - obs, _ = env.reset() - count = 0 - print("[INFO]: Resetting robots state...") - - # infer action - action = locomotion_policy(obs["policy"]) - # step env - obs, _ = env.step(action) - # update counter - count += 1 - - -if __name__ == "__main__": - # run the main function - main() - # close sim app - simulation_app.close() diff --git a/source/isaaclab/test/envs/check_manager_based_env_floating_cube.py b/source/isaaclab/test/envs/check_manager_based_env_floating_cube.py deleted file mode 100644 index 4108b0160127..000000000000 --- a/source/isaaclab/test/envs/check_manager_based_env_floating_cube.py +++ /dev/null @@ -1,267 +0,0 @@ -# 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 - -""" -This script demonstrates the base environment concept that combines a scene with an action, -observation and event manager for a floating cube. -""" - -"""Launch Isaac Sim Simulator first.""" - - -import argparse - -from isaaclab.app import AppLauncher - -# add argparse arguments -parser = argparse.ArgumentParser(description="This script demonstrates how to use the concept of an Environment.") -parser.add_argument("--num_envs", type=int, default=64, help="Number of environments to spawn.") - -# append AppLauncher cli args -AppLauncher.add_app_launcher_args(parser) -# parse the arguments -args_cli = parser.parse_args() - -# launch omniverse app -app_launcher = AppLauncher(args_cli) -simulation_app = app_launcher.app - -"""Rest everything follows.""" - -import torch -from isaaclab_physx.sim.schemas import PhysxRigidBodyCfg - -import isaaclab.envs.mdp as mdp -import isaaclab.sim as sim_utils -from isaaclab.assets import AssetBaseCfg, RigidObject, RigidObjectCfg -from isaaclab.envs import ManagerBasedEnv, ManagerBasedEnvCfg -from isaaclab.managers import EventTermCfg as EventTerm -from isaaclab.managers import ObservationGroupCfg as ObsGroup -from isaaclab.managers import ObservationTermCfg as ObsTerm -from isaaclab.managers import SceneEntityCfg -from isaaclab.managers.action_manager import ActionTerm, ActionTermCfg -from isaaclab.scene import InteractiveSceneCfg -from isaaclab.terrains import TerrainImporterCfg -from isaaclab.utils import configclass - -## -# Scene definition -## - - -@configclass -class MySceneCfg(InteractiveSceneCfg): - """Example scene configuration.""" - - # add terrain - terrain = TerrainImporterCfg(prim_path="/World/ground", terrain_type="plane", debug_vis=False) - - # add cube - cube: RigidObjectCfg = RigidObjectCfg( - prim_path="{ENV_REGEX_NS}/cube", - spawn=sim_utils.CuboidCfg( - size=(0.2, 0.2, 0.2), - rigid_props=PhysxRigidBodyCfg(max_depenetration_velocity=1.0), - mass_props=sim_utils.MassCfg(mass=1.0), - physics_material=sim_utils.RigidBodyMaterialCfg(), - visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.5, 0.0, 0.0)), - ), - init_state=RigidObjectCfg.InitialStateCfg(pos=(0.0, 0.0, 5)), - ) - - # lights - light = AssetBaseCfg( - prim_path="/World/light", - spawn=sim_utils.DistantLightCfg(color=(0.75, 0.75, 0.75), intensity=3000.0), - ) - - -## -# Action Term -## - - -class CubeActionTerm(ActionTerm): - """Simple action term that implements a PD controller to track a target position.""" - - _asset: RigidObject - """The articulation asset on which the action term is applied.""" - - def __init__(self, cfg: ActionTermCfg, env: ManagerBasedEnv): - # call super constructor - super().__init__(cfg, env) - # create buffers - self._raw_actions = torch.zeros(env.num_envs, 3, device=self.device) - self._processed_actions = torch.zeros(env.num_envs, 3, device=self.device) - self._vel_command = torch.zeros(self.num_envs, 6, device=self.device) - # gains of controller - self.p_gain = 5.0 - self.d_gain = 0.5 - - """ - Properties. - """ - - @property - def action_dim(self) -> int: - return self._raw_actions.shape[1] - - @property - def raw_actions(self) -> torch.Tensor: - # desired: (x, y, z) - return self._raw_actions - - @property - def processed_actions(self) -> torch.Tensor: - return self._processed_actions - - """ - Operations - """ - - def process_actions(self, actions: torch.Tensor): - # store the raw actions - self._raw_actions[:] = actions - # no-processing of actions - self._processed_actions[:] = self._raw_actions[:] - - def apply_actions(self): - # implement a PD controller to track the target position - pos_error = self._processed_actions - (self._asset.data.root_pos_w.torch - self._env.scene.env_origins) - vel_error = -self._asset.data.root_lin_vel_w.torch - # set velocity targets - self._vel_command[:, :3] = self.p_gain * pos_error + self.d_gain * vel_error - self._asset.write_root_velocity_to_sim(self._vel_command) - - -@configclass -class CubeActionTermCfg(ActionTermCfg): - """Configuration for the cube action term.""" - - class_type: type = CubeActionTerm - - -## -# Observation Term -## - - -def base_position(env: ManagerBasedEnv, asset_cfg: SceneEntityCfg) -> torch.Tensor: - """Root linear velocity in the asset's root frame.""" - # extract the used quantities (to enable type-hinting) - asset: RigidObject = env.scene[asset_cfg.name] - return asset.data.root_pos_w.torch - env.scene.env_origins - - -## -# Environment settings -## - - -@configclass -class ActionsCfg: - """Action specifications for the MDP.""" - - joint_pos = CubeActionTermCfg(asset_name="cube") - - -@configclass -class ObservationsCfg: - """Observation specifications for the MDP.""" - - @configclass - class PolicyCfg(ObsGroup): - """Observations for policy group.""" - - # cube velocity - position = ObsTerm(func=base_position, params={"asset_cfg": SceneEntityCfg("cube")}) - - def __post_init__(self): - self.enable_corruption = True - self.concatenate_terms = True - - # observation groups - policy: PolicyCfg = PolicyCfg() - - -@configclass -class EventCfg: - """Configuration for events.""" - - reset_base = EventTerm( - func=mdp.reset_root_state_uniform, - mode="reset", - params={ - "pose_range": {"x": (-0.5, 0.5), "y": (-0.5, 0.5), "yaw": (-3.14, 3.14)}, - "velocity_range": { - "x": (-0.5, 0.5), - "y": (-0.5, 0.5), - "z": (-0.5, 0.5), - }, - "asset_cfg": SceneEntityCfg("cube"), - }, - ) - - -## -# Environment configuration -## - - -@configclass -class CubeEnvCfg(ManagerBasedEnvCfg): - """Configuration for the locomotion velocity-tracking environment.""" - - # Scene settings - scene: MySceneCfg = MySceneCfg(num_envs=args_cli.num_envs, env_spacing=2.5, replicate_physics=True) - # Basic settings - observations: ObservationsCfg = ObservationsCfg() - actions: ActionsCfg = ActionsCfg() - events: EventCfg = EventCfg() - - def __post_init__(self): - """Post initialization.""" - # general settings - self.decimation = 2 - # simulation settings - self.sim.dt = 0.01 - self.sim.physics_material = self.scene.terrain.physics_material - - -def main(): - """Main function.""" - - # setup base environment - env = ManagerBasedEnv(cfg=CubeEnvCfg()) - - # setup target position commands - target_position = torch.rand(env.num_envs, 3, device=env.device) * 2 - target_position[:, 2] += 2.0 - # offset all targets so that they move to the world origin - target_position -= env.scene.env_origins - - # simulate physics - count = 0 - while simulation_app.is_running(): - with torch.inference_mode(): - # reset - if count % 300 == 0: - env.reset() - count = 0 - - # step env - obs, _ = env.step(target_position) - # print mean squared position error between target and current position - error = torch.linalg.norm(obs["policy"] - target_position).mean().item() - print(f"[Step: {count:04d}]: Mean position error: {error:.4f}") - # update counter - count += 1 - - -if __name__ == "__main__": - # run the main function - main() - # close sim app - simulation_app.close() diff --git a/source/isaaclab/test/envs/test_action_state_recorder_term.py b/source/isaaclab/test/envs/test_action_state_recorder_term.py index 609b472374d3..25c6ff385a7e 100644 --- a/source/isaaclab/test/envs/test_action_state_recorder_term.py +++ b/source/isaaclab/test/envs/test_action_state_recorder_term.py @@ -25,6 +25,7 @@ from isaaclab.envs.mdp.recorders.recorders_cfg import ActionStateRecorderManagerCfg from isaaclab.test.env_cfgs import make_empty_manager_based_env_cfg from isaaclab.test.integration_scene_cfgs import ArticulationRigidObjectSceneCfg +from isaaclab.test.utils import DeviceScope, test_devices pytestmark = pytest.mark.integration @@ -84,10 +85,11 @@ def check_initial_state_recorder_term(env: ManagerBasedEnv): assert are_states_equal, output_log -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -@pytest.mark.parametrize("num_envs", [1, 2]) -def test_action_state_recorder_terms(device, num_envs, temp_dir): +# Two environments, so that resetting the last one is a partial reset; recorder bookkeeping is device independent. +@pytest.mark.parametrize("device", test_devices(DeviceScope.DEFAULT_CUDA)) +def test_action_state_recorder_terms(device, temp_dir): """Check action state recorder terms.""" + num_envs = 2 sim_utils.create_new_stage() dummy_dataset_filename = f"{uuid.uuid4()}.hdf5" @@ -106,10 +108,30 @@ def test_action_state_recorder_terms(device, num_envs, temp_dir): # reset all environment instances to trigger post-reset recorder callbacks env.reset() check_initial_state_recorder_term(env) - - # reset only one environment that is not the first one + initial_state = env.scene.get_state(is_relative=True) + + # move the object in the first environment only, so its state differs from its recorded initial state + # and from the state of the environment that is reset next + rigid_object = env.scene["object"] + root_pose = rigid_object.data.root_pose_w.torch.clone() + root_pose[0, 2] += 0.5 + rigid_object.write_root_pose_to_sim_index(root_pose=root_pose) + env.sim.step(render=False) + env.scene.update(dt=env.physics_dt) + moved_state = env.scene.get_state(is_relative=True) + assert not compare_states(env.recorder_manager.get_episode(0).get_initial_state(), moved_state, 0)[0] + + # reset only one environment that is not the first one: only it records a new initial state env.reset(env_ids=torch.tensor([num_envs - 1], device=env.device)) - check_initial_state_recorder_term(env) + current_state = env.scene.get_state(is_relative=True) + are_states_equal, output_log = compare_states( + env.recorder_manager.get_episode(num_envs - 1).get_initial_state(), current_state, num_envs - 1 + ) + assert are_states_equal, output_log + are_states_equal, output_log = compare_states( + env.recorder_manager.get_episode(0).get_initial_state(), initial_state, 0 + ) + assert are_states_equal, output_log # close the environment env.close() diff --git a/source/isaaclab/test/envs/test_action_state_recorder_term_task_integration.py b/source/isaaclab/test/envs/test_action_state_recorder_term_task_integration.py index 2886310e1d15..6fafbdf286bd 100644 --- a/source/isaaclab/test/envs/test_action_state_recorder_term_task_integration.py +++ b/source/isaaclab/test/envs/test_action_state_recorder_term_task_integration.py @@ -28,6 +28,7 @@ import isaaclab.sim as sim_utils from isaaclab.app.settings_manager import get_settings_manager from isaaclab.envs.mdp.recorders.recorders_cfg import ActionStateRecorderManagerCfg +from isaaclab.test.utils import DeviceScope, test_devices import isaaclab_tasks # noqa: F401 from isaaclab_tasks.utils.parse_cfg import parse_env_cfg @@ -91,10 +92,11 @@ def check_initial_state_recorder_term(env): @pytest.mark.parametrize("task_name", ["IsaacContrib-Lift-Cube-Franka"]) -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -@pytest.mark.parametrize("num_envs", [1, 2]) -def test_action_state_recorder_terms(task_name, device, num_envs, temp_dir): +# Two environments, so that resetting the last one is a partial reset; recorder bookkeeping is device independent. +@pytest.mark.parametrize("device", test_devices(DeviceScope.DEFAULT_CUDA)) +def test_action_state_recorder_terms(task_name, device, temp_dir): """Check action state recorder terms through a registered task and Gym wrapper.""" + num_envs = 2 sim_utils.create_new_stage() dummy_dataset_filename = f"{uuid.uuid4()}.hdf5" diff --git a/source/isaaclab/test/envs/test_diffik_jacobian_aliasing.py b/source/isaaclab/test/envs/test_diffik_jacobian_aliasing.py index c92794fe8b79..f06ef4d368dd 100644 --- a/source/isaaclab/test/envs/test_diffik_jacobian_aliasing.py +++ b/source/isaaclab/test/envs/test_diffik_jacobian_aliasing.py @@ -94,66 +94,33 @@ def test_compute_frame_jacobian_is_idempotent_within_step(): torch.testing.assert_close(backing, backing_snapshot) -def test_compute_frame_jacobian_applies_offset_once(): - """The body-offset correction is applied exactly once. - - Compares the method output against an out-of-place reference computation. - """ - num_envs, num_joints = 2, 5 - backing = torch.randn(num_envs, 6, num_joints) - offset_pos = [0.1, -0.02, 0.03] - offset_rot = [0.7071, 0.0, 0.7071, 0.0] - - stub = _make_stub(num_envs, num_joints, offset_pos, offset_rot, backing) - - # Reference: out-of-place computation, no aliasing. The offset is rotated into the root frame - # and the angular rows are unchanged, since the offset frame is rigidly attached to the body. - root_rot = math_utils.matrix_from_quat(stub._asset.data.root_quat_w.torch) - body_rot = math_utils.matrix_from_quat(stub._asset.data.body_quat_w.torch[:, 0]) - offset_b = torch.bmm(root_rot.mT @ body_rot, stub._offset_pos.unsqueeze(-1)).squeeze(-1) - ref_trans = backing[:, 0:3, :] + torch.bmm(-math_utils.skew_symmetric_matrix(offset_b), backing[:, 3:, :]) - reference = torch.cat([ref_trans, backing[:, 3:, :]], dim=1) - - actual = DifferentialInverseKinematicsAction._compute_frame_jacobian(stub) - torch.testing.assert_close(actual, reference) - - @pytest.mark.parametrize( - "compute", + ("compute", "returns_jacobian"), [ - DifferentialInverseKinematicsAction._compute_frame_jacobian, - OperationalSpaceControllerAction._compute_ee_jacobian, + (DifferentialInverseKinematicsAction._compute_frame_jacobian, True), + (OperationalSpaceControllerAction._compute_ee_jacobian, False), ], ids=["diff_ik", "osc"], ) -def test_body_offset_jacobian_uses_offset_in_root_frame(compute): +@pytest.mark.parametrize( + ("root_yaw", "body_yaw"), [(0.0, math.pi / 2.0), (math.pi / 2.0, math.pi)], ids=["identity_root", "rotated_root"] +) +def test_body_offset_jacobian_uses_offset_in_root_frame(compute, returns_jacobian, root_yaw, body_yaw): """The offset uses root axes, and a rigid offset rotation leaves angular rows unchanged.""" - # One revolute joint about root z, with the body rotated 90 degrees about z. + # One revolute joint about root z, with the body rotated 90 degrees about z relative to the root. backing = torch.tensor([[[0.0], [0.0], [0.0], [0.0], [0.0], [1.0]]]) # A 90 degree rotation about x for the offset frame must not change the angular rows. offset_rot = [math.sin(math.pi / 4.0), 0.0, 0.0, math.cos(math.pi / 4.0)] stub = _make_stub(1, 1, [1.0, 0.0, 0.0], offset_rot, backing) - stub._asset.data.root_quat_w.torch[:] = torch.tensor([0.0, 0.0, 0.0, 1.0]) - stub._asset.data.body_quat_w.torch[:] = torch.tensor([0.0, 0.0, math.sin(math.pi / 4.0), math.cos(math.pi / 4.0)]) - compute(stub) + stub._asset.data.root_quat_w.torch[:] = torch.tensor([0.0, 0.0, math.sin(root_yaw / 2.0), math.cos(root_yaw / 2.0)]) + stub._asset.data.body_quat_w.torch[:] = torch.tensor([0.0, 0.0, math.sin(body_yaw / 2.0), math.cos(body_yaw / 2.0)]) + result = compute(stub) # R_body_b @ (1, 0, 0) = (0, 1, 0), and z x (0, 1, 0) = (-1, 0, 0). expected = torch.tensor([[[-1.0], [0.0], [0.0], [0.0], [0.0], [1.0]]]) torch.testing.assert_close(stub._jacobian_b, expected) - - -def test_compute_frame_jacobian_returns_owned_buffer(): - """The returned tensor must be the owned buffer, not the data-layer source. - - Guards against future regressions where ``return self.jacobian_b`` slips back in. - """ - num_envs, num_joints = 1, 3 - backing = torch.randn(num_envs, 6, num_joints) - stub = _make_stub(num_envs, num_joints, [0.0, 0.0, 0.0], [1.0, 0.0, 0.0, 0.0], backing) - - out = DifferentialInverseKinematicsAction._compute_frame_jacobian(stub) - assert out.data_ptr() == stub._jacobian_b.data_ptr() - assert out.data_ptr() != backing.data_ptr() + if returns_jacobian: + torch.testing.assert_close(result, expected) if __name__ == "__main__": diff --git a/source/isaaclab/test/envs/test_direct_marl_env.py b/source/isaaclab/test/envs/test_direct_marl_env.py index b1e3ac0abf07..62968bb49843 100644 --- a/source/isaaclab/test/envs/test_direct_marl_env.py +++ b/source/isaaclab/test/envs/test_direct_marl_env.py @@ -22,11 +22,13 @@ import isaaclab.sim as sim_utils from isaaclab.envs import DirectMARLEnv from isaaclab.test.env_cfgs import make_empty_direct_marl_env_cfg +from isaaclab.test.utils import DeviceScope, test_devices pytestmark = pytest.mark.integration -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +# Close bookkeeping is device independent; per-agent spaces are covered in test_direct_marl_env_unit.py. +@pytest.mark.parametrize("device", test_devices(DeviceScope.DEFAULT_CUDA)) def test_initialization_and_close(device): """DirectMARLEnv initializes its spaces and releases its simulation context.""" sim_utils.create_new_stage() @@ -36,9 +38,6 @@ def test_initialization_and_close(device): assert not env._is_closed assert sim_utils.SimulationContext.instance() is env.sim assert env.num_agents == 2 - assert env.max_num_agents == 2 - assert len(env.observation_spaces) == 2 - assert len(env.action_spaces) == 2 assert env.state_space.shape == (7,) finally: env.close() diff --git a/source/isaaclab/test/envs/test_env_rendering_logic.py b/source/isaaclab/test/envs/test_env_rendering_logic.py index 7fb6b5134f49..9e6e012aa7b4 100644 --- a/source/isaaclab/test/envs/test_env_rendering_logic.py +++ b/source/isaaclab/test/envs/test_env_rendering_logic.py @@ -156,10 +156,13 @@ def callback(dt): return callback, lambda: (render_time, num_render_steps) +# Each workflow has its own step(). An interval of 10 with decimation 4 is not a multiple of the decimation, +# so render boundaries fall mid-block and a dropped modulo check renders on every sub-step. @pytest.mark.parametrize("env_type", ["manager_based_env", "manager_based_rl_env", "direct_rl_env"]) -@pytest.mark.parametrize("render_interval", [1, 4, 10]) +@pytest.mark.parametrize("render_interval", [10]) def test_env_rendering_logic(env_type, render_interval, physics_callback, render_callback): - """Test the rendering logic of the different environment workflows.""" + """Test the rendering logic of the different environment workflows, and that disabling rendering + between steps skips rendering while physics continues.""" physics_cb, get_physics_stats = physics_callback render_cb, get_render_stats = render_callback @@ -238,182 +241,29 @@ def wrapped_step(dt): "Render time mismatch" ) - finally: - # Restore original step method - if viz is not None and original_step is not None: - viz.step = original_step - # Deregister physics callback - if physics_handle is not None: - physics_handle.deregister() - # Close environment (this also clears SimulationContext) - if env is not None: - env.close() - else: - # If env creation failed, still clear the singleton - SimulationContext.clear_instance() - - -@pytest.mark.parametrize("env_type", ["manager_based_env", "manager_based_rl_env", "direct_rl_env"]) -def test_env_render_false_skips_rendering(env_type, physics_callback, render_callback): - """Test that setting render_enabled=False skips all rendering while physics continues.""" - physics_cb, get_physics_stats = physics_callback - render_cb, get_render_stats = render_callback - - env = None - physics_handle = None - original_step = None - viz = None - - try: - # create a new stage - sim_utils.create_new_stage() - - # create environment with render_interval=1 so rendering would happen every physics step - if env_type == "manager_based_env": - env = create_manager_based_env(render_interval=1) - elif env_type == "manager_based_rl_env": - env = create_manager_based_rl_env(render_interval=1) - else: - env = create_direct_rl_env(render_interval=1) - - # enable the flag to render the environment - env.sim.set_setting("/isaaclab/render/rtx_sensors", True) - - # disable the app from shutting down when the environment is closed - env.sim._app_control_on_stop_handle = None # type: ignore - - # Reset to initialize visualizers - env.reset() - - # Ensure the default Kit visualizer is active for rendering callbacks. - assert isinstance(env.sim.visualizers[0], KitVisualizer) - - # add physics callback - physics_handle = env.sim.physics_manager.register_callback( - physics_cb, IsaacEvents.POST_PHYSICS_STEP, name="physics_step" - ) - - # Wrap visualizer step to track render calls - viz = env.sim.visualizers[0] - original_step = viz.step - render_dt = env.cfg.sim.dt * env.cfg.sim.render_interval - - def wrapped_step(dt): - original_step(dt) - render_cb(render_dt) - - viz.step = wrapped_step - - # create a zero action tensor for stepping the environment - actions = torch.zeros((env.num_envs, 0), device=env.device) - - # Step with render_enabled=False for several steps + # toggle rendering off between steps: physics keeps advancing and no further rendering happens + _, num_render_steps_before = get_render_stats() env.render_enabled = False - for i in range(10): + for i in range(10, 15): env.step(action=actions) - # Physics should still advance normally _, num_physics_steps = get_physics_stats() assert num_physics_steps == (i + 1) * env.cfg.decimation, "Physics steps mismatch with render_enabled=False" - - # No rendering should have occurred _, num_render_steps = get_render_stats() - assert num_render_steps == 0, f"Expected 0 render steps with render_enabled=False, got {num_render_steps}" - - finally: - if viz is not None and original_step is not None: - viz.step = original_step - if physics_handle is not None: - physics_handle.deregister() - if env is not None: - env.close() - else: - SimulationContext.clear_instance() - - -@pytest.mark.parametrize("env_type", ["manager_based_env", "manager_based_rl_env", "direct_rl_env"]) -def test_env_render_flag_mixed_steps(env_type, physics_callback, render_callback): - """Test that render_enabled can be toggled between steps and rendering counts are correct.""" - physics_cb, get_physics_stats = physics_callback - render_cb, get_render_stats = render_callback - - env = None - physics_handle = None - original_step = None - viz = None - - try: - # create a new stage - sim_utils.create_new_stage() - - # create environment with render_interval=1 so every decimation step renders - if env_type == "manager_based_env": - env = create_manager_based_env(render_interval=1) - elif env_type == "manager_based_rl_env": - env = create_manager_based_rl_env(render_interval=1) - else: - env = create_direct_rl_env(render_interval=1) - - # enable the flag to render the environment - env.sim.set_setting("/isaaclab/render/rtx_sensors", True) - - # disable the app from shutting down when the environment is closed - env.sim._app_control_on_stop_handle = None # type: ignore - - # Reset to initialize visualizers - env.reset() - - # Ensure the default Kit visualizer is active for rendering callbacks. - assert isinstance(env.sim.visualizers[0], KitVisualizer) - - # add physics callback - physics_handle = env.sim.physics_manager.register_callback( - physics_cb, IsaacEvents.POST_PHYSICS_STEP, name="physics_step" - ) - - # Wrap visualizer step to track render calls - viz = env.sim.visualizers[0] - original_step = viz.step - render_dt = env.cfg.sim.dt * env.cfg.sim.render_interval - - def wrapped_step(dt): - original_step(dt) - render_cb(render_dt) - - viz.step = wrapped_step - - # create a zero action tensor for stepping the environment - actions = torch.zeros((env.num_envs, 0), device=env.device) - - expected_render_steps = 0 - - # Step 5 times with render_enabled=True, then 5 with render_enabled=False - for i in range(10): - should_render = i < 5 - env.render_enabled = should_render - env.step(action=actions) - - # Physics always advances - _, num_physics_steps = get_physics_stats() - assert num_physics_steps == (i + 1) * env.cfg.decimation, "Physics steps mismatch in mixed test" - - # Rendering only happens in the first 5 steps - if should_render: - expected_render_steps += env.cfg.decimation # render_interval=1, so renders every decimation step - - _, num_render_steps = get_render_stats() - assert num_render_steps == expected_render_steps, ( - f"Render steps mismatch at step {i}: expected {expected_render_steps}, got {num_render_steps}" - ) + assert num_render_steps == num_render_steps_before, "Rendering occurred with render_enabled=False" finally: + # Restore original step method if viz is not None and original_step is not None: viz.step = original_step + # Deregister physics callback if physics_handle is not None: physics_handle.deregister() + # Close environment (this also clears SimulationContext) if env is not None: env.close() else: + # If env creation failed, still clear the singleton SimulationContext.clear_instance() diff --git a/source/isaaclab/test/envs/test_manager_based_env.py b/source/isaaclab/test/envs/test_manager_based_env.py index 3554701c39f3..db7692fe473b 100644 --- a/source/isaaclab/test/envs/test_manager_based_env.py +++ b/source/isaaclab/test/envs/test_manager_based_env.py @@ -25,6 +25,7 @@ from isaaclab.managers import ObservationGroupCfg as ObsGroup from isaaclab.managers import ObservationTermCfg as ObsTerm from isaaclab.test.env_cfgs import make_empty_manager_based_env_cfg +from isaaclab.test.utils import DeviceScope, test_devices from isaaclab.utils import configclass pytestmark = pytest.mark.integration @@ -60,38 +61,15 @@ def make_empty_manager_based_env_with_history_cfg( return cfg -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -def test_initialization(device): - """Test initialization of ManagerBasedEnv.""" - # create a new stage - sim_utils.create_new_stage() - # create environment - env = ManagerBasedEnv(cfg=make_empty_manager_based_env_cfg(device=device)) - # check size of action manager terms - assert env.action_manager.total_action_dim == 0 - assert len(env.action_manager.active_terms) == 0 - assert len(env.action_manager.action_term_dim) == 0 - # check size of observation manager terms - assert len(env.observation_manager.active_terms) == 0 - assert len(env.observation_manager.group_obs_dim) == 0 - assert len(env.observation_manager.group_obs_term_dim) == 0 - assert len(env.observation_manager.group_obs_concatenate) == 0 - # create actions of correct size (1,0) - act = torch.randn_like(env.action_manager.action) - # step environment to verify setup - for _ in range(2): - obs, ext = env.step(action=act) - # close the environment - env.close() - - -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +# Both devices, so the CPU and GPU simulation pipelines each build and step an environment. +@pytest.mark.parametrize("device", test_devices(DeviceScope.CPU_AND_DEFAULT_CUDA)) def test_step_updates_observation_history(device): - """Test that a real environment step advances observation history.""" + """Test that real environment steps advance observation history.""" # create a new stage sim_utils.create_new_stage() # create environment with history length of 5 env = ManagerBasedEnv(cfg=make_empty_manager_based_env_with_history_cfg(device=device)) + assert env.action_manager.total_action_dim == 0 history = env.observation_manager._group_obs_term_history_buffer["empty_observation"]["dummy_term"] torch.testing.assert_close( @@ -99,11 +77,12 @@ def test_step_updates_observation_history(device): torch.zeros((env.num_envs,), device=device, dtype=torch.int64), ) - # step the environment and verify that history advances - env.step(torch.randn_like(env.action_manager.action)) - torch.testing.assert_close( - history.current_length, - torch.ones((env.num_envs,), device=device, dtype=torch.int64), - ) + # step the environment repeatedly and verify that history advances with each step + for num_steps in (1, 2): + env.step(torch.randn_like(env.action_manager.action)) + torch.testing.assert_close( + history.current_length, + torch.full((env.num_envs,), num_steps, device=device, dtype=torch.int64), + ) env.close() diff --git a/source/isaaclab/test/envs/test_manager_based_rl_env_obs_spaces_task_integration.py b/source/isaaclab/test/envs/test_manager_based_rl_env_obs_spaces_task_integration.py index 30780a28d20d..a36cdaa95b03 100644 --- a/source/isaaclab/test/envs/test_manager_based_rl_env_obs_spaces_task_integration.py +++ b/source/isaaclab/test/envs/test_manager_based_rl_env_obs_spaces_task_integration.py @@ -22,65 +22,24 @@ import isaaclab.sim as sim_utils from isaaclab.envs import ManagerBasedRLEnv from isaaclab.managers import ObservationGroupCfg +from isaaclab.test.utils import DeviceScope, test_devices from isaaclab_tasks.utils import resolve_task_config pytestmark = pytest.mark.integration -@pytest.mark.parametrize("device", ["cpu", "cuda"]) -def test_non_concatenated_obs_groups_contain_all_terms(device): - """Test that non-concatenated observation groups contain all defined terms (issue #3133). - - Before the fix, only the last term in each non-concatenated group would be present - in the observation space Dict. This test ensures all terms are correctly included. - """ +# Gym spaces are built on the host, so one device covers them. Camera image terms have no clip and share the +# unbounded branch that test_manager_based_rl_env_unit.py covers, so the ray-caster task with a clipped height +# scan is the task-backed row. +@pytest.mark.parametrize("device", test_devices(DeviceScope.DEFAULT_CUDA)) +def test_obs_space_follows_clip_constraint(device): + """Ensure observation space bounds reflect the clip constraint on each term, and that non-concatenated + groups contain all their terms (issue #3133).""" # new USD stage sim_utils.create_new_stage() - # configure the policy group to return its terms separately - env_cfg, _ = resolve_task_config("Isaac-Cartpole", "", overrides=()) - env_cfg.scene.num_envs = 2 # keep num_envs small for testing - env_cfg.observations.policy.concatenate_terms = False - env_cfg.sim.device = device - - env = ManagerBasedRLEnv(cfg=env_cfg) - try: - assert isinstance(env.observation_space, gym.spaces.Dict) - policy_space = env.observation_space.spaces["policy"] - assert isinstance(policy_space, gym.spaces.Dict) - - expected_policy_terms = ["joint_pos_rel", "joint_vel_rel"] - - assert list(policy_space.spaces) == expected_policy_terms - for term_name in expected_policy_terms: - assert isinstance(policy_space.spaces[term_name], gym.spaces.Box) - - # Test that observations match the space structure. - env.reset() - action = torch.tensor(env.action_space.sample(), device=env.device) - obs, _, _, _, _ = env.step(action) - assert list(obs["policy"]) == expected_policy_terms - finally: - env.close() - - -@pytest.mark.parametrize( - ("task_name", "overrides"), - [ - ("Isaac-Cartpole-Camera", ("presets=rgb",)), - ("Isaac-Cartpole-Camera", ("presets=depth",)), - ("IsaacContrib-Velocity-Rough-AnymalC", ()), - ], - ids=["RGB", "Depth", "RayCaster"], -) -@pytest.mark.parametrize("device", ["cpu", "cuda"]) -def test_obs_space_follows_clip_constraint(task_name, overrides, device): - """Ensure observation space bounds reflect the clip constraint on each term.""" - # new USD stage - sim_utils.create_new_stage() - - env_cfg, _ = resolve_task_config(task_name, "", overrides=overrides) + env_cfg, _ = resolve_task_config("IsaacContrib-Velocity-Rough-AnymalC", "", overrides=()) env_cfg.scene.num_envs = 2 # keep num_envs small for testing for group_cfg in vars(env_cfg.observations).values(): if isinstance(group_cfg, ObservationGroupCfg): @@ -100,5 +59,15 @@ def test_obs_space_follows_clip_constraint(task_name, overrides, device): ) assert np.all(term_space.low == low) assert np.all(term_space.high == high) + + # every term of a non-concatenated group is present in its space and in the stepped observations + expected_policy_terms = env.observation_manager.active_terms["policy"] + assert len(expected_policy_terms) > 1 + # gymnasium sorts Dict space keys, so compare the term sets + assert sorted(env.observation_space.spaces["policy"].spaces) == sorted(expected_policy_terms) + env.reset() + action = torch.tensor(env.action_space.sample(), device=env.device) + obs, _, _, _, _ = env.step(action) + assert list(obs["policy"]) == expected_policy_terms finally: env.close() diff --git a/source/isaaclab/test/envs/test_marl_utils.py b/source/isaaclab/test/envs/test_marl_utils.py index 7580ac35fd05..cdcffa200c37 100644 --- a/source/isaaclab/test/envs/test_marl_utils.py +++ b/source/isaaclab/test/envs/test_marl_utils.py @@ -57,24 +57,6 @@ def close(self): pass -def test_multi_agent_to_single_agent_reset_concatenates_agents(): - """The adapter reset should concatenate the agents' observations.""" - env = multi_agent_to_single_agent(_FakeMultiAgentEnv()) - - observations, _ = env.reset() - - torch.testing.assert_close(observations["policy"], torch.tensor([[1.0, 2.0, 5.0], [3.0, 4.0, 6.0]])) - - -def test_multi_agent_to_single_agent_reset_can_use_state(): - """The adapter reset should support the state-as-observation mode.""" - env = multi_agent_to_single_agent(_FakeMultiAgentEnv(), state_as_observation=True) - - observations, _ = env.reset() - - torch.testing.assert_close(observations["policy"], torch.tensor([[7.0, 8.0], [9.0, 10.0]])) - - def test_multi_agent_to_single_agent_forwards_episode_lengths(): """RSL-RL episode randomization should update the wrapped environment buffer in place.""" source_env = _FakeMultiAgentEnv() @@ -109,6 +91,7 @@ def test_multi_agent_to_single_agent_state_observation_tracks_steps(): reset_obs, _ = env.reset() torch.testing.assert_close(env.obs_buf["policy"], reset_obs["policy"]) + torch.testing.assert_close(reset_obs["policy"], torch.tensor([[7.0, 8.0], [9.0, 10.0]])) step_obs = env.step(torch.zeros(2, 2))[0] torch.testing.assert_close(env.obs_buf["policy"], step_obs["policy"]) diff --git a/source/isaaclab/test/envs/test_modify_env_param_curr_term.py b/source/isaaclab/test/envs/test_modify_env_param_curr_term.py index 6ad8c584d7b4..0c7bf7b242ba 100644 --- a/source/isaaclab/test/envs/test_modify_env_param_curr_term.py +++ b/source/isaaclab/test/envs/test_modify_env_param_curr_term.py @@ -26,6 +26,7 @@ from isaaclab.managers import SceneEntityCfg from isaaclab.test.env_cfgs import EmptyManagerCfg from isaaclab.test.integration_scene_cfgs import CartpoleTestSceneCfg +from isaaclab.test.utils import DeviceScope, test_devices from isaaclab.utils import configclass pytestmark = pytest.mark.integration @@ -128,7 +129,8 @@ def __post_init__(self) -> None: self.sim.render_interval = self.decimation -@pytest.mark.parametrize("device", ["cpu", "cuda"]) +# Curriculum address resolution and cfg mutation are device independent, so one device covers them. +@pytest.mark.parametrize("device", test_devices(DeviceScope.DEFAULT_CUDA)) def test_curriculum_modify_env_param(device): """Ensure curriculum terms apply correctly after the fallback and replacement.""" # new USD stage diff --git a/source/isaaclab/test/envs/test_null_command_term.py b/source/isaaclab/test/envs/test_null_command_term.py index a6233ab252cc..b31882f78ab3 100644 --- a/source/isaaclab/test/envs/test_null_command_term.py +++ b/source/isaaclab/test/envs/test_null_command_term.py @@ -22,9 +22,7 @@ def test_str(env): """Test the string representation of the command manager.""" cfg = NullCommandCfg() command_term = cfg.class_type(cfg, env) - # print the expected string - print() - print(command_term) + assert "NullCommand" in str(command_term) def test_compute(env): diff --git a/source/isaaclab/test/envs/test_scale_randomization.py b/source/isaaclab/test/envs/test_scale_randomization.py index bb88921fa226..058f16d06cf0 100644 --- a/source/isaaclab/test/envs/test_scale_randomization.py +++ b/source/isaaclab/test/envs/test_scale_randomization.py @@ -36,6 +36,7 @@ from isaaclab.managers import ObservationTermCfg as ObsTerm from isaaclab.scene import InteractiveSceneCfg from isaaclab.terrains import TerrainImporterCfg +from isaaclab.test.utils import DeviceScope, test_devices from isaaclab.utils import configclass pytestmark = pytest.mark.integration @@ -281,7 +282,8 @@ def __post_init__(self): self.sim.render_interval = self.decimation -@pytest.mark.parametrize("device", ["cpu", "cuda"]) +# Scale randomization authors USD before the simulation starts, so one device covers it. +@pytest.mark.parametrize("device", test_devices(DeviceScope.DEFAULT_CUDA)) def test_scale_randomization(device): """Test scale randomization for cube environment.""" # create a new stage @@ -293,11 +295,6 @@ def test_scale_randomization(device): # setup base environment env = ManagerBasedEnv(cfg=env_cfg) - # setup target position commands - target_position = torch.rand(env.num_envs, 3, device=env.device) * 2 - target_position[:, 2] += 2.0 - # offset all targets so that they move to the world origin - target_position -= env.scene.env_origins # test to make sure all assets in the scene are created all_prim_paths = sim_utils.find_matching_prim_paths("/World/envs/env_[^/]+/cube[^/]*/[^/]*") @@ -327,15 +324,6 @@ def test_scale_randomization(device): scale_spec = prim_spec.GetAttributeAtPath(prim_paths[i] + ".xformOp:scale") assert tuple(scale_spec.default) == (1.0, 1.0, 1.0) - # simulate physics - with torch.inference_mode(): - for count in range(200): - # reset every few steps to check nothing breaks - if count % 100 == 0: - env.reset() - # step the environment - env.step(target_position) - env.close() @@ -348,6 +336,6 @@ def test_scale_randomization_failure_replicate_physics(): cfg_failure.scene.replicate_physics = True # run the test - with pytest.raises(RuntimeError): + with pytest.raises(RuntimeError, match="Scene replication is enabled"): env = ManagerBasedEnv(cfg_failure) env.close() diff --git a/source/isaaclab/test/envs/test_stacked_image_mdp.py b/source/isaaclab/test/envs/test_stacked_image_mdp.py index 4e5f0c20d80a..16e3554a8c47 100644 --- a/source/isaaclab/test/envs/test_stacked_image_mdp.py +++ b/source/isaaclab/test/envs/test_stacked_image_mdp.py @@ -17,7 +17,7 @@ import pytest import torch -pytestmark = pytest.mark.integration +pytestmark = pytest.mark.unit from isaaclab.envs.mdp.observations import image_features, stacked_image @@ -45,24 +45,6 @@ def _frame(value: int) -> torch.Tensor: class TestStackedImage: """Tests for the ``stacked_image`` observation term.""" - def test_output_shape_channel_stacked(self): - """Output shape is ``(N, H, W, K * C)``.""" - env = _make_env() - term = stacked_image(_make_cfg(frame_stack=3), env) - with mock.patch("isaaclab.envs.mdp.observations.image", return_value=_frame(1)): - out = term(env) - assert out.shape == (NUM_ENVS, HEIGHT, WIDTH, CHANNELS * 3) - - def test_warmup_fills_all_slots_with_first_frame(self): - """First call after construction fills all ``K`` slots with that one frame.""" - env = _make_env() - term = stacked_image(_make_cfg(frame_stack=2), env) - with mock.patch("isaaclab.envs.mdp.observations.image", return_value=_frame(7)): - out = term(env, normalize=False) - f7 = _frame(7) - assert torch.equal(out[..., :CHANNELS], f7) - assert torch.equal(out[..., CHANNELS:], f7) - def test_oldest_to_newest_channel_order(self): """K=3 with three distinct frames produces oldest→newest along the channel dim.""" env = _make_env() @@ -74,6 +56,7 @@ def test_oldest_to_newest_channel_order(self): term(env, normalize=False) # slots: [10, 10, 20] patched.return_value = _frame(30) out = term(env, normalize=False) # slots: [10, 20, 30] + assert out.shape == (NUM_ENVS, HEIGHT, WIDTH, CHANNELS * 3) assert torch.equal(out[..., :CHANNELS], _frame(10)) assert torch.equal(out[..., CHANNELS : 2 * CHANNELS], _frame(20)) assert torch.equal(out[..., 2 * CHANNELS :], _frame(30)) diff --git a/source/isaaclab/test/envs/test_stacked_image_obs_manager.py b/source/isaaclab/test/envs/test_stacked_image_obs_manager.py index dec8843dd020..dcf7053d9ac5 100644 --- a/source/isaaclab/test/envs/test_stacked_image_obs_manager.py +++ b/source/isaaclab/test/envs/test_stacked_image_obs_manager.py @@ -83,30 +83,11 @@ class PolicyCfg(ObservationGroupCfg): return ObsCfg() -def test_obs_manager_infers_channel_stacked_shape(env_with_sim): - """ObservationManager probes ``stacked_image`` at construction and infers the stacked shape.""" - with mock.patch("isaaclab.envs.mdp.observations.image", side_effect=_fake_image): - manager = ObservationManager(_make_cfg(frame_stack=2), env_with_sim) - assert manager.group_obs_dim["policy"] == (HEIGHT, WIDTH, CHANNELS * 2) - - def test_obs_manager_compute_returns_stacked_output(env_with_sim): """``compute()`` after construction returns the channel-stacked obs tensor.""" with mock.patch("isaaclab.envs.mdp.observations.image", side_effect=_fake_image): manager = ObservationManager(_make_cfg(frame_stack=3), env_with_sim) obs = manager.compute() + # the manager probes the term at construction and infers the channel-stacked shape + assert manager.group_obs_dim["policy"] == (HEIGHT, WIDTH, CHANNELS * 3) assert obs["policy"].shape == (NUM_ENVS, HEIGHT, WIDTH, CHANNELS * 3) - - -def test_obs_manager_reset_clears_term_state(env_with_sim): - """``manager.reset()`` forwards to ``stacked_image.reset()``; next compute fills slots with the new frame.""" - with mock.patch("isaaclab.envs.mdp.observations.image", side_effect=_fake_image): - manager = ObservationManager(_make_cfg(frame_stack=2), env_with_sim) - manager.compute() - manager.compute() # ring fills with two distinct frames - manager.reset() - # Next compute should treat the frame as fresh init — both channel-slots identical. - obs = manager.compute() - oldest = obs["policy"][..., :CHANNELS] - newest = obs["policy"][..., CHANNELS:] - assert torch.equal(oldest, newest), "After reset, init path should fill all slots with the same frame." diff --git a/source/isaaclab/test/envs/test_texture_randomization.py b/source/isaaclab/test/envs/test_texture_randomization.py index 2fb3a1d4ca56..70f8caa23bcd 100644 --- a/source/isaaclab/test/envs/test_texture_randomization.py +++ b/source/isaaclab/test/envs/test_texture_randomization.py @@ -30,6 +30,7 @@ from isaaclab.managers import ObservationTermCfg as ObsTerm from isaaclab.managers import SceneEntityCfg from isaaclab.test.integration_scene_cfgs import CartpoleTestSceneCfg +from isaaclab.test.utils import DeviceScope, test_devices from isaaclab.utils import configclass from isaaclab.utils.assets import NVIDIA_NUCLEUS_DIR @@ -128,36 +129,6 @@ class EventCfg: ) -@configclass -class EventCfgFallback: - """Configuration for events that tests the fallback mechanism.""" - - # Test fallback when /visuals pattern doesn't match - test_fallback_texture_randomizer = EventTerm( - func=mdp.randomize_visual_texture_material, - mode="reset", - params={ - "asset_cfg": SceneEntityCfg("robot", body_names=["slider"]), - "texture_paths": [ - f"{NVIDIA_NUCLEUS_DIR}/Materials/Base/Wood/Bamboo_Planks/Bamboo_Planks_BaseColor.png", - f"{NVIDIA_NUCLEUS_DIR}/Materials/Base/Wood/Cherry/Cherry_BaseColor.png", - ], - "event_name": "test_fallback_texture_randomizer", - "texture_rotation": (0.0, 0.0), - }, - ) - - reset_cart_position = EventTerm( - func=mdp.reset_joints_by_offset, - mode="reset", - params={ - "asset_cfg": SceneEntityCfg("robot", joint_names=["slider_to_cart"]), - "position_range": (-1.0, 1.0), - "velocity_range": (-0.1, 0.1), - }, - ) - - @configclass class CartpoleEnvCfg(ManagerBasedEnvCfg): """Configuration for the cartpole environment.""" @@ -181,7 +152,8 @@ def __post_init__(self): self.sim.dt = 0.005 # sim step every 5ms: 200Hz -@pytest.mark.parametrize("device", ["cpu", "cuda"]) +# Texture authoring through Replicator is device independent, so one device covers it. +@pytest.mark.parametrize("device", test_devices(DeviceScope.DEFAULT_CUDA)) def test_texture_randomization(device): """Test texture randomization for cartpole environment.""" # Create a new stage @@ -198,16 +170,18 @@ def test_texture_randomization(device): env = ManagerBasedEnv(cfg=env_cfg) try: - # Simulate physics - with torch.inference_mode(): - for count in range(50): - # Reset every few steps to check nothing breaks - if count % 10 == 0: - env.reset() - # Sample random actions - joint_efforts = torch.randn_like(env.action_manager.action) - # Step the environment - env.step(joint_efforts) + # the prestartup term applied cart textures; the reset term applies pole textures + env.reset() + env.step(torch.randn_like(env.action_manager.action)) + for term_name in ("cart_texture_randomizer", "pole_texture_randomizer"): + term_cfg = env.event_manager.get_term_cfg(term_name) + texture_paths = set(term_cfg.params["texture_paths"]) + applied = [ + material.GetChild("Shader").GetAttribute("inputs:diffuse_texture").Get() + for material in term_cfg.func.material_prims + ] + assert len(applied) == env.num_envs + assert all(texture is not None and texture.path in texture_paths for texture in applied), applied finally: env.close() finally: @@ -227,7 +201,7 @@ def test_texture_randomization_failure_replicate_physics(): cfg_failure.scene.replicate_physics = True # Test that creating the environment raises RuntimeError - with pytest.raises(RuntimeError): + with pytest.raises(RuntimeError, match="Scene replication is enabled"): env = ManagerBasedEnv(cfg_failure) env.close() finally: diff --git a/source/isaaclab/test/envs/test_video_recorder.py b/source/isaaclab/test/envs/test_video_recorder.py index a3ec38d0ee2b..516619bfe27e 100644 --- a/source/isaaclab/test/envs/test_video_recorder.py +++ b/source/isaaclab/test/envs/test_video_recorder.py @@ -11,7 +11,6 @@ from __future__ import annotations import logging -import warnings from types import SimpleNamespace from unittest.mock import MagicMock, patch @@ -275,22 +274,6 @@ def test_unavailable_source_logs_error_and_returns_none(caplog, source, make_env assert any(message in r.message for r in caplog.records) -# --------------------------------------------------------------------------- -# ViewerCfg deprecation shim -# --------------------------------------------------------------------------- - - -def test_viewer_cfg_warns_on_non_default_field(): - with pytest.warns(DeprecationWarning, match="ViewerCfg is deprecated"): - ViewerCfg(eye=(1.0, 2.0, 3.0)) - - -def test_viewer_cfg_default_no_warning(): - with warnings.catch_warnings(): - warnings.simplefilter("error", DeprecationWarning) - ViewerCfg() # must not raise - - # --------------------------------------------------------------------------- # _apply_deprecated_viewer_cfg bridge # --------------------------------------------------------------------------- diff --git a/source/isaaclab/test/envs/test_viewer_cfg_deprecation.py b/source/isaaclab/test/envs/test_viewer_cfg_deprecation.py index f41319d58c7b..fc60b21ae17c 100644 --- a/source/isaaclab/test/envs/test_viewer_cfg_deprecation.py +++ b/source/isaaclab/test/envs/test_viewer_cfg_deprecation.py @@ -24,28 +24,12 @@ def test_viewer_cfg_default_no_warning(): ViewerCfg() # must not raise -def test_viewer_cfg_custom_eye_warns(): - """ViewerCfg(eye=...) with a non-default value must emit DeprecationWarning.""" +# One tuple field and one non-tuple field: the check loops over every field with these two comparisons. +@pytest.mark.parametrize("field_value", [{"eye": (1.0, 2.0, 3.0)}, {"origin_type": "env"}], ids=["eye", "origin_type"]) +def test_viewer_cfg_custom_eye_warns(field_value): + """ViewerCfg with a non-default field value must emit DeprecationWarning.""" with pytest.warns(DeprecationWarning, match="ViewerCfg is deprecated"): - ViewerCfg(eye=(1.0, 2.0, 3.0)) - - -def test_viewer_cfg_custom_lookat_warns(): - """ViewerCfg(lookat=...) with a non-default value must emit DeprecationWarning.""" - with pytest.warns(DeprecationWarning, match="ViewerCfg is deprecated"): - ViewerCfg(lookat=(1.0, 0.0, 0.0)) - - -def test_viewer_cfg_custom_resolution_warns(): - """ViewerCfg(resolution=...) with a non-default value must emit DeprecationWarning.""" - with pytest.warns(DeprecationWarning, match="ViewerCfg is deprecated"): - ViewerCfg(resolution=(640, 480)) - - -def test_viewer_cfg_custom_origin_type_warns(): - """ViewerCfg(origin_type=...) with a non-default value must emit DeprecationWarning.""" - with pytest.warns(DeprecationWarning, match="ViewerCfg is deprecated"): - ViewerCfg(origin_type="env") + ViewerCfg(**field_value) def test_viewer_cfg_default_eye_no_warning(): @@ -53,12 +37,3 @@ def test_viewer_cfg_default_eye_no_warning(): with warnings.catch_warnings(): warnings.simplefilter("error", DeprecationWarning) ViewerCfg(eye=(7.5, 7.5, 7.5)) - - -def test_viewer_cfg_warning_is_deprecation_warning_subclass(): - """The emitted warning must be a DeprecationWarning (not just a UserWarning).""" - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") - ViewerCfg(eye=(0.0, 0.0, 1.0)) - - assert any(issubclass(w.category, DeprecationWarning) for w in caught) diff --git a/source/isaaclab/test/envs/test_visual_material_events.py b/source/isaaclab/test/envs/test_visual_material_events.py index 9b047c2d1d70..0277d9336969 100644 --- a/source/isaaclab/test/envs/test_visual_material_events.py +++ b/source/isaaclab/test/envs/test_visual_material_events.py @@ -5,7 +5,6 @@ """Unit tests for visual-material manager terms.""" -import inspect from types import SimpleNamespace import pytest @@ -91,10 +90,6 @@ def test_shape_backend_follows_only_active_render_consumers() -> None: with pytest.raises(NotImplementedError, match="no per-shape visual storage"): randomize_visual_shape._get_backend(None, SimpleNamespace(sim=sim)) - selector = inspect.getsource(randomize_visual_shape._get_backend) - assert "physics_manager" not in selector - assert "FactoryBase._get_backend" not in selector - unsupported_env = SimpleNamespace( sim=SimpleNamespace(resolve_visualizer_types=lambda: [], render_context=SimpleNamespace(renderer_types=())) ) diff --git a/source/isaaclab/test/managers/test_event_manager.py b/source/isaaclab/test/managers/test_event_manager.py index 9ae299c4a507..6f048a7464ba 100644 --- a/source/isaaclab/test/managers/test_event_manager.py +++ b/source/isaaclab/test/managers/test_event_manager.py @@ -6,29 +6,18 @@ # ignore private usage of variables warning # pyright: reportPrivateUsage=none -"""Launch Isaac Sim Simulator first.""" - -from collections.abc import Sequence - -from isaaclab.app import AppLauncher - -# launch omniverse app -simulation_app = AppLauncher(headless=True).app - -"""Rest everything follows.""" - - from collections import namedtuple +from collections.abc import Sequence +from unittest.mock import MagicMock import pytest import torch from isaaclab.envs import ManagerBasedEnv from isaaclab.managers import EventManager, EventTermCfg, ManagerTermBase, ManagerTermBaseCfg -from isaaclab.sim import SimulationContext from isaaclab.utils import configclass -pytestmark = pytest.mark.integration +pytestmark = pytest.mark.unit DummyEnv = namedtuple("ManagerBasedRLEnv", ["num_envs", "dt", "device", "sim", "dummy1", "dummy2"]) """Dummy environment for testing.""" @@ -91,26 +80,11 @@ def env(): # create dummy tensors dummy1 = torch.zeros((num_envs, 2), device=device) dummy2 = torch.zeros((num_envs, 10), device=device) - # create sim - sim = SimulationContext() + # simulation double that has not started playing, so class terms stay deferred to the physics-ready callback + sim = MagicMock() + sim.is_playing.return_value = False # create dummy environment - yield DummyEnv(num_envs, 0.01, device, sim, dummy1, dummy2) - SimulationContext.clear_instance() - - -def test_str(env): - """Test the string representation of the event manager.""" - cfg = { - "term_1": EventTermCfg(func=increment_dummy1_by_one, mode="interval", interval_range_s=(0.1, 0.1)), - "term_2": EventTermCfg(func=reset_dummy1_to_zero, mode="reset"), - "term_3": EventTermCfg(func=change_dummy1_by_value, mode="custom", params={"value": 10}), - "term_4": EventTermCfg(func=change_dummy1_by_value, mode="custom", params={"value": 2}), - } - event_man = EventManager(cfg, env) - - # print the expected string - print() - print(event_man) + return DummyEnv(num_envs, 0.01, device, sim, dummy1, dummy2) def test_config_equivalence(env): @@ -172,6 +146,10 @@ def test_active_terms(env): assert len(event_man.active_terms["interval"]) == 1 assert len(event_man.active_terms["reset"]) == 1 assert len(event_man.active_terms["custom"]) == 2 + # the string representation lists each term, with the interval range for interval terms + event_man_str = str(event_man) + assert "term_4" in event_man_str + assert "Interval time range" in event_man_str and "(0.1, 0.1)" in event_man_str def test_class_terms(env): @@ -207,10 +185,6 @@ def test_config_empty(env): event_man = EventManager(None, env) assert len(event_man.active_terms) == 0 - # print the expected string - print() - print(event_man) - def test_invalid_event_func_module(env): """Test the handling of invalid event function's module in string representation.""" diff --git a/source/isaaclab/test/managers/test_manager_base.py b/source/isaaclab/test/managers/test_manager_base.py index 658831e65e8c..634e75c62ba9 100644 --- a/source/isaaclab/test/managers/test_manager_base.py +++ b/source/isaaclab/test/managers/test_manager_base.py @@ -22,9 +22,10 @@ from isaaclab.envs import ManagerBasedEnv from isaaclab.managers import ManagerTermBase, ManagerTermBaseCfg from isaaclab.managers.manager_base import ManagerBase +from isaaclab.physics import PhysicsEvent from isaaclab.utils import configclass, modifiers -pytestmark = pytest.mark.integration +pytestmark = pytest.mark.unit DummyEnv = namedtuple("ManagerBasedRLEnv", ["num_envs", "dt", "device", "sim", "dummy1", "dummy2"]) """Dummy environment for testing.""" @@ -143,65 +144,6 @@ def env(): return DummyEnv(num_envs, 0.01, device, sim, dummy1, dummy2) -def test_nested_term_cfg_in_dict_params(env): - """Test that nested ManagerTermBaseCfg inside a dict param are recursively resolved.""" - cfg = { - "chained": ManagerTermBaseCfg( - func=chained_terms_class, - params={ - "terms": { - "step_a": ManagerTermBaseCfg(func=increment_dummy1_by_one), - "step_b": ManagerTermBaseCfg(func=change_dummy1_by_value, params={"value": 5}), - } - }, - ), - } - manager = SimpleManager(cfg, env) - - # The outer chained_terms_class should be instantiated (class -> instance). - outer_cfg = manager._term_cfgs[0][1] - assert isinstance(outer_cfg.func, chained_terms_class) - - # Inner terms should have their func resolved to callables (not strings). - inner_terms = outer_cfg.params["terms"] - assert callable(inner_terms["step_a"].func), "Nested func should be resolved to a callable" - assert callable(inner_terms["step_b"].func), "Nested func should be resolved to a callable" - assert inner_terms["step_a"].func is increment_dummy1_by_one - assert inner_terms["step_b"].func is change_dummy1_by_value - - # Functionally: applying the chained term should run both inner terms. - manager.apply(torch.arange(env.num_envs, device=env.device)) - # increment_dummy1_by_one adds 1, then change_dummy1_by_value adds 5 -> total 6 - torch.testing.assert_close(env.dummy1, 6 * torch.ones_like(env.dummy1)) - - -def test_nested_term_cfg_in_list_params(env): - """Test that nested ManagerTermBaseCfg inside a list param are recursively resolved.""" - cfg = { - "list_chained": ManagerTermBaseCfg( - func=list_terms_class, - params={ - "term_list": [ - ManagerTermBaseCfg(func=increment_dummy1_by_one), - ManagerTermBaseCfg(func=change_dummy1_by_value, params={"value": 3}), - ] - }, - ), - } - manager = SimpleManager(cfg, env) - - # Inner terms in the list should have callable funcs. - outer_cfg = manager._term_cfgs[0][1] - term_list = outer_cfg.params["term_list"] - assert isinstance(term_list, list) - assert callable(term_list[0].func) - assert callable(term_list[1].func) - - # Apply and verify: +1 then +3 -> total 4 - manager.apply(torch.arange(env.num_envs, device=env.device)) - torch.testing.assert_close(env.dummy1, 4 * torch.ones_like(env.dummy1)) - - def test_string_func_in_nested_term_cfg(env): """Test that string-based func references inside nested term cfgs are resolved.""" this_module = __name__ @@ -274,32 +216,6 @@ def test_string_func_top_level_class_term(env): torch.testing.assert_close(env.dummy2, torch.zeros_like(env.dummy2)) -def test_deeply_nested_dict_in_params(env): - """Test that term cfgs are resolved even when nested inside dict values.""" - cfg = { - "chained": ManagerTermBaseCfg( - func=chained_terms_class, - params={ - "terms": { - "only": ManagerTermBaseCfg( - func=change_dummy1_by_value, - params={"value": 7}, - ), - } - }, - ), - } - manager = SimpleManager(cfg, env) - - outer_cfg = manager._term_cfgs[0][1] - inner_cfg = outer_cfg.params["terms"]["only"] - assert callable(inner_cfg.func) - assert inner_cfg.params == {"value": 7} - - manager.apply(torch.arange(env.num_envs, device=env.device)) - torch.testing.assert_close(env.dummy1, 7 * torch.ones_like(env.dummy1)) - - def test_chained_containing_chained_and_list(env): """Test multi-level nesting: a chained term whose children are chained and list terms.""" cfg = { @@ -321,7 +237,8 @@ def test_chained_containing_chained_and_list(env): params={ "term_list": [ ManagerTermBaseCfg(func=change_dummy1_by_value, params={"value": 10}), - ManagerTermBaseCfg(func=change_dummy1_by_value, params={"value": 20}), + # string funcs inside lists must be resolved as well + ManagerTermBaseCfg(func=f"{__name__}:change_dummy1_by_value", params={"value": 20}), ] }, ), @@ -354,3 +271,22 @@ def test_chained_containing_chained_and_list(env): # Apply and verify: inner_chain adds (1 + 2) = 3, inner_list adds (10 + 20) = 30 -> total 33 manager.apply(torch.arange(env.num_envs, device=env.device)) torch.testing.assert_close(env.dummy1, 33 * torch.ones_like(env.dummy1)) + + +def test_terms_resolve_when_physics_is_ready_if_created_before_play(env): + """A manager created before the simulation plays defers term resolution to the physics-ready callback.""" + env.sim.is_playing.return_value = False + cfg = {"term_class": ManagerTermBaseCfg(func=reset_dummy2_to_zero_class)} + manager = SimpleManager(cfg, env) + + # class terms are not instantiated until the physics-ready callback fires + term_cfg = manager._term_cfgs[0][1] + assert term_cfg.func is reset_dummy2_to_zero_class + callback, event = env.sim.physics_manager.register_callback.call_args.args + assert event == PhysicsEvent.PHYSICS_READY + + callback(None) + assert isinstance(term_cfg.func, reset_dummy2_to_zero_class) + env.dummy2[:] = 42.0 + manager.apply(torch.arange(env.num_envs, device=env.device)) + torch.testing.assert_close(env.dummy2, torch.zeros_like(env.dummy2)) diff --git a/source/isaaclab/test/managers/test_observation_manager.py b/source/isaaclab/test/managers/test_observation_manager.py index 4cd0a14ea27c..8a0a13a6335c 100644 --- a/source/isaaclab/test/managers/test_observation_manager.py +++ b/source/isaaclab/test/managers/test_observation_manager.py @@ -3,25 +3,19 @@ # # SPDX-License-Identifier: BSD-3-Clause +"""Unit tests for observation managers.""" + # needed to import for allowing type-hinting: torch.Tensor | None from __future__ import annotations -"""Launch Isaac Sim Simulator first.""" - -from isaaclab.app import AppLauncher - -# launch omniverse app -simulation_app = AppLauncher(headless=True).app - -"""Rest everything follows.""" - +# ignore private usage of variables warning +# pyright: reportPrivateUsage=none from collections import namedtuple -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast import pytest import torch -import isaaclab.sim as sim_utils from isaaclab.managers import ( ManagerTermBase, ObservationGroupCfg, @@ -29,9 +23,9 @@ ObservationTermCfg, RewardTermCfg, ) -from isaaclab.utils import configclass, modifiers +from isaaclab.utils import DelayBuffer, configclass, modifiers, noise -pytestmark = pytest.mark.integration +pytestmark = pytest.mark.unit if TYPE_CHECKING: from isaaclab.envs import ManagerBasedEnv @@ -103,24 +97,23 @@ def lin_vel_w_data(env) -> torch.Tensor: return env.data.lin_vel_w -@pytest.fixture(autouse=True) +class DummySimulation: + """Minimal playing simulation double.""" + + def is_playing(self) -> bool: + """Return whether the simulated timeline is playing.""" + return True + + +@pytest.fixture def setup_env(): dt = 0.01 num_envs = 20 - device = "cuda:0" - # set up sim - sim_cfg = sim_utils.SimulationCfg(dt=dt, device=device) - sim = sim_utils.SimulationContext(sim_cfg) - # create dummy environment - env = namedtuple("ManagerBasedEnv", ["num_envs", "device", "data", "dt", "sim"])( - num_envs, device, MyDataClass(num_envs, device), dt, sim + device = "cpu" + # create dummy environment with a playing simulation, so the manager resolves terms immediately + return namedtuple("ManagerBasedEnv", ["num_envs", "device", "data", "dt", "sim"])( + num_envs, device, MyDataClass(num_envs, device), dt, DummySimulation() ) - # let the simulation play (we need this for observation manager to compute obs dims) - env.sim._app_control_on_stop_handle = None - env.sim.reset() - yield env - sim.stop() - sim_utils.SimulationContext.clear_instance() def test_str(setup_env): @@ -146,59 +139,22 @@ class SampleGroupCfg(ObservationGroupCfg): ) policy: ObservationGroupCfg = SampleGroupCfg() + # a term history flattens into the term shape + hist: ObservationGroupCfg = SampleGroupCfg( + term_1=ObservationTermCfg(func=grilled_chicken, scale=10, history_length=5) + ) # create observation manager cfg = MyObservationManagerCfg() obs_man = ObservationManager(cfg, env) assert len(obs_man.active_terms["policy"]) == 5 - # print the expected string obs_man_str = str(obs_man) - print() - print(obs_man_str) - obs_man_str_split = obs_man_str.split("|") - term_1_str_index = next(i for i, s in enumerate(obs_man_str_split) if s.strip() == "term_1") - term_1_str_shape = obs_man_str_split[term_1_str_index + 1].strip() - assert term_1_str_shape == "(4,)" - - -def test_str_with_history(setup_env): - env = setup_env - """Test the string representation of the observation manager with history terms.""" - - TERM_1_HISTORY = 5 - - @configclass - class MyObservationManagerCfg: - """Test config class for observation manager.""" - - @configclass - class SampleGroupCfg(ObservationGroupCfg): - """Test config class for policy observation group.""" - - term_1 = ObservationTermCfg(func=grilled_chicken, scale=10, history_length=TERM_1_HISTORY) - term_2 = ObservationTermCfg(func=grilled_chicken, scale=2) - term_3 = ObservationTermCfg(func=grilled_chicken_with_bbq, scale=5, params={"bbq": True}) - term_4 = ObservationTermCfg( - func=grilled_chicken_with_yoghurt, scale=1.0, params={"hot": False, "bland": 2.0} - ) - term_5 = ObservationTermCfg( - func=grilled_chicken_with_yoghurt_and_bbq, scale=1.0, params={"hot": False, "bland": 2.0} - ) - - policy: ObservationGroupCfg = SampleGroupCfg() - - # create observation manager - cfg = MyObservationManagerCfg() - obs_man = ObservationManager(cfg, env) - assert len(obs_man.active_terms["policy"]) == 5 - # print the expected string - obs_man_str = str(obs_man) - print() - print(obs_man_str) - obs_man_str_split = obs_man_str.split("|") - term_1_str_index = next(i for i, s in enumerate(obs_man_str_split) if s.strip() == "term_1") - term_1_str_shape = obs_man_str_split[term_1_str_index + 1].strip() - assert term_1_str_shape == "(20,)" + for group_name, expected_shape in (("policy", "(4,)"), ("hist", "(20,)")): + group_str = obs_man_str.split(f"Group: '{group_name}'")[1] + group_str_split = group_str.split("|") + term_1_str_index = next(i for i, s in enumerate(group_str_split) if s.strip() == "term_1") + term_1_str_shape = group_str_split[term_1_str_index + 1].strip() + assert term_1_str_shape == expected_shape def test_config_equivalence(setup_env): @@ -373,58 +329,6 @@ class ImageCfg(ObservationGroupCfg): assert torch.equal(obs_policy[:, 8:11], obs_critic[:, 3:6]) -def test_compute_with_history(setup_env): - env = setup_env - """Test the observation computation with history buffers.""" - HISTORY_LENGTH = 5 - - @configclass - class MyObservationManagerCfg: - """Test config class for observation manager.""" - - @configclass - class PolicyCfg(ObservationGroupCfg): - """Test config class for policy observation group.""" - - term_1 = ObservationTermCfg(func=grilled_chicken, history_length=HISTORY_LENGTH) - # total observation size: term_dim (4) * history_len (5) = 20 - term_2 = ObservationTermCfg(func=lin_vel_w_data) - # total observation size: term_dim (3) = 3 - - policy: ObservationGroupCfg = PolicyCfg() - - # create observation manager - cfg = MyObservationManagerCfg() - obs_man = ObservationManager(cfg, env) - # compute observation using manager - observations = obs_man.compute() - # obtain the group observations - obs_policy: torch.Tensor = observations["policy"] - # check the observation shape - assert obs_policy.shape == (env.num_envs, 23) - # check the observation data - expected_obs_term_1_data = torch.ones(env.num_envs, 4 * HISTORY_LENGTH, device=env.device) - expected_obs_term_2_data = lin_vel_w_data(env) - expected_obs_data_t0 = torch.concat((expected_obs_term_1_data, expected_obs_term_2_data), dim=-1) - torch.testing.assert_close(expected_obs_data_t0, obs_policy) - # test that the history buffer holds previous data - for _ in range(HISTORY_LENGTH): - observations = obs_man.compute() - obs_policy = observations["policy"] - expected_obs_term_1_data = torch.ones(env.num_envs, 4 * HISTORY_LENGTH, device=env.device) - expected_obs_data_t5 = torch.concat((expected_obs_term_1_data, expected_obs_term_2_data), dim=-1) - assert torch.equal(expected_obs_data_t5, obs_policy) - # test reset - obs_man.reset() - observations = obs_man.compute() - obs_policy = observations["policy"] - torch.testing.assert_close(expected_obs_data_t0, obs_policy) - # test reset of specific env ids - reset_env_ids = [2, 4, 16] - obs_man.reset(reset_env_ids) - torch.testing.assert_close(expected_obs_data_t0[reset_env_ids], obs_policy[reset_env_ids]) - - def test_compute_with_2d_history(setup_env): env = setup_env """Test the observation computation with history buffers for 2D observations.""" @@ -471,64 +375,6 @@ class PolicyCfg(ObservationGroupCfg): assert obs_policy.shape == (env.num_envs, HISTORY_LENGTH, 128, 256, 1) -def test_compute_with_group_history(setup_env): - env = setup_env - """Test the observation computation with group level history buffer configuration.""" - TERM_HISTORY_LENGTH = 5 - GROUP_HISTORY_LENGTH = 10 - - @configclass - class MyObservationManagerCfg: - """Test config class for observation manager.""" - - @configclass - class PolicyCfg(ObservationGroupCfg): - """Test config class for policy observation group.""" - - history_length = GROUP_HISTORY_LENGTH - # group level history length will override all terms - term_1 = ObservationTermCfg(func=grilled_chicken, history_length=TERM_HISTORY_LENGTH) - # total observation size: term_dim (4) * history_len (5) = 20 - # with override total obs size: term_dim (4) * history_len (10) = 40 - term_2 = ObservationTermCfg(func=lin_vel_w_data) - # total observation size: term_dim (3) = 3 - # with override total obs size: term_dim (3) * history_len (10) = 30 - - policy: ObservationGroupCfg = PolicyCfg() - - # create observation manager - cfg = MyObservationManagerCfg() - obs_man = ObservationManager(cfg, env) - # compute observation using manager - observations = obs_man.compute() - # obtain the group observations - obs_policy: torch.Tensor = observations["policy"] - # check the total observation shape - assert obs_policy.shape == (env.num_envs, 70) - # check the observation data is initialized properly - expected_obs_term_1_data = torch.ones(env.num_envs, 4 * GROUP_HISTORY_LENGTH, device=env.device) - expected_obs_term_2_data = lin_vel_w_data(env).repeat(1, GROUP_HISTORY_LENGTH) - expected_obs_data_t0 = torch.concat((expected_obs_term_1_data, expected_obs_term_2_data), dim=-1) - torch.testing.assert_close(expected_obs_data_t0, obs_policy) - # test that the history buffer holds previous data - for _ in range(GROUP_HISTORY_LENGTH): - observations = obs_man.compute() - obs_policy = observations["policy"] - expected_obs_term_1_data = torch.ones(env.num_envs, 4 * GROUP_HISTORY_LENGTH, device=env.device) - expected_obs_term_2_data = lin_vel_w_data(env).repeat(1, GROUP_HISTORY_LENGTH) - expected_obs_data_t10 = torch.concat((expected_obs_term_1_data, expected_obs_term_2_data), dim=-1) - torch.testing.assert_close(expected_obs_data_t10, obs_policy) - # test reset - obs_man.reset() - observations = obs_man.compute() - obs_policy = observations["policy"] - torch.testing.assert_close(expected_obs_data_t0, obs_policy) - # test reset of specific env ids - reset_env_ids = [2, 4, 16] - obs_man.reset(reset_env_ids) - torch.testing.assert_close(expected_obs_data_t0[reset_env_ids], obs_policy[reset_env_ids]) - - def test_invalid_observation_config(setup_env): env = setup_env """Test the invalid observation config.""" @@ -663,6 +509,8 @@ class CriticCfg(ObservationGroupCfg): # check correct application of modifications assert torch.equal(obs_policy["term_1"] + 1.0, obs_policy["term_2"]) + # the first integration step averages the biased signal with a zero initial value + torch.testing.assert_close(obs_policy["term_3"], 0.5 * (obs_policy["term_1"] + 1.0) * env.dt) assert torch.equal(obs_critic["term_1"] + 1.0, obs_critic["term_2"]) assert torch.equal(2.0 * (obs_critic["term_1"] + 1.0), obs_critic["term_3"]) assert torch.min(obs_critic["term_4"]) >= -0.5 @@ -798,3 +646,173 @@ class CriticCfg_neg_dim(ObservationGroupCfg): # For critic_neg_dim: check that it is the same as critic torch.testing.assert_close(obs_critic_neg_dim, obs_critic) + + +def dummy_observation(env: DummyEnv) -> torch.Tensor: + """Return the dummy environment observation.""" + return env.observation + + +class DummyEnv: + """Minimal environment double used by :class:`ObservationManager`.""" + + def __init__(self, num_envs: int = 2) -> None: + self.num_envs = num_envs + self.device = "cpu" + self.sim = DummySimulation() + self.observation = torch.arange(num_envs, dtype=torch.float32).unsqueeze(-1) + + +class StatefulBiasModifier(modifiers.ModifierBase): + """Stateful modifier used to verify lazy callable resolution.""" + + def __init__(self, cfg: modifiers.ModifierCfg, data_dim: tuple[int, ...], device: str) -> None: + super().__init__(cfg, data_dim, device) + self.value = cfg.params["value"] + self.reset_count = 0 + + def reset(self, env_ids=None) -> None: + self.reset_count += 1 + + def __call__(self, data: torch.Tensor) -> torch.Tensor: + return data + self.value + + +class InvalidModifier: + """Class with the modifier constructor contract but the wrong base type.""" + + def __init__(self, cfg, data_dim, device): + pass + + +@configclass +class HistoryObservationsCfg: + """Observation configuration with group-level history.""" + + @configclass + class PolicyCfg(ObservationGroupCfg): + """Policy observation group configuration.""" + + dummy: ObservationTermCfg = ObservationTermCfg(func=dummy_observation) + + def __post_init__(self): + self.history_length = 5 + + policy: PolicyCfg = PolicyCfg() + + +def test_class_modifier_roundtrip_preserves_func_and_params(): + """Reproduce #6067 with a class modifier and non-empty parameters.""" + cfg = HistoryObservationsCfg() + cfg.policy.history_length = None + cfg.policy.dummy.modifiers = [modifiers.ModifierCfg(func=StatefulBiasModifier, params={"value": 2.0})] + cfg.from_dict(cfg.to_dict()) + term_cfg = cfg.policy.dummy + assert term_cfg.modifiers is not None + modifier_cfg = term_cfg.modifiers[0] + assert isinstance(modifier_cfg, modifiers.ModifierCfg) + assert isinstance(modifier_cfg.func, str) + assert modifier_cfg.params == {"value": 2.0} + + env = DummyEnv() + manager = ObservationManager(cfg, cast("ManagerBasedEnv", env)) + prepared_term_cfg = manager.cfg.policy.dummy + assert prepared_term_cfg.modifiers is not None + prepared_modifier_cfg = prepared_term_cfg.modifiers[0] + assert isinstance(prepared_modifier_cfg.func, StatefulBiasModifier) + observations = manager.compute()["policy"] + torch.testing.assert_close(observations, env.observation + 2.0) + + manager.reset() + assert prepared_modifier_cfg.func.reset_count == 1 + + +def test_stateless_modifier_cfg_roundtrip_preserves_signature_validation(): + """A stateless modifier remains callable and inspectable after a configuration round-trip.""" + cfg = HistoryObservationsCfg() + cfg.policy.history_length = None + cfg.policy.dummy.modifiers = [modifiers.ModifierCfg(func=modifiers.bias, params={"value": 2.0})] + cfg.from_dict(cfg.to_dict()) + + env = DummyEnv() + manager = ObservationManager(cfg, cast("ManagerBasedEnv", env)) + observations = manager.compute()["policy"] + torch.testing.assert_close(observations, env.observation + 2.0) + + +def test_class_modifier_validates_constructed_instance(): + """Class modifier validation checks the constructed object.""" + cfg = HistoryObservationsCfg() + cfg.policy.history_length = None + cfg.policy.dummy.modifiers = [modifiers.ModifierCfg(func=InvalidModifier)] + cfg.from_dict(cfg.to_dict()) + + with pytest.raises(TypeError, match="is not an instance of 'ModifierBase'"): + ObservationManager(cfg, cast("ManagerBasedEnv", DummyEnv())) + + +@pytest.mark.parametrize( + ("lag", "history_length", "term_history"), [(0, 2, False), (1, 0, False), (1, 2, False), (0, 2, True)] +) +def test_compute_updates_history_only_when_requested(lag, history_length, term_history): + """History alone, delay alone, and their combination advance only on recorded samples. + + A group history length overrides the term's own history length; without one, the term history applies. + """ + cfg = HistoryObservationsCfg() + if term_history: + cfg.policy.history_length = None + cfg.policy.dummy.history_length = history_length + else: + cfg.policy.history_length = history_length + cfg.policy.dummy.history_length = 3 + cfg.policy.enable_corruption = True + cfg.policy.dummy.delay_min_lag = cfg.policy.dummy.delay_max_lag = lag + cfg.policy.dummy.delay_hold_prob = 0.5 if history_length == 0 else 1.0 + cfg.policy.dummy.noise = noise.ConstantNoiseCfg(bias=0.0) + cfg.policy.dummy.scale = 2.0 + env = DummyEnv() + manager = ObservationManager(cfg, cast("ManagerBasedEnv", env)) + delay = manager._group_obs_term_delay_buffer["policy"].get("dummy") + history = manager._group_obs_term_history_buffer["policy"].get("dummy") + manager.compute() + if delay: + assert isinstance(delay, DelayBuffer) + assert torch.all(delay.num_pushes == 0) + if history: + assert torch.all(history.current_length == 0) + + for step in range(6): + if step == 3: + manager.reset([1]) + env.observation.fill_(step) + # Delay retains each sample's noise; history stacks the delayed, scaled outputs. + manager.cfg.policy.dummy.noise.bias = float(step) + output = manager.compute(update_history=True)["policy"] + sample_steps = torch.arange(step - max(1, history_length) + 1, step + 1) + expected = 4.0 * (sample_steps - lag).clamp_min(0).expand(env.num_envs, -1).clone() + if step >= 3: + expected[1].clamp_(min=12.0) + torch.testing.assert_close(output, expected) + env.observation.fill_(-100.0) + rng_state = torch.get_rng_state() + torch.testing.assert_close(manager.compute()["policy"], expected) + torch.testing.assert_close(manager.compute_group("policy"), expected) + assert torch.equal(torch.get_rng_state(), rng_state) + + +@pytest.mark.parametrize( + ("params", "error"), + [ + ({"delay_min_lag": -1}, ValueError), + ({"delay_min_lag": 2, "delay_max_lag": 1}, ValueError), + ({"delay_min_lag": 0.5}, TypeError), + ({"delay_hold_prob": -0.1}, ValueError), + ({"delay_hold_prob": 1.1}, ValueError), + ], +) +def test_observation_delay_config_validation(params, error): + """Delay requires ordered nonnegative integer bounds and a probability in [0, 1].""" + cfg = ObservationTermCfg(func=dummy_observation, **params) + with pytest.raises(error, match="delay"): + cfg.validate() diff --git a/source/isaaclab/test/managers/test_observation_manager_unit.py b/source/isaaclab/test/managers/test_observation_manager_unit.py deleted file mode 100644 index 9bcdd528fd6a..000000000000 --- a/source/isaaclab/test/managers/test_observation_manager_unit.py +++ /dev/null @@ -1,205 +0,0 @@ -# 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 observation managers.""" - -from __future__ import annotations - -# ignore private usage of variables warning -# pyright: reportPrivateUsage=none -import inspect -from typing import TYPE_CHECKING, cast - -import pytest -import torch - -from isaaclab.managers import ObservationGroupCfg, ObservationManager, ObservationTermCfg -from isaaclab.utils import DelayBuffer, configclass, modifiers, noise - -pytestmark = pytest.mark.unit - -if TYPE_CHECKING: - from isaaclab.envs import ManagerBasedEnv - - -def dummy_observation(env: DummyEnv) -> torch.Tensor: - """Return the dummy environment observation.""" - return env.observation - - -class DummySimulation: - """Minimal playing simulation double.""" - - def is_playing(self) -> bool: - """Return whether the simulated timeline is playing.""" - return True - - -class DummyEnv: - """Minimal environment double used by :class:`ObservationManager`.""" - - def __init__(self, num_envs: int = 2) -> None: - self.num_envs = num_envs - self.device = "cpu" - self.sim = DummySimulation() - self.observation = torch.arange(num_envs, dtype=torch.float32).unsqueeze(-1) - - -class StatefulBiasModifier(modifiers.ModifierBase): - """Stateful modifier used to verify lazy callable resolution.""" - - def __init__(self, cfg: modifiers.ModifierCfg, data_dim: tuple[int, ...], device: str) -> None: - super().__init__(cfg, data_dim, device) - self.value = cfg.params["value"] - self.reset_count = 0 - - def reset(self, env_ids=None) -> None: - self.reset_count += 1 - - def __call__(self, data: torch.Tensor) -> torch.Tensor: - return data + self.value - - -class InvalidModifier: - """Class with the modifier constructor contract but the wrong base type.""" - - def __init__(self, cfg, data_dim, device): - pass - - -@configclass -class HistoryObservationsCfg: - """Observation configuration with group-level history.""" - - @configclass - class PolicyCfg(ObservationGroupCfg): - """Policy observation group configuration.""" - - dummy: ObservationTermCfg = ObservationTermCfg(func=dummy_observation) - - def __post_init__(self): - self.history_length = 5 - - policy: PolicyCfg = PolicyCfg() - - -def test_class_modifier_roundtrip_preserves_func_and_params(): - """Reproduce #6067 with a class modifier and non-empty parameters.""" - cfg = HistoryObservationsCfg() - cfg.policy.history_length = None - cfg.policy.dummy.modifiers = [modifiers.ModifierCfg(func=StatefulBiasModifier, params={"value": 2.0})] - cfg.from_dict(cfg.to_dict()) - term_cfg = cfg.policy.dummy - assert term_cfg.modifiers is not None - modifier_cfg = term_cfg.modifiers[0] - assert isinstance(modifier_cfg, modifiers.ModifierCfg) - assert isinstance(modifier_cfg.func, str) - assert modifier_cfg.params == {"value": 2.0} - - env = DummyEnv() - manager = ObservationManager(cfg, cast("ManagerBasedEnv", env)) - prepared_term_cfg = manager.cfg.policy.dummy - assert prepared_term_cfg.modifiers is not None - prepared_modifier_cfg = prepared_term_cfg.modifiers[0] - assert isinstance(prepared_modifier_cfg.func, StatefulBiasModifier) - observations = manager.compute()["policy"] - torch.testing.assert_close(observations, env.observation + 2.0) - - manager.reset() - assert prepared_modifier_cfg.func.reset_count == 1 - - -def test_stateless_modifier_cfg_roundtrip_preserves_signature_validation(): - """A stateless modifier remains callable and inspectable after a configuration round-trip.""" - cfg = HistoryObservationsCfg() - cfg.policy.history_length = None - cfg.policy.dummy.modifiers = [modifiers.ModifierCfg(func=modifiers.bias, params={"value": 2.0})] - cfg.from_dict(cfg.to_dict()) - - env = DummyEnv() - manager = ObservationManager(cfg, cast("ManagerBasedEnv", env)) - observations = manager.compute()["policy"] - torch.testing.assert_close(observations, env.observation + 2.0) - - -def test_class_modifier_validates_constructed_instance(): - """Class modifier validation checks the constructed object.""" - cfg = HistoryObservationsCfg() - cfg.policy.history_length = None - cfg.policy.dummy.modifiers = [modifiers.ModifierCfg(func=InvalidModifier)] - cfg.from_dict(cfg.to_dict()) - - with pytest.raises(TypeError, match="is not an instance of 'ModifierBase'"): - ObservationManager(cfg, cast("ManagerBasedEnv", DummyEnv())) - - -def test_modifier_resolution_stays_out_of_observation_manager(): - """Observation-specific code receives resolved modifier callables from ``ManagerBase``.""" - source = inspect.getsource(ObservationManager._prepare_terms) - assert "inspect.isclass(mod_cfg.func)" in source - assert "string_to_callable" not in source - - -def test_modifier_base_cfg_marker_does_not_exist(): - """Stateful modifiers must not require a marker configuration subtype.""" - assert not hasattr(modifiers, "ModifierBaseCfg") - assert not hasattr(modifiers, "DelayCfg"), "Observation delay uses the shared buffer directly." - - -@pytest.mark.parametrize(("lag", "history_length"), [(0, 2), (1, 0), (1, 2)]) -def test_compute_updates_history_only_when_requested(lag, history_length): - """History alone, delay alone, and their combination advance only on recorded samples.""" - cfg = HistoryObservationsCfg() - cfg.policy.history_length = history_length - cfg.policy.enable_corruption = True - cfg.policy.dummy.delay_min_lag = cfg.policy.dummy.delay_max_lag = lag - cfg.policy.dummy.delay_hold_prob = 0.5 if history_length == 0 else 1.0 - cfg.policy.dummy.noise = noise.ConstantNoiseCfg(bias=0.0) - cfg.policy.dummy.scale = 2.0 - env = DummyEnv() - manager = ObservationManager(cfg, cast("ManagerBasedEnv", env)) - delay = manager._group_obs_term_delay_buffer["policy"].get("dummy") - history = manager._group_obs_term_history_buffer["policy"].get("dummy") - manager.compute() - if delay: - assert isinstance(delay, DelayBuffer) - assert torch.all(delay.num_pushes == 0) - if history: - assert torch.all(history.current_length == 0) - - for step in range(6): - if step == 3: - manager.reset([1]) - env.observation.fill_(step) - # Delay retains each sample's noise; history stacks the delayed, scaled outputs. - manager.cfg.policy.dummy.noise.bias = float(step) - output = manager.compute(update_history=True)["policy"] - sample_steps = torch.arange(step - max(1, history_length) + 1, step + 1) - expected = 4.0 * (sample_steps - lag).clamp_min(0).expand(env.num_envs, -1).clone() - if step >= 3: - expected[1].clamp_(min=12.0) - torch.testing.assert_close(output, expected) - env.observation.fill_(-100.0) - rng_state = torch.get_rng_state() - torch.testing.assert_close(manager.compute()["policy"], expected) - torch.testing.assert_close(manager.compute_group("policy"), expected) - assert torch.equal(torch.get_rng_state(), rng_state) - - -@pytest.mark.parametrize( - ("params", "error"), - [ - ({"delay_min_lag": -1}, ValueError), - ({"delay_min_lag": 2, "delay_max_lag": 1}, ValueError), - ({"delay_min_lag": 0.5}, TypeError), - ({"delay_hold_prob": -0.1}, ValueError), - ({"delay_hold_prob": 1.1}, ValueError), - ], -) -def test_observation_delay_config_validation(params, error): - """Delay requires ordered nonnegative integer bounds and a probability in [0, 1].""" - cfg = ObservationTermCfg(func=dummy_observation, **params) - with pytest.raises(error, match="delay"): - cfg.validate() diff --git a/source/isaaclab/test/managers/test_recorder_manager.py b/source/isaaclab/test/managers/test_recorder_manager.py index 84efee72be65..8541e7c3867a 100644 --- a/source/isaaclab/test/managers/test_recorder_manager.py +++ b/source/isaaclab/test/managers/test_recorder_manager.py @@ -11,19 +11,15 @@ from collections.abc import Sequence from typing import TYPE_CHECKING, cast -import h5py import pytest import torch from isaaclab.managers import DatasetExportMode, RecorderManager, RecorderManagerBaseCfg, RecorderTerm, RecorderTermCfg -from isaaclab.test.utils import DeviceScope, test_devices from isaaclab.utils import configclass pytestmark = pytest.mark.unit if TYPE_CHECKING: - import numpy as np - from isaaclab.envs import ManagerBasedEnv @@ -69,31 +65,6 @@ class DummyStepRecorderTermCfg(RecorderTermCfg): dataset_export_mode = DatasetExportMode.EXPORT_ALL -def get_file_contents(file_name: str, num_steps: int) -> dict[str, np.ndarray]: - """Retrieves the contents of the hdf5 file - Args: - file_name: absolute path to the hdf5 file - num_steps: number of steps taken in the environment - Returns: - dict[str, np.ndarray]: dictionary where keys are HDF5 paths and values are the corresponding data arrays. - """ - data = {} - with h5py.File(file_name, "r") as f: - - def get_data(name, obj): - if isinstance(obj, h5py.Dataset): - if "record_post_step" in name: - assert obj[()].shape == (num_steps, 5) - elif "record_pre_step" in name: - assert obj[()].shape == (num_steps, 4) - else: - raise Exception(f"The hdf5 file contains an unexpected data path, {name}") - data[name] = obj[()] - - f.visititems(get_data) - return data - - @configclass class DummyEnvCfg: """Dummy environment configuration.""" @@ -169,26 +140,14 @@ def test_str(dataset_dir): assert "contains 2 active terms" in manager_str assert "record_reset_term" in manager_str assert "record_step_term" in manager_str - recorder_manager.close() - - -def test_initialize_dataset_file(dataset_dir): - """Test the initialization of the dataset file.""" - # create recorder manager - cfg = DummyRecorderManagerCfg() - cfg.dataset_export_dir_path = dataset_dir - cfg.dataset_filename = f"{uuid.uuid4()}.hdf5" - recorder_manager = RecorderManager(cfg, create_dummy_env()) - - # check if the dataset is created + # the dataset file is created on construction assert os.path.exists(os.path.join(cfg.dataset_export_dir_path, cfg.dataset_filename)) recorder_manager.close() -@pytest.mark.parametrize("device", test_devices(DeviceScope.CPU_AND_DEFAULT_CUDA)) -def test_record(device, dataset_dir): +def test_record(dataset_dir): """Test the recording of the data.""" - env = create_dummy_env(device=device) + env = create_dummy_env() # create recorder manager cfg = DummyRecorderManagerCfg() cfg.dataset_export_dir_path = dataset_dir @@ -219,26 +178,3 @@ def test_record(device, dataset_dir): episode = recorder_manager.get_episode(env_id) assert torch.stack(episode.data["record_post_reset"]).shape == (1, 3) recorder_manager.close() - - -@pytest.mark.parametrize("device", test_devices(DeviceScope.CPU_AND_DEFAULT_CUDA)) -def test_close(device, dataset_dir): - """Test whether close exports buffered data when ``export_in_close`` is enabled.""" - env = create_dummy_env(device=device, num_envs=2) - cfg = DummyRecorderManagerCfg() - cfg.export_in_close = True - cfg.dataset_export_dir_path = dataset_dir - cfg.dataset_filename = f"{uuid.uuid4()}.hdf5" - recorder_manager = RecorderManager(cfg, env) - num_steps = 3 - for _ in range(num_steps): - recorder_manager.record_pre_step() - recorder_manager.record_post_step() - - # check contents of hdf5 file - file_name = os.path.join(cfg.dataset_export_dir_path, cfg.dataset_filename) - data_pre_close = get_file_contents(file_name, num_steps) - assert len(data_pre_close) == 0 - recorder_manager.close() - data_post_close = get_file_contents(file_name, num_steps) - assert len(data_post_close.keys()) == 2 * env.num_envs diff --git a/source/isaaclab/test/managers/test_recorder_manager_integration.py b/source/isaaclab/test/managers/test_recorder_manager_integration.py index de0a5e2bd17b..88e4292f9838 100644 --- a/source/isaaclab/test/managers/test_recorder_manager_integration.py +++ b/source/isaaclab/test/managers/test_recorder_manager_integration.py @@ -22,6 +22,7 @@ from isaaclab.envs import ManagerBasedEnv from isaaclab.managers import DatasetExportMode, RecorderManagerBaseCfg, RecorderTerm, RecorderTermCfg from isaaclab.test.env_cfgs import make_empty_manager_based_env_cfg +from isaaclab.test.utils import DeviceScope, test_devices from isaaclab.utils import configclass pytestmark = pytest.mark.integration @@ -64,7 +65,8 @@ def collect_shape(name, obj): return shapes -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +# Recorder wiring is device independent, so one device covers it. +@pytest.mark.parametrize("device", test_devices(DeviceScope.DEFAULT_CUDA)) def test_manager_based_env_close_exports_buffered_recorder_data(device: str, tmp_path: Path): """Environment stepping and close should record and export buffered episodes.""" sim_utils.create_new_stage() @@ -79,6 +81,9 @@ def test_manager_based_env_close_exports_buffered_recorder_data(device: str, tmp num_steps = 3 try: + # the empty config builds no action or observation terms + assert env.action_manager.total_action_dim == 0 + assert env.observation_manager.group_obs_dim == {} for _ in range(num_steps): env.step(torch.randn_like(env.action_manager.action)) diff --git a/source/isaaclab/test/managers/test_reward_manager.py b/source/isaaclab/test/managers/test_reward_manager.py index d3cc9fd74187..56b1e1d1e7d4 100644 --- a/source/isaaclab/test/managers/test_reward_manager.py +++ b/source/isaaclab/test/managers/test_reward_manager.py @@ -3,25 +3,16 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Launch Isaac Sim Simulator first.""" - -from isaaclab.app import AppLauncher - -# launch omniverse app -simulation_app = AppLauncher(headless=True).app - -"""Rest everything follows.""" - from collections import namedtuple +from unittest.mock import MagicMock import pytest import torch from isaaclab.managers import RewardManager, RewardTermCfg -from isaaclab.sim import SimulationContext from isaaclab.utils import configclass -pytestmark = pytest.mark.integration +pytestmark = pytest.mark.unit def grilled_chicken(env): @@ -42,27 +33,10 @@ def grilled_chicken_with_yoghurt(env, hot: bool, bland: float): @pytest.fixture def env(): - sim = SimulationContext() - yield namedtuple("ManagerBasedRLEnv", ["num_envs", "dt", "device", "sim"])(20, 0.1, "cpu", sim) - SimulationContext.clear_instance() - - -def test_str(env): - """Test the string representation of the reward manager.""" - cfg = { - "term_1": RewardTermCfg(func=grilled_chicken, weight=10), - "term_2": RewardTermCfg(func=grilled_chicken_with_bbq, weight=5, params={"bbq": True}), - "term_3": RewardTermCfg( - func=grilled_chicken_with_yoghurt, - weight=1.0, - params={"hot": False, "bland": 2.0}, - ), - } - rew_man = RewardManager(cfg, env) - assert len(rew_man.active_terms) == 3 - # print the expected string - print() - print(rew_man) + # simulation double that has not started playing + sim = MagicMock() + sim.is_playing.return_value = False + return namedtuple("ManagerBasedRLEnv", ["num_envs", "dt", "device", "sim"])(20, 0.1, "cpu", sim) def test_config_equivalence(env): @@ -137,10 +111,6 @@ def test_config_empty(env): rew_man = RewardManager(None, env) assert len(rew_man.active_terms) == 0 - # print the expected string - print() - print(rew_man) - # compute reward rewards = rew_man.compute(dt=env.dt) @@ -158,6 +128,8 @@ def test_active_terms(env): rew_man = RewardManager(cfg, env) assert len(rew_man.active_terms) == 3 + # the string representation lists every active term + assert "term_3" in str(rew_man) def test_missing_weight(env): diff --git a/source/isaaclab/test/managers/test_termination_manager.py b/source/isaaclab/test/managers/test_termination_manager.py index 43e066e1b211..6cb588444d9e 100644 --- a/source/isaaclab/test/managers/test_termination_manager.py +++ b/source/isaaclab/test/managers/test_termination_manager.py @@ -3,28 +3,20 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Launch Isaac Sim Simulator first.""" - -from isaaclab.app import AppLauncher - -# launch omniverse app -simulation_app = AppLauncher(headless=True).app - -"""Rest everything follows.""" +from unittest.mock import MagicMock import pytest import torch from isaaclab.managers import TerminationManager, TerminationTermCfg -from isaaclab.sim import SimulationContext -pytestmark = pytest.mark.integration +pytestmark = pytest.mark.unit class DummyEnv: """Minimal mutable env stub for the termination manager tests.""" - def __init__(self, num_envs: int, device: str, sim: SimulationContext): + def __init__(self, num_envs: int, device: str, sim: MagicMock): self.num_envs = num_envs self.device = device self.sim = sim @@ -51,28 +43,10 @@ def fail_every_3_steps(env) -> torch.Tensor: @pytest.fixture def env(): - sim = SimulationContext() - yield DummyEnv(num_envs=20, device="cpu", sim=sim) - SimulationContext.clear_instance() - - -def test_initial_state_and_shapes(env): - cfg = { - "term_5": TerminationTermCfg(func=fail_every_5_steps), - "term_10": TerminationTermCfg(func=fail_every_10_steps), - } - tm = TerminationManager(cfg, env) - - # Active term names - assert tm.active_terms == ["term_5", "term_10"] - - # Internal buffers have expected shapes and start as all False - assert tm._term_dones.shape == (env.num_envs, 2) - assert tm._last_episode_dones.shape == (env.num_envs, 2) - assert tm.dones.shape == (env.num_envs,) - assert tm.time_outs.shape == (env.num_envs,) - assert tm.terminated.shape == (env.num_envs,) - assert torch.all(~tm._term_dones) and torch.all(~tm._last_episode_dones) + # simulation double that has not started playing + sim = MagicMock() + sim.is_playing.return_value = False + return DummyEnv(num_envs=20, device="cpu", sim=sim) def test_term_transitions_and_persistence(env): @@ -129,6 +103,12 @@ def test_time_out_vs_terminated_split(env): } tm = TerminationManager(cfg, env) + # Before the first compute, every public done buffer has one entry per env and is False + assert tm.active_terms == ["term_5", "term_10"] + for dones in (tm.dones, tm.time_outs, tm.terminated): + assert dones.shape == (env.num_envs,) + assert torch.all(~dones) + # Step 5: terminated fires, not timeout env.counter = 5 out = tm.compute() diff --git a/source/isaaclab/test/markers/test_visualization_markers.py b/source/isaaclab/test/markers/test_visualization_markers.py index 8b174394fa79..70d9eeeee6b6 100644 --- a/source/isaaclab/test/markers/test_visualization_markers.py +++ b/source/isaaclab/test/markers/test_visualization_markers.py @@ -70,20 +70,6 @@ def close(self): pass -def test_instantiation(sim): - """Test that the class can be initialized properly.""" - config = VisualizationMarkersCfg( - prim_path="/World/Visuals/test", - markers={ - "test": sim_utils.SphereCfg(radius=1.0), - }, - ) - test_marker = VisualizationMarkers(config) - print(test_marker) - # check number of markers - assert test_marker.num_prototypes == 1 - - @pytest.mark.parametrize( ("has_gui", "rtx_sensors", "xr_enabled", "has_offscreen_render", "visualizers", "expected_backends"), [ @@ -160,6 +146,7 @@ def test_rendering_context_authors_visible_usd_point_instancer(sim): }, ) test_marker = VisualizationMarkers(config) + assert test_marker.num_prototypes == 2 test_marker.visualize( translations=torch.tensor([[0.0, 0.0, 0.0], [0.2, 0.0, 0.0]], device=sim.device), marker_indices=torch.tensor([0, 1], device=sim.device), @@ -176,36 +163,8 @@ def test_rendering_context_authors_visible_usd_point_instancer(sim): assert list(instancer.GetProtoIndicesAttr().Get()) == [0, 1] -def test_environment_ids_author_point_instance_scene_partitions(sim): - """Per-instance environment IDs should author vertex-interpolated scene-partition tokens.""" - from pxr import Sdf, UsdGeom - - sim._has_offscreen_render = True - stage = sim_utils.get_current_stage() - for env_id in range(2): - env_prim = stage.DefinePrim(f"/World/envs/env_{env_id}", "Xform") - env_prim.CreateAttribute("primvars:omni:scenePartition", Sdf.ValueTypeNames.Token).Set(f"env_{env_id}") - - config = VisualizationMarkersCfg( - prim_path="/World/Visuals/partitioned_marker", - markers={"test": sim_utils.SphereCfg(radius=0.1)}, - ) - test_marker = VisualizationMarkers(config) - test_marker.visualize( - translations=torch.tensor([[0.0, 0.0, 0.0], [0.2, 0.0, 0.0]], device=sim.device), - environment_ids=torch.tensor([1, 0], device=sim.device), - ) - - instancer_prim = stage.GetPrimAtPath(test_marker.prim_path) - primvar = UsdGeom.PrimvarsAPI(instancer_prim).GetPrimvar("omni:scenePartition") - assert primvar - assert primvar.GetTypeName() == Sdf.ValueTypeNames.TokenArray - assert primvar.GetInterpolation() == UsdGeom.Tokens.vertex - assert list(primvar.Get()) == ["env_1", "env_0"] - - -def test_unchanged_environment_ids_do_not_rebuild_scene_partitions(sim, monkeypatch): - """Unchanged environment IDs should not rebuild partition tokens on every call. +def test_environment_ids_author_scene_partitions_and_rebuild_only_on_change(sim, monkeypatch): + """Per-instance environment IDs author vertex-interpolated scene-partition tokens, rebuilt only on change. Marker ownership is static in most tasks, but ``visualize`` runs every frame. Rebuilding the token array anyway costs a device synchronization and one string per marker per frame. @@ -227,6 +186,12 @@ def test_unchanged_environment_ids_do_not_rebuild_scene_partitions(sim, monkeypa environment_ids = torch.tensor([1, 0], device=sim.device) test_marker.visualize(translations=translations, environment_ids=environment_ids) + primvar = UsdGeom.PrimvarsAPI(stage.GetPrimAtPath(test_marker.prim_path)).GetPrimvar("omni:scenePartition") + assert primvar + assert primvar.GetTypeName() == Sdf.ValueTypeNames.TokenArray + assert primvar.GetInterpolation() == UsdGeom.Tokens.vertex + assert list(primvar.Get()) == ["env_1", "env_0"] + rebuilt_token_arrays = [] original_token_array = Vt.TokenArray @@ -239,30 +204,10 @@ def _counting_token_array(*args, **kwargs): test_marker.visualize(translations=translations + 0.1, environment_ids=environment_ids) assert rebuilt_token_arrays == [] - primvar = UsdGeom.PrimvarsAPI(stage.GetPrimAtPath(test_marker.prim_path)).GetPrimvar("omni:scenePartition") assert list(primvar.Get()) == ["env_1", "env_0"] - -def test_changed_environment_ids_reauthor_scene_partitions(sim): - """New environment IDs should still update the authored partition tokens.""" - from pxr import Sdf, UsdGeom - - sim._has_offscreen_render = True - stage = sim_utils.get_current_stage() - for env_id in range(2): - env_prim = stage.DefinePrim(f"/World/envs/env_{env_id}", "Xform") - env_prim.CreateAttribute("primvars:omni:scenePartition", Sdf.ValueTypeNames.Token).Set(f"env_{env_id}") - - config = VisualizationMarkersCfg( - prim_path="/World/Visuals/updated_partition_marker", - markers={"test": sim_utils.SphereCfg(radius=0.1)}, - ) - test_marker = VisualizationMarkers(config) - translations = torch.tensor([[0.0, 0.0, 0.0], [0.2, 0.0, 0.0]], device=sim.device) - test_marker.visualize(translations=translations, environment_ids=torch.tensor([1, 0], device=sim.device)) + # New environment IDs still re-author the partition tokens. test_marker.visualize(translations=translations, environment_ids=torch.tensor([0, 1], device=sim.device)) - - primvar = UsdGeom.PrimvarsAPI(stage.GetPrimAtPath(test_marker.prim_path)).GetPrimvar("omni:scenePartition") assert list(primvar.Get()) == ["env_0", "env_1"] @@ -331,44 +276,15 @@ def test_usd_marker(sim): # play the simulation sim.reset() - # create a buffer - num_frames = 0 - # run with randomization of poses - for count in range(1000): - # sample random poses - if count % 50 == 0: - num_frames = torch.randint(10, 1000, (1,)).item() - frame_translations = torch.randn(num_frames, 3, device=sim.device) - frame_rotations = random_orientation(num_frames, device=sim.device) - # set the marker - test_marker.visualize(translations=frame_translations, orientations=frame_rotations) - # update the kit - sim.step() - # asset that count is correct + # grow and then shrink the number of frames + for num_frames in (500, 20): + frame_translations = torch.randn(num_frames, 3, device=sim.device) + frame_rotations = random_orientation(num_frames, device=sim.device) + test_marker.visualize(translations=frame_translations, orientations=frame_rotations) assert test_marker.count == num_frames - - -def test_multiple_prototypes_marker(sim): - """Test with multiple prototypes of spheres.""" - # create a marker - config = POSITION_GOAL_MARKER_CFG.copy() - config.prim_path = "/World/Visuals/test_protos" - test_marker = VisualizationMarkers(config) - - # play the simulation - sim.reset() - # run with randomization of poses - for count in range(1000): - # sample random poses - if count % 50 == 0: - num_frames = torch.randint(100, 1000, (1,)).item() - frame_translations = torch.randn(num_frames, 3, device=sim.device) - # randomly choose a prototype - marker_indices = torch.randint(0, test_marker.num_prototypes, (num_frames,), device=sim.device) - # set the marker - test_marker.visualize(translations=frame_translations, marker_indices=marker_indices) - # update the kit - sim.step() + # update the kit + sim.step() + assert test_marker.count == num_frames def test_visualization_skips_updates_when_invisible(sim): @@ -767,23 +683,6 @@ def test_newton_marker_partial_update_preserves_prototype_indices(): assert marker.marker_indices is expected_indices -def test_newton_marker_render_filters_visible_envs(monkeypatch: pytest.MonkeyPatch): - world_offsets = _patch_newton_marker_render_deps(monkeypatch) - translations = torch.arange(8, dtype=torch.float32).unsqueeze(1).repeat(1, 3) - marker = _make_newton_marker_for_render( - marker_names=["arrow"], - translations=translations, - marker_indices=torch.zeros(8, dtype=torch.int32), - ) - viewer = _FakeNewtonMarkerViewer(world_offsets) - - marker.render(viewer, visible_env_ids=[1, 3], num_envs=4) - - assert len(viewer.instances) == 1 - assert viewer.instances[0]["hidden"] is False - assert viewer.instances[0]["xforms"][:, 0].tolist() == [2.0, 3.0, 6.0, 7.0] - - @pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA marker state") def test_newton_marker_render_uses_viewer_device(monkeypatch: pytest.MonkeyPatch): world_offsets = wp.zeros(4, dtype=wp.vec3, device="cpu") @@ -833,6 +732,8 @@ def test_newton_marker_render_applies_world_offsets( marker.render(viewer, visible_env_ids=visible_env_ids, num_envs=4) + assert len(viewer.instances) == 1 + assert viewer.instances[0]["hidden"] is False assert viewer.instances[0]["xforms"][:, 0].tolist() == expected diff --git a/source/isaaclab/test/performance/test_robot_load_performance.py b/source/isaaclab/test/performance/test_robot_load_performance.py index aaab0daebe56..a05e4a51c365 100644 --- a/source/isaaclab/test/performance/test_robot_load_performance.py +++ b/source/isaaclab/test/performance/test_robot_load_performance.py @@ -21,6 +21,7 @@ from isaaclab import cloner from isaaclab.assets import Articulation from isaaclab.sim import build_simulation_context +from isaaclab.test.utils.devices import DeviceScope, test_devices from isaaclab.utils.timer import Timer from isaaclab_assets import ANYMAL_D_CFG, CARTPOLE_CFG @@ -31,15 +32,15 @@ SPACING = 2.0 +# PhysX CPU and GPU load through distinct pipelines, so each asset is timed on both. +@pytest.mark.parametrize("device", test_devices(DeviceScope.CPU_AND_DEFAULT_CUDA)) @pytest.mark.parametrize( - "test_config,device", + "test_config", [ # TODO: regression - this used to be 10 - ({"name": "Cartpole", "robot_cfg": CARTPOLE_CFG, "expected_load_time": 15.0}, "cuda:0"), - ({"name": "Cartpole", "robot_cfg": CARTPOLE_CFG, "expected_load_time": 15.0}, "cpu"), + {"name": "Cartpole", "robot_cfg": CARTPOLE_CFG, "expected_load_time": 15.0}, # TODO: regression - this used to be 40 - ({"name": "Anymal_D", "robot_cfg": ANYMAL_D_CFG, "expected_load_time": 60.0}, "cuda:0"), - ({"name": "Anymal_D", "robot_cfg": ANYMAL_D_CFG, "expected_load_time": 60.0}, "cpu"), + {"name": "Anymal_D", "robot_cfg": ANYMAL_D_CFG, "expected_load_time": 60.0}, ], ) def test_robot_load_performance(test_config, device): diff --git a/source/isaaclab/test/renderers/test_camera_output_contract.py b/source/isaaclab/test/renderers/test_camera_output_contract.py index 6856b2287f61..cdffcd121ac2 100644 --- a/source/isaaclab/test/renderers/test_camera_output_contract.py +++ b/source/isaaclab/test/renderers/test_camera_output_contract.py @@ -76,18 +76,21 @@ def test_camera_cfg_default_does_not_warn_or_forward(): assert cfg.renderer_cfg.colorize_semantic_segmentation is True -def test_camera_cfg_post_construction_mutation_is_silent_no_op(): - """Mutating a deprecated field after construction does not propagate to renderer_cfg.""" - cfg = CameraCfg( - height=64, - width=64, - prim_path="/World/Camera", - spawn=_SPAWN, - data_types=["rgb"], - ) - assert cfg.renderer_cfg.colorize_semantic_segmentation is True - cfg.colorize_semantic_segmentation = False - assert cfg.renderer_cfg.colorize_semantic_segmentation is True +def test_camera_cfg_copy_does_not_reforward_deprecated_fields(): + """Copying a cfg (as ``SensorBase`` does) keeps a renderer_cfg value set after forwarding.""" + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + cfg = CameraCfg( + height=64, + width=64, + prim_path="/World/Camera", + spawn=_SPAWN, + data_types=["rgb"], + colorize_semantic_segmentation=False, + ) + cfg.renderer_cfg.colorize_semantic_segmentation = True + + assert cfg.copy().renderer_cfg.colorize_semantic_segmentation is True def test_tiled_camera_cfg_does_not_forward_deprecated_fields(): @@ -143,41 +146,24 @@ def test_newton_warp_supported_output_types_key_set(): assert specs[RenderBufferKind.RGB_HDR] == RenderBufferSpec(3, wp.float32) -@pytest.mark.parametrize("data_type", ["simple_shading_full_mdl", "not_a_render_buffer_kind"]) -def test_camera_cfg_rejects_outputs_unsupported_by_renderer(data_type): - """Camera config validation rejects output types absent from the renderer contract.""" - pytest.importorskip("isaaclab_newton") - from isaaclab_newton.renderers import NewtonWarpRendererCfg - - cfg = CameraCfg( - height=64, - width=64, - prim_path="/World/Camera", - spawn=_SPAWN, - data_types=[data_type], - renderer_cfg=NewtonWarpRendererCfg(), - ) - - with pytest.raises(ValueError, match=data_type): - cfg.validate() - - -@pytest.mark.parametrize("data_type", ["rgba", "rgb_hdr", "albedo"]) -def test_camera_cfg_accepts_supported_newton_outputs(data_type): - """Camera config validation accepts every formerly omitted Newton color output.""" +def test_camera_cfg_rejects_outputs_unsupported_by_renderer(): + """Camera config validation rejects output types absent from the renderer contract and accepts supported ones.""" pytest.importorskip("isaaclab_newton") from isaaclab_newton.renderers import NewtonWarpRendererCfg - cfg = CameraCfg( - height=64, - width=64, - prim_path="/World/Camera", - spawn=_SPAWN, - data_types=[data_type], - renderer_cfg=NewtonWarpRendererCfg(), - ) + def make_cfg(data_type: str) -> CameraCfg: + return CameraCfg( + height=64, + width=64, + prim_path="/World/Camera", + spawn=_SPAWN, + data_types=[data_type], + renderer_cfg=NewtonWarpRendererCfg(), + ) - cfg.validate() + make_cfg("rgb_hdr").validate() + with pytest.raises(ValueError, match="simple_shading_full_mdl"): + make_cfg("simple_shading_full_mdl").validate() @pytest.mark.parametrize("colorize", [True, False]) @@ -236,7 +222,7 @@ def _make_camera_cfg(data_types: list[str]) -> CameraCfg: def test_camera_data_allocates_supported_subset_and_aliases_rgb(): """CameraData allocates the intersection of requested + supported and aliases rgb into rgba.""" - cfg = _make_camera_cfg(["rgb", "rgba", "depth"]) + cfg = _make_camera_cfg(["rgb", "rgba", "depth", "instance_segmentation"]) specs = { RenderBufferKind.RGBA: RenderBufferSpec(4, wp.uint8), RenderBufferKind.RGB: RenderBufferSpec(3, wp.uint8), @@ -257,51 +243,6 @@ def test_camera_data_allocates_supported_subset_and_aliases_rgb(): assert data.info == {"rgba": None, "rgb": None, "depth": None} -def test_camera_data_drops_requested_types_not_in_supported_specs(): - """Requested types absent from supported_specs are absent from data.output.""" - cfg = _make_camera_cfg(["rgb", "normals"]) - specs = { - RenderBufferKind.RGBA: RenderBufferSpec(4, wp.uint8), - RenderBufferKind.RGB: RenderBufferSpec(3, wp.uint8), - } - data = CameraData.allocate( - data_types=cfg.data_types, height=4, width=4, num_views=1, device="cpu", supported_specs=specs - ) - - assert "normals" not in data.output - assert {"rgb", "rgba"} <= set(data.output.keys()) - - -def test_camera_data_no_arg_construction_yields_empty_container(): - """Bare CameraData() produces an all-None container.""" - data = CameraData() - assert data.pos_w is None - assert data.quat_w_world is None - assert data.intrinsic_matrices is None - assert data.output is None - assert data.info is None - assert data.image_shape is None - - -def test_camera_data_segmentation_dtype_follows_supported_spec(): - """CameraData consumes the layout dtype declared by the renderer spec.""" - cfg = _make_camera_cfg(["instance_segmentation"]) - raw_specs = {RenderBufferKind.INSTANCE_SEGMENTATION: RenderBufferSpec(1, wp.int32)} - colorized_specs = {RenderBufferKind.INSTANCE_SEGMENTATION: RenderBufferSpec(4, wp.uint8)} - - raw = CameraData.allocate( - data_types=cfg.data_types, height=4, width=4, num_views=1, device="cpu", supported_specs=raw_specs - ) - colorized = CameraData.allocate( - data_types=cfg.data_types, height=4, width=4, num_views=1, device="cpu", supported_specs=colorized_specs - ) - - assert raw.output["instance_segmentation"].dtype == wp.int32 - assert raw.output["instance_segmentation"].shape == (1, 4, 4, 1) - assert colorized.output["instance_segmentation"].dtype == wp.uint8 - assert colorized.output["instance_segmentation"].shape == (1, 4, 4, 4) - - def test_camera_data_allocate_raises_on_unknown_name(): """An unknown data_types name raises ValueError naming the offender.""" supported_specs = {RenderBufferKind.RGBA: RenderBufferSpec(4, wp.uint8)} diff --git a/source/isaaclab/test/renderers/test_renderer_cfg_construction.py b/source/isaaclab/test/renderers/test_renderer_cfg_construction.py index 4ca56f973298..ac07eb8f9090 100644 --- a/source/isaaclab/test/renderers/test_renderer_cfg_construction.py +++ b/source/isaaclab/test/renderers/test_renderer_cfg_construction.py @@ -7,8 +7,7 @@ import pytest -import isaaclab.renderers as renderers -from isaaclab.renderers import RenderBufferKind, RendererCfg +from isaaclab.renderers import RenderBufferKind from isaaclab.utils.string import ResolvableString pytestmark = [pytest.mark.integration, pytest.mark.rendering] @@ -30,11 +29,6 @@ def test_renderer_cfg_names_its_implementation(module_name, cfg_name, implementa assert class_type.__name__ == implementation -def test_renderer_construction_has_no_factory_api(): - assert not hasattr(renderers, "Renderer") - assert not any(hasattr(RendererCfg, name) for name in ("build", "build_renderer", "clone_context")) - - @pytest.mark.parametrize( "module_name,cfg_name,required_output", [ diff --git a/source/isaaclab/test/renderers/test_rigid_object_rendering_contract_architecture.py b/source/isaaclab/test/renderers/test_rigid_object_rendering_contract_architecture.py deleted file mode 100644 index 48f1cb757202..000000000000 --- a/source/isaaclab/test/renderers/test_rigid_object_rendering_contract_architecture.py +++ /dev/null @@ -1,84 +0,0 @@ -# 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 - -"""Architecture gates for the shared rigid-object rendering contract.""" - -import ast -from pathlib import Path - -_REPO_ROOT = Path(__file__).resolve().parents[4] -_CONTRACT = Path(__file__).with_name("rigid_object_rendering_contract.py") -_ADAPTERS = ( - _REPO_ROOT / "source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_rigid_object_rendering.py", - _REPO_ROOT / "source/isaaclab_newton/test/renderers/test_newton_warp_renderer_rigid_object_rendering.py", - _REPO_ROOT / "source/isaaclab_ov/test/test_ovrtx_renderer_rigid_object_rendering.py", -) - - -def _imported_modules(tree: ast.AST) -> set[str]: - modules = set() - for node in ast.walk(tree): - if isinstance(node, ast.Import): - modules.update(alias.name for alias in node.names) - elif isinstance(node, ast.ImportFrom) and node.module is not None: - modules.add(node.module) - return modules - - -def test_shared_contract_does_not_depend_on_backend_packages() -> None: - """Optional backends depend on the contract; the core contract never points back.""" - tree = ast.parse(_CONTRACT.read_text(encoding="utf-8")) - forbidden_roots = {"isaaclab_newton", "isaaclab_ov", "isaaclab_ovphysx", "isaaclab_physx", "newton", "ovrtx"} - imported_roots = {module.split(".", 1)[0] for module in _imported_modules(tree)} - - assert imported_roots.isdisjoint(forbidden_roots), imported_roots & forbidden_roots - assert "subprocess" not in imported_roots - assert not (_REPO_ROOT / "source/isaaclab/test/sensors/test_camera_runtime_intrinsics.py").exists() - - -def test_backend_adapters_do_not_duplicate_scene_ownership() -> None: - """Adapters may select backends, but scene construction and assertions stay shared.""" - for adapter in _ADAPTERS: - tree = ast.parse(adapter.read_text(encoding="utf-8")) - modules = _imported_modules(tree) - forbidden = { - module - for module in modules - if module == "isaaclab.assets" - or module.startswith("isaaclab.assets.") - or module == "isaaclab.scene" - or module.startswith("isaaclab.scene.") - or module == "isaaclab.sensors" - or module.startswith("isaaclab.sensors.") - } - contract_calls = [ - node - for node in ast.walk(tree) - if isinstance(node, ast.Call) - and isinstance(node.func, ast.Name) - and node.func.id == "run_rigid_object_scale_and_pose_rendering_contract" - ] - - assert not forbidden, f"{adapter.relative_to(_REPO_ROOT)} duplicates contract ownership: {forbidden}" - assert len(contract_calls) == 1, f"{adapter.relative_to(_REPO_ROOT)} must compose the shared contract once" - assert not any(isinstance(node, ast.ClassDef) for node in ast.walk(tree)), ( - f"{adapter.relative_to(_REPO_ROOT)} must not define backend-local scene classes" - ) - - -def test_geometry_transport_does_not_cross_backend_ownership() -> None: - """Renderers consume SDP; physics does not own Fabric geometry destinations or shadow remapping.""" - for path in (_REPO_ROOT / "source/isaaclab_ov/isaaclab_ov/renderers").glob("*.py"): - modules = _imported_modules(ast.parse(path.read_text())) - assert not {module.split(".", 1)[0] for module in modules} & {"newton", "isaaclab_newton"}, path - for path in (_REPO_ROOT / "source/isaaclab_newton/isaaclab_newton/physics").glob("*.py"): - for node in ast.walk(ast.parse(path.read_text())): - if isinstance(node, ast.ClassDef): - assert node.name not in {"ShadowDeformableEntity", "ShadowDeformableRegistryGroup"}, path - if isinstance(node, ast.FunctionDef): - assert not any( - argument.annotation is not None and "fabricarray" in ast.unparse(argument.annotation) - for argument in node.args.args - ), (path, node.name) diff --git a/source/isaaclab/test/renderers/test_segmentation_colors.py b/source/isaaclab/test/renderers/test_segmentation_colors.py index a7b793466d8c..25abd4a98469 100644 --- a/source/isaaclab/test/renderers/test_segmentation_colors.py +++ b/source/isaaclab/test/renderers/test_segmentation_colors.py @@ -14,7 +14,6 @@ from isaaclab.renderers.segmentation_colors import ( BACKGROUND_ID, UNLABELLED_ID, - color_hash, pack_rgba, random_color_from_id, random_color_from_id_wp, @@ -45,12 +44,6 @@ def test_unpack_rgba_is_inverse_of_pack(): assert unpack_rgba(pack_rgba(color)) == color -def test_color_hash_is_uint32(): - for seed in (0, 1, 2, 12345, 0xFFFFFFFF): - h = color_hash(seed) - assert 0 <= h <= 0xFFFFFFFF - - @pytest.mark.parametrize("device", ["cpu"]) def test_host_and_device_random_color_agree(device): """The host and Warp implementations must produce byte-identical colors for every id.""" diff --git a/source/isaaclab/test/renderers/test_visual_material_pipeline.py b/source/isaaclab/test/renderers/test_visual_material_pipeline.py index 95854dfc3f2d..4de7f365f7d7 100644 --- a/source/isaaclab/test/renderers/test_visual_material_pipeline.py +++ b/source/isaaclab/test/renderers/test_visual_material_pipeline.py @@ -17,7 +17,7 @@ import torch import warp as wp -from isaaclab.assets.visual_material.visual_material import VisualMaterial, _channel_specs +from isaaclab.assets.visual_material.visual_material import VisualMaterial from isaaclab.cloner import ClonePlan from isaaclab.renderers.render_context import RenderContext from isaaclab.renderers.renderer_cfg import RendererCfg @@ -184,11 +184,15 @@ def test_material_registration_is_idempotent_after_lifecycle_stop() -> None: context = RenderContext([]) material = _Material("material", "roughness", torch.zeros(2)) context.register_visual_material(material) - context.finalize_consumers([]) + factory = _WriterFactory() + consumers = [SimpleNamespace(visual_material_writer=factory)] + context.finalize_consumers(consumers) context.register_visual_material(material) + context.finalize_consumers(consumers, rebuild=True) - assert context._visual_materials == [material] # noqa: SLF001 + # The re-registered material still composes into a single row per instance. + assert factory.batches[-1][0].values.shape == (2,) with pytest.raises(RuntimeError, match="before rendering consumers"): context.register_visual_material(_Material("late", "roughness", torch.zeros(2))) @@ -232,16 +236,6 @@ def test_runtime_material_writes_have_no_host_or_usd_path() -> None: assert not hits, f"{method.name} contains forbidden runtime tokens: {sorted(hits)}" -def _identifiers(tree: ast.AST) -> set[str]: - return { - identifier - for node in ast.walk(tree) - for identifier in ( - (node.id,) if isinstance(node, ast.Name) else (node.attr,) if isinstance(node, ast.Attribute) else () - ) - } - - def test_material_initialization_orders_paths_by_plan_column(monkeypatch: pytest.MonkeyPatch) -> None: plan = ClonePlan( sources=("/World/envs/env_42/Robot", "/World/envs/env_7/Robot"), @@ -279,12 +273,6 @@ def test_material_initialization_orders_paths_by_plan_column(monkeypatch: pytest assert registered == [material] -def test_preview_surface_channels_follow_shader_id() -> None: - shader = SimpleNamespace(GetShaderId=lambda: "UsdPreviewSurface") - - assert _channel_specs(shader)["color"] == ("diffuseColor", (0.18, 0.18, 0.18)) - - def test_material_initialization_rejects_partial_owner_row(monkeypatch: pytest.MonkeyPatch) -> None: plan = ClonePlan( sources=("/World/envs/env_0/Robot",), @@ -305,59 +293,3 @@ def test_material_initialization_rejects_partial_owner_row(monkeypatch: pytest.M with pytest.raises(ValueError, match="must populate every environment"): VisualMaterial._initialize_impl(material) - - -def test_rejected_visual_material_ownership_does_not_return() -> None: - material_path = _PACKAGE_ROOT / "assets" / "visual_material" / "visual_material.py" - cfg_path = material_path.with_name("visual_material_cfg.py") - scene_path = _PACKAGE_ROOT / "scene" / "interactive_scene.py" - for path in (material_path, cfg_path, scene_path): - assert not {"owner_asset", "target_prim_paths"} & _identifiers(ast.parse(path.read_text())) - - material_tree = ast.parse(material_path.read_text()) - material_class = next( - node for node in material_tree.body if isinstance(node, ast.ClassDef) and node.name == "VisualMaterial" - ) - assert "FactoryBase" not in {ast.unparse(base) for base in material_class.bases} - assert not material_path.with_name("base_visual_material.py").exists() - for package in ("isaaclab_newton", "isaaclab_physx", "isaaclab_ov"): - backend_assets = _SOURCE_ROOT / package / package / "assets" / "visual_material" - assert not tuple(backend_assets.glob("*.py")) - - renderer_tree = ast.parse((_PACKAGE_ROOT / "renderers" / "base_renderer.py").read_text()) - renderer_names = { - node.name - for node in renderer_tree.body - if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)) - } - renderer_names.update( - node.target.id - for node in renderer_tree.body - if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name) - ) - assert not {"VisualMaterialWriterFactory", "VisualMaterialBackendFactory"} & renderer_names - - simulation_tree = ast.parse((_PACKAGE_ROOT / "sim" / "simulation_context.py").read_text()) - simulation_class = next( - node for node in simulation_tree.body if isinstance(node, ast.ClassDef) and node.name == "SimulationContext" - ) - simulation_api = { - node.name - for node in simulation_class.body - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and "visual_material" in node.name - } - assert not simulation_api - - owners = [] - for path in (_PACKAGE_ROOT / "envs" / "mdp").glob("*.py"): - tree = ast.parse(path.read_text()) - if any( - isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)) - and node.name == "randomize_visual_color" - for node in tree.body - ): - owners.append(path.name) - assert owners == ["events.py"] - assert "randomize_visual_color" not in _identifiers( - ast.parse((_PACKAGE_ROOT / "envs" / "mdp" / "visual_events.py").read_text()) - ) diff --git a/source/isaaclab/test/scene/test_interactive_scene.py b/source/isaaclab/test/scene/test_interactive_scene.py index 83d0c8935f67..feaa819dd49e 100644 --- a/source/isaaclab/test/scene/test_interactive_scene.py +++ b/source/isaaclab/test/scene/test_interactive_scene.py @@ -66,13 +66,6 @@ class StaticSceneCfg(InteractiveSceneCfg): light = AssetBaseCfg(prim_path="/World/Light", spawn=sim_utils.DistantLightCfg()) -@configclass -class MarkerSceneCfg(InteractiveSceneCfg): - """Scene with one global visualization marker.""" - - goal = SPHERE_MARKER_CFG.replace(prim_path="/Visuals/Goal") - - @configclass class DeferredMarkerAssetCfg(AssetBaseCfg): """Authoring-only asset whose optional visualization starts disabled.""" @@ -81,9 +74,10 @@ class DeferredMarkerAssetCfg(AssetBaseCfg): @configclass -class DeferredMarkerSceneCfg(InteractiveSceneCfg): - """Scene that owns a marker even before its visualization is enabled.""" +class MarkerSceneCfg(InteractiveSceneCfg): + """Scene with one global visualization marker and one marker whose debug owner starts disabled.""" + goal = SPHERE_MARKER_CFG.replace(prim_path="/Visuals/Goal") prop = DeferredMarkerAssetCfg(prim_path="/World/Prop", spawn=sim_utils.DistantLightCfg(), debug_vis=False) @@ -102,7 +96,7 @@ def make_scene(num_envs: int, env_spacing: float = 1.0): # Note: cleanup is handled by build_simulation_context's finally block -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("device", ["cuda:0"]) def test_relative_flag(device, setup_scene): make_scene, sim = setup_scene scene_cfg = make_scene(num_envs=4) @@ -134,6 +128,16 @@ def test_relative_flag(device, setup_scene): scene.reset_to(prev_state, is_relative=True) assert_state_equal(prev_state, scene.get_state(is_relative=True)) + # test env_ids = None and env_ids = int32 torch tensor + prev_state = scene.get_state() + for env_ids in (None, torch.arange(scene.num_envs, device=scene.device, dtype=torch.int32)): + joint_pos = torch.rand_like(scene["robot"].data.joint_pos.torch) + joint_vel = torch.rand_like(scene["robot"].data.joint_pos.torch) + scene["robot"].write_joint_position_to_sim_index(position=joint_pos) + scene["robot"].write_joint_velocity_to_sim_index(velocity=joint_vel) + scene.reset_to(prev_state, env_ids=env_ids) + assert_state_equal(prev_state, scene.get_state()) + def test_relative_deformable_state(): env_origins = torch.arange(12, dtype=torch.float32).reshape(4, 3) @@ -185,31 +189,6 @@ def test_relative_deformable_state(): torch.testing.assert_close(written_state["env_ids"], env_ids) -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -def test_reset_to_env_ids_input_types(device, setup_scene): - make_scene, sim = setup_scene - scene_cfg = make_scene(num_envs=4) - scene = InteractiveScene(scene_cfg) - sim.reset() - - # test env_ids = None - prev_state = scene.get_state() - joint_pos = torch.rand_like(scene["robot"].data.joint_pos.torch) - joint_vel = torch.rand_like(scene["robot"].data.joint_pos.torch) - scene["robot"].write_joint_position_to_sim_index(position=joint_pos) - scene["robot"].write_joint_velocity_to_sim_index(velocity=joint_vel) - scene.reset_to(prev_state, env_ids=None) - assert_state_equal(prev_state, scene.get_state()) - - # test env_ids = torch tensor - joint_pos = torch.rand_like(scene["robot"].data.joint_pos.torch) - joint_vel = torch.rand_like(scene["robot"].data.joint_pos.torch) - scene["robot"].write_joint_position_to_sim_index(position=joint_pos) - scene["robot"].write_joint_velocity_to_sim_index(velocity=joint_vel) - scene.reset_to(prev_state, env_ids=torch.arange(scene.num_envs, device=scene.device, dtype=torch.int32)) - assert_state_equal(prev_state, scene.get_state()) - - def test_scene_publishes_plan_before_replicate(monkeypatch: pytest.MonkeyPatch): """A cfg-driven scene publishes the exact plan it forwards to replication.""" import isaaclab.cloner.replicate_session as replicate_session_module @@ -245,20 +224,15 @@ def test_scene_constructs_authoring_only_assets(): def test_scene_constructs_plan_owned_markers(): - """Scene markers are constructed while their global root belongs to the clone plan.""" + """Scene markers are constructed while their global root belongs to the clone plan. + + A marker declared on a disabled debug owner still belongs to the immutable plan. + """ with build_simulation_context(device="cpu", auto_add_lighting=False, add_ground_plane=False) as sim: scene = InteractiveScene(MarkerSceneCfg(num_envs=1, env_spacing=1.0)) assert isinstance(scene["goal"], VisualizationMarkers) - assert sim.get_clone_plan().global_paths == ("/Visuals/Goal",) - - -def test_scene_plans_markers_before_debug_visualization_is_enabled(): - """A marker declared on a disabled debug owner still belongs to the immutable plan.""" - with build_simulation_context(device="cpu", auto_add_lighting=False, add_ground_plane=False) as sim: - InteractiveScene(DeferredMarkerSceneCfg(num_envs=1, env_spacing=1.0)) - - assert sim.get_clone_plan().global_paths == ("/World/Prop", "/Visuals/Deferred") + assert sim.get_clone_plan().global_paths == ("/Visuals/Goal", "/World/Prop", "/Visuals/Deferred") def test_empty_scene_leaves_clone_lifecycle_to_caller(): diff --git a/source/isaaclab/test/scene/test_visual_material_scene.py b/source/isaaclab/test/scene/test_visual_material_scene.py index a4efca04a2a1..17ed288daebf 100644 --- a/source/isaaclab/test/scene/test_visual_material_scene.py +++ b/source/isaaclab/test/scene/test_visual_material_scene.py @@ -120,6 +120,8 @@ def Cube "mesh" material = scene["warm"] before = material.data["color"].clone() + # The initial value is read from the preview surface's diffuseColor input. + torch.testing.assert_close(before[0], torch.tensor([0.8, 0.1, 0.1], device=scene.device)) env_ids = torch.tensor([8, 2], dtype=torch.int32, device=scene.device) colors = torch.tensor([[[0.2, 0.7, 0.3], [0.9, 0.4, 0.1]]], device=scene.device) VisualMaterial.write_channels([material], {"color": colors}, env_ids) diff --git a/source/isaaclab/test/scene_data/test_deformable_discovery.py b/source/isaaclab/test/scene_data/test_deformable_discovery.py index 9f817becef77..1cad225abf63 100644 --- a/source/isaaclab/test/scene_data/test_deformable_discovery.py +++ b/source/isaaclab/test/scene_data/test_deformable_discovery.py @@ -13,8 +13,6 @@ from isaaclab.cloner import ClonePlan from isaaclab.scene_data.deformable_discovery import ( - _matrix4d_to_numpy, - _transform_points, deformable_entry, deformable_prototypes, expand_deformable_entries, @@ -27,30 +25,8 @@ def _add_api_schemas(prim: Usd.Prim, schemas: list[str]) -> None: prim.SetMetadata("apiSchemas", api_schemas) -def test_transform_points_matches_usd_matrix4d_transform(): - """Numpy baking must use USD row-vector convention (``p @ M``).""" - matrix = Gf.Matrix4d(1.0) - matrix.SetRotate(Gf.Rotation(Gf.Vec3d(0.0, 1.0, 0.0), 30.0)) - matrix.SetTranslateOnly(Gf.Vec3d(0.5, -0.1, 0.25)) - points = np.array( - [ - [0.15, -0.025, 0.025], - [-0.15, 0.025, -0.025], - [0.0, 0.0, 0.1], - ], - dtype=np.float32, - ) - - baked = _transform_points(_matrix4d_to_numpy(matrix), points) - expected = np.array( - [list(matrix.Transform(Gf.Vec3d(float(p[0]), float(p[1]), float(p[2])))) for p in points], - dtype=np.float32, - ) - assert np.allclose(baked, expected, atol=1e-6) - - def test_deformable_entry_volume_tet_mesh(): - """Classify tetrahedra and prefer the named visual over unrelated child meshes.""" + """Classify tetrahedra, bake sim vertices, and prefer the named visual over unrelated child meshes.""" stage = Usd.Stage.CreateInMemory() root = UsdGeom.Xform.Define(stage, "/World/envs/env_0/SoftBody").GetPrim() _add_api_schemas(root, ["OmniPhysicsDeformableBodyAPI"]) @@ -59,6 +35,9 @@ def test_deformable_entry_volume_tet_mesh(): points = [Gf.Vec3f(0.0, 0.0, 0.0), Gf.Vec3f(1.0, 0.0, 0.0), Gf.Vec3f(0.0, 1.0, 0.0), Gf.Vec3f(0.0, 0.0, 1.0)] tet.CreatePointsAttr(points) tet.CreateTetVertexIndicesAttr([Gf.Vec4i(0, 1, 2, 3)]) + # A rotated and translated sim mesh: vertices are baked into the root's parent frame. + tet.AddTranslateOp().Set(Gf.Vec3d(0.5, -0.1, 0.25)) + tet.AddRotateYOp().Set(30.0) for name in ("decoration", "visual", "props/unrelated"): mesh = UsdGeom.Mesh.Define(stage, f"/World/envs/env_0/SoftBody/{name}") mesh.CreatePointsAttr(points) @@ -72,6 +51,10 @@ def test_deformable_entry_volume_tet_mesh(): assert entry.root_path.endswith("/SoftBody") assert entry.sim_mesh_path.endswith("/simulation") assert entry.vis_mesh_path.endswith("/visual") + # USD's own row-vector transform is the reference for the baked vertices. + matrix = tet.ComputeLocalToWorldTransform(Usd.TimeCode.Default()) + expected = np.array([list(matrix.Transform(Gf.Vec3d(p))) for p in points], dtype=np.float32) + np.testing.assert_allclose(entry.vertices, expected, atol=1e-6) def test_deformable_entry_surface_mesh(): diff --git a/source/isaaclab/test/scene_data/test_scene_data_geometry.py b/source/isaaclab/test/scene_data/test_scene_data_geometry.py index efa71831542e..d6bf9eee4528 100644 --- a/source/isaaclab/test/scene_data/test_scene_data_geometry.py +++ b/source/isaaclab/test/scene_data/test_scene_data_geometry.py @@ -7,8 +7,10 @@ from __future__ import annotations +import ast import gc import weakref +from pathlib import Path from types import SimpleNamespace from unittest.mock import Mock @@ -20,6 +22,26 @@ from isaaclab.scene_data.scene_data_provider import SceneDataProvider +def test_geometry_transport_does_not_cross_backend_ownership(): + """Renderers consume SDP; physics does not own Fabric destinations or shadow remapping.""" + repo_root = Path(__file__).resolve().parents[4] + for path in (repo_root / "source/isaaclab_ov/isaaclab_ov/renderers").glob("*.py"): + for node in ast.walk(ast.parse(path.read_text())): + if isinstance(node, ast.Import): + assert not {alias.name.split(".", 1)[0] for alias in node.names} & {"newton", "isaaclab_newton"}, path + elif isinstance(node, ast.ImportFrom) and node.module: + assert node.module.split(".", 1)[0] not in {"newton", "isaaclab_newton"}, path + for path in (repo_root / "source/isaaclab_newton/isaaclab_newton/physics").glob("*.py"): + for node in ast.walk(ast.parse(path.read_text())): + if isinstance(node, ast.ClassDef): + assert node.name not in {"ShadowDeformableEntity", "ShadowDeformableRegistryGroup"}, path + if isinstance(node, ast.FunctionDef): + assert not any( + argument.annotation is not None and "fabricarray" in ast.unparse(argument.annotation) + for argument in node.args.args + ), (path, node.name) + + def test_geometry_views_alias_native_ranges_and_follow_pointer_swaps(monkeypatch): """Native geometry skips copies, including padded ranges and published pointer swaps.""" source = SceneDataFormat.Points() diff --git a/source/isaaclab/test/scene_data/test_scene_data_transforms.py b/source/isaaclab/test/scene_data/test_scene_data_transforms.py index b7b8ef8e36b1..9b60b6312917 100644 --- a/source/isaaclab/test/scene_data/test_scene_data_transforms.py +++ b/source/isaaclab/test/scene_data/test_scene_data_transforms.py @@ -7,6 +7,7 @@ from __future__ import annotations +import itertools from types import SimpleNamespace from unittest.mock import Mock @@ -152,8 +153,11 @@ def test_mapping_preserves_unmapped_destination_slots(): np.testing.assert_array_equal(output.transforms.numpy()[2], np.zeros(7)) -@pytest.mark.parametrize("format_name", ["Transform", "Vec3_Quat", "Vec3_Matrix33", "Matrix44"]) -@pytest.mark.parametrize("scaled", [False, True]) +# Scale is applied by one shared helper, so it rotates across the formats instead of doubling them. +@pytest.mark.parametrize( + ("format_name", "scaled"), + [("Transform", False), ("Vec3_Quat", True), ("Vec3_Matrix33", False), ("Matrix44", True)], +) def test_transposed_matrices_fuse_format_mapping_and_scale(format_name, scaled): """All native formats produce the same row-vector matrices, with output-indexed scale.""" poses = np.array([[1, 2, 3, 0, 0, 0, 1], [4, 5, 6, 1, 2, 2, 2]], dtype=np.float32) @@ -189,8 +193,11 @@ def test_transposed_matrices_fuse_format_mapping_and_scale(format_name, scaled): assert output.matrices is matrices -@pytest.mark.parametrize("format_name", ["Transform", "Vec3_Quat", "Vec3_Matrix33", "Matrix44"]) -@pytest.mark.parametrize("device", test_devices()) +# Rotate the device across formats so both the CPU and CUDA fabric paths are exercised. +@pytest.mark.parametrize( + "format_name, device", + list(zip(["Transform", "Vec3_Quat", "Vec3_Matrix33", "Matrix44"], itertools.cycle(test_devices()))), +) def test_fabric_conversion_preserves_scale_and_refreshes_reallocated_destinations(format_name, device, monkeypatch): """Fabric conversion skips solver-only bodies and preserves scales across buffer reallocations.""" assert set(SceneDataFormat.FabricMatrix44.vars) == {"matrices"} diff --git a/source/isaaclab/test/sensors/check_ray_caster.py b/source/isaaclab/test/sensors/check_ray_caster.py index 98fdd9518e75..ad6562c941a0 100644 --- a/source/isaaclab/test/sensors/check_ray_caster.py +++ b/source/isaaclab/test/sensors/check_ray_caster.py @@ -9,7 +9,7 @@ .. code-block:: bash # Usage - uv run python source/isaaclab/test/sensors/test_ray_caster.py + uv run python source/isaaclab/test/sensors/check_ray_caster.py """ """Launch Isaac Sim Simulator first.""" diff --git a/source/isaaclab/test/sensors/test_camera.py b/source/isaaclab/test/sensors/test_camera.py index d8c403e7689b..f540c60165a9 100644 --- a/source/isaaclab/test/sensors/test_camera.py +++ b/source/isaaclab/test/sensors/test_camera.py @@ -29,6 +29,7 @@ import isaaclab.sim as sim_utils from isaaclab.sensors.camera import Camera, CameraCfg +from isaaclab.test.utils import DeviceScope, test_devices pytestmark = [pytest.mark.integration, pytest.mark.rendering, pytest.mark.isaacsim_ci] @@ -128,6 +129,7 @@ def test_camera_init(setup_sim_camera): assert camera.data.intrinsic_matrices.torch.shape == (1, 3, 3) assert camera.data.image_shape == (camera_cfg.height, camera_cfg.width) assert camera.data.info == {camera_cfg.data_types[0]: None} + assert "Camera" in str(camera) # Simulate physics for _ in range(10): @@ -215,63 +217,6 @@ def test_camera_init_offset(setup_sim_camera): _assert_quat_close(camera_ros.data.quat_w_world[0], QUAT_WORLD, rtol=1e-5, atol=1e-5) -def test_multi_camera_init(setup_sim_camera): - """Test multi-camera initialization.""" - sim, camera_cfg, dt = setup_sim_camera - # create two cameras with different prim paths - # -- camera 1 - cam_cfg_1 = copy.deepcopy(camera_cfg) - cam_cfg_1.prim_path = "/World/Camera_1" - cam_1 = Camera(cam_cfg_1) - # -- camera 2 - cam_cfg_2 = copy.deepcopy(camera_cfg) - cam_cfg_2.prim_path = "/World/Camera_2" - cam_2 = Camera(cam_cfg_2) - - # play sim - sim.reset() - - # Simulate physics - for _ in range(10): - # perform rendering - sim.step() - # update camera - cam_1.update(dt) - cam_2.update(dt) - # check image data - for cam in [cam_1, cam_2]: - for im_data in cam.data.output.values(): - assert im_data.shape == (1, camera_cfg.height, camera_cfg.width, 1) - - -def test_multi_camera_with_different_resolution(setup_sim_camera): - """Test multi-camera initialization with cameras having different image resolutions.""" - sim, camera_cfg, dt = setup_sim_camera - # create two cameras with different prim paths - # -- camera 1 - cam_cfg_1 = copy.deepcopy(camera_cfg) - cam_cfg_1.prim_path = "/World/Camera_1" - cam_1 = Camera(cam_cfg_1) - # -- camera 2 - cam_cfg_2 = copy.deepcopy(camera_cfg) - cam_cfg_2.prim_path = "/World/Camera_2" - cam_cfg_2.height = 240 - cam_cfg_2.width = 320 - cam_2 = Camera(cam_cfg_2) - - # play sim - sim.reset() - - # perform rendering - sim.step() - # update camera - cam_1.update(dt) - cam_2.update(dt) - # check image sizes - assert cam_1.data.output["distance_to_image_plane"].shape == (1, camera_cfg.height, camera_cfg.width, 1) - assert cam_2.data.output["distance_to_image_plane"].shape == (1, cam_cfg_2.height, cam_cfg_2.width, 1) - - def test_camera_init_intrinsic_matrix(setup_sim_camera): """Test camera initialization from intrinsic matrix.""" sim, camera_cfg, dt = setup_sim_camera @@ -325,11 +270,12 @@ def test_camera_init_intrinsic_matrix(setup_sim_camera): ) -@pytest.mark.parametrize("update_latest_camera_pose", [False, True]) -def test_camera_set_world_poses(setup_sim_camera, update_latest_camera_pose): - """Test that an explicitly set world pose is reflected in the data buffers.""" +def test_camera_set_world_poses(setup_sim_camera): + """Test that explicitly set world poses are reflected in the data buffers. + + Uses the default ``update_latest_camera_pose=False``: pose writes must reach the buffers regardless. + """ sim, camera_cfg, dt = setup_sim_camera - camera_cfg.update_latest_camera_pose = update_latest_camera_pose # init camera camera = Camera(camera_cfg) # play sim @@ -344,22 +290,11 @@ def test_camera_set_world_poses(setup_sim_camera, update_latest_camera_pose): np.testing.assert_allclose(camera.data.pos_w.warp.numpy(), position) _assert_quat_close(camera.data.quat_w_world.warp.numpy(), orientation, rtol=1e-5, atol=1e-5) - -@pytest.mark.parametrize("update_latest_camera_pose", [False, True]) -def test_camera_set_world_poses_from_view(setup_sim_camera, update_latest_camera_pose): - """Test that a pose set from eye/target is reflected in the data buffers.""" - sim, camera_cfg, dt = setup_sim_camera - camera_cfg.update_latest_camera_pose = update_latest_camera_pose - # init camera - camera = Camera(camera_cfg) - # play sim - sim.reset() - + # set a pose from eye/target eyes_np = np.asarray([POSITION], dtype=np.float32) targets_np = np.asarray([[0.0, 0.0, 0.0]], dtype=np.float32) eyes = torch.tensor(eyes_np, dtype=torch.float32, device=camera.device) quat_ros_gt = torch.tensor([QUAT_ROS], dtype=torch.float32, device=camera.device) - # set new pose camera.set_world_poses_from_view(eyes_np, targets_np) # check if transform correctly set in output @@ -467,7 +402,7 @@ def test_depth_clipping(setup_sim_camera): ) assert ( camera_none.data.output["distance_to_image_plane"][ - ~torch.isinf(camera_none.data.output["distance_to_camera"]) + ~torch.isinf(camera_none.data.output["distance_to_image_plane"]) ].max() <= camera_cfg_zero.spawn.clipping_range[1] ) @@ -512,239 +447,8 @@ def test_depth_clipping(setup_sim_camera): assert camera_max.data.output["distance_to_image_plane"].max() <= camera_cfg_zero.spawn.clipping_range[1] -def test_camera_resolution_all_colorize(setup_sim_camera): - """Test camera resolution is correctly set for all types with colorization enabled.""" - # Add all types - sim, camera_cfg, dt = setup_sim_camera - camera_cfg.data_types = [ - "rgb", - "rgba", - "albedo", - "depth", - "distance_to_camera", - "distance_to_image_plane", - "normals", - "motion_vectors", - "semantic_segmentation", - "instance_segmentation", - "instance_id_segmentation_fast", - ] - camera_cfg.renderer_cfg.colorize_instance_id_segmentation = True - camera_cfg.renderer_cfg.colorize_instance_segmentation = True - camera_cfg.renderer_cfg.colorize_semantic_segmentation = True - # Create camera - camera = Camera(camera_cfg) - - # Play sim - sim.reset() - - camera.update(dt) - - # expected sizes - hw_1c_shape = (1, camera_cfg.height, camera_cfg.width, 1) - hw_2c_shape = (1, camera_cfg.height, camera_cfg.width, 2) - hw_3c_shape = (1, camera_cfg.height, camera_cfg.width, 3) - hw_4c_shape = (1, camera_cfg.height, camera_cfg.width, 4) - # access image data and compare shapes - output = camera.data.output - assert output["rgb"].shape == hw_3c_shape - assert output["rgba"].shape == hw_4c_shape - assert output["albedo"].shape == hw_4c_shape - assert output["depth"].shape == hw_1c_shape - assert output["distance_to_camera"].shape == hw_1c_shape - assert output["distance_to_image_plane"].shape == hw_1c_shape - assert output["normals"].shape == hw_3c_shape - assert output["motion_vectors"].shape == hw_2c_shape - assert output["semantic_segmentation"].shape == hw_4c_shape - assert output["instance_segmentation"].shape == hw_4c_shape - assert output["instance_id_segmentation_fast"].shape == hw_4c_shape - - # access image data and compare dtype - output = camera.data.output - assert output["rgb"].dtype == wp.uint8 - assert output["rgba"].dtype == wp.uint8 - assert output["albedo"].dtype == wp.uint8 - assert output["depth"].dtype == wp.float32 - assert output["distance_to_camera"].dtype == wp.float32 - assert output["distance_to_image_plane"].dtype == wp.float32 - assert output["normals"].dtype == wp.float32 - assert output["motion_vectors"].dtype == wp.float32 - assert output["semantic_segmentation"].dtype == wp.uint8 - assert output["instance_segmentation"].dtype == wp.uint8 - assert output["instance_id_segmentation_fast"].dtype == wp.uint8 - - -def test_camera_resolution_no_colorize(setup_sim_camera): - """Test camera resolution is correctly set for all types with no colorization enabled.""" - # Add all types - sim, camera_cfg, dt = setup_sim_camera - camera_cfg.data_types = [ - "rgb", - "rgba", - "albedo", - "depth", - "distance_to_camera", - "distance_to_image_plane", - "normals", - "motion_vectors", - "semantic_segmentation", - "instance_segmentation", - "instance_id_segmentation_fast", - ] - camera_cfg.renderer_cfg.colorize_instance_id_segmentation = False - camera_cfg.renderer_cfg.colorize_instance_segmentation = False - camera_cfg.renderer_cfg.colorize_semantic_segmentation = False - # Create camera - camera = Camera(camera_cfg) - - # Play sim - sim.reset() - camera.update(dt) - - # expected sizes - hw_1c_shape = (1, camera_cfg.height, camera_cfg.width, 1) - hw_2c_shape = (1, camera_cfg.height, camera_cfg.width, 2) - hw_3c_shape = (1, camera_cfg.height, camera_cfg.width, 3) - hw_4c_shape = (1, camera_cfg.height, camera_cfg.width, 4) - # access image data and compare shapes - output = camera.data.output - assert output["rgb"].shape == hw_3c_shape - assert output["rgba"].shape == hw_4c_shape - assert output["albedo"].shape == hw_4c_shape - assert output["depth"].shape == hw_1c_shape - assert output["distance_to_camera"].shape == hw_1c_shape - assert output["distance_to_image_plane"].shape == hw_1c_shape - assert output["normals"].shape == hw_3c_shape - assert output["motion_vectors"].shape == hw_2c_shape - assert output["semantic_segmentation"].shape == hw_1c_shape - assert output["instance_segmentation"].shape == hw_1c_shape - assert output["instance_id_segmentation_fast"].shape == hw_1c_shape - - # access image data and compare dtype - output = camera.data.output - assert output["rgb"].dtype == wp.uint8 - assert output["rgba"].dtype == wp.uint8 - assert output["albedo"].dtype == wp.uint8 - assert output["depth"].dtype == wp.float32 - assert output["distance_to_camera"].dtype == wp.float32 - assert output["distance_to_image_plane"].dtype == wp.float32 - assert output["normals"].dtype == wp.float32 - assert output["motion_vectors"].dtype == wp.float32 - assert output["semantic_segmentation"].dtype == wp.int32 - assert output["instance_segmentation"].dtype == wp.int32 - assert output["instance_id_segmentation_fast"].dtype == wp.int32 - - -def test_camera_large_resolution_all_colorize(setup_sim_camera): - """Test camera resolution is correctly set for all types with colorization enabled.""" - # Add all types - sim, camera_cfg, dt = setup_sim_camera - camera_cfg.data_types = [ - "rgb", - "rgba", - "albedo", - "depth", - "distance_to_camera", - "distance_to_image_plane", - "normals", - "motion_vectors", - "semantic_segmentation", - "instance_segmentation", - "instance_id_segmentation_fast", - ] - camera_cfg.renderer_cfg.colorize_instance_id_segmentation = True - camera_cfg.renderer_cfg.colorize_instance_segmentation = True - camera_cfg.renderer_cfg.colorize_semantic_segmentation = True - camera_cfg.width = 512 - camera_cfg.height = 512 - # Create camera - camera = Camera(camera_cfg) - - # Play sim - sim.reset() - - camera.update(dt) - - # expected sizes - hw_1c_shape = (1, camera_cfg.height, camera_cfg.width, 1) - hw_2c_shape = (1, camera_cfg.height, camera_cfg.width, 2) - hw_3c_shape = (1, camera_cfg.height, camera_cfg.width, 3) - hw_4c_shape = (1, camera_cfg.height, camera_cfg.width, 4) - # access image data and compare shapes - output = camera.data.output - assert output["rgb"].shape == hw_3c_shape - assert output["rgba"].shape == hw_4c_shape - assert output["albedo"].shape == hw_4c_shape - assert output["depth"].shape == hw_1c_shape - assert output["distance_to_camera"].shape == hw_1c_shape - assert output["distance_to_image_plane"].shape == hw_1c_shape - assert output["normals"].shape == hw_3c_shape - assert output["motion_vectors"].shape == hw_2c_shape - assert output["semantic_segmentation"].shape == hw_4c_shape - assert output["instance_segmentation"].shape == hw_4c_shape - assert output["instance_id_segmentation_fast"].shape == hw_4c_shape - - # access image data and compare dtype - output = camera.data.output - assert output["rgb"].dtype == wp.uint8 - assert output["rgba"].dtype == wp.uint8 - assert output["albedo"].dtype == wp.uint8 - assert output["depth"].dtype == wp.float32 - assert output["distance_to_camera"].dtype == wp.float32 - assert output["distance_to_image_plane"].dtype == wp.float32 - assert output["normals"].dtype == wp.float32 - assert output["motion_vectors"].dtype == wp.float32 - assert output["semantic_segmentation"].dtype == wp.uint8 - assert output["instance_segmentation"].dtype == wp.uint8 - assert output["instance_id_segmentation_fast"].dtype == wp.uint8 - - -def test_camera_resolution_rgb_only(setup_sim_camera): - """Test camera resolution is correctly set for RGB only.""" - # Add all types - sim, camera_cfg, dt = setup_sim_camera - camera_cfg.data_types = ["rgb"] - # Create camera - camera = Camera(camera_cfg) - - # Play sim - sim.reset() - - camera.update(dt) - - # expected sizes - hw_3c_shape = (1, camera_cfg.height, camera_cfg.width, 3) - # access image data and compare shapes - output = camera.data.output - assert output["rgb"].shape == hw_3c_shape - # access image data and compare dtype - assert output["rgb"].dtype == wp.uint8 - - -def test_camera_resolution_rgba_only(setup_sim_camera): - """Test camera resolution is correctly set for RGBA only.""" - # Add all types - sim, camera_cfg, dt = setup_sim_camera - camera_cfg.data_types = ["rgba"] - # Create camera - camera = Camera(camera_cfg) - - # Play sim - sim.reset() - - camera.update(dt) - - # expected sizes - hw_4c_shape = (1, camera_cfg.height, camera_cfg.width, 4) - # access image data and compare shapes - output = camera.data.output - assert output["rgba"].shape == hw_4c_shape - # access image data and compare dtype - assert output["rgba"].dtype == wp.uint8 - - def test_camera_resolution_albedo_only(setup_sim_camera): - """Test camera resolution is correctly set for albedo only.""" + """Test albedo-only cameras produce albedo content with the correct shape and dtype.""" # Add all types sim, camera_cfg, dt = setup_sim_camera camera_cfg.data_types = ["albedo"] @@ -763,6 +467,8 @@ def test_camera_resolution_albedo_only(setup_sim_camera): assert output["albedo"].shape == hw_4c_shape # access image data and compare dtype assert output["albedo"].dtype == wp.uint8 + # albedo is still produced when it is the only color-like output requested + assert (output["albedo"] / 255.0).mean() > 0.0 @pytest.mark.parametrize( @@ -791,39 +497,6 @@ def test_camera_resolution_simple_shading_only(setup_sim_camera, data_type): assert output[data_type].dtype == wp.uint8 -def test_camera_resolution_depth_only(setup_sim_camera): - """Test camera resolution is correctly set for depth only.""" - # Add all types - sim, camera_cfg, dt = setup_sim_camera - camera_cfg.data_types = ["depth"] - # Create camera - camera = Camera(camera_cfg) - - # Play sim - sim.reset() - - camera.update(dt) - - # expected sizes - hw_1c_shape = (1, camera_cfg.height, camera_cfg.width, 1) - # access image data and compare shapes - output = camera.data.output - assert output["depth"].shape == hw_1c_shape - # access image data and compare dtype - assert output["depth"].dtype == wp.float32 - - -def test_sensor_print(setup_sim_camera): - """Test sensor print is working correctly.""" - # Create sensor - sim, camera_cfg, dt = setup_sim_camera - sensor = Camera(cfg=camera_cfg) - # Play sim - sim.reset() - # print info - print(sensor) - - def setup_with_device(device) -> tuple[sim_utils.SimulationContext, CameraCfg, float]: camera_cfg = CameraCfg( height=128, @@ -891,6 +564,8 @@ def test_camera_multi_regex_init(setup_camera_device, device): assert im_data.shape == (num_cameras, camera_cfg.height, camera_cfg.width, 1) for i in range(4): assert im_data[i].mean() > 0.0 + # an rgb request without rgba is still served (through the rgba buffer) + assert camera.data.output["rgb"].dtype == wp.uint8 # Distinct selected rows must reach the matching Fabric prims, regardless of bucket order. matrices = camera.data.intrinsic_matrices.torch[[8, 0, 4]].clone() matrices[:, 0, 0] = matrices[:, 1, 1] = torch.tensor([100.0, 200.0, 300.0], device=device) @@ -905,7 +580,10 @@ def test_camera_multi_regex_init(setup_camera_device, device): @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) def test_camera_all_annotators(setup_camera_device, device): - """Test all supported annotators produce correct shapes, dtypes, content, and info.""" + """Test all supported annotators produce correct shapes, dtypes, content, and info. + + Uses the default (colorized) segmentation, and checks that normals have unit length. + """ sim, camera_cfg, dt = setup_camera_device all_annotator_types = [ "rgb", @@ -941,6 +619,11 @@ def test_camera_all_annotators(setup_camera_device, device): for data_type, im_data in camera.data.output.items(): if data_type in ["rgb", "normals"]: assert im_data.shape == (num_cameras, camera_cfg.height, camera_cfg.width, 3) + if data_type == "normals": + for i in range(4): + assert im_data[i].mean() > 0.0 + norms = torch.linalg.norm(im_data, dim=-1) + assert torch.allclose(norms, torch.ones_like(norms), atol=1e-9) elif data_type in [ "rgba", "albedo", @@ -980,7 +663,7 @@ def test_camera_all_annotators(setup_camera_device, device): del camera -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("device", test_devices(DeviceScope.CPU_AND_DEFAULT_CUDA)) def test_camera_segmentation_non_colorize(setup_camera_device, device): """Test segmentation outputs with colorization disabled produce correct dtypes and info.""" sim, camera_cfg, dt = setup_camera_device @@ -1006,40 +689,13 @@ def test_camera_segmentation_non_colorize(setup_camera_device, device): assert camera.data.output[seg_type].shape == (num_cameras, camera_cfg.height, camera_cfg.width, 1) assert camera.data.output[seg_type].dtype == wp.int32 assert isinstance(camera.data.info[seg_type], dict) + # Scene objects must produce non-zero IDs, so a dropped buffer copy cannot pass. + assert (camera.data.output[seg_type].torch != 0).any() del camera -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -def test_camera_normals_unit_length(setup_camera_device, device): - """Test that normals output vectors have approximately unit length.""" - sim, camera_cfg, dt = setup_camera_device - num_cameras = 9 - for i in range(num_cameras): - sim_utils.create_prim(f"/World/Origin_{i}", "Xform") - - camera_cfg = copy.deepcopy(camera_cfg) - camera_cfg.data_types = ["normals"] - camera_cfg.prim_path = "/World/Origin_[^/]*/CameraSensor" - camera = Camera(camera_cfg) - - sim.reset() - - for _ in range(10): - sim.step() - camera.update(dt) - im_data = camera.data.output["normals"] - assert im_data.shape == (num_cameras, camera_cfg.height, camera_cfg.width, 3) - for i in range(4): - assert im_data[i].mean() > 0.0 - norms = torch.linalg.norm(im_data, dim=-1) - assert torch.allclose(norms, torch.ones_like(norms), atol=1e-9) - - assert camera.data.output["normals"].dtype == wp.float32 - del camera - - -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("device", test_devices(DeviceScope.DEFAULT_CUDA)) def test_camera_data_types_ordering(setup_camera_device, device): """Test that requesting specific data types produces the expected output keys.""" sim, camera_cfg, dt = setup_camera_device @@ -1072,44 +728,6 @@ def test_camera_data_types_ordering(setup_camera_device, device): del camera_both -@pytest.mark.parametrize("device", ["cuda:0"]) -def test_camera_frame_offset(setup_camera_device, device): - """Test that camera reflects scene color changes without frame-offset lag.""" - sim, camera_cfg, dt = setup_camera_device - camera_cfg = copy.deepcopy(camera_cfg) - camera_cfg.height = 480 - camera_cfg.width = 480 - camera = Camera(camera_cfg) - - stage = sim_utils.get_current_stage() - for i in range(10): - prim = stage.GetPrimAtPath(f"/World/Objects/Obj_{i:02d}") - color = Gf.Vec3f(1, 1, 1) - UsdGeom.Gprim(prim).GetDisplayColorAttr().Set([color]) - - sim.reset() - - for _ in range(100): - sim.step() - camera.update(dt) - - image_before = camera.data.output["rgb"].clone() / 255.0 - - for i in range(10): - prim = stage.GetPrimAtPath(f"/World/Objects/Obj_{i:02d}") - color = Gf.Vec3f(0, 0, 0) - UsdGeom.Gprim(prim).GetDisplayColorAttr().Set([color]) - - sim.step() - camera.update(dt) - - image_after = camera.data.output["rgb"].clone() / 255.0 - - assert torch.abs(image_after - image_before).mean() > 0.01 - - del camera - - def test_camera_raises_on_unsupported_data_types(setup_sim_camera): """Test Camera rejects data types its runtime renderer cannot produce.""" from isaaclab.renderers.base_renderer import BaseRenderer @@ -1174,11 +792,14 @@ def test_camera_raises_on_instance_segmentation_fast(setup_sim_camera): Camera(camera_cfg) -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("device", test_devices(DeviceScope.CPU)) @pytest.mark.isaacsim_ci def test_camera_pose_update_reflected_in_render(setup_camera_device, device): """Camera pose changes via FrameView should be visible in rendered depth. + Runs on CPU only: the CUDA pose-write path is covered on every backend by + ``test_camera_pose_write_across_backends.py``; this covers the CPU Fabric write from ``set_world_poses_from_view``. + Moves the camera close then far, renders depth, and verifies that the mean valid depth from the far position is significantly larger (>1.5×) than the close position. This validates that Fabric-side pose writes (via diff --git a/source/isaaclab/test/sensors/test_camera_opencv_distortion_ovrtx.py b/source/isaaclab/test/sensors/test_camera_opencv_distortion_ovrtx.py index 7890ad332155..5700d18a17c5 100644 --- a/source/isaaclab/test/sensors/test_camera_opencv_distortion_ovrtx.py +++ b/source/isaaclab/test/sensors/test_camera_opencv_distortion_ovrtx.py @@ -160,55 +160,36 @@ def _render_grid(distortion: OpenCvDistortionCfg, device: str) -> tuple[np.ndarr @pytest.mark.parametrize("device", ["cuda:0"]) @_SKIP_MISSING_OVRTX def test_opencv_distortion_changes_ovrtx_render(device): - """OVRTX must render the distorted and zero-coefficient cameras meaningfully differently.""" - distorted, _ = _render_grid(_pinhole_distortion(True), device=device) + """OVRTX renders the OpenCV pinhole and fisheye models and reports the authored calibration. + + The same calibrated camera is rendered with the pinhole coefficients applied, with them muted + (reference), and under the OpenCV fisheye model. Both distorted frames must differ from the + reference well beyond render noise, and the reported intrinsics must match the authored, + non-square, off-center calibration. + """ + distorted, k = _render_grid(_pinhole_distortion(True), device=device) reference, _ = _render_grid(_pinhole_distortion(False), device=device) + fisheye, k_fisheye = _render_grid(_fisheye_distortion(True), device=device) assert distorted.shape == (HEIGHT, WIDTH, 3) - # both frames render content (not degenerate) + assert fisheye.shape == (HEIGHT, WIDTH, 3) + # all frames render content (not degenerate) assert distorted.std() > 1.0 assert reference.std() > 1.0 + assert fisheye.std() > 1.0 # OVRTX applied the lens distortion: the two renders differ well beyond render noise mean_abs_diff = np.abs(distorted.astype(np.float32) - reference.astype(np.float32)).mean() assert mean_abs_diff > 2.0, f"distorted vs reference differ by only {mean_abs_diff:.3f}/255" + # OVRTX applied the fisheye projection: it differs from the pinhole render well beyond render noise + mean_abs_diff = np.abs(fisheye.astype(np.float32) - reference.astype(np.float32)).mean() + assert mean_abs_diff > 2.0, f"fisheye vs pinhole differ by only {mean_abs_diff:.3f}/255" - -@pytest.mark.parametrize("device", ["cuda:0"]) -@_SKIP_MISSING_OVRTX -def test_opencv_distortion_intrinsics_match_authored_ovrtx(device): - """The OVRTX camera reports intrinsics matching the authored, non-square, off-center calibration.""" - _rgb, k = _render_grid(_pinhole_distortion(True), device=device) - - assert k[0, 0] == pytest.approx(_CALIB["fx"], abs=1e-2) - assert k[1, 1] == pytest.approx(_CALIB["fy"], abs=1e-2) - assert k[0, 2] == pytest.approx(_CALIB["cx"], abs=1e-2) - assert k[1, 2] == pytest.approx(_CALIB["cy"], abs=1e-2) + # both cameras report the authored, non-square, off-center calibration + for intrinsics in (k, k_fisheye): + assert intrinsics[0, 0] == pytest.approx(_CALIB["fx"], abs=1e-2) + assert intrinsics[1, 1] == pytest.approx(_CALIB["fy"], abs=1e-2) + assert intrinsics[0, 2] == pytest.approx(_CALIB["cx"], abs=1e-2) + assert intrinsics[1, 2] == pytest.approx(_CALIB["cy"], abs=1e-2) # not the stock fx == fy / centered-principal-point collapse assert k[0, 0] != k[1, 1] assert k[0, 2] != pytest.approx(WIDTH / 2) - - -@pytest.mark.parametrize("device", ["cuda:0"]) -@_SKIP_MISSING_OVRTX -def test_opencv_fisheye_distortion_renders_through_ovrtx(device): - """OVRTX honors the OpenCV fisheye schema: its render differs meaningfully from the pinhole projection. - - The same calibrated camera is rendered under the OpenCV fisheye model and under an undistorted - pinhole. The fisheye equidistant projection bends the straight grid, so the two frames must differ - well beyond render noise, and the reported intrinsics must still match the authored calibration. - """ - fisheye, k = _render_grid(_fisheye_distortion(True), device=device) - pinhole, _ = _render_grid(_pinhole_distortion(False), device=device) - - assert fisheye.shape == (HEIGHT, WIDTH, 3) - # both frames render content (not degenerate) - assert fisheye.std() > 1.0 - assert pinhole.std() > 1.0 - # OVRTX applied the fisheye projection: it differs from the pinhole render well beyond render noise - mean_abs_diff = np.abs(fisheye.astype(np.float32) - pinhole.astype(np.float32)).mean() - assert mean_abs_diff > 2.0, f"fisheye vs pinhole differ by only {mean_abs_diff:.3f}/255" - # the fisheye camera still reports the authored, non-square, off-center calibration - assert k[0, 0] == pytest.approx(_CALIB["fx"], abs=1e-2) - assert k[1, 1] == pytest.approx(_CALIB["fy"], abs=1e-2) - assert k[0, 2] == pytest.approx(_CALIB["cx"], abs=1e-2) - assert k[1, 2] == pytest.approx(_CALIB["cy"], abs=1e-2) diff --git a/source/isaaclab/test/sensors/test_camera_pose_write_across_backends.py b/source/isaaclab/test/sensors/test_camera_pose_write_across_backends.py index a3a3662c3e2d..eb7aaa13d279 100644 --- a/source/isaaclab/test/sensors/test_camera_pose_write_across_backends.py +++ b/source/isaaclab/test/sensors/test_camera_pose_write_across_backends.py @@ -9,8 +9,8 @@ backend-specific. The Newton view used to update Warp state only, leaving ``camera.data.pos_w`` correct while the RTX renderer -- which reads the camera prim -- kept drawing the old pose. -Both tests raise a downward-looking camera over the ground plane, which scales the distance to every -visible surface, and check one observable consequence each: the reported pose, and the rendered depth. +The test raises a downward-looking camera over the ground plane, which scales the distance to every +visible surface, and checks both observable consequences: the reported pose and the rendered depth. """ """Launch Isaac Sim Simulator first.""" @@ -107,26 +107,20 @@ def _capture_at_heights(physics_cfg, heights_m: tuple[float, ...]) -> list[tuple @pytest.mark.parametrize("physics_cfg", BACKEND_CFGS, ids=BACKEND_IDS) -def test_camera_pose_write_moves_reported_pose(physics_cfg): - """``camera.data.pos_w`` follows a ``set_world_poses`` write on every backend.""" - (pos_close, _), (pos_far, _) = _capture_at_heights(physics_cfg, (CLOSE_M, FAR_M)) - - np.testing.assert_allclose(pos_close.numpy(), [[0.0, 0.0, CLOSE_M]], atol=1e-3) - np.testing.assert_allclose(pos_far.numpy(), [[0.0, 0.0, FAR_M]], atol=1e-3) - - -@pytest.mark.parametrize("physics_cfg", BACKEND_CFGS, ids=BACKEND_IDS) -def test_camera_pose_write_moves_render(physics_cfg): - """The rendered depth follows a ``set_world_poses`` write on every backend. +def test_camera_pose_write_moves_pose_and_render(physics_cfg): + """``camera.data.pos_w`` and the rendered depth follow a ``set_world_poses`` write on every backend. A write that never reaches the camera prim the RTX renderer reads leaves the image at the old pose, - collapsing the ratio to ~1. + collapsing the depth ratio to ~1. """ # The true ratio is ~4x; 1.5 leaves room for the ground plane filling different fractions of the # frame while still failing hard if the render does not move at all. depth_ratio_threshold = 1.5 - (_, depth_close_m), (_, depth_far_m) = _capture_at_heights(physics_cfg, (CLOSE_M, FAR_M)) + (pos_close, depth_close_m), (pos_far, depth_far_m) = _capture_at_heights(physics_cfg, (CLOSE_M, FAR_M)) + + np.testing.assert_allclose(pos_close.numpy(), [[0.0, 0.0, CLOSE_M]], atol=1e-3) + np.testing.assert_allclose(pos_far.numpy(), [[0.0, 0.0, FAR_M]], atol=1e-3) ratio = depth_far_m / depth_close_m assert ratio > depth_ratio_threshold, ( diff --git a/source/isaaclab/test/sensors/test_camera_ppisp_gaussian.py b/source/isaaclab/test/sensors/test_camera_ppisp_gaussian.py index cbb541d1567b..35370bb8b11b 100644 --- a/source/isaaclab/test/sensors/test_camera_ppisp_gaussian.py +++ b/source/isaaclab/test/sensors/test_camera_ppisp_gaussian.py @@ -104,37 +104,6 @@ def _isaac_rtx_sim_cfg(device: str) -> SimulationCfg: ) -@pytest.mark.parametrize("device", ["cuda:0"]) -@pytest.mark.parametrize("renderer_cfg_cls", _RENDERER_CFG_PARAMS) -@pytest.mark.isaacsim_ci -def test_camera_ppisp_wrapper_signatures_on_synthetic_gaussians(renderer_cfg_cls, device): - """Wrapper PPISP via ``isaac_rtx`` must show every PPISP-feature signature. - - Renders a synthetic RGBW gaussian grid through ``isaac_rtx`` + the aggressive - wrapper PPISP cfg and asserts: - - 1. **Non-degenerate frame** — content is rendered (not pure black / pure white). - 2. **HDR source** — ``rgb_hdr`` is present and bright enough for PPISP. - 3. **PPISP LDR mapping** — the center patch lands in a useful, non-saturated - LDR range after the calibrated responsivity/exposure pair. - 4. **Vignetting** — each corner patch mean is meaningfully below the center mean. - 5. **CRF/clamping** — output stays in [0, 255] with no overflow. - """ - with tempfile.TemporaryDirectory(prefix="isaaclab-synth-gauss-") as tmpdir: - asset_path = make_synthetic_gaussian_usd(f"{tmpdir}/synthetic_gaussians.usda") - output = render_synthetic_gaussian_scene( - asset_path, - sim_cfg=_isaac_rtx_sim_cfg(device), - renderer_cfg=renderer_cfg_cls(), - data_types=["rgb", "rgb_hdr"], - sim_dt=SIM_DT, - stabilisation_steps=15, - responsivity=ISAAC_RTX_RESPONSIVITY, - ) - assert_ppisp_lifts_exposure(output["rgb_hdr"][0], output["rgb"][0], label="isaac_rtx") - assert_ppisp_invariants(output["rgb"][0], label="isaac_rtx") - - @pytest.mark.parametrize("device", ["cuda:0"]) @pytest.mark.parametrize("renderer_cfg_cls", _RENDERER_CFG_PARAMS) @pytest.mark.isaacsim_ci @@ -204,8 +173,12 @@ def test_camera_ppisp_controller_matches_static_attrs_on_synthetic_gaussians(ren @pytest.mark.parametrize("renderer_cfg_cls", _RENDERER_CFG_PARAMS) @pytest.mark.isaacsim_ci def test_camera_ppisp_wrapper_signatures_on_synthetic_gaussians_multitile(renderer_cfg_cls, device): - """Multi-tile wrapper PPISP via ``isaac_rtx`` must hold the same invariants - independently for every tile. + """Multi-tile wrapper PPISP via ``isaac_rtx`` must show every PPISP-feature signature on every tile. + + Renders a synthetic RGBW gaussian grid through ``isaac_rtx`` + the aggressive + wrapper PPISP cfg and asserts per tile: a non-degenerate frame, a bright + enough ``rgb_hdr`` source, a useful non-saturated PPISP LDR mapping, + vignetting, and output bounded to [0, 255]. Builds an :class:`InteractiveScene` with :data:`MULTI_TILE_COUNT` envs so the camera regex resolves to one camera per env. Both ``rgb`` and @@ -222,6 +195,7 @@ def test_camera_ppisp_wrapper_signatures_on_synthetic_gaussians_multitile(render data_types=["rgb", "rgb_hdr"], num_envs=MULTI_TILE_COUNT, sim_dt=SIM_DT, + stabilisation_steps=15, responsivity=ISAAC_RTX_RESPONSIVITY, ) diff --git a/source/isaaclab/test/sensors/test_camera_ppisp_gaussian_newton.py b/source/isaaclab/test/sensors/test_camera_ppisp_gaussian_newton.py index 248f0b54e307..71d3dcd803e4 100644 --- a/source/isaaclab/test/sensors/test_camera_ppisp_gaussian_newton.py +++ b/source/isaaclab/test/sensors/test_camera_ppisp_gaussian_newton.py @@ -120,31 +120,6 @@ def _newton_sim_cfg(device: str) -> SimulationCfg: ) -@pytest.mark.parametrize("device", ["cuda:0"]) -@pytest.mark.isaacsim_ci -@_SKIP_MISSING_NEWTON -def test_camera_ppisp_wrapper_signatures_on_synthetic_gaussians_newton(device): - """Wrapper PPISP via ``newton_warp`` must show the PPISP signatures. - - Renders a synthetic RGBW gaussian grid through Newton's Warp ray tracer - plus the aggressive wrapper PPISP cfg. The test checks that Newton produces - a valid HDR source, that PPISP maps it into a useful non-saturated LDR - center range, and that vignetting and bounded-output invariants hold. - """ - with tempfile.TemporaryDirectory(prefix="isaaclab-synth-gauss-") as tmpdir: - asset_path = make_synthetic_gaussian_usd(f"{tmpdir}/synthetic_gaussians.usda", _center_probed_scene()) - output = render_synthetic_gaussian_scene( - asset_path, - sim_cfg=_newton_sim_cfg(device), - renderer_cfg=NewtonWarpRendererCfg(), - data_types=["rgb", "rgb_hdr"], - sim_dt=SIM_DT, - responsivity=RESPONSIVITY, - ) - assert_ppisp_lifts_exposure(output["rgb_hdr"][0], output["rgb"][0], label="newton_warp") - assert_ppisp_invariants(output["rgb"][0], label="newton_warp") - - @pytest.mark.parametrize("device", ["cuda:0"]) @pytest.mark.isaacsim_ci @_SKIP_MISSING_NEWTON diff --git a/source/isaaclab/test/sensors/test_camera_ppisp_gaussian_ovrtx.py b/source/isaaclab/test/sensors/test_camera_ppisp_gaussian_ovrtx.py index f5452e6f2375..bf3450b2dec3 100644 --- a/source/isaaclab/test/sensors/test_camera_ppisp_gaussian_ovrtx.py +++ b/source/isaaclab/test/sensors/test_camera_ppisp_gaussian_ovrtx.py @@ -108,36 +108,6 @@ def _ovrtx_sim_cfg(device: str) -> SimulationCfg: ) -@pytest.mark.parametrize("device", ["cuda:0"]) -@_SKIP_MISSING_OVRTX -@_XFAIL_OVRTX_GAUSSIAN_PPISP -def test_camera_ppisp_wrapper_signatures_on_synthetic_gaussians_ovrtx(device): - """Wrapper PPISP via ``ovrtx`` must show every PPISP-feature signature. - - Renders a synthetic RGBW gaussian grid through ``ovrtx`` plus the - aggressive wrapper PPISP cfg and asserts: - - 1. **Non-degenerate frame** — content is rendered (not pure black / pure white). - 2. **HDR source** — ``rgb_hdr`` is present and bright enough for PPISP. - 3. **PPISP LDR mapping** — the center patch lands in a useful, non-saturated - LDR range after the calibrated responsivity/exposure pair. - 4. **Vignetting** — each corner patch mean is meaningfully below the center patch mean. - 5. **CRF/clamping** — no value exceeds 255. - """ - with tempfile.TemporaryDirectory(prefix="isaaclab-synth-gauss-") as tmpdir: - asset_path = make_synthetic_gaussian_usd(f"{tmpdir}/synthetic_gaussians.usda") - output = render_synthetic_gaussian_scene( - asset_path, - sim_cfg=_ovrtx_sim_cfg(device), - renderer_cfg=OVRTXRendererCfg(), - data_types=["rgb", "rgb_hdr"], - sim_dt=SIM_DT, - stabilisation_steps=15, - ) - assert_ppisp_lifts_exposure(output["rgb_hdr"][0], output["rgb"][0], label="ovrtx") - assert_ppisp_invariants(output["rgb"][0], label="ovrtx") - - @pytest.mark.parametrize("device", ["cuda:0"]) @_SKIP_MISSING_OVRTX @_XFAIL_OVRTX_GAUSSIAN_PPISP @@ -221,6 +191,7 @@ def test_camera_ppisp_wrapper_signatures_on_synthetic_gaussians_ovrtx_multitile( data_types=["rgb", "rgb_hdr"], num_envs=MULTI_TILE_COUNT, sim_dt=SIM_DT, + stabilisation_steps=15, ) rgb = output["rgb"] diff --git a/source/isaaclab/test/sensors/test_multi_mesh_ray_caster.py b/source/isaaclab/test/sensors/test_multi_mesh_ray_caster.py index 85c9438f4584..cffcb8ef9518 100644 --- a/source/isaaclab/test/sensors/test_multi_mesh_ray_caster.py +++ b/source/isaaclab/test/sensors/test_multi_mesh_ray_caster.py @@ -13,7 +13,7 @@ import warp as wp from isaaclab.utils.math import matrix_from_quat, quat_from_euler_xyz, random_orientation -from isaaclab.utils.warp.ops import convert_to_warp_mesh, raycast_dynamic_meshes, raycast_single_mesh +from isaaclab.utils.warp.ops import convert_to_warp_mesh, raycast_dynamic_meshes, raycast_mesh, raycast_single_mesh pytestmark = pytest.mark.integration @@ -103,8 +103,23 @@ def test_raycast_multi_cubes(device, trimesh_box, rays): def test_raycast_single_cube(device, single_mesh, rays): """Test raycasting against a single cube.""" ray_starts, ray_directions, expected_ray_hits = rays - _, single_mesh_id = single_mesh + wp_mesh, single_mesh_id = single_mesh + + # check the wp.Mesh implementation + ray_hits, ray_distance, ray_normal, ray_face_id = raycast_mesh( + ray_starts, + ray_directions, + wp_mesh, + return_distance=True, + return_normal=True, + return_face_id=True, + ) + torch.testing.assert_close(ray_hits, expected_ray_hits) + torch.testing.assert_close(ray_distance, torch.tensor([[4.5, 4.5]], dtype=torch.float32, device=device)) + torch.testing.assert_close(ray_normal, torch.tensor([[[0, 0, -1], [0, 0, -1]]], dtype=torch.float32, device=device)) + torch.testing.assert_close(ray_face_id, torch.tensor([[3, 8]], dtype=torch.int32, device=device)) + # check the mesh-id implementation ray_hits, ray_distance, ray_normal, ray_face_id = raycast_single_mesh( ray_starts, ray_directions, diff --git a/source/isaaclab/test/sensors/test_multi_mesh_ray_caster_camera.py b/source/isaaclab/test/sensors/test_multi_mesh_ray_caster_camera.py index a0da81c198f6..90e79ab39c30 100644 --- a/source/isaaclab/test/sensors/test_multi_mesh_ray_caster_camera.py +++ b/source/isaaclab/test/sensors/test_multi_mesh_ray_caster_camera.py @@ -106,48 +106,14 @@ def setup_simulation(): sim.clear_instance() -@pytest.mark.parametrize( - "convention,quat", - [ - ("ros", QUAT_ROS), - ("opengl", QUAT_OPENGL), - ("world", QUAT_WORLD), - ], -) -@pytest.mark.isaacsim_ci -def test_camera_init_offset(setup_simulation, convention, quat): - """Test camera initialization with offset using different conventions.""" - sim, dt, camera_cfg = setup_simulation - - # Create camera config with specific convention - cam_cfg_offset = copy.deepcopy(camera_cfg) - cam_cfg_offset.offset = MultiMeshRayCasterCameraCfg.OffsetCfg( - pos=POSITION, - rot=quat, - convention=convention, - ) - sim_utils.create_prim(f"/World/CameraOffset{convention.capitalize()}", "Xform") - cam_cfg_offset.prim_path = f"/World/CameraOffset{convention.capitalize()}" - - camera = MultiMeshRayCasterCamera(cam_cfg_offset) - - # play sim - sim.reset() - - # update camera - camera.update(dt) - - # check that transform is set correctly - np.testing.assert_allclose(camera.data.pos_w.torch[0].cpu().numpy(), cam_cfg_offset.offset.pos) - - del camera - - @pytest.mark.isaacsim_ci def test_camera_init(setup_simulation): - """Test camera initialization.""" + """Test camera initialization and that image mesh ids identify the hit mesh.""" sim, dt, camera_cfg = setup_simulation + camera_cfg = copy.deepcopy(camera_cfg) + camera_cfg.update_mesh_ids = True + camera_cfg.data_types = ["distance_to_camera"] # Create camera camera = MultiMeshRayCasterCamera(cfg=camera_cfg) # Play sim @@ -172,116 +138,30 @@ def test_camera_init(setup_simulation): for im_data in camera.data.output.values(): assert im_data.shape == (1, camera_cfg.pattern_cfg.height, camera_cfg.pattern_cfg.width, 1) - del camera - - -@pytest.mark.isaacsim_ci -def test_camera_resolution(setup_simulation): - """Test camera resolution is correctly set.""" - sim, dt, camera_cfg = setup_simulation - - # Create camera - camera = MultiMeshRayCasterCamera(cfg=camera_cfg) - # Play sim - sim.reset() - camera.update(dt) - # access image data and compare shapes - for im_data in camera.data.output.values(): - assert im_data.shape == (1, camera_cfg.pattern_cfg.height, camera_cfg.pattern_cfg.width, 1) - - del camera - - -@pytest.mark.isaacsim_ci -def test_camera_init_intrinsic_matrix(setup_simulation): - """Test camera initialization from intrinsic matrix.""" - sim, dt, camera_cfg = setup_simulation - - # get the first camera - camera_1 = MultiMeshRayCasterCamera(cfg=camera_cfg) - # get intrinsic matrix - sim.reset() - intrinsic_matrix = camera_1.data.intrinsic_matrices.torch[0].cpu().flatten().tolist() - - # initialize from intrinsic matrix - intrinsic_camera_cfg = MultiMeshRayCasterCameraCfg( - prim_path="/World/Camera", - mesh_prim_paths=["/World/defaultGroundPlane"], - update_period=0, - offset=MultiMeshRayCasterCameraCfg.OffsetCfg(pos=(0.0, 0.0, 0.0), rot=(0.0, 0.0, 0.0, 1.0), convention="world"), - debug_vis=False, - pattern_cfg=patterns.PinholeCameraPatternCfg.from_intrinsic_matrix( - intrinsic_matrix=intrinsic_matrix, - height=camera_cfg.pattern_cfg.height, - width=camera_cfg.pattern_cfg.width, - focal_length=camera_cfg.pattern_cfg.focal_length, - ), - data_types=["distance_to_image_plane"], - ) - camera_2 = MultiMeshRayCasterCamera(cfg=intrinsic_camera_cfg) - - # play sim - sim.reset() - sim.play() + assert camera.data.image_mesh_ids is not None, "image_mesh_ids should not be None when update_mesh_ids=True" + mesh_ids = camera.data.image_mesh_ids.torch # shape (N, H, W, 1), dtype torch.int16 + assert mesh_ids.shape[-1] == 1 + assert mesh_ids.dtype == torch.int16 - # update cameras - camera_1.update(dt) - camera_2.update(dt) + # Identify actual hits via distance < inf. This relies on depth_clipping_behavior="none" + # (the default), which leaves missed rays at the Warp-kernel fill value of inf. + # Under "max" clipping, missed rays would be clamped to a finite max_distance, making + # the inf comparison incorrect. + hit_mask = camera.data.output["distance_to_camera"].torch[0, :, :, 0] < float("inf") + assert hit_mask.any(), "Expected at least some rays to hit the ground plane" - # check image data - torch.testing.assert_close( - camera_1.data.output["distance_to_image_plane"].torch, - camera_2.data.output["distance_to_image_plane"].torch, - ) - # check that both intrinsic matrices are the same - torch.testing.assert_close( - camera_1.data.intrinsic_matrices.torch[0], - camera_2.data.intrinsic_matrices.torch[0], + # All hits against the single registered mesh must carry the ground mesh id. + hit_mesh_ids = mesh_ids[0, :, :, 0][hit_mask] + assert torch.all(hit_mesh_ids == MESH_ID_GROUND), ( + f"All hits against the single ground mesh must have mesh_id={MESH_ID_GROUND}, got: {hit_mesh_ids.unique()}" ) - del camera_1, camera_2 - - -@pytest.mark.isaacsim_ci -def test_multi_camera_init(setup_simulation): - """Test multi-camera initialization.""" - sim, dt, camera_cfg = setup_simulation - - # -- camera 1 - cam_cfg_1 = copy.deepcopy(camera_cfg) - cam_cfg_1.prim_path = "/World/Camera_0" - sim_utils.create_prim("/World/Camera_0", "Xform") - # Create camera - cam_1 = MultiMeshRayCasterCamera(cam_cfg_1) - - # -- camera 2 - cam_cfg_2 = copy.deepcopy(camera_cfg) - cam_cfg_2.prim_path = "/World/Camera_1" - sim_utils.create_prim("/World/Camera_1", "Xform") - # Create camera - cam_2 = MultiMeshRayCasterCamera(cam_cfg_2) - - # play sim - sim.reset() - - # Simulate physics - for _ in range(10): - # perform rendering - sim.step() - # update camera - cam_1.update(dt) - cam_2.update(dt) - # check image data - for cam in [cam_1, cam_2]: - for im_data in cam.data.output.values(): - assert im_data.shape == (1, camera_cfg.pattern_cfg.height, camera_cfg.pattern_cfg.width, 1) - - del cam_1, cam_2 + del camera @pytest.mark.isaacsim_ci def test_camera_set_world_poses(setup_simulation): - """Test camera function to set specific world pose.""" + """Test camera functions to set specific world poses directly and from view.""" sim, dt, camera_cfg = setup_simulation camera = MultiMeshRayCasterCamera(camera_cfg) @@ -298,23 +178,10 @@ def test_camera_set_world_poses(setup_simulation): torch.testing.assert_close(camera.data.pos_w.torch, position) torch.testing.assert_close(camera.data.quat_w_world.torch, orientation) - del camera - - -@pytest.mark.isaacsim_ci -def test_camera_set_world_poses_from_view(setup_simulation): - """Test camera function to set specific world pose from view.""" - sim, dt, camera_cfg = setup_simulation - - camera = MultiMeshRayCasterCamera(camera_cfg) - # play sim - sim.reset() - - # convert to torch tensors + # set a pose from eye/target eyes = torch.tensor([POSITION], dtype=torch.float32, device=camera.device) targets = torch.tensor([[0.0, 0.0, 0.0]], dtype=torch.float32, device=camera.device) quat_ros_gt = torch.tensor([QUAT_ROS], dtype=torch.float32, device=camera.device) - # set new pose camera.set_world_poses_from_view(eyes.clone(), targets.clone()) # check if transform correctly set in output @@ -324,47 +191,11 @@ def test_camera_set_world_poses_from_view(setup_simulation): del camera -@pytest.mark.parametrize("height,width", [(240, 320), (480, 640)]) -@pytest.mark.isaacsim_ci -def test_intrinsic_matrix(setup_simulation, height, width): - """Checks that the camera's set and retrieve methods work for intrinsic matrix.""" - sim, dt, camera_cfg = setup_simulation - - camera_cfg_copy = copy.deepcopy(camera_cfg) - camera_cfg_copy.pattern_cfg.height = height - camera_cfg_copy.pattern_cfg.width = width - camera = MultiMeshRayCasterCamera(camera_cfg_copy) - # play sim - sim.reset() - # Desired properties (obtained from realsense camera at 320x240 resolution) - rs_intrinsic_matrix = [229.31640625, 0.0, 164.810546875, 0.0, 229.826171875, 122.1650390625, 0.0, 0.0, 1.0] - rs_intrinsic_matrix = torch.tensor(rs_intrinsic_matrix, device=camera.device).reshape(3, 3).unsqueeze(0) - # Set matrix into simulator - camera.set_intrinsic_matrices(rs_intrinsic_matrix.clone()) - # Simulate physics - for _ in range(10): - # perform rendering - sim.step() - # update camera - camera.update(dt) - # Check that matrix is correct - torch.testing.assert_close(rs_intrinsic_matrix, camera.data.intrinsic_matrices.torch) - - del camera - - -@pytest.mark.parametrize( - "data_types", - [ - ["distance_to_image_plane", "distance_to_camera", "normals"], - ["distance_to_image_plane"], - ["distance_to_camera"], - ], -) @pytest.mark.isaacsim_ci -def test_output_equal_to_usdcamera(setup_simulation, data_types): +def test_output_equal_to_usdcamera(setup_simulation): """Test that ray caster camera output equals USD camera output.""" sim, dt, camera_cfg = setup_simulation + data_types = ["distance_to_image_plane", "distance_to_camera", "normals"] camera_pattern_cfg = patterns.PinholeCameraPatternCfg( focal_length=24.0, @@ -635,82 +466,6 @@ def test_depth_output_equal_to_usd_camera_heterogeneous_scene(setup_simulation): del camera_usd, camera_warp -@pytest.mark.isaacsim_ci -def test_output_equal_to_usdcamera_offset(setup_simulation): - """Test that ray caster camera output equals USD camera output with offset.""" - sim, dt, camera_cfg = setup_simulation - offset_rot = (0.3617, 0.8731, -0.3020, -0.1251) - - camera_pattern_cfg = patterns.PinholeCameraPatternCfg( - focal_length=24.0, - horizontal_aperture=20.955, - height=240, - width=320, - ) - sim_utils.create_prim("/World/Camera_warp", "Xform") - camera_cfg_warp = MultiMeshRayCasterCameraCfg( - prim_path="/World/Camera_warp", - mesh_prim_paths=["/World/defaultGroundPlane"], - update_period=0, - offset=MultiMeshRayCasterCameraCfg.OffsetCfg(pos=(2.5, 2.5, 4.0), rot=offset_rot, convention="ros"), - debug_vis=False, - pattern_cfg=camera_pattern_cfg, - data_types=["distance_to_image_plane", "distance_to_camera", "normals"], - ) - camera_warp = MultiMeshRayCasterCamera(camera_cfg_warp) - - # create usd camera - camera_cfg_usd = CameraCfg( - height=240, - width=320, - prim_path="/World/Camera_usd", - update_period=0, - data_types=["distance_to_image_plane", "distance_to_camera", "normals"], - spawn=PinholeCameraCfg( - focal_length=24.0, focus_distance=400.0, horizontal_aperture=20.955, clipping_range=(1e-6, 1.0e5) - ), - offset=CameraCfg.OffsetCfg(pos=(2.5, 2.5, 4.0), rot=offset_rot, convention="ros"), - ) - camera_usd = Camera(camera_cfg_usd) - - # play sim - sim.reset() - sim.play() - - # perform steps - for _ in range(5): - sim.step() - - # update camera - camera_usd.update(dt) - camera_warp.update(dt) - - # check image data - torch.testing.assert_close( - camera_usd.data.output["distance_to_image_plane"].torch, - camera_warp.data.output["distance_to_image_plane"].torch, - atol=5e-5, - rtol=5e-6, - ) - torch.testing.assert_close( - camera_usd.data.output["distance_to_camera"].torch, - camera_warp.data.output["distance_to_camera"].torch, - atol=5e-5, - rtol=5e-6, - ) - - # check normals - # NOTE: floating point issues of ~1e-5, so using atol and rtol in this case - torch.testing.assert_close( - camera_usd.data.output["normals"].torch[..., :3], - camera_warp.data.output["normals"].torch, - rtol=1e-5, - atol=1e-4, - ) - - del camera_usd, camera_warp - - @pytest.mark.isaacsim_ci def test_output_equal_to_usdcamera_prim_offset(setup_simulation): """Test that the output of the ray caster camera is equal to the output of the usd camera when both are placed @@ -807,12 +562,13 @@ def test_output_equal_to_usdcamera_prim_offset(setup_simulation): del camera_usd, camera_warp -@pytest.mark.parametrize("height,width", [(540, 960), (240, 320)]) @pytest.mark.isaacsim_ci -def test_output_equal_to_usd_camera_intrinsics(setup_simulation, height, width): +def test_output_equal_to_usd_camera_intrinsics(setup_simulation): """Test that the output of the ray caster camera and usd camera are the same when both are initialized with the same intrinsic matrix.""" sim, dt, camera_cfg = setup_simulation + # the principal point is centered at any resolution, so one resolution covers the ray pattern + height, width = 240, 320 # create cameras offset_rot = [0.3617, 0.8731, -0.3020, -0.1251] @@ -982,35 +738,3 @@ def test_output_equal_to_usd_camera_when_intrinsics_set(setup_simulation): ) del camera_usd, camera_warp - - -@pytest.mark.isaacsim_ci -def test_image_mesh_ids_identifies_hit_mesh(setup_simulation): - """image_mesh_ids must contain 0 for ground-plane hits (only one mesh registered).""" - sim, dt, camera_cfg = setup_simulation - - cfg = copy.deepcopy(camera_cfg) - cfg.update_mesh_ids = True - cfg.data_types = ["distance_to_camera"] - - camera = MultiMeshRayCasterCamera(cfg=cfg) - sim.reset() - camera.update(dt) - - assert camera.data.image_mesh_ids is not None, "image_mesh_ids should not be None when update_mesh_ids=True" - mesh_ids = camera.data.image_mesh_ids.torch # shape (N, H, W, 1), dtype torch.int16 - assert mesh_ids.shape[-1] == 1 - assert mesh_ids.dtype == torch.int16 - - # Identify actual hits via distance < inf. This relies on depth_clipping_behavior="none" - # (the default), which leaves missed rays at the Warp-kernel fill value of inf. - # Under "max" clipping, missed rays would be clamped to a finite max_distance, making - # the inf comparison incorrect. - hit_mask = camera.data.output["distance_to_camera"].torch[0, :, :, 0] < float("inf") - assert hit_mask.any(), "Expected at least some rays to hit the ground plane" - - # All hits against the single registered mesh must carry the ground mesh id. - hit_mesh_ids = mesh_ids[0, :, :, 0][hit_mask] - assert torch.all(hit_mesh_ids == MESH_ID_GROUND), ( - f"All hits against the single ground mesh must have mesh_id={MESH_ID_GROUND}, got: {hit_mesh_ids.unique()}" - ) diff --git a/source/isaaclab/test/sensors/test_multi_tiled_camera.py b/source/isaaclab/test/sensors/test_multi_tiled_camera.py index c44450b10759..f538260478e1 100644 --- a/source/isaaclab/test/sensors/test_multi_tiled_camera.py +++ b/source/isaaclab/test/sensors/test_multi_tiled_camera.py @@ -69,85 +69,9 @@ def setup_camera(): sim.clear_instance() -@pytest.mark.isaacsim_ci -def test_multi_tiled_camera_init(setup_camera): - """Test initialization of multiple tiled cameras.""" - camera_cfg, sim, dt = setup_camera - num_tiled_cameras = 3 - num_cameras_per_tiled_camera = 7 - - tiled_cameras = [] - for i in range(num_tiled_cameras): - for j in range(num_cameras_per_tiled_camera): - sim_utils.create_prim(f"/World/Origin_{i}_{j}", "Xform") - - # Create camera - camera_cfg = copy.deepcopy(camera_cfg) - camera_cfg.prim_path = f"/World/Origin_{i}[^/]*/CameraSensor" - camera = TiledCamera(camera_cfg) - tiled_cameras.append(camera) - - # Check simulation parameter is set correctly - assert sim.get_setting("/isaaclab/render/rtx_sensors") - - # Play sim - sim.reset() - - for i, camera in enumerate(tiled_cameras): - # Check if camera is initialized - assert camera.is_initialized - # Check if camera prim is set correctly and that it is a camera prim - assert camera._sensor_prims[1].GetPath().pathString == f"/World/Origin_{i}_1/CameraSensor" - assert isinstance(camera._sensor_prims[0], UsdGeom.Camera) - - for camera in tiled_cameras: - # Check buffers that exists and have correct shapes - assert camera.data.pos_w.torch.shape == (num_cameras_per_tiled_camera, 3) - assert camera.data.quat_w_ros.torch.shape == (num_cameras_per_tiled_camera, 4) - assert camera.data.quat_w_world.torch.shape == (num_cameras_per_tiled_camera, 4) - assert camera.data.quat_w_opengl.torch.shape == (num_cameras_per_tiled_camera, 4) - assert camera.data.intrinsic_matrices.torch.shape == (num_cameras_per_tiled_camera, 3, 3) - assert camera.data.image_shape == (camera.cfg.height, camera.cfg.width) - - # Simulate physics - for _ in range(10): - # Initialize data arrays - rgbs = [] - distances = [] - - # perform rendering - sim.step() - for i, camera in enumerate(tiled_cameras): - # update camera - camera.update(dt) - # check image data - for data_type, im_data in camera.data.output.items(): - if data_type == "rgb": - im_data = im_data.clone() / 255.0 - assert im_data.shape == (num_cameras_per_tiled_camera, camera.cfg.height, camera.cfg.width, 3) - for j in range(num_cameras_per_tiled_camera): - assert (im_data[j]).mean().item() > 0.0 - rgbs.append(im_data) - elif data_type == "distance_to_camera": - im_data = im_data.clone() - im_data[torch.isinf(im_data)] = 0 - assert im_data.shape == (num_cameras_per_tiled_camera, camera.cfg.height, camera.cfg.width, 1) - for j in range(num_cameras_per_tiled_camera): - assert im_data[j].mean().item() > 0.0 - distances.append(im_data) - - # Check data from tiled cameras are consistent, assumes >1 tiled cameras - for i in range(1, num_tiled_cameras): - assert torch.abs(rgbs[0] - rgbs[i]).mean() < 0.05 # images of same color should be below 0.001 - assert torch.abs(distances[0] - distances[i]).mean() < 0.01 # distances of same scene should be 0 - - for camera in tiled_cameras: - del camera - - @pytest.mark.isaacsim_ci def test_all_annotators_multi_tiled_camera(setup_camera): - """Test initialization of multiple tiled cameras with all supported annotators.""" + """Test multiple tiled cameras with all supported annotators and consistent images across cameras.""" camera_cfg, sim, dt = setup_camera all_annotator_types = [ "rgb", @@ -164,7 +88,7 @@ def test_all_annotators_multi_tiled_camera(setup_camera): ] num_tiled_cameras = 2 - num_cameras_per_tiled_camera = 9 + num_cameras_per_tiled_camera = 7 tiled_cameras = [] for i in range(num_tiled_cameras): @@ -203,11 +127,20 @@ def test_all_annotators_multi_tiled_camera(setup_camera): # Simulate physics for _ in range(10): + # Initialize data arrays + rgbs = [] + distances = [] + # perform rendering sim.step() for i, camera in enumerate(tiled_cameras): # update camera camera.update(dt) + # tiled cameras share the same pose, so their images must match + rgbs.append(camera.data.output["rgb"].clone() / 255.0) + distance = camera.data.output["distance_to_camera"].clone() + distance[torch.isinf(distance)] = 0 + distances.append(distance) # check image data for data_type, im_data in camera.data.output.items(): if data_type in ["rgb", "normals"]: @@ -231,6 +164,11 @@ def test_all_annotators_multi_tiled_camera(setup_camera): for i in range(num_cameras_per_tiled_camera): assert im_data[i].mean().item() > 0.0 + # Check data from tiled cameras are consistent, assumes >1 tiled cameras + for i in range(1, num_tiled_cameras): + assert torch.abs(rgbs[0] - rgbs[i]).mean() < 0.05 # images of same color should be below 0.001 + assert torch.abs(distances[0] - distances[i]).mean() < 0.01 # distances of same scene should be 0 + for camera in tiled_cameras: # access image data and compare dtype output = camera.data.output diff --git a/source/isaaclab/test/sensors/test_opencv_distortion.py b/source/isaaclab/test/sensors/test_opencv_distortion.py index 275525088ca2..6d276bc0f451 100644 --- a/source/isaaclab/test/sensors/test_opencv_distortion.py +++ b/source/isaaclab/test/sensors/test_opencv_distortion.py @@ -56,8 +56,13 @@ OpenCvPinholeDistortionCfg, PinholeCameraCfg, ) + from isaaclab.test.utils import DeviceScope, test_devices from isaaclab.utils.warp import ProxyArray + _CUDA_DEVICES = test_devices(DeviceScope.DEFAULT_CUDA) +else: + _CUDA_DEVICES = [] + # Real SO-101 wrist-camera calibration: exercises fx != fy and an off-center principal point, which # stock Isaac Lab camera cfgs cannot express. @@ -144,17 +149,6 @@ def test_fisheye_distortion_authors_opencv_fisheye_api(): assert prim.GetAttribute(f"{prefix}:k4").Get() == pytest.approx(0.0) -def test_distortion_attributes_survive_usd_export(): - """The authored OpenCV distortion API survives serialization, which the RTX/OVRTX path consumes.""" - cfg = PinholeCameraCfg(distortion=OpenCvPinholeDistortionCfg(apply_lens_distortion=True, **_PINHOLE_CALIB)) - stage, _prim = _spawn_camera_on_new_stage(cfg) - - exported = stage.ExportToString() - assert "OmniLensDistortionOpenCvPinholeAPI" in exported - assert "omni:lensdistortion:opencvPinhole:fx" in exported - assert "omni:lensdistortion:model" in exported - - def test_camera_without_distortion_authors_no_opencv_api(): """A camera cfg without a distortion model does not author any ``omni:lensdistortion`` attributes.""" cfg = PinholeCameraCfg() @@ -244,21 +238,6 @@ def test_readback_uses_authored_fx_fy_cx_cy(): assert k[2, 2] == pytest.approx(1.0) -def test_readback_preserves_non_square_and_off_center(): - """The readback must not collapse to the stock ``fx == fy`` / centered principal point.""" - fx, fy, cx, cy = 339.26592887, 338.82010626, 323.55809091, 250.27360914 - width, height = 640, 480 - _stage, cam = _camera_prim_with_pinhole_distortion(fx, fy, cx, cy, width, height) - - k = _read_back_intrinsics(cam, width, height) - - # non-square pixels are preserved (the old readback forced fx == fy) - assert k[0, 0] != k[1, 1] - # principal point is off-center (the old readback forced width/2, height/2) - assert k[0, 2] != pytest.approx(width / 2) - assert k[1, 2] != pytest.approx(height / 2) - - def _camera_prim_with_model_token_only(): """Build a camera prim that declares the distortion model token but authors no fx/fy/cx/cy.""" stage = Usd.Stage.CreateInMemory() @@ -316,7 +295,7 @@ def test_pointcloud_from_rgbd_uniform_color(): torch.testing.assert_close(points_rgb, expected) -@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +@pytest.mark.parametrize("device", _CUDA_DEVICES) def test_set_intrinsic_matrices_skips_only_distortion_cameras_in_batch(device): """In a mixed batch only the distortion camera is skipped (with a warning); a plain camera is updated. @@ -324,8 +303,6 @@ def test_set_intrinsic_matrices_skips_only_distortion_cameras_in_batch(device): focal-length/aperture write would be discarded for a distortion camera. Skipping the whole call would also drop ordinary selected cameras; only the distortion entries must be left untouched. """ - if device.startswith("cuda") and not wp.is_cuda_available(): - pytest.skip("CUDA is unavailable") width, height = 640, 480 _stage_d, distortion_cam = _camera_prim_with_pinhole_distortion(339.0, 338.0, 323.0, 250.0, width, height) plain_stage = Usd.Stage.CreateInMemory() @@ -360,11 +337,12 @@ def test_set_intrinsic_matrices_skips_only_distortion_cameras_in_batch(device): assert any("skipped" in message.lower() for message in messages) -@pytest.fixture(params=["cpu", "cuda:0"]) +@pytest.fixture(params=_CUDA_DEVICES) def intrinsic_camera(request): - """Three independent camera prims, with buffers on the selected device.""" - if request.param.startswith("cuda") and not wp.is_cuda_available(): - pytest.skip("CUDA is unavailable") + """Three independent camera prims, with buffers on the selected device. + + The calibration kernels and validation are device-independent; CUDA also exercises host-tensor inputs. + """ stage = Usd.Stage.CreateInMemory() prims = [UsdGeom.Camera.Define(stage, f"/Camera_{i}") for i in range(3)] camera = _camera_for_prims(prims, device=request.param) @@ -539,28 +517,3 @@ def test_single_intrinsic_matrix(intrinsic_camera, as_warp): matrix = torch.tensor([[240.0, 0, 320], [0, 240.0, 240], [0, 0, 1]], device=camera.device) camera.set_intrinsic_matrices(wp.from_torch(matrix) if as_warp else matrix, env_ids=[1]) np.testing.assert_allclose(camera._data.intrinsic_matrices.warp.numpy()[1], matrix.cpu().numpy()) - - -@pytest.mark.parametrize("edit_layer", ["root", "sublayer", "session"]) -def test_runtime_intrinsics_are_independent_of_usd_edit_target(intrinsic_camera, edit_layer): - """Runtime calibration owns its values independently of USD composition and never authors layers.""" - stage, camera = intrinsic_camera - with Usd.EditContext(stage, stage.GetSessionLayer()): - camera._sensor_prims[1].GetFocalLengthAttr().Set(25.0) - camera._sensor_prims[1].GetHorizontalApertureAttr().Set(32.0) - target = stage.GetRootLayer() - if edit_layer == "sublayer": - target = Sdf.Layer.CreateAnonymous() - stage.GetRootLayer().subLayerPaths.append(target.identifier) - elif edit_layer == "session": - target = stage.GetSessionLayer() - matrix = torch.tensor([[[100.0, 0, 320], [0, 100.0, 240], [0, 0, 1]]], device=camera.device) - - before = stage.ExportToString() - with Usd.EditContext(stage, target): - camera.set_intrinsic_matrices(matrix, env_ids=[1]) - assert stage.ExportToString() == before - - expected_focal = 100 - expected = [[expected_focal, 0, 320], [0, expected_focal, 240], [0, 0, 1]] - np.testing.assert_allclose(camera._data.intrinsic_matrices.warp.numpy()[1], expected) diff --git a/source/isaaclab/test/sensors/test_outdated_sensor.py b/source/isaaclab/test/sensors/test_outdated_sensor.py index d58b1ccfed65..bd7628666688 100644 --- a/source/isaaclab/test/sensors/test_outdated_sensor.py +++ b/source/isaaclab/test/sensors/test_outdated_sensor.py @@ -42,11 +42,14 @@ def temp_dir(): @pytest.mark.parametrize("task_name", ["IsaacContrib-Stack-Cube-Franka-IK-Rel"]) -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -@pytest.mark.parametrize("num_envs", [1, 2]) +@pytest.mark.parametrize("device,num_envs", [("cuda:0", 2), ("cpu", 1)]) @pytest.mark.isaacsim_ci -def test_action_state_recorder_terms(temp_dir, task_name, device, num_envs): - """Check FrameTransformer values after reset.""" +def test_observations_after_reset_are_not_outdated(temp_dir, task_name, device, num_envs): + """Sensor-based observations returned by reset match those after a first idle step. + + The end-effector position comes from a FrameTransformer; stale sensor data after reset would make the + reset observation differ from the one after an idle action. + """ sim_utils.create_new_stage() # parse configuration @@ -64,7 +67,6 @@ def test_action_state_recorder_terms(temp_dir, task_name, device, num_envs): # get the end effector position after the reset pre_reset_eef_pos = obs["policy"]["eef_pos"].clone() - print(pre_reset_eef_pos) # step the environment with idle actions idle_actions = torch.zeros(env.action_space.shape, device=env.unwrapped.device) @@ -72,7 +74,6 @@ def test_action_state_recorder_terms(temp_dir, task_name, device, num_envs): # get the end effector position after the first step post_reset_eef_pos = obs["policy"]["eef_pos"] - print(post_reset_eef_pos) # check if the end effector position is the same after the reset and the first step torch.testing.assert_close(pre_reset_eef_pos, post_reset_eef_pos, atol=1e-5, rtol=1e-3) diff --git a/source/isaaclab/test/sensors/test_ray_caster.py b/source/isaaclab/test/sensors/test_ray_caster.py deleted file mode 100644 index a68a60b25e60..000000000000 --- a/source/isaaclab/test/sensors/test_ray_caster.py +++ /dev/null @@ -1,524 +0,0 @@ -# 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 - -from __future__ import annotations - -from isaaclab.app import AppLauncher - -# launch omniverse app -simulation_app = AppLauncher(headless=True, enable_cameras=True).app - -import numpy as np -import pytest -import torch -import trimesh - -# Import after app launch -import warp as wp - -from isaaclab.sensors.ray_caster.kernels import quat_yaw_only as _quat_yaw_only_func -from isaaclab.utils.math import matrix_from_quat, quat_from_euler_xyz, random_orientation, yaw_quat -from isaaclab.utils.warp.kernels import raycast_mesh_masked_kernel as _raycast_mesh_masked_kernel -from isaaclab.utils.warp.ops import convert_to_warp_mesh, raycast_dynamic_meshes, raycast_mesh - -pytestmark = pytest.mark.integration - - -@pytest.fixture(scope="module") -def raycast_setup(): - device = "cuda" if torch.cuda.is_available() else "cpu" - # Base trimesh cube and its Warp conversion - trimesh_mesh = trimesh.creation.box([2, 2, 1]) - single_mesh = [ - convert_to_warp_mesh( - trimesh_mesh.vertices, - trimesh_mesh.faces, - device, - ) - ] - single_mesh_id = single_mesh[0].id - - # Rays - ray_starts = torch.tensor([[0, -0.35, -5], [0.25, 0.35, -5]], dtype=torch.float32, device=device).unsqueeze(0) - ray_directions = torch.tensor([[0, 0, 1], [0, 0, 1]], dtype=torch.float32, device=device).unsqueeze(0) - expected_ray_hits = torch.tensor( - [[0, -0.35, -0.5], [0.25, 0.35, -0.5]], dtype=torch.float32, device=device - ).unsqueeze(0) - - return { - "device": device, - "trimesh_mesh": trimesh_mesh, - "single_mesh_id": single_mesh_id, - "wp_mesh": single_mesh[0], - "ray_starts": ray_starts, - "ray_directions": ray_directions, - "expected_ray_hits": expected_ray_hits, - } - - -def test_raycast_multi_cubes(raycast_setup): - device = raycast_setup["device"] - base_tm = raycast_setup["trimesh_mesh"] - - tm1 = base_tm.copy() - wp_mesh_1 = convert_to_warp_mesh(tm1.vertices, tm1.faces, device) - - translation = np.eye(4) - translation[:3, 3] = [0, 2, 0] - tm2 = base_tm.copy().apply_transform(translation) - wp_mesh_2 = convert_to_warp_mesh(tm2.vertices, tm2.faces, device) - - mesh_ids_wp = wp.array2d([[wp_mesh_1.id, wp_mesh_2.id]], dtype=wp.uint64, device=device) - - ray_directions = raycast_setup["ray_directions"] - - # Case 1 - ray_start = torch.tensor([[0, 0, -5], [0, 2.5, -5]], dtype=torch.float32, device=device).unsqueeze(0) - ray_hits, ray_distance, ray_normal, ray_face_id, mesh_ids = raycast_dynamic_meshes( - ray_start, - ray_directions, - mesh_ids_wp, - return_distance=True, - return_normal=True, - return_face_id=True, - return_mesh_id=True, - ) - - torch.testing.assert_close(ray_hits, torch.tensor([[[0, 0, -0.5], [0, 2.5, -0.5]]], device=device)) - torch.testing.assert_close(ray_distance, torch.tensor([[4.5, 4.5]], device=device)) - torch.testing.assert_close(ray_normal, torch.tensor([[[0, 0, -1], [0, 0, -1]]], device=device, dtype=torch.float32)) - assert torch.equal(mesh_ids, torch.tensor([[0, 1]], dtype=torch.int32, device=device)) - - # Case 2 (explicit poses/orientations) - ray_start = torch.tensor([[0, 0, -5], [0, 4.5, -5]], dtype=torch.float32, device=device).unsqueeze(0) - ray_hits, ray_distance, ray_normal, ray_face_id, mesh_ids = raycast_dynamic_meshes( - ray_start, - ray_directions, - mesh_ids_wp, - return_distance=True, - return_normal=True, - return_face_id=True, - mesh_positions_w=torch.tensor([[[0, 0, 0], [0, 2, 0]]], dtype=torch.float32, device=device), - mesh_orientations_w=torch.tensor([[[0, 0, 0, 1], [0, 0, 0, 1]]], dtype=torch.float32, device=device), - return_mesh_id=True, - ) - - torch.testing.assert_close(ray_hits, torch.tensor([[[0, 0, -0.5], [0, 4.5, -0.5]]], device=device)) - torch.testing.assert_close(ray_distance, torch.tensor([[4.5, 4.5]], device=device)) - torch.testing.assert_close(ray_normal, torch.tensor([[[0, 0, -1], [0, 0, -1]]], device=device, dtype=torch.float32)) - assert torch.equal(mesh_ids, torch.tensor([[0, 1]], dtype=torch.int32, device=device)) - - -def test_raycast_single_cube(raycast_setup): - device = raycast_setup["device"] - ray_starts = raycast_setup["ray_starts"] - ray_directions = raycast_setup["ray_directions"] - mesh = raycast_setup["wp_mesh"] - expected_ray_hits = raycast_setup["expected_ray_hits"] - single_mesh_id = raycast_setup["single_mesh_id"] - - # Single-mesh helper - ray_hits, ray_distance, ray_normal, ray_face_id = raycast_mesh( - ray_starts, - ray_directions, - mesh, - return_distance=True, - return_normal=True, - return_face_id=True, - ) - torch.testing.assert_close(ray_hits, expected_ray_hits) - torch.testing.assert_close(ray_distance, torch.tensor([[4.5, 4.5]], device=device)) - torch.testing.assert_close(ray_normal, torch.tensor([[[0, 0, -1], [0, 0, -1]]], device=device, dtype=torch.float32)) - torch.testing.assert_close(ray_face_id, torch.tensor([[3, 8]], dtype=torch.int32, device=device)) - - # Multi-mesh API with one mesh - ray_hits, ray_distance, ray_normal, ray_face_id, _ = raycast_dynamic_meshes( - ray_starts, - ray_directions, - wp.array2d([[single_mesh_id]], dtype=wp.uint64, device=device), - return_distance=True, - return_normal=True, - return_face_id=True, - ) - torch.testing.assert_close(ray_hits, expected_ray_hits) - torch.testing.assert_close(ray_distance, torch.tensor([[4.5, 4.5]], device=device)) - torch.testing.assert_close(ray_normal, torch.tensor([[[0, 0, -1], [0, 0, -1]]], device=device, dtype=torch.float32)) - torch.testing.assert_close(ray_face_id, torch.tensor([[3, 8]], dtype=torch.int32, device=device)) - - -def test_raycast_moving_cube(raycast_setup): - device = raycast_setup["device"] - ray_starts = raycast_setup["ray_starts"] - ray_directions = raycast_setup["ray_directions"] - single_mesh_id = raycast_setup["single_mesh_id"] - expected_ray_hits = raycast_setup["expected_ray_hits"] - - for distance in torch.linspace(0, 1, 10, device=device): - ray_hits, ray_distance, ray_normal, ray_face_id, mesh_id = raycast_dynamic_meshes( - ray_starts, - ray_directions, - wp.array2d([[single_mesh_id]], dtype=wp.uint64, device=device), - return_distance=True, - return_normal=True, - return_face_id=True, - return_mesh_id=True, - mesh_positions_w=torch.tensor([[0, 0, distance.item()]], dtype=torch.float32, device=device), - ) - offset = torch.tensor([[0, 0, distance.item()], [0, 0, distance.item()]], dtype=torch.float32, device=device) - torch.testing.assert_close(ray_hits, expected_ray_hits + offset.unsqueeze(0)) - torch.testing.assert_close(ray_distance, distance + torch.tensor([[4.5, 4.5]], device=device)) - torch.testing.assert_close( - ray_normal, torch.tensor([[[0, 0, -1], [0, 0, -1]]], device=device, dtype=torch.float32) - ) - torch.testing.assert_close(ray_face_id, torch.tensor([[3, 8]], dtype=torch.int32, device=device)) - - -def test_raycast_rotated_cube(raycast_setup): - device = raycast_setup["device"] - ray_starts = raycast_setup["ray_starts"] - ray_directions = raycast_setup["ray_directions"] - single_mesh_id = raycast_setup["single_mesh_id"] - expected_ray_hits = raycast_setup["expected_ray_hits"] - - cube_rotation = quat_from_euler_xyz( - torch.tensor([0.0], device=device), torch.tensor([0.0], device=device), torch.tensor([np.pi], device=device) - ) - ray_hits, ray_distance, ray_normal, ray_face_id, _ = raycast_dynamic_meshes( - ray_starts, - ray_directions, - wp.array2d([[single_mesh_id]], dtype=wp.uint64, device=device), - return_distance=True, - return_normal=True, - return_face_id=True, - mesh_orientations_w=cube_rotation.unsqueeze(0), - ) - torch.testing.assert_close(ray_hits, expected_ray_hits) - torch.testing.assert_close(ray_distance, torch.tensor([[4.5, 4.5]], device=device)) - torch.testing.assert_close(ray_normal, torch.tensor([[[0, 0, -1], [0, 0, -1]]], device=device, dtype=torch.float32)) - # Rotated cube swaps face IDs - torch.testing.assert_close(ray_face_id, torch.tensor([[8, 3]], dtype=torch.int32, device=device)) - - -def test_raycast_random_cube(raycast_setup): - device = raycast_setup["device"] - base_tm = raycast_setup["trimesh_mesh"] - ray_starts = raycast_setup["ray_starts"] - ray_directions = raycast_setup["ray_directions"] - single_mesh_id = raycast_setup["single_mesh_id"] - - for orientation in random_orientation(10, device): - pos = torch.tensor([[0.0, 0.0, torch.rand(1, device=device).item()]], dtype=torch.float32, device=device) - - tf_hom = np.eye(4) - tf_hom[:3, :3] = matrix_from_quat(orientation).cpu().numpy() - tf_hom[:3, 3] = pos.squeeze(0).cpu().numpy() - - tf_mesh = base_tm.copy().apply_transform(tf_hom) - wp_mesh = convert_to_warp_mesh(tf_mesh.vertices, tf_mesh.faces, device) - - # Raycast transformed, static mesh - ray_hits, ray_distance, ray_normal, ray_face_id, _ = raycast_dynamic_meshes( - ray_starts, - ray_directions, - wp.array2d([[wp_mesh.id]], dtype=wp.uint64, device=device), - return_distance=True, - return_normal=True, - return_face_id=True, - ) - # Raycast original mesh with pose provided - ray_hits_m, ray_distance_m, ray_normal_m, ray_face_id_m, _ = raycast_dynamic_meshes( - ray_starts, - ray_directions, - wp.array2d([[single_mesh_id]], dtype=wp.uint64, device=device), - return_distance=True, - return_normal=True, - return_face_id=True, - mesh_positions_w=pos, - mesh_orientations_w=orientation.view(1, 1, -1), - ) - - torch.testing.assert_close(ray_hits, ray_hits_m) - torch.testing.assert_close(ray_distance, ray_distance_m) - torch.testing.assert_close(ray_normal, ray_normal_m) - torch.testing.assert_close(ray_face_id, ray_face_id_m) - - -## -# RayCaster sensor-level tests -## - - -def test_raycaster_offset_does_not_affect_pos_w(): - """Verify that cfg.offset.pos shifts ray starts but NOT data.pos_w. - - data.pos_w must reflect the parent body position so that downstream - observations like height_scan (pos_w_z - hit_z - 0.5) produce values - relative to the body, not relative to the offset sensor frame. - - Regression test: previously the offset was baked into the FrameView's - Xform local transform, causing data.pos_w to include the 20m offset - and breaking height-scan observations during training. - """ - import isaaclab.sim as sim_utils - from isaaclab.sensors.ray_caster import RayCaster, RayCasterCfg, patterns - from isaaclab.terrains.trimesh.utils import make_plane - from isaaclab.terrains.utils import create_prim_from_mesh - - sim_utils.create_new_stage() - - # ground plane at z=0 - mesh = make_plane(size=(100, 100), height=0.0, center_zero=True) - create_prim_from_mesh("/World/ground", mesh) - - # parent body at known position - body_pos = (0.0, 0.0, 0.6) - sim_utils.create_prim("/World/Robot", "Xform", translation=body_pos) - - # large z-offset to make the regression obvious - offset_z = 20.0 - cfg = RayCasterCfg( - prim_path="/World/Robot", - offset=RayCasterCfg.OffsetCfg(pos=(0.0, 0.0, offset_z)), - mesh_prim_paths=["/World/ground"], - pattern_cfg=patterns.GridPatternCfg(resolution=0.5, size=[1.0, 1.0]), - ray_alignment="yaw", - ) - - dt = 0.01 - sim = sim_utils.SimulationContext(sim_utils.SimulationCfg(dt=dt)) - - sensor = RayCaster(cfg) - sim.reset() - sensor.update(dt) - - # data.pos_w / data.ray_hits_w return ProxyArray wrappers; use .torch for tensor indexing. - pos_w = sensor.data.pos_w.torch[0].cpu() - - # pos_w.z should be near the body height, NOT body_height + offset - assert abs(pos_w[2].item() - body_pos[2]) < 1.0, ( - f"data.pos_w.z = {pos_w[2].item():.2f}, expected near body height {body_pos[2]}." - f" If pos_w.z ≈ {body_pos[2] + offset_z}, the offset was incorrectly baked into the FrameView." - ) - - # ray_hits should be near z=0 (ground plane) - hits_z = sensor.data.ray_hits_w.torch[0, :, 2].cpu() - valid = hits_z[~torch.isinf(hits_z)] - if len(valid) > 0: - assert valid.abs().max().item() < 2.0, ( - f"Ray hits z range [{valid.min().item():.2f}, {valid.max().item():.2f}] — expected near ground (z≈0)." - ) - - # height_scan observation: pos_w_z - hit_z - 0.5 should be small, not ~20 - if len(valid) > 0: - height_obs = pos_w[2].item() - valid.mean().item() - 0.5 - assert abs(height_obs) < 5.0, ( - f"height_scan observation = {height_obs:.2f}, expected near 0." - f" If ≈{offset_z}, the offset leaked into data.pos_w." - ) - - sim.stop() - sim.clear_instance() - - -# --------------------------------------------------------------------------- -# Tests for raycast_mesh_masked_kernel (new kernel in utils/warp/kernels.py) -# --------------------------------------------------------------------------- - -_SENTINEL = -2.0 # value pre-filled into output buffers; chosen outside [-1, 1] so it cannot -# equal any component of a unit-length surface normal, making "not written" assertions unambiguous. - - -def _make_masked_buffers(device, n_envs, n_rays): - """Allocate all warp buffers needed by raycast_mesh_masked_kernel. - - ray_dist_w and ray_normal_w are pre-filled with _SENTINEL so that tests can - meaningfully assert those buffers were *not* written when the corresponding - return flag is 0. - """ - ray_starts_w = wp.zeros((n_envs, n_rays), dtype=wp.vec3f, device=device) - ray_dirs_w = wp.zeros((n_envs, n_rays), dtype=wp.vec3f, device=device) - ray_hits_w = wp.zeros((n_envs, n_rays), dtype=wp.vec3f, device=device) - ray_dist_w = wp.zeros((n_envs, n_rays), dtype=wp.float32, device=device) - wp.to_torch(ray_dist_w).fill_(_SENTINEL) - ray_normal_w = wp.zeros((n_envs, n_rays), dtype=wp.vec3f, device=device) - wp.to_torch(ray_normal_w).fill_(_SENTINEL) - return ray_starts_w, ray_dirs_w, ray_hits_w, ray_dist_w, ray_normal_w - - -def test_raycast_mesh_masked_kernel_hits_only(raycast_setup): - """return_distance=0, return_normal=0: only ray_hits are written on a hit.""" - device = raycast_setup["device"] - mesh_id = raycast_setup["single_mesh_id"] - expected_hits = raycast_setup["expected_ray_hits"] # shape (1, 2, 3) - - n_envs, n_rays = 1, 2 - ray_starts_w, ray_dirs_w, ray_hits_w, ray_dist_w, ray_normal_w = _make_masked_buffers(device, n_envs, n_rays) - env_mask = wp.array([True], dtype=wp.bool, device=device) - - wp.to_torch(ray_starts_w)[:] = torch.tensor([[[0, -0.35, -5], [0.25, 0.35, -5]]], device=device) - wp.to_torch(ray_dirs_w)[:] = torch.tensor([[[0, 0, 1], [0, 0, 1]]], device=device) - wp.to_torch(ray_hits_w).fill_(float("inf")) - - wp.launch( - _raycast_mesh_masked_kernel, - dim=(n_envs, n_rays), - inputs=[mesh_id, env_mask, ray_starts_w, ray_dirs_w, float(1e6), 0, 0, ray_hits_w, ray_dist_w, ray_normal_w], - device=device, - ) - - torch.testing.assert_close(wp.to_torch(ray_hits_w), expected_hits) - assert torch.all(wp.to_torch(ray_dist_w) == _SENTINEL), "Distance buffer must not be written when return_distance=0" - assert torch.all(wp.to_torch(ray_normal_w) == _SENTINEL), "Normal buffer must not be written when return_normal=0" - - -def test_raycast_mesh_masked_kernel_with_distance(raycast_setup): - """return_distance=1: distances are written in addition to hits.""" - device = raycast_setup["device"] - mesh_id = raycast_setup["single_mesh_id"] - - n_envs, n_rays = 1, 2 - ray_starts_w, ray_dirs_w, ray_hits_w, ray_dist_w, ray_normal_w = _make_masked_buffers(device, n_envs, n_rays) - env_mask = wp.array([True], dtype=wp.bool, device=device) - - wp.to_torch(ray_starts_w)[:] = torch.tensor([[[0, -0.35, -5], [0.25, 0.35, -5]]], device=device) - wp.to_torch(ray_dirs_w)[:] = torch.tensor([[[0, 0, 1], [0, 0, 1]]], device=device) - wp.to_torch(ray_hits_w).fill_(float("inf")) - - wp.launch( - _raycast_mesh_masked_kernel, - dim=(n_envs, n_rays), - inputs=[mesh_id, env_mask, ray_starts_w, ray_dirs_w, float(1e6), 1, 0, ray_hits_w, ray_dist_w, ray_normal_w], - device=device, - ) - - # Cube bottom at z=-0.5, rays start at z=-5 going +z, distance = 4.5 - torch.testing.assert_close(wp.to_torch(ray_dist_w), torch.tensor([[4.5, 4.5]], device=device)) - assert torch.all(wp.to_torch(ray_normal_w) == _SENTINEL), "Normal buffer must not be written when return_normal=0" - - -def test_raycast_mesh_masked_kernel_with_normal(raycast_setup): - """return_distance=1, return_normal=1: both distances and surface normals are written.""" - device = raycast_setup["device"] - mesh_id = raycast_setup["single_mesh_id"] - - n_envs, n_rays = 1, 2 - ray_starts_w, ray_dirs_w, ray_hits_w, ray_dist_w, ray_normal_w = _make_masked_buffers(device, n_envs, n_rays) - env_mask = wp.array([True], dtype=wp.bool, device=device) - - wp.to_torch(ray_starts_w)[:] = torch.tensor([[[0, -0.35, -5], [0.25, 0.35, -5]]], device=device) - wp.to_torch(ray_dirs_w)[:] = torch.tensor([[[0, 0, 1], [0, 0, 1]]], device=device) - wp.to_torch(ray_hits_w).fill_(float("inf")) - - wp.launch( - _raycast_mesh_masked_kernel, - dim=(n_envs, n_rays), - inputs=[mesh_id, env_mask, ray_starts_w, ray_dirs_w, float(1e6), 1, 1, ray_hits_w, ray_dist_w, ray_normal_w], - device=device, - ) - - # Cube bottom at z=-0.5, rays start at z=-5, distance = 4.5 - torch.testing.assert_close(wp.to_torch(ray_dist_w), torch.tensor([[4.5, 4.5]], device=device)) - torch.testing.assert_close( - wp.to_torch(ray_normal_w), - torch.tensor([[[0, 0, -1], [0, 0, -1]]], device=device, dtype=torch.float32), - ) - - -def test_raycast_mesh_masked_kernel_env_mask(raycast_setup): - """Masked-out environments must not be written.""" - device = raycast_setup["device"] - mesh_id = raycast_setup["single_mesh_id"] - - n_envs, n_rays = 2, 2 - ray_starts_w, ray_dirs_w, ray_hits_w, ray_dist_w, ray_normal_w = _make_masked_buffers(device, n_envs, n_rays) - env_mask = wp.array([True, False], dtype=wp.bool, device=device) - - starts = torch.tensor([[[0, -0.35, -5], [0.25, 0.35, -5]], [[0, -0.35, -5], [0.25, 0.35, -5]]], device=device) - dirs = torch.tensor([[[0, 0, 1], [0, 0, 1]], [[0, 0, 1], [0, 0, 1]]], device=device) - wp.to_torch(ray_starts_w)[:] = starts - wp.to_torch(ray_dirs_w)[:] = dirs - wp.to_torch(ray_hits_w).fill_(float("inf")) - - wp.launch( - _raycast_mesh_masked_kernel, - dim=(n_envs, n_rays), - inputs=[mesh_id, env_mask, ray_starts_w, ray_dirs_w, float(1e6), 1, 0, ray_hits_w, ray_dist_w, ray_normal_w], - device=device, - ) - - hits = wp.to_torch(ray_hits_w) - dist = wp.to_torch(ray_dist_w) - - assert not torch.isinf(hits[0]).any(), "Active env 0 should have valid hits" - torch.testing.assert_close(dist[0], torch.tensor([4.5, 4.5], device=device)) - assert torch.isinf(hits[1]).all(), "Masked env 1 hits must remain inf" - assert torch.all(dist[1] == _SENTINEL), "Masked env 1 distances must remain at sentinel" - assert torch.all(wp.to_torch(ray_normal_w) == _SENTINEL), "Normal buffer must not be written when return_normal=0" - - -# --------------------------------------------------------------------------- -# Test quat_yaw_only correctness (regression for atan2-based fix) -# --------------------------------------------------------------------------- - - -@wp.kernel(enable_backward=False) -def _call_quat_yaw_only(q_in: wp.array(dtype=wp.quatf), q_out: wp.array(dtype=wp.quatf)): - i = wp.tid() - q_out[i] = _quat_yaw_only_func(q_in[i]) - - -def test_quat_yaw_only_pure_yaw(): - """Pure yaw: quat_yaw_only should match the yaw_quat() reference for all yaw angles.""" - device = "cuda" if torch.cuda.is_available() else "cpu" - yaw_angles = torch.tensor([0.0, 0.5, 1.2, -0.8, np.pi], device=device) - - for yaw in yaw_angles: - q_torch = quat_from_euler_xyz( - torch.tensor([0.0], device=device), - torch.tensor([0.0], device=device), - yaw.unsqueeze(0), - ) # shape (1, 4), xyzw - - expected = yaw_quat(q_torch) # shape (1, 4) - - q_in = wp.from_torch(q_torch.contiguous(), dtype=wp.quatf) - q_out = wp.zeros(1, dtype=wp.quatf, device=device) - wp.launch(_call_quat_yaw_only, dim=1, inputs=[q_in, q_out], device=device) - result = wp.to_torch(q_out) # shape (1, 4) - - torch.testing.assert_close(result, expected, atol=1e-5, rtol=1e-5) - - -def test_quat_yaw_only_with_pitch_roll(): - """Non-zero pitch and roll: only the yaw component should be preserved. - - This is the regression test for the old bug where simply zeroing qx/qy and - renormalizing gave the wrong answer when pitch or roll was non-zero. - """ - device = "cuda" if torch.cuda.is_available() else "cpu" - - # Several combined pitch+roll+yaw orientations: (roll, pitch, yaw) - test_cases = [ - (0.3, 0.4, 1.2), - (0.5, 0.0, 0.7), - (-0.2, 0.6, -1.0), - (1.0, 1.0, 0.0), # heavy pitch+roll, zero yaw → result should be identity - ] - - for roll, pitch, yaw in test_cases: - q_torch = quat_from_euler_xyz( - torch.tensor([roll], device=device), - torch.tensor([pitch], device=device), - torch.tensor([yaw], device=device), - ) # shape (1, 4), xyzw - - expected = yaw_quat(q_torch) # shape (1, 4) - - q_in = wp.from_torch(q_torch.contiguous(), dtype=wp.quatf) - q_out = wp.zeros(1, dtype=wp.quatf, device=device) - wp.launch(_call_quat_yaw_only, dim=1, inputs=[q_in, q_out], device=device) - result = wp.to_torch(q_out) - - torch.testing.assert_close(result, expected, atol=1e-5, rtol=1e-5) diff --git a/source/isaaclab/test/sensors/test_ray_caster_camera.py b/source/isaaclab/test/sensors/test_ray_caster_camera.py index 39284954599e..3887c407d1e7 100644 --- a/source/isaaclab/test/sensors/test_ray_caster_camera.py +++ b/source/isaaclab/test/sensors/test_ray_caster_camera.py @@ -116,14 +116,21 @@ def setup_sim(): @pytest.mark.isaacsim_ci def test_camera_init(setup_sim): - """Test camera initialization.""" + """Test camera initialization, shared mesh loading, and frame counting.""" sim, camera_cfg, dt = setup_sim # Create camera camera = RayCasterCamera(cfg=camera_cfg) + # -- second camera with a different prim path + cam_cfg_2 = copy.deepcopy(camera_cfg) + cam_cfg_2.prim_path = "/World/Camera_2" + sim_utils.create_prim("/World/Camera_2", "Xform") + camera_2 = RayCasterCamera(cam_cfg_2) # Play sim sim.reset() # Check if camera is initialized assert camera.is_initialized + assert torch.all(camera.frame == 0), "Frame must start at 0" + assert "Ray-Caster-Camera" in str(camera) # Check buffers that exist and have correct shapes assert camera.data.pos_w.torch.shape == (1, 3) assert camera.data.quat_w_ros.torch.shape == (1, 4) @@ -132,37 +139,46 @@ def test_camera_init(setup_sim): assert camera.data.intrinsic_matrices.torch.shape == (1, 3, 3) assert camera.data.image_shape == (camera_cfg.pattern_cfg.height, camera_cfg.pattern_cfg.width) assert camera.data.info == {camera_cfg.data_types[0]: None} - # Simulate physics - for _ in range(10): - sim.step() - camera.update(dt) - # check image data - for im_data in camera.data.output.values(): - assert im_data.shape == (1, camera_cfg.pattern_cfg.height, camera_cfg.pattern_cfg.width, 1) + + # both cameras share one cached ground mesh and must see it identically from the same pose + mesh_keys = [key for key in camera.meshes if key[0] == camera_cfg.mesh_prim_paths[0]] + assert len(mesh_keys) == 1 + eyes = torch.tensor([POSITION], dtype=torch.float32, device=camera.device) + targets = torch.zeros_like(eyes) + for cam in (camera, camera_2): + cam.set_world_poses_from_view(eyes.clone(), targets.clone()) + cam.update(dt, force_recompute=True) + depth_1 = camera.data.output["distance_to_image_plane"].torch + # the optical axis hits the ground at the origin, so the center depth is the eye-to-origin distance + center_row, center_col = camera_cfg.pattern_cfg.height // 2, camera_cfg.pattern_cfg.width // 2 + assert depth_1[0, center_row, center_col, 0].item() == pytest.approx(float(np.linalg.norm(POSITION)), abs=0.02) + torch.testing.assert_close(camera_2.data.output["distance_to_image_plane"].torch, depth_1) # check the camera reset camera.reset() assert torch.all(camera.frame == 0) # Simulate physics - for _ in range(10): + n_steps = 7 + for step in range(1, n_steps + 1): sim.step() - camera.update(dt) - camera.reset(env_ids=[0]) - assert camera.frame[0] == 0 + camera.update(dt, force_recompute=True) + camera_2.update(dt) + assert camera.frame[0].item() == step, f"Frame must be {step} after {step} update(s)" + # check image data + for cam in [camera, camera_2]: + for im_data in cam.data.output.values(): + assert im_data.shape == (1, camera_cfg.pattern_cfg.height, camera_cfg.pattern_cfg.width, 1) + # Partial reset: only env 0 (single-env camera, but API accepts env_ids) + camera.reset(env_ids=[0]) + assert camera.frame[0].item() == 0, "Frame must be 0 after reset(env_ids=[0])" -@pytest.mark.isaacsim_ci -def test_camera_resolution(setup_sim): - """Test camera resolution is correctly set.""" - sim, camera_cfg, dt = setup_sim - # Create camera - camera = RayCasterCamera(cfg=camera_cfg) - # Play sim - sim.reset() - camera.update(dt) - # access image data and compare shapes - for im_data in camera.data.output.values(): - assert im_data.shape == (1, camera_cfg.pattern_cfg.height, camera_cfg.pattern_cfg.width, 1) + # Full reset + for _ in range(3): + sim.step() + camera.update(dt, force_recompute=True) + camera.reset() + assert torch.all(camera.frame == 0), "Frame must be 0 after full reset()" @pytest.mark.isaacsim_ci @@ -369,48 +385,9 @@ def test_camera_init_intrinsic_matrix(setup_sim): ) -@pytest.mark.isaacsim_ci -def test_multi_camera_init(setup_sim): - """Test multi-camera initialization.""" - sim, camera_cfg, dt = setup_sim - # create two cameras with different prim paths - # -- camera 1 - cam_cfg_1 = copy.deepcopy(camera_cfg) - cam_cfg_1.prim_path = "/World/Camera_1" - sim_utils.create_prim("/World/Camera_1", "Xform") - # Create camera - cam_1 = RayCasterCamera(cam_cfg_1) - # -- camera 2 - cam_cfg_2 = copy.deepcopy(camera_cfg) - cam_cfg_2.prim_path = "/World/Camera_2" - sim_utils.create_prim("/World/Camera_2", "Xform") - cam_2 = RayCasterCamera(cam_cfg_2) - - # check that the loaded meshes are equal - assert cam_1.meshes == cam_2.meshes - - # play sim - sim.reset() - - # Simulate for a few steps - for _ in range(5): - sim.step() - # Simulate physics - for _ in range(10): - # perform rendering - sim.step() - # update camera - cam_1.update(dt) - cam_2.update(dt) - # check image data - for cam in [cam_1, cam_2]: - for im_data in cam.data.output.values(): - assert im_data.shape == (1, camera_cfg.pattern_cfg.height, camera_cfg.pattern_cfg.width, 1) - - @pytest.mark.isaacsim_ci def test_camera_set_world_poses(setup_sim): - """Test camera function to set specific world pose.""" + """Test camera functions to set specific world poses directly and from view.""" sim, camera_cfg, dt = setup_sim camera = RayCasterCamera(camera_cfg) # play sim @@ -426,20 +403,10 @@ def test_camera_set_world_poses(setup_sim): torch.testing.assert_close(camera.data.pos_w.torch, position) torch.testing.assert_close(camera.data.quat_w_world.torch, orientation) - -@pytest.mark.isaacsim_ci -def test_camera_set_world_poses_from_view(setup_sim): - """Test camera function to set specific world pose from view.""" - sim, camera_cfg, dt = setup_sim - camera = RayCasterCamera(camera_cfg) - # play sim - sim.reset() - - # convert to torch tensors + # set a pose from eye/target eyes = torch.tensor([POSITION], dtype=torch.float32, device=camera.device) targets = torch.tensor([[0.0, 0.0, 0.0]], dtype=torch.float32, device=camera.device) quat_ros_gt = torch.tensor([QUAT_ROS], dtype=torch.float32, device=camera.device) - # set new pose camera.set_world_poses_from_view(eyes.clone(), targets.clone()) # check if transform correctly set in output @@ -447,31 +414,6 @@ def test_camera_set_world_poses_from_view(setup_sim): _assert_quat_close(camera.data.quat_w_ros, quat_ros_gt) -@pytest.mark.isaacsim_ci -def test_intrinsic_matrix(setup_sim): - """Checks that the camera's set and retrieve methods work for intrinsic matrix.""" - sim, camera_cfg, dt = setup_sim - camera_cfg = copy.deepcopy(camera_cfg) - camera_cfg.pattern_cfg.height = 240 - camera_cfg.pattern_cfg.width = 320 - camera = RayCasterCamera(camera_cfg) - # play sim - sim.reset() - # Desired properties (obtained from realsense camera at 320x240 resolution) - rs_intrinsic_matrix = [229.31640625, 0.0, 164.810546875, 0.0, 229.826171875, 122.1650390625, 0.0, 0.0, 1.0] - rs_intrinsic_matrix = torch.tensor(rs_intrinsic_matrix, device=camera.device).reshape(3, 3).unsqueeze(0) - # Set matrix into simulator - camera.set_intrinsic_matrices(rs_intrinsic_matrix.clone()) - # Simulate physics - for _ in range(10): - # perform rendering - sim.step() - # update camera - camera.update(dt) - # Check that matrix is correct - torch.testing.assert_close(rs_intrinsic_matrix, camera.data.intrinsic_matrices.torch) - - @pytest.mark.isaacsim_ci def test_output_equal_to_usdcamera(setup_sim): sim, camera_cfg, dt = setup_sim @@ -572,82 +514,6 @@ def test_output_equal_to_usdcamera(setup_sim): ) -@pytest.mark.isaacsim_ci -def test_output_equal_to_usdcamera_offset(setup_sim): - sim, camera_cfg, dt = setup_sim - offset_rot = [0.3617, 0.8731, -0.3020, -0.1251] - - camera_pattern_cfg = patterns.PinholeCameraPatternCfg( - focal_length=24.0, - horizontal_aperture=20.955, - height=240, - width=320, - ) - sim_utils.create_prim("/World/Camera_warp", "Xform") - camera_cfg_warp = RayCasterCameraCfg( - prim_path="/World/Camera", - mesh_prim_paths=["/World/defaultGroundPlane"], - update_period=0, - offset=RayCasterCameraCfg.OffsetCfg(pos=(2.5, 2.5, 4.0), rot=tuple(offset_rot), convention="ros"), - debug_vis=False, - pattern_cfg=camera_pattern_cfg, - data_types=["distance_to_image_plane", "distance_to_camera", "normals"], - ) - - camera_warp = RayCasterCamera(camera_cfg_warp) - - # create usd camera - camera_cfg_usd = CameraCfg( - height=240, - width=320, - prim_path="/World/Camera_usd", - update_period=0, - data_types=["distance_to_image_plane", "distance_to_camera", "normals"], - spawn=PinholeCameraCfg( - focal_length=24.0, focus_distance=400.0, horizontal_aperture=20.955, clipping_range=(1e-6, 1.0e5) - ), - offset=CameraCfg.OffsetCfg( - pos=(2.5, 2.5, 4.0), rot=(offset_rot[0], offset_rot[1], offset_rot[2], offset_rot[3]), convention="ros" - ), - ) - camera_usd = Camera(camera_cfg_usd) - - # play sim - sim.reset() - sim.play() - - # perform steps - for _ in range(5): - sim.step() - - # update camera - camera_usd.update(dt) - camera_warp.update(dt) - - # check image data - torch.testing.assert_close( - camera_usd.data.output["distance_to_image_plane"].torch, - camera_warp.data.output["distance_to_image_plane"].torch, - rtol=1e-3, - atol=1e-5, - ) - torch.testing.assert_close( - camera_usd.data.output["distance_to_camera"].torch, - camera_warp.data.output["distance_to_camera"].torch, - rtol=1e-3, - atol=1e-5, - ) - - # check normals - # NOTE: floating point issues of ~1e-5, so using atol and rtol in this case - torch.testing.assert_close( - camera_usd.data.output["normals"].torch[..., :3], - camera_warp.data.output["normals"].torch, - rtol=1e-5, - atol=1e-4, - ) - - @pytest.mark.isaacsim_ci def test_output_equal_to_usdcamera_prim_offset(setup_sim): """Test that the output of the ray caster camera is equal to the output of the usd camera when both are placed @@ -742,15 +608,16 @@ def test_output_equal_to_usdcamera_prim_offset(setup_sim): ) -@pytest.mark.parametrize("focal_length", [0.193, 1.93, 19.3]) @pytest.mark.isaacsim_ci -def test_output_equal_to_usd_camera_intrinsics(setup_sim, focal_length): +def test_output_equal_to_usd_camera_intrinsics(setup_sim): """ Test that the output of the ray caster camera and usd camera are the same when both are initialized with the same intrinsic matrix. """ sim, camera_cfg, dt = setup_sim + # the focal length only scales the apertures (same K), so one value covers the ray pattern + focal_length = 19.3 # create cameras offset_rot = (0.3617, 0.8731, -0.3020, -0.1251) offset_pos = (2.5, 2.5, 4.0) @@ -854,152 +721,16 @@ def test_output_equal_to_usd_camera_intrinsics(setup_sim, focal_length): plt.close() # check image data - if focal_length != 0.193: - # FIXME: 0.193 is not working on the IsaacSim/ UsdGeom side, add back once fixed - torch.testing.assert_close( - cam_warp_output, - cam_usd_output, - atol=5e-5, - rtol=1e-5, - ) - - del camera_warp, camera_usd - - -@pytest.mark.parametrize( - "focal_length_aperture", - [ - (0.193, 0.20955), - pytest.param((1.93, 2.0955), marks=pytest.mark.flaky(max_runs=3, min_passes=1)), - (19.3, 20.955), - (0.193, 20.955), - ], -) -@pytest.mark.isaacsim_ci -def test_output_equal_to_usd_camera_when_intrinsics_set(setup_sim, focal_length_aperture): - """ - Test that the output of the ray caster camera is equal to the output of the usd camera when both are placed - under an XForm prim and an intrinsic matrix is set. - """ - # unpack focal length and aperture - focal_length, aperture = focal_length_aperture - - sim, camera_cfg, dt = setup_sim - camera_pattern_cfg = patterns.PinholeCameraPatternCfg( - focal_length=focal_length, - horizontal_aperture=aperture, - height=540, - width=960, - ) - camera_cfg_warp = RayCasterCameraCfg( - prim_path="/World/Camera", - mesh_prim_paths=["/World/defaultGroundPlane"], - update_period=0, - offset=RayCasterCameraCfg.OffsetCfg(pos=(0.0, 0.0, 0.0), rot=(0.0, 0.0, 0.0, 1.0)), - debug_vis=False, - pattern_cfg=camera_pattern_cfg, - data_types=["distance_to_camera"], - ) - - camera_warp = RayCasterCamera(camera_cfg_warp) - - # create usd camera - camera_cfg_usd = CameraCfg( - height=540, - width=960, - prim_path="/World/Camera_usd", - update_period=0, - data_types=["distance_to_camera"], - spawn=PinholeCameraCfg( - focal_length=focal_length, focus_distance=400.0, horizontal_aperture=aperture, clipping_range=(1e-4, 1.0e5) - ), - ) - camera_usd = Camera(camera_cfg_usd) - - # play sim - sim.reset() - sim.play() - - # set intrinsic matrix - # NOTE: extend the test to cover aperture offsets once supported by the usd camera - # intrinsic_matrix = torch.tensor( - # [[380.0831, 0.0, camera_cfg_usd.width / 2, 0.0, 380.0831, camera_cfg_usd.height / 2, 0.0, 0.0, 1.0]], - # device=camera_warp.device, - # ).reshape(1, 3, 3) - # camera_warp.set_intrinsic_matrices(intrinsic_matrix, focal_length=10) - # camera_usd.set_intrinsic_matrices(intrinsic_matrix, focal_length=10) - - # set camera position - eyes_np = np.asarray([[0.0, 0.0, 5.0]], dtype=np.float32) - targets_np = np.asarray([[0.0, 0.0, 0.0]], dtype=np.float32) - eyes = torch.tensor(eyes_np, device=camera_warp.device) - targets = torch.tensor(targets_np, device=camera_warp.device) - camera_warp.set_world_poses_from_view( - eyes=eyes, - targets=targets, + torch.testing.assert_close( + cam_warp_output, + cam_usd_output, + atol=5e-5, + rtol=1e-5, ) - camera_usd.set_world_poses_from_view(eyes=eyes_np, targets=targets_np) - - # perform steps - for _ in range(5): - sim.step() - - # update camera - camera_usd.update(dt) - camera_warp.update(dt) - - if DEBUG_PLOTS: - # plot both images next to each other plus their difference in a 1x3 grid figure - import matplotlib.pyplot as plt - - fig, axs = plt.subplots(1, 3, figsize=(15, 5)) - usd_plt = axs[0].imshow(camera_usd.data.output["distance_to_camera"].torch[0].cpu().numpy()) - fig.colorbar(usd_plt, ax=axs[0]) - axs[0].set_title("USD") - warp_plt = axs[1].imshow(camera_warp.data.output["distance_to_camera"].torch[0].cpu().numpy()) - fig.colorbar(warp_plt, ax=axs[1]) - axs[1].set_title("WARP") - diff_plt = axs[2].imshow( - torch.abs( - camera_usd.data.output["distance_to_camera"].torch - camera_warp.data.output["distance_to_camera"].torch - )[0] - .cpu() - .numpy() - ) - fig.colorbar(diff_plt, ax=axs[2]) - axs[2].set_title("Difference") - # save figure - plt.tight_layout() - plt.savefig( - f"{os.path.dirname(os.path.abspath(__file__))}/output/test_output_equal_to_usd_camera_when_intrinsics_set_{focal_length}_{aperture}.png" - ) - plt.close() - - # check image data - if focal_length != 0.193: - # FIXME: 0.193 is not working on the IsaacSim/ UsdGeom side, add back once fixed - torch.testing.assert_close( - camera_usd.data.output["distance_to_camera"].torch, - camera_warp.data.output["distance_to_camera"].torch, - rtol=5e-3, - atol=1e-4, - ) del camera_warp, camera_usd -@pytest.mark.isaacsim_ci -def test_sensor_print(setup_sim): - """Test sensor print is working correctly.""" - sim, camera_cfg, dt = setup_sim - # Create sensor - sensor = RayCasterCamera(cfg=camera_cfg) - # Play sim - sim.reset() - # print info - print(sensor) - - @pytest.mark.isaacsim_ci def test_depth_clipping_d2ip_and_d2c_are_independent(setup_sim): """Clipping distance_to_image_plane must not corrupt distance_to_camera and vice versa. @@ -1067,33 +798,6 @@ def test_depth_clipping_d2ip_and_d2c_are_independent(setup_sim): assert d2c_joint.max().item() <= base_cfg.max_distance + 1e-4 -@pytest.mark.isaacsim_ci -def test_frame_counter_increments_per_update(setup_sim): - """frame counter must increment by exactly 1 per update() call and reset to 0 on reset().""" - sim, camera_cfg, dt = setup_sim - camera = RayCasterCamera(cfg=camera_cfg) - sim.reset() - - assert torch.all(camera.frame == 0), "Frame must start at 0" - - n_steps = 7 - for step in range(1, n_steps + 1): - sim.step() - camera.update(dt, force_recompute=True) - assert camera.frame[0].item() == step, f"Frame must be {step} after {step} update(s)" - - # Partial reset: only env 0 (single-env camera, but API accepts env_ids) - camera.reset(env_ids=[0]) - assert camera.frame[0].item() == 0, "Frame must be 0 after reset(env_ids=[0])" - - # Full reset - for _ in range(3): - sim.step() - camera.update(dt, force_recompute=True) - camera.reset() - assert torch.all(camera.frame == 0), "Frame must be 0 after full reset()" - - @pytest.mark.isaacsim_ci def test_set_intrinsic_matrices_updates_output(setup_sim): """Depth output must change when intrinsics are updated via set_intrinsic_matrices(). @@ -1105,6 +809,8 @@ def test_set_intrinsic_matrices_updates_output(setup_sim): # Place camera looking straight down at the ground camera_cfg = copy.deepcopy(camera_cfg) + camera_cfg.pattern_cfg.height = 240 + camera_cfg.pattern_cfg.width = 320 camera_cfg.offset = RayCasterCameraCfg.OffsetCfg(pos=(0.0, 0.0, 5.0), rot=(0.0, 0.0, 0.0, 1.0), convention="world") camera_cfg.data_types = ["distance_to_camera"] camera = RayCasterCamera(cfg=camera_cfg) @@ -1116,16 +822,16 @@ def test_set_intrinsic_matrices_updates_output(setup_sim): camera.update(dt) output_before = camera.data.output["distance_to_camera"].torch.clone() - # Change to a very different focal length (longer → tighter FOV → depth values differ at edges) - new_matrix = torch.tensor( - [[200.0, 0.0, 320.0], [0.0, 200.0, 240.0], [0.0, 0.0, 1.0]], - device=camera.device, - ).unsqueeze(0) - camera.set_intrinsic_matrices(new_matrix, focal_length=1.0) + # Change to a non-square, off-center calibration (obtained from a realsense camera at 320x240 resolution) + rs_intrinsic_matrix = [229.31640625, 0.0, 164.810546875, 0.0, 229.826171875, 122.1650390625, 0.0, 0.0, 1.0] + rs_intrinsic_matrix = torch.tensor(rs_intrinsic_matrix, device=camera.device).reshape(3, 3).unsqueeze(0) + camera.set_intrinsic_matrices(rs_intrinsic_matrix.clone(), focal_length=1.0) for _ in range(3): sim.step() camera.update(dt) + # Check that the matrix reads back unchanged + torch.testing.assert_close(rs_intrinsic_matrix, camera.data.intrinsic_matrices.torch) output_after = camera.data.output["distance_to_camera"].torch.clone() # Outputs must differ after intrinsics change (different ray angles → different depths) diff --git a/source/isaaclab/test/sensors/test_ray_caster_integration.py b/source/isaaclab/test/sensors/test_ray_caster_integration.py index a7a6e6ddb9e8..4afb05d074ad 100644 --- a/source/isaaclab/test/sensors/test_ray_caster_integration.py +++ b/source/isaaclab/test/sensors/test_ray_caster_integration.py @@ -10,7 +10,7 @@ These tests require Isaac Sim (AppLauncher). They cover the integration-level items from ``TODO_ray_caster_kernel_tests.md``: -- ``_get_sensor_transforms_wp`` ArticulationView and RigidBodyView paths +- ``_get_sensor_transforms_wp`` RigidBodyView path - ``MultiMeshRayCaster`` env_mask behavior - ``MultiMeshRayCasterCamera.set_intrinsic_matrices`` propagation - ``_update_mesh_transforms`` non-identity orientation offset @@ -95,55 +95,6 @@ def sim_ground(): sim.clear_instance() -# --------------------------------------------------------------------------- -# _get_sensor_transforms_wp: ArticulationView path -# --------------------------------------------------------------------------- - - -@pytest.mark.isaacsim_ci -def test_articulation_view_path(sim_ground): - """Mount a ray caster on a prim with ArticulationRootAPI. - - Verifies that sensor pos_w matches the prim's initial position and that - the downward ray hits the ground plane. This exercises the - ``ArticulationView.get_root_transforms()`` quaternion-convention path in - :meth:`_get_sensor_transforms_wp`. - """ - sim = sim_ground - expected_pos = (3.0, 4.0, 5.0) - - prim_path = "/World/ArticulatedBody" - sim_utils.create_prim(prim_path, "Xform", translation=expected_pos) - stage = sim_utils.get_current_stage() - prim = stage.GetPrimAtPath(prim_path) - UsdPhysics.RigidBodyAPI.Apply(prim) - UsdPhysics.ArticulationRootAPI.Apply(prim) - # Mass is needed for physics; collision is needed for PhysX to track the body. - mass_api = cast(Any, UsdPhysics.MassAPI.Apply(prim)) - mass_api.CreateMassAttr().Set(1.0) - # Create a small collision cube so PhysX treats this as a real body. - cube_path = f"{prim_path}/CollisionCube" - cube_geom = cast(Any, UsdGeom.Cube.Define(stage, cube_path)) - cube_geom.CreateSizeAttr().Set(0.1) - UsdPhysics.CollisionAPI.Apply(stage.GetPrimAtPath(cube_path)) - sim_utils.update_stage() - - sensor = RayCaster(_single_downward_ray_cfg(prim_path)) - sim.reset() - sensor.update(_DT) - - pos_w = sensor.data.pos_w.torch[0].cpu().numpy() - np.testing.assert_allclose( - pos_w, - expected_pos, - atol=0.15, - err_msg="ArticulationView: sensor pos_w must match initial prim position", - ) - - hits = sensor.data.ray_hits_w.torch[0, 0].cpu().numpy() - assert abs(hits[2]) < 0.5, f"ArticulationView: downward ray should hit near z=0, got z={hits[2]}" - - # --------------------------------------------------------------------------- # _get_sensor_transforms_wp: RigidBodyView path # --------------------------------------------------------------------------- diff --git a/source/isaaclab/test/sensors/test_ray_caster_kernels.py b/source/isaaclab/test/sensors/test_ray_caster_kernels.py index f2905a70a31c..c296c335a4f5 100644 --- a/source/isaaclab/test/sensors/test_ray_caster_kernels.py +++ b/source/isaaclab/test/sensors/test_ray_caster_kernels.py @@ -20,6 +20,7 @@ import numpy as np import pytest +import trimesh import warp as wp pytestmark = pytest.mark.unit @@ -31,7 +32,8 @@ fill_ray_hits_distance_inf_kernel, quat_yaw_only, ) -from isaaclab.utils.warp.kernels import raycast_dynamic_meshes_kernel +from isaaclab.utils.warp.kernels import raycast_dynamic_meshes_kernel, raycast_mesh_masked_kernel +from isaaclab.utils.warp.ops import convert_to_warp_mesh # --------------------------------------------------------------------------- # Constants & setup @@ -352,6 +354,83 @@ def test_equidistant_meshes(self): assert out["mesh_id"][0, 0] in (0, 1) +# --------------------------------------------------------------------------- +# Tests: raycast_mesh_masked_kernel +# --------------------------------------------------------------------------- + +_SENTINEL = -2.0 +"""Pre-fill for distance/normal buffers; outside [-1, 1] so it cannot equal a unit-normal component.""" + + +class TestRaycastMeshMaskedKernel: + """Tests for :func:`raycast_mesh_masked_kernel` from ``utils/warp/kernels.py``. + + Two rays start at z=-5 and travel +z into a 2x2x1 box centered at the origin, hitting its + bottom face at z=-0.5 (distance 4.5, normal -z). + """ + + RAY_STARTS = [[0.0, -0.35, -5.0], [0.25, 0.35, -5.0]] + EXPECTED_HITS = [[0.0, -0.35, -0.5], [0.25, 0.35, -0.5]] + + def _launch(self, env_mask: list[bool], return_distance: int, return_normal: int) -> dict[str, np.ndarray]: + """Launch the kernel with hits pre-filled with inf and distance/normal with :data:`_SENTINEL`.""" + box = trimesh.creation.box([2, 2, 1]) + mesh = convert_to_warp_mesh(box.vertices, box.faces, DEVICE) + num_envs, num_rays = len(env_mask), len(self.RAY_STARTS) + starts = np.tile(np.array(self.RAY_STARTS, dtype=np.float32), (num_envs, 1, 1)) + dirs = np.tile(np.array([0.0, 0.0, 1.0], dtype=np.float32), (num_envs, num_rays, 1)) + ray_hits = wp.array(np.full((num_envs, num_rays, 3), np.inf, dtype=np.float32), dtype=wp.vec3f, device=DEVICE) + ray_dist = wp.array(np.full((num_envs, num_rays), _SENTINEL, dtype=np.float32), dtype=wp.float32, device=DEVICE) + ray_normal = wp.array( + np.full((num_envs, num_rays, 3), _SENTINEL, dtype=np.float32), dtype=wp.vec3f, device=DEVICE + ) + + wp.launch( + raycast_mesh_masked_kernel, + dim=(num_envs, num_rays), + inputs=[ + mesh.id, + wp.array(np.array(env_mask, dtype=np.bool_), dtype=wp.bool, device=DEVICE), + wp.array(starts, dtype=wp.vec3f, device=DEVICE), + wp.array(dirs, dtype=wp.vec3f, device=DEVICE), + float(1e6), + return_distance, + return_normal, + ray_hits, + ray_dist, + ray_normal, + ], + device=DEVICE, + ) + wp.synchronize_device(DEVICE) + return {"hits": _to_numpy(ray_hits), "distance": _to_numpy(ray_dist), "normal": _to_numpy(ray_normal)} + + def test_hits_only(self): + """return_distance=0, return_normal=0: only ray hits are written on a hit.""" + out = self._launch([True], return_distance=0, return_normal=0) + + np.testing.assert_allclose(out["hits"][0], self.EXPECTED_HITS, atol=ATOL) + assert np.all(out["distance"] == _SENTINEL), "Distance buffer must not be written when return_distance=0" + assert np.all(out["normal"] == _SENTINEL), "Normal buffer must not be written when return_normal=0" + + def test_distance_and_normal(self): + """return_distance=1, return_normal=1: both distances and surface normals are written.""" + out = self._launch([True], return_distance=1, return_normal=1) + + np.testing.assert_allclose(out["distance"][0], [4.5, 4.5], atol=ATOL) + np.testing.assert_allclose(out["normal"][0], [[0.0, 0.0, -1.0], [0.0, 0.0, -1.0]], atol=ATOL) + + def test_env_mask(self): + """Masked-out environments are not written; return_normal=0 leaves normals untouched.""" + out = self._launch([True, False], return_distance=1, return_normal=0) + + assert not np.isinf(out["hits"][0]).any(), "Active env 0 should have valid hits" + np.testing.assert_allclose(out["distance"][0], [4.5, 4.5], atol=ATOL) + assert np.isinf(out["hits"][1]).all(), "Masked env 1 hits must remain inf" + assert np.all(out["distance"][1] == _SENTINEL), "Masked env 1 distances must remain at sentinel" + assert np.all(out["normal"] == _SENTINEL), "Normal buffer must not be written when return_normal=0" + + class TestComputeDistanceToImagePlaneToImageMaskedKernel: """Tests for :func:`compute_distance_to_image_plane_to_image_masked_kernel`.""" @@ -483,14 +562,6 @@ def _launch( wp.synchronize_device(DEVICE) return _to_numpy(hits_wp) - def test_known_drift(self): - """ray_cast_drift = (0, 0, 1.5) shifts ray hit z by exactly 1.5.""" - result = self._launch( - hits=[[[3.0, 4.0, 5.0]]], - drift=[[0.0, 0.0, 1.5]], - ) - np.testing.assert_allclose(result[0, 0], [3.0, 4.0, 6.5], atol=ATOL) - def test_only_z_component(self): """Only z-component of drift is applied; x and y are unchanged.""" result = self._launch( @@ -540,3 +611,21 @@ def test_gimbal_lock(self): # Must be unit-norm norm = math.sqrt(float(qx) ** 2 + float(qy) ** 2 + float(qz) ** 2 + float(qw) ** 2) assert norm == pytest.approx(1.0, abs=ATOL), f"Non-unit quaternion at index {i}: norm={norm}" + + def test_with_pitch_roll(self): + """Non-zero pitch and roll keep only the yaw component. + + Regression for zeroing qx/qy and renormalizing, which is wrong once roll or pitch is non-zero. + """ + # (roll, pitch, yaw); the last case has heavy pitch+roll and zero yaw, so the result is identity + cases = [(0.3, 0.4, 1.2), (0.5, 0.0, 0.7), (-0.2, 0.6, -1.0), (1.0, 1.0, 0.0)] + q_in = wp.array( + np.array([_euler_to_quat_xyzw(*c) for c in cases], dtype=np.float32), dtype=wp.quatf, device=DEVICE + ) + q_out = wp.zeros(len(cases), dtype=wp.quatf, device=DEVICE) + + wp.launch(_quat_yaw_only_test_kernel, dim=len(cases), inputs=[q_in], outputs=[q_out], device=DEVICE) + wp.synchronize_device(DEVICE) + + expected = [[0.0, 0.0, math.sin(yaw / 2), math.cos(yaw / 2)] for _, _, yaw in cases] + np.testing.assert_allclose(_to_numpy(q_out), expected, atol=ATOL) diff --git a/source/isaaclab/test/sensors/test_ray_caster_patterns.py b/source/isaaclab/test/sensors/test_ray_caster_patterns.py index de5f9ae9aa52..f74963f74821 100644 --- a/source/isaaclab/test/sensors/test_ray_caster_patterns.py +++ b/source/isaaclab/test/sensors/test_ray_caster_patterns.py @@ -5,12 +5,6 @@ from __future__ import annotations -from isaaclab.app import AppLauncher - -# launch omniverse app -simulation_app = AppLauncher(headless=True, enable_cameras=False).app - -# Import after app launch import math import pytest @@ -21,12 +15,10 @@ pytestmark = pytest.mark.integration -@pytest.fixture(scope="module", params=["cuda", "cpu"]) -def device(request): - """Fixture to parameterize tests over both CUDA and CPU devices.""" - if request.param == "cuda" and not torch.cuda.is_available(): - pytest.skip("CUDA not available") - return request.param +@pytest.fixture(scope="module") +def device(): + """Pattern generators are plain torch ops without a device branch, so one device suffices.""" + return "cuda" if torch.cuda.is_available() else "cpu" class TestGridPattern: @@ -39,7 +31,6 @@ class TestGridPattern: ((2.0, 2.0), 0.5, "xy", 25), # 5x5 grid ((4.0, 2.0), 1.0, "xy", 15), # 5x3 grid ((2.0, 4.0), 1.0, "yx", 15), # 3x5 grid - ((1.0, 1.0), 0.25, "xy", 25), # 5x5 grid with smaller size ], ) def test_grid_pattern_num_rays(self, device, size, resolution, ordering, expected_num_rays): @@ -70,9 +61,9 @@ def test_grid_pattern_ordering(self, device, ordering): assert ray_starts[0, 0] == ray_starts[1, 0] # Same x assert ray_starts[0, 1] != ray_starts[1, 1] # Different y - @pytest.mark.parametrize("direction", [(0.0, 0.0, -1.0), (0.0, 0.0, 1.0), (1.0, 0.0, 0.0)]) - def test_grid_pattern_direction(self, device, direction): - """Test that grid pattern uses the specified direction.""" + def test_grid_pattern_direction(self, device): + """Test that grid pattern uses the specified (non-default) direction for every ray.""" + direction = (1.0, 0.0, 0.0) cfg = patterns_cfg.GridPatternCfg(size=(2.0, 2.0), resolution=1.0, direction=direction) ray_starts, ray_directions = patterns.grid_pattern(cfg, device) @@ -112,27 +103,19 @@ class TestLidarPattern: """Test cases for lidar_pattern function.""" @pytest.mark.parametrize( - "horizontal_fov_range,horizontal_res,channels,vertical_fov_range", + "horizontal_fov_range,horizontal_res,channels,vertical_fov_range,expected_num_rays", [ - # Test 360 degree horizontal FOV - ((-180.0, 180.0), 90.0, 1, (-10.0, -10.0)), - ((-180.0, 180.0), 45.0, 1, (-10.0, -10.0)), - ((-180.0, 180.0), 1.0, 1, (-10.0, -10.0)), - # Test partial horizontal FOV - ((-90.0, 90.0), 30.0, 1, (-10.0, -10.0)), - ((0.0, 180.0), 45.0, 1, (-10.0, -10.0)), - # Test 360 no overlap case - ((-180.0, 180.0), 90.0, 1, (0.0, 0.0)), - # Test partial FOV case - ((-90.0, 90.0), 90.0, 1, (0.0, 0.0)), - # Test multiple channels - ((-180.0, 180.0), 90.0, 16, (-15.0, 15.0)), - ((-180.0, 180.0), 45.0, 32, (-30.0, 10.0)), - # Test single channel, different vertical angles - ((-180.0, 180.0), 90.0, 1, (45.0, 45.0)), + # 360 degree FOV: the endpoint duplicates the start angle and is dropped (-180, -90, 0, 90) + ((-180.0, 180.0), 90.0, 1, (-10.0, -10.0), 4), + # partial FOV keeps both endpoints: -90, -60, ..., 90 + ((-90.0, 90.0), 30.0, 1, (-10.0, -10.0), 7), + # multiple channels multiply the horizontal count + ((-180.0, 180.0), 90.0, 16, (-15.0, 15.0), 64), ], ) - def test_lidar_pattern_num_rays(self, device, horizontal_fov_range, horizontal_res, channels, vertical_fov_range): + def test_lidar_pattern_num_rays( + self, device, horizontal_fov_range, horizontal_res, channels, vertical_fov_range, expected_num_rays + ): """Test that lidar pattern generates the correct number of rays.""" cfg = patterns_cfg.LidarPatternCfg( horizontal_fov_range=horizontal_fov_range, @@ -142,26 +125,8 @@ def test_lidar_pattern_num_rays(self, device, horizontal_fov_range, horizontal_r ) ray_starts, ray_directions = patterns.lidar_pattern(cfg, device) - # Calculate expected number of horizontal angles - if abs(abs(horizontal_fov_range[0] - horizontal_fov_range[1]) - 360.0) < 1e-6: - # 360 degree FOV - exclude last point to avoid overlap - expected_num_horizontal = ( - math.ceil((horizontal_fov_range[1] - horizontal_fov_range[0]) / horizontal_res) + 1 - ) - 1 - else: - expected_num_horizontal = ( - math.ceil((horizontal_fov_range[1] - horizontal_fov_range[0]) / horizontal_res) + 1 - ) - - expected_num_rays = channels * expected_num_horizontal - - assert ray_starts.shape[0] == expected_num_rays, ( - f"Expected {expected_num_rays} rays, got {ray_starts.shape[0]} rays. " - f"Horizontal angles: {expected_num_horizontal}, channels: {channels}" - ) - assert ray_directions.shape[0] == expected_num_rays - assert ray_starts.shape[1] == 3 - assert ray_directions.shape[1] == 3 + assert ray_starts.shape == (expected_num_rays, 3) + assert ray_directions.shape == (expected_num_rays, 3) def test_lidar_pattern_basic_properties(self, device): """Test that ray directions are normalized and rays start from origin.""" @@ -275,29 +240,6 @@ def test_lidar_pattern_exact_angles( class TestBpearlPattern: """Test cases for bpearl_pattern function.""" - @pytest.mark.parametrize( - "horizontal_fov,horizontal_res", - [ - (360.0, 10.0), # Default config - (360.0, 5.0), - (180.0, 10.0), - (90.0, 5.0), - ], - ) - def test_bpearl_pattern_horizontal_params(self, device, horizontal_fov, horizontal_res): - """Test bpearl pattern with different horizontal parameters.""" - cfg = patterns_cfg.BpearlPatternCfg( - horizontal_fov=horizontal_fov, - horizontal_res=horizontal_res, - ) - ray_starts, ray_directions = patterns.bpearl_pattern(cfg, device) - - # Calculate expected number of horizontal angles - expected_num_horizontal = int(horizontal_fov / horizontal_res) - expected_num_rays = len(cfg.vertical_ray_angles) * expected_num_horizontal - - assert ray_starts.shape[0] == expected_num_rays - def test_bpearl_pattern_basic_properties(self, device): """Test that ray directions are normalized and rays start from origin.""" cfg = patterns_cfg.BpearlPatternCfg() @@ -311,34 +253,26 @@ def test_bpearl_pattern_basic_properties(self, device): torch.testing.assert_close(ray_starts, torch.zeros_like(ray_starts)) def test_bpearl_pattern_custom_vertical_angles(self, device): - """Test bpearl pattern with custom vertical angles.""" + """Test bpearl pattern with custom vertical angles and a partial horizontal field of view.""" custom_angles = [10.0, 20.0, 30.0, 40.0, 50.0] cfg = patterns_cfg.BpearlPatternCfg( - horizontal_fov=360.0, + horizontal_fov=180.0, horizontal_res=90.0, vertical_ray_angles=custom_angles, ) ray_starts, ray_directions = patterns.bpearl_pattern(cfg, device) - # 360/90 = 4 horizontal angles, 5 custom vertical angles - expected_num_rays = 4 * 5 + # 180/90 = 2 horizontal angles, 5 custom vertical angles + expected_num_rays = 2 * 5 assert ray_starts.shape[0] == expected_num_rays class TestPinholeCameraPattern: """Test cases for pinhole_camera_pattern function.""" - @pytest.mark.parametrize( - "width,height", - [ - (640, 480), - (1920, 1080), - (320, 240), - (100, 100), - ], - ) - def test_pinhole_camera_pattern_num_rays(self, device, width, height): - """Test that pinhole camera pattern generates the correct number of rays.""" + def test_pinhole_camera_pattern_num_rays(self, device): + """Test that pinhole camera pattern generates one ray per pixel.""" + width, height = 640, 480 cfg = patterns_cfg.PinholeCameraPatternCfg( width=width, height=height, @@ -423,6 +357,6 @@ def test_pinhole_camera_from_intrinsic_matrix(self, device): assert cfg.height == height assert cfg.focal_length == 24.0 # default - # The apertures should be calculated based on the intrinsic matrix - assert cfg.horizontal_aperture > 0 - assert cfg.vertical_aperture > 0 + # aperture = image size * focal length / focal length in pixels + assert cfg.horizontal_aperture == pytest.approx(640 * 24.0 / 500.0) + assert cfg.vertical_aperture == pytest.approx(480 * 24.0 / 500.0) diff --git a/source/isaaclab/test/sensors/test_ray_caster_sensor.py b/source/isaaclab/test/sensors/test_ray_caster_sensor.py index a82a4175ce6e..c28579437767 100644 --- a/source/isaaclab/test/sensors/test_ray_caster_sensor.py +++ b/source/isaaclab/test/sensors/test_ray_caster_sensor.py @@ -5,7 +5,7 @@ # pyright: reportPrivateUsage=none -"""Tests for RayCaster sensor behavior: alignment modes and reset.""" +"""Tests for RayCaster sensor behavior: alignment modes, offsets, reset, and parent tracking.""" from typing import Literal @@ -71,58 +71,16 @@ def sim_ground(): @pytest.mark.isaacsim_ci -def test_world_alignment_ignores_sensor_pitch(sim_ground): - """In 'world' alignment, ray direction is always (0,0,-1) regardless of sensor pitch. +def test_ray_alignment_modes(sim_ground): + """Each alignment mode rotates ray starts and directions by its share of the sensor orientation. - Two sensors at the same location: one upright (identity), one pitched 30°. - World-mode sensors must produce the same hit position (straight below at z=0). - """ - sim = sim_ground - - # Upright sensor: identity orientation - sim_utils.create_prim("/World/SensorUpright", "Xform", translation=(0.0, 0.0, 2.0)) - # Pitched 30° sensor — orientation=(x,y,z,w) per Isaac Lab convention - pitch_quat = quat_from_euler_xyz( - torch.tensor([0.0]), torch.tensor([np.pi / 6]), torch.tensor([0.0]) - ) # shape (1, 4), xyzw - sim_utils.create_prim( - "/World/SensorPitched", - "Xform", - translation=(0.0, 0.0, 2.0), - orientation=tuple(pitch_quat[0].tolist()), # xyzw - ) - - sensor_upright = RayCaster(_ray_caster_cfg("/World/SensorUpright", "world")) - sensor_pitched = RayCaster(_ray_caster_cfg("/World/SensorPitched", "world")) - sim.reset() + Sensors at height 2 above the ground plane z=0: - dt = 0.01 - sensor_upright.update(dt) - sensor_pitched.update(dt) - - # ray_hits_w returns a ProxyArray; use .torch for tensor indexing. - hits_upright = sensor_upright.data.ray_hits_w.torch # (1, 1, 3) - hits_pitched = sensor_pitched.data.ray_hits_w.torch - - # Both must hit z=0 (straight down, world frame direction) - assert abs(hits_upright[0, 0, 2].item()) < 0.02, ( - f"Upright world sensor must hit z≈0, got {hits_upright[0, 0, 2].item()}" - ) - assert abs(hits_pitched[0, 0, 2].item()) < 0.02, ( - f"Pitched world sensor must hit z≈0, got {hits_pitched[0, 0, 2].item()}" - ) - # Lateral positions must agree (same start at [0,0,2] + same direction [0,0,-1]) - torch.testing.assert_close(hits_upright, hits_pitched, atol=0.02, rtol=0) - - -@pytest.mark.isaacsim_ci -def test_base_alignment_rotates_ray_direction(sim_ground): - """In 'base' alignment, ray direction follows the full sensor orientation. - - A sensor pitched +30° around Y (quat_from_euler_xyz(pitch=pi/6)): - - Rotates (0,0,-1) to (-sin(30°), 0, -cos(30°)) = (-0.5, 0, -0.866) - - world mode → ray still goes straight down, hits x≈0, z≈0 - - base mode → ray tilts, hits at x ≈ -2*tan(30°) ≈ -1.155 + - Pitched +30° around Y (quat_from_euler_xyz(pitch=pi/6)), single ray at the sensor origin: + world mode ignores the pitch and hits straight below, matching an upright world sensor; base mode + rotates (0,0,-1) to (-0.5, 0, -0.866) and hits at x ≈ -2*tan(30°) ≈ -1.155. + - Pitched 30° and yawed 45°, single ray at local offset (+1, 0, 0): world mode does not rotate the + offset (hit x ≈ 1); yaw mode rotates it by the yaw only (hit x ≈ cos45°). Both fire straight down. """ sim = sim_ground @@ -130,59 +88,18 @@ def test_base_alignment_rotates_ray_direction(sim_ground): torch.tensor([0.0]), torch.tensor([np.pi / 6]), torch.tensor([0.0]) ) # shape (1, 4), xyzw orientation = tuple(pitch_quat[0].tolist()) - - sim_utils.create_prim("/World/SensorWorld", "Xform", translation=(0.0, 0.0, 2.0), orientation=orientation) - sim_utils.create_prim("/World/SensorBase", "Xform", translation=(0.0, 0.0, 2.0), orientation=orientation) - - sensor_world = RayCaster(_ray_caster_cfg("/World/SensorWorld", "world")) - sensor_base = RayCaster(_ray_caster_cfg("/World/SensorBase", "base")) - sim.reset() - - dt = 0.01 - sensor_world.update(dt) - sensor_base.update(dt) - - hits_world = sensor_world.data.ray_hits_w.torch # (1, 1, 3) - hits_base = sensor_base.data.ray_hits_w.torch - - # World mode: ray still hits directly below (x≈0, y≈0, z≈0) - assert abs(hits_world[0, 0, 0].item()) < 0.05, f"World mode hit x must be near 0, got {hits_world[0, 0, 0].item()}" - assert abs(hits_world[0, 0, 2].item()) < 0.05, f"World mode must hit z≈0, got {hits_world[0, 0, 2].item()}" - - # Base mode: pitch +30° around Y rotates (0,0,-1) to (-0.5, 0, -0.866). - # From height 2, the ray hits x = -2 * tan(30°) ≈ -1.155. - expected_x = -2.0 * np.tan(np.pi / 6) - assert abs(hits_base[0, 0, 0].item() - expected_x) < 0.05, ( - f"Base mode hit x should be ≈{expected_x:.3f}, got {hits_base[0, 0, 0].item():.3f}" - ) - assert abs(hits_base[0, 0, 2].item()) < 0.05, f"Base mode must hit ground (z≈0), got {hits_base[0, 0, 2].item()}" - - -@pytest.mark.isaacsim_ci -def test_yaw_alignment_direction_unchanged(sim_ground): - """In 'yaw' alignment, ray directions stay world-down despite pitch+roll. - - Setup: sensor at (0,0,2), pitched 30° and yawed 45°; pattern has a single ray - at local offset (+1, 0, 0). - - - world mode: start = sensor_pos + (1,0,0) (no rotation applied to offset) - - yaw mode: start = sensor_pos + yaw_rot(45°) @ (1,0,0) = (cos45°, sin45°, 0) - - Both modes fire the ray straight down (direction unchanged), so both hit z=0. - The hit x-coordinate differs between modes, confirming the yaw-only rotation of - start positions is applied in 'yaw' mode but not in 'world' mode. - """ - sim = sim_ground - combined_quat = quat_from_euler_xyz( torch.tensor([0.0]), torch.tensor([np.pi / 6]), # 30° pitch torch.tensor([np.pi / 4]), # 45° yaw ) # shape (1, 4), xyzw - orientation = tuple(combined_quat[0].tolist()) + combined_orientation = tuple(combined_quat[0].tolist()) - sim_utils.create_prim("/World/SensorWorldY", "Xform", translation=(0.0, 0.0, 2.0), orientation=orientation) - sim_utils.create_prim("/World/SensorYaw", "Xform", translation=(0.0, 0.0, 2.0), orientation=orientation) + sim_utils.create_prim("/World/SensorUpright", "Xform", translation=(0.0, 0.0, 2.0)) + sim_utils.create_prim("/World/SensorWorld", "Xform", translation=(0.0, 0.0, 2.0), orientation=orientation) + sim_utils.create_prim("/World/SensorBase", "Xform", translation=(0.0, 0.0, 2.0), orientation=orientation) + sim_utils.create_prim("/World/SensorWorldY", "Xform", translation=(0.0, 0.0, 2.0), orientation=combined_orientation) + sim_utils.create_prim("/World/SensorYaw", "Xform", translation=(0.0, 0.0, 2.0), orientation=combined_orientation) # Use a single ray at local offset (+1, 0, 0), still pointing down def _cfg_with_offset(prim_path, alignment): @@ -196,33 +113,59 @@ def _cfg_with_offset(prim_path, alignment): ray_alignment=alignment, ) - sensor_world = RayCaster(_cfg_with_offset("/World/SensorWorldY", "world")) - sensor_yaw = RayCaster(_cfg_with_offset("/World/SensorYaw", "yaw")) + sensor_upright = RayCaster(_ray_caster_cfg("/World/SensorUpright", "world")) + sensor_world = RayCaster(_ray_caster_cfg("/World/SensorWorld", "world")) + sensor_base = RayCaster(_ray_caster_cfg("/World/SensorBase", "base")) + sensor_world_offset = RayCaster(_cfg_with_offset("/World/SensorWorldY", "world")) + sensor_yaw_offset = RayCaster(_cfg_with_offset("/World/SensorYaw", "yaw")) sim.reset() dt = 0.01 - sensor_world.update(dt) - sensor_yaw.update(dt) + for sensor in (sensor_upright, sensor_world, sensor_base, sensor_world_offset, sensor_yaw_offset): + sensor.update(dt) + + # ray_hits_w returns a ProxyArray; use .torch for tensor indexing. + hits_upright = sensor_upright.data.ray_hits_w.torch # (1, 1, 3) + hits_world = sensor_world.data.ray_hits_w.torch + hits_base = sensor_base.data.ray_hits_w.torch + + # World mode: ray still hits directly below (x≈0, y≈0, z≈0), same as an upright sensor + assert abs(hits_upright[0, 0, 2].item()) < 0.02, ( + f"Upright world sensor must hit z≈0, got {hits_upright[0, 0, 2].item()}" + ) + assert abs(hits_world[0, 0, 0].item()) < 0.05, f"World mode hit x must be near 0, got {hits_world[0, 0, 0].item()}" + assert abs(hits_world[0, 0, 2].item()) < 0.02, f"World mode must hit z≈0, got {hits_world[0, 0, 2].item()}" + # Lateral positions must agree (same start at [0,0,2] + same direction [0,0,-1]) + torch.testing.assert_close(hits_upright, hits_world, atol=0.02, rtol=0) + + # Base mode: pitch +30° around Y rotates (0,0,-1) to (-0.5, 0, -0.866). + # From height 2, the ray hits x = -2 * tan(30°) ≈ -1.155. + expected_x = -2.0 * np.tan(np.pi / 6) + assert abs(hits_base[0, 0, 0].item() - expected_x) < 0.05, ( + f"Base mode hit x should be ≈{expected_x:.3f}, got {hits_base[0, 0, 0].item():.3f}" + ) + assert abs(hits_base[0, 0, 2].item()) < 0.05, f"Base mode must hit ground (z≈0), got {hits_base[0, 0, 2].item()}" - hits_world = sensor_world.data.ray_hits_w.torch # (1, 1, 3) - hits_yaw = sensor_yaw.data.ray_hits_w.torch + hits_world_offset = sensor_world_offset.data.ray_hits_w.torch + hits_yaw_offset = sensor_yaw_offset.data.ray_hits_w.torch # Both modes must hit the ground (direction unchanged = straight down in both modes) - assert abs(hits_world[0, 0, 2].item()) < 0.05, "World mode must hit z≈0" - assert abs(hits_yaw[0, 0, 2].item()) < 0.05, "Yaw mode must hit z≈0 (direction straight down)" + assert abs(hits_world_offset[0, 0, 2].item()) < 0.05, "World mode must hit z≈0" + assert abs(hits_yaw_offset[0, 0, 2].item()) < 0.05, "Yaw mode must hit z≈0 (direction straight down)" # world mode: offset (1,0,0) not rotated → ray starts at sensor_pos+(1,0,0) → hits x≈1 - assert abs(hits_world[0, 0, 0].item() - 1.0) < 0.05, ( - f"World mode: hit x should be ≈1.0 (unrotated offset), got {hits_world[0, 0, 0].item():.3f}" + assert abs(hits_world_offset[0, 0, 0].item() - 1.0) < 0.05, ( + f"World mode: hit x should be ≈1.0 (unrotated offset), got {hits_world_offset[0, 0, 0].item():.3f}" ) # yaw mode: offset (1,0,0) rotated by 45° yaw → starts at sensor_pos+(cos45°, sin45°, 0) → hits x≈cos45° expected_x_yaw = np.cos(np.pi / 4) # ≈ 0.707 - assert abs(hits_yaw[0, 0, 0].item() - expected_x_yaw) < 0.05, ( - f"Yaw mode: hit x should be ≈{expected_x_yaw:.3f} (yaw-rotated offset), got {hits_yaw[0, 0, 0].item():.3f}" + assert abs(hits_yaw_offset[0, 0, 0].item() - expected_x_yaw) < 0.05, ( + f"Yaw mode: hit x should be ≈{expected_x_yaw:.3f} (yaw-rotated offset)," + f" got {hits_yaw_offset[0, 0, 0].item():.3f}" ) # Confirm they differ — if they were the same, the test would not cover the yaw rotation - assert not torch.allclose(hits_world, hits_yaw, atol=0.1), ( + assert not torch.allclose(hits_world_offset, hits_yaw_offset, atol=0.1), ( "Yaw and world modes must produce different hit positions for non-zero lateral offset" ) @@ -276,6 +219,64 @@ def test_ray_caster_reset_resamples_drift(sim_ground): ) +@pytest.mark.isaacsim_ci +def test_offset_does_not_affect_pos_w(sim_ground): + """Verify that cfg.offset.pos shifts ray starts but NOT data.pos_w. + + data.pos_w must reflect the parent body position so that downstream + observations like height_scan (pos_w_z - hit_z - 0.5) produce values + relative to the body, not relative to the offset sensor frame. + + Regression test: previously the offset was baked into the FrameView's + Xform local transform, causing data.pos_w to include the 20m offset + and breaking height-scan observations during training. + """ + sim = sim_ground + + # parent body at known position + body_pos = (0.0, 0.0, 0.6) + sim_utils.create_prim("/World/Robot", "Xform", translation=body_pos) + + # large z-offset to make the regression obvious + offset_z = 20.0 + cfg = RayCasterCfg( + prim_path="/World/Robot", + offset=RayCasterCfg.OffsetCfg(pos=(0.0, 0.0, offset_z)), + mesh_prim_paths=[_GROUND_PATH], + pattern_cfg=patterns.GridPatternCfg(resolution=0.5, size=[1.0, 1.0]), + ray_alignment="yaw", + ) + + dt = 0.01 + sensor = RayCaster(cfg) + sim.reset() + sensor.update(dt) + + # data.pos_w / data.ray_hits_w return ProxyArray wrappers; use .torch for tensor indexing. + pos_w = sensor.data.pos_w.torch[0].cpu() + + # pos_w.z should be near the body height, NOT body_height + offset + assert abs(pos_w[2].item() - body_pos[2]) < 1.0, ( + f"data.pos_w.z = {pos_w[2].item():.2f}, expected near body height {body_pos[2]}." + f" If pos_w.z ≈ {body_pos[2] + offset_z}, the offset was incorrectly baked into the FrameView." + ) + + # ray_hits should be near z=0 (ground plane) + hits_z = sensor.data.ray_hits_w.torch[0, :, 2].cpu() + valid = hits_z[~torch.isinf(hits_z)] + assert valid.numel() > 0, "Expected the offset rays to hit the ground plane" + assert valid.abs().max().item() < 2.0, ( + f"Ray hits z range [{valid.min().item():.2f}, {valid.max().item():.2f}] — expected near ground (z≈0)." + ) + + # height_scan observation: pos_w_z - hit_z - 0.5 should be small, not ~20 + height_obs = pos_w[2].item() - valid.mean().item() - 0.5 + assert abs(height_obs) < 5.0, ( + f"height_scan observation = {height_obs:.2f}, expected near 0." + f" If ≈{offset_z}, the offset leaked into data.pos_w." + ) + + @pytest.mark.isaacsim_ci def test_ray_caster_tracks_physics_body_parent_motion(sim_ground): """RayCaster pose must follow its physics-body parent after simulation steps.""" @@ -285,8 +286,10 @@ def test_ray_caster_tracks_physics_body_parent_motion(sim_ground): dt = 0.01 parent_path = "/World/PhysicsParent" + expected_pos = (3.0, 4.0, 5.0) + stage = sim_utils.get_current_stage() - sim_utils.create_prim(parent_path, "Xform", translation=(0.0, 0.0, 5.0), stage=stage) + sim_utils.create_prim(parent_path, "Xform", translation=expected_pos, stage=stage) parent_prim = stage.GetPrimAtPath(parent_path) UsdPhysics.RigidBodyAPI.Apply(parent_prim) UsdPhysics.ArticulationRootAPI.Apply(parent_prim) @@ -307,6 +310,11 @@ def test_ray_caster_tracks_physics_body_parent_motion(sim_ground): sim.reset() sensor.update(dt, force_recompute=True) pos_before = sensor.data.pos_w.torch[0].clone() + np.testing.assert_allclose( + pos_before.cpu().numpy(), expected_pos, atol=0.15, err_msg="sensor pos_w must match initial parent position" + ) + hit_z = sensor.data.ray_hits_w.torch[0, 0, 2].item() + assert abs(hit_z) < 0.5, f"downward ray should hit near z=0, got z={hit_z}" for _ in range(100): sim.step(render=False) diff --git a/source/isaaclab/test/sensors/test_sensor_base.py b/source/isaaclab/test/sensors/test_sensor_base.py index 05d523c4c491..1e6538f80c5f 100644 --- a/source/isaaclab/test/sensors/test_sensor_base.py +++ b/source/isaaclab/test/sensors/test_sensor_base.py @@ -25,6 +25,7 @@ import isaaclab.sim as sim_utils from isaaclab.sensors import SensorBase, SensorBaseCfg +from isaaclab.test.utils import DeviceScope, test_devices from isaaclab.utils import configclass pytestmark = pytest.mark.integration @@ -132,7 +133,7 @@ def create_dummy_sensor(request, device): sim.clear_instance() -@pytest.mark.parametrize("device", ("cpu", "cuda")) +@pytest.mark.parametrize("device", test_devices(DeviceScope.DEFAULT_CUDA)) def test_sensor_init(create_dummy_sensor, device): """Test that the sensor initializes, steps without update, and forces update.""" @@ -168,7 +169,7 @@ def test_sensor_init(create_dummy_sensor, device): ) -@pytest.mark.parametrize("device", ("cpu", "cuda")) +@pytest.mark.parametrize("device", test_devices(DeviceScope.DEFAULT_CUDA)) def test_sensor_update_rate(create_dummy_sensor, device): """Test that the update_rate configuration parameter works by checking the value of the data is old for an update period of 2. @@ -196,7 +197,7 @@ def test_sensor_update_rate(create_dummy_sensor, device): expected_value += i % 2 -@pytest.mark.parametrize("device", ("cpu", "cuda")) +@pytest.mark.parametrize("device", test_devices(DeviceScope.CPU)) def test_sensor_reset(create_dummy_sensor, device): """Test that sensor can be reset for all or partial env ids.""" sensor_cfg, sim, dt = create_dummy_sensor @@ -246,9 +247,9 @@ def test_sensor_reset(create_dummy_sensor, device): ) -@pytest.mark.parametrize("device", ("cpu", "cuda")) -def test_repeated_data_reads_update_backend_once(create_dummy_sensor, device): - """Test that repeated data reads update the backend once per sensor update.""" +@pytest.mark.parametrize("device", test_devices(DeviceScope.DEFAULT_CUDA)) +def test_reset_invalidates_cached_sensor_data(create_dummy_sensor, device): + """Test that repeated reads refresh once per update and resets each invalidate cached data once.""" sensor_cfg, sim, dt = create_dummy_sensor sensor = DummySensor(cfg=sensor_cfg) sim.step() @@ -258,19 +259,8 @@ def test_repeated_data_reads_update_backend_once(create_dummy_sensor, device): _ = sensor.data backend_update_count = sensor.backend_update_count _ = sensor.data - assert sensor.backend_update_count == backend_update_count - -@pytest.mark.parametrize("device", ("cpu", "cuda")) -def test_reset_invalidates_cached_sensor_data(create_dummy_sensor, device): - """Test that full and partial resets each invalidate cached sensor data once.""" - sensor_cfg, sim, _ = create_dummy_sensor - sensor = DummySensor(cfg=sensor_cfg) - sim.step() - sim.reset() - _ = sensor.data - sensor.reset() backend_update_count = sensor.backend_update_count _ = sensor.data @@ -293,22 +283,6 @@ def test_reset_invalidates_cached_sensor_data(create_dummy_sensor, device): ) -@pytest.mark.parametrize("device", ("cpu", "cuda")) -def test_force_recompute_bypasses_sensor_data_cache(create_dummy_sensor, device): - """Test that forced recomputation bypasses a consumed freshness generation.""" - sensor_cfg, sim, _ = create_dummy_sensor - sensor = DummySensor(cfg=sensor_cfg) - sim.step() - sim.reset() - _ = sensor.data - backend_update_count = sensor.backend_update_count - - sensor._update_outdated_buffers(force_recompute=True) - _ = sensor.data - - assert sensor.backend_update_count == backend_update_count + 1 - - @pytest.mark.parametrize("device", ("cuda",)) def test_repeated_data_reads_are_graph_safe(create_dummy_sensor, device): """Test that CUDA graph capture records one backend refresh for repeated reads.""" diff --git a/source/isaaclab/test/sensors/test_tiled_camera.py b/source/isaaclab/test/sensors/test_tiled_camera.py index 812235a1e5f2..573345314a49 100644 --- a/source/isaaclab/test/sensors/test_tiled_camera.py +++ b/source/isaaclab/test/sensors/test_tiled_camera.py @@ -10,8 +10,8 @@ TiledCamera is now a thin subclass of Camera that emits a DeprecationWarning. All substantive Camera tests live in ``test_camera.py``. This file only verifies -that the deprecation mechanism works correctly and that TiledCamera remains a -functional Camera alias. +that the deprecation mechanism works correctly and that TiledCamera remains an +initializable Camera alias. """ """Launch Isaac Sim Simulator first.""" @@ -35,6 +35,7 @@ import isaaclab.sim as sim_utils from isaaclab.sensors.camera import Camera, CameraCfg, TiledCamera, TiledCameraCfg +from isaaclab.test.utils import DeviceScope, test_devices pytestmark = [pytest.mark.integration, pytest.mark.rendering] @@ -71,10 +72,10 @@ def setup_camera(device) -> tuple[sim_utils.SimulationContext, CameraCfg, float] sim.clear_instance() -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("device", test_devices(DeviceScope.DEFAULT_CUDA)) @pytest.mark.isaacsim_ci def test_tiled_camera_deprecation_warning(setup_camera, device): - """TiledCamera instantiation emits a DeprecationWarning.""" + """TiledCamera instantiation emits a DeprecationWarning and yields a working Camera.""" sim, camera_cfg, dt = setup_camera with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") @@ -82,12 +83,14 @@ def test_tiled_camera_deprecation_warning(setup_camera, device): deprecation_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)] assert len(deprecation_warnings) >= 1 assert "TiledCamera is deprecated" in str(deprecation_warnings[0].message) + assert isinstance(camera, Camera) + # the alias must still run Camera initialization + sim.reset() + assert camera.is_initialized del camera -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -@pytest.mark.isaacsim_ci -def test_tiled_camera_cfg_deprecation_warning(setup_camera, device): +def test_tiled_camera_cfg_deprecation_warning(): """TiledCameraCfg instantiation emits a DeprecationWarning.""" with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") @@ -105,56 +108,6 @@ def test_tiled_camera_cfg_deprecation_warning(setup_camera, device): assert "TiledCameraCfg is deprecated" in str(deprecation_warnings[0].message) -@pytest.mark.filterwarnings("ignore::DeprecationWarning") -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -@pytest.mark.isaacsim_ci -def test_tiled_camera_is_camera_subclass(setup_camera, device): - """TiledCamera is a subclass of Camera, so isinstance checks work.""" - sim, camera_cfg, dt = setup_camera - camera = TiledCamera(camera_cfg) - assert isinstance(camera, Camera) - assert isinstance(camera, TiledCamera) - del camera - - -@pytest.mark.filterwarnings("ignore::DeprecationWarning") -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -@pytest.mark.isaacsim_ci -def test_tiled_camera_basic_functionality(setup_camera, device): - """TiledCamera produces correct output (proving it delegates to Camera).""" - sim, camera_cfg, dt = setup_camera - # Create camera - camera = TiledCamera(camera_cfg) - # Play sim - sim.reset() - # Check if camera is initialized - assert camera.is_initialized - # Check if camera prim is set correctly and that it is a camera prim - assert camera._sensor_prims[0].GetPath().pathString == camera_cfg.prim_path - assert isinstance(camera._sensor_prims[0], UsdGeom.Camera) - - # Check buffers that exists and have correct shapes - assert camera.data.pos_w.torch.shape == (1, 3) - assert camera.data.intrinsic_matrices.torch.shape == (1, 3, 3) - assert camera.data.image_shape == (camera_cfg.height, camera_cfg.width) - - # Simulate physics - for _ in range(5): - # perform rendering - sim.step() - # update camera - camera.update(dt) - # check image data - for im_type, im_data in camera.data.output.items(): - if im_type == "rgb": - assert im_data.shape == (1, camera_cfg.height, camera_cfg.width, 3) - assert (im_data / 255.0).mean() > 0.0 - elif im_type == "distance_to_camera": - assert im_data.shape == (1, camera_cfg.height, camera_cfg.width, 1) - assert im_data.mean() > 0.0 - del camera - - """ Helper functions. """ diff --git a/source/isaaclab/test/sensors/test_tiled_camera_env.py b/source/isaaclab/test/sensors/test_tiled_camera_env.py deleted file mode 100644 index 23a94306cfb2..000000000000 --- a/source/isaaclab/test/sensors/test_tiled_camera_env.py +++ /dev/null @@ -1,142 +0,0 @@ -# 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 - -"""Launch Isaac Sim Simulator first.""" - -import argparse -import sys - -from isaaclab.app import AppLauncher - -# add argparse arguments -parser = argparse.ArgumentParser( - description=("Test Isaac-Cartpole-Camera-Direct environment with different resolutions and number of environments.") -) -parser.add_argument("--save_images", action="store_true", default=False, help="Save out renders to file.") -parser.add_argument("unittest_args", nargs="*") - -# parse the arguments -args_cli = parser.parse_args() -# set the sys.argv to the unittest_args -sys.argv[1:] = args_cli.unittest_args - -# launch the simulator -simulation_app = AppLauncher(headless=True, enable_cameras=True).app - -"""Rest everything follows.""" - -import sys - -import gymnasium as gym -import pytest - -import isaaclab.sim as sim_utils -from isaaclab.envs import DirectRLEnv, DirectRLEnvCfg, ManagerBasedRLEnv, ManagerBasedRLEnvCfg -from isaaclab.sensors import save_images_to_file - -import isaaclab_tasks # noqa: F401 -from isaaclab_tasks.utils.parse_cfg import parse_env_cfg - -pytestmark = [pytest.mark.integration, pytest.mark.rendering, pytest.mark.isaacsim_ci] - - -@pytest.mark.skip(reason="Currently takes too long to run") -def test_tiled_resolutions_tiny(): - """Define settings for resolution and number of environments""" - num_envs = 1024 - tile_widths = range(32, 48) - tile_heights = range(32, 48) - _launch_tests(tile_widths, tile_heights, num_envs) - - -@pytest.mark.skip(reason="Currently takes too long to run") -def test_tiled_resolutions_small(): - """Define settings for resolution and number of environments""" - num_envs = 300 - tile_widths = range(128, 156) - tile_heights = range(128, 156) - _launch_tests(tile_widths, tile_heights, num_envs) - - -@pytest.mark.skip(reason="Currently takes too long to run") -def test_tiled_resolutions_medium(): - """Define settings for resolution and number of environments""" - num_envs = 64 - tile_widths = range(320, 400, 20) - tile_heights = range(320, 400, 20) - _launch_tests(tile_widths, tile_heights, num_envs) - - -@pytest.mark.skip(reason="Currently takes too long to run") -def test_tiled_resolutions_large(): - """Define settings for resolution and number of environments""" - num_envs = 4 - tile_widths = range(480, 640, 40) - tile_heights = range(480, 640, 40) - _launch_tests(tile_widths, tile_heights, num_envs) - - -@pytest.mark.skip(reason="Currently takes too long to run") -def test_tiled_resolutions_edge_cases(): - """Define settings for resolution and number of environments""" - num_envs = 1000 - tile_widths = [12, 67, 93, 147] - tile_heights = [12, 67, 93, 147] - _launch_tests(tile_widths, tile_heights, num_envs) - - -@pytest.mark.skip(reason="Currently takes too long to run") -def test_tiled_num_envs_edge_cases(): - """Define settings for resolution and number of environments""" - num_envs = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 53, 359, 733, 927] - tile_widths = [67, 93, 147] - tile_heights = [67, 93, 147] - for n_envs in num_envs: - _launch_tests(tile_widths, tile_heights, n_envs) - - -# Helper functions - - -def _launch_tests(tile_widths: range, tile_heights: range, num_envs: int): - """Run through different resolutions for tiled rendering""" - device = "cuda:0" - task_name = "Isaac-Cartpole-Camera-Direct" - # iterate over all registered environments - for width in tile_widths: - for height in tile_heights: - # create a new stage - sim_utils.create_new_stage() - # parse configuration - env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg = parse_env_cfg(task_name, device=device, num_envs=num_envs) - env_cfg.scene.tiled_camera.width = width - env_cfg.scene.tiled_camera.height = height - print(f">>> Running test for resolution: {width} x {height}") - # check environment - _run_environment(env_cfg) - # close the environment - print(f">>> Closing environment: {task_name}") - print("-" * 80) - - -def _run_environment(env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg): - """Run environment and capture a rendered image.""" - # create environment - env: ManagerBasedRLEnv | DirectRLEnv = gym.make("Isaac-Cartpole-Camera-Direct", cfg=env_cfg) - # this flag is necessary to prevent a bug where the simulation gets stuck randomly when running the - # test on many environments. - env.sim.set_setting("/physics/cooking/ujitsoCollisionCooking", False) - - # reset environment - obs, _ = env.reset() - # save image - if args_cli.save_images: - save_images_to_file( - obs["policy"] + 0.93, - f"output_{env.num_envs}_{env_cfg.scene.tiled_camera.width}x{env_cfg.scene.tiled_camera.height}.png", - ) - - # close the environment - env.close() diff --git a/source/isaaclab/test/sensors/test_update_ray_caster_kernel.py b/source/isaaclab/test/sensors/test_update_ray_caster_kernel.py index 1eaa3592c519..05b540459a99 100644 --- a/source/isaaclab/test/sensors/test_update_ray_caster_kernel.py +++ b/source/isaaclab/test/sensors/test_update_ray_caster_kernel.py @@ -381,28 +381,24 @@ def test_ray_cast_drift_yaw_mode(self): np.testing.assert_allclose(starts_w[0, 0], expected_start, atol=ATOL) def test_ray_cast_drift_base_mode(self): - """Base mode: ray_cast_drift XY is rotated by full combined_quat, Z is NOT applied. + """Base mode: ray_cast_drift is rotated by the full combined_quat, then only its XY is applied. - Sensor pitched 90° around Y, drift = (1.0, 0.0, 0.0). - Full rotation of (1,0,0) by 90° pitch around Y → (0, 0, -1). - pos_drifted = (combined_pos.x + 0, combined_pos.y + 0, combined_pos.z) — both XY of - rotated drift happen to be 0 in this case. + Sensor pitched 90° around Y, drift = (0.0, 0.0, 1.0). + Full rotation of (0,0,1) by 90° pitch around Y → (1, 0, 0), so the start moves by +1 in x. + A yaw-only rotation (identity here) or an ignored drift would leave the start at (0, 0, 5). """ pitch90 = _euler_to_quat_xyzw(0, math.pi / 2, 0) inputs = _make_inputs( view_pos=(0.0, 0.0, 5.0), view_quat=pitch90, - ray_cast_drift=(1.0, 0.0, 0.0), + ray_cast_drift=(0.0, 0.0, 1.0), ray_start=(0.0, 0.0, 0.0), ray_dir=(0.0, 0.0, -1.0), ) _, _, starts_w, _ = _launch_kernel(*inputs, alignment_mode=2, num_envs=1, num_rays=1) - rot_drift = _quat_rotate(pitch90, (1, 0, 0)) # (0, 0, -1) - # pos_drifted = (0 + rot_drift.x, 0 + rot_drift.y, 5) = (0, 0, 5) - # local_start (0,0,0) rotated by pitch90 → still (0,0,0) - expected_start = np.array([rot_drift[0], rot_drift[1], 5.0]) - np.testing.assert_allclose(starts_w[0, 0], expected_start, atol=ATOL) + # local_start (0,0,0) rotated by pitch90 → still (0,0,0); Z comes from combined_pos + np.testing.assert_allclose(starts_w[0, 0], [1.0, 0.0, 5.0], atol=ATOL) def test_env_mask_skips_masked_envs(self): """Masked-out environments retain sentinel values in output buffers. diff --git a/source/isaaclab/test/sim/check_meshes.py b/source/isaaclab/test/sim/check_meshes.py deleted file mode 100644 index 67386a8488b3..000000000000 --- a/source/isaaclab/test/sim/check_meshes.py +++ /dev/null @@ -1,168 +0,0 @@ -# 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 - -"""This script demonstrates different rigid and deformable meshes in the scene. - -It randomly spawns different types of meshes in the scene. The meshes can be rigid or deformable -based on the probability of 0.5. The rigid meshes are spawned with rigid body and collision properties, -while the deformable meshes are spawned with deformable body properties. - -.. code-block:: bash - - # Usage - uv run python source/isaaclab/test/sim/check_meshes.py - -""" - -"""Launch Isaac Sim Simulator first.""" - - -import argparse - -from isaaclab.app import AppLauncher - -# create argparser -parser = argparse.ArgumentParser(description="This script demonstrates different meshes in the scene.") -# append AppLauncher cli args -AppLauncher.add_app_launcher_args(parser) -# parse the arguments -args_cli = parser.parse_args() -# launch omniverse app -app_launcher = AppLauncher(args_cli) -simulation_app = app_launcher.app - -"""Rest everything follows.""" - -import random - -import numpy as np -import torch -import tqdm -from isaaclab_physx.sim.schemas import PhysxCollisionCfg - -import isaaclab.sim as sim_utils - - -def define_origins(num_origins: int, spacing: float) -> list[list[float]]: - """Defines the origins of the the scene.""" - # create tensor based on number of environments - env_origins = torch.zeros(num_origins, 3) - # create a grid of origins - num_cols = np.floor(np.sqrt(num_origins)) - num_rows = np.ceil(num_origins / num_cols) - xx, yy = torch.meshgrid(torch.arange(num_rows), torch.arange(num_cols), indexing="xy") - env_origins[:, 0] = spacing * xx.flatten()[:num_origins] - spacing * (num_rows - 1) / 2 - env_origins[:, 1] = spacing * yy.flatten()[:num_origins] - spacing * (num_cols - 1) / 2 - env_origins[:, 2] = torch.rand(num_origins) + 1.0 - # return the origins - return env_origins.tolist() - - -def design_scene(): - """Designs the scene by spawning ground plane, light, and deformable meshes.""" - # Ground-plane - cfg_ground = sim_utils.GroundPlaneCfg() - cfg_ground.func("/World/defaultGroundPlane", cfg_ground) - - # spawn distant light - cfg_light = sim_utils.DomeLightCfg( - intensity=3000.0, - color=(0.75, 0.75, 0.75), - ) - cfg_light.func("/World/light", cfg_light) - - # create new xform prims for all objects to be spawned under - origins = define_origins(num_origins=4, spacing=5.5) - for idx, origin in enumerate(origins): - sim_utils.create_prim(f"/World/Origin{idx:02d}", "Xform", translation=origin) - - # spawn a red cone - cfg_sphere = sim_utils.MeshSphereCfg( - radius=0.25, - mass_props=sim_utils.MassCfg(mass=1.0), - visual_material=sim_utils.PreviewSurfaceCfg(), - ) - cfg_cuboid = sim_utils.MeshCuboidCfg( - size=(0.2, 0.2, 0.2), - mass_props=sim_utils.MassCfg(mass=1.0), - visual_material=sim_utils.PreviewSurfaceCfg(), - ) - cfg_cylinder = sim_utils.MeshCylinderCfg( - radius=0.15, - height=0.5, - mass_props=sim_utils.MassCfg(mass=1.0), - visual_material=sim_utils.PreviewSurfaceCfg(), - ) - cfg_capsule = sim_utils.MeshCapsuleCfg( - radius=0.15, - height=0.5, - mass_props=sim_utils.MassCfg(mass=1.0), - visual_material=sim_utils.PreviewSurfaceCfg(), - ) - cfg_cone = sim_utils.MeshConeCfg( - radius=0.15, - height=0.5, - mass_props=sim_utils.MassCfg(mass=1.0), - visual_material=sim_utils.PreviewSurfaceCfg(), - ) - # create a dictionary of all the objects to be spawned - objects_cfg = { - "sphere": cfg_sphere, - "cuboid": cfg_cuboid, - "cylinder": cfg_cylinder, - "capsule": cfg_capsule, - "cone": cfg_cone, - } - - # Create separate groups of deformable objects - origins = define_origins(num_origins=25, spacing=0.5) - print("[INFO]: Spawning objects...") - # Iterate over all the origins and randomly spawn objects - for idx, origin in tqdm.tqdm(enumerate(origins), total=len(origins)): - # randomly select an object to spawn - obj_name = random.choice(list(objects_cfg.keys())) - obj_cfg = objects_cfg[obj_name] - # randomly decide if it is rigid or deformable - if random.random() < 0.5: - obj_cfg.rigid_props = None - obj_cfg.deformable_props = sim_utils.DeformableBodyPropertiesCfg() - obj_cfg.collision_props = PhysxCollisionCfg(rest_offset=0.0) - else: - obj_cfg.deformable_props = None - obj_cfg.rigid_props = sim_utils.UsdPhysicsRigidBodyCfg() - obj_cfg.collision_props = sim_utils.UsdPhysicsCollisionCfg() - # randomize the color - obj_cfg.visual_material.diffuse_color = (random.random(), random.random(), random.random()) - # spawn the object - obj_cfg.func(f"/World/Origin[^/]+/Object{idx:02d}", obj_cfg, translation=origin) - - -def main(): - """Main function.""" - # Initialize the simulation context - sim_cfg = sim_utils.SimulationCfg(dt=0.01) - sim = sim_utils.SimulationContext(sim_cfg) - # Set main camera - sim.set_camera_view([8.0, 8.0, 6.0], [0.0, 0.0, 0.0]) - - # Design scene by adding assets to it - design_scene() - - # Play the simulator - sim.reset() - # Now we are ready! - print("[INFO]: Setup complete...") - - # Simulate physics - while simulation_app.is_running(): - # perform step - sim.step() - - -if __name__ == "__main__": - # run the main function - main() - # close sim app - simulation_app.close() diff --git a/source/isaaclab/test/sim/frame_view_contract_utils.py b/source/isaaclab/test/sim/frame_view_contract_utils.py index 7ff53025415e..831316392e2b 100644 --- a/source/isaaclab/test/sim/frame_view_contract_utils.py +++ b/source/isaaclab/test/sim/frame_view_contract_utils.py @@ -43,6 +43,7 @@ class ViewBundle(NamedTuple): import torch import warp as wp +from isaaclab.test.utils import test_devices from isaaclab.utils.warp import ProxyArray CHILD_OFFSET = (0.1, 0.0, 0.05) @@ -81,7 +82,7 @@ def _wp_vec4f(data, device="cpu"): # ================================================================== -@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +@pytest.mark.parametrize("device", test_devices()) def test_world_pose_equals_parent_plus_offset(device, view_factory): """world_pose == parent_pos + local offset (identity parent orientation).""" bundle = view_factory(num_envs=4, device=device) @@ -95,7 +96,7 @@ def test_world_pose_equals_parent_plus_offset(device, view_factory): bundle.teardown() -@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +@pytest.mark.parametrize("device", test_devices()) def test_local_pose_equals_structural_offset(device, view_factory): """local_pose == the authored offset (0.1, 0, 0.05) for every prim.""" bundle = view_factory(num_envs=4, device=device) @@ -110,28 +111,7 @@ def test_local_pose_equals_structural_offset(device, view_factory): bundle.teardown() -@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) -def test_local_differs_from_world(device, view_factory): - """local != world when parent is not at the origin. - - Asserts |world - local| > 0.5 to catch any implementation that returns - world as local. The parent is offset from the origin so the z-component - alone provides > 0.5 difference. - """ - bundle = view_factory(num_envs=2, device=device) - try: - world_pos = _t(bundle.view.get_world_poses()[0]) - local_pos = _t(bundle.view.get_local_poses()[0]) - - diff = (world_pos - local_pos).abs().max().item() - assert diff > 0.5, ( - f"Expected |world - local| > 0.5, got {diff:.4f}. world={world_pos.tolist()}, local={local_pos.tolist()}" - ) - finally: - bundle.teardown() - - -@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +@pytest.mark.parametrize("device", test_devices()) def test_local_stable_after_parent_move(device, view_factory): """Moving the parent changes world but NOT local.""" bundle = view_factory(num_envs=2, device=device) @@ -145,7 +125,7 @@ def test_local_stable_after_parent_move(device, view_factory): bundle.teardown() -@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +@pytest.mark.parametrize("device", test_devices()) def test_world_tracks_parent_move(device, view_factory): """Moving the parent shifts world poses by the same amount.""" bundle = view_factory(num_envs=2, device=device) @@ -161,7 +141,7 @@ def test_world_tracks_parent_move(device, view_factory): bundle.teardown() -@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +@pytest.mark.parametrize("device", test_devices()) def test_indexed_get_returns_correct_subset(device, view_factory): """Indexed get (out-of-order) returns exact copies for both world and local.""" bundle = view_factory(num_envs=5, device=device) @@ -186,7 +166,7 @@ def test_indexed_get_returns_correct_subset(device, view_factory): # ================================================================== -@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +@pytest.mark.parametrize("device", test_devices()) def test_set_world_roundtrip(device, view_factory): """set_world_poses -> get_world_poses returns the same values.""" bundle = view_factory(num_envs=2, device=device) @@ -203,7 +183,7 @@ def test_set_world_roundtrip(device, view_factory): bundle.teardown() -@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +@pytest.mark.parametrize("device", test_devices()) def test_set_local_roundtrip(device, view_factory): """set_local_poses -> get_local_poses returns the same values.""" bundle = view_factory(num_envs=2, device=device) @@ -220,51 +200,16 @@ def test_set_local_roundtrip(device, view_factory): bundle.teardown() -@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) -def test_set_world_does_not_move_parent(device, view_factory): - """set_world_poses must not modify the parent prim/body position.""" - bundle = view_factory(num_envs=2, device=device) - try: - parent_before = bundle.get_parent_pos(2, device).clone() - with bundle.view.xform_world_space_writer() as w: - w.set_poses( - _wp_vec3f([[99.0, 99.0, 99.0], [88.0, 88.0, 88.0]], device=device), - _wp_vec4f([[0.0, 0.0, 0.0, 1.0]] * 2, device=device), - ) - parent_after = bundle.get_parent_pos(2, device) - - torch.testing.assert_close(parent_after, parent_before, atol=0, rtol=0) - finally: - bundle.teardown() - - -@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) -def test_set_local_does_not_move_parent(device, view_factory): - """set_local_poses must not modify the parent prim/body position.""" - bundle = view_factory(num_envs=2, device=device) - try: - parent_before = bundle.get_parent_pos(2, device).clone() - with bundle.view.xform_local_space_writer() as w: - w.set_poses( - _wp_vec3f([[0.5, 0.5, 0.5], [1.0, 1.0, 1.0]], device=device), - _wp_vec4f([[0.0, 0.0, 0.0, 1.0]] * 2, device=device), - ) - parent_after = bundle.get_parent_pos(2, device) - - torch.testing.assert_close(parent_after, parent_before, atol=0, rtol=0) - finally: - bundle.teardown() - - -@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +@pytest.mark.parametrize("device", test_devices()) def test_set_world_updates_local(device, view_factory): - """After set_world_poses, get_local_poses reflects the new offset. + """After set_world_poses, get_local_poses reflects the new offset and the parent is unmoved. Uses non-axis-aligned offsets to catch coordinate swap bugs. """ bundle = view_factory(num_envs=2, device=device) try: parent_pos = bundle.get_parent_pos(2, device) + parent_before = parent_pos.clone() desired_offset = torch.tensor([[0.3, 0.7, 0.2], [0.8, 0.1, 0.6]], device=device) new_world = parent_pos + desired_offset @@ -276,19 +221,23 @@ def test_set_world_updates_local(device, view_factory): local_pos = _t(bundle.view.get_local_poses()[0]) torch.testing.assert_close(local_pos, desired_offset, atol=ATOL, rtol=0) + # set_world_poses must not modify the parent prim/body position + parent_after = bundle.get_parent_pos(2, device) + torch.testing.assert_close(parent_after, parent_before, atol=0, rtol=0) finally: bundle.teardown() -@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +@pytest.mark.parametrize("device", test_devices()) def test_set_local_updates_world(device, view_factory): - """After set_local_poses, get_world_poses == parent + new_local. + """After set_local_poses, get_world_poses == parent + new_local and the parent is unmoved. Uses non-axis-aligned offsets to catch coordinate swap bugs. """ bundle = view_factory(num_envs=2, device=device) try: parent_pos = bundle.get_parent_pos(2, device) + parent_before = parent_pos.clone() new_offset = torch.tensor([[0.4, 0.9, 0.15], [0.6, 0.2, 0.85]], device=device) with bundle.view.xform_local_space_writer() as w: w.set_poses( @@ -298,11 +247,14 @@ def test_set_local_updates_world(device, view_factory): world_pos = _t(bundle.view.get_world_poses()[0]) torch.testing.assert_close(world_pos, parent_pos + new_offset, atol=ATOL, rtol=0) + # set_local_poses must not modify the parent prim/body position + parent_after = bundle.get_parent_pos(2, device) + torch.testing.assert_close(parent_after, parent_before, atol=0, rtol=0) finally: bundle.teardown() -@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +@pytest.mark.parametrize("device", test_devices()) def test_set_world_partial_position_only(device, view_factory): """Setting only positions: new positions written, orientations preserved.""" bundle = view_factory(num_envs=2, device=device) @@ -319,7 +271,7 @@ def test_set_world_partial_position_only(device, view_factory): bundle.teardown() -@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +@pytest.mark.parametrize("device", test_devices()) def test_set_world_partial_orientation_only(device, view_factory): """Setting only orientations: new orientations written, positions preserved.""" bundle = view_factory(num_envs=2, device=device) @@ -336,7 +288,7 @@ def test_set_world_partial_orientation_only(device, view_factory): bundle.teardown() -@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +@pytest.mark.parametrize("device", test_devices()) def test_set_local_partial_position_only(device, view_factory): """Setting only local translations: new translations written, orientations preserved.""" bundle = view_factory(num_envs=2, device=device) @@ -353,7 +305,7 @@ def test_set_local_partial_position_only(device, view_factory): bundle.teardown() -@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +@pytest.mark.parametrize("device", test_devices()) def test_set_world_indexed_only_affects_subset(device, view_factory): """Indexed set_world_poses writes requested indices, leaves others untouched.""" bundle = view_factory(num_envs=4, device=device) @@ -373,7 +325,7 @@ def test_set_world_indexed_only_affects_subset(device, view_factory): bundle.teardown() -@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +@pytest.mark.parametrize("device", test_devices()) def test_return_types_are_torcharray(device, view_factory): """Public API contract — every backend returns ProxyArray from the pose and scale getters.""" bundle = view_factory(num_envs=2, device=device) @@ -444,46 +396,46 @@ def test_return_types_are_torcharray(device, view_factory): # ================================================================== -@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) -def test_local_scales_default_identity(device, view_factory): - """Local scales are (1, 1, 1) by default (no authored scale transforms).""" +@pytest.mark.parametrize("device", test_devices()) +def test_scales_default_identity(device, view_factory): + """Local and world scales are (1, 1, 1) by default (no authored scale transforms).""" bundle = view_factory(num_envs=2, device=device) try: - scales = _t(bundle.view.get_local_scales()) expected = torch.ones(2, 3, device=device) + scales = _t(bundle.view.get_local_scales()) torch.testing.assert_close(scales, expected, atol=ATOL, rtol=0) - finally: - bundle.teardown() - - -@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) -def test_world_scales_default_identity(device, view_factory): - """World scales are (1, 1, 1) by default (no authored scale transforms).""" - bundle = view_factory(num_envs=2, device=device) - try: scales = _t(bundle.view.get_world_scales()) - expected = torch.ones(2, 3, device=device) torch.testing.assert_close(scales, expected, atol=ATOL, rtol=0) finally: bundle.teardown() -@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +@pytest.mark.parametrize("device", test_devices()) def test_local_scales_roundtrip(device, view_factory): - """Writing scales through the local-space writer roundtrips via ``get_local_scales``.""" + """Writing scales through the local-space writer roundtrips via ``get_local_scales`` and leaves + local poses unchanged.""" bundle = view_factory(num_envs=2, device=device) try: + local_pos_before = _t(bundle.view.get_local_poses()[0]).clone() + local_ori_before = _t(bundle.view.get_local_poses()[1]).clone() + new_scales = _wp_vec3f([[2.0, 3.0, 4.0], [0.5, 1.5, 2.5]], device=device) with bundle.view.xform_local_space_writer() as w: w.set_scales(new_scales) ret_scales = _t(bundle.view.get_local_scales()) torch.testing.assert_close(ret_scales, _t(new_scales), atol=ATOL, rtol=0) + + # changing scales does not change local pose translations/orientations + local_pos_after = _t(bundle.view.get_local_poses()[0]) + local_ori_after = _t(bundle.view.get_local_poses()[1]) + torch.testing.assert_close(local_pos_after, local_pos_before, atol=ATOL, rtol=0) + torch.testing.assert_close(local_ori_after, local_ori_before, atol=ATOL, rtol=0) finally: bundle.teardown() -@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +@pytest.mark.parametrize("device", test_devices()) def test_world_scales_roundtrip(device, view_factory): """Writing scales through the world-space writer roundtrips via ``get_world_scales``.""" bundle = view_factory(num_envs=2, device=device) @@ -496,51 +448,3 @@ def test_world_scales_roundtrip(device, view_factory): torch.testing.assert_close(ret_scales, _t(new_scales), atol=ATOL, rtol=0) finally: bundle.teardown() - - -@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) -def test_local_scales_do_not_affect_local_poses(device, view_factory): - """Changing scales does not change local pose translations/orientations.""" - bundle = view_factory(num_envs=2, device=device) - try: - local_pos_before = _t(bundle.view.get_local_poses()[0]).clone() - local_ori_before = _t(bundle.view.get_local_poses()[1]).clone() - - new_scales = _wp_vec3f([[3.0, 3.0, 3.0], [5.0, 5.0, 5.0]], device=device) - with bundle.view.xform_local_space_writer() as w: - w.set_scales(new_scales) - - local_pos_after = _t(bundle.view.get_local_poses()[0]) - local_ori_after = _t(bundle.view.get_local_poses()[1]) - - torch.testing.assert_close(local_pos_after, local_pos_before, atol=ATOL, rtol=0) - torch.testing.assert_close(local_ori_after, local_ori_before, atol=ATOL, rtol=0) - finally: - bundle.teardown() - - -@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) -def test_scale_getters_return_proxyarray(device, view_factory): - """Public API contract -- scale getters return ProxyArray.""" - bundle = view_factory(num_envs=2, device=device) - try: - local_scales = bundle.view.get_local_scales() - assert isinstance(local_scales, ProxyArray), ( - f"get_local_scales() must return ProxyArray, got {type(local_scales).__name__}" - ) - world_scales = bundle.view.get_world_scales() - assert isinstance(world_scales, ProxyArray), ( - f"get_world_scales() must return ProxyArray, got {type(world_scales).__name__}" - ) - - indices = wp.array([0], dtype=wp.int32, device=bundle.view.device) - local_indexed = bundle.view.get_local_scales(indices) - assert isinstance(local_indexed, ProxyArray), ( - f"get_local_scales(indices) must return ProxyArray, got {type(local_indexed).__name__}" - ) - world_indexed = bundle.view.get_world_scales(indices) - assert isinstance(world_indexed, ProxyArray), ( - f"get_world_scales(indices) must return ProxyArray, got {type(world_indexed).__name__}" - ) - finally: - bundle.teardown() diff --git a/source/isaaclab/test/sim/test_articulation_fragments.py b/source/isaaclab/test/sim/test_articulation_fragments.py index 3b1f68cdffdc..5c01be0e74b9 100644 --- a/source/isaaclab/test/sim/test_articulation_fragments.py +++ b/source/isaaclab/test/sim/test_articulation_fragments.py @@ -34,68 +34,6 @@ def _make_xform(stage, path="/World/Art"): return stage.GetPrimAtPath(path) -# ------------------------------------------------------------------------------------- -# ArticulationRootFragment marker + metadata -# ------------------------------------------------------------------------------------- - - -def test_articulation_fragment_metadata_defaults(): - from isaaclab_physx.sim.schemas import PhysxArticulationCfg - - from isaaclab.sim.schemas import ArticulationRootFragment, SchemaFragment - - cfg = PhysxArticulationCfg(articulation_enabled=True) - assert isinstance(cfg, ArticulationRootFragment) and isinstance(cfg, SchemaFragment) - assert type(cfg)._usd_namespace == "physxArticulation" - assert type(cfg)._usd_applied_schema == "PhysxArticulationAPI" - assert cfg.func == "isaaclab.sim.schemas:apply_namespaced" - assert cfg.articulation_enabled is True and cfg.enabled_self_collisions is None - - -# ------------------------------------------------------------------------------------- -# PhysxArticulationCfg writes physxArticulation:* namespace -# ------------------------------------------------------------------------------------- - - -def test_physx_articulation_fragment_writes_physx_namespace(): - from isaaclab_physx.sim.schemas import PhysxArticulationCfg - - from isaaclab.sim.schemas import apply_namespaced - - sim_utils.create_new_stage() - SimulationContext(SimulationCfg(dt=0.01)) - stage = sim_utils.get_current_stage() - prim = _make_xform(stage, "/World/A1") - UsdPhysics.ArticulationRootAPI.Apply(prim) - apply_namespaced( - PhysxArticulationCfg(articulation_enabled=True, enabled_self_collisions=False, sleep_threshold=0.1), - "/World/A1", - stage, - ) - assert prim.GetAttribute("physxArticulation:articulationEnabled").Get() is True - assert prim.GetAttribute("physxArticulation:enabledSelfCollisions").Get() is False - assert abs(prim.GetAttribute("physxArticulation:sleepThreshold").Get() - 0.1) < 1e-6 - - -# ------------------------------------------------------------------------------------- -# NewtonArticulationCfg writes newton:* namespace -# ------------------------------------------------------------------------------------- - - -def test_newton_articulation_fragment_writes_newton_namespace(): - from isaaclab_newton.sim.schemas import NewtonArticulationCfg - - from isaaclab.sim.schemas import apply_namespaced - - sim_utils.create_new_stage() - SimulationContext(SimulationCfg(dt=0.01)) - stage = sim_utils.get_current_stage() - prim = _make_xform(stage, "/World/A2") - UsdPhysics.ArticulationRootAPI.Apply(prim) - apply_namespaced(NewtonArticulationCfg(self_collision_enabled=True), "/World/A2", stage) - assert prim.GetAttribute("newton:selfCollisionEnabled").Get() is True - - # ------------------------------------------------------------------------------------- # apply_articulation_root_properties dispatch (anchor + multi-namespace composition) # ------------------------------------------------------------------------------------- @@ -114,7 +52,12 @@ def test_apply_articulation_root_properties_composes_namespaces(): apply_articulation_root_properties( "/World/A3", [ - PhysxArticulationCfg(enabled_self_collisions=True, solver_position_iteration_count=8), + PhysxArticulationCfg( + articulation_enabled=True, + enabled_self_collisions=True, + solver_position_iteration_count=8, + sleep_threshold=0.1, + ), NewtonArticulationCfg(self_collision_enabled=True), ], stage, @@ -122,7 +65,10 @@ def test_apply_articulation_root_properties_composes_namespaces(): ) prim = stage.GetPrimAtPath("/World/A3") assert bool(UsdPhysics.ArticulationRootAPI(prim)) # presence-gated anchor applied + assert "PhysxArticulationAPI" in prim.GetAppliedSchemas() + assert prim.GetAttribute("physxArticulation:articulationEnabled").Get() is True assert prim.GetAttribute("physxArticulation:enabledSelfCollisions").Get() is True + assert abs(prim.GetAttribute("physxArticulation:sleepThreshold").Get() - 0.1) < 1e-6 assert prim.GetAttribute("physxArticulation:solverPositionIterationCount").Get() == 8 assert prim.GetAttribute("newton:selfCollisionEnabled").Get() is True @@ -218,30 +164,6 @@ def test_apply_articulation_root_properties_warns_on_nested_roots(caplog): assert "nested" in caplog.text.lower() -def test_apply_articulation_root_properties_processes_sibling_roots_via_expression(): - """Independent sibling articulations matched by one expression are all tuned.""" - from isaaclab_physx.sim.schemas import PhysxArticulationCfg - - from isaaclab.sim.schemas import apply_articulation_root_properties - - sim_utils.create_new_stage() - SimulationContext(SimulationCfg(dt=0.01)) - stage = sim_utils.get_current_stage() - UsdGeom.Xform.Define(stage, "/World/Rig") - left = UsdGeom.Xform.Define(stage, "/World/Rig/armL").GetPrim() - right = UsdGeom.Xform.Define(stage, "/World/Rig/armR").GetPrim() - UsdPhysics.ArticulationRootAPI.Apply(left) - UsdPhysics.ArticulationRootAPI.Apply(right) - - result = apply_articulation_root_properties( - "/World/Rig(/.*)?", [PhysxArticulationCfg(solver_position_iteration_count=8)], stage=stage - ) - - assert result is True - for prim in (left, right): - assert prim.GetAttribute("physxArticulation:solverPositionIterationCount").Get() == 8 - - def test_apply_articulation_root_properties_does_not_duplicate_instance_proxy_root(caplog): """A root hidden in an instance suppresses define-fresh but is skipped because proxies are read-only.""" from isaaclab_physx.sim.schemas import PhysxArticulationCfg @@ -327,46 +249,6 @@ def test_apply_articulation_root_properties_enables_existing_joint_and_relocates assert not root.HasAPI(UsdPhysics.ArticulationRootAPI) -# ------------------------------------------------------------------------------------- -# fix_root_link spawner-level flag: creates a fixed joint and reparents the root -# ------------------------------------------------------------------------------------- - - -def test_apply_articulation_root_properties_creates_fixed_joint_and_reparents_root(): - """fix_root_link=True with no existing fixed joint: a fixed joint is created and the - articulation root is moved from the rigid-body root link to its parent (PhysX parser - limitation -- a fixed joint on a rigid body is otherwise treated as a maximal-coordinate tree). - """ - from isaaclab_physx.sim.schemas import PhysxArticulationCfg - - from isaaclab.sim.schemas import apply_articulation_root_properties - - sim_utils.create_new_stage() - SimulationContext(SimulationCfg(dt=0.01)) - stage = sim_utils.get_current_stage() - # parent xform + a rigid-body root link carrying the articulation root - _make_xform(stage, "/World/Robot") - root = _make_xform(stage, "/World/Robot/base") - UsdPhysics.RigidBodyAPI.Apply(root) - UsdPhysics.ArticulationRootAPI.Apply(root) - # no existing global fixed joint -> the writer must create one - assert not any(p.IsA(UsdPhysics.FixedJoint) for p in stage.Traverse()) - - apply_articulation_root_properties( - "/World/Robot(/.*)?", - [PhysxArticulationCfg(articulation_enabled=True)], - stage, - fix_root_link=True, - ) - - parent = stage.GetPrimAtPath("/World/Robot") - # a fixed joint was created ... - assert any(p.IsA(UsdPhysics.FixedJoint) for p in stage.Traverse()) - # ... and the articulation root was moved from the root link to its parent - assert parent.HasAPI(UsdPhysics.ArticulationRootAPI) - assert not root.HasAPI(UsdPhysics.ArticulationRootAPI) - - def test_apply_articulation_root_properties_fix_root_link_requires_rigid_body(): """fix_root_link=True on a non-rigid-body root raises NotImplementedError: the writer cannot determine the first rigid body link to anchor the fixed joint to.""" @@ -429,6 +311,8 @@ def test_physx_and_newton_fragments_fix_root_link_keeps_single_root(): child = _make_xform(stage, "/World/Robot5/base") UsdPhysics.RigidBodyAPI.Apply(child) UsdPhysics.ArticulationRootAPI.Apply(child) + # no existing global fixed joint -> the writer must create one + assert not any(p.IsA(UsdPhysics.FixedJoint) for p in stage.Traverse()) apply_articulation_root_properties( "/World/Robot5(/.*)?", @@ -441,6 +325,7 @@ def test_physx_and_newton_fragments_fix_root_link_keeps_single_root(): ) parent = stage.GetPrimAtPath("/World/Robot5") + assert any(p.IsA(UsdPhysics.FixedJoint) for p in stage.Traverse()) # exactly one articulation root remains, and it is the (relocated) parent roots = [p for p in stage.Traverse() if p.HasAPI(UsdPhysics.ArticulationRootAPI)] assert len(roots) == 1 and roots[0] == parent @@ -772,22 +657,6 @@ def test_spawn_from_usd_file_applies_composed_fragment_list(tmp_path): assert root.GetAttribute("newton:selfCollisionEnabled").Get() is True -# ------------------------------------------------------------------------------------- -# public imports -# ------------------------------------------------------------------------------------- - - -def test_public_imports(): - from isaaclab_newton.sim.schemas import NewtonArticulationCfg # noqa: F401 - from isaaclab_physx.sim.schemas import PhysxArticulationCfg # noqa: F401 - - from isaaclab.sim.schemas import ( # noqa: F401 - ArticulationRootFragment, - SchemaFragment, - apply_articulation_root_properties, - ) - - def test_apply_articulation_root_properties_creates_on_every_matched_prim(): """Creation applies the root API to every matched sibling prim; the expression is trusted.""" from isaaclab_physx.sim.schemas import PhysxArticulationCfg diff --git a/source/isaaclab/test/sim/test_build_simulation_context_headless.py b/source/isaaclab/test/sim/test_build_simulation_context_headless.py index cf266f73f4fe..08a8cc3128aa 100644 --- a/source/isaaclab/test/sim/test_build_simulation_context_headless.py +++ b/source/isaaclab/test/sim/test_build_simulation_context_headless.py @@ -3,16 +3,6 @@ # # SPDX-License-Identifier: BSD-3-Clause -""" -This test has a lot of duplication with ``test_build_simulation_context_nonheadless.py``. - -This is intentional to ensure that the tests are run in both headless and non-headless modes, -and we currently can't re-build the simulation app in a script. - -If you need to make a change to this test, please make sure to also make the same change to -``test_build_simulation_context_nonheadless.py``. -""" - """Launch Isaac Sim Simulator first.""" from isaaclab.app import AppLauncher @@ -30,9 +20,8 @@ pytestmark = pytest.mark.integration -@pytest.mark.parametrize("gravity_enabled", [True, False]) -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -@pytest.mark.parametrize("dt", [0.01, 0.1]) +# each argument maps onto one cfg field, so every value is covered once with the others rotated +@pytest.mark.parametrize(("gravity_enabled", "device", "dt"), [(True, "cpu", 0.01), (False, "cuda:0", 0.1)]) @pytest.mark.isaacsim_ci def test_build_simulation_context_no_cfg(gravity_enabled, device, dt): """Test that the simulation context is built when no simulation cfg is passed in.""" @@ -62,11 +51,13 @@ def test_build_simulation_context_ground_plane(add_ground_plane): @pytest.mark.parametrize("add_lighting", [True, False]) -@pytest.mark.parametrize("auto_add_lighting", [True, False]) @pytest.mark.isaacsim_ci -def test_build_simulation_context_auto_add_lighting(add_lighting, auto_add_lighting): - """Test that the simulation context is built with the correct lighting.""" - with build_simulation_context(add_lighting=add_lighting, auto_add_lighting=auto_add_lighting) as sim: +def test_build_simulation_context_auto_add_lighting(add_lighting): + """Test that the simulation context is built with the correct lighting. + + ``auto_add_lighting`` only adds a light when a GUI is present, so headless runs follow ``add_lighting``. + """ + with build_simulation_context(add_lighting=add_lighting, auto_add_lighting=True) as sim: if add_lighting: # Ensure that dome light got added assert sim.stage.GetPrimAtPath("/World/defaultDomeLight").IsValid() diff --git a/source/isaaclab/test/sim/test_build_simulation_context_nonheadless.py b/source/isaaclab/test/sim/test_build_simulation_context_nonheadless.py deleted file mode 100644 index 2ce2345062c8..000000000000 --- a/source/isaaclab/test/sim/test_build_simulation_context_nonheadless.py +++ /dev/null @@ -1,104 +0,0 @@ -# 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 - -"""This test has a lot of duplication with ``test_build_simulation_context_headless.py``. - -This is intentional to ensure that the tests are run in both headless and non-headless modes, -and we currently can't re-build the simulation app in a script. - -If you need to make a change to this test, please make sure to also make the same change to -``test_build_simulation_context_headless.py``. -""" - -"""Launch Isaac Sim Simulator first.""" - -from isaaclab.app import AppLauncher - -# launch omniverse app -simulation_app = AppLauncher(headless=True).app - -"""Rest everything follows.""" - -import pytest - -from isaaclab.sim.simulation_cfg import SimulationCfg -from isaaclab.sim.simulation_context import build_simulation_context - -pytestmark = pytest.mark.integration - - -@pytest.mark.parametrize("gravity_enabled", [True, False]) -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -@pytest.mark.parametrize("dt", [0.01, 0.1]) -def test_build_simulation_context_no_cfg(gravity_enabled, device, dt): - """Test that the simulation context is built when no simulation cfg is passed in.""" - with build_simulation_context(gravity_enabled=gravity_enabled, device=device, dt=dt) as sim: - if gravity_enabled: - assert sim.cfg.gravity == (0.0, 0.0, -9.81) - else: - assert sim.cfg.gravity == (0.0, 0.0, 0.0) - - assert sim.cfg.device == device - assert sim.cfg.dt == dt - - -@pytest.mark.parametrize("add_ground_plane", [True, False]) -def test_build_simulation_context_ground_plane(add_ground_plane): - """Test that the simulation context is built with the correct ground plane.""" - with build_simulation_context(add_ground_plane=add_ground_plane) as sim: - # Ensure that ground plane got added - if add_ground_plane: - assert sim.stage.GetPrimAtPath("/World/defaultGroundPlane").IsValid() - else: - assert not sim.stage.GetPrimAtPath("/World/defaultGroundPlane").IsValid() - - -@pytest.mark.parametrize("add_lighting", [True, False]) -@pytest.mark.parametrize("auto_add_lighting", [True, False]) -def test_build_simulation_context_auto_add_lighting(add_lighting, auto_add_lighting): - """Test that the simulation context is built with the correct lighting.""" - with build_simulation_context(add_lighting=add_lighting, auto_add_lighting=auto_add_lighting) as sim: - has_gui = sim.get_setting("/isaaclab/has_gui") - # Dome light is added if add_lighting=True OR (auto_add_lighting=True AND has_gui) - should_have_light = add_lighting or (auto_add_lighting and has_gui) - if should_have_light: - # Ensure that dome light got added - assert sim.stage.GetPrimAtPath("/World/defaultDomeLight").IsValid() - else: - # Ensure that dome light didn't get added - assert not sim.stage.GetPrimAtPath("/World/defaultDomeLight").IsValid() - - -def test_build_simulation_context_cfg(): - """Test that the simulation context honors sim_cfg's values, with an explicit - device override winning when both ``sim_cfg`` and ``device`` are passed. - - Most test callers pass both kwargs together expecting the device kwarg to - win; the override branch in :func:`build_simulation_context` exists for - that case. ``gravity`` and ``dt`` are not overridable by the helper's - kwargs (only sim_cfg's values are used). - """ - dt = 0.001 - # Non-standard gravity - gravity = (0.0, 0.0, -1.81) - device = "cuda:0" - - cfg = SimulationCfg( - gravity=gravity, - device=device, - dt=dt, - ) - - # Pass only sim_cfg: gravity, device, dt all come from sim_cfg (kwargs ignored). - with build_simulation_context(sim_cfg=cfg, gravity_enabled=False, dt=0.01) as sim: - assert sim.cfg.gravity == gravity - assert sim.cfg.device == device - assert sim.cfg.dt == dt - - # Pass sim_cfg and an explicit device override: device kwarg wins. - with build_simulation_context(sim_cfg=cfg, device="cpu") as sim: - assert sim.cfg.gravity == gravity - assert sim.cfg.device == "cpu" - assert sim.cfg.dt == dt diff --git a/source/isaaclab/test/sim/test_cloner.py b/source/isaaclab/test/sim/test_cloner.py index de4be38452d4..c2481c67458c 100644 --- a/source/isaaclab/test/sim/test_cloner.py +++ b/source/isaaclab/test/sim/test_cloner.py @@ -35,19 +35,23 @@ ) from isaaclab.sim import build_simulation_context from isaaclab.sim.utils import queries +from isaaclab.test.utils import resolve_test_sim_device pytestmark = [pytest.mark.integration, pytest.mark.isaacsim_ci] -@pytest.fixture(params=["cpu", "cuda"]) -def sim(request): - """Provide a fresh simulation context for each test on CPU and CUDA.""" - with build_simulation_context(device=request.param, dt=0.01, add_lighting=False) as sim: +@pytest.fixture +def sim(): + """Provide a fresh simulation context for each test. + + The cloner utilities author USD and plan bookkeeping only, with no device branch, so one device suffices. + """ + with build_simulation_context(device=resolve_test_sim_device(), dt=0.01, add_lighting=False) as sim: yield sim def test_usd_replicate_with_positions_and_mask(sim): - """Replicate sources to selected envs and author translate ops from positions.""" + """Replicate sources only to the envs selected by the mask.""" # Prepare sources under /World/template sim_utils.create_prim("/World/template", "Xform") sim_utils.create_prim("/World/template/A", "Xform") @@ -73,7 +77,7 @@ def test_usd_replicate_with_positions_and_mask(sim): mask=mask, ) - # Validate replication and translate op + # Validate replication follows the mask stage = sim_utils.get_current_stage() assert stage.GetPrimAtPath("/World/envs/env_0/Object/A").IsValid() assert not stage.GetPrimAtPath("/World/envs/env_0/Object/B").IsValid() @@ -81,12 +85,6 @@ def test_usd_replicate_with_positions_and_mask(sim): assert not stage.GetPrimAtPath("/World/envs/env_1/Object/A").IsValid() assert stage.GetPrimAtPath("/World/envs/env_2/Object/A").IsValid() - # Check xformOp:translate authored for env_2/A - prim = stage.GetPrimAtPath("/World/envs/env_2/Object/A") - xform = UsdGeom.Xformable(prim) - ops = xform.GetOrderedXformOps() - assert any(op.GetOpType() == UsdGeom.XformOp.TypeTranslate for op in ops) - def test_usd_replicate_context_consumes_plan(sim): """UsdReplicateContext consumes the same plan used by every clone backend.""" @@ -324,7 +322,7 @@ def test_make_clone_plan_homogeneous_returns_env_root_plan(sim): assert cube.spawn.spawn_path == "/World/envs/env_0/Robot" -def test_resolve_matching_prims_from_source_searches_only_plan_source(sim, monkeypatch): +def test_resolve_matching_prims_from_source_searches_only_plan_source(sim): """Clone-aware regex discovery traverses its plan source, never cloned destinations.""" stage = sim_utils.get_current_stage() for path in ( @@ -342,23 +340,9 @@ def test_resolve_matching_prims_from_source_searches_only_plan_source(sim, monke ) sim.set_clone_plan(plan) - traversed_roots = [] - source_matcher = queries._iter_matching_prims_in_subtree - - def record_source_root(path_expr, root_prim): - traversed_roots.append(root_prim.GetPath().pathString) - return source_matcher(path_expr, root_prim) - - monkeypatch.setattr(queries, "_iter_matching_prims_in_subtree", record_source_root) - monkeypatch.setattr( - queries, - "find_matching_prims", - lambda *args, **kwargs: pytest.fail("clone-aware resolution called the unscoped stage matcher"), - ) - matches = queries.resolve_matching_prims_from_source(r"/World/envs/env_[^/]+/Robot/[^A]+") - assert traversed_roots == ["/World/envs/env_0/Robot"] + # the clone-only prim under env_1 would match the expression if cloned destinations were traversed assert [prim.GetPath().pathString for prim, _ in matches] == [ "/World/envs/env_0/Robot/foo", "/World/envs/env_0/Robot/foo/bar", diff --git a/source/isaaclab/test/sim/test_collision_fragments.py b/source/isaaclab/test/sim/test_collision_fragments.py index b4ae604a71c6..52f58c6f2746 100644 --- a/source/isaaclab/test/sim/test_collision_fragments.py +++ b/source/isaaclab/test/sim/test_collision_fragments.py @@ -34,82 +34,6 @@ def _make_xform(stage, path="/World/Body"): return stage.GetPrimAtPath(path) -# ------------------------------------------------------------------------------------- -# CollisionFragment marker + UsdPhysicsCollisionCfg -# ------------------------------------------------------------------------------------- - - -def test_collision_fragment_metadata_defaults(): - from isaaclab.sim.schemas import CollisionFragment, SchemaFragment, UsdPhysicsCollisionCfg - - cfg = UsdPhysicsCollisionCfg(collision_enabled=True) - assert isinstance(cfg, CollisionFragment) and isinstance(cfg, SchemaFragment) - assert type(cfg)._usd_namespace == "physics" - assert type(cfg)._usd_applied_schema is None # anchor applies CollisionAPI, not the fragment - assert cfg.func == "isaaclab.sim.schemas:apply_namespaced" - assert cfg.collision_enabled is True - - -# ------------------------------------------------------------------------------------- -# UsdPhysicsCollisionCfg writes its physics namespace via apply_namespaced -# ------------------------------------------------------------------------------------- - - -def test_usd_physics_collision_fragment_writes_physics_namespace(): - from isaaclab.sim.schemas import UsdPhysicsCollisionCfg, apply_namespaced - - sim_utils.create_new_stage() - SimulationContext(SimulationCfg(dt=0.01)) - stage = sim_utils.get_current_stage() - prim = _make_xform(stage) - UsdPhysics.CollisionAPI.Apply(prim) - apply_namespaced(UsdPhysicsCollisionCfg(collision_enabled=True), "/World/Body", stage) - assert prim.GetAttribute("physics:collisionEnabled").Get() is True - - -# ------------------------------------------------------------------------------------- -# PhysxCollisionCfg (isaaclab_physx) -# ------------------------------------------------------------------------------------- - - -def test_physx_collision_fragment_writes_physx_namespace(): - from isaaclab_physx.sim.schemas import PhysxCollisionCfg - - from isaaclab.sim.schemas import apply_namespaced - - sim_utils.create_new_stage() - SimulationContext(SimulationCfg(dt=0.01)) - stage = sim_utils.get_current_stage() - prim = _make_xform(stage, "/World/C2") - UsdPhysics.CollisionAPI.Apply(prim) - apply_namespaced( - PhysxCollisionCfg(contact_offset=0.02, rest_offset=0.0, torsional_patch_radius=0.1), "/World/C2", stage - ) - assert abs(prim.GetAttribute("physxCollision:contactOffset").Get() - 0.02) < 1e-6 - assert abs(prim.GetAttribute("physxCollision:restOffset").Get() - 0.0) < 1e-6 - assert abs(prim.GetAttribute("physxCollision:torsionalPatchRadius").Get() - 0.1) < 1e-6 - - -# ------------------------------------------------------------------------------------- -# NewtonCollisionCfg (isaaclab_newton) -# ------------------------------------------------------------------------------------- - - -def test_newton_collision_fragment_writes_newton_namespace(): - from isaaclab_newton.sim.schemas import NewtonCollisionCfg - - from isaaclab.sim.schemas import apply_namespaced - - sim_utils.create_new_stage() - SimulationContext(SimulationCfg(dt=0.01)) - stage = sim_utils.get_current_stage() - prim = _make_xform(stage, "/World/C3") - UsdPhysics.CollisionAPI.Apply(prim) - apply_namespaced(NewtonCollisionCfg(contact_margin=0.01, contact_gap=0.005), "/World/C3", stage) - assert abs(prim.GetAttribute("newton:contactMargin").Get() - 0.01) < 1e-6 - assert abs(prim.GetAttribute("newton:contactGap").Get() - 0.005) < 1e-6 - - # ------------------------------------------------------------------------------------- # MujocoCollisionCfg (isaaclab_newton) # ------------------------------------------------------------------------------------- @@ -201,8 +125,8 @@ def test_apply_collision_properties_composes_namespaces(): "/World/C4", [ UsdPhysicsCollisionCfg(collision_enabled=True), - PhysxCollisionCfg(contact_offset=0.02), - NewtonCollisionCfg(contact_margin=0.01), + PhysxCollisionCfg(contact_offset=0.02, rest_offset=0.0, torsional_patch_radius=0.1), + NewtonCollisionCfg(contact_margin=0.01, contact_gap=0.005), MujocoCollisionCfg(condim=4), ], create_if_missing=True, @@ -212,12 +136,17 @@ def test_apply_collision_properties_composes_namespaces(): assert bool(UsdPhysics.CollisionAPI(prim)) # implicit anchor applied assert prim.GetAttribute("physics:collisionEnabled").Get() is True assert abs(prim.GetAttribute("physxCollision:contactOffset").Get() - 0.02) < 1e-6 + # an explicit 0.0 is a set value, not an unset field + assert prim.GetAttribute("physxCollision:restOffset").HasAuthoredValue() + assert abs(prim.GetAttribute("physxCollision:restOffset").Get() - 0.0) < 1e-6 + assert abs(prim.GetAttribute("physxCollision:torsionalPatchRadius").Get() - 0.1) < 1e-6 assert abs(prim.GetAttribute("newton:contactMargin").Get() - 0.01) < 1e-6 + assert abs(prim.GetAttribute("newton:contactGap").Get() - 0.005) < 1e-6 assert prim.GetAttribute("mjc:condim").Get() == 4 # ------------------------------------------------------------------------------------- -# expression targeting: gprim gate, pattern narrowing, zero-target warning +# expression targeting: mesh-collision fragments on matched colliders # ------------------------------------------------------------------------------------- @@ -243,43 +172,6 @@ def test_mesh_collision_fragments_author_on_every_matched_collider(): assert prim.GetAttribute("physics:approximation").HasAuthoredValue(), prim.GetPath() -def test_collision_fragments_pattern_narrows_targets(): - """An expression targets only the colliders it matches.""" - from isaaclab_physx.sim.schemas import PhysxCollisionCfg - - from isaaclab.sim.schemas import apply_collision_properties - - sim_utils.create_new_stage() - SimulationContext(SimulationCfg(dt=0.01)) - stage = sim_utils.get_current_stage() - for path in ("/World/Bot/colL", "/World/Bot/colR"): - prim = UsdGeom.Cube.Define(stage, path).GetPrim() - UsdPhysics.CollisionAPI.Apply(prim) - result = apply_collision_properties("/World/Bot/colL", [PhysxCollisionCfg(contact_offset=0.02)], stage=stage) - assert result is True - left = stage.GetPrimAtPath("/World/Bot/colL") - right = stage.GetPrimAtPath("/World/Bot/colR") - assert abs(left.GetAttribute("physxCollision:contactOffset").Get() - 0.02) < 1e-6 - assert not right.GetAttribute("physxCollision:contactOffset").HasAuthoredValue() - - -def test_collision_fragments_zero_targets_warn_and_return_false(caplog): - """No collider matched and no creation requested: warn, author nothing, report failure.""" - from isaaclab_physx.sim.schemas import PhysxCollisionCfg - - from isaaclab.sim.schemas import apply_collision_properties - - sim_utils.create_new_stage() - SimulationContext(SimulationCfg(dt=0.01)) - stage = sim_utils.get_current_stage() - with caplog.at_level("WARNING"): - result = apply_collision_properties( - "/World/DoesNotExist", [PhysxCollisionCfg(contact_offset=0.02)], stage=stage - ) - assert result is False - assert "/World/DoesNotExist" in caplog.text - - # ------------------------------------------------------------------------------------- # spawner slot accepts a fragment mapping + routing by type # ------------------------------------------------------------------------------------- @@ -300,44 +192,3 @@ def test_spawn_shape_with_collision_fragment_list(): prim = sim_utils.get_current_stage().GetPrimAtPath("/World/Cube/geometry/mesh") assert bool(UsdPhysics.CollisionAPI(prim)) assert abs(prim.GetAttribute("physxCollision:contactOffset").Get() - 0.03) < 1e-6 - - -# ------------------------------------------------------------------------------------- -# public imports -# ------------------------------------------------------------------------------------- - - -def test_public_imports(): - from isaaclab_newton.sim.schemas import MujocoCollisionCfg, NewtonCollisionCfg, apply_mujoco_collision # noqa: F401 - from isaaclab_physx.sim.schemas import PhysxCollisionCfg # noqa: F401 - - from isaaclab.sim.schemas import ( # noqa: F401 - CollisionFragment, - SchemaFragment, - UsdPhysicsCollisionCfg, - apply_collision_properties, - apply_namespaced, - ) - - -def test_collision_fragments_create_on_every_matched_prim(): - """Creation applies ``CollisionAPI`` to every matched prim lacking it.""" - from isaaclab_physx.sim.schemas import PhysxCollisionCfg - - from isaaclab.sim.schemas import apply_collision_properties - - sim_utils.create_new_stage() - SimulationContext(SimulationCfg(dt=0.01)) - stage = sim_utils.get_current_stage() - for path in ("/World/Grp", "/World/Grp/a", "/World/Grp/b"): - UsdGeom.Xform.Define(stage, path) - - result = apply_collision_properties( - "/World/Grp(/.*)?", [PhysxCollisionCfg(contact_offset=0.02)], create_if_missing=True, stage=stage - ) - - assert result is True - for path in ("/World/Grp", "/World/Grp/a", "/World/Grp/b"): - prim = stage.GetPrimAtPath(path) - assert prim.HasAPI(UsdPhysics.CollisionAPI), path - assert prim.GetAttribute("physxCollision:contactOffset").Get() == pytest.approx(0.02), path diff --git a/source/isaaclab/test/sim/test_joint_drive_fragments.py b/source/isaaclab/test/sim/test_joint_drive_fragments.py index 2c4069a5b589..fb17ad22bd69 100644 --- a/source/isaaclab/test/sim/test_joint_drive_fragments.py +++ b/source/isaaclab/test/sim/test_joint_drive_fragments.py @@ -39,30 +39,11 @@ def _make_revolute_joint(stage, path="/World/Articulation/joint_0"): return stage.GetPrimAtPath(path) -def _make_prismatic_joint(stage, path="/World/Articulation/joint_p"): - UsdGeom.Xform.Define(stage, "/World/Articulation") - UsdGeom.Cube.Define(stage, "/World/Articulation/body0") - UsdGeom.Cube.Define(stage, "/World/Articulation/body1") - UsdPhysics.PrismaticJoint.Define(stage, path) - return stage.GetPrimAtPath(path) - - # ------------------------------------------------------------------------------------- -# Fragment metadata +# Fragment deprecation aliases and apply_drive guard # ------------------------------------------------------------------------------------- -def test_drive_fragment_metadata_defaults(): - from isaaclab.sim.schemas import JointDriveFragment, SchemaFragment, UsdPhysicsDriveCfg - - cfg = UsdPhysicsDriveCfg(drive_type="acceleration", max_force=80.0, stiffness=10.0, damping=0.1) - assert isinstance(cfg, JointDriveFragment) and isinstance(cfg, SchemaFragment) - assert type(cfg)._usd_namespace is None # typed multi-instance DriveAPI, no namespace writes - assert type(cfg)._usd_applied_schema is None # DriveAPI applied by apply_drive (presence-gated) - assert cfg.func == "isaaclab.sim.schemas:apply_drive" - assert cfg.stiffness == 10.0 and cfg.damping == 0.1 - - def test_drive_fragment_max_effort_alias(): from isaaclab.sim.schemas import UsdPhysicsDriveCfg @@ -72,56 +53,6 @@ def test_drive_fragment_max_effort_alias(): assert cfg.max_effort is None -# ------------------------------------------------------------------------------------- -# apply_drive -- revolute (angular) rad->deg conversion -# ------------------------------------------------------------------------------------- - - -def test_apply_drive_revolute_converts_rad_to_deg(): - from isaaclab.sim.schemas import UsdPhysicsDriveCfg, apply_drive - - sim_utils.create_new_stage() - SimulationContext(SimulationCfg(dt=0.01)) - stage = sim_utils.get_current_stage() - prim = _make_revolute_joint(stage) - assert apply_drive( - UsdPhysicsDriveCfg(drive_type="acceleration", max_force=80.0, stiffness=10.0, damping=0.1), - prim.GetPath().pathString, - stage, - ) - assert bool(UsdPhysics.DriveAPI(prim, "angular")) - assert prim.GetAttribute("drive:angular:physics:type").Get() == "acceleration" - assert prim.GetAttribute("drive:angular:physics:maxForce").Get() == pytest.approx(80.0, rel=1e-6) - # angular stiffness/damping are converted from radian to degree units - assert prim.GetAttribute("drive:angular:physics:stiffness").Get() == pytest.approx(10.0 * math.pi / 180.0, rel=1e-6) - assert prim.GetAttribute("drive:angular:physics:damping").Get() == pytest.approx(0.1 * math.pi / 180.0, rel=1e-6) - - -# ------------------------------------------------------------------------------------- -# apply_drive -- prismatic (linear) no conversion -# ------------------------------------------------------------------------------------- - - -def test_apply_drive_prismatic_writes_linear_unchanged(): - from isaaclab.sim.schemas import UsdPhysicsDriveCfg, apply_drive - - sim_utils.create_new_stage() - SimulationContext(SimulationCfg(dt=0.01)) - stage = sim_utils.get_current_stage() - prim = _make_prismatic_joint(stage) - assert apply_drive( - UsdPhysicsDriveCfg(drive_type="force", max_force=42.0, stiffness=10.0, damping=0.1), - prim.GetPath().pathString, - stage, - ) - assert bool(UsdPhysics.DriveAPI(prim, "linear")) - assert prim.GetAttribute("drive:linear:physics:type").Get() == "force" - assert prim.GetAttribute("drive:linear:physics:maxForce").Get() == pytest.approx(42.0, rel=1e-6) - # linear drives are written as authored (no rad->deg conversion) - assert prim.GetAttribute("drive:linear:physics:stiffness").Get() == pytest.approx(10.0, rel=1e-6) - assert prim.GetAttribute("drive:linear:physics:damping").Get() == pytest.approx(0.1, rel=1e-6) - - def test_apply_drive_returns_false_on_non_joint(): from isaaclab.sim.schemas import UsdPhysicsDriveCfg, apply_drive @@ -137,22 +68,6 @@ def test_apply_drive_returns_false_on_non_joint(): # ------------------------------------------------------------------------------------- -def test_physx_joint_fragment_converts_max_velocity_by_joint_type(): - from isaaclab_physx.sim.schemas import PhysxJointCfg, apply_physx_joint - - sim_utils.create_new_stage() - SimulationContext(SimulationCfg(dt=0.01)) - stage = sim_utils.get_current_stage() - # angular (revolute) joint: rad/s -> deg/s conversion - rev = _make_revolute_joint(stage) - apply_physx_joint(PhysxJointCfg(max_joint_velocity=10.0), rev.GetPath().pathString, stage) - assert rev.GetAttribute("physxJoint:maxJointVelocity").Get() == pytest.approx(10.0 * 180.0 / math.pi, rel=1e-6) - # linear (prismatic) joint: written unchanged - prismatic = _make_prismatic_joint(stage) - apply_physx_joint(PhysxJointCfg(max_joint_velocity=10.0), prismatic.GetPath().pathString, stage) - assert prismatic.GetAttribute("physxJoint:maxJointVelocity").Get() == pytest.approx(10.0, rel=1e-6) - - def test_physx_joint_fragment_max_velocity_alias(): from isaaclab_physx.sim.schemas import PhysxJointCfg @@ -167,51 +82,6 @@ def test_physx_joint_fragment_max_velocity_alias(): # ------------------------------------------------------------------------------------- -def test_mujoco_joint_fragment_writes_mjc_namespace(): - from isaaclab_newton.sim.schemas import MujocoJointCfg - - from isaaclab.sim.schemas import apply_namespaced - - sim_utils.create_new_stage() - SimulationContext(SimulationCfg(dt=0.01)) - stage = sim_utils.get_current_stage() - prim = _make_revolute_joint(stage) - apply_namespaced(MujocoJointCfg(actuatorgravcomp=True), prim.GetPath().pathString, stage) - assert prim.GetAttribute("mjc:actuatorgravcomp").Get() is True - - -def test_mujoco_joint_applier_does_not_write_actuatorgravcomp_when_none(): - # fragment-path equivalent of the legacy test_mujoco_actuatorgravcomp_not_written_when_none: - # an unset actuatorgravcomp must not *author* mjc:actuatorgravcomp through the joint applier. - # (Unlike the legacy path, the fragment applies its MjcJointAPI schema, so the attribute exists - # and resolves to the schema default ``False`` -- but it must not be authored to ``True``.) - from isaaclab_newton.sim.schemas import MujocoJointCfg, apply_mujoco_joint - - sim_utils.create_new_stage() - SimulationContext(SimulationCfg(dt=0.01)) - stage = sim_utils.get_current_stage() - prim = _make_revolute_joint(stage) - apply_mujoco_joint(MujocoJointCfg(), prim.GetPath().pathString, stage) - assert not prim.GetAttribute("mjc:actuatorgravcomp").HasAuthoredValue() - - -def test_mujoco_joint_actuatorgravcomp_enables_child_body_gravcomp(): - # actuatorgravcomp is inert unless the actuated body has non-zero mjc:gravcomp, so the Mujoco - # joint applier flips gravcomp on the joint's child body (physics:body1) when it is unset - from isaaclab_newton.sim.schemas import MujocoJointCfg - - from isaaclab.sim.schemas import apply_joint_drive_properties - - sim_utils.create_new_stage() - SimulationContext(SimulationCfg(dt=0.01)) - stage = sim_utils.get_current_stage() - prim = _make_revolute_joint(stage) - UsdPhysics.RevoluteJoint(prim).CreateBody1Rel().SetTargets(["/World/Articulation/body1"]) - apply_joint_drive_properties("/World/Articulation(/.*)?", [MujocoJointCfg(actuatorgravcomp=True)], stage) - body = stage.GetPrimAtPath("/World/Articulation/body1") - assert body.GetAttribute("mjc:gravcomp").Get() == pytest.approx(1.0) - - def test_mujoco_joint_without_actuatorgravcomp_leaves_body_gravcomp_untouched(): # the gravcomp coupling must fire ONLY when actuatorgravcomp is requested; an unset flag leaves # the child body's mjc:gravcomp unauthored @@ -227,6 +97,9 @@ def test_mujoco_joint_without_actuatorgravcomp_leaves_body_gravcomp_untouched(): apply_joint_drive_properties("/World/Articulation(/.*)?", [MujocoJointCfg()], stage) body = stage.GetPrimAtPath("/World/Articulation/body1") assert body.GetAttribute("mjc:gravcomp").Get() is None + # the joint applier applies MjcJointAPI, so the attribute resolves to the schema default, but an + # unset actuatorgravcomp must not be authored + assert not prim.GetAttribute("mjc:actuatorgravcomp").HasAuthoredValue() def test_mujoco_joint_actuatorgravcomp_enables_gravcomp_on_every_joint_body(): @@ -294,6 +167,7 @@ def test_apply_joint_drive_properties_composes_namespaces(): stage, ) assert bool(UsdPhysics.DriveAPI(prim, "angular")) # presence-gated anchor applied + assert prim.GetAttribute("drive:angular:physics:type").Get() == "acceleration" assert prim.GetAttribute("drive:angular:physics:maxForce").Get() == pytest.approx(80.0, rel=1e-6) assert prim.GetAttribute("drive:angular:physics:stiffness").Get() == pytest.approx(10.0 * math.pi / 180.0, rel=1e-6) # revolute joint -> rad/s to deg/s conversion via apply_physx_joint @@ -426,24 +300,6 @@ def test_joint_drive_fragments_create_missing_drive_api(): assert joint.GetAttribute("physxJoint:maxJointVelocity").Get() == pytest.approx(math.degrees(5.0)) -# ------------------------------------------------------------------------------------- -# public imports -# ------------------------------------------------------------------------------------- - - -def test_public_imports(): - from isaaclab_newton.sim.schemas import MujocoJointCfg # noqa: F401 - from isaaclab_physx.sim.schemas import PhysxJointCfg # noqa: F401 - - from isaaclab.sim.schemas import ( # noqa: F401 - JointDriveFragment, - SchemaFragment, - UsdPhysicsDriveCfg, - apply_drive, - apply_joint_drive_properties, - ) - - def test_joint_drive_fragments_instanced_joints_warn_once_and_fail(caplog): """Instanced joints are reported by the matcher only, and the writer returns False.""" from isaaclab_physx.sim.schemas import PhysxJointCfg diff --git a/source/isaaclab/test/sim/test_mass_fragments.py b/source/isaaclab/test/sim/test_mass_fragments.py index e5effb5f38e4..fabba48d1b19 100644 --- a/source/isaaclab/test/sim/test_mass_fragments.py +++ b/source/isaaclab/test/sim/test_mass_fragments.py @@ -29,122 +29,11 @@ def cleanup_simulation_context(): SimulationContext.clear_instance() -def _make_xform(stage, path="/World/Body"): - UsdGeom.Xform.Define(stage, path) - return stage.GetPrimAtPath(path) - - -# ------------------------------------------------------------------------------------- -# Fragment metadata -- MassFragment marker, MassCfg -# ------------------------------------------------------------------------------------- - - -def test_fragment_metadata_defaults(): - from isaaclab.sim.schemas import MassCfg, MassFragment, SchemaFragment - - cfg = MassCfg(mass=2.0) - assert isinstance(cfg, MassFragment) and isinstance(cfg, SchemaFragment) - assert type(cfg)._usd_namespace == "physics" - assert type(cfg)._usd_applied_schema is None # anchor applies MassAPI, not the fragment - assert cfg.func == "isaaclab.sim.schemas:apply_namespaced" - assert cfg.mass == 2.0 and cfg.density is None - - # ------------------------------------------------------------------------------------- -# apply_namespaced writes only the set fields under the physics namespace +# apply_mass_properties creation and empty-list no-op # ------------------------------------------------------------------------------------- -def test_apply_namespaced_writes_only_set_fields(): - from isaaclab.sim.schemas import MassCfg, apply_namespaced - - sim_utils.create_new_stage() - SimulationContext(SimulationCfg(dt=0.01)) - stage = sim_utils.get_current_stage() - prim = _make_xform(stage) - UsdPhysics.MassAPI.Apply(prim) - apply_namespaced(MassCfg(mass=3.0), "/World/Body", stage) - assert abs(prim.GetAttribute("physics:mass").Get() - 3.0) < 1e-6 - # None field must not be authored (density exists as a MassAPI fallback attr) - assert not prim.GetAttribute("physics:density").HasAuthoredValue() - - -# ------------------------------------------------------------------------------------- -# apply_mass_properties dispatch (implicit MassAPI anchor) -# ------------------------------------------------------------------------------------- - - -def test_apply_mass_properties_applies_anchor_and_writes_fields(): - from isaaclab.sim.schemas import MassCfg, apply_mass_properties - - sim_utils.create_new_stage() - SimulationContext(SimulationCfg(dt=0.01)) - stage = sim_utils.get_current_stage() - _make_xform(stage, "/World/B2") - apply_mass_properties("/World/B2", [MassCfg(mass=5.0, density=100.0)], create_if_missing=True, stage=stage) - prim = stage.GetPrimAtPath("/World/B2") - assert bool(UsdPhysics.MassAPI(prim)) # implicit anchor applied - assert abs(prim.GetAttribute("physics:mass").Get() - 5.0) < 1e-6 - assert abs(prim.GetAttribute("physics:density").Get() - 100.0) < 1e-6 - - -# ------------------------------------------------------------------------------------- -# spawner slot accepts a fragment mapping + routing by type -# ------------------------------------------------------------------------------------- - - -def test_spawn_shape_with_mass_fragment_dict(): - from isaaclab.sim.schemas import MassCfg, UsdPhysicsRigidBodyCfg - - sim_utils.create_new_stage() - SimulationContext(SimulationCfg(dt=0.01)) - cfg = sim_utils.CuboidCfg( - size=(1, 1, 1), - rigid_props={"": [UsdPhysicsRigidBodyCfg(rigid_body_enabled=True)]}, - mass_props={"": [MassCfg(mass=4.0)]}, - ) - cfg.func("/World/Cube", cfg) - prim = sim_utils.get_current_stage().GetPrimAtPath("/World/Cube") - assert bool(UsdPhysics.MassAPI(prim)) - assert abs(prim.GetAttribute("physics:mass").Get() - 4.0) < 1e-6 - - -# ------------------------------------------------------------------------------------- -# Review follow-ups -- unmatched-path warning, aggregated return, empty-list no-op -# ------------------------------------------------------------------------------------- - - -def test_apply_mass_properties_warns_on_unmatched_path(caplog): - from isaaclab.sim.schemas import MassCfg, apply_mass_properties - - sim_utils.create_new_stage() - SimulationContext(SimulationCfg(dt=0.01)) - stage = sim_utils.get_current_stage() - # no prim authored at this path -> zero matches -> warning + False, no exception - with caplog.at_level("WARNING"): - result = apply_mass_properties("/World/DoesNotExist", [MassCfg(mass=1.0)], stage=stage) - assert result is False - assert "/World/DoesNotExist" in caplog.text - - -def test_apply_mass_properties_aggregates_fragment_results(): - from isaaclab.sim.schemas import MassCfg, apply_mass_properties - - sim_utils.create_new_stage() - SimulationContext(SimulationCfg(dt=0.01)) - stage = sim_utils.get_current_stage() - _make_xform(stage, "/World/Agg") - - # a fragment whose applier reports failure must make the aggregate return False - failing = MassCfg(mass=1.0) - failing.func = lambda cfg, prim_path, stage=None: False - assert apply_mass_properties("/World/Agg", [failing], create_if_missing=True, stage=stage) is False - - # all-succeeding fragments return True - ok = MassCfg(mass=1.0) - assert apply_mass_properties("/World/Agg", [ok], create_if_missing=True, stage=stage) is True - - def test_apply_mass_properties_creates_on_every_matched_prim(): """Creation applies ``MassAPI`` to every matched prim lacking it, rigid body or not.""" from isaaclab.sim.schemas import MassCfg, apply_mass_properties @@ -157,12 +46,15 @@ def test_apply_mass_properties_creates_on_every_matched_prim(): UsdPhysics.RigidBodyAPI.Apply(body) plain = UsdGeom.Xform.Define(stage, "/World/Bot/frame").GetPrim() - result = apply_mass_properties("/World/Bot(/.*)?", [MassCfg(mass=2.0)], create_if_missing=True, stage=stage) + result = apply_mass_properties( + "/World/Bot(/.*)?", [MassCfg(mass=2.0, density=100.0)], create_if_missing=True, stage=stage + ) assert result is True for prim in (root, body, plain): assert prim.HasAPI(UsdPhysics.MassAPI), prim.GetPath() assert prim.GetAttribute("physics:mass").Get() == pytest.approx(2.0), prim.GetPath() + assert prim.GetAttribute("physics:density").Get() == pytest.approx(100.0), prim.GetPath() def test_spawn_shape_with_empty_mass_list_is_noop(): @@ -180,17 +72,3 @@ def test_spawn_shape_with_empty_mass_list_is_noop(): prim = sim_utils.get_current_stage().GetPrimAtPath("/World/Cube") # mass anchor is not required when there are zero fragments to apply assert not prim.GetAttribute("physics:mass").HasAuthoredValue() - - -# ------------------------------------------------------------------------------------- -# public imports -# ------------------------------------------------------------------------------------- - - -def test_public_imports(): - from isaaclab.sim.schemas import ( # noqa: F401 - MassCfg, - MassFragment, - SchemaFragment, - apply_mass_properties, - ) diff --git a/source/isaaclab/test/sim/test_material_fragments.py b/source/isaaclab/test/sim/test_material_fragments.py index d08c0d2cf362..d27ce96a05db 100644 --- a/source/isaaclab/test/sim/test_material_fragments.py +++ b/source/isaaclab/test/sim/test_material_fragments.py @@ -29,38 +29,6 @@ def cleanup_simulation_context(): SimulationContext.clear_instance() -# ------------------------------------------------------------------------------------- -# RigidBodyMaterialFragment marker + metadata -# ------------------------------------------------------------------------------------- - - -def test_rigid_body_material_fragment_metadata_defaults(): - from isaaclab.sim.schemas import SchemaFragment - from isaaclab.sim.spawners.materials.physics_materials_cfg import ( - RigidBodyMaterialFragment, - UsdPhysicsRigidBodyMaterialCfg, - ) - - cfg = UsdPhysicsRigidBodyMaterialCfg(static_friction=0.7) - assert isinstance(cfg, RigidBodyMaterialFragment) and isinstance(cfg, SchemaFragment) - assert type(cfg)._usd_namespace == "physics" - assert type(cfg)._usd_applied_schema is None # MaterialAPI applied by the family writer - assert cfg.func == "isaaclab.sim.schemas:apply_namespaced" - assert cfg.static_friction == 0.7 and cfg.dynamic_friction is None - - -def test_physx_material_fragment_metadata_defaults(): - from isaaclab_physx.sim.spawners.materials.physics_materials_cfg import PhysxMaterialCfg - - from isaaclab.sim.spawners.materials.physics_materials_cfg import RigidBodyMaterialFragment - - cfg = PhysxMaterialCfg(compliant_contact_stiffness=100.0) - assert isinstance(cfg, RigidBodyMaterialFragment) - assert type(cfg)._usd_namespace == "physxMaterial" - assert type(cfg)._usd_applied_schema == "PhysxMaterialAPI" - assert cfg.func == "isaaclab.sim.schemas:apply_namespaced" - - # ------------------------------------------------------------------------------------- # spawn_rigid_body_material_from_fragments: spawn prim + anchor + multi-namespace compose # ------------------------------------------------------------------------------------- @@ -78,8 +46,13 @@ def test_spawn_rigid_body_material_from_fragments_composes_namespaces(): prim = spawn_rigid_body_material_from_fragments( "/World/Mat", [ - UsdPhysicsRigidBodyMaterialCfg(static_friction=0.7, dynamic_friction=0.6, restitution=0.1), - PhysxMaterialCfg(compliant_contact_stiffness=100.0, friction_combine_mode="max"), + UsdPhysicsRigidBodyMaterialCfg(static_friction=0.7, dynamic_friction=0.6, restitution=0.1, density=1200.0), + PhysxMaterialCfg( + compliant_contact_stiffness=100.0, + friction_combine_mode="max", + damping_combine_mode="min", + compliant_contact_acceleration_spring=True, + ), ], stage, ) @@ -88,10 +61,14 @@ def test_spawn_rigid_body_material_from_fragments_composes_namespaces(): assert prim.GetAttribute("physics:staticFriction").Get() == pytest.approx(0.7) assert prim.GetAttribute("physics:dynamicFriction").Get() == pytest.approx(0.6) assert prim.GetAttribute("physics:restitution").Get() == pytest.approx(0.1) + # density participates in mass computation via material binding + assert prim.GetAttribute("physics:density").Get() == pytest.approx(1200.0) # the PhysX fragment applied its own schema and namespace assert "PhysxMaterialAPI" in prim.GetAppliedSchemas() assert prim.GetAttribute("physxMaterial:compliantContactStiffness").Get() == pytest.approx(100.0) assert prim.GetAttribute("physxMaterial:frictionCombineMode").Get() == "max" + assert prim.GetAttribute("physxMaterial:dampingCombineMode").Get() == "min" + assert prim.GetAttribute("physxMaterial:compliantContactAccelerationSpring").Get() is True def test_spawn_rigid_body_material_from_fragments_accepts_single_fragment(): @@ -183,41 +160,11 @@ def test_fragment_writer_validates_inputs_before_authoring(): assert not stage.GetPrimAtPath(f"/World/{path}").IsValid() -def test_spawn_rigid_body_material_from_fragments_leaves_none_fields_unwritten(): - from isaaclab.sim.spawners.materials.physics_materials import spawn_rigid_body_material_from_fragments - from isaaclab.sim.spawners.materials.physics_materials_cfg import UsdPhysicsRigidBodyMaterialCfg - - sim_utils.create_new_stage() - SimulationContext(SimulationCfg(dt=0.01)) - stage = sim_utils.get_current_stage() - prim = spawn_rigid_body_material_from_fragments( - "/World/Mat3", [UsdPhysicsRigidBodyMaterialCfg(static_friction=0.5)], stage - ) - # only the authored field is written; None fields are left unauthored (partial update) - assert prim.GetAttribute("physics:staticFriction").Get() == pytest.approx(0.5) - assert not prim.GetAttribute("physics:dynamicFriction").HasAuthoredValue() - - # ------------------------------------------------------------------------------------- -# UsdPhysicsRigidBodyMaterialCfg: density round-trip + physics:* schema parity +# UsdPhysicsRigidBodyMaterialCfg: physics:* schema parity # ------------------------------------------------------------------------------------- -def test_usd_physics_rigid_body_material_density_round_trips(): - """``physics:density`` participates in mass computation via material binding; it must author - the same as the other ``UsdPhysics.MaterialAPI`` friction/restitution fields.""" - from isaaclab.sim.spawners.materials.physics_materials import spawn_rigid_body_material_from_fragments - from isaaclab.sim.spawners.materials.physics_materials_cfg import UsdPhysicsRigidBodyMaterialCfg - - sim_utils.create_new_stage() - SimulationContext(SimulationCfg(dt=0.01)) - stage = sim_utils.get_current_stage() - prim = spawn_rigid_body_material_from_fragments( - "/World/MatDensity", [UsdPhysicsRigidBodyMaterialCfg(density=1200.0)], stage - ) - assert prim.GetAttribute("physics:density").Get() == pytest.approx(1200.0) - - def test_usd_physics_rigid_body_material_fragment_matches_material_api_schema(): """Schema-parity guard: the set of ``physics:*`` attrs the neutral fragment can author must equal the attribute set on ``UsdPhysics.MaterialAPI`` (4 attrs: static/dynamic friction, @@ -233,29 +180,6 @@ def test_usd_physics_rigid_body_material_fragment_matches_material_api_schema(): assert fragment_attr_names == schema_attr_names -# ------------------------------------------------------------------------------------- -# PhysxMaterialCfg: damping-combine-mode + compliant-contact-acceleration-spring -# ------------------------------------------------------------------------------------- - - -def test_physx_material_fragment_authors_damping_combine_mode_and_acceleration_spring(): - from isaaclab_physx.sim.spawners.materials.physics_materials_cfg import PhysxMaterialCfg - - from isaaclab.sim.spawners.materials.physics_materials import spawn_rigid_body_material_from_fragments - - sim_utils.create_new_stage() - SimulationContext(SimulationCfg(dt=0.01)) - stage = sim_utils.get_current_stage() - prim = spawn_rigid_body_material_from_fragments( - "/World/MatPhysxExtra", - [PhysxMaterialCfg(damping_combine_mode="min", compliant_contact_acceleration_spring=True)], - stage, - ) - assert "PhysxMaterialAPI" in prim.GetAppliedSchemas() - assert prim.GetAttribute("physxMaterial:dampingCombineMode").Get() == "min" - assert prim.GetAttribute("physxMaterial:compliantContactAccelerationSpring").Get() is True - - # ------------------------------------------------------------------------------------- # Finding 4: mesh spawner must accept a fragment list for a rigid physics_material # ------------------------------------------------------------------------------------- @@ -384,29 +308,6 @@ def test_spawn_mesh_with_rigid_props_accepts_legacy_newton_material(): assert bound_material.GetPath() == material_prim.GetPath() -# ------------------------------------------------------------------------------------- -# PhysxRigidBodyMaterialCfg (legacy): damping-combine-mode + compliant-contact-acceleration-spring -# ------------------------------------------------------------------------------------- - - -def test_legacy_physx_rigid_body_material_authors_damping_combine_mode_and_acceleration_spring(): - """The legacy :class:`~isaaclab_physx.sim.spawners.materials.PhysxRigidBodyMaterialCfg` must - author the same two ``physxMaterial:*`` attributes as the - :class:`~isaaclab_physx.sim.spawners.materials.PhysxMaterialCfg` fragment, since the legacy - spawner is metadata-driven off the same ``physxMaterial`` namespace.""" - from isaaclab_physx.sim.spawners.materials.physics_materials_cfg import PhysxRigidBodyMaterialCfg - - from isaaclab.sim.spawners.materials import spawn_rigid_body_material - - sim_utils.create_new_stage() - SimulationContext(SimulationCfg(dt=0.01)) - cfg = PhysxRigidBodyMaterialCfg(damping_combine_mode="min", compliant_contact_acceleration_spring=True) - prim = spawn_rigid_body_material("/World/MatLegacyPhysxExtra", cfg) - assert "PhysxMaterialAPI" in prim.GetAppliedSchemas() - assert prim.GetAttribute("physxMaterial:dampingCombineMode").Get() == "min" - assert prim.GetAttribute("physxMaterial:compliantContactAccelerationSpring").Get() is True - - # ------------------------------------------------------------------------------------- # Generated-terrain routing: create_prim_from_mesh must accept a fragment-list physics_material # ------------------------------------------------------------------------------------- diff --git a/source/isaaclab/test/sim/test_mesh_collision_fragments.py b/source/isaaclab/test/sim/test_mesh_collision_fragments.py index 4ecbcf8fcdb8..b58107b90c68 100644 --- a/source/isaaclab/test/sim/test_mesh_collision_fragments.py +++ b/source/isaaclab/test/sim/test_mesh_collision_fragments.py @@ -13,11 +13,20 @@ """Rest everything follows.""" import pytest +from isaaclab_newton.sim.schemas import NewtonMeshCollisionCfg, NewtonSDFCollisionCfg +from isaaclab_physx.sim.schemas import ( + PhysxConvexDecompositionCfg, + PhysxConvexHullCfg, + PhysxSDFMeshCfg, + PhysxTriangleMeshCfg, + PhysxTriangleMeshSimplificationCfg, +) from pxr import UsdGeom, UsdPhysics import isaaclab.sim as sim_utils from isaaclab.sim import SimulationCfg, SimulationContext +from isaaclab.sim.schemas import UsdPhysicsMeshCollisionCfg, apply_mesh_collision pytestmark = pytest.mark.integration @@ -51,22 +60,6 @@ def _has_authored_api_schema(prim, schema_name: str) -> bool: ) -# ------------------------------------------------------------------------------------- -# Fragment metadata + marker hierarchy -# ------------------------------------------------------------------------------------- - - -def test_mesh_collision_fragment_metadata_defaults(): - from isaaclab.sim.schemas import MeshCollisionFragment, SchemaFragment, UsdPhysicsMeshCollisionCfg - - cfg = UsdPhysicsMeshCollisionCfg(mesh_approximation_name="convexHull") - assert isinstance(cfg, MeshCollisionFragment) and isinstance(cfg, SchemaFragment) - assert type(cfg)._usd_namespace == "physics" - assert type(cfg)._usd_applied_schema is None # anchor applies MeshCollisionAPI, not the fragment - assert cfg.func == "isaaclab.sim.schemas:apply_mesh_collision" - assert cfg.mesh_approximation_name == "convexHull" - - # ------------------------------------------------------------------------------------- # Core USD fragment: physics:approximation token via apply_mesh_collision_properties # ------------------------------------------------------------------------------------- @@ -88,125 +81,89 @@ def test_usd_mesh_collision_fragment_writes_approximation_token(): # ------------------------------------------------------------------------------------- -# PhysX cooking fragments (isaaclab_physx): each writes its own physx*Collision namespace -# ------------------------------------------------------------------------------------- - - -def test_physx_convex_hull_fragment_writes_namespace(): - from isaaclab_physx.sim.schemas import PhysxConvexHullCfg - - from isaaclab.sim.schemas import apply_namespaced - - sim_utils.create_new_stage() - SimulationContext(SimulationCfg(dt=0.01)) - stage = sim_utils.get_current_stage() - prim = _make_xform(stage, "/World/M1") - UsdPhysics.MeshCollisionAPI.Apply(prim) - apply_namespaced(PhysxConvexHullCfg(hull_vertex_limit=32, min_thickness=0.002), "/World/M1", stage) - assert prim.GetAttribute("physxConvexHullCollision:hullVertexLimit").Get() == 32 - assert abs(prim.GetAttribute("physxConvexHullCollision:minThickness").Get() - 0.002) < 1e-6 - # ``mesh_approximation_name`` must NOT be authored as a namespaced attr by the generic applier. - assert not prim.HasAttribute("physxConvexHullCollision:meshApproximationName") - - -def test_physx_convex_decomposition_fragment_writes_namespace(): - from isaaclab_physx.sim.schemas import PhysxConvexDecompositionCfg - - from isaaclab.sim.schemas import apply_namespaced - - sim_utils.create_new_stage() - SimulationContext(SimulationCfg(dt=0.01)) - stage = sim_utils.get_current_stage() - prim = _make_xform(stage, "/World/M2") - UsdPhysics.MeshCollisionAPI.Apply(prim) - apply_namespaced(PhysxConvexDecompositionCfg(max_convex_hulls=8, shrink_wrap=True), "/World/M2", stage) - assert prim.GetAttribute("physxConvexDecompositionCollision:maxConvexHulls").Get() == 8 - assert prim.GetAttribute("physxConvexDecompositionCollision:shrinkWrap").Get() is True - - -def test_physx_triangle_mesh_fragment_writes_namespace(): - from isaaclab_physx.sim.schemas import PhysxTriangleMeshCfg - - from isaaclab.sim.schemas import apply_namespaced - - sim_utils.create_new_stage() - SimulationContext(SimulationCfg(dt=0.01)) - stage = sim_utils.get_current_stage() - prim = _make_xform(stage, "/World/M3") - UsdPhysics.MeshCollisionAPI.Apply(prim) - apply_namespaced(PhysxTriangleMeshCfg(weld_tolerance=0.01), "/World/M3", stage) - assert abs(prim.GetAttribute("physxTriangleMeshCollision:weldTolerance").Get() - 0.01) < 1e-6 - - -def test_physx_triangle_mesh_simplification_fragment_writes_namespace(): - from isaaclab_physx.sim.schemas import PhysxTriangleMeshSimplificationCfg - - from isaaclab.sim.schemas import apply_namespaced - - sim_utils.create_new_stage() - SimulationContext(SimulationCfg(dt=0.01)) - stage = sim_utils.get_current_stage() - prim = _make_xform(stage, "/World/M4") - UsdPhysics.MeshCollisionAPI.Apply(prim) - apply_namespaced(PhysxTriangleMeshSimplificationCfg(simplification_metric=0.7), "/World/M4", stage) - ns = "physxTriangleMeshSimplificationCollision" - assert abs(prim.GetAttribute(f"{ns}:simplificationMetric").Get() - 0.7) < 1e-6 - - -def test_physx_sdf_mesh_fragment_writes_namespace(): - from isaaclab_physx.sim.schemas import PhysxSDFMeshCfg - - from isaaclab.sim.schemas import apply_namespaced - - sim_utils.create_new_stage() - SimulationContext(SimulationCfg(dt=0.01)) - stage = sim_utils.get_current_stage() - prim = _make_xform(stage, "/World/M5") - UsdPhysics.MeshCollisionAPI.Apply(prim) - apply_namespaced(PhysxSDFMeshCfg(sdf_resolution=128, sdf_margin=0.02), "/World/M5", stage) - assert prim.GetAttribute("physxSDFMeshCollision:sdfResolution").Get() == 128 - assert abs(prim.GetAttribute("physxSDFMeshCollision:sdfMargin").Get() - 0.02) < 1e-6 - - -# ------------------------------------------------------------------------------------- -# Newton cooking fragments (isaaclab_newton): newton namespace + applied schema +# apply_mesh_collision: each fragment writes its own namespace and the token it implies # ------------------------------------------------------------------------------------- -def test_newton_mesh_collision_fragment_writes_namespace(): - from isaaclab_newton.sim.schemas import NewtonMeshCollisionCfg - - from isaaclab.sim.schemas import apply_namespaced - +@pytest.mark.parametrize( + ("fragment", "attrs", "token", "authored_schema"), + [ + ( + PhysxConvexHullCfg(hull_vertex_limit=32, min_thickness=0.002), + {"physxConvexHullCollision:hullVertexLimit": 32, "physxConvexHullCollision:minThickness": 0.002}, + "convexHull", + None, + ), + ( + PhysxConvexDecompositionCfg(max_convex_hulls=8, shrink_wrap=True), + { + "physxConvexDecompositionCollision:maxConvexHulls": 8, + "physxConvexDecompositionCollision:shrinkWrap": True, + }, + "convexDecomposition", + None, + ), + ( + PhysxTriangleMeshCfg(weld_tolerance=0.01), + {"physxTriangleMeshCollision:weldTolerance": 0.01}, + None, + None, + ), + ( + PhysxTriangleMeshSimplificationCfg(simplification_metric=0.7), + {"physxTriangleMeshSimplificationCollision:simplificationMetric": 0.7}, + "meshSimplification", + None, + ), + ( + PhysxSDFMeshCfg(sdf_resolution=128, sdf_margin=0.02), + {"physxSDFMeshCollision:sdfResolution": 128, "physxSDFMeshCollision:sdfMargin": 0.02}, + "sdf", + None, + ), + ( + UsdPhysicsMeshCollisionCfg(mesh_approximation_name="boundingSphere"), + {}, + "boundingSphere", + None, + ), + # Newton cooking fragments author no token; their API schemas are authored into the + # ``apiSchemas`` listOp but are not registered in this Newton build + ( + NewtonMeshCollisionCfg(max_hull_vertices=24), + {"newton:maxHullVertices": 24}, + None, + "NewtonMeshCollisionAPI", + ), + ( + NewtonSDFCollisionCfg(sdf_max_resolution=64, hydroelastic_enabled=True), + {"newton:sdfMaxResolution": 64, "newton:hydroelasticEnabled": True}, + None, + "NewtonSDFCollisionAPI", + ), + ], + ids=lambda value: type(value).__name__ if hasattr(value, "func") else None, +) +def test_apply_mesh_collision_writes_namespace_and_implied_token(fragment, attrs, token, authored_schema): + """The per-fragment func (the default ``func`` of every MeshCollisionFragment) writes the + fragment's namespaced cooking attrs AND the ``physics:approximation`` token it implies.""" sim_utils.create_new_stage() SimulationContext(SimulationCfg(dt=0.01)) stage = sim_utils.get_current_stage() - prim = _make_xform(stage, "/World/M6") + prim = _make_xform(stage, "/World/Mfunc") UsdPhysics.MeshCollisionAPI.Apply(prim) - apply_namespaced(NewtonMeshCollisionCfg(max_hull_vertices=24), "/World/M6", stage) - assert prim.GetAttribute("newton:maxHullVertices").Get() == 24 - # ``NewtonMeshCollisionAPI`` is authored into the ``apiSchemas`` listOp but is not a registered - # schema in this Newton build, so it is absent from the composed ``GetAppliedSchemas()``. - assert _has_authored_api_schema(prim, "NewtonMeshCollisionAPI") - + apply_mesh_collision(fragment, "/World/Mfunc", stage) -def test_newton_sdf_collision_fragment_writes_namespace(): - from isaaclab_newton.sim.schemas import NewtonSDFCollisionCfg - - from isaaclab.sim.schemas import apply_namespaced - - sim_utils.create_new_stage() - SimulationContext(SimulationCfg(dt=0.01)) - stage = sim_utils.get_current_stage() - prim = _make_xform(stage, "/World/M7") - UsdPhysics.MeshCollisionAPI.Apply(prim) - apply_namespaced(NewtonSDFCollisionCfg(sdf_max_resolution=64, hydroelastic_enabled=True), "/World/M7", stage) - assert prim.GetAttribute("newton:sdfMaxResolution").Get() == 64 - assert prim.GetAttribute("newton:hydroelasticEnabled").Get() is True - # ``NewtonSDFCollisionAPI`` is authored into the ``apiSchemas`` listOp (like the legacy cfg) but - # is not a registered schema in this Newton build, so it is absent from the composed - # ``GetAppliedSchemas()``. Assert the authored token, matching the legacy Newton test. - assert _has_authored_api_schema(prim, "NewtonSDFCollisionAPI") + for name, value in attrs.items(): + assert prim.GetAttribute(name).Get() == pytest.approx(value), name + if token is None: + assert not prim.GetAttribute("physics:approximation").HasAuthoredValue() + else: + assert prim.GetAttribute("physics:approximation").Get() == token + # ``mesh_approximation_name`` selects the token and is never authored as a namespaced attr + assert not any(attr.GetName().endswith(":meshApproximationName") for attr in prim.GetAttributes()) + if authored_schema is not None: + assert _has_authored_api_schema(prim, authored_schema) # ------------------------------------------------------------------------------------- @@ -304,63 +261,3 @@ def test_apply_mesh_collision_properties_accepts_generator(): # both passes ran: approximation token resolved AND the per-fragment namespaced attr written assert prim.GetAttribute("physics:approximation").Get() == "convexHull" assert prim.GetAttribute("physxConvexHullCollision:hullVertexLimit").Get() == 48 - - -# ------------------------------------------------------------------------------------- -# apply_mesh_collision: the per-fragment func carrying the approximation-token coupling -# ------------------------------------------------------------------------------------- - - -def test_apply_mesh_collision_writes_namespace_and_implied_token(): - # the per-fragment func (the default ``func`` of every MeshCollisionFragment) writes the - # fragment's namespaced cooking attrs AND the ``physics:approximation`` token it implies - from isaaclab_physx.sim.schemas import PhysxConvexHullCfg - - from isaaclab.sim.schemas import apply_mesh_collision - - sim_utils.create_new_stage() - SimulationContext(SimulationCfg(dt=0.01)) - stage = sim_utils.get_current_stage() - prim = _make_xform(stage, "/World/Mfunc") - UsdPhysics.MeshCollisionAPI.Apply(prim) - apply_mesh_collision(PhysxConvexHullCfg(hull_vertex_limit=16), "/World/Mfunc", stage) - assert prim.GetAttribute("physxConvexHullCollision:hullVertexLimit").Get() == 16 - assert prim.GetAttribute("physics:approximation").Get() == "convexHull" - - -def test_apply_mesh_collision_rejects_invalid_token(): - import pytest - - from isaaclab.sim.schemas import UsdPhysicsMeshCollisionCfg, apply_mesh_collision - - sim_utils.create_new_stage() - SimulationContext(SimulationCfg(dt=0.01)) - stage = sim_utils.get_current_stage() - _make_xform(stage, "/World/Mfunc2") - with pytest.raises(ValueError): - apply_mesh_collision(UsdPhysicsMeshCollisionCfg(mesh_approximation_name="notAToken"), "/World/Mfunc2", stage) - - -# ------------------------------------------------------------------------------------- -# Public imports -# ------------------------------------------------------------------------------------- - - -def test_public_imports(): - from isaaclab_newton.sim.schemas import NewtonMeshCollisionCfg, NewtonSDFCollisionCfg # noqa: F401 - from isaaclab_physx.sim.schemas import ( # noqa: F401 - PhysxConvexDecompositionCfg, - PhysxConvexHullCfg, - PhysxSDFMeshCfg, - PhysxTriangleMeshCfg, - PhysxTriangleMeshSimplificationCfg, - ) - - from isaaclab.sim.schemas import ( # noqa: F401 - MeshCollisionFragment, - SchemaFragment, - UsdPhysicsMeshCollisionCfg, - apply_mesh_collision, - apply_mesh_collision_properties, - apply_namespaced, - ) diff --git a/source/isaaclab/test/sim/test_mesh_converter.py b/source/isaaclab/test/sim/test_mesh_converter.py index 6a6a6e83963b..e3e759f7ad0d 100644 --- a/source/isaaclab/test/sim/test_mesh_converter.py +++ b/source/isaaclab/test/sim/test_mesh_converter.py @@ -19,13 +19,7 @@ import tempfile import pytest -from isaaclab_physx.sim.schemas import ( - PhysxConvexDecompositionCfg, - PhysxConvexHullCfg, - PhysxSDFMeshCfg, - PhysxTriangleMeshCfg, - PhysxTriangleMeshSimplificationCfg, -) +from isaaclab_physx.sim.schemas import PhysxConvexHullCfg from pxr import Usd, UsdGeom, UsdPhysics @@ -287,96 +281,6 @@ def test_collider_convex_hull(assets): check_mesh_collider_settings(mesh_converter) -def test_collider_convex_decomposition(assets): - """Convert an OBJ file using convex decomposition approximation""" - collision_props = [schemas_cfg.UsdPhysicsCollisionCfg(collision_enabled=True)] - mesh_collision_prop = [PhysxConvexDecompositionCfg()] - mesh_config = MeshConverterCfg( - asset_path=assets["obj"], - mesh_collision_props=mesh_collision_prop, - collision_props=collision_props, - ) - mesh_converter = MeshConverter(mesh_config) - - # check that mesh conversion is successful - check_mesh_collider_settings(mesh_converter) - - -def test_collider_triangle_mesh(assets): - """Convert an OBJ file using triangle mesh approximation""" - collision_props = [schemas_cfg.UsdPhysicsCollisionCfg(collision_enabled=True)] - mesh_collision_prop = [PhysxTriangleMeshCfg()] - mesh_config = MeshConverterCfg( - asset_path=assets["obj"], - mesh_collision_props=mesh_collision_prop, - collision_props=collision_props, - ) - mesh_converter = MeshConverter(mesh_config) - - # check that mesh conversion is successful - check_mesh_collider_settings(mesh_converter) - - -def test_collider_mesh_simplification(assets): - """Convert an OBJ file using mesh simplification approximation""" - collision_props = [schemas_cfg.UsdPhysicsCollisionCfg(collision_enabled=True)] - mesh_collision_prop = [PhysxTriangleMeshSimplificationCfg()] - mesh_config = MeshConverterCfg( - asset_path=assets["obj"], - mesh_collision_props=mesh_collision_prop, - collision_props=collision_props, - ) - mesh_converter = MeshConverter(mesh_config) - - # check that mesh conversion is successful - check_mesh_collider_settings(mesh_converter) - - -def test_collider_mesh_bounding_cube(assets): - """Convert an OBJ file using bounding cube approximation""" - collision_props = [schemas_cfg.UsdPhysicsCollisionCfg(collision_enabled=True)] - mesh_collision_prop = [schemas_cfg.UsdPhysicsMeshCollisionCfg(mesh_approximation_name="boundingCube")] - mesh_config = MeshConverterCfg( - asset_path=assets["obj"], - mesh_collision_props=mesh_collision_prop, - collision_props=collision_props, - ) - mesh_converter = MeshConverter(mesh_config) - - # check that mesh conversion is successful - check_mesh_collider_settings(mesh_converter) - - -def test_collider_mesh_bounding_sphere(assets): - """Convert an OBJ file using bounding sphere""" - collision_props = [schemas_cfg.UsdPhysicsCollisionCfg(collision_enabled=True)] - mesh_collision_prop = [schemas_cfg.UsdPhysicsMeshCollisionCfg(mesh_approximation_name="boundingSphere")] - mesh_config = MeshConverterCfg( - asset_path=assets["obj"], - mesh_collision_props=mesh_collision_prop, - collision_props=collision_props, - ) - mesh_converter = MeshConverter(mesh_config) - - # check that mesh conversion is successful - check_mesh_collider_settings(mesh_converter) - - -def test_collider_mesh_sdf(assets): - """Convert an OBJ file using signed distance field approximation""" - collision_props = [schemas_cfg.UsdPhysicsCollisionCfg(collision_enabled=True)] - mesh_collision_prop = [PhysxSDFMeshCfg()] - mesh_config = MeshConverterCfg( - asset_path=assets["obj"], - mesh_collision_props=mesh_collision_prop, - collision_props=collision_props, - ) - mesh_converter = MeshConverter(mesh_config) - - # check that mesh conversion is successful - check_mesh_collider_settings(mesh_converter) - - def test_collider_mesh_no_collision(assets): """Convert an OBJ file using bounding sphere with collision disabled""" collision_props = [schemas_cfg.UsdPhysicsCollisionCfg(collision_enabled=False)] diff --git a/source/isaaclab/test/sim/test_mjcf_converter.py b/source/isaaclab/test/sim/test_mjcf_converter.py index a1ecec3a0a8b..9c3ace3fedc4 100644 --- a/source/isaaclab/test/sim/test_mjcf_converter.py +++ b/source/isaaclab/test/sim/test_mjcf_converter.py @@ -202,30 +202,11 @@ def test_self_collision(test_setup_teardown, tmp_path): @pytest.mark.isaacsim_ci def test_collision_from_visuals(test_setup_teardown, tmp_path): - """Verify that ``collision_from_visuals=True`` runs successfully and produces a spawnable USD.""" + """Verify that ``collision_from_visuals=True`` with a non-default collision type produces a spawnable USD.""" sim, config = test_setup_teardown output_dir = os.path.join(str(tmp_path), "mjcf_collision_visuals") os.makedirs(output_dir, exist_ok=True) - config.collision_from_visuals = True - config.force_usd_conversion = True - config.usd_dir = output_dir - mjcf_converter = MjcfConverter(config) - - assert os.path.exists(mjcf_converter.usd_path), "USD file should exist after conversion" - - prim_path = "/World/Robot" - sim_utils.create_prim(prim_path, usd_path=mjcf_converter.usd_path) - assert sim.stage.GetPrimAtPath(prim_path).IsValid() - - -@pytest.mark.isaacsim_ci -def test_collision_type_convex_decomposition(test_setup_teardown, tmp_path): - """Verify that ``collision_type='Convex Decomposition'`` runs without error.""" - sim, config = test_setup_teardown - output_dir = os.path.join(str(tmp_path), "mjcf_convex_decomp") - os.makedirs(output_dir, exist_ok=True) - config.collision_from_visuals = True config.collision_type = "Convex Decomposition" config.force_usd_conversion = True @@ -241,10 +222,10 @@ def test_collision_type_convex_decomposition(test_setup_teardown, tmp_path): @pytest.mark.isaacsim_ci def test_link_density(test_setup_teardown, tmp_path): - """Verify that ``link_density`` applies density without errors. + """Verify that ``link_density`` applies density to bodies without an explicit mass. - ``nv_ant.xml`` has explicit inertial data on most bodies, so density is only applied where - mass is unspecified. This test ensures the pipeline runs and the output is spawnable. + ``nv_ant.xml`` derives body masses from its geoms, so the bodies carry no explicit mass and get the + configured density. """ sim, config = test_setup_teardown output_dir = os.path.join(str(tmp_path), "mjcf_link_density") @@ -258,8 +239,10 @@ def test_link_density(test_setup_teardown, tmp_path): from pxr import Usd, UsdPhysics stage = Usd.Stage.Open(mjcf_converter.usd_path) - mass_prims = [p for p in stage.Traverse() if p.HasAPI(UsdPhysics.MassAPI)] - assert len(mass_prims) > 0, "Expected prims with MassAPI" + # bodies without an explicit mass get the configured density + torso = next(p for p in stage.Traverse() if p.GetName() == "torso") + assert torso.HasAPI(UsdPhysics.MassAPI) + assert torso.GetAttribute("physics:density").Get() == pytest.approx(500.0) prim_path = "/World/Robot" sim_utils.create_prim(prim_path, usd_path=mjcf_converter.usd_path) @@ -287,7 +270,7 @@ def test_merge_mesh(test_setup_teardown, tmp_path): @pytest.mark.isaacsim_ci def test_import_physics_scene(test_setup_teardown, tmp_path): - """Verify that ``import_physics_scene=True`` still produces a spawnable USD.""" + """Verify that ``import_physics_scene=True`` writes the MJCF physics scene into the converted asset.""" sim, config = test_setup_teardown output_dir = os.path.join(str(tmp_path), "mjcf_physics_scene") os.makedirs(output_dir, exist_ok=True) @@ -299,6 +282,21 @@ def test_import_physics_scene(test_setup_teardown, tmp_path): assert os.path.exists(mjcf_converter.usd_path), "USD file should exist after conversion" + # the imported scene is authored into the asset's layers (it is not composed under the default prim) + from pxr import Usd + + stage = Usd.Stage.Open(mjcf_converter.usd_path) + scene_paths = [] + for layer in stage.GetUsedLayers(): + + def _collect(path, layer=layer): + spec = layer.GetPrimAtPath(path) if path.IsPrimPath() else None + if spec and spec.typeName == "PhysicsScene": + scene_paths.append(path) + + layer.Traverse(layer.pseudoRoot.path, _collect) + assert scene_paths, "Expected the converted asset layers to define a PhysicsScene" + @pytest.mark.isaacsim_ci def test_run_asset_transformer_disabled(test_setup_teardown, tmp_path): diff --git a/source/isaaclab/test/sim/test_newton_manager_visualization_state.py b/source/isaaclab/test/sim/test_newton_manager_visualization_state.py index bfff638c8f60..3574b205f4a9 100644 --- a/source/isaaclab/test/sim/test_newton_manager_visualization_state.py +++ b/source/isaaclab/test/sim/test_newton_manager_visualization_state.py @@ -49,33 +49,6 @@ def _make_surface_cloth_stage(path: str = "/World/envs/env_0/Cloth"): return stage -def test_physics_manager_close_only_clears_active_manager_binding(monkeypatch): - """Only the active physics manager can clear shared SimulationContext state.""" - from isaaclab.physics import PhysicsManager - - class _ActiveManager(PhysicsManager): - _callbacks = {} - - class _InactiveManager(PhysicsManager): - pass - - _ActiveManager.close() - assert PhysicsManager._sim is None - - active_sim = SimpleNamespace(physics_manager=_ActiveManager) - monkeypatch.setattr(PhysicsManager, "_sim", active_sim, raising=False) - monkeypatch.setattr(PhysicsManager, "_cfg", "active-cfg", raising=False) - monkeypatch.setattr(PhysicsManager, "_sim_time", 1.25, raising=False) - - monkeypatch.setattr(PhysicsManager, "_callbacks", {1: (None, lambda _: None, 0, "stale", None)}, raising=False) - _InactiveManager.close() - assert PhysicsManager._callbacks == {} - assert (PhysicsManager._sim, PhysicsManager._cfg, PhysicsManager._sim_time) == (active_sim, "active-cfg", 1.25) - - _ActiveManager.close() - assert (PhysicsManager._sim, PhysicsManager._cfg, PhysicsManager._sim_time) == (None, None, 0.0) - - @pytest.mark.parametrize(("body_count", "particle_count"), [(0, 0), (3, 0), (0, 4)]) def test_visualization_model_is_built_during_clone_and_allocated_on_physics_ready( monkeypatch, body_count, particle_count diff --git a/source/isaaclab/test/sim/test_newton_model_utils.py b/source/isaaclab/test/sim/test_newton_model_utils.py index 4edfa08934ba..3820ebdd6034 100644 --- a/source/isaaclab/test/sim/test_newton_model_utils.py +++ b/source/isaaclab/test/sim/test_newton_model_utils.py @@ -20,8 +20,6 @@ from isaaclab.sim.utils.newton_model_utils import ( _OMNIPBR_DEFAULTS, _UNBOUND_DEFAULT_FALLBACK_GRAY, - _get_omnipbr_albedo, - _resolve_shape_color, replace_newton_builder_shape_colors, ) @@ -148,96 +146,6 @@ def linear_to_srgb(c: float) -> float: return (linear_to_srgb(r), linear_to_srgb(g), linear_to_srgb(b)) -@pytest.mark.parametrize(("diffuse_color_constant", "diffuse_tint"), _OMNIPBR_ALBEDO_INPUT_CASES) -def test_get_omnipbr_albedo( - diffuse_color_constant: tuple[float, float, float] | None, - diffuse_tint: tuple[float, float, float] | None, -): - """``_get_omnipbr_albedo`` is diffuse × tint per channel; ``None`` means that shader input is not authored. - - Unauthored inputs use ``_OMNIPBR_DEFAULTS`` in ``newton_model_utils``. - """ - stage = Usd.Stage.CreateInMemory() - - shader = _make_omnipbr_test_shader(stage, "/World/Mat") - if diffuse_color_constant is not None: - diffuse_inp = shader.CreateInput("diffuse_color_constant", Sdf.ValueTypeNames.Color3f) - diffuse_inp.Set(Gf.Vec3f(*diffuse_color_constant)) - if diffuse_tint is not None: - tint_inp = shader.CreateInput("diffuse_tint", Sdf.ValueTypeNames.Color3f) - tint_inp.Set(Gf.Vec3f(*diffuse_tint)) - - expected_albedo = _expected_omnipbr_linear_albedo(diffuse_color_constant, diffuse_tint) - - shader_prim = shader.GetPrim() - assert shader_prim.IsValid() - assert _get_omnipbr_albedo(shader_prim) == pytest.approx(expected_albedo, rel=1e-5) - - -def test_resolve_shape_color_invalid_prim(): - """Invalid prim path yields ``None`` (no replacement).""" - stage = Usd.Stage.CreateInMemory() - assert _resolve_shape_color(stage, "/World/Missing", {}) is None - - -def test_resolve_shape_color_guide_purpose(): - """Guide-purpose geometry is left on Newton's palette (no resolved replacement).""" - stage = Usd.Stage.CreateInMemory() - - mesh = UsdGeom.Mesh.Define(stage, "/World/GuideMesh") - assert mesh.GetPrim().IsValid() - purpose_attr = UsdGeom.Imageable(mesh).GetPurposeAttr() - assert purpose_attr.IsValid() - purpose_attr.Set(UsdGeom.Tokens.guide) - - assert _resolve_shape_color(stage, "/World/GuideMesh", {}) is None - - -def test_resolve_shape_color_no_material_binding(): - """Unbound mesh without ``displayColor``: neutral linear gray fallback.""" - stage = Usd.Stage.CreateInMemory() - - mesh = UsdGeom.Mesh.Define(stage, "/World/Mesh") - assert mesh.GetPrim().IsValid() - - # Default fallback gray should be returned when there is no material binding and no display color. - material_color_cache: dict[str, tuple[float, float, float] | None] = {} - out = _resolve_shape_color(stage, "/World/Mesh", material_color_cache) - assert out == pytest.approx(_UNBOUND_DEFAULT_FALLBACK_GRAY, rel=1e-5) - - # Add display color primvar - pv = UsdGeom.PrimvarsAPI(mesh).CreatePrimvar( - "displayColor", Sdf.ValueTypeNames.Color3fArray, UsdGeom.Tokens.constant, 1 - ) - - # Set an arbitrary color. - display_color = (0.11, 0.55, 0.9) - pv.Set([Gf.Vec3f(*display_color)]) - - # The display color should be returned instead of the fallback gray. - out = _resolve_shape_color(stage, "/World/Mesh", material_color_cache) - assert out == pytest.approx(display_color, rel=1e-5) - - -@pytest.mark.parametrize(("diffuse_color_constant", "diffuse_tint"), _OMNIPBR_ALBEDO_INPUT_CASES) -def test_resolve_shape_color_omnipbr_binding( - diffuse_color_constant: tuple[float, float, float] | None, - diffuse_tint: tuple[float, float, float] | None, -): - """Bound OmniPBR mesh: :func:`_resolve_shape_color` matches diffuse × tint.""" - stage, _shader, mesh_path = _make_mesh_bound_to_omnipbr_test_material(diffuse_color_constant, diffuse_tint) - expected_albedo = _expected_omnipbr_linear_albedo(diffuse_color_constant, diffuse_tint) - - out = _resolve_shape_color(stage, mesh_path, {}) - assert out == pytest.approx(expected_albedo, rel=1e-5) - - -def test_resolve_shape_color_neutral_material_binding(): - """Bound ``UsdPreviewSurface`` material: not OmniPBR, so resolution is ``None`` (Newton row unchanged).""" - stage, mesh_path = _make_preview_surface_bound_mesh_stage() - assert _resolve_shape_color(stage, mesh_path, {}) is None - - def test_replace_newton_builder_shape_colors_warning(): """A :exc:`FutureWarning` is expected by default.""" builder = SimpleNamespace(shape_label=None, shape_color=None) diff --git a/source/isaaclab/test/sim/test_physics_manager_lifecycle.py b/source/isaaclab/test/sim/test_physics_manager_lifecycle.py index 5b793863e282..c692baa1bc77 100644 --- a/source/isaaclab/test/sim/test_physics_manager_lifecycle.py +++ b/source/isaaclab/test/sim/test_physics_manager_lifecycle.py @@ -9,6 +9,7 @@ import inspect import weakref from dataclasses import dataclass, replace +from pathlib import Path from types import SimpleNamespace from unittest.mock import Mock @@ -23,6 +24,18 @@ def test_backend_registry_identity_and_lifecycle(): """Share by type/cfg, construct once, and release only the selected resource after successful cleanup.""" from isaaclab.sim import BackendCfg, SimulationContext + # SimulationContext owns backends directly, with no second registry or lookup layer. + sim_package = Path(__file__).parents[2] / "isaaclab" / "sim" + assert not (sim_package / "service_locator.py").exists() + assert not hasattr(SimulationContext, "services") + assert issubclass(RendererCfg, BackendCfg) + assert not hasattr(RenderContext, "get_renderer") + assert "_renderer_entries" not in RenderContext.__slots__ + assert tuple(inspect.signature(SimulationContext.get_or_create_backend).parameters) == ("self", "cfg") + assert all( + "resource_key" not in cfg_type.__dataclass_fields__ for cfg_type in (PhysicsCfg, RendererCfg, VisualizerCfg) + ) + @dataclass(kw_only=True) class Cfg(BackendCfg): values: list[int] @@ -81,21 +94,30 @@ class OtherBackend(Backend): assert second.close.call_count == 2 -def test_backend_ownership_has_no_service_locator_or_resource_keys(): - """Backend ownership stays directly on SimulationContext and uses cfg identity, not custom keys.""" - from pathlib import Path +def test_physics_manager_close_only_clears_active_manager_binding(monkeypatch): + """Only the active physics manager can clear shared SimulationContext state.""" - from isaaclab.sim import BackendCfg, SimulationContext + class _ActiveManager(PhysicsManager): + _callbacks = {} - sim_package = Path(__file__).parents[2] / "isaaclab" / "sim" - assert not (sim_package / "service_locator.py").exists() - assert not hasattr(SimulationContext, "services") - assert issubclass(RendererCfg, BackendCfg) - assert not hasattr(RenderContext, "get_renderer") - assert "_renderer_entries" not in RenderContext.__slots__ - assert tuple(inspect.signature(SimulationContext.get_or_create_backend).parameters) == ("self", "cfg") - cfg_types = (PhysicsCfg, RendererCfg, VisualizerCfg) - assert all("resource_key" not in cfg_type.__dataclass_fields__ for cfg_type in cfg_types) + class _InactiveManager(PhysicsManager): + pass + + _ActiveManager.close() + assert PhysicsManager._sim is None + + active_sim = SimpleNamespace(physics_manager=_ActiveManager) + monkeypatch.setattr(PhysicsManager, "_sim", active_sim, raising=False) + monkeypatch.setattr(PhysicsManager, "_cfg", "active-cfg", raising=False) + monkeypatch.setattr(PhysicsManager, "_sim_time", 1.25, raising=False) + + monkeypatch.setattr(PhysicsManager, "_callbacks", {1: (None, lambda _: None, 0, "stale", None)}, raising=False) + _InactiveManager.close() + assert PhysicsManager._callbacks == {} + assert (PhysicsManager._sim, PhysicsManager._cfg, PhysicsManager._sim_time) == (active_sim, "active-cfg", 1.25) + + _ActiveManager.close() + assert (PhysicsManager._sim, PhysicsManager._cfg, PhysicsManager._sim_time) == (None, None, 0.0) def test_close_runs_all_live_stop_listeners_and_aggregates_failures(monkeypatch): diff --git a/source/isaaclab/test/sim/test_rendering_kit_defaults.py b/source/isaaclab/test/sim/test_rendering_kit_defaults.py index 7dc105bf016f..cc1aa1db421e 100644 --- a/source/isaaclab/test/sim/test_rendering_kit_defaults.py +++ b/source/isaaclab/test/sim/test_rendering_kit_defaults.py @@ -10,8 +10,6 @@ import re from pathlib import Path -import pytest - _APPS_DIR = Path(__file__).resolve().parents[4] / "apps" # Kit loaded when cameras are enabled. @@ -78,14 +76,6 @@ def _kit_value(content: str, key: str) -> str | None: return match.group(1).split("#", 1)[0].strip() -@pytest.mark.parametrize("kit_name", _RENDERING_KIT) -def test_rendering_kits_define_high_fidelity_defaults(kit_name): - """Rendering kits carry the high-fidelity RTX defaults.""" - content = _read_kit(kit_name) - missing = [key for key in _HIGH_FIDELITY_DEFAULTS if not _kit_defines(content, key)] - assert not missing, f"{kit_name} is missing high-fidelity RTX defaults: {missing}" - - def test_base_kit_omits_high_fidelity_defaults(): """Base kit (cameras disabled) omits the high-fidelity RTX defaults.""" content = _read_kit(_BASE_KIT) @@ -97,6 +87,8 @@ def test_rendering_kits_agree_on_rtx_defaults(): """Both rendering kits define identical values for every shared RTX default.""" primary, secondary = _RENDERING_KIT primary_content, secondary_content = _read_kit(primary), _read_kit(secondary) + missing = [key for key in _HIGH_FIDELITY_DEFAULTS if _kit_value(primary_content, key) is None] + assert not missing, f"{primary} is missing high-fidelity RTX defaults: {missing}" mismatches = {} for key in _SHARED_RTX_DEFAULTS: primary_value = _kit_value(primary_content, key) diff --git a/source/isaaclab/test/sim/test_schema_fragment_parity.py b/source/isaaclab/test/sim/test_schema_fragment_parity.py index d977d47b6a22..c0695c9bad14 100644 --- a/source/isaaclab/test/sim/test_schema_fragment_parity.py +++ b/source/isaaclab/test/sim/test_schema_fragment_parity.py @@ -277,27 +277,11 @@ def test_joint_drive_fragments_match_legacy_authoring(joint_type): assert authored[f"drive:{instance}:physics:stiffness"] == pytest.approx(100.0 * scale) assert authored[f"drive:{instance}:physics:damping"] == pytest.approx(10.0 * scale) assert authored[f"drive:{instance}:physics:maxForce"] == pytest.approx(87.0) + assert authored[f"drive:{instance}:physics:type"] == "force" velocity_scale = 180.0 / math.pi if joint_type == "revolute" else 1.0 assert authored["physxJoint:maxJointVelocity"] == pytest.approx(3.0 * velocity_scale) -def test_migrated_consumers_select_joint_drive_fragments(source_checkout_root): - """Joint-drive slots combine USD and PhysX fragments and cannot be read as one cfg.""" - import ast - - violations = [] - for backend in ("newton", "physx", "ov"): - path = source_checkout_root / f"source/isaaclab_{backend}/test/assets/test_articulation.py" - for node in ast.walk(ast.parse(path.read_text())): - if ( - isinstance(node, ast.Attribute) - and isinstance(node.value, ast.Attribute) - and node.value.attr == "joint_drive_props" - ): - violations.append(f"{path.relative_to(source_checkout_root)}:{node.lineno}: {ast.unparse(node)}") - assert not violations, "Single-config access on fragment lists:\n" + "\n".join(violations) - - def test_asset_and_task_configs_use_bare_single_fragments(source_checkout_root): """Single schema fragments stay bare; lists are reserved for multiple fragments.""" import ast diff --git a/source/isaaclab/test/sim/test_schema_fragments.py b/source/isaaclab/test/sim/test_schema_fragments.py index 9f2615e3f3e0..05b5a7a81e71 100644 --- a/source/isaaclab/test/sim/test_schema_fragments.py +++ b/source/isaaclab/test/sim/test_schema_fragments.py @@ -34,22 +34,6 @@ def _make_xform(stage, path="/World/Body"): return stage.GetPrimAtPath(path) -# ------------------------------------------------------------------------------------- -# SchemaFragment base, RigidBodyFragment marker, UsdPhysicsRigidBodyCfg -# ------------------------------------------------------------------------------------- - - -def test_fragment_metadata_defaults(): - from isaaclab.sim.schemas import RigidBodyFragment, SchemaFragment, UsdPhysicsRigidBodyCfg - - cfg = UsdPhysicsRigidBodyCfg(rigid_body_enabled=True) - assert isinstance(cfg, RigidBodyFragment) and isinstance(cfg, SchemaFragment) - assert type(cfg)._usd_namespace == "physics" - assert type(cfg)._usd_applied_schema is None # anchor applies RigidBodyAPI, not the fragment - assert cfg.func == "isaaclab.sim.schemas:apply_namespaced" - assert cfg.rigid_body_enabled is True and cfg.kinematic_enabled is None - - # ------------------------------------------------------------------------------------- # apply_namespaced generic applier # ------------------------------------------------------------------------------------- @@ -70,61 +54,6 @@ def test_apply_namespaced_writes_only_set_fields(): assert not prim.GetAttribute("physics:kinematicEnabled").HasAuthoredValue() -# ------------------------------------------------------------------------------------- -# PhysxRigidBodyCfg (isaaclab_physx) -# ------------------------------------------------------------------------------------- - - -def test_physx_rigid_body_fragment_writes_physx_namespace(): - from isaaclab_physx.sim.schemas import PhysxRigidBodyCfg - - from isaaclab.sim.schemas import apply_namespaced - - sim_utils.create_new_stage() - SimulationContext(SimulationCfg(dt=0.01)) - stage = sim_utils.get_current_stage() - prim = _make_xform(stage, "/World/B2") - UsdPhysics.RigidBodyAPI.Apply(prim) - apply_namespaced(PhysxRigidBodyCfg(linear_damping=0.1, disable_gravity=True), "/World/B2", stage) - assert abs(prim.GetAttribute("physxRigidBody:linearDamping").Get() - 0.1) < 1e-6 - assert prim.GetAttribute("physxRigidBody:disableGravity").Get() is True - - -# ------------------------------------------------------------------------------------- -# MujocoRigidBodyCfg (isaaclab_newton) -# ------------------------------------------------------------------------------------- - - -def test_mujoco_rigid_body_fragment_writes_mjc_namespace(): - from isaaclab_newton.sim.schemas import MujocoRigidBodyCfg - - from isaaclab.sim.schemas import apply_namespaced - - sim_utils.create_new_stage() - SimulationContext(SimulationCfg(dt=0.01)) - stage = sim_utils.get_current_stage() - prim = _make_xform(stage, "/World/B3") - UsdPhysics.RigidBodyAPI.Apply(prim) - apply_namespaced(MujocoRigidBodyCfg(gravcomp=1.0), "/World/B3", stage) - assert abs(prim.GetAttribute("mjc:gravcomp").Get() - 1.0) < 1e-6 - - -def test_mujoco_rigid_body_fragment_does_not_write_gravcomp_when_none(): - # fragment-path equivalent of the legacy test_mujoco_gravcomp_not_written_when_none: - # an unset gravcomp must not author mjc:gravcomp - from isaaclab_newton.sim.schemas import MujocoRigidBodyCfg - - from isaaclab.sim.schemas import apply_namespaced - - sim_utils.create_new_stage() - SimulationContext(SimulationCfg(dt=0.01)) - stage = sim_utils.get_current_stage() - prim = _make_xform(stage, "/World/B3b") - UsdPhysics.RigidBodyAPI.Apply(prim) - apply_namespaced(MujocoRigidBodyCfg(), "/World/B3b", stage) - assert prim.GetAttribute("mjc:gravcomp").Get() is None - - # ------------------------------------------------------------------------------------- # apply_rigid_body_properties dispatch (explicit anchor creation + multi-namespace) # ------------------------------------------------------------------------------------- @@ -144,7 +73,7 @@ def test_apply_rigid_body_properties_composes_namespaces(): "/World/B4", [ UsdPhysicsRigidBodyCfg(rigid_body_enabled=True), - PhysxRigidBodyCfg(linear_damping=0.2), + PhysxRigidBodyCfg(linear_damping=0.2, disable_gravity=True), MujocoRigidBodyCfg(gravcomp=1.0), ], create_if_missing=True, @@ -154,49 +83,10 @@ def test_apply_rigid_body_properties_composes_namespaces(): assert bool(UsdPhysics.RigidBodyAPI(prim)) # anchor created on the bare prim assert prim.GetAttribute("physics:rigidBodyEnabled").Get() is True assert abs(prim.GetAttribute("physxRigidBody:linearDamping").Get() - 0.2) < 1e-6 + assert prim.GetAttribute("physxRigidBody:disableGravity").Get() is True assert abs(prim.GetAttribute("mjc:gravcomp").Get() - 1.0) < 1e-6 -# ------------------------------------------------------------------------------------- -# spawner slot accepts a fragment mapping + routing by type -# ------------------------------------------------------------------------------------- - - -def test_spawn_shape_with_rigid_fragment_list(): - from isaaclab_physx.sim.schemas import PhysxRigidBodyCfg - - from isaaclab.sim.schemas import UsdPhysicsRigidBodyCfg - - sim_utils.create_new_stage() - SimulationContext(SimulationCfg(dt=0.01)) - cfg = sim_utils.CuboidCfg( - size=(1, 1, 1), - rigid_props={"": [UsdPhysicsRigidBodyCfg(rigid_body_enabled=True), PhysxRigidBodyCfg(linear_damping=0.3)]}, - ) - cfg.func("/World/Cube", cfg) - prim = sim_utils.get_current_stage().GetPrimAtPath("/World/Cube") - assert bool(UsdPhysics.RigidBodyAPI(prim)) - assert abs(prim.GetAttribute("physxRigidBody:linearDamping").Get() - 0.3) < 1e-6 - - -# ------------------------------------------------------------------------------------- -# public imports -# ------------------------------------------------------------------------------------- - - -def test_public_imports(): - from isaaclab_newton.sim.schemas import MujocoRigidBodyCfg # noqa: F401 - from isaaclab_physx.sim.schemas import PhysxRigidBodyCfg # noqa: F401 - - from isaaclab.sim.schemas import ( # noqa: F401 - RigidBodyFragment, - SchemaFragment, - UsdPhysicsRigidBodyCfg, - apply_namespaced, - apply_rigid_body_properties, - ) - - # ------------------------------------------------------------------------------------- # Review follow-ups -- prim-validity guard, aggregated return, namespace invariant guard # ------------------------------------------------------------------------------------- @@ -213,22 +103,14 @@ def test_apply_namespaced_raises_on_invalid_prim(): apply_namespaced(UsdPhysicsRigidBodyCfg(rigid_body_enabled=True), "/World/DoesNotExist", stage) -def test_apply_rigid_body_properties_warns_on_unmatched_path(caplog): - from isaaclab.sim.schemas import UsdPhysicsRigidBodyCfg, apply_rigid_body_properties +@pytest.mark.parametrize("family", ["rigid_body", "mass"]) +def test_apply_family_properties_aggregates_fragment_results(family): + from isaaclab.sim.schemas import MassCfg, UsdPhysicsRigidBodyCfg, apply_mass_properties, apply_rigid_body_properties - sim_utils.create_new_stage() - SimulationContext(SimulationCfg(dt=0.01)) - stage = sim_utils.get_current_stage() - with caplog.at_level("WARNING"): - result = apply_rigid_body_properties( - "/World/DoesNotExist", [UsdPhysicsRigidBodyCfg(rigid_body_enabled=True)], stage=stage - ) - assert result is False - assert "/World/DoesNotExist" in caplog.text - - -def test_apply_rigid_body_properties_aggregates_fragment_results(): - from isaaclab.sim.schemas import UsdPhysicsRigidBodyCfg, apply_rigid_body_properties + writer, make_fragment = { + "rigid_body": (apply_rigid_body_properties, lambda: UsdPhysicsRigidBodyCfg(rigid_body_enabled=True)), + "mass": (apply_mass_properties, lambda: MassCfg(mass=1.0)), + }[family] sim_utils.create_new_stage() SimulationContext(SimulationCfg(dt=0.01)) @@ -236,13 +118,13 @@ def test_apply_rigid_body_properties_aggregates_fragment_results(): _make_xform(stage, "/World/Agg") # a fragment whose applier reports failure must make the aggregate return False - failing = UsdPhysicsRigidBodyCfg(rigid_body_enabled=True) + failing = make_fragment() failing.func = lambda cfg, prim_path, stage=None: False - assert apply_rigid_body_properties("/World/Agg", [failing], create_if_missing=True, stage=stage) is False + assert writer("/World/Agg", [failing], create_if_missing=True, stage=stage) is False # all-succeeding fragments return True - ok = UsdPhysicsRigidBodyCfg(rigid_body_enabled=True) - assert apply_rigid_body_properties("/World/Agg", [ok], stage=stage) is True + ok = make_fragment() + assert writer("/World/Agg", [ok], create_if_missing=True, stage=stage) is True def test_apply_namespaced_raises_without_namespace(): diff --git a/source/isaaclab/test/sim/test_schema_writer_nested_targets.py b/source/isaaclab/test/sim/test_schema_writer_nested_targets.py index fc3cb37513b7..fac775469020 100644 --- a/source/isaaclab/test/sim/test_schema_writer_nested_targets.py +++ b/source/isaaclab/test/sim/test_schema_writer_nested_targets.py @@ -80,7 +80,8 @@ def _spawn_robot(tmp_path, prim_path: str, **cfg_kwargs): def test_rigid_body_fragments_target_existing_bodies_on_usd_asset(tmp_path): - """Fragment ``rigid_props`` on a USD robot must modify the existing link bodies in place. + """Fragment ``rigid_props``, ``collision_props``, and ``mass_props`` on a USD robot must modify + the existing link bodies, colliders, and mass prims in place. Force-applying ``RigidBodyAPI`` on the spawn prim would invent a body the asset's joints never reference and change the asset's dynamics. The fragment path must match the legacy @@ -91,50 +92,29 @@ def test_rigid_body_fragments_target_existing_bodies_on_usd_asset(tmp_path): tmp_path, "/World/RobotA", rigid_props={"(/.*)?": [PhysxRigidBodyCfg(max_depenetration_velocity=5.0)]}, + collision_props={"(/.*)?": [PhysxCollisionCfg(contact_offset=0.02)]}, + mass_props={"(/.*)?": [MassCfg(mass=2.0)]}, ) spawn_prim = stage.GetPrimAtPath("/World/RobotA") # the spawn prim keeps its articulation root and gains NO rigid-body anchor or attrs assert spawn_prim.HasAPI(UsdPhysics.ArticulationRootAPI) assert not spawn_prim.HasAPI(UsdPhysics.RigidBodyAPI) assert not spawn_prim.GetAttribute("physxRigidBody:maxDepenetrationVelocity").HasAuthoredValue() + assert not spawn_prim.HasAPI(UsdPhysics.CollisionAPI) + assert not spawn_prim.GetAttribute("physxCollision:contactOffset").HasAuthoredValue() + assert not spawn_prim.HasAPI(UsdPhysics.MassAPI) + assert not spawn_prim.GetAttribute("physics:mass").HasAuthoredValue() # every existing link body received the fragment's attribute, including the nested one for link_name in LINK_REL_PATHS: link = stage.GetPrimAtPath(f"/World/RobotA/{link_name}") assert link.HasAPI(UsdPhysics.RigidBodyAPI) assert link.GetAttribute("physxRigidBody:maxDepenetrationVelocity").Get() == pytest.approx(5.0), link_name - - -def test_collision_fragments_target_existing_colliders_on_usd_asset(tmp_path): - """Fragment ``collision_props`` must modify the asset's existing collider prims in place.""" - stage = _spawn_robot( - tmp_path, - "/World/RobotB", - collision_props={"(/.*)?": [PhysxCollisionCfg(contact_offset=0.02)]}, - ) - spawn_prim = stage.GetPrimAtPath("/World/RobotB") - assert not spawn_prim.HasAPI(UsdPhysics.CollisionAPI) - assert not spawn_prim.GetAttribute("physxCollision:contactOffset").HasAuthoredValue() - for link_name in LINK_REL_PATHS: - collider = stage.GetPrimAtPath(f"/World/RobotB/{link_name}/collider") + assert link.GetAttribute("physics:mass").Get() == pytest.approx(2.0), link_name + collider = stage.GetPrimAtPath(f"/World/RobotA/{link_name}/collider") assert collider.HasAPI(UsdPhysics.CollisionAPI) assert collider.GetAttribute("physxCollision:contactOffset").Get() == pytest.approx(0.02), link_name -def test_mass_fragments_target_existing_mass_prims_on_usd_asset(tmp_path): - """Fragment ``mass_props`` must modify the asset's existing mass-bearing link prims in place.""" - stage = _spawn_robot( - tmp_path, - "/World/RobotC", - mass_props={"(/.*)?": [MassCfg(mass=2.0)]}, - ) - spawn_prim = stage.GetPrimAtPath("/World/RobotC") - assert not spawn_prim.HasAPI(UsdPhysics.MassAPI) - assert not spawn_prim.GetAttribute("physics:mass").HasAuthoredValue() - for link_name in LINK_REL_PATHS: - link = stage.GetPrimAtPath(f"/World/RobotC/{link_name}") - assert link.GetAttribute("physics:mass").Get() == pytest.approx(2.0), link_name - - def test_fragment_and_legacy_paths_place_apis_identically_on_usd_asset(tmp_path): """On a real asset topology, the fragment path must place APIs exactly where legacy does. @@ -211,76 +191,83 @@ def test_fragment_dicts_target_and_override_in_insertion_order(tmp_path): # ------------------------------------------------------------------------------------- -def test_rigid_body_fragments_create_on_bare_prim(): - """With ``create_if_missing`` and one non-carrier match, the writer anchors that prim.""" - from isaaclab.sim.schemas import apply_rigid_body_properties - - sim_utils.create_new_stage() - SimulationContext(SimulationCfg(dt=0.01)) - stage = sim_utils.get_current_stage() - UsdGeom.Xform.Define(stage, "/World/Bare") - result = apply_rigid_body_properties( - "/World/Bare", [PhysxRigidBodyCfg(max_depenetration_velocity=3.0)], create_if_missing=True, stage=stage - ) - prim = stage.GetPrimAtPath("/World/Bare") - assert result is True - assert prim.HasAPI(UsdPhysics.RigidBodyAPI) - assert prim.GetAttribute("physxRigidBody:maxDepenetrationVelocity").Get() == pytest.approx(3.0) - +def _family_writer(family: str): + """Return ``(writer, carrier API, fragment, authored attribute, value)`` for a fragment family. -def test_rigid_body_fragments_create_on_every_matched_prim(): - """Creation applies ``RigidBodyAPI`` to every matched prim lacking it; the expression is trusted.""" - from isaaclab.sim.schemas import apply_rigid_body_properties + The rigid-body, collision, and mass writers share the targeting logic but each carries its + own copy, so the targeting tests run once per family. + """ + from isaaclab.sim.schemas import apply_collision_properties, apply_mass_properties, apply_rigid_body_properties + + return { + "rigid_body": ( + apply_rigid_body_properties, + UsdPhysics.RigidBodyAPI, + PhysxRigidBodyCfg(max_depenetration_velocity=3.0), + "physxRigidBody:maxDepenetrationVelocity", + 3.0, + ), + "collision": ( + apply_collision_properties, + UsdPhysics.CollisionAPI, + PhysxCollisionCfg(contact_offset=0.02), + "physxCollision:contactOffset", + 0.02, + ), + "mass": (apply_mass_properties, UsdPhysics.MassAPI, MassCfg(mass=1.0), "physics:mass", 1.0), + }[family] + + +@pytest.mark.parametrize("family", ["rigid_body", "collision"]) +def test_family_fragments_create_on_every_matched_prim(family): + """Creation applies the carrier API to every matched prim lacking it; the expression is trusted.""" + writer, api, fragment, attr, value = _family_writer(family) sim_utils.create_new_stage() SimulationContext(SimulationCfg(dt=0.01)) stage = sim_utils.get_current_stage() for path in ("/World/Grp", "/World/Grp/a", "/World/Grp/b"): UsdGeom.Xform.Define(stage, path) - result = apply_rigid_body_properties( - "/World/Grp(/.*)?", [PhysxRigidBodyCfg(max_depenetration_velocity=3.0)], create_if_missing=True, stage=stage - ) + result = writer("/World/Grp(/.*)?", [fragment], create_if_missing=True, stage=stage) assert result is True for path in ("/World/Grp", "/World/Grp/a", "/World/Grp/b"): prim = stage.GetPrimAtPath(path) - assert prim.HasAPI(UsdPhysics.RigidBodyAPI), path - assert prim.GetAttribute("physxRigidBody:maxDepenetrationVelocity").Get() == pytest.approx(3.0), path + assert prim.HasAPI(api), path + assert prim.GetAttribute(attr).Get() == pytest.approx(value), path -def test_rigid_body_fragments_pattern_narrows_targets(): +@pytest.mark.parametrize("family", ["rigid_body", "collision"]) +def test_family_fragments_pattern_narrows_targets(family): """An expression targets only the carriers it matches.""" - from isaaclab.sim.schemas import apply_rigid_body_properties + writer, api, fragment, attr, value = _family_writer(family) sim_utils.create_new_stage() SimulationContext(SimulationCfg(dt=0.01)) stage = sim_utils.get_current_stage() for path in ("/World/Bot/armL", "/World/Bot/armR"): prim = UsdGeom.Xform.Define(stage, path).GetPrim() - UsdPhysics.RigidBodyAPI.Apply(prim) - result = apply_rigid_body_properties( - "/World/Bot/armL", [PhysxRigidBodyCfg(max_depenetration_velocity=7.0)], stage=stage - ) + api.Apply(prim) + result = writer("/World/Bot/armL", [fragment], stage=stage) assert result is True left = stage.GetPrimAtPath("/World/Bot/armL") right = stage.GetPrimAtPath("/World/Bot/armR") - assert left.GetAttribute("physxRigidBody:maxDepenetrationVelocity").Get() == pytest.approx(7.0) - assert not right.GetAttribute("physxRigidBody:maxDepenetrationVelocity").HasAuthoredValue() + assert left.GetAttribute(attr).Get() == pytest.approx(value) + assert not right.GetAttribute(attr).HasAuthoredValue() -def test_rigid_body_fragments_zero_targets_warn_and_return_false(caplog): +@pytest.mark.parametrize("family", ["rigid_body", "collision", "mass"]) +def test_family_fragments_zero_targets_warn_and_return_false(family, caplog): """No carrier matched and no creation requested: warn, author nothing, report failure.""" - from isaaclab.sim.schemas import apply_rigid_body_properties + writer, api, fragment, _, _ = _family_writer(family) sim_utils.create_new_stage() SimulationContext(SimulationCfg(dt=0.01)) stage = sim_utils.get_current_stage() UsdGeom.Xform.Define(stage, "/World/Bare") with caplog.at_level("WARNING"): - result = apply_rigid_body_properties( - "/World/Bare", [PhysxRigidBodyCfg(max_depenetration_velocity=3.0)], stage=stage - ) + result = writer("/World/Bare", [fragment], stage=stage) assert result is False - assert not stage.GetPrimAtPath("/World/Bare").HasAPI(UsdPhysics.RigidBodyAPI) + assert not stage.GetPrimAtPath("/World/Bare").HasAPI(api) assert "/World/Bare" in caplog.text diff --git a/source/isaaclab/test/sim/test_schemas.py b/source/isaaclab/test/sim/test_schemas.py index 9c980715d344..c76d4760c7b1 100644 --- a/source/isaaclab/test/sim/test_schemas.py +++ b/source/isaaclab/test/sim/test_schemas.py @@ -17,12 +17,6 @@ import warnings import pytest -from isaaclab_physx.sim.schemas import ( - ArticulationRootPropertiesCfg as ArticulationRootDeprecatedAliasCfg, -) -from isaaclab_physx.sim.schemas import ( - CollisionPropertiesCfg as PhysxCollisionPropertiesCfgAlias, -) from isaaclab_physx.sim.schemas import ( PhysxArticulationRootPropertiesCfg, PhysxCollisionCfg, @@ -34,7 +28,6 @@ from isaaclab_physx.sim.spawners.materials import ( PhysxRigidBodyMaterialCfg, PhysxSurfaceDeformableBodyMaterialCfg, - RigidBodyMaterialCfg, ) from pxr import Usd, UsdGeom, UsdPhysics @@ -103,27 +96,6 @@ def setup_simulation(): sim.clear_instance() -@pytest.mark.isaacsim_ci -def test_valid_properties_cfg(setup_simulation): - """Test that all the config instances have non-None values. - - This is to ensure that we check that all the properties of the schema are set. - """ - sim, arti_cfg, rigid_cfg, collision_cfg, mass_cfg, joint_cfg = setup_simulation - # deprecation aliases are nulled by __post_init__ after forwarding to the canonical - # field; exclude them from the all-non-None check. - deprecation_aliases = {"max_velocity", "max_effort"} - # nested opt-in cfgs whose ``None`` means "leave the USD-authored value alone" - optional_nested_cfgs = {"mesh_collision_property"} - for cfg in [arti_cfg, rigid_cfg, collision_cfg, mass_cfg, joint_cfg]: - for k, v in cfg.__dict__.items(): - # skip class-metadata keys (``_usd_*``), deprecation aliases nulled in __post_init__, - # and nested cfgs that are meaningfully unset - if k.startswith("_") or k in deprecation_aliases or k in optional_nested_cfgs: - continue - assert v is not None, f"{cfg.__class__.__name__}:{k} is None. Please make sure schemas are valid." - - @pytest.mark.isaacsim_ci def test_max_joint_velocity_on_base_cfg(setup_simulation): """Setting ``max_joint_velocity`` on the base ``JointDriveBaseCfg`` must author @@ -161,69 +133,6 @@ def test_max_joint_velocity_on_base_cfg(setup_simulation): assert attr.Get() == pytest.approx(expected_deg_per_sec, rel=1e-6) -@pytest.mark.isaacsim_ci -def test_max_velocity_deprecation_alias(setup_simulation): - """Legacy ``max_velocity`` kwarg must forward to ``max_joint_velocity`` and emit - a ``DeprecationWarning``. Behavior must match setting ``max_joint_velocity`` directly. - """ - sim, _, _, _, _, _ = setup_simulation - stage = sim_utils.get_current_stage() - - with pytest.warns(DeprecationWarning, match="max_velocity"): - base_cfg = schemas.JointDriveBaseCfg( - drive_type="acceleration", - max_force=80.0, - max_velocity=10.0, - stiffness=10.0, - damping=0.1, - ) - - assert base_cfg.max_joint_velocity == 10.0 - assert base_cfg.max_velocity is None - - sim_utils.create_prim("/World/Articulation_dep", prim_type="Xform") - sim_utils.create_prim("/World/Articulation_dep/body0", prim_type="Cube") - sim_utils.create_prim("/World/Articulation_dep/body1", prim_type="Cube") - UsdPhysics.RevoluteJoint.Define(stage, "/World/Articulation_dep/joint_0") - prim_path = "/World/Articulation_dep/joint_0" - inspect.unwrap(schemas.modify_joint_drive_properties)(prim_path, base_cfg) - - attr = stage.GetPrimAtPath(prim_path).GetAttribute("physxJoint:maxJointVelocity") - assert attr.IsValid() - assert attr.Get() == pytest.approx(10.0 * 180.0 / math.pi, rel=1e-6) - - -@pytest.mark.isaacsim_ci -def test_max_effort_deprecation_alias(setup_simulation): - """Legacy ``max_effort`` kwarg must forward to ``max_force`` and emit - a ``DeprecationWarning``. Behavior must match setting ``max_force`` directly. - """ - sim, _, _, _, _, _ = setup_simulation - stage = sim_utils.get_current_stage() - - with pytest.warns(DeprecationWarning, match="max_effort"): - base_cfg = schemas.JointDriveBaseCfg( - drive_type="acceleration", - max_effort=42.0, - stiffness=10.0, - damping=0.1, - ) - - assert base_cfg.max_force == 42.0 - assert base_cfg.max_effort is None - - sim_utils.create_prim("/World/Articulation_eff", prim_type="Xform") - sim_utils.create_prim("/World/Articulation_eff/body0", prim_type="Cube") - sim_utils.create_prim("/World/Articulation_eff/body1", prim_type="Cube") - UsdPhysics.PrismaticJoint.Define(stage, "/World/Articulation_eff/joint_0") - prim_path = "/World/Articulation_eff/joint_0" - inspect.unwrap(schemas.modify_joint_drive_properties)(prim_path, base_cfg) - - attr = stage.GetPrimAtPath(prim_path).GetAttribute("drive:linear:physics:maxForce") - assert attr.IsValid() - assert attr.Get() == pytest.approx(42.0, rel=1e-6) - - @pytest.mark.isaacsim_ci def test_joint_drive_base_no_physx_schema_when_max_joint_velocity_unset(setup_simulation): """Regression: setting only UsdPhysics drive fields on JointDriveBaseCfg @@ -345,18 +254,6 @@ def test_physx_rigid_body_material_cfg(setup_simulation): ) -@pytest.mark.isaacsim_ci -def test_rigid_body_material_deprecation_alias(setup_simulation): - """Instantiating the legacy ``RigidBodyMaterialCfg`` name emits exactly one - ``DeprecationWarning`` whose message references the 5.0 removal target.""" - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") - RigidBodyMaterialCfg() - deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)] - assert len(deprecations) == 1, f"expected exactly one DeprecationWarning, got {len(deprecations)}" - assert "3.2" in str(deprecations[0].message) - - @pytest.mark.isaacsim_ci def test_collision_base_cfg_writes_physx_namespaced_attrs(setup_simulation): """Setting ``contact_offset`` / ``rest_offset`` on the base ``CollisionBaseCfg`` must @@ -451,18 +348,6 @@ def test_physx_collision_cfg_writes_torsional_patch(setup_simulation): assert "PhysxCollisionAPI" in applied -@pytest.mark.isaacsim_ci -def test_collision_deprecation_alias(setup_simulation): - """Instantiating the legacy ``CollisionPropertiesCfg`` name emits exactly one - ``DeprecationWarning`` whose message references the 5.0 removal target.""" - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") - PhysxCollisionPropertiesCfgAlias() - deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)] - assert len(deprecations) == 1, f"expected exactly one DeprecationWarning, got {len(deprecations)}" - assert "3.2" in str(deprecations[0].message) - - @pytest.mark.isaacsim_ci def test_articulation_root_base_cfg_writes_articulation_enabled(setup_simulation): """Setting ``articulation_enabled`` on the base ``ArticulationRootBaseCfg`` must author @@ -570,18 +455,6 @@ def test_physx_articulation_root_self_collisions_follow_fixed_root(setup_simulat assert not child.GetAttribute("newton:selfCollisionEnabled").HasAuthoredValue() -@pytest.mark.isaacsim_ci -def test_articulation_root_deprecation_alias(setup_simulation): - """Instantiating the legacy ``ArticulationRootPropertiesCfg`` name emits exactly one - ``DeprecationWarning`` whose message references the 5.0 removal target.""" - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") - ArticulationRootDeprecatedAliasCfg() - deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)] - assert len(deprecations) == 1, f"expected exactly one DeprecationWarning, got {len(deprecations)}" - assert "3.2" in str(deprecations[0].message) - - @pytest.mark.isaacsim_ci def test_mesh_collision_base_cfg_writes_approximation_token(setup_simulation): """``MeshCollisionBaseCfg(mesh_approximation_name="boundingCube")`` authors @@ -665,100 +538,6 @@ def test_bounding_cube_default_token(setup_simulation): assert cfg.mesh_approximation_name == "boundingCube" -@pytest.mark.isaacsim_ci -@pytest.mark.parametrize( - "name", - [ - "MeshCollisionPropertiesCfg", - "ConvexHullPropertiesCfg", - "ConvexDecompositionPropertiesCfg", - "TriangleMeshPropertiesCfg", - "TriangleMeshSimplificationPropertiesCfg", - "SDFMeshPropertiesCfg", - ], -) -def test_mesh_collision_deprecation_aliases(setup_simulation, name): - """Each legacy mesh-collision class name emits exactly one DeprecationWarning on - instantiation and the warning message references the 5.0 removal target.""" - from isaaclab_physx.sim.schemas import schemas_cfg as physx_cfg - - cls = getattr(physx_cfg, name) - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") - cls() - deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)] - assert len(deprecations) == 1, f"{name}: expected one DeprecationWarning, got {len(deprecations)}" - assert "3.2" in str(deprecations[0].message) - - -@pytest.mark.isaacsim_ci -def test_physx_fixed_tendon_relocation(setup_simulation): - """``PhysxFixedTendonPropertiesCfg`` is importable from - :mod:`isaaclab_physx.sim.schemas` and round-trips its fields.""" - from isaaclab_physx.sim.schemas import PhysxFixedTendonPropertiesCfg - - cfg = PhysxFixedTendonPropertiesCfg( - tendon_enabled=True, - stiffness=10.0, - damping=0.5, - limit_stiffness=1.0, - offset=0.1, - rest_length=0.2, - ) - assert cfg.tendon_enabled is True - assert cfg.stiffness == 10.0 - assert cfg.damping == 0.5 - assert cfg.limit_stiffness == 1.0 - assert cfg.offset == 0.1 - assert cfg.rest_length == 0.2 - - -@pytest.mark.isaacsim_ci -def test_fixed_tendon_deprecation_alias(setup_simulation): - """Instantiating the legacy ``FixedTendonPropertiesCfg`` (via the shim) emits exactly - one ``DeprecationWarning`` whose message references the 5.0 removal target.""" - cls = schemas.FixedTendonPropertiesCfg - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") - cls() - deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)] - assert len(deprecations) == 1, f"expected one DeprecationWarning, got {len(deprecations)}" - assert "3.2" in str(deprecations[0].message) - - -@pytest.mark.isaacsim_ci -def test_physx_spatial_tendon_relocation(setup_simulation): - """``PhysxSpatialTendonPropertiesCfg`` is importable from - :mod:`isaaclab_physx.sim.schemas` and round-trips its fields.""" - from isaaclab_physx.sim.schemas import PhysxSpatialTendonPropertiesCfg - - cfg = PhysxSpatialTendonPropertiesCfg( - tendon_enabled=True, - stiffness=20.0, - damping=0.25, - limit_stiffness=2.0, - offset=0.05, - ) - assert cfg.tendon_enabled is True - assert cfg.stiffness == 20.0 - assert cfg.damping == 0.25 - assert cfg.limit_stiffness == 2.0 - assert cfg.offset == 0.05 - - -@pytest.mark.isaacsim_ci -def test_spatial_tendon_deprecation_alias(setup_simulation): - """Instantiating the legacy ``SpatialTendonPropertiesCfg`` (via the shim) emits exactly - one ``DeprecationWarning`` whose message references the 5.0 removal target.""" - cls = schemas.SpatialTendonPropertiesCfg - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") - cls() - deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)] - assert len(deprecations) == 1, f"expected one DeprecationWarning, got {len(deprecations)}" - assert "3.2" in str(deprecations[0].message) - - @pytest.mark.isaacsim_ci def test_usd_api_physx_api_attrs_deprecated(setup_simulation): """Reading ``cfg.usd_api`` and ``cfg.physx_api`` on the new mesh cfgs emits a @@ -814,8 +593,10 @@ def test_modify_properties_on_articulation_instanced_usd(setup_simulation): _validate_joint_drive_properties_on_prim("/World/asset_instanced", joint_cfg) # make a fixed joint + assert sim_utils.find_global_fixed_joint_prim("/World/asset_instanced") is None arti_cfg.fix_root_link = True schemas.modify_articulation_root_properties("/World/asset_instanced", arti_cfg) + assert sim_utils.find_global_fixed_joint_prim("/World/asset_instanced", check_enabled_only=True) is not None @pytest.mark.isaacsim_ci @@ -913,24 +694,25 @@ def test_defining_rigid_body_properties_on_prim(setup_simulation): schemas.define_rigid_body_properties("/World/cube1", rigid_cfg) schemas.define_collision_properties("/World/cube1", collision_cfg) schemas.define_mass_properties("/World/cube1", mass_cfg) - # validate the properties - _validate_rigid_body_properties_on_prim("/World/cube1", rigid_cfg) - _validate_collision_properties_on_prim("/World/cube1", collision_cfg) - _validate_mass_properties_on_prim("/World/cube1", mass_cfg) # spawn another prim sim_utils.create_prim("/World/cube2", prim_type="Cube", translation=(1.0, 1.0, 0.62)) # set properties on the asset and check all properties are set schemas.define_rigid_body_properties("/World/cube2", rigid_cfg) schemas.define_collision_properties("/World/cube2", collision_cfg) - # validate the properties - _validate_rigid_body_properties_on_prim("/World/cube2", rigid_cfg) - _validate_collision_properties_on_prim("/World/cube2", collision_cfg) - # check if we can play - sim.reset() - for _ in range(100): - sim.step() + # the define_* functions apply the schemas they author + for prim_path, apis in ( + ("/World/cube1", (UsdPhysics.RigidBodyAPI, UsdPhysics.CollisionAPI, UsdPhysics.MassAPI)), + ("/World/cube2", (UsdPhysics.RigidBodyAPI, UsdPhysics.CollisionAPI)), + ): + prim = sim.stage.GetPrimAtPath(prim_path) + for api in apis: + assert prim.HasAPI(api), f"{prim_path} is missing {api.__name__}" + # validate the properties; the validators inspect the children (collision: grandchildren) of the given prim + _validate_rigid_body_properties_on_prim("/World", rigid_cfg) + _validate_collision_properties_on_prim("/", collision_cfg) + _validate_mass_properties_on_prim("/World", mass_cfg) @pytest.mark.isaacsim_ci @@ -947,11 +729,12 @@ def test_defining_articulation_properties_on_prim(setup_simulation): sim_utils.create_prim("/World/parent/child", prim_type="Cube", translation=(0.0, 0.0, 0.62)) schemas.define_rigid_body_properties("/World/parent/child", rigid_cfg) schemas.define_mass_properties("/World/parent/child", mass_cfg) - - # check if we can play - sim.reset() - for _ in range(100): - sim.step() + child_prim = sim.stage.GetPrimAtPath("/World/parent/child") + assert child_prim.HasAPI(UsdPhysics.RigidBodyAPI) + assert child_prim.HasAPI(UsdPhysics.MassAPI) + # validate the properties; the validators inspect the children of the given prim + _validate_rigid_body_properties_on_prim("/World/parent", rigid_cfg) + _validate_mass_properties_on_prim("/World/parent", mass_cfg) @pytest.mark.isaacsim_ci diff --git a/source/isaaclab/test/sim/test_schemas_deprecation.py b/source/isaaclab/test/sim/test_schemas_deprecation.py index dd78648ea0c3..09477d3776cd 100644 --- a/source/isaaclab/test/sim/test_schemas_deprecation.py +++ b/source/isaaclab/test/sim/test_schemas_deprecation.py @@ -25,7 +25,6 @@ from pxr import Usd, UsdGeom, UsdPhysics import isaaclab.sim.schemas as schemas -import isaaclab.sim.schemas.schemas as schemas_impl import isaaclab.sim.schemas.schemas_cfg as schemas_cfg pytestmark = [pytest.mark.unit, pytest.mark.kitless] @@ -299,11 +298,16 @@ def test_legacy_cfg_keeps_init_signature(): def test_legacy_cfg_keeps_field_alias_forwarding(): """The renamed-field aliases on the legacy joint-drive cfg still forward.""" - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") cfg = schemas_cfg.JointDriveBaseCfg(max_effort=80.0, max_velocity=5.0) + messages = [str(w.message) for w in caught if issubclass(w.category, DeprecationWarning)] + assert any("max_effort" in message for message in messages) + assert any("max_velocity" in message for message in messages) assert cfg.max_force == 80.0 assert cfg.max_joint_velocity == 5.0 + # the aliases are cleared once forwarded, so writers only see the canonical fields + assert cfg.max_velocity is None and cfg.max_effort is None def test_legacy_cfg_subclass_warns_only_for_itself(): @@ -401,12 +405,6 @@ def test_apply_mass_properties_does_not_warn(): assert stage.GetPrimAtPath(prim_path).GetAttribute("physics:mass").Get() == pytest.approx(5.0) -def test_legacy_writers_do_not_track_warning_depth(): - """Keep deprecation at public entry points, without custom nesting state.""" - for name in ("_deprecated_schema_writer", "_legacy_writer_state", "_legacy_writer_depth"): - assert not hasattr(schemas_impl, name) - - """ Silencing the warnings, as documented in the 3.0 migration guide. """ diff --git a/source/isaaclab/test/sim/test_schemas_shim.py b/source/isaaclab/test/sim/test_schemas_shim.py index 6db6779ea941..00918e5a6b7e 100644 --- a/source/isaaclab/test/sim/test_schemas_shim.py +++ b/source/isaaclab/test/sim/test_schemas_shim.py @@ -53,18 +53,9 @@ "PhysxSpatialTendonPropertiesCfg", ] +# Deprecated forwarded names that ``test_schemas_deprecation.py`` does not already cover. DEPRECATED_FORWARDED_NAMES = [ - "RigidBodyPropertiesCfg", - "JointDrivePropertiesCfg", - "CollisionPropertiesCfg", "DeformableBodyPropertiesCfg", - "ArticulationRootPropertiesCfg", - "MeshCollisionPropertiesCfg", - "ConvexHullPropertiesCfg", - "ConvexDecompositionPropertiesCfg", - "TriangleMeshPropertiesCfg", - "TriangleMeshSimplificationPropertiesCfg", - "SDFMeshPropertiesCfg", "FixedTendonPropertiesCfg", "SpatialTendonPropertiesCfg", ] @@ -155,14 +146,6 @@ def test_sim_namespace_material_shim_resolves_to_physx_class(name): assert getattr(sim_utils, name) is getattr(physx_mat_cfg, name) -def test_deprecated_alias_emits_deprecation_warning(): - """Instantiating ``RigidBodyPropertiesCfg`` via the shim still emits ``DeprecationWarning``.""" - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") - schemas.RigidBodyPropertiesCfg() - assert any(issubclass(w.category, DeprecationWarning) for w in caught) - - @pytest.mark.parametrize("name", DEPRECATED_FORWARDED_NAMES) def test_deprecated_aliases_emit_deprecation_warning(name): """Instantiating each deprecated forwarded alias via the shim emits exactly one @@ -173,6 +156,7 @@ def test_deprecated_aliases_emit_deprecation_warning(name): cls() deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)] assert len(deprecations) == 1, f"{name}: expected one DeprecationWarning, got {len(deprecations)}" + assert "3.2" in str(deprecations[0].message) @pytest.mark.parametrize("name", DEPRECATED_FORWARDED_MATERIAL_NAMES) @@ -186,19 +170,6 @@ def test_deprecated_material_aliases_emit_deprecation_warning(name): assert "3.2" in str(deprecations[0].message) -def test_new_class_does_not_emit_deprecation_warning(): - """Instantiating the ``PhysxRigidBodyCfg`` fragment does NOT emit ``DeprecationWarning``. - - Both ``RigidBodyPropertiesCfg`` and the ``PhysxRigidBodyPropertiesCfg`` it forwards to are - now deprecated in favor of the single-namespace fragments, so the fragment is the class - that must stay silent. See ``test_schemas_deprecation.py`` for the full coverage. - """ - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") - physx_cfg.PhysxRigidBodyCfg() - assert not any(issubclass(w.category, DeprecationWarning) for w in caught) - - def test_new_material_class_does_not_emit_deprecation_warning(): """Instantiating ``PhysxRigidBodyMaterialCfg`` directly does NOT emit ``DeprecationWarning``.""" with warnings.catch_warnings(record=True) as caught: diff --git a/source/isaaclab/test/sim/test_simulation_context.py b/source/isaaclab/test/sim/test_simulation_context.py index e5ab209e5ca3..d066ded512ba 100644 --- a/source/isaaclab/test/sim/test_simulation_context.py +++ b/source/isaaclab/test/sim/test_simulation_context.py @@ -13,7 +13,6 @@ """Rest everything follows.""" -import weakref from unittest.mock import Mock import numpy as np @@ -27,7 +26,6 @@ import isaaclab.sim as sim_utils from isaaclab.physics import PhysicsEvent -from isaaclab.renderers import RendererCfg from isaaclab.sim import SimulationCfg, SimulationContext pytestmark = pytest.mark.integration @@ -66,6 +64,7 @@ def initialize(cls, sim_context): monkeypatch.setattr(PhysxManager, "initialize", classmethod(initialize)) cfg = SimulationCfg( device=device, + dt=0.005, physics_prim_path="/Physics/PhysX", gravity=(0.0, -0.5, -0.5), physics_material=RigidBodyMaterialCfg(), @@ -131,42 +130,6 @@ def on_stop(_): assert stopped, "PhysicsEvent.STOP was not dispatched at teardown" -@pytest.mark.isaacsim_ci -def test_clear_instance_closes_renderers(): - """``clear_instance`` must close registered renderers rather than leave them to garbage collection. - - A renderer is shared by every camera whose config resolves to it, so the stage-bound resources it - owns cannot be released from a single camera's ``cleanup``. Nothing else releases them either: - the OVRTX ovstage path holds its stage in a ``contextlib.ExitStack``, which has no finalizer, so - collection never runs the context managers that own it. - """ - sim = SimulationContext(SimulationCfg(physics=PhysxCfg())) - - closed = [] - - class _Renderer: - def __init__(self, cfg): - pass - - def close(self): - closed.append(True) - - sim.get_or_create_backend(RendererCfg(class_type=_Renderer)) - SimulationContext.clear_instance() - - assert closed, "registered renderers were not closed at teardown" - - -@pytest.mark.isaacsim_ci -def test_instance_before_creation(): - """Test accessing instance before creating returns None.""" - # clear any existing instance - SimulationContext.clear_instance() - - # accessing instance before creation should return None - assert SimulationContext.instance() is None - - @pytest.mark.isaacsim_ci def test_singleton(): """Construction creates a context; only instance() retrieves the live context.""" @@ -298,8 +261,8 @@ def test_timeline_pause(): @pytest.mark.isaacsim_ci -def test_reset(): - """Test simulation reset.""" +def test_reset(monkeypatch): + """Test simulation reset, then soft reset, forward, step, and render on the reset simulation.""" cfg = SimulationCfg(dt=0.01) sim = SimulationContext(cfg) @@ -316,86 +279,24 @@ def test_reset(): # check that physics sim view is created assert sim.physics_sim_view is not None - -@pytest.mark.isaacsim_ci -def test_reset_soft(): - """Test soft reset (without stopping simulation).""" - cfg = SimulationCfg(dt=0.01) - sim = SimulationContext(cfg) - - # create a simple cube - cube_cfg = sim_utils.CuboidCfg(size=(0.1, 0.1, 0.1)) - cube_cfg.func("/World/Cube", cube_cfg) - - # perform initial reset - sim.reset() - assert sim.is_playing() - - # perform soft reset + # soft reset keeps the simulation playing sim.reset(soft=True) - - # simulation should still be playing assert sim.is_playing() - -@pytest.mark.isaacsim_ci -def test_forward(): - """Test forward propagation for fabric updates.""" - cfg = SimulationCfg(dt=0.01, use_fabric=True) - sim = SimulationContext(cfg) - - # create simple scene - cube_cfg = sim_utils.CuboidCfg(size=(0.1, 0.1, 0.1)) - cube_cfg.func("/World/Cube", cube_cfg) - - sim.reset() - - # call forward + # forward delegates to the physics backend exactly once + physics_forward = Mock(wraps=PhysxManager.forward) + monkeypatch.setattr(PhysxManager, "forward", physics_forward) sim.forward() + physics_forward.assert_called_once() - # should not raise any errors - assert sim.is_playing() - - -@pytest.mark.isaacsim_ci -@pytest.mark.parametrize("render", [True, False]) -def test_step(render): - """Test stepping simulation with and without rendering.""" - cfg = SimulationCfg(dt=0.01) - sim = SimulationContext(cfg) - - # create simple scene - cube_cfg = sim_utils.CuboidCfg(size=(0.1, 0.1, 0.1)) - cube_cfg.func("/World/Cube", cube_cfg) - - sim.reset() - - # step with rendering - for _ in range(10): - sim.step(render=render) - - # simulation should still be playing - assert sim.is_playing() - - -@pytest.mark.isaacsim_ci -def test_render(): - """Test rendering simulation.""" - cfg = SimulationCfg(dt=0.01) - sim = SimulationContext(cfg) - - # create simple scene - cube_cfg = sim_utils.CuboidCfg(size=(0.1, 0.1, 0.1)) - cube_cfg.func("/World/Cube", cube_cfg) - - sim.reset() - - # render - for _ in range(10): - sim.render() - - # simulation should still be playing - assert sim.is_playing() + # enable continuous rendering so step(render=True) renders; step(render=False) must not + sim.set_setting("/isaaclab/render/rtx_sensors", True) + render = Mock(wraps=sim.render) + monkeypatch.setattr(sim, "render", render) + sim.step(render=False) + render.assert_not_called() + sim.step(render=True) + render.assert_called_once() @pytest.mark.isaacsim_ci @@ -484,19 +385,6 @@ def test_render_skips_app_update_when_visualizer_pumps_it(): """ -@pytest.mark.isaacsim_ci -def test_get_initial_stage(): - """Test getting the initial stage.""" - sim = SimulationContext() - - # get initial stage - stage = sim.stage - - # verify stage is valid - assert stage is not None - assert stage == sim.stage - - @pytest.mark.isaacsim_ci def test_clear_stage(): """Test clearing the stage.""" @@ -552,26 +440,11 @@ def test_fabric_setting(use_fabric): @pytest.mark.isaacsim_ci -@pytest.mark.parametrize("dt", [0.01, 0.02, 0.005]) -def test_physics_dt(dt): - """Test that physics time step is properly configured.""" - cfg = SimulationCfg(dt=dt) - sim = SimulationContext(cfg) - - # obtain physics scene from USD (string-based: physxScene:timeStepsPerSecond) - physics_scene_prim = sim.stage.GetPrimAtPath(cfg.physics_prim_path) - physics_hz = physics_scene_prim.GetAttribute("physxScene:timeStepsPerSecond").Get() - physics_dt = 1.0 / physics_hz - assert abs(physics_dt - dt) < 1e-6 - - -@pytest.mark.isaacsim_ci -@pytest.mark.parametrize("gravity", [(0.0, 0.0, 0.0), (0.0, 0.0, -9.81), (0.5, 0.5, 0.5)]) -def test_custom_gravity(gravity): - """Test that gravity can be properly set.""" +def test_zero_gravity(): + """Test that zero gravity (no direction or magnitude) can be set; ``test_init`` covers a non-zero vector.""" from pxr import UsdPhysics - cfg = SimulationCfg(gravity=gravity) + cfg = SimulationCfg(gravity=(0.0, 0.0, 0.0)) sim = SimulationContext(cfg) # obtain physics scene from USD @@ -593,7 +466,7 @@ def test_custom_gravity(gravity): @pytest.mark.isaacsim_ci def test_timeline_callbacks_on_play(): - """Test that timeline callbacks are triggered on play event.""" + """Test that timeline callbacks are triggered on play, pause, and stop events.""" cfg = SimulationCfg(dt=0.01) sim = SimulationContext(cfg) @@ -602,7 +475,7 @@ def test_timeline_callbacks_on_play(): cube_cfg.func("/World/Cube", cube_cfg) # create a flag to track callback execution - callback_state = {"play_called": False, "stop_called": False} + callback_state = {"play_called": False, "stop_called": False, "pause_called": False} # define callback functions def on_play_callback(event): @@ -611,6 +484,9 @@ def on_play_callback(event): def on_stop_callback(event): callback_state["stop_called"] = True + def on_pause_callback(event): + callback_state["pause_called"] = True + # register callbacks timeline_event_stream = omni.timeline.get_timeline_interface().get_timeline_event_stream() play_handle = timeline_event_stream.create_subscription_to_pop_by_type( @@ -623,6 +499,9 @@ def on_stop_callback(event): lambda event: on_stop_callback(event), order=20, ) + pause_handle = timeline_event_stream.create_subscription_to_pop_by_type( + int(omni.timeline.TimelineEventType.PAUSE), lambda event: on_pause_callback(event), order=20 + ) try: # ensure callbacks haven't been called yet @@ -634,6 +513,12 @@ def on_stop_callback(event): assert callback_state["play_called"] assert not callback_state["stop_called"] + assert not callback_state["pause_called"] + + # pause the simulation - this should trigger pause callback + sim.pause() + assert callback_state["pause_called"] + # reset flags callback_state["play_called"] = False @@ -650,276 +535,6 @@ def on_stop_callback(event): play_handle.unsubscribe() if stop_handle is not None: stop_handle.unsubscribe() - - -@pytest.mark.isaacsim_ci -def test_timeline_callbacks_with_weakref(): - """Test that timeline callbacks work correctly with weak references (similar to asset_base.py).""" - - cfg = SimulationCfg(dt=0.01) - sim = SimulationContext(cfg) - - # create a simple scene - cube_cfg = sim_utils.CuboidCfg(size=(0.1, 0.1, 0.1)) - cube_cfg.func("/World/Cube", cube_cfg) - - # create a test object that will be weakly referenced - class CallbackTracker: - def __init__(self): - self.play_count = 0 - self.stop_count = 0 - - def on_play(self, event): - self.play_count += 1 - - def on_stop(self, event): - self.stop_count += 1 - - # create an instance of the callback tracker - tracker = CallbackTracker() - - # define safe callback wrapper (similar to asset_base.py pattern) - def safe_callback(callback_name, event, obj_ref): - """Safely invoke a callback on a weakly-referenced object.""" - try: - obj = obj_ref() # Dereference the weakref - if obj is not None: - getattr(obj, callback_name)(event) - except ReferenceError: - # Object has been deleted; ignore - pass - - # register callbacks with weakref - obj_ref = weakref.ref(tracker) - timeline_event_stream = omni.timeline.get_timeline_interface().get_timeline_event_stream() - - play_handle = timeline_event_stream.create_subscription_to_pop_by_type( - int(omni.timeline.TimelineEventType.PLAY), - lambda event, obj_ref=obj_ref: safe_callback("on_play", event, obj_ref), - order=20, - ) - stop_handle = timeline_event_stream.create_subscription_to_pop_by_type( - int(omni.timeline.TimelineEventType.STOP), - lambda event, obj_ref=obj_ref: safe_callback("on_stop", event, obj_ref), - order=20, - ) - - try: - # verify callbacks haven't been called - assert tracker.play_count == 0 - assert tracker.stop_count == 0 - - # trigger play event - sim.play() - assert tracker.play_count == 1 - assert tracker.stop_count == 0 - - # disable app control to prevent hanging - sim._disable_app_control_on_stop_handle = True # type: ignore - - # trigger stop event - sim.stop() - assert tracker.play_count == 1 - assert tracker.stop_count == 1 - - # delete the tracker object - del tracker - - # trigger events again - callbacks should handle the deleted object gracefully - sim.play() - # regression check: a second play() after stop() with no reset() in between used to - # SIGSEGV inside PhysX's tensor view registry (see PhysxManager._on_stop); it must also - # still warm up and recreate the simulation view, not just avoid crashing - assert PhysxManager.get_physics_sim_view() is not None - # disable app control again - sim._disable_app_control_on_stop_handle = True # type: ignore - sim.stop() - # should not raise any errors - - finally: - # cleanup callbacks - if play_handle is not None: - play_handle.unsubscribe() - if stop_handle is not None: - stop_handle.unsubscribe() - - -@pytest.mark.isaacsim_ci -def test_multiple_callbacks_on_same_event(): - """Test that multiple callbacks can be registered for the same event.""" - cfg = SimulationCfg(dt=0.01) - sim = SimulationContext(cfg) - - # create tracking for multiple callbacks - callback_counts = {"callback1": 0, "callback2": 0, "callback3": 0} - - def callback1(event): - callback_counts["callback1"] += 1 - - def callback2(event): - callback_counts["callback2"] += 1 - - def callback3(event): - callback_counts["callback3"] += 1 - - # register multiple callbacks for play event - timeline_event_stream = omni.timeline.get_timeline_interface().get_timeline_event_stream() - handle1 = timeline_event_stream.create_subscription_to_pop_by_type( - int(omni.timeline.TimelineEventType.PLAY), lambda event: callback1(event), order=20 - ) - handle2 = timeline_event_stream.create_subscription_to_pop_by_type( - int(omni.timeline.TimelineEventType.PLAY), lambda event: callback2(event), order=21 - ) - handle3 = timeline_event_stream.create_subscription_to_pop_by_type( - int(omni.timeline.TimelineEventType.PLAY), lambda event: callback3(event), order=22 - ) - - try: - # verify none have been called - assert all(count == 0 for count in callback_counts.values()) - - # trigger play event - sim.play() - - # all callbacks should have been called - assert callback_counts["callback1"] == 1 - assert callback_counts["callback2"] == 1 - assert callback_counts["callback3"] == 1 - - finally: - # cleanup all callbacks - if handle1 is not None: - handle1.unsubscribe() - if handle2 is not None: - handle2.unsubscribe() - if handle3 is not None: - handle3.unsubscribe() - - -@pytest.mark.isaacsim_ci -def test_callback_execution_order(): - """Test that callbacks are executed in the correct order based on priority.""" - cfg = SimulationCfg(dt=0.01) - sim = SimulationContext(cfg) - - # track execution order - execution_order = [] - - def callback_low_priority(event): - execution_order.append("low") - - def callback_medium_priority(event): - execution_order.append("medium") - - def callback_high_priority(event): - execution_order.append("high") - - # register callbacks with different priorities (lower order = higher priority) - timeline_event_stream = omni.timeline.get_timeline_interface().get_timeline_event_stream() - handle_high = timeline_event_stream.create_subscription_to_pop_by_type( - int(omni.timeline.TimelineEventType.PLAY), lambda event: callback_high_priority(event), order=5 - ) - handle_medium = timeline_event_stream.create_subscription_to_pop_by_type( - int(omni.timeline.TimelineEventType.PLAY), lambda event: callback_medium_priority(event), order=10 - ) - handle_low = timeline_event_stream.create_subscription_to_pop_by_type( - int(omni.timeline.TimelineEventType.PLAY), lambda event: callback_low_priority(event), order=15 - ) - - try: - # trigger play event - sim.play() - - # verify callbacks were executed in correct order - assert len(execution_order) == 3 - assert execution_order[0] == "high" - assert execution_order[1] == "medium" - assert execution_order[2] == "low" - - finally: - # cleanup callbacks - if handle_high is not None: - handle_high.unsubscribe() - if handle_medium is not None: - handle_medium.unsubscribe() - if handle_low is not None: - handle_low.unsubscribe() - - -@pytest.mark.isaacsim_ci -def test_callback_unsubscribe(): - """Test that unsubscribing callbacks works correctly.""" - cfg = SimulationCfg(dt=0.01) - sim = SimulationContext(cfg) - - # create callback counter - callback_count = {"count": 0} - - def on_play_callback(event): - callback_count["count"] += 1 - - # register callback - timeline_event_stream = omni.timeline.get_timeline_interface().get_timeline_event_stream() - play_handle = timeline_event_stream.create_subscription_to_pop_by_type( - int(omni.timeline.TimelineEventType.PLAY), lambda event: on_play_callback(event), order=20 - ) - - try: - # trigger play event - sim.play() - assert callback_count["count"] == 1 - - # stop simulation - sim._disable_app_control_on_stop_handle = True # type: ignore - sim.stop() - - # unsubscribe the callback - play_handle.unsubscribe() - play_handle = None - - # trigger play event again - sim.play() - - # callback should not have been called again (still 1) - assert callback_count["count"] == 1 - - finally: - # cleanup if needed - if play_handle is not None: - play_handle.unsubscribe() - - -@pytest.mark.isaacsim_ci -def test_pause_event_callback(): - """Test that pause event callbacks are triggered correctly.""" - cfg = SimulationCfg(dt=0.01) - sim = SimulationContext(cfg) - - # create callback tracker - callback_state = {"pause_called": False} - - def on_pause_callback(event): - callback_state["pause_called"] = True - - # register pause callback - timeline_event_stream = omni.timeline.get_timeline_interface().get_timeline_event_stream() - pause_handle = timeline_event_stream.create_subscription_to_pop_by_type( - int(omni.timeline.TimelineEventType.PAUSE), lambda event: on_pause_callback(event), order=20 - ) - - try: - # play the simulation first - sim.play() - assert not callback_state["pause_called"] - - # pause the simulation - sim.pause() - - # callback should have been triggered - assert callback_state["pause_called"] - - finally: - # cleanup if pause_handle is not None: pause_handle.unsubscribe() @@ -968,163 +583,6 @@ def on_event(event): PhysxManager.deregister_callback(callback_id) -@pytest.mark.isaacsim_ci -def test_isaac_event_prim_deletion(): - """Test that PRIM_DELETION Isaac event is triggered when a prim is deleted.""" - cfg = SimulationCfg(dt=0.01) - sim = SimulationContext(cfg) - - # create simple scene - cube_cfg = sim_utils.CuboidCfg(size=(0.1, 0.1, 0.1)) - cube_cfg.func("/World/Cube", cube_cfg) - - sim.reset() - - # create callback tracker - callback_state = {"prim_deleted": False, "deleted_path": None} - - def on_prim_deletion(event): - callback_state["prim_deleted"] = True - # event payload should contain the deleted prim path - if hasattr(event, "payload") and event.payload: - callback_state["deleted_path"] = event.payload.get("prim_path") - - # register callback for PRIM_DELETION event - callback_id = PhysxManager.register_callback(lambda event: on_prim_deletion(event), event=IsaacEvents.PRIM_DELETION) - - try: - # verify callback hasn't been called yet - assert not callback_state["prim_deleted"] - - # delete the cube prim - sim_utils.delete_prim("/World/Cube") - - # trigger the event by dispatching it manually (since deletion might be handled differently) - PhysxManager._message_bus.dispatch_event(IsaacEvents.PRIM_DELETION.value, payload={"prim_path": "/World/Cube"}) # type: ignore - - # verify callback was triggered - assert callback_state["prim_deleted"] - - finally: - # cleanup callback - if callback_id is not None: - PhysxManager.deregister_callback(callback_id) - - -@pytest.mark.isaacsim_ci -def test_isaac_event_timeline_stop(): - """Test that TIMELINE_STOP Isaac event can be registered and triggered.""" - cfg = SimulationCfg(dt=0.01) - sim = SimulationContext(cfg) - - # create callback tracker - callback_state = {"timeline_stop_called": False} - - def on_timeline_stop(event): - callback_state["timeline_stop_called"] = True - - # register callback for TIMELINE_STOP event - callback_id = PhysxManager.register_callback(lambda event: on_timeline_stop(event), event=IsaacEvents.TIMELINE_STOP) - - try: - # verify callback hasn't been called yet - assert not callback_state["timeline_stop_called"] - - # play and stop the simulation - sim.play() - - # disable app control to prevent hanging - sim._disable_app_control_on_stop_handle = True # type: ignore - - # stop the simulation - sim.stop() - - # dispatch the event manually - PhysxManager._message_bus.dispatch_event(IsaacEvents.TIMELINE_STOP.value, payload={}) # type: ignore - - # verify callback was triggered - assert callback_state["timeline_stop_called"] - - finally: - # cleanup callback - if callback_id is not None: - PhysxManager.deregister_callback(callback_id) - - -@pytest.mark.isaacsim_ci -def test_isaac_event_callbacks_with_weakref(): - """Test Isaac event callbacks with weak references (similar to asset_base.py pattern).""" - cfg = SimulationCfg(dt=0.01) - sim = SimulationContext(cfg) - - # create simple scene - cube_cfg = sim_utils.CuboidCfg(size=(0.1, 0.1, 0.1)) - cube_cfg.func("/World/Cube", cube_cfg) - - # create a test object that will be weakly referenced - class PhysicsTracker: - def __init__(self): - self.warmup_count = 0 - self.ready_count = 0 - - def on_warmup(self, event): - self.warmup_count += 1 - - def on_ready(self, event): - self.ready_count += 1 - - tracker = PhysicsTracker() - - # define safe callback wrapper (same pattern as asset_base.py) - def safe_callback(callback_name, event, obj_ref): - """Safely invoke a callback on a weakly-referenced object.""" - try: - obj = obj_ref() - if obj is not None: - getattr(obj, callback_name)(event) - except ReferenceError: - # Object has been deleted; ignore - pass - - # register callbacks with weakref - obj_ref = weakref.ref(tracker) - - warmup_id = PhysxManager.register_callback( - lambda event, obj_ref=obj_ref: safe_callback("on_warmup", event, obj_ref), - event=IsaacEvents.PHYSICS_WARMUP, - ) - ready_id = PhysxManager.register_callback( - lambda event, obj_ref=obj_ref: safe_callback("on_ready", event, obj_ref), event=IsaacEvents.PHYSICS_READY - ) - - try: - # verify callbacks haven't been called - assert tracker.warmup_count == 0 - assert tracker.ready_count == 0 - - # reset simulation - triggers WARMUP and READY events - sim.reset() - - # verify callbacks were triggered (may be called multiple times during warmup sequence) - assert tracker.warmup_count >= 1 - assert tracker.ready_count >= 1 - - # delete the tracker object - del tracker - - # reset again - callbacks should handle the deleted object gracefully - sim.reset(soft=True) - - # should not raise any errors even though tracker is deleted - - finally: - # cleanup callbacks - if warmup_id is not None: - PhysxManager.deregister_callback(warmup_id) - if ready_id is not None: - PhysxManager.deregister_callback(ready_id) - - @pytest.mark.isaacsim_ci def test_multiple_isaac_event_callbacks(): """Test that multiple callbacks can be registered for the same Isaac event.""" @@ -1314,6 +772,8 @@ def test_remove_render_callback_stops_invocation(): cb = MagicMock() sim.add_render_callback("cb", cb) sim.remove_render_callback("cb") + # removing an unknown name is a no-op + sim.remove_render_callback("nonexistent") with patch("isaaclab.utils.version.has_kit", return_value=False): sim.render() @@ -1323,17 +783,6 @@ def test_remove_render_callback_stops_invocation(): SimulationContext.clear_instance() -def test_remove_render_callback_noop_for_unknown_name(): - """remove_render_callback is a no-op when the name was never registered.""" - cfg = SimulationCfg(dt=0.01) - sim = SimulationContext(cfg) - sim.reset() - - sim.remove_render_callback("nonexistent") # must not raise - - SimulationContext.clear_instance() - - def test_reset_callback_registered_before_construction_fires_on_reset(): """A class-level reset callback receives each context it resets until it is removed.""" from unittest.mock import MagicMock diff --git a/source/isaaclab/test/sim/test_simulation_context_visualizers.py b/source/isaaclab/test/sim/test_simulation_context_visualizers.py index 68f9d095ae78..8c04c6c67411 100644 --- a/source/isaaclab/test/sim/test_simulation_context_visualizers.py +++ b/source/isaaclab/test/sim/test_simulation_context_visualizers.py @@ -897,19 +897,6 @@ def test_cli_type_newton_rtx_resolves_to_newton_rtx_visualizer_cfg(): assert isinstance(cfgs[0], NewtonRTXVisualizerCfg) -def test_is_rendering_true_when_only_cfg_visualizer_is_newton_rtx(): - """is_rendering is True when only a newton_rtx cfg visualizer is configured.""" - cfg_visualizer = type("CfgVisualizer", (), {"visualizer_type": "newton_rtx"})() - settings = { - "/isaaclab/render/rtx_sensors": False, - "/isaaclab/visualizer/types": "", - "/isaaclab/visualizer/explicit": False, - "/isaaclab/visualizer/disable_all": False, - } - ctx = _make_context_with_settings(settings, visualizer_cfgs=[cfg_visualizer]) - assert ctx.is_rendering is True - - def test_default_visualizer_cfg_applies_to_explicit_visualizer_cfgs(): """default_visualizer_cfg fills in env-level hints (eye, lookat) on explicit cfgs. @@ -1108,8 +1095,8 @@ def test_explicit_partial_valid_types_raises_for_invalid(): def test_explicit_deprecated_alias_alone_does_not_raise(): - """Requesting only the deprecated 'newton' alias resolves successfully and does not raise, - even though the resolved cfg carries the canonical 'newton_gl' type.""" + """Requesting only the deprecated 'newton' alias warns, resolves to a NewtonGLVisualizerCfg, and does + not raise, even though the resolved cfg carries the canonical 'newton_gl' type.""" settings = { "/isaaclab/visualizer/types": "newton", "/isaaclab/visualizer/explicit": True, @@ -1122,6 +1109,7 @@ def test_explicit_deprecated_alias_alone_does_not_raise(): ctx._create_visualizers() assert len(ctx._pending_visualizers) == 1 + assert isinstance(ctx._pending_visualizers[0].cfg, NewtonGLVisualizerCfg) def test_explicit_deprecated_alias_matches_existing_cfg_by_canonical_type(): @@ -1166,23 +1154,6 @@ def test_explicit_existing_cfg_plus_failing_requested_type_raises_for_the_failur assert "'kit':" not in message -def test_deprecated_newton_alias_warns_and_resolves_to_newton_gl(): - """Requesting 'newton' via CLI emits DeprecationWarning and resolves to a NewtonGLVisualizerCfg.""" - settings = { - "/isaaclab/visualizer/types": "newton", - "/isaaclab/visualizer/explicit": True, - "/isaaclab/visualizer/disable_all": False, - "/isaaclab/visualizer/max_visible_envs": None, - } - ctx = _make_context_with_settings(settings) - - with pytest.warns(DeprecationWarning, match="newton.*deprecated.*newton_gl"): - cfgs = ctx._create_default_visualizer_configs(["newton"]) - - assert len(cfgs) == 1 - assert isinstance(cfgs[0], NewtonGLVisualizerCfg) - - def test_non_explicit_unknown_type_silently_skipped(caplog): """Without --visualizer flag, unknown types are silently skipped (no error).""" settings = { diff --git a/source/isaaclab/test/sim/test_simulation_render_config.py b/source/isaaclab/test/sim/test_simulation_render_config.py index 3190a96f05d6..2ca58807a5e5 100644 --- a/source/isaaclab/test/sim/test_simulation_render_config.py +++ b/source/isaaclab/test/sim/test_simulation_render_config.py @@ -174,7 +174,7 @@ def test_isaac_rtx_global_settings(monkeypatch): def test_isaac_rtx_determinism_settings(monkeypatch): - """Write RTPT reproducibility carb settings.""" + """Write RTPT reproducibility carb settings to the global or a provided settings manager.""" utils = _import_isaac_rtx_utils(monkeypatch) settings = _FakeSettings() monkeypatch.setattr(utils, "get_settings_manager", lambda: settings) @@ -185,11 +185,7 @@ def test_isaac_rtx_determinism_settings(monkeypatch): assert settings.get("/rtx/rtpt/cached/enabled") is False assert settings.get("/rtx/rtpt/lightcache/cached/enabled") is False - -def test_isaac_rtx_determinism_settings_accepts_explicit_manager(monkeypatch): - """Use the provided settings manager when one is passed.""" - utils = _import_isaac_rtx_utils(monkeypatch) - + # a provided settings manager is used instead of the global one def _fail(): raise AssertionError("global settings manager should not be queried when one is provided") diff --git a/source/isaaclab/test/sim/test_spawn_from_files.py b/source/isaaclab/test/sim/test_spawn_from_files.py index 7de8e88fe4fe..a3fd18cd44ce 100644 --- a/source/isaaclab/test/sim/test_spawn_from_files.py +++ b/source/isaaclab/test/sim/test_spawn_from_files.py @@ -203,98 +203,34 @@ def test_spawn_mesh_from_obj_file(sim, tmp_path): @pytest.mark.isaacsim_ci def test_spawn_usd_with_compliant_contact_material(sim): - """Test loading prim from USD file with physics material applied to specific prim.""" - # Spawn gelsight finger with physics material on specific prim + """Test loading prim from USD file with physics material applied to a prim, several prims, or none.""" usd_file_path = f"{ISAACLAB_NUCLEUS_DIR}/TacSL/gelsight_r15_finger/gelsight_r15_finger.usd" - - # Create spawn configuration - spawn_cfg = sim_utils.UsdFileWithCompliantContactCfg( - usd_path=usd_file_path, - rigid_props=PhysxRigidBodyCfg(disable_gravity=True), - compliant_contact_stiffness=1000.0, - compliant_contact_damping=100.0, - physics_material_prim_path="elastomer", - ) - - # Spawn the prim - prim = spawn_cfg.func("/World/Robot", spawn_cfg) - - # Check validity - assert prim.IsValid() - assert sim.stage.GetPrimAtPath("/World/Robot").IsValid() - assert prim.GetPrimTypeInfo().GetTypeName() == "Xform" - - material_prim_path = "/World/Robot/elastomer/compliant_material" - # Check that the physics material was applied to the specified prim - assert sim.stage.GetPrimAtPath(material_prim_path).IsValid() - - # Check properties - material_prim = sim.stage.GetPrimAtPath(material_prim_path) - assert material_prim.IsValid() - assert material_prim.GetAttribute("physxMaterial:compliantContactStiffness").Get() == 1000.0 - assert material_prim.GetAttribute("physxMaterial:compliantContactDamping").Get() == 100.0 - - -@pytest.mark.isaacsim_ci -def test_spawn_usd_with_compliant_contact_material_on_multiple_prims(sim): - """Test loading prim from USD file with physics material applied to multiple prims.""" - # Spawn Panda robot with physics material on specific prims - usd_file_path = f"{ISAACLAB_NUCLEUS_DIR}/TacSL/gelsight_r15_finger/gelsight_r15_finger.usd" - - # Create spawn configuration - spawn_cfg = sim_utils.UsdFileWithCompliantContactCfg( - usd_path=usd_file_path, - rigid_props=PhysxRigidBodyCfg(disable_gravity=True), - compliant_contact_stiffness=1000.0, - compliant_contact_damping=100.0, - physics_material_prim_path=["elastomer", "gelsight_finger"], - ) - - # Spawn the prim - prim = spawn_cfg.func("/World/Robot", spawn_cfg) - - # Check validity - assert prim.IsValid() - assert sim.stage.GetPrimAtPath("/World/Robot").IsValid() - assert prim.GetPrimTypeInfo().GetTypeName() == "Xform" - - # Check that the physics material was applied to the specified prims - for link_name in ["elastomer", "gelsight_finger"]: - material_prim_path = f"/World/Robot/{link_name}/compliant_material" - print("checking", material_prim_path) - assert sim.stage.GetPrimAtPath(material_prim_path).IsValid() - - # Check properties - material_prim = sim.stage.GetPrimAtPath(material_prim_path) - assert material_prim.IsValid() - assert material_prim.GetAttribute("physxMaterial:compliantContactStiffness").Get() == 1000.0 - assert material_prim.GetAttribute("physxMaterial:compliantContactDamping").Get() == 100.0 - - -@pytest.mark.isaacsim_ci -def test_spawn_usd_with_compliant_contact_material_no_prim_path(sim): - """Test loading prim from USD file with physics material but no prim path specified.""" - # Spawn gelsight finger without specifying prim path for physics material - usd_file_path = f"{ISAACLAB_NUCLEUS_DIR}/TacSL/gelsight_r15_finger/gelsight_r15_finger.usd" - - # Create spawn configuration without physics material prim path - spawn_cfg = sim_utils.UsdFileWithCompliantContactCfg( - usd_path=usd_file_path, - rigid_props=PhysxRigidBodyCfg(disable_gravity=True), - compliant_contact_stiffness=1000.0, - compliant_contact_damping=100.0, - physics_material_prim_path=None, - ) - - # Spawn the prim - prim = spawn_cfg.func("/World/Robot", spawn_cfg) - - # Check validity - should still spawn successfully but without physics material - assert prim.IsValid() - assert sim.stage.GetPrimAtPath("/World/Robot").IsValid() - assert prim.GetPrimTypeInfo().GetTypeName() == "Xform" - - material_prim_path = "/World/Robot/elastomer/compliant_material" - material_prim = sim.stage.GetPrimAtPath(material_prim_path) - assert material_prim is not None - assert not material_prim.IsValid() + cases = [ + ("/World/RobotStr", "elastomer", ["elastomer"]), + ("/World/RobotList", ["elastomer", "gelsight_finger"], ["elastomer", "gelsight_finger"]), + ("/World/RobotNone", None, []), + ] + for prim_path, material_prim_path, material_links in cases: + spawn_cfg = sim_utils.UsdFileWithCompliantContactCfg( + usd_path=usd_file_path, + rigid_props=PhysxRigidBodyCfg(disable_gravity=True), + compliant_contact_stiffness=1000.0, + compliant_contact_damping=100.0, + physics_material_prim_path=material_prim_path, + ) + prim = spawn_cfg.func(prim_path, spawn_cfg) + + # Check validity + assert prim.IsValid() + assert sim.stage.GetPrimAtPath(prim_path).IsValid() + assert prim.GetPrimTypeInfo().GetTypeName() == "Xform" + + # Check that the physics material was applied only to the specified prims + for link_name in ["elastomer", "gelsight_finger"]: + material_prim = sim.stage.GetPrimAtPath(f"{prim_path}/{link_name}/compliant_material") + if link_name not in material_links: + assert not material_prim.IsValid(), (prim_path, link_name) + continue + assert material_prim.IsValid(), (prim_path, link_name) + assert material_prim.GetAttribute("physxMaterial:compliantContactStiffness").Get() == 1000.0 + assert material_prim.GetAttribute("physxMaterial:compliantContactDamping").Get() == 100.0 diff --git a/source/isaaclab/test/sim/test_spawn_lights.py b/source/isaaclab/test/sim/test_spawn_lights.py index 59c771880782..ee85b208777c 100644 --- a/source/isaaclab/test/sim/test_spawn_lights.py +++ b/source/isaaclab/test/sim/test_spawn_lights.py @@ -44,79 +44,25 @@ def sim(): sim.clear_instance() -def test_spawn_disk_light(sim): - """Test spawning a disk light source.""" - cfg = sim_utils.DiskLightCfg( - color=(0.1, 0.1, 0.1), enable_color_temperature=True, color_temperature=5500, intensity=100, radius=20.0 - ) - prim = cfg.func("/World/disk_light", cfg) - - # check if the light is spawned - assert prim.IsValid() - assert sim.stage.GetPrimAtPath("/World/disk_light").IsValid() - assert prim.GetPrimTypeInfo().GetTypeName() == "DiskLight" - # validate properties on the prim - _validate_properties_on_prim(prim, cfg) - - -def test_spawn_distant_light(sim): - """Test spawning a distant light.""" - cfg = sim_utils.DistantLightCfg( - color=(0.1, 0.1, 0.1), enable_color_temperature=True, color_temperature=5500, intensity=100, angle=20 - ) - prim = cfg.func("/World/distant_light", cfg) - - # check if the light is spawned - assert prim.IsValid() - assert sim.stage.GetPrimAtPath("/World/distant_light").IsValid() - assert prim.GetPrimTypeInfo().GetTypeName() == "DistantLight" - # validate properties on the prim - _validate_properties_on_prim(prim, cfg) - - -def test_spawn_dome_light(sim): - """Test spawning a dome light source.""" - cfg = sim_utils.DomeLightCfg( - color=(0.1, 0.1, 0.1), enable_color_temperature=True, color_temperature=5500, intensity=100 - ) - prim = cfg.func("/World/dome_light", cfg) - - # check if the light is spawned - assert prim.IsValid() - assert sim.stage.GetPrimAtPath("/World/dome_light").IsValid() - assert prim.GetPrimTypeInfo().GetTypeName() == "DomeLight" - # validate properties on the prim - _validate_properties_on_prim(prim, cfg) - - -def test_spawn_cylinder_light(sim): - """Test spawning a cylinder light source.""" - cfg = sim_utils.CylinderLightCfg( - color=(0.1, 0.1, 0.1), enable_color_temperature=True, color_temperature=5500, intensity=100, radius=20.0 - ) - prim = cfg.func("/World/cylinder_light", cfg) - - # check if the light is spawned - assert prim.IsValid() - assert sim.stage.GetPrimAtPath("/World/cylinder_light").IsValid() - assert prim.GetPrimTypeInfo().GetTypeName() == "CylinderLight" - # validate properties on the prim - _validate_properties_on_prim(prim, cfg) - - -def test_spawn_sphere_light(sim): - """Test spawning a sphere light source.""" - cfg = sim_utils.SphereLightCfg( - color=(0.1, 0.1, 0.1), enable_color_temperature=True, color_temperature=5500, intensity=100, radius=20.0 - ) - prim = cfg.func("/World/sphere_light", cfg) - - # check if the light is spawned - assert prim.IsValid() - assert sim.stage.GetPrimAtPath("/World/sphere_light").IsValid() - assert prim.GetPrimTypeInfo().GetTypeName() == "SphereLight" - # validate properties on the prim - _validate_properties_on_prim(prim, cfg) +def test_spawn_lights(sim): + """Test spawning each light type through the shared light spawner.""" + common = dict(color=(0.1, 0.1, 0.1), enable_color_temperature=True, color_temperature=5500, intensity=100) + cases = [ + ("/World/disk_light", sim_utils.DiskLightCfg(**common, radius=20.0), "DiskLight"), + ("/World/distant_light", sim_utils.DistantLightCfg(**common, angle=20), "DistantLight"), + ("/World/dome_light", sim_utils.DomeLightCfg(**common), "DomeLight"), + ("/World/cylinder_light", sim_utils.CylinderLightCfg(**common, radius=20.0), "CylinderLight"), + ("/World/sphere_light", sim_utils.SphereLightCfg(**common, radius=20.0), "SphereLight"), + ] + for prim_path, cfg, type_name in cases: + prim = cfg.func(prim_path, cfg) + + # check if the light is spawned + assert prim.IsValid() + assert sim.stage.GetPrimAtPath(prim_path).IsValid() + assert prim.GetPrimTypeInfo().GetTypeName() == type_name + # validate properties on the prim + _validate_properties_on_prim(prim, cfg) """ diff --git a/source/isaaclab/test/sim/test_spawn_materials.py b/source/isaaclab/test/sim/test_spawn_materials.py index d1cb86c87029..47e49e778026 100644 --- a/source/isaaclab/test/sim/test_spawn_materials.py +++ b/source/isaaclab/test/sim/test_spawn_materials.py @@ -19,7 +19,6 @@ import isaaclab.sim as sim_utils from isaaclab.sim import SimulationCfg, SimulationContext -from isaaclab.utils.assets import NVIDIA_NUCLEUS_DIR pytestmark = [pytest.mark.integration, pytest.mark.isaacsim_ci] @@ -36,35 +35,6 @@ def sim(): sim.clear_instance() -def test_spawn_preview_surface(sim): - """Test spawning preview surface.""" - cfg = sim_utils.PreviewSurfaceCfg(diffuse_color=(0.0, 1.0, 0.0)) - prim = cfg.func("/Looks/PreviewSurface", cfg) - # Check validity - assert prim.IsValid() - assert sim.stage.GetPrimAtPath("/Looks/PreviewSurface").IsValid() - assert prim.GetPrimTypeInfo().GetTypeName() == "Shader" - # Check properties - assert prim.GetAttribute("inputs:diffuseColor").Get() == cfg.diffuse_color - - -def test_spawn_mdl_material(sim): - """Test spawning mdl material.""" - cfg = sim_utils.MdlFileCfg( - mdl_path=f"{NVIDIA_NUCLEUS_DIR}/Materials/Base/Metals/Aluminum_Anodized.mdl", - project_uvw=True, - albedo_brightness=0.5, - ) - prim = cfg.func("/Looks/MdlMaterial", cfg) - # Check validity - assert prim.IsValid() - assert sim.stage.GetPrimAtPath("/Looks/MdlMaterial").IsValid() - assert prim.GetPrimTypeInfo().GetTypeName() == "Shader" - # Check properties - assert prim.GetAttribute("inputs:project_uvw").Get() == cfg.project_uvw - assert prim.GetAttribute("inputs:albedo_brightness").Get() == cfg.albedo_brightness - - def test_spawn_glass_mdl_material(sim): """Test spawning a glass mdl material.""" cfg = sim_utils.GlassMdlCfg(thin_walled=False, glass_ior=1.0, glass_color=(0.0, 1.0, 0.0)) diff --git a/source/isaaclab/test/sim/test_spawn_meshes.py b/source/isaaclab/test/sim/test_spawn_meshes.py index d948ce529a70..5b04c7d761fd 100644 --- a/source/isaaclab/test/sim/test_spawn_meshes.py +++ b/source/isaaclab/test/sim/test_spawn_meshes.py @@ -47,51 +47,6 @@ def sim(): """ -def test_spawn_cone(sim): - """Test spawning of UsdGeomMesh as a cone prim.""" - # Spawn cone - cfg = sim_utils.MeshConeCfg(radius=1.0, height=2.0, axis="Y") - prim = cfg.func("/World/Cone", cfg) - - # Check validity - assert prim.IsValid() - assert sim.stage.GetPrimAtPath("/World/Cone").IsValid() - assert prim.GetPrimTypeInfo().GetTypeName() == "Xform" - # Check properties - prim = sim.stage.GetPrimAtPath("/World/Cone/geometry/mesh") - assert prim.GetPrimTypeInfo().GetTypeName() == "Mesh" - - -def test_spawn_capsule(sim): - """Test spawning of UsdGeomMesh as a capsule prim.""" - # Spawn capsule - cfg = sim_utils.MeshCapsuleCfg(radius=1.0, height=2.0, axis="Y") - prim = cfg.func("/World/Capsule", cfg) - - # Check validity - assert prim.IsValid() - assert sim.stage.GetPrimAtPath("/World/Capsule").IsValid() - assert prim.GetPrimTypeInfo().GetTypeName() == "Xform" - # Check properties - prim = sim.stage.GetPrimAtPath("/World/Capsule/geometry/mesh") - assert prim.GetPrimTypeInfo().GetTypeName() == "Mesh" - - -def test_spawn_cylinder(sim): - """Test spawning of UsdGeomMesh as a cylinder prim.""" - # Spawn cylinder - cfg = sim_utils.MeshCylinderCfg(radius=1.0, height=2.0, axis="Y") - prim = cfg.func("/World/Cylinder", cfg) - - # Check validity - assert prim.IsValid() - assert sim.stage.GetPrimAtPath("/World/Cylinder").IsValid() - assert prim.GetPrimTypeInfo().GetTypeName() == "Xform" - # Check properties - prim = sim.stage.GetPrimAtPath("/World/Cylinder/geometry/mesh") - assert prim.GetPrimTypeInfo().GetTypeName() == "Mesh" - - def test_spawn_cuboid(sim): """Test spawning of UsdGeomMesh as a cuboid prim.""" # Spawn cuboid @@ -130,8 +85,10 @@ def test_spawn_mesh_with_edge_refinement(sim, monkeypatch, cfg_type, kwargs, edg """Test surface edge refinement for deformable mesh primitives.""" monkeypatch.setattr(mesh_spawner.schemas, "define_deformable_body_properties", lambda *a, **k: None) cfg = cfg_type(**kwargs, edge_refinement=edge_refinement, deformable_props=sim_utils.DeformableBodyPropertiesCfg()) - cfg.func("/World/Refined", cfg) + root = cfg.func("/World/Refined", cfg) + assert root.GetPrimTypeInfo().GetTypeName() == "Xform" prim = sim.stage.GetPrimAtPath("/World/Refined/geometry/mesh") + assert prim.GetPrimTypeInfo().GetTypeName() == "Mesh" points = np.asarray(prim.GetAttribute("points").Get()) faces = np.asarray(prim.GetAttribute("faceVertexIndices").Get()).reshape(-1, 3) edges = points[faces[:, [0, 1, 1, 2, 2, 0]]].reshape(-1, 2, 3) @@ -173,25 +130,10 @@ def capture_deformable_properties(*args, **kwargs): assert captured_kwargs["tetrahedralization_edge_length_fac"] == pytest.approx(expected_factor) -def test_spawn_sphere(sim): - """Test spawning of UsdGeomMesh as a sphere prim.""" - # Spawn sphere - cfg = sim_utils.MeshSphereCfg(radius=1.0) - prim = cfg.func("/World/Sphere", cfg) - - # Check validity - assert prim.IsValid() - assert sim.stage.GetPrimAtPath("/World/Sphere").IsValid() - assert prim.GetPrimTypeInfo().GetTypeName() == "Xform" - # Check properties - prim = sim.stage.GetPrimAtPath("/World/Sphere/geometry/mesh") - assert prim.GetPrimTypeInfo().GetTypeName() == "Mesh" - - -@pytest.mark.parametrize("size", [(1.0, 1.0), (1.5, 0.8)]) -def test_spawn_rectangle(sim, size): +def test_spawn_rectangle(sim): """Test spawning of UsdGeomMesh as a rectangle prim.""" - # Spawn rectangle + # Spawn a non-square rectangle so the extents identify each side + size = (1.5, 0.8) cfg = sim_utils.MeshRectangleCfg(size=size) prim = cfg.func("/World/Rectangle", cfg) @@ -202,8 +144,10 @@ def test_spawn_rectangle(sim, size): # Check properties prim = sim.stage.GetPrimAtPath("/World/Rectangle/geometry/mesh") assert prim.GetPrimTypeInfo().GetTypeName() == "Mesh" - assert len(prim.GetAttribute("points").Get()) == 4 + points = np.asarray(prim.GetAttribute("points").Get()) + assert len(points) == 4 assert len(prim.GetAttribute("faceVertexCounts").Get()) == 2 + np.testing.assert_allclose(points.max(axis=0)[:2] - points.min(axis=0)[:2], size, atol=1e-6) def test_invalid_edge_refinement(sim): @@ -252,8 +196,3 @@ def test_spawn_cone_with_all_rigid_props(sim): # -- collision shape prim = sim.stage.GetPrimAtPath("/World/Cone/geometry/mesh") assert prim.GetAttribute("physics:collisionEnabled").Get() is True - - # check sim playing - sim.play() - for _ in range(10): - sim.step() diff --git a/source/isaaclab/test/sim/test_spawn_sensors.py b/source/isaaclab/test/sim/test_spawn_sensors.py index 9e50b54496bc..6121dc71638c 100644 --- a/source/isaaclab/test/sim/test_spawn_sensors.py +++ b/source/isaaclab/test/sim/test_spawn_sensors.py @@ -42,8 +42,8 @@ def sim(): """ -def test_spawn_pinhole_camera(sim): - """Test spawning a pinhole camera.""" +def test_spawn_cameras(sim): + """Test spawning a pinhole and a fisheye camera.""" cfg = sim_utils.PinholeCameraCfg( focal_length=5.0, f_stop=10.0, clipping_range=(0.1, 1000.0), horizontal_aperture=10.0 ) @@ -55,9 +55,7 @@ def test_spawn_pinhole_camera(sim): # Check properties _validate_properties_on_prim(prim, cfg, CUSTOM_PINHOLE_CAMERA_ATTRIBUTES) - -def test_spawn_fisheye_camera(sim): - """Test spawning a fisheye camera.""" + # spawn a fisheye camera on the same stage cfg = sim_utils.FisheyeCameraCfg( projection_type="fisheyePolynomial", focal_length=5.0, diff --git a/source/isaaclab/test/sim/test_spawn_shapes.py b/source/isaaclab/test/sim/test_spawn_shapes.py index b9c827c04d08..cca010e11192 100644 --- a/source/isaaclab/test/sim/test_spawn_shapes.py +++ b/source/isaaclab/test/sim/test_spawn_shapes.py @@ -124,35 +124,6 @@ def test_spawn_sphere(sim): """ -def test_spawn_cone_with_rigid_props(sim): - """Test spawning of UsdGeom.Cone prim with rigid body API. - - Note: - Playing the simulation in this case will give a warning that no mass is specified! - Need to also setup mass and colliders. - """ - usd_rigid_props = sim_utils.UsdPhysicsRigidBodyCfg(rigid_body_enabled=True) - physx_rigid_props = PhysxRigidBodyCfg(solver_position_iteration_count=8, sleep_threshold=0.1) - cfg = sim_utils.ConeCfg( - radius=1.0, - height=2.0, - rigid_props=[usd_rigid_props, physx_rigid_props], - ) - prim = cfg.func("/World/Cone", cfg) - - # Check validity - assert prim.IsValid() - assert sim.stage.GetPrimAtPath("/World/Cone").IsValid() - # Check properties - prim = sim.stage.GetPrimAtPath("/World/Cone") - assert prim.GetAttribute("physics:rigidBodyEnabled").Get() == usd_rigid_props.rigid_body_enabled - assert ( - prim.GetAttribute("physxRigidBody:solverPositionIterationCount").Get() - == physx_rigid_props.solver_position_iteration_count - ) - assert prim.GetAttribute("physxRigidBody:sleepThreshold").Get() == pytest.approx(physx_rigid_props.sleep_threshold) - - def test_spawn_cone_with_rigid_and_mass_props(sim): """Test spawning of UsdGeom.Cone prim with rigid body and mass API.""" cfg = sim_utils.ConeCfg( @@ -172,11 +143,9 @@ def test_spawn_cone_with_rigid_and_mass_props(sim): # Check properties prim = sim.stage.GetPrimAtPath("/World/Cone") assert prim.GetAttribute("physics:mass").Get() == cfg.mass_props.mass - - # check sim playing - sim.play() - for _ in range(10): - sim.step() + assert prim.GetAttribute("physics:rigidBodyEnabled").Get() is True + assert prim.GetAttribute("physxRigidBody:solverPositionIterationCount").Get() == 8 + assert prim.GetAttribute("physxRigidBody:sleepThreshold").Get() == pytest.approx(0.1) def test_spawn_cone_with_rigid_and_density_props(sim): @@ -206,11 +175,6 @@ def test_spawn_cone_with_rigid_and_density_props(sim): prim = sim.stage.GetPrimAtPath("/World/Cone") assert prim.GetAttribute("physics:density").Get() == cfg.mass_props.density - # check sim playing - sim.play() - for _ in range(10): - sim.step() - def test_spawn_cone_with_all_props(sim): """Test spawning of UsdGeom.Cone prim with all properties.""" @@ -237,11 +201,6 @@ def test_spawn_cone_with_all_props(sim): prim = sim.stage.GetPrimAtPath("/World/Cone/geometry/mesh") assert prim.GetAttribute("physics:collisionEnabled").Get() is True - # check sim playing - sim.play() - for _ in range(10): - sim.step() - """ Cloning. diff --git a/source/isaaclab/test/sim/test_spawn_wrappers.py b/source/isaaclab/test/sim/test_spawn_wrappers.py index ed4acf2d1cbc..0c880a271bf2 100644 --- a/source/isaaclab/test/sim/test_spawn_wrappers.py +++ b/source/isaaclab/test/sim/test_spawn_wrappers.py @@ -17,6 +17,8 @@ from isaaclab_newton.sim.schemas import NewtonArticulationCfg from isaaclab_physx.sim.schemas import PhysxArticulationCfg, PhysxRigidBodyCfg +from pxr import Usd, UsdPhysics + import isaaclab.sim as sim_utils from isaaclab.sim import SimulationCfg, SimulationContext from isaaclab.utils.assets import ISAACLAB_NUCLEUS_DIR @@ -80,43 +82,6 @@ def test_spawn_multiple_shapes_with_regex_prefix(sim): assert sim.stage.GetPrimAtPath(path).GetAttribute("physics:mass").Get() == cfg.mass_props.mass -def test_spawn_multiple_shapes_with_global_settings(sim): - """Test spawning of shapes randomly with global rigid body settings.""" - sim_utils.create_prim("/World/template", "Xform", translation=(0, 0, 0)) - - cfg = sim_utils.MultiAssetSpawnerCfg( - assets_cfg=[ - sim_utils.ConeCfg( - radius=0.3, - height=0.6, - visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.0, 1.0, 0.0), metallic=0.2), - mass_props=sim_utils.MassCfg(mass=100.0), # this one should get overridden - ), - sim_utils.CuboidCfg( - size=(0.3, 0.3, 0.3), - visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(1.0, 0.0, 0.0), metallic=0.2), - ), - sim_utils.SphereCfg( - radius=0.3, - visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.0, 0.0, 1.0), metallic=0.2), - ), - ], - rigid_props=PhysxRigidBodyCfg(solver_position_iteration_count=4, solver_velocity_iteration_count=0), - mass_props=sim_utils.MassCfg(mass=1.0), - collision_props=sim_utils.UsdPhysicsCollisionCfg(), - ) - prim = cfg.func("/World/template/Cone/asset_.*", cfg) - - assert prim.IsValid() - assert str(prim.GetPath()) == "/World/template/Cone/asset_0" - prim_paths = sim_utils.find_matching_prim_paths("/World/template/Cone/asset_[^/]*") - assert len(prim_paths) == 3 - - for prim_path in prim_paths: - prim = sim.stage.GetPrimAtPath(prim_path) - assert prim.GetAttribute("physics:mass").Get() == cfg.mass_props.mass - - def test_spawn_multiple_shapes_with_individual_settings(sim): """Test spawning of shapes randomly with individual rigid object settings.""" sim_utils.create_prim("/World/template", "Xform", translation=(0, 0, 0)) @@ -155,9 +120,10 @@ def test_spawn_multiple_shapes_with_individual_settings(sim): prim_paths = sim_utils.find_matching_prim_paths("/World/template/Cone/asset_[^/]*") assert len(prim_paths) == 3 - for prim_path in prim_paths: - prim = sim.stage.GetPrimAtPath(prim_path) - assert prim.GetAttribute("physics:mass").Get() in mass_variations + # each variant keeps its own settings, in spawn order + for asset_idx, mass in enumerate(mass_variations): + prim = sim.stage.GetPrimAtPath(f"/World/template/Cone/asset_{asset_idx}") + assert prim.GetAttribute("physics:mass").Get() == mass def test_spawn_multiple_shapes_with_explicit_spawn_paths(sim): @@ -234,3 +200,15 @@ def test_spawn_multiple_files_with_global_settings(sim): assert str(prim.GetPath()) == "/World/template/Robot/asset_0" prim_paths = sim_utils.find_matching_prim_paths("/World/template/Robot/asset_[^/]*") assert len(prim_paths) == 2 + + # the global settings reach every file's articulation root and rigid bodies + for prim_path in prim_paths: + prims = list(Usd.PrimRange(sim.stage.GetPrimAtPath(prim_path), Usd.TraverseInstanceProxies())) + roots = [p for p in prims if p.HasAPI(UsdPhysics.ArticulationRootAPI)] + bodies = [p for p in prims if p.HasAPI(UsdPhysics.RigidBodyAPI)] + assert roots and bodies, prim_path + for root in roots: + assert root.GetAttribute("physxArticulation:enabledSelfCollisions").Get() is True, root.GetPath() + for body in bodies: + max_depenetration = body.GetAttribute("physxRigidBody:maxDepenetrationVelocity").Get() + assert max_depenetration == pytest.approx(1.0), body.GetPath() diff --git a/source/isaaclab/test/sim/test_urdf_converter.py b/source/isaaclab/test/sim/test_urdf_converter.py index 0be2819bd33b..14f6234038f8 100644 --- a/source/isaaclab/test/sim/test_urdf_converter.py +++ b/source/isaaclab/test/sim/test_urdf_converter.py @@ -16,9 +16,7 @@ import math import os -import tempfile import warnings -import xml.etree.ElementTree as ET from types import SimpleNamespace import pytest @@ -141,7 +139,8 @@ def test_create_prim_from_usd(sim_config): @pytest.mark.isaacsim_ci def test_config_drive_type(sim_config, tmp_path): - """Verify that ``target_type='position'`` plus uniform PD gains are written into every joint's DriveAPI. + """Verify that ``target_type='position'``, a uniform drive type, and uniform PD gains are written into + every joint's DriveAPI. Reads the converter's USD output directly via :class:`pxr.UsdPhysics.DriveAPI` so the assertion does not depend on a running PhysX simulation. Revolute joints are checked in N·m/deg (the USD storage @@ -155,6 +154,7 @@ def test_config_drive_type(sim_config, tmp_path): damping = 4.2 config.force_usd_conversion = True + config.joint_drive.drive_type = "acceleration" config.joint_drive.target_type = "position" config.joint_drive.gains.stiffness = stiffness config.joint_drive.gains.damping = damping @@ -176,6 +176,7 @@ def test_config_drive_type(sim_config, tmp_path): drive = UsdPhysics.DriveAPI.Get(prim, instance_name) actual_stiffness = drive.GetStiffnessAttr().Get() actual_damping = drive.GetDampingAttr().Get() + assert drive.GetTypeAttr().Get() == "acceleration", f"Joint {prim.GetName()}: expected 'acceleration' drive" if is_revolute: expected_stiffness = stiffness * math.pi / 180.0 @@ -199,73 +200,12 @@ def test_config_drive_type(sim_config, tmp_path): @pytest.mark.isaacsim_ci -def test_merge_fixed_joints_xml(): - """Test that the merge_fixed_joints utility correctly modifies URDF XML. - - Uses ``test_merge_joints.urdf`` which has: - - 7 links (root_link, base_link, link_1, link_2, palm_link, finger_link_1, finger_link_2) - - 6 joints (3 fixed, 1 continuous, 2 prismatic) +def test_merge_fixed_joints_converter(sim_config, tmp_path): + """Test the full URDF converter pipeline with merge_fixed_joints enabled. - After merging: - - 4 links (root_link, link_1, finger_link_1, finger_link_2) - - 3 joints (0 fixed, 1 continuous, 2 prismatic) + ``test_merge_joints.urdf`` has 7 links and 6 joints (3 fixed, 1 continuous, 2 prismatic); merging + leaves 4 links (root_link, link_1, finger_link_1, finger_link_2) and the 3 movable joints. """ - if _USE_KIT: - # this test takes no fixture, so enable the owning extension before importing from it - manager = omni.kit.app.get_app().get_extension_manager() - if not manager.is_extension_enabled("isaacsim.asset.importer.urdf"): - manager.set_extension_enabled_immediate("isaacsim.asset.importer.urdf", True) - - from isaacsim.asset.importer.urdf.impl.urdf_utils import merge_fixed_joints - - with tempfile.TemporaryDirectory(prefix="isaaclab_test_merge_") as tmpdir: - output_path = os.path.join(tmpdir, "merged.urdf") - merge_fixed_joints(_MERGE_JOINTS_URDF, output_path) - - # parse the output URDF - tree = ET.parse(output_path) - root = tree.getroot() - - links = root.findall("link") - joints = root.findall("joint") - link_names = sorted(link.get("name") for link in links) - joint_types = [j.get("type") for j in joints] - - # verify link count and names - assert len(links) == 4, f"Expected 4 links, got {len(links)}: {link_names}" - assert sorted(link_names) == sorted(["root_link", "link_1", "finger_link_1", "finger_link_2"]) - - # verify no fixed joints remain - assert "fixed" not in joint_types, f"Fixed joints should be removed, got types: {joint_types}" - - # verify joint count and types - assert len(joints) == 3, f"Expected 3 joints, got {len(joints)}" - - # verify that visuals from merged links were transferred - # root_link should have base_link's visual (1 visual from base_link) - root_link = next(link for link in links if link.get("name") == "root_link") - root_visuals = root_link.findall("visual") - assert len(root_visuals) >= 1, "root_link should have at least 1 visual from merged base_link" - - # link_1 should have visuals from link_1, link_2, and palm_link (3 total) - link_1 = next(link for link in links if link.get("name") == "link_1") - link_1_visuals = link_1.findall("visual") - assert len(link_1_visuals) == 3, ( - f"link_1 should have 3 visuals (own + link_2 + palm_link), got {len(link_1_visuals)}" - ) - - # verify that re-parented joints (finger joints) now reference link_1 - for joint in joints: - parent_name = joint.find("parent").get("link") - child_name = joint.find("child").get("link") - # finger joints were parented to palm_link, should now be parented to link_1 - if child_name in ("finger_link_1", "finger_link_2"): - assert parent_name == "link_1", f"Expected finger joint parent to be 'link_1', got '{parent_name}'" - - -@pytest.mark.isaacsim_ci -def test_merge_fixed_joints_converter(sim_config, tmp_path): - """Test the full URDF converter pipeline with merge_fixed_joints enabled.""" sim, config = sim_config # Create directory to dump results output_dir = os.path.join(str(tmp_path), "urdf_converter_merge") @@ -287,6 +227,17 @@ def test_merge_fixed_joints_converter(sim_config, tmp_path): sim_utils.create_prim(prim_path, usd_path=urdf_converter.usd_path) assert sim.stage.GetPrimAtPath(prim_path).IsValid() + # the fixed-joint children are merged into their parents; the movable joints remain + from pxr import Usd, UsdPhysics + + stage = Usd.Stage.Open(urdf_converter.usd_path) + prims = list(stage.Traverse()) + names = {prim.GetName() for prim in prims} + assert not names & {"base_link", "link_2", "palm_link"}, f"Merged links still present: {sorted(names)}" + assert {"root_link", "link_1", "finger_link_1", "finger_link_2"} <= names + movable = [p for p in prims if p.IsA(UsdPhysics.RevoluteJoint) or p.IsA(UsdPhysics.PrismaticJoint)] + assert len(movable) == 3, f"Expected 3 movable joints, got {[p.GetName() for p in movable]}" + @pytest.mark.isaacsim_ci def test_fix_base_creates_fixed_joint(sim_config, tmp_path): @@ -337,7 +288,7 @@ def test_no_fix_base(sim_config, tmp_path): @pytest.mark.isaacsim_ci def test_collision_from_visuals(sim_config, tmp_path): - """Verify that collision_from_visuals runs without error and produces valid output. + """Verify that collision_from_visuals with a non-default collision type runs and produces valid output. Note: CollisionAPI is applied on the intermediate stage before the asset transformer restructures the USD. The transformer may not preserve CollisionAPI in the final @@ -349,25 +300,7 @@ def test_collision_from_visuals(sim_config, tmp_path): os.makedirs(output_dir, exist_ok=True) config.collision_from_visuals = True - config.force_usd_conversion = True - config.usd_dir = output_dir - urdf_converter = UrdfConverter(config) - - assert os.path.exists(urdf_converter.usd_path), "USD file should exist after conversion" - - prim_path = "/World/Robot" - sim_utils.create_prim(prim_path, usd_path=urdf_converter.usd_path) - assert sim.stage.GetPrimAtPath(prim_path).IsValid() - - -@pytest.mark.isaacsim_ci -def test_no_collision_from_visuals(sim_config, tmp_path): - """Verify that conversion succeeds when collision_from_visuals is disabled.""" - sim, config = sim_config - output_dir = os.path.join(str(tmp_path), "urdf_no_collision_visuals") - os.makedirs(output_dir, exist_ok=True) - - config.collision_from_visuals = False + config.collision_type = "Convex Decomposition" config.force_usd_conversion = True config.usd_dir = output_dir urdf_converter = UrdfConverter(config) @@ -411,8 +344,6 @@ def test_self_collision(sim_config, tmp_path): found_self_collision = False for prim in articulation_roots: - print(prim.GetName()) - print(prim.GetAttribute("newton:selfCollisionEnabled")) sc_attr = prim.GetAttribute("newton:selfCollisionEnabled") if sc_attr and sc_attr.HasValue() and sc_attr.Get(): found_self_collision = True @@ -421,39 +352,6 @@ def test_self_collision(sim_config, tmp_path): assert found_self_collision, "Expected ``newton:selfCollisionEnabled`` to be True on a Newton articulation root" -@pytest.mark.isaacsim_ci -def test_drive_type_acceleration(sim_config, tmp_path): - """Verify that drive_type='acceleration' is applied to all joints.""" - sim, config = sim_config - output_dir = os.path.join(str(tmp_path), "urdf_drive_accel") - os.makedirs(output_dir, exist_ok=True) - - config.force_usd_conversion = True - config.joint_drive.drive_type = "acceleration" - config.joint_drive.gains.stiffness = 100.0 - config.joint_drive.gains.damping = 10.0 - config.usd_dir = output_dir - urdf_converter = UrdfConverter(config) - - from pxr import Usd, UsdPhysics - - stage = Usd.Stage.Open(urdf_converter.usd_path) - - joint_count = 0 - for prim in stage.Traverse(): - if prim.IsA(UsdPhysics.RevoluteJoint) or prim.IsA(UsdPhysics.PrismaticJoint): - instance_name = "angular" if prim.IsA(UsdPhysics.RevoluteJoint) else "linear" - drive = UsdPhysics.DriveAPI.Get(prim, instance_name) - type_attr = drive.GetTypeAttr() - assert type_attr and type_attr.HasValue(), f"Joint {prim.GetName()} missing drive type" - assert type_attr.Get() == "acceleration", ( - f"Expected drive type 'acceleration' on {prim.GetName()}, got '{type_attr.Get()}'" - ) - joint_count += 1 - - assert joint_count > 0, "No joints found in the output USD" - - @pytest.mark.isaacsim_ci def test_target_type_none_zeros_gains(sim_config, tmp_path): """Verify that ``target_type='none'`` zeros the DriveAPI stiffness and damping on every joint.""" @@ -491,7 +389,7 @@ def test_target_type_none_zeros_gains(sim_config, tmp_path): @pytest.mark.isaacsim_ci def test_per_joint_dict_gains(sim_config, tmp_path): - """Verify that per-joint dict-based gains are applied correctly.""" + """Verify that per-joint dict-based gains and drive types are applied correctly.""" sim, config = sim_config output_dir = os.path.join(str(tmp_path), "urdf_dict_gains") os.makedirs(output_dir, exist_ok=True) @@ -513,6 +411,10 @@ def test_per_joint_dict_gains(sim_config, tmp_path): "panda_finger": finger_damping, }, ) + config.joint_drive.drive_type = { + "panda_joint[1-7]": "acceleration", + "panda_finger": "force", + } config.usd_dir = output_dir urdf_converter = UrdfConverter(config) @@ -543,6 +445,7 @@ def test_per_joint_dict_gains(sim_config, tmp_path): assert abs(damping_attr.Get() - expected_d) < 0.01, ( f"Arm joint {name}: expected damping ~{expected_d}, got {damping_attr.Get()}" ) + assert drive.GetTypeAttr().Get() == "acceleration", f"Arm joint {name}: expected 'acceleration'" arm_joint_count += 1 elif "finger" in name: # finger joint (prismatic) — USD stores directly in N/m @@ -552,49 +455,13 @@ def test_per_joint_dict_gains(sim_config, tmp_path): assert abs(damping_attr.Get() - finger_damping) < 0.01, ( f"Finger joint {name}: expected damping {finger_damping}, got {damping_attr.Get()}" ) + assert drive.GetTypeAttr().Get() == "force", f"Finger joint {name}: expected 'force'" finger_joint_count += 1 assert arm_joint_count == 7, f"Expected 7 arm joints, got {arm_joint_count}" assert finger_joint_count == 2, f"Expected 2 finger joints, got {finger_joint_count}" -@pytest.mark.isaacsim_ci -def test_per_joint_dict_drive_type(sim_config, tmp_path): - """Verify that per-joint dict-based drive type is applied correctly.""" - sim, config = sim_config - output_dir = os.path.join(str(tmp_path), "urdf_dict_drive_type") - os.makedirs(output_dir, exist_ok=True) - - config.force_usd_conversion = True - config.joint_drive.drive_type = { - "panda_joint[1-7]": "acceleration", - "panda_finger": "force", - } - config.joint_drive.gains.stiffness = 50.0 - config.joint_drive.gains.damping = 5.0 - config.usd_dir = output_dir - urdf_converter = UrdfConverter(config) - - from pxr import Usd, UsdPhysics - - stage = Usd.Stage.Open(urdf_converter.usd_path) - - for prim in stage.Traverse(): - if not (prim.IsA(UsdPhysics.RevoluteJoint) or prim.IsA(UsdPhysics.PrismaticJoint)): - continue - name = prim.GetName() - instance_name = "angular" if prim.IsA(UsdPhysics.RevoluteJoint) else "linear" - drive = UsdPhysics.DriveAPI.Get(prim, instance_name) - type_attr = drive.GetTypeAttr() - - if "panda_joint" in name and "finger" not in name: - assert type_attr.Get() == "acceleration", ( - f"Arm joint {name}: expected 'acceleration', got '{type_attr.Get()}'" - ) - elif "finger" in name: - assert type_attr.Get() == "force", f"Finger joint {name}: expected 'force', got '{type_attr.Get()}'" - - @pytest.mark.isaacsim_ci def test_natural_frequency_gains_deprecation(sim_config, tmp_path): """Verify that NaturalFrequencyGainsCfg emits a DeprecationWarning and conversion still succeeds.""" @@ -624,100 +491,40 @@ def test_natural_frequency_gains_deprecation(sim_config, tmp_path): assert sim.stage.GetPrimAtPath(prim_path).IsValid() -@pytest.mark.isaacsim_ci -def test_usd_structure_has_joints_and_links(sim_config, tmp_path): - """Validate that the output USD contains the expected joint and link prims for Franka Panda.""" - sim, config = sim_config - output_dir = os.path.join(str(tmp_path), "urdf_structure") - os.makedirs(output_dir, exist_ok=True) - - config.merge_fixed_joints = False - config.force_usd_conversion = True - config.usd_dir = output_dir - urdf_converter = UrdfConverter(config) - - from pxr import Usd, UsdPhysics - - stage = Usd.Stage.Open(urdf_converter.usd_path) - - # count revolute and prismatic joints - revolute_joints = [p for p in stage.Traverse() if p.IsA(UsdPhysics.RevoluteJoint)] - prismatic_joints = [p for p in stage.Traverse() if p.IsA(UsdPhysics.PrismaticJoint)] - rigid_bodies = [p for p in stage.Traverse() if p.HasAPI(UsdPhysics.RigidBodyAPI)] - - # Franka Panda: 7 revolute arm joints, 2 prismatic finger joints - assert len(revolute_joints) >= 7, f"Expected at least 7 revolute joints, got {len(revolute_joints)}" - assert len(prismatic_joints) >= 2, f"Expected at least 2 prismatic joints, got {len(prismatic_joints)}" - assert len(rigid_bodies) >= 1, "Expected at least one rigid body link" - - # all joints should have DriveAPI applied - for joint_prim in revolute_joints + prismatic_joints: - instance_name = "angular" if joint_prim.IsA(UsdPhysics.RevoluteJoint) else "linear" - drive = UsdPhysics.DriveAPI.Get(joint_prim, instance_name) - stiffness_attr = drive.GetStiffnessAttr() - assert stiffness_attr and stiffness_attr.HasValue(), ( - f"Joint {joint_prim.GetName()} missing stiffness attribute in DriveAPI" - ) - - @pytest.mark.isaacsim_ci def test_link_density(sim_config, tmp_path): - """Verify that link_density applies density to rigid body links. + """Verify that link_density applies density to links without an explicit mass. - Note: The Franka Panda URDF has explicit mass on all links, so the importer's - ``apply_link_density`` only sets density on links without explicit mass (mass == 0). - This test verifies the pipeline runs without errors when ``link_density`` is set. + In ``test_merge_joints.urdf``, ``link_2`` has no inertial and the finger links have no mass, so they + get the configured density; ``link_1`` and ``palm_link`` have an explicit mass and keep it. """ sim, config = sim_config output_dir = os.path.join(str(tmp_path), "urdf_link_density") os.makedirs(output_dir, exist_ok=True) + config.asset_path = _MERGE_JOINTS_URDF + config.merge_fixed_joints = False config.link_density = 500.0 config.force_usd_conversion = True config.usd_dir = output_dir urdf_converter = UrdfConverter(config) - from pxr import Usd, UsdPhysics + from pxr import Usd stage = Usd.Stage.Open(urdf_converter.usd_path) - - # verify conversion succeeds and prims with MassAPI exist - mass_prims = [p for p in stage.Traverse() if p.HasAPI(UsdPhysics.MassAPI)] - assert len(mass_prims) > 0, "Expected prims with MassAPI" - - # verify we can spawn from the output - prim_path = "/World/Robot" - sim_utils.create_prim(prim_path, usd_path=urdf_converter.usd_path) - assert sim.stage.GetPrimAtPath(prim_path).IsValid() - - -@pytest.mark.isaacsim_ci -def test_collision_type_convex_decomposition(sim_config, tmp_path): - """Verify that ``collision_type='Convex Decomposition'`` runs without error and produces valid output. - - Note: MeshCollisionAPI is applied on the intermediate stage before the asset transformer. - The transformer may not preserve these schemas in the final output, so this test - verifies the pipeline executes successfully and produces a spawnable USD. - """ - sim, config = sim_config - output_dir = os.path.join(str(tmp_path), "urdf_convex_decomp") - os.makedirs(output_dir, exist_ok=True) - - config.collision_from_visuals = True - config.collision_type = "Convex Decomposition" - config.force_usd_conversion = True - config.usd_dir = output_dir - urdf_converter = UrdfConverter(config) - - assert os.path.exists(urdf_converter.usd_path), "USD file should exist after conversion" - - prim_path = "/World/Robot" - sim_utils.create_prim(prim_path, usd_path=urdf_converter.usd_path) - assert sim.stage.GetPrimAtPath(prim_path).IsValid() + densities = { + prim.GetName(): prim.GetAttribute("physics:density").Get() + for prim in stage.Traverse() + if prim.GetAttribute("physics:density").HasAuthoredValue() + } + for link_name in ("link_2", "finger_link_1", "finger_link_2"): + assert densities.get(link_name) == pytest.approx(500.0), f"{link_name}: densities={densities}" + for link_name in ("link_1", "palm_link"): + assert link_name not in densities, f"{link_name} has an explicit mass: densities={densities}" @pytest.mark.isaacsim_ci -def test_unsupported_features_warn(sim_config, tmp_path): +def test_unsupported_features_warn(sim_config, tmp_path, caplog): """Verify that deprecated config options emit warnings without failing.""" sim, config = sim_config output_dir = os.path.join(str(tmp_path), "urdf_deprecated_warn") @@ -729,9 +536,12 @@ def test_unsupported_features_warn(sim_config, tmp_path): config.force_usd_conversion = True config.usd_dir = output_dir - # conversion should succeed despite deprecated options - urdf_converter = UrdfConverter(config) + # conversion should succeed despite deprecated options, warning once per option + with caplog.at_level("WARNING"): + urdf_converter = UrdfConverter(config) assert os.path.exists(urdf_converter.usd_path), "USD file should be created despite deprecated options" + for option in ("convert_mimic_joints_to_normal_joints", "replace_cylinders_with_capsules", "root_link_name"): + assert f"'{option}' is no longer supported" in caplog.text, option def _physics_variant(usd_path: str) -> tuple[str, list[str]]: diff --git a/source/isaaclab/test/sim/test_utils_prims.py b/source/isaaclab/test/sim/test_utils_prims.py index 52b570300070..67eea8bfa8b2 100644 --- a/source/isaaclab/test/sim/test_utils_prims.py +++ b/source/isaaclab/test/sim/test_utils_prims.py @@ -23,7 +23,7 @@ import isaaclab.sim as sim_utils from isaaclab.sim.utils.prims import _to_tuple # type: ignore[reportPrivateUsage] -from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR, ISAACLAB_NUCLEUS_DIR, retrieve_file_path +from isaaclab.utils.assets import ISAACLAB_NUCLEUS_DIR, retrieve_file_path pytestmark = [pytest.mark.integration, pytest.mark.isaacsim_ci] @@ -191,13 +191,12 @@ def test_create_prim_with_different_input_types(input_type: str): assert op_names == ["xformOp:translate", "xformOp:orient", "xformOp:scale"] -@pytest.mark.parametrize( - "input_type", - ["list", "tuple", "numpy", "torch_cpu", "torch_cuda"], - ids=["list", "tuple", "numpy", "torch_cpu", "torch_cuda"], -) -def test_create_prim_with_world_position_different_types(input_type: str): - """Test create_prim() with world position using different input types.""" +def test_create_prim_with_world_position(): + """Test create_prim() converts a world position under a non-origin parent to the local frame. + + The pose is passed as device tensors, so this also covers device-to-host conversion; the other + input types share the conversion path covered by ``test_create_prim_with_different_input_types``. + """ # obtain stage handle stage = sim_utils.get_current_stage() @@ -214,28 +213,13 @@ def test_create_prim_with_world_position_different_types(input_type: str): world_pos_vals = [10.0, 20.0, 30.0] world_orient_vals = [0.0, 0.7071068, 0.0, 0.7071068] # 90 deg around Y - # Convert to the specified input type - if input_type == "list": - world_pos = world_pos_vals - world_orient = world_orient_vals - elif input_type == "tuple": - world_pos = tuple(world_pos_vals) - world_orient = tuple(world_orient_vals) - elif input_type == "numpy": - world_pos = np.array(world_pos_vals) - world_orient = np.array(world_orient_vals) - elif input_type == "torch_cpu": - world_pos = torch.tensor(world_pos_vals) - world_orient = torch.tensor(world_orient_vals) - elif input_type == "torch_cuda": - if not torch.cuda.is_available(): - pytest.skip("CUDA not available") - world_pos = torch.tensor(world_pos_vals, device="cuda") - world_orient = torch.tensor(world_orient_vals, device="cuda") + device = "cuda" if torch.cuda.is_available() else "cpu" + world_pos = torch.tensor(world_pos_vals, device=device) + world_orient = torch.tensor(world_orient_vals, device=device) # Create child prim with world position child = sim_utils.create_prim( - f"/World/Parent/Child_{input_type}", + "/World/Parent/Child", "Xform", stage=stage, position=world_pos, # Using position (world space) @@ -406,78 +390,11 @@ def test_select_usd_variants(): sim_utils.utils.select_usd_variants("/World", {"absent_set": "anything"}, stage) -def test_select_usd_variants_in_usd_file(): - """Test select_usd_variants() function in USD file.""" - stage = sim_utils.get_current_stage() - - prim = sim_utils.create_prim( - "/World/Test", "Xform", usd_path=f"{ISAAC_NUCLEUS_DIR}/Robots/UniversalRobots/ur10e/ur10e.usd", stage=stage - ) - - variant_sets = prim.GetVariantSets() - - # show all variants - for name in variant_sets.GetNames(): - vs = variant_sets.GetVariantSet(name) - options = vs.GetVariantNames() - selected = vs.GetVariantSelection() - - print(f"{name}: {selected} / {options}") - - print("Setting variant 'Gripper' to 'Robotiq_2f_140'.") - # The following performs the operations done internally - # in Isaac Lab. This should be removed in favor of 'select_usd_variants'. - target_vs = variant_sets.GetVariantSet("Gripper") - target_vs.SetVariantSelection("Robotiq_2f_140") - - # show again all variants - variant_sets = prim.GetVariantSets() - - for name in variant_sets.GetNames(): - vs = variant_sets.GetVariantSet(name) - options = vs.GetVariantNames() - selected = vs.GetVariantSelection() - - print(f"{name}: {selected} / {options}") - - # Uncomment the following once resolved - - # Set the variant selection - # sim_utils.select_usd_variants(prim.GetPath(), {"Gripper": "Robotiq_2f_140"}, stage) - - # Obtain variant set - # variant_set = prim.GetVariantSet("Gripper") - # # Check if the variant selection is correct - # assert variant_set.GetVariantSelection() == "Robotiq_2f_140" - - """ Property Management. """ -def test_change_prim_property_basic(): - """Test change_prim_property() with existing property.""" - # obtain stage handle - stage = sim_utils.get_current_stage() - # create a cube prim - prim = sim_utils.create_prim("/World/Cube", "Cube", stage=stage, attributes={"size": 1.0}) - - # check initial value - assert prim.GetAttribute("size").Get() == 1.0 - - # change the property - result = sim_utils.change_prim_property( - prop_path="/World/Cube.size", - value=2.0, - stage=stage, - ) - - # check that the change was successful - assert result is True - assert prim.GetAttribute("size").Get() == 2.0 - - def test_change_prim_property_create_new(): """Test change_prim_property() creates new property when it doesn't exist.""" # obtain stage handle @@ -497,8 +414,9 @@ def test_change_prim_property_create_new(): is_custom=True, ) - # check that the property was created successfully + # check that the property was created successfully with the requested type assert result is True + assert prim.GetAttribute("customValue").GetTypeName() == Sdf.ValueTypeNames.Int assert prim.GetAttribute("customValue").Get() == 42 @@ -525,45 +443,6 @@ def test_change_prim_property_clear_value(): assert prim.GetAttribute("size").Get() == 2.0 -@pytest.mark.parametrize( - "attr_name,value,value_type,expected", - [ - ("floatValue", 3.14, Sdf.ValueTypeNames.Float, 3.14), - ("boolValue", True, Sdf.ValueTypeNames.Bool, True), - ("intValue", 42, Sdf.ValueTypeNames.Int, 42), - ("stringValue", "test", Sdf.ValueTypeNames.String, "test"), - ("vec3Value", Gf.Vec3f(1.0, 2.0, 3.0), Sdf.ValueTypeNames.Float3, Gf.Vec3f(1.0, 2.0, 3.0)), - ("colorValue", Gf.Vec3f(1.0, 0.0, 0.5), Sdf.ValueTypeNames.Color3f, Gf.Vec3f(1.0, 0.0, 0.5)), - ], - ids=["float", "bool", "int", "string", "vec3", "color"], -) -def test_change_prim_property_different_types(attr_name: str, value, value_type, expected): - """Test change_prim_property() with different value types.""" - # obtain stage handle - stage = sim_utils.get_current_stage() - # create a prim - prim = sim_utils.create_prim("/World/Test", "Xform", stage=stage) - - # change the property - result = sim_utils.change_prim_property( - prop_path=f"/World/Test.{attr_name}", - value=value, - stage=stage, - type_to_create_if_not_exist=value_type, - is_custom=True, - ) - - # check that the change was successful - assert result is True - actual_value = prim.GetAttribute(attr_name).Get() - - # handle float comparison separately for precision - if isinstance(expected, float): - assert math.isclose(actual_value, expected, abs_tol=1e-6) - else: - assert actual_value == expected - - @pytest.mark.parametrize( "prop_path_input", ["/World/Cube.size", Sdf.Path("/World/Cube.size")], @@ -627,35 +506,22 @@ def test_change_prim_property_error_missing_type(): """ -def test_to_tuple_basic(): - """Test _to_tuple() with basic input types.""" - # Test with list - result = _to_tuple([1.0, 2.0, 3.0]) - assert result == (1.0, 2.0, 3.0) - assert isinstance(result, tuple) - - # Test with tuple - result = _to_tuple((1.0, 2.0, 3.0)) - assert result == (1.0, 2.0, 3.0) - - # Test with numpy array - result = _to_tuple(np.array([1.0, 2.0, 3.0])) - assert result == (1.0, 2.0, 3.0) - - # Test with torch tensor (CPU) - result = _to_tuple(torch.tensor([1.0, 2.0, 3.0])) - assert result == (1.0, 2.0, 3.0) - - # Test squeezing first dimension (batch size 1) - result = _to_tuple(torch.tensor([[1.0, 2.0]])) - assert result == (1.0, 2.0) - - result = _to_tuple(np.array([[1.0, 2.0, 3.0]])) - assert result == (1.0, 2.0, 3.0) +def test_to_tuple_edge_cases(): + """Test _to_tuple() squeezes a batch of one, converts mixed scalar items, and rejects N-D input. + Plain list/tuple/numpy/torch inputs are covered through ``create_prim`` in + ``test_create_prim_with_different_input_types``. + """ + # squeezing first dimension (batch size 1) + assert _to_tuple(torch.tensor([[1.0, 2.0]])) == (1.0, 2.0) + assert _to_tuple(np.array([[1.0, 2.0, 3.0]])) == (1.0, 2.0, 3.0) -def test_to_tuple_raises_error(): - """Test _to_tuple() raises an error for N-dimensional arrays.""" + # mixed sequences of numpy/torch scalar items and floats + result = _to_tuple([np.float32(1.0), 2.0, 3.0]) + assert len(result) == 3 + assert all(isinstance(x, float) for x in result) + assert _to_tuple([torch.tensor(1.0), 2.0, 3.0]) == (1.0, 2.0, 3.0) + assert _to_tuple((np.float32(1.0), 2.0, torch.tensor(3.0))) == (1.0, 2.0, 3.0) with pytest.raises(ValueError, match="not one dimensional"): _to_tuple(np.array([[1.0, 2.0], [3.0, 4.0]])) @@ -665,33 +531,3 @@ def test_to_tuple_raises_error(): with pytest.raises(ValueError, match="only one element tensors can be converted"): _to_tuple((torch.tensor([1.0, 2.0]), 3.0)) - - -def test_to_tuple_mixed_sequences(): - """Test _to_tuple() with mixed type sequences.""" - - # Mixed list with numpy and floats - result = _to_tuple([np.float32(1.0), 2.0, 3.0]) - assert len(result) == 3 - assert all(isinstance(x, float) for x in result) - - # Mixed tuple with torch tensor items and floats - result = _to_tuple([torch.tensor(1.0), 2.0, 3.0]) - assert result == (1.0, 2.0, 3.0) - - # Mixed tuple with numpy array items and torch tensor - result = _to_tuple((np.float32(1.0), 2.0, torch.tensor(3.0))) - assert result == (1.0, 2.0, 3.0) - - -def test_to_tuple_precision(): - """Test _to_tuple() maintains numerical precision.""" - from isaaclab.sim.utils.prims import _to_tuple - - # Test with high precision values - high_precision = [1.123456789, 2.987654321, 3.141592653] - result = _to_tuple(torch.tensor(high_precision, dtype=torch.float64)) - - # Check that precision is maintained reasonably well - for i, val in enumerate(high_precision): - assert math.isclose(result[i], val, abs_tol=1e-6) diff --git a/source/isaaclab/test/sim/test_utils_queries.py b/source/isaaclab/test/sim/test_utils_queries.py index 2cb3b496547b..26945d9268eb 100644 --- a/source/isaaclab/test/sim/test_utils_queries.py +++ b/source/isaaclab/test/sim/test_utils_queries.py @@ -13,16 +13,12 @@ """Rest everything follows.""" -import ast -import inspect -import textwrap import pytest from pxr import Sdf, UsdPhysics import isaaclab.sim as sim_utils -from isaaclab.sim.utils import queries from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR, ISAACLAB_NUCLEUS_DIR pytestmark = pytest.mark.integration @@ -129,22 +125,6 @@ def test_find_matching_prims_uses_unbounded_full_path_regex(): assert [prim.GetPath().pathString for prim in matches] == ["/World/Robot/foo", "/World/A/foo", "/World/B/foo"] -def test_find_matching_prims_has_no_inferred_traversal_bounds(): - """The query must not narrow or prune USD traversal from the user's regex.""" - sources = (sim_utils.find_matching_prims, queries._iter_matching_prims_in_subtree) - tree = ast.parse("\n".join(textwrap.dedent(inspect.getsource(function)) for function in sources)) - called_methods = { - node.func.attr for node in ast.walk(tree) if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) - } - called_functions = { - node.func.id for node in ast.walk(tree) if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) - } - - assert "GetPrimAtPath" not in called_methods - assert "PruneChildren" not in called_methods - assert "_bound_search" not in called_functions - - def test_find_matching_prims_fullmatches_top_level_alternation(): """Anchoring applies to the complete expression rather than individual alternatives.""" sim_utils.create_prim("/World/Robot/foo/bar") diff --git a/source/isaaclab/test/sim/test_utils_semantics.py b/source/isaaclab/test/sim/test_utils_semantics.py index 926a2d0d80a4..ac6b71f953cd 100644 --- a/source/isaaclab/test/sim/test_utils_semantics.py +++ b/source/isaaclab/test/sim/test_utils_semantics.py @@ -79,7 +79,7 @@ def test_add_and_get_labels(): def test_add_labels_with_overwrite(): - """Test add_labels() function with overwriting existing labels.""" + """Test add_labels() appends without overwrite and replaces one instance with overwrite.""" # get stage handle stage = sim_utils.get_current_stage() # create a test prim @@ -89,6 +89,11 @@ def test_add_labels_with_overwrite(): sim_utils.add_labels(prim, ["label_a", "label_b"], instance_name="class") sim_utils.add_labels(prim, ["shape_a"], instance_name="shape") + # Re-add labels with overwrite=False (should append) + sim_utils.add_labels(prim, ["label_c"], instance_name="class", overwrite=False) + labels_dict = sim_utils.get_labels(prim) + assert sorted(labels_dict["class"]) == sorted(["label_a", "label_b", "label_c"]) + # Overwrite existing labels for a specific instance sim_utils.add_labels(prim, ["replaced_label"], instance_name="class", overwrite=True) labels_dict = sim_utils.get_labels(prim) @@ -97,23 +102,6 @@ def test_add_labels_with_overwrite(): assert labels_dict["shape"] == ["shape_a"] -def test_add_labels_without_overwrite(): - """Test add_labels() function without overwriting existing labels.""" - # get stage handle - stage = sim_utils.get_current_stage() - # create a test prim - prim = stage.DefinePrim("/test", "Xform") - - # Add labels - sim_utils.add_labels(prim, ["label_a", "label_b"], instance_name="class") - sim_utils.add_labels(prim, ["shape_a"], instance_name="shape") - - # Re-add labels with overwrite=False (should append) - sim_utils.add_labels(prim, ["label_c"], instance_name="class", overwrite=False) - labels_dict = sim_utils.get_labels(prim) - assert sorted(labels_dict["class"]) == sorted(["label_a", "label_b", "label_c"]) - - def test_remove_all_labels(): """Test removing of all labels from a prim and its descendants.""" # get stage handle diff --git a/source/isaaclab/test/sim/test_utils_stage.py b/source/isaaclab/test/sim/test_utils_stage.py index 2f5fd96518da..8852fbb78c72 100644 --- a/source/isaaclab/test/sim/test_utils_stage.py +++ b/source/isaaclab/test/sim/test_utils_stage.py @@ -43,33 +43,6 @@ def test_create_new_stage(): assert root_prim.IsValid() -def test_create_multiple_stages(): - """Test creating multiple stages.""" - stage1 = sim_utils.create_new_stage() - stage2 = sim_utils.create_new_stage() - stage3 = sim_utils.create_new_stage() - - assert stage1 is not None - assert stage2 is not None - assert stage3 is not None - assert stage1 != stage2 - assert stage1 != stage3 - assert stage2 != stage3 - - -def test_create_new_stage_in_memory(): - """Test creating a new stage in memory (Isaac Sim 5.0+).""" - stage = sim_utils.create_new_stage() - - # Should return a valid stage - assert stage is not None - assert isinstance(stage, Usd.Stage) - - # Stage should have a root prim - root_prim = stage.GetPseudoRoot() - assert root_prim.IsValid() - - def test_is_current_stage_in_memory(): """Test checking if current stage is in memory.""" # Create a stage - in kitless mode, this creates an in-memory stage @@ -105,9 +78,14 @@ def test_save_and_open_stage(): assert result is True assert save_path.exists() - # Open the saved stage + # Switch to a different stage before opening + sim_utils.create_new_stage() + sim_utils.get_current_stage().DefinePrim("/DifferentPrim", "Xform") + + # Open the saved stage; it becomes the current stage opened_stage = sim_utils.open_stage(str(save_path)) assert isinstance(opened_stage, Usd.Stage) + assert sim_utils.get_current_stage() == opened_stage # Verify content was preserved test_cube = opened_stage.GetPrimAtPath("/World/TestCube") @@ -121,35 +99,6 @@ def test_open_stage_invalid_path(): sim_utils.open_stage("/invalid/path/to/stage.invalid") -def test_use_stage_context_manager(): - """Test use_stage context manager.""" - # Create two stages - stage1 = sim_utils.create_new_stage() - stage1.DefinePrim("/World", "Xform") - stage1.DefinePrim("/World/Stage1Marker", "Xform") - - stage2 = Usd.Stage.CreateInMemory() - stage2.DefinePrim("/World", "Xform") - stage2.DefinePrim("/World/Stage2Marker", "Xform") - - # Initially on stage1 - current = sim_utils.get_current_stage() - marker1 = current.GetPrimAtPath("/World/Stage1Marker") - assert marker1.IsValid() - - # Switch to stage2 temporarily - with sim_utils.use_stage(stage2): - temp_current = sim_utils.get_current_stage() - # Should be on stage2 now - marker2 = temp_current.GetPrimAtPath("/World/Stage2Marker") - assert marker2.IsValid() - - # Should be back on stage1 - final_current = sim_utils.get_current_stage() - marker1_again = final_current.GetPrimAtPath("/World/Stage1Marker") - assert marker1_again.IsValid() - - def test_use_stage_with_invalid_input(): """Test use_stage with invalid input.""" with pytest.raises((TypeError, AssertionError)): @@ -157,23 +106,6 @@ def test_use_stage_with_invalid_input(): pass -def test_update_stage(): - """Test updating the stage.""" - # Create a new stage - stage = sim_utils.create_new_stage() - - # Add a prim - prim_path = "/World/Test" - stage.DefinePrim(prim_path, "Xform") - - # Update stage should not raise errors - sim_utils.update_stage() - - # Prim should still exist - prim = stage.GetPrimAtPath(prim_path) - assert prim.IsValid() - - def test_save_stage_with_reload(): """Test saving stage with reload in place.""" with tempfile.TemporaryDirectory() as temp_dir: @@ -241,12 +173,13 @@ def test_close_stage(): # Close it result = sim_utils.close_stage() - # Should succeed (or return bool) - assert isinstance(result, bool) + assert result is True + # the closed stage is no longer the current stage + assert sim_utils.get_current_stage() != stage def test_clear_stage(): - """Test clearing the stage.""" + """Test clearing the stage deletes local prims and keeps protected ones.""" # Create a new stage stage = sim_utils.create_new_stage() @@ -254,23 +187,18 @@ def test_clear_stage(): stage.DefinePrim("/World", "Xform") stage.DefinePrim("/World/Cube", "Cube") stage.DefinePrim("/World/Sphere", "Sphere") + # prims the default predicate must preserve + stage.DefinePrim("/Keep", "Xform").SetMetadata("no_delete", True) + stage.DefinePrim("/Render/Vars", "Scope") # Clear the stage sim_utils.clear_stage() - # Stage should still exist but prims should be removed - assert stage is not None - - -def test_get_current_stage(): - """Test getting the current stage.""" - # Create a new stage - created_stage = sim_utils.create_new_stage() - - # Get current stage should return the same stage - current_stage = sim_utils.get_current_stage() - assert current_stage == created_stage - assert isinstance(current_stage, Usd.Stage) + # locally authored prims are removed; protected prims and the root survive + for path in ("/World", "/World/Cube", "/World/Sphere"): + assert not stage.GetPrimAtPath(path).IsValid(), path + for path in ("/", "/Keep", "/Render", "/Render/Vars"): + assert stage.GetPrimAtPath(path).IsValid(), path def test_get_current_stage_id(): @@ -286,41 +214,6 @@ def test_get_current_stage_id(): assert stage_id >= 0 -def test_resolve_paths(): - """Test resolve_paths helper for asset path resolution.""" - from isaaclab.sim.utils.stage import resolve_paths - - with tempfile.TemporaryDirectory() as temp_dir: - # Create a source stage with a sublayer reference - source_path = Path(temp_dir) / "source" / "source_stage.usd" - source_path.parent.mkdir(parents=True, exist_ok=True) - - # Create source stage with some content - source_stage = Usd.Stage.CreateNew(str(source_path)) - source_stage.DefinePrim("/World", "Xform") - source_stage.DefinePrim("/World/Cube", "Cube") - source_stage.GetRootLayer().Save() - - # Copy to a different location using layer transfer - dest_path = Path(temp_dir) / "dest" / "dest_stage.usd" - dest_path.parent.mkdir(parents=True, exist_ok=True) - - from pxr import Sdf - - dest_layer = Sdf.Layer.CreateNew(str(dest_path)) - dest_layer.TransferContent(source_stage.GetRootLayer()) - - # Resolve paths (should not raise any errors) - resolve_paths(str(source_path), str(dest_path)) - dest_layer.Save() - - # Open destination stage and verify content was preserved - dest_stage = Usd.Stage.Open(str(dest_path)) - cube_prim = dest_stage.GetPrimAtPath("/World/Cube") - assert cube_prim.IsValid() - assert cube_prim.GetTypeName() == "Cube" - - def test_resolve_paths_keeps_search_path_assets(): """Test resolve_paths leaves search-path asset identifiers untouched. @@ -390,45 +283,3 @@ def test_stage_context_tracking(): # After context manager, should be back to stage2 current = sim_utils.get_current_stage() assert current.GetPrimAtPath("/Stage2Marker").IsValid() - - -def test_is_prim_deletable(): - """Test _is_prim_deletable with various prim types.""" - from isaaclab.sim.utils.stage import _is_prim_deletable - - stage = sim_utils.create_new_stage() - - # Create a locally authored prim - should be deletable - local_prim = stage.DefinePrim("/World/LocalPrim", "Xform") - assert _is_prim_deletable(local_prim) is True - - # Create another deletable prim - another_prim = stage.DefinePrim("/World/AnotherPrim", "Cube") - assert _is_prim_deletable(another_prim) is True - - # Root prim should not be deletable - root_prim = stage.GetPseudoRoot() - assert _is_prim_deletable(root_prim) is False - - -def test_open_stage_sets_current(): - """Test that open_stage sets the opened stage as current.""" - with tempfile.TemporaryDirectory() as temp_dir: - # Create and save a stage - stage = sim_utils.create_new_stage() - stage.DefinePrim("/TestPrim", "Xform") - - save_path = Path(temp_dir) / "test.usd" - sim_utils.save_stage(str(save_path), save_and_reload_in_place=False) - - # Create a different stage - sim_utils.create_new_stage() - sim_utils.get_current_stage().DefinePrim("/DifferentPrim", "Xform") - - # Open the saved stage - opened = sim_utils.open_stage(str(save_path)) - - # Opened stage should now be current - current = sim_utils.get_current_stage() - assert current == opened - assert current.GetPrimAtPath("/TestPrim").IsValid() diff --git a/source/isaaclab/test/sim/test_utils_transforms.py b/source/isaaclab/test/sim/test_utils_transforms.py index e7cc178b65d5..946b07c40552 100644 --- a/source/isaaclab/test/sim/test_utils_transforms.py +++ b/source/isaaclab/test/sim/test_utils_transforms.py @@ -81,42 +81,6 @@ def get_xform_ops(prim: Usd.Prim) -> list[str]: """ -def test_standardize_xform_ops_basic(): - """Test basic functionality of standardize_xform_ops on a simple prim.""" - # obtain stage handle - stage = sim_utils.get_current_stage() - - # Create a simple xform prim with standard operations - prim = sim_utils.create_prim( - "/World/TestXform", - "Xform", - translation=(1.0, 2.0, 3.0), - orientation=(0.0, 0.0, 0.0, 1.0), # x, y, z, w (identity) - scale=(1.0, 1.0, 1.0), - stage=stage, - ) - - # Apply standardize_xform_ops - result = sim_utils.standardize_xform_ops(prim) - - # Verify the operation succeeded - assert result is True - assert prim.IsValid() - - # Check that the xform operations are in the correct order - xform_ops = get_xform_ops(prim) - assert xform_ops == [ - "xformOp:translate", - "xformOp:orient", - "xformOp:scale", - ], f"Expected standard xform order, got {xform_ops}" - - # Verify the transform values are preserved (approximately) - assert_vec3_close(prim.GetAttribute("xformOp:translate").Get(), (1.0, 2.0, 3.0)) - assert_quat_close(prim.GetAttribute("xformOp:orient").Get(), (0.0, 0.0, 0.0, 1.0)) - assert_vec3_close(prim.GetAttribute("xformOp:scale").Get(), (1.0, 1.0, 1.0)) - - def test_standardize_xform_ops_with_rotation_xyz(): """Test standardize_xform_ops removes deprecated rotateXYZ operations.""" # obtain stage handle @@ -200,40 +164,6 @@ def test_standardize_xform_ops_with_transform_matrix(): assert "xformOp:scale" in prim.GetPropertyNames() -def test_standardize_xform_ops_preserves_world_pose(): - """Test that standardize_xform_ops preserves the world-space pose of the prim.""" - # obtain stage handle - stage = sim_utils.get_current_stage() - - # Create a prim with specific world pose - translation = (10.0, 20.0, 30.0) - # Rotation of 90 degrees around Z axis - orientation = (0.0, 0.0, 0.7071068, 0.7071068) # x, y, z, w - scale = (2.0, 3.0, 4.0) - - prim = sim_utils.create_prim( - "/World/TestPreservePose", - "Xform", - translation=translation, - orientation=orientation, - scale=scale, - stage=stage, - ) - - # Get the world pose before standardization - pos_before, quat_before = sim_utils.resolve_prim_pose(prim) - - # Apply standardize_xform_ops - result = sim_utils.standardize_xform_ops(prim) - assert result is True - - # Get the world pose after standardization - pos_after, quat_after = sim_utils.resolve_prim_pose(prim) - # Verify the world pose is preserved - assert_vec3_close(Gf.Vec3d(*pos_before), pos_after, eps=1e-5) - assert_quat_close(quat_before, quat_after, eps=1e-5) - - def test_standardize_xform_ops_with_units_resolve(): """Test standardize_xform_ops handles scale:unitsResolve attribute.""" # obtain stage handle @@ -428,102 +358,6 @@ def test_standardize_xform_ops_invalid_prim(): sim_utils.standardize_xform_ops(invalid_prim) -def test_standardize_xform_ops_on_geometry_prim(): - """Test standardize_xform_ops on a geometry prim (Cube, Sphere, etc.).""" - # obtain stage handle - stage = sim_utils.get_current_stage() - - # Create a cube with transform - cube_prim = sim_utils.create_prim( - "/World/TestCube", - "Cube", - translation=(1.0, 2.0, 3.0), - orientation=(0.0, 0.0, 0.0, 1.0), - scale=(2.0, 2.0, 2.0), - attributes={"size": 1.0}, - stage=stage, - ) - - # Get world pose before - pos_before, quat_before = sim_utils.resolve_prim_pose(cube_prim) - - # Apply standardize_xform_ops - sim_utils.standardize_xform_ops(cube_prim) - - # Get world pose after - pos_after, quat_after = sim_utils.resolve_prim_pose(cube_prim) - # Verify world pose is preserved - assert_vec3_close(Gf.Vec3d(*pos_before), pos_after, eps=1e-5) - assert_quat_close(quat_before, quat_after, eps=1e-5) - - # Verify standard operations exist - xform_ops = get_xform_ops(cube_prim) - assert xform_ops == ["xformOp:translate", "xformOp:orient", "xformOp:scale"] - - -def test_standardize_xform_ops_with_non_uniform_scale(): - """Test standardize_xform_ops with non-uniform scale.""" - # obtain stage handle - stage = sim_utils.get_current_stage() - - # Create a prim with non-uniform scale - prim = sim_utils.create_prim( - "/World/TestNonUniformScale", - "Xform", - translation=(5.0, 10.0, 15.0), - orientation=(0.0, 0.7071068, 0.0, 0.7071068), # 90 deg around Y - scale=(1.0, 2.0, 3.0), # Non-uniform scale - stage=stage, - ) - - # Get initial scale - initial_scale = prim.GetAttribute("xformOp:scale").Get() - - # Get world pose before standardization - pos_before, quat_before = sim_utils.resolve_prim_pose(prim) - - # Apply standardize_xform_ops - result = sim_utils.standardize_xform_ops(prim) - assert result is True - - # Get world pose after standardization - pos_after, quat_after = sim_utils.resolve_prim_pose(prim) - # Verify world pose is preserved - assert_vec3_close(Gf.Vec3d(*pos_before), pos_after, eps=1e-5) - assert_quat_close(quat_before, quat_after, eps=1e-5) - # Verify scale is preserved - final_scale = prim.GetAttribute("xformOp:scale").Get() - assert_vec3_close(initial_scale, final_scale, eps=1e-5) - - -def test_standardize_xform_ops_identity_transform(): - """Test standardize_xform_ops with identity transform (no translation, rotation, or scale).""" - # obtain stage handle - stage = sim_utils.get_current_stage() - - # Create a prim with identity transform - prim = sim_utils.create_prim( - "/World/TestIdentity", - "Xform", - translation=(0.0, 0.0, 0.0), - orientation=(0.0, 0.0, 0.0, 1.0), # Identity quaternion - scale=(1.0, 1.0, 1.0), - stage=stage, - ) - - # Apply standardize_xform_ops - sim_utils.standardize_xform_ops(prim) - - # Verify standard operations exist - xform_ops = get_xform_ops(prim) - assert xform_ops == ["xformOp:translate", "xformOp:orient", "xformOp:scale"] - - # Verify identity values - assert_vec3_close(prim.GetAttribute("xformOp:translate").Get(), (0.0, 0.0, 0.0)) - assert_quat_close(prim.GetAttribute("xformOp:orient").Get(), (0.0, 0.0, 0.0, 1.0)) - assert_vec3_close(prim.GetAttribute("xformOp:scale").Get(), (1.0, 1.0, 1.0)) - - def test_standardize_xform_ops_with_explicit_values(): """Test standardize_xform_ops with explicit translation, orientation, and scale values.""" # obtain stage handle @@ -653,41 +487,6 @@ def test_standardize_xform_ops_preserves_reset_xform_stack(): assert xformable.GetResetXformStack() is True -def test_standardize_xform_ops_with_complex_hierarchy(): - """Test standardize_xform_ops on deeply nested hierarchy.""" - # obtain stage handle - stage = sim_utils.get_current_stage() - - # Create a complex hierarchy - root = sim_utils.create_prim("/World/Root", "Xform", translation=(1.0, 0.0, 0.0), stage=stage) - child1 = sim_utils.create_prim("/World/Root/Child1", "Xform", translation=(0.0, 1.0, 0.0), stage=stage) - child2 = sim_utils.create_prim("/World/Root/Child1/Child2", "Xform", translation=(0.0, 0.0, 1.0), stage=stage) - child3 = sim_utils.create_prim("/World/Root/Child1/Child2/Child3", "Cube", translation=(1.0, 1.0, 1.0), stage=stage) - - # Get world poses before - poses_before = {} - for name, prim in [("root", root), ("child1", child1), ("child2", child2), ("child3", child3)]: - poses_before[name] = sim_utils.resolve_prim_pose(prim) - - # Apply standardize_xform_ops to all prims - assert sim_utils.standardize_xform_ops(root) is True - assert sim_utils.standardize_xform_ops(child1) is True - assert sim_utils.standardize_xform_ops(child2) is True - assert sim_utils.standardize_xform_ops(child3) is True - - # Get world poses after - poses_after = {} - for name, prim in [("root", root), ("child1", child1), ("child2", child2), ("child3", child3)]: - poses_after[name] = sim_utils.resolve_prim_pose(prim) - - # Verify all world poses are preserved - for name in poses_before: - pos_before, quat_before = poses_before[name] - pos_after, quat_after = poses_after[name] - assert_vec3_close(Gf.Vec3d(*pos_before), pos_after, eps=1e-5) - assert_quat_close(quat_before, quat_after, eps=1e-5) - - def test_standardize_xform_ops_preserves_float_precision(): """Test that standardize_xform_ops preserves float precision when it already exists.""" # obtain stage handle @@ -756,28 +555,6 @@ def test_standardize_xform_ops_preserves_float_precision(): """ -def test_validate_standard_xform_ops_valid(): - """Test validate_standard_xform_ops returns True for standardized prims.""" - # obtain stage handle - stage = sim_utils.get_current_stage() - - # Create a prim with standard operations - prim = sim_utils.create_prim( - "/World/TestValid", - "Xform", - translation=(1.0, 2.0, 3.0), - orientation=(0.0, 0.0, 0.0, 1.0), - scale=(1.0, 1.0, 1.0), - stage=stage, - ) - - # Standardize the prim - sim_utils.standardize_xform_ops(prim) - - # Validate it - assert sim_utils.validate_standard_xform_ops(prim) is True - - def test_validate_standard_xform_ops_invalid_order(): """Test validate_standard_xform_ops returns False for non-standard operation order.""" # obtain stage handle @@ -802,24 +579,6 @@ def test_validate_standard_xform_ops_invalid_order(): assert sim_utils.validate_standard_xform_ops(prim) is False -def test_validate_standard_xform_ops_with_deprecated_ops(): - """Test validate_standard_xform_ops returns False when deprecated operations exist.""" - # obtain stage handle - stage = sim_utils.get_current_stage() - - # Create a prim with deprecated rotateXYZ operation - prim_path = "/World/TestDeprecated" - prim = stage.DefinePrim(prim_path, "Xform") - xformable = UsdGeom.Xformable(prim) - - # Add deprecated rotateXYZ operation - rotate_xyz_op = xformable.AddRotateXYZOp(UsdGeom.XformOp.PrecisionDouble) - rotate_xyz_op.Set(Gf.Vec3d(45.0, 30.0, 60.0)) - - # Validate it - should return False - assert sim_utils.validate_standard_xform_ops(prim) is False - - def test_validate_standard_xform_ops_missing_operations(): """Test validate_standard_xform_ops returns False when standard operations are missing.""" # obtain stage handle @@ -863,25 +622,6 @@ def test_validate_standard_xform_ops_non_xformable(): assert sim_utils.validate_standard_xform_ops(material_prim) is False -def test_validate_standard_xform_ops_with_transform_matrix(): - """Test validate_standard_xform_ops returns False when transform matrix operation exists.""" - # obtain stage handle - stage = sim_utils.get_current_stage() - - # Create a prim with transform matrix - prim_path = "/World/TestTransformMatrix" - prim = stage.DefinePrim(prim_path, "Xform") - xformable = UsdGeom.Xformable(prim) - - # Add transform matrix operation - transform_op = xformable.AddTransformOp(UsdGeom.XformOp.PrecisionDouble) - matrix = Gf.Matrix4d().SetTranslate(Gf.Vec3d(5.0, 10.0, 15.0)) - transform_op.Set(matrix) - - # Validate it - should return False - assert sim_utils.validate_standard_xform_ops(prim) is False - - def test_validate_standard_xform_ops_extra_operations(): """Test validate_standard_xform_ops returns False when extra operations exist.""" # obtain stage handle @@ -935,28 +675,6 @@ def test_validate_standard_xform_ops_after_standardization(): assert sim_utils.validate_standard_xform_ops(prim) is True -def test_validate_standard_xform_ops_on_geometry(): - """Test validate_standard_xform_ops works correctly on geometry prims.""" - # obtain stage handle - stage = sim_utils.get_current_stage() - - # Create a cube with standard operations - cube_prim = sim_utils.create_prim( - "/World/TestCube", - "Cube", - translation=(1.0, 2.0, 3.0), - orientation=(0.0, 0.0, 0.0, 1.0), - scale=(2.0, 2.0, 2.0), - stage=stage, - ) - - # Standardize it - sim_utils.standardize_xform_ops(cube_prim) - - # Validate it - should be True - assert sim_utils.validate_standard_xform_ops(cube_prim) is True - - def test_validate_standard_xform_ops_empty_prim(): """Test validate_standard_xform_ops on prim with no xform operations.""" # obtain stage handle @@ -977,15 +695,16 @@ def test_validate_standard_xform_ops_empty_prim(): def test_resolve_prim_pose(): """Test resolve_prim_pose() function.""" + rng = np.random.default_rng(0) # number of objects num_objects = 20 # sample random scales for x, y, z - rand_scales = np.random.uniform(0.5, 1.5, size=(num_objects, 3, 3)) - rand_widths = np.random.uniform(0.1, 10.0, size=(num_objects,)) + rand_scales = rng.uniform(0.5, 1.5, size=(num_objects, 3, 3)) + rand_widths = rng.uniform(0.1, 10.0, size=(num_objects,)) # sample random positions - rand_positions = np.random.uniform(-100, 100, size=(num_objects, 3, 3)) + rand_positions = rng.uniform(-100, 100, size=(num_objects, 3, 3)) # sample random rotations - rand_quats = np.random.randn(num_objects, 3, 4) + rand_quats = rng.standard_normal((num_objects, 3, 4)) rand_quats /= np.linalg.norm(rand_quats, axis=2, keepdims=True) # create objects @@ -1087,13 +806,14 @@ def test_resolve_prim_scale(): orthogonal and uniformly scaled. Otherwise, scale is not composable like that in local component-wise fashion. """ + rng = np.random.default_rng(0) # number of objects num_objects = 20 # sample random scales for x, y, z - rand_scales = np.random.uniform(0.5, 1.5, size=(num_objects, 3, 3)) - rand_widths = np.random.uniform(0.1, 10.0, size=(num_objects,)) + rand_scales = rng.uniform(0.5, 1.5, size=(num_objects, 3, 3)) + rand_widths = rng.uniform(0.1, 10.0, size=(num_objects,)) # sample random positions - rand_positions = np.random.uniform(-100, 100, size=(num_objects, 3, 3)) + rand_positions = rng.uniform(-100, 100, size=(num_objects, 3, 3)) # create objects for i in range(num_objects): @@ -1178,47 +898,6 @@ def test_convert_world_pose_to_local_basic(): assert_quat_close(local_orientation, (0.0, 0.0, 0.0, 1.0), eps=1e-5) -def test_convert_world_pose_to_local_with_rotation(): - """Test world-to-local conversion with parent rotation.""" - # obtain stage handle - stage = sim_utils.get_current_stage() - - # Create parent with 90-degree rotation around Z axis - parent_prim = sim_utils.create_prim( - "/World/RotatedParent", - "Xform", - translation=(0.0, 0.0, 0.0), - orientation=(0.0, 0.0, 0.7071068, 0.7071068), # 90 deg around Z - scale=(1.0, 1.0, 1.0), - stage=stage, - ) - - # World pose: position at (1, 0, 0) with identity rotation - world_position = (1.0, 0.0, 0.0) - world_orientation = (0.0, 0.0, 0.0, 1.0) - - # Convert to local space - local_translation, local_orientation = sim_utils.convert_world_pose_to_local( - world_position, world_orientation, parent_prim - ) - - # Create a child with the local transform and verify world pose - child_prim = sim_utils.create_prim( - "/World/RotatedParent/Child", - "Xform", - translation=local_translation, - orientation=local_orientation, - stage=stage, - ) - - # Get world pose of child - child_world_pos, child_world_quat = sim_utils.resolve_prim_pose(child_prim) - - # Verify it matches the desired world pose - assert_vec3_close(Gf.Vec3d(*child_world_pos), world_position, eps=1e-5) - assert_quat_close(child_world_quat, world_orientation, eps=1e-5) - - def test_convert_world_pose_to_local_with_scale(): """Test world-to-local conversion with parent scale.""" # obtain stage handle @@ -1321,8 +1000,8 @@ def test_convert_world_pose_to_local_none_orientation(): # Orientation should be None assert local_orientation is None - # Translation should still be converted - assert local_translation is not None + # Translation is converted: (10, 10, 10) - (3, 4, 5) = (7, 6, 5), rotated by -90 deg about Z + assert_vec3_close(Gf.Vec3d(*local_translation), (6.0, -7.0, 5.0), eps=1e-5) def test_convert_world_pose_to_local_complex_hierarchy(): @@ -1408,7 +1087,7 @@ def test_convert_world_pose_to_local_with_mixed_prim_types(): # Convert to local space relative to parent (Scope) local_translation, local_orientation = sim_utils.convert_world_pose_to_local( - world_position, world_orientation, child + world_position, world_orientation, parent ) # Verify orientation is not None diff --git a/source/isaaclab/test/sim/test_views_xform_prim.py b/source/isaaclab/test/sim/test_views_xform_prim.py index 9eb02058038c..e674e1550284 100644 --- a/source/isaaclab/test/sim/test_views_xform_prim.py +++ b/source/isaaclab/test/sim/test_views_xform_prim.py @@ -11,7 +11,7 @@ """ from isaaclab.app import AppLauncher -from isaaclab.test.utils import resolve_test_sim_device, test_devices +from isaaclab.test.utils import resolve_test_sim_device simulation_app = AppLauncher(headless=True, device=resolve_test_sim_device()).app @@ -38,6 +38,9 @@ pytestmark = [pytest.mark.integration, pytest.mark.isaacsim_ci] PARENT_POS = (0.0, 0.0, 1.0) +USD_ONLY_DEVICES = [resolve_test_sim_device()] +"""UsdFrameView computes on the host and only places its outputs on the device, which the shared +contract tests cover per device; the USD-only tests below run on the boot device alone.""" @pytest.fixture(autouse=True) @@ -105,12 +108,9 @@ def factory(num_envs: int, device: str) -> ViewBundle: # ================================================================== -@pytest.mark.parametrize("device", test_devices()) +@pytest.mark.parametrize("device", USD_ONLY_DEVICES) def test_visibility_toggle(device): """Test toggling visibility multiple times.""" - if device == "cuda" and not torch.cuda.is_available(): - pytest.skip("CUDA not available") - stage = sim_utils.get_current_stage() num_prims = 3 for i in range(num_prims): @@ -133,12 +133,9 @@ def test_visibility_toggle(device): assert vis[0] and not vis[1] and vis[2] -@pytest.mark.parametrize("device", test_devices()) +@pytest.mark.parametrize("device", USD_ONLY_DEVICES) def test_visibility_parent_inheritance(device): """Making a parent invisible hides all children.""" - if device == "cuda" and not torch.cuda.is_available(): - pytest.skip("CUDA not available") - stage = sim_utils.get_current_stage() sim_utils.create_prim("/World/Parent", "Xform", stage=stage) for i in range(4): @@ -159,12 +156,9 @@ def test_visibility_parent_inheritance(device): # ================================================================== -@pytest.mark.parametrize("device", test_devices()) +@pytest.mark.parametrize("device", USD_ONLY_DEVICES) def test_prim_ordering_follows_creation_order(device): """Prims are returned in USD creation order (DFS), not alphabetical.""" - if device == "cuda" and not torch.cuda.is_available(): - pytest.skip("CUDA not available") - stage = sim_utils.get_current_stage() num_envs = 3 for i in range(num_envs): @@ -185,7 +179,7 @@ def test_prim_ordering_follows_creation_order(device): # ================================================================== -@pytest.mark.parametrize("device", test_devices()) +@pytest.mark.parametrize("device", USD_ONLY_DEVICES) @pytest.mark.parametrize( ("scale_precision", "scale_value"), [ @@ -215,12 +209,9 @@ def test_local_scales_accept_all_usd_precisions(device, scale_precision, scale_v torch.testing.assert_close(scales.torch, expected, atol=1e-6, rtol=0) -@pytest.mark.parametrize("device", test_devices()) +@pytest.mark.parametrize("device", USD_ONLY_DEVICES) def test_standardize_transform_op(device): """FrameView standardizes a prim with xformOp:transform to translate/orient/scale.""" - if device == "cuda" and not torch.cuda.is_available(): - pytest.skip("CUDA not available") - expected_pos = (3.0, -1.0, 0.5) matrix = Gf.Matrix4d(1.0) matrix.SetTranslateOnly(Gf.Vec3d(*expected_pos)) @@ -238,41 +229,6 @@ def test_standardize_transform_op(device): assert ordered_ops[0].Get() == Gf.Vec3d(*expected_pos) -# ================================================================== -# USD-only: Nested hierarchy (frame + target) -# ================================================================== - - -@pytest.mark.parametrize("device", test_devices()) -def test_nested_hierarchy_world_poses(device): - """World pose of nested child == sum of parent + child translations.""" - if device == "cuda" and not torch.cuda.is_available(): - pytest.skip("CUDA not available") - - stage = sim_utils.get_current_stage() - frame_positions = [(0.0, 0.0, 0.0), (0.0, 10.0, 5.0), (0.0, 3.0, 5.0)] - target_positions = [(0.0, 20.0, 10.0), (0.0, 30.0, 20.0), (0.0, 50.0, 10.0)] - - for i in range(3): - sim_utils.create_prim(f"/World/Frame_{i}", "Xform", translation=frame_positions[i], stage=stage) - sim_utils.create_prim(f"/World/Frame_{i}/Target", "Xform", translation=target_positions[i], stage=stage) - - frames_view = FrameView("/World/Frame_[^/]*", device=device) - targets_view = FrameView("/World/Frame_[^/]*/Target", device=device) - - with frames_view.xform_local_space_writer() as w: - w.set_poses(positions=torch.tensor(frame_positions, device=device)) - with targets_view.xform_local_space_writer() as w: - w.set_poses(positions=torch.tensor(target_positions, device=device)) - - world_pos = targets_view.get_world_poses()[0].torch - expected = torch.tensor( - [[f[j] + t[j] for j in range(3)] for f, t in zip(frame_positions, target_positions)], - device=device, - ) - torch.testing.assert_close(world_pos, expected, atol=1e-5, rtol=0) - - # ================================================================== # USD-only: Cross-space scale conversion under a scaled parent # ================================================================== @@ -293,16 +249,13 @@ def _make_scaled_parent_child_view(device, parent_scale, child_scale=None): return FrameView("/World/Parent_[^/]*/Child", device=device) -@pytest.mark.parametrize("device", ["cpu", "cuda"]) +@pytest.mark.parametrize("device", USD_ONLY_DEVICES) def test_world_scale_composes_with_parent_scale(device): """Under a scaled parent, ``get_world_scales`` returns ``parent_scale * local_scale``. Writes the child's local scale via the local-space writer and verifies that reading the world scale composes with the parent's scale. """ - if device == "cuda" and not torch.cuda.is_available(): - pytest.skip("CUDA not available") - view = _make_scaled_parent_child_view(device, parent_scale=(2.0, 1.0, 1.0)) local_scales = wp.array([wp.vec3f(3.0, 1.0, 1.0)], dtype=wp.vec3f, device=device) with view.xform_local_space_writer() as w: @@ -313,7 +266,7 @@ def test_world_scale_composes_with_parent_scale(device): torch.testing.assert_close(world_scales, expected, atol=1e-5, rtol=0) -@pytest.mark.parametrize("device", ["cpu", "cuda"]) +@pytest.mark.parametrize("device", USD_ONLY_DEVICES) def test_local_scale_inverts_parent_when_writing_world_scale(device): """Writing a world scale derives ``local = world / parent_scale`` under a scaled parent. @@ -321,9 +274,6 @@ def test_local_scale_inverts_parent_when_writing_world_scale(device): that the derived local scale is the world scale divided by the parent's scale. """ - if device == "cuda" and not torch.cuda.is_available(): - pytest.skip("CUDA not available") - view = _make_scaled_parent_child_view(device, parent_scale=(2.0, 1.0, 1.0)) world_scales = wp.array([wp.vec3f(6.0, 1.0, 1.0)], dtype=wp.vec3f, device=device) with view.xform_world_space_writer() as w: @@ -370,12 +320,12 @@ def test_compare_get_world_poses_with_isaacsim(): continue raise - isaaclab_pos = isaaclab_view.get_world_poses()[0].torch - isaacsim_pos, isaacsim_quat = isaacsim_view.get_world_poses() - if not isinstance(isaacsim_pos, torch.Tensor): - isaacsim_pos = torch.tensor(isaacsim_pos, dtype=torch.float32) + isaaclab_pos, isaaclab_quat = (a.torch for a in isaaclab_view.get_world_poses()) + isaacsim_pos, isaacsim_quat = (wp.to_torch(a).cpu() for a in isaacsim_view.get_world_poses()) torch.testing.assert_close(isaaclab_pos, isaacsim_pos, atol=1e-5, rtol=0) + # Isaac Sim returns (w, x, y, z); Isaac Lab returns (x, y, z, w) + torch.testing.assert_close(isaaclab_quat, isaacsim_quat[:, [1, 2, 3, 0]], atol=1e-5, rtol=0) # ================================================================== @@ -383,12 +333,9 @@ def test_compare_get_world_poses_with_isaacsim(): # ================================================================== -@pytest.mark.parametrize("device", test_devices()) +@pytest.mark.parametrize("device", USD_ONLY_DEVICES) def test_with_franka_robots(device): """Verify FrameView works with real Franka robot USD assets.""" - if device == "cuda" and not torch.cuda.is_available(): - pytest.skip("CUDA not available") - stage = sim_utils.get_current_stage() franka_usd_path = f"{ISAAC_NUCLEUS_DIR}/Robots/FrankaRobotics/FrankaPanda/franka.usd" diff --git a/source/isaaclab/test/sim/test_visual_materials_kitless.py b/source/isaaclab/test/sim/test_visual_materials_kitless.py index aedd40e4ff4d..4f21f9a2566b 100644 --- a/source/isaaclab/test/sim/test_visual_materials_kitless.py +++ b/source/isaaclab/test/sim/test_visual_materials_kitless.py @@ -27,7 +27,9 @@ def test_spawn_preview_surface_without_kit(monkeypatch): shader = UsdShade.Shader(prim) material = UsdShade.Material(stage.GetPrimAtPath("/Looks/PreviewSurface")) + assert prim.GetPrimTypeInfo().GetTypeName() == "Shader" assert shader.GetIdAttr().Get() == "UsdPreviewSurface" + assert shader.GetInput("diffuseColor").Get() == cfg.diffuse_color assert shader.GetInput("diffuseColor").GetTypeName() == Sdf.ValueTypeNames.Color3f assert shader.GetInput("emissiveColor").GetTypeName() == Sdf.ValueTypeNames.Color3f assert shader.GetInput("roughness").GetTypeName() == Sdf.ValueTypeNames.Float diff --git a/source/isaaclab/test/terrains/test_terrain_generator.py b/source/isaaclab/test/terrains/test_terrain_generator.py index dac95fb06960..4ac34199cf49 100644 --- a/source/isaaclab/test/terrains/test_terrain_generator.py +++ b/source/isaaclab/test/terrains/test_terrain_generator.py @@ -34,27 +34,6 @@ def output_dir(): shutil.rmtree(output_dir) -def test_generation(output_dir): - """Generates assorted terrains and tests that the resulting mesh has the expected size.""" - # create terrain generator - cfg = ROUGH_TERRAINS_CFG - terrain_generator = TerrainGenerator(cfg=cfg) - - # print terrain generator info - print(terrain_generator) - - # get size from mesh bounds - bounds = terrain_generator.terrain_mesh.bounds - actualSize = abs(bounds[1] - bounds[0]) - # compute the expected size - expectedSizeX = cfg.size[0] * cfg.num_rows + 2 * cfg.border_width - expectedSizeY = cfg.size[1] * cfg.num_cols + 2 * cfg.border_width - - # check if the size is as expected - assert actualSize[0] == pytest.approx(expectedSizeX) - assert actualSize[1] == pytest.approx(expectedSizeY) - - def test_generation_star_terrain(): """Generates a star sub-terrain and tests that the resulting mesh has the expected size. @@ -105,13 +84,13 @@ def test_repeated_objects_default_object_type(): @pytest.mark.parametrize("use_global_seed", [True, False]) -@pytest.mark.parametrize("seed", [20, 40, 80]) -def test_generation_reproducibility(use_global_seed, seed): +def test_generation_reproducibility(use_global_seed): """Generates assorted terrains and tests that the resulting mesh is reproducible. We check both scenarios where the seed is set globally only and when it is set both globally and locally. Setting only locally is not tested as it is not supported. """ + seed = 20 # set initial seed configure_seed(seed) @@ -188,7 +167,7 @@ def test_generation_cache(output_dir, curriculum): def test_terrain_flat_patches(): - """Test the flat patches generation.""" + """Test the terrain size and the flat patches generation.""" # create terrain generator cfg = ROUGH_TERRAINS_CFG # add flat patch configuration @@ -200,6 +179,11 @@ def test_terrain_flat_patches(): # generate terrain terrain_generator = TerrainGenerator(cfg=cfg) + # check the mesh spans every sub-terrain plus the border + actual_size = abs(terrain_generator.terrain_mesh.bounds[1] - terrain_generator.terrain_mesh.bounds[0]) + assert actual_size[0] == pytest.approx(cfg.size[0] * cfg.num_rows + 2 * cfg.border_width) + assert actual_size[1] == pytest.approx(cfg.size[1] * cfg.num_cols + 2 * cfg.border_width) + # check if flat patches are generated assert terrain_generator.flat_patches # check the size of the flat patches diff --git a/source/isaaclab/test/terrains/test_terrain_importer.py b/source/isaaclab/test/terrains/test_terrain_importer.py index e2a664b64ae0..1ef078c5fd21 100644 --- a/source/isaaclab/test/terrains/test_terrain_importer.py +++ b/source/isaaclab/test/terrains/test_terrain_importer.py @@ -18,14 +18,11 @@ import pytest import torch import trimesh -import warp as wp from pxr import UsdGeom -import isaaclab.sim as sim_utils import isaaclab.terrains as terrain_gen -from isaaclab import cloner as lab_cloner -from isaaclab.sim import PreviewSurfaceCfg, SimulationContext, build_simulation_context, get_first_matching_child_prim +from isaaclab.sim import PreviewSurfaceCfg, build_simulation_context, get_first_matching_child_prim from isaaclab.terrains import TerrainImporter, TerrainImporterCfg from isaaclab.terrains.config.rough import ROUGH_TERRAINS_CFG from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR @@ -33,36 +30,9 @@ pytestmark = pytest.mark.integration -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -@pytest.mark.parametrize("env_spacing", [1.0, 4.325, 8.0]) -@pytest.mark.parametrize("num_envs", [1, 4, 125, 379, 1024]) -def test_terrain_importer_env_origins(device, env_spacing, num_envs): - """Tests that env origins are consistent when computed using the TerrainImporter and Lab's grid_transforms.""" - with build_simulation_context(device=device, auto_add_lighting=True) as sim: - sim._app_control_on_stop_handle = None - # create terrain importer - terrain_importer_cfg = TerrainImporterCfg( - num_envs=num_envs, - env_spacing=env_spacing, - prim_path="/World/ground", - terrain_type="plane", # for flat ground, origins are in grid - terrain_generator=None, - ) - terrain_importer = TerrainImporter(terrain_importer_cfg) - # obtain env origins using terrain importer - terrain_importer_origins = terrain_importer.env_origins - - # obtain env origins using Lab's grid_transforms - lab_grid_origins, _ = lab_cloner.grid_transforms(num_envs, spacing=env_spacing) - - # check if the env origins are the same - np.testing.assert_allclose(terrain_importer_origins.cpu().numpy(), lab_grid_origins, rtol=1e-5, atol=1e-5) - - -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -def test_terrain_generation(device): +def test_terrain_generation(): """Generates assorted terrains and tests that the resulting mesh has the correct size.""" - with build_simulation_context(device=device, auto_add_lighting=True) as sim: + with build_simulation_context(device="cuda:0", auto_add_lighting=True) as sim: sim._app_control_on_stop_handle = None # Handler for terrains importing terrain_importer_cfg = terrain_gen.TerrainImporterCfg( @@ -111,25 +81,28 @@ def test_visual_material_defaults(): assert unmaterialized_generator_cfg.visual_material is None -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -@pytest.mark.parametrize("use_custom_material", [True, False]) -def test_plane(device, use_custom_material): +def test_plane(): """Generates a plane and tests that the resulting mesh has the correct size.""" - with build_simulation_context(device=device, auto_add_lighting=True) as sim: + with build_simulation_context(device="cuda:0", auto_add_lighting=True) as sim: sim._app_control_on_stop_handle = None - # create custom material - visual_material = PreviewSurfaceCfg(diffuse_color=(1.0, 0.0, 0.0)) if use_custom_material else None # Handler for terrains importing terrain_importer_cfg = terrain_gen.TerrainImporterCfg( prim_path="/World/ground", terrain_type="plane", num_envs=4096, env_spacing=4.0, - visual_material=visual_material, + visual_material=PreviewSurfaceCfg(diffuse_color=(1.0, 0.0, 0.0)), ) terrain_importer = TerrainImporter(terrain_importer_cfg) + # The importer lays the environments out on a 64 x 64 grid with the configured spacing. + origins = terrain_importer.env_origins + assert origins.shape == (4096, 3) + assert origins.device == torch.device("cuda:0") + torch.testing.assert_close(origins[0].cpu(), torch.tensor([126.0, -126.0, 0.0])) + torch.testing.assert_close(origins[-1].cpu(), torch.tensor([-126.0, 126.0, 0.0])) + # check if mesh prim path exists mesh_prim_path = terrain_importer.cfg.prim_path + "/terrain" assert mesh_prim_path in terrain_importer.terrain_prim_paths @@ -164,10 +137,9 @@ def test_plane(device, use_custom_material): assert mesh is None -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -def test_usd(device): +def test_usd(): """Imports terrain from a usd and tests that the resulting mesh has the correct size.""" - with build_simulation_context(device=device, auto_add_lighting=True) as sim: + with build_simulation_context(device="cuda:0", auto_add_lighting=True) as sim: sim._app_control_on_stop_handle = None # Handler for terrains importing terrain_importer_cfg = terrain_gen.TerrainImporterCfg( @@ -199,71 +171,6 @@ def test_usd(device): assert actualSize[1] == pytest.approx(expectedSizeY) -@pytest.mark.skip(reason="It seems like IsaacSim is not setting the initial positions correctly for the balls.") -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -def test_ball_drop(device): - """Generates assorted terrains and spheres created as meshes. - - Tests that spheres fall onto terrain and do not pass through it. This ensures that the triangle mesh - collision works as expected. - """ - with build_simulation_context(device=device, auto_add_lighting=True) as sim: - sim._app_control_on_stop_handle = None - # Create a scene with rough terrain and balls - _populate_scene(geom_sphere=False, sim=sim) - - # Play simulator - sim.reset() - - # Create a view over all the balls using PhysX view - physics_sim_view = sim.physics_manager.get_physics_sim_view() - ball_view = physics_sim_view.create_rigid_body_view("/World/envs/env_*/ball") - - # Run simulator - for _ in range(500): - sim.step(render=False) - - # Ball may have some small non-zero velocity if the roll on terrain <~.2 - # If balls fall through terrain velocity is much higher ~82.0 - view_velocities = ball_view.get_linear_velocities().contiguous() - max_velocity_z = torch.max(torch.abs(wp.to_torch(view_velocities)[:, 2])) - assert max_velocity_z.item() <= 0.5 - - -@pytest.mark.skip(reason="It seems like IsaacSim is not setting the initial positions correctly for the balls.") -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -def test_ball_drop_geom_sphere(device): - """Generates assorted terrains and geom spheres. - - Tests that spheres fall onto terrain and do not pass through it. This ensures that the sphere collision - works as expected. - """ - with build_simulation_context(device=device, auto_add_lighting=True) as sim: - sim._app_control_on_stop_handle = None - # Create a scene with rough terrain and balls - # TODO: Currently the test fails with geom spheres, need to investigate with the PhysX team. - # Setting the geom_sphere as False to pass the test. This test should be enabled once - # the issue is fixed. - _populate_scene(geom_sphere=False, sim=sim) - - # Play simulator - sim.reset() - - # Create a view over all the balls using PhysX view - physics_sim_view = sim.physics_manager.get_physics_sim_view() - ball_view = physics_sim_view.create_rigid_body_view("/World/envs/env_*/ball") - - # Run simulator - for _ in range(500): - sim.step(render=False) - - # Ball may have some small non-zero velocity if the roll on terrain <~.2 - # If balls fall through terrain velocity is much higher ~82.0 - view_velocities = ball_view.get_linear_velocities().contiguous() - max_velocity_z = torch.max(torch.abs(wp.to_torch(view_velocities)[:, 2])) - assert max_velocity_z.item() <= 0.5 - - def _obtain_collision_mesh(mesh_prim_path: str, mesh_type: Literal["Mesh", "Plane"]) -> trimesh.Trimesh | None: """Get the collision mesh from the terrain.""" # traverse the prim and get the collision mesh @@ -280,83 +187,3 @@ def _obtain_collision_mesh(mesh_prim_path: str, mesh_type: Literal["Mesh", "Plan return trimesh.Trimesh(vertices=vertices, faces=faces) else: return None - - -def _populate_scene(sim: SimulationContext, num_balls: int = 2048, geom_sphere: bool = False): - """Create a scene with terrain and randomly spawned balls. - - The spawned balls are either USD Geom Spheres or are USD Meshes. We check against both these to make sure - both USD-shape and USD-mesh collisions work as expected. - """ - # Handler for terrains importing - terrain_importer_cfg = terrain_gen.TerrainImporterCfg( - prim_path="/World/ground", - max_init_terrain_level=None, - terrain_type="generator", - terrain_generator=ROUGH_TERRAINS_CFG, - num_envs=num_balls, - ) - terrain_importer = TerrainImporter(terrain_importer_cfg) - - # Create environment clones using Lab's cloner utilities - env_fmt = "/World/envs/env_{}" - env_ids = np.arange(num_balls, dtype=np.int64) - env_origins, _ = lab_cloner.grid_transforms(num_balls, spacing=2.0) - # Everything under the namespace "/World/envs/env_0" will be cloned - sim.stage.DefinePrim("/World/envs/env_0", "Xform") - - # Define the scene - # -- Ball with physics properties using Isaac Lab spawners - ball_prim_path = "/World/envs/env_0/ball" - - # Create physics material - physics_material_cfg = sim_utils.RigidBodyMaterialCfg( - static_friction=0.2, - dynamic_friction=1.0, - restitution=0.0, - ) - - # Create visual material - visual_material_cfg = sim_utils.PreviewSurfaceCfg(diffuse_color=(0.0, 0.0, 1.0)) - - if geom_sphere: - # Spawn a geom sphere with rigid body properties - sphere_cfg = sim_utils.SphereCfg( - radius=0.25, - rigid_props=sim_utils.UsdPhysicsRigidBodyCfg(), - mass_props=sim_utils.MassCfg(mass=0.5), - collision_props=sim_utils.UsdPhysicsCollisionCfg(), - visual_material=visual_material_cfg, - physics_material=physics_material_cfg, - ) - sphere_cfg.func(ball_prim_path, sphere_cfg, translation=(0.0, 0.0, 5.0)) - else: - # Spawn a mesh sphere with rigid body properties - mesh_sphere_cfg = sim_utils.MeshSphereCfg( - radius=0.25, - rigid_props=sim_utils.UsdPhysicsRigidBodyCfg(), - mass_props=sim_utils.MassCfg(mass=0.5), - collision_props=sim_utils.UsdPhysicsCollisionCfg(collision_enabled=True), - visual_material=visual_material_cfg, - physics_material=physics_material_cfg, - ) - mesh_sphere_cfg.func(ball_prim_path, mesh_sphere_cfg, translation=(0.0, 0.0, 0.5)) - - # Clone the scene - envs_prim_paths = [f"/World/envs/env_{i}" for i in range(num_balls)] - lab_cloner.usd_replicate(sim.stage, [env_fmt.format(0)], [env_fmt], env_ids, positions=env_origins) - physics_scene_path = sim.cfg.physics_prim_path - lab_cloner.filter_collisions( - sim.stage, physics_scene_path, "/World/collisions", prim_paths=envs_prim_paths, global_paths=["/World/ground"] - ) - - # Set ball positions over terrain origins - # Create a view over all the balls using Isaac Lab's FrameView - ball_view = sim_utils.FrameView("/World/envs/env_[^/]+/ball") - # cache initial state of the balls - ball_initial_positions = terrain_importer.env_origins.clone() - ball_initial_positions[:, 2] += 5.0 - # set initial poses - # note: setting here writes to USD :) - with ball_view.xform_world_space_writer() as w: - w.set_poses(positions=wp.from_torch(ball_initial_positions)) diff --git a/source/isaaclab/test/test_scripts_torcharray_patterns.py b/source/isaaclab/test/test_scripts_torcharray_patterns.py index 6e11c4df7a85..cd1d0ae22ed8 100644 --- a/source/isaaclab/test/test_scripts_torcharray_patterns.py +++ b/source/isaaclab/test/test_scripts_torcharray_patterns.py @@ -18,8 +18,6 @@ import re from pathlib import Path -import pytest - # Matches: # wp.to_torch(.data.) # where can be , [...], ., .[...], etc. @@ -62,24 +60,22 @@ _EXCLUDE_PREFIXES = ("source/isaaclab_contrib/",) -def _repo_root() -> Path: - # this test lives at source/isaaclab/test/test_scripts_torcharray_patterns.py - # parents[0]=test, [1]=isaaclab, [2]=source, [3]=repo root - return Path(__file__).resolve().parents[3] - - -def _scripts_files() -> list[Path]: - scripts = _repo_root() / "scripts" - return sorted(p for p in scripts.rglob("*.py") if "__pycache__" not in p.parts) - - -def _source_and_scripts_files() -> list[Path]: - roots = [_repo_root() / "scripts", _repo_root() / "source"] - return sorted(p for root in roots for p in root.rglob("*.py") if "__pycache__" not in p.parts) +def _find_offenders(root: Path, scan_dirs: tuple[str, ...], patterns: tuple[re.Pattern, ...]) -> list[str]: + """Return ``path:line: text`` for every in-scope line matching any pattern.""" + # A missing directory would make the scan pass vacuously. + assert all((root / d).is_dir() for d in scan_dirs), f"scan directories missing under {root}: {scan_dirs}" + offenders: list[str] = [] + for path in sorted(p for d in scan_dirs for p in (root / d).rglob("*.py") if "__pycache__" not in p.parts): + rel = path.relative_to(root).as_posix() + if rel.startswith(_EXCLUDE_PREFIXES): + continue + for i, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + if any(pattern.search(line) for pattern in patterns): + offenders.append(f"{rel}:{i}: {line.rstrip()}") + return offenders -@pytest.mark.parametrize("path", _scripts_files(), ids=lambda p: str(p.relative_to(_repo_root()))) -def test_no_wp_to_torch_on_torcharray_data(path: Path) -> None: +def test_no_wp_to_torch_on_torcharray_data(source_checkout_root: Path) -> None: """No ``wp.to_torch(.data.)`` / ``wp.to_torch(_data.)`` in scripts/. Post-migration, ``.data.`` returns a ``ProxyArray`` @@ -87,40 +83,19 @@ def test_no_wp_to_torch_on_torcharray_data(path: Path) -> None: shim is deprecated, so use the ``.torch`` accessor instead (or omit the wrap entirely for torch-native fields). """ - rel = path.relative_to(_repo_root()).as_posix() - if rel.startswith(_EXCLUDE_PREFIXES): - pytest.skip(f"{rel} is outside the ProxyArray migration scan scope") + offenders = _find_offenders(source_checkout_root, ("scripts",), (_WP_TO_TORCH_DOT_DATA, _WP_TO_TORCH_NAME_DATA)) - text = path.read_text(encoding="utf-8") - - offenders: list[str] = [] - for i, line in enumerate(text.splitlines(), start=1): - if _WP_TO_TORCH_DOT_DATA.search(line) or _WP_TO_TORCH_NAME_DATA.search(line): - offenders.append(f"{rel}:{i}: {line.rstrip()}") - - if offenders: - pytest.fail( - "Found wp.to_torch(...) calls on a migrated ProxyArray data accessor. " - "Use .torch instead of wp.to_torch(...) (see isaaclab 4.6.15 CHANGELOG).\n" + "\n".join(offenders) - ) + assert not offenders, ( + "Found wp.to_torch(...) calls on a migrated ProxyArray data accessor. " + "Use .torch instead of wp.to_torch(...) (see isaaclab 4.6.15 CHANGELOG).\n" + "\n".join(offenders) + ) -@pytest.mark.parametrize("path", _source_and_scripts_files(), ids=lambda p: str(p.relative_to(_repo_root()))) -def test_no_direct_proxyarray_data_methods(path: Path) -> None: +def test_no_direct_proxyarray_data_methods(source_checkout_root: Path) -> None: """No direct tensor/wp.array methods on migrated ``.data.`` accessors.""" - rel = path.relative_to(_repo_root()).as_posix() - if rel.startswith(_EXCLUDE_PREFIXES): - pytest.skip(f"{rel} is outside the ProxyArray migration scan scope") + offenders = _find_offenders(source_checkout_root, ("scripts", "source"), (_PROXYARRAY_DIRECT_METHOD_DOT_DATA,)) - text = path.read_text(encoding="utf-8") - - offenders: list[str] = [] - for i, line in enumerate(text.splitlines(), start=1): - if _PROXYARRAY_DIRECT_METHOD_DOT_DATA.search(line): - offenders.append(f"{rel}:{i}: {line.rstrip()}") - - if offenders: - pytest.fail( - "Found direct tensor/wp.array methods on migrated ProxyArray data accessors. " - "Use .torch.clone() for tensor copies or .warp.assign(...) for warp writes.\n" + "\n".join(offenders) - ) + assert not offenders, ( + "Found direct tensor/wp.array methods on migrated ProxyArray data accessors. " + "Use .torch.clone() for tensor copies or .warp.assign(...) for warp writes.\n" + "\n".join(offenders) + ) diff --git a/source/isaaclab/test/utils/test_articulation_ordering.py b/source/isaaclab/test/utils/test_articulation_ordering.py index a2afe036f5f5..4b97534272c4 100644 --- a/source/isaaclab/test/utils/test_articulation_ordering.py +++ b/source/isaaclab/test/utils/test_articulation_ordering.py @@ -5,6 +5,7 @@ import importlib +import pytest import torch @@ -56,60 +57,13 @@ def test_assert_articulation_ordering_trace_matches_canonicalizes_public_and_ada helper.assert_articulation_ordering_trace_matches(identity_trace, reordered_trace, ("knee", "hip")) - -def test_shared_articulation_ordering_constants_are_consistent() -> None: - helper = importlib.import_module("isaaclab.test.utils.articulation_ordering") - - assert helper.PANDA_ROOT_PRESERVING_REVERSED_BODY_NAMES == ( - "panda_link0", - "panda_rightfinger", - "panda_leftfinger", - "panda_hand", - "panda_link7", - "panda_link6", - "panda_link5", - "panda_link4", - "panda_link3", - "panda_link2", - "panda_link1", - ) - assert helper.ANYMAL_C_PHYSX_JOINT_NAMES == ( - "LF_HAA", - "LH_HAA", - "RF_HAA", - "RH_HAA", - "LF_HFE", - "LH_HFE", - "RF_HFE", - "RH_HFE", - "LF_KFE", - "LH_KFE", - "RF_KFE", - "RH_KFE", - ) - assert helper.BRANCHING_PHYSX_JOINT_NAMES == ( - "left_shoulder", - "right_shoulder", - "left_elbow", - "right_elbow", - ) - assert helper.BRANCHING_MJWARP_JOINT_NAMES == ( - "left_shoulder", - "left_elbow", - "right_shoulder", - "right_elbow", - ) - assert helper.BRANCHING_PHYSX_BODY_NAMES == ( - "base", - "left_upper", - "right_upper", - "left_tip", - "right_tip", - ) - assert helper.BRANCHING_MJWARP_BODY_NAMES == ( - "base", - "left_upper", - "left_tip", - "right_upper", - "right_tip", + # an adapter axis left in backend order must be rejected + unordered_adapter_trace = _make_trace( + ("knee", "hip"), + ("knee", "hip"), + torch.tensor([[2.0, 1.0]]), + torch.tensor([[3.0, 4.0]]), + torch.tensor([[6.0, 5.0]]), ) + with pytest.raises(AssertionError): + helper.assert_articulation_ordering_trace_matches(identity_trace, unordered_adapter_trace, ("knee", "hip")) diff --git a/source/isaaclab/test/utils/test_assets.py b/source/isaaclab/test/utils/test_assets.py index f1a93aa5a571..cd107cf876d7 100644 --- a/source/isaaclab/test/utils/test_assets.py +++ b/source/isaaclab/test/utils/test_assets.py @@ -42,24 +42,11 @@ def test_asset_root_falls_back_to_kit_file(monkeypatch): assert assets_utils._resolve_asset_root() == "https://example.com/kit-assets" -def test_asset_root_uses_china_storage_profile(monkeypatch): - """Test the China asset region profile uses the same public bucket root as Isaac Sim.""" - monkeypatch.delenv("ISAACSIM_ASSET_ROOT", raising=False) - monkeypatch.setenv("ISAACSIM_ASSET_REGION_PROFILE", "china") - monkeypatch.setattr(assets_utils, "_parse_kit_asset_root", lambda: "https://example.com/kit-assets") - - expected_root = ( - f"https://simready-cn.s3.oss-cn-shanghai.aliyuncs.com/Assets/Isaac/{assets_utils._ISAAC_SIM_ASSET_RELEASE}" - ) - assert assets_utils._resolve_asset_root() == expected_root - - def test_us_asset_root_is_parsed_from_kit_file(monkeypatch): """Test the US asset region profile initializes its root from the shipped experience.""" monkeypatch.delenv("ISAACSIM_ASSET_ROOT", raising=False) monkeypatch.setenv("ISAACSIM_ASSET_REGION_PROFILE", "us") - assert assets_utils._parse_kit_asset_root() == assets_utils._US_ASSET_ROOT assert assets_utils._resolve_asset_root() == assets_utils._US_ASSET_ROOT @@ -104,9 +91,13 @@ def configure(**kwargs): ] -def test_configure_us_storage_profile_is_lazy(monkeypatch): - """Test the primary profile selects its root without importing OmniClient.""" - monkeypatch.setenv("ISAACSIM_ASSET_REGION_PROFILE", "us") +@pytest.mark.parametrize("profile", ["us", None], ids=["us", "unset"]) +def test_configure_storage_profile_is_lazy(monkeypatch, profile): + """Test the primary profile, or no selected profile, does not import OmniClient.""" + if profile is None: + monkeypatch.delenv("ISAACSIM_ASSET_REGION_PROFILE", raising=False) + else: + monkeypatch.setenv("ISAACSIM_ASSET_REGION_PROFILE", profile) original_omni_client = sys.modules.pop("omni.client", None) try: assets_utils.configure_storage_profile() @@ -128,18 +119,6 @@ def test_configure_storage_profile_reports_client_failure(monkeypatch): assets_utils.configure_storage_profile() -def test_configure_storage_profile_is_lazy_without_selection(monkeypatch): - """Test the initializer does not import OmniClient when no profile is selected.""" - monkeypatch.delenv("ISAACSIM_ASSET_REGION_PROFILE", raising=False) - original_omni_client = sys.modules.pop("omni.client", None) - try: - assets_utils.configure_storage_profile() - assert "omni.client" not in sys.modules - finally: - if original_omni_client is not None: - sys.modules["omni.client"] = original_omni_client - - def test_configure_asset_region_profile_alias(monkeypatch): """Test the public Asset Region Profile initializer forwards to the existing initializer.""" calls = [] @@ -178,12 +157,6 @@ def test_asset_root_ignores_empty_environment_override(monkeypatch): assert assets_utils._resolve_asset_root() == "https://example.com/kit-assets" -def test_kit_experience_path_resolves_to_the_shipped_experience(): - """Test the unpatched experience-file path so a broken relative walk fails here.""" - assert Path(assets_utils._KIT_EXPERIENCE_PATH).is_file() - assert assets_utils._parse_kit_asset_root() - - def test_kit_experience_asset_roots_use_production(): """Test every shipped experience uses the canonical production asset root.""" kit_directory = Path(assets_utils._KIT_EXPERIENCE_PATH).parent @@ -256,12 +229,6 @@ def test_exported_asset_root_constants_follow_china_storage_profile(monkeypatch) importlib.reload(assets_utils) -def test_nucleus_connection(): - """Test checking the Nucleus connection.""" - # check nucleus connection - assert assets_utils.NUCLEUS_ASSET_ROOT_DIR is not None - - def test_check_file_path_nucleus(): """Test checking a file path on the Nucleus server.""" # robot file path @@ -727,18 +694,14 @@ def test_unmirror_file_path_recovers_the_url_a_copy_was_cached_from(asset_cache, assert assets_utils.unmirror_file_path(assets_utils._mirror_path(url, str(asset_cache))) == url -@pytest.mark.parametrize( - "path", - ["/home/user/assets/example.usd", "Materials/dex_cube_mod.png", "OmniPBR.mdl", ""], -) -def test_unmirror_file_path_leaves_paths_outside_the_cache_unclaimed(asset_cache, path): - """Test a locally authored asset path is not mistaken for a cached remote copy.""" - assert assets_utils.unmirror_file_path(path) == "" - - @pytest.mark.parametrize( "path", [ + "/home/user/assets/example.usd", + "Materials/dex_cube_mod.png", + "OmniPBR.mdl", + "", + # an ordinary local layout is not read as a cache layout because of a directory name; # ``Omniverse`` is where Omniverse puts user projects by default "C:/Users/user/Omniverse/MyProject/scene.usd", "/data/omniverse/assets/robot.usd", @@ -746,8 +709,8 @@ def test_unmirror_file_path_leaves_paths_outside_the_cache_unclaimed(asset_cache "/home/user/projects/https/site/logo.png", ], ) -def test_unmirror_file_path_leaves_a_directory_named_after_a_url_scheme_unclaimed(asset_cache, path): - """Test an ordinary local layout is not read as a cache layout because of a directory name.""" +def test_unmirror_file_path_leaves_paths_outside_the_cache_unclaimed(asset_cache, path): + """Test a locally authored asset path is not mistaken for a cached remote copy.""" assert assets_utils.unmirror_file_path(path) == "" diff --git a/source/isaaclab/test/utils/test_backend_utils.py b/source/isaaclab/test/utils/test_backend_utils.py index dae867231f8b..3e89a4b93396 100644 --- a/source/isaaclab/test/utils/test_backend_utils.py +++ b/source/isaaclab/test/utils/test_backend_utils.py @@ -5,26 +5,22 @@ """Tests for backend module resolution.""" +import pytest + from isaaclab.sim.simulation_context import SimulationContext from isaaclab.utils.backend_utils import FactoryBase -def test_get_module_name_routes_ovphysx_to_isaaclab_ov(monkeypatch): - """OVPhysX backend modules resolve from the consolidated package.""" - monkeypatch.setattr(FactoryBase, "_module_subpath", "assets.articulation", raising=False) - - assert FactoryBase._get_package_name("ovphysx") == "isaaclab_ov" - assert FactoryBase._get_module_name("ovphysx") == "isaaclab_ov.assets.articulation" - - -def test_get_module_name_preserves_other_backend_conventions(monkeypatch): - """Other backend modules continue to use their backend-named packages.""" +@pytest.mark.parametrize( + "backend, package", + [("ovphysx", "isaaclab_ov"), ("physx", "isaaclab_physx"), ("newton", "isaaclab_newton")], +) +def test_get_module_name_routes_backend_to_package(monkeypatch, backend, package): + """OVPhysX backend modules resolve from the consolidated package; others use their backend-named packages.""" monkeypatch.setattr(FactoryBase, "_module_subpath", "assets.articulation", raising=False) - assert FactoryBase._get_package_name("physx") == "isaaclab_physx" - assert FactoryBase._get_package_name("newton") == "isaaclab_newton" - assert FactoryBase._get_module_name("physx") == "isaaclab_physx.assets.articulation" - assert FactoryBase._get_module_name("newton") == "isaaclab_newton.assets.articulation" + assert FactoryBase._get_package_name(backend) == package + assert FactoryBase._get_module_name(backend) == f"{package}.assets.articulation" def test_factory_backend_falls_back_to_newton_without_simulation_context(monkeypatch): diff --git a/source/isaaclab/test/utils/test_circular_buffer.py b/source/isaaclab/test/utils/test_circular_buffer.py index 805acc2c7a57..8b72c5e23562 100644 --- a/source/isaaclab/test/utils/test_circular_buffer.py +++ b/source/isaaclab/test/utils/test_circular_buffer.py @@ -63,24 +63,6 @@ def test_reset_subset(circular_buffer): torch.testing.assert_close(circular_buffer.buffer[reset_batch_id, 0], circular_buffer.buffer[reset_batch_id, i]) -def test_append_and_retrieve(circular_buffer): - """Test appending and retrieving data from the circular buffer.""" - # append some data - data1 = torch.tensor([[1, 1], [1, 1], [1, 1]], device=circular_buffer.device) - data2 = torch.tensor([[2, 2], [2, 2], [2, 2]], device=circular_buffer.device) - - circular_buffer.append(data1) - circular_buffer.append(data2) - - assert circular_buffer.current_length.tolist() == [2, 2, 2] - - retrieved_data = circular_buffer[torch.tensor([0, 0, 0], device=circular_buffer.device)] - assert torch.equal(retrieved_data, data2) - - retrieved_data = circular_buffer[torch.tensor([1, 1, 1], device=circular_buffer.device)] - assert torch.equal(retrieved_data, data1) - - def test_buffer_overflow(circular_buffer): """Test buffer overflow. @@ -143,6 +125,11 @@ def test_key_greater_than_pushes(circular_buffer): circular_buffer.append(data1) circular_buffer.append(data2) + assert circular_buffer.current_length.tolist() == [2, 2, 2] + # before wrap-around, the key counts back from the most recent entry + assert torch.equal(circular_buffer[torch.tensor([0, 0, 0], device=circular_buffer.device)], data2) + assert torch.equal(circular_buffer[torch.tensor([1, 1, 1], device=circular_buffer.device)], data1) + retrieved_data = circular_buffer[torch.tensor([5, 5, 5], device=circular_buffer.device)] assert torch.equal(retrieved_data, data1) @@ -191,9 +178,9 @@ def test_reset_subset_zeroes_buffer_storage_default_mode(): buf.append(torch.full((4, 2), 5.0)) buf.append(torch.full((4, 2), 5.0)) buf.reset(batch_ids=[1, 3]) - # Reset rows must read as zero in the raw buffer; non-reset rows must still hold 5.0. - torch.testing.assert_close(buf._buffer[:, [1, 3]], torch.zeros((3, 2, 2))) - torch.testing.assert_close(buf._buffer[:, [0, 2]], torch.full((3, 2, 2), 5.0)) + # Reset rows must read as zero in the buffer; non-reset rows must still hold 5.0. + torch.testing.assert_close(buf.buffer[[1, 3]], torch.zeros((2, 3, 2))) + torch.testing.assert_close(buf.buffer[[0, 2]], torch.full((2, 3, 2), 5.0)) def test_reset_subset_zeroes_buffer_storage_stack_dim_mode(): @@ -206,8 +193,8 @@ def test_reset_subset_zeroes_buffer_storage_stack_dim_mode(): buf.append(torch.full((4, 8, 8, 3), 5.0)) buf.append(torch.full((4, 8, 8, 3), 5.0)) buf.reset(batch_ids=[1, 3]) - torch.testing.assert_close(buf._buffer[[1, 3]], torch.zeros((2, 8, 8, 2, 3))) - torch.testing.assert_close(buf._buffer[[0, 2]], torch.full((2, 8, 8, 2, 3), 5.0)) + torch.testing.assert_close(buf.buffer[[1, 3]], torch.zeros((2, 2, 8, 8, 3))) + torch.testing.assert_close(buf.buffer[[0, 2]], torch.full((2, 2, 8, 8, 3), 5.0)) def test_stack_dim_zero_rejected(): @@ -224,33 +211,6 @@ def test_stack_dim_out_of_range_rejected_on_first_append(): buf.append(data) -def test_stack_dim_minus_one_output_shape(): - """stack_dim=-1 on (B,H,W,C) data yields .stacked shape (B,H,W,K*C).""" - B, H, W, C, K = 4, 8, 8, 3, 2 - buf = CircularBuffer(max_len=K, batch_size=B, device="cpu", stack_dim=-1) - data = torch.zeros(B, H, W, C) - buf.append(data) - assert buf.stacked.shape == (B, H, W, K * C) - # And .buffer still honors the legacy (B, K, *frame_shape) contract. - assert buf.buffer.shape == (B, K, H, W, C) - - -def test_stack_dim_oldest_to_newest_channel_order(): - """Channels must appear in oldest-to-newest order along the stacked dim.""" - B, H, W, C, K = 2, 4, 4, 3, 2 - buf = CircularBuffer(max_len=K, batch_size=B, device="cpu", stack_dim=-1) - # First frame: all 1s - f1 = torch.ones(B, H, W, C) - buf.append(f1) - # Second frame: all 2s - f2 = torch.full((B, H, W, C), 2.0) - buf.append(f2) - stacked = buf.stacked # (B, H, W, 2*C) - # Oldest C channels should equal f1 (i.e., 1.0); newest C channels should equal f2 (i.e., 2.0). - torch.testing.assert_close(stacked[..., :C], torch.ones(B, H, W, C)) - torch.testing.assert_close(stacked[..., C:], torch.full((B, H, W, C), 2.0)) - - def test_stack_dim_warmup_fills_all_slots_with_first_frame(): """The first append must fill all K slots with the first frame (warmup contract).""" B, H, W, C, K = 2, 4, 4, 3, 2 @@ -260,6 +220,8 @@ def test_stack_dim_warmup_fills_all_slots_with_first_frame(): stacked = buf.stacked # Both K slots should be 7.0 after the warmup. torch.testing.assert_close(stacked, torch.full((B, H, W, K * C), 7.0)) + # .buffer still honors the legacy (B, K, *frame_shape) contract. + assert buf.buffer.shape == (B, K, H, W, C) def test_stack_dim_minus_three_output_shape(): diff --git a/source/isaaclab/test/utils/test_configclass.py b/source/isaaclab/test/utils/test_configclass.py index f04549452d32..04ad5c8c1652 100644 --- a/source/isaaclab/test/utils/test_configclass.py +++ b/source/isaaclab/test/utils/test_configclass.py @@ -5,7 +5,6 @@ from __future__ import annotations -import copy import os import subprocess import sys @@ -17,8 +16,8 @@ import pytest import torch -from isaaclab.utils.configclass import _field_module_dir, configclass -from isaaclab.utils.dict import class_to_dict, dict_to_md5_hash, update_class_from_dict +from isaaclab.utils.configclass import configclass +from isaaclab.utils.dict import class_to_dict, update_class_from_dict from isaaclab.utils.io import dump_yaml, load_yaml from isaaclab.utils.string import ResolvableString @@ -137,16 +136,6 @@ class BasicDemoTorchCfg: some_tensor: torch.Tensor = torch.Tensor([1, 2, 3]) -@configclass -class BasicActuatorCfg: - """Dummy configuration class for ActuatorBase config.""" - - joint_names_expr: list[str] = ["some_string"] - joint_parameter_lookup: list[list[float]] = [[1, 2, 3], [4, 5, 6]] - stiffness: float = 1.0 - damping: float = 2.0 - - """ Dummy configuration to check type annotations ordering. """ @@ -519,22 +508,6 @@ class MissingChildDemoCfg(MissingParentDemoCfg): """ -def test_str(): - """Test printing the configuration.""" - cfg = BasicDemoCfg() - print() - print(cfg) - - -def test_str_dict(): - """Test printing the configuration using dataclass utility.""" - cfg = BasicDemoCfg() - print() - print("Using dataclass function: ", asdict(cfg)) - print("Using internal function: ", cfg.to_dict()) - assert asdict(cfg) == cfg.to_dict() - - def test_dict_conversion(): """Test dictionary conversion of configclass instance.""" cfg = BasicDemoCfg() @@ -555,19 +528,6 @@ def test_dict_conversion(): assert torch.all(torch_cfg_dict["some_tensor"] == torch.tensor([1, 2, 3])) -def test_actuator_cfg_dict_conversion(): - """Test dict conversion of ActuatorConfig.""" - # create a basic RemotizedPDActuator config - actuator_cfg = BasicActuatorCfg() - # return writable attributes of config object - actuator_cfg_dict_attr = actuator_cfg.__dict__ - # check if __dict__ attribute of config is not empty - assert len(actuator_cfg_dict_attr) > 0 - # class_to_dict utility function should return a primitive dictionary - actuator_cfg_dict = class_to_dict(actuator_cfg) - assert isinstance(actuator_cfg_dict, dict) - - def test_dict_conversion_order(): """Tests that order is conserved when converting to dictionary.""" true_outer_order = ["device_id", "env", "robot_default_state", "list_config"] @@ -575,25 +535,13 @@ def test_dict_conversion_order(): # create config cfg = BasicDemoCfg() # check ordering - for label, parsed_value in zip(true_outer_order, cfg.__dict__.keys()): - assert label == parsed_value - for label, parsed_value in zip(true_env_order, cfg.env.__dict__.keys()): - assert label == parsed_value + assert list(cfg.__dict__.keys()) == true_outer_order + assert list(cfg.env.__dict__.keys()) == true_env_order # convert config to dictionary cfg_dict = class_to_dict(cfg) # check ordering - for label, parsed_value in zip(true_outer_order, cfg_dict.keys()): - assert label == parsed_value - for label, parsed_value in zip(true_env_order, cfg_dict["env"].keys()): - assert label == parsed_value - # check ordering when copied - cfg_dict_copied = copy.deepcopy(cfg_dict) - cfg_dict_copied.pop("list_config") - # check ordering - for label, parsed_value in zip(true_outer_order, cfg_dict_copied.keys()): - assert label == parsed_value - for label, parsed_value in zip(true_env_order, cfg_dict_copied["env"].keys()): - assert label == parsed_value + assert list(cfg_dict.keys()) == true_outer_order + assert list(cfg_dict["env"].keys()) == true_env_order def test_config_update_via_constructor(): @@ -602,14 +550,6 @@ def test_config_update_via_constructor(): assert asdict(cfg) == basic_demo_cfg_change_correct -def test_config_update_after_init(): - """Test updating configclass using instance members.""" - cfg = BasicDemoCfg() - cfg.env.num_envs = 22 - cfg.env.viewer.eye = (2.0, 2.0, 2.0) # note: changes from list to tuple - assert asdict(cfg) == basic_demo_cfg_change_correct - - def test_config_update_dict(): """Test updating configclass using dictionary.""" cfg = BasicDemoCfg() @@ -621,6 +561,11 @@ def test_config_update_dict(): assert isinstance(cfg.env.viewer, ViewerCfg) assert isinstance(cfg.env.viewer.eye, tuple) + # the configclass method applies the same update + cfg2 = BasicDemoCfg() + cfg2.from_dict(cfg_dict) + assert cfg2.to_dict() == basic_demo_cfg_change_correct + def test_config_update_dict_with_none(): """Test updating configclass using a dictionary that contains None.""" @@ -687,7 +632,7 @@ class _BaseCfg: @configclass class _ChildCfg(_BaseCfg): - pass + extra: type | str = "{DIR}.child_mod:ChildSymbol" # Simulate subclass declared in a different package than the parent config. _BaseCfg.__module__ = "test_pkg.parent.base_cfg" @@ -697,6 +642,8 @@ class _ChildCfg(_BaseCfg): assert isinstance(cfg.class_type, ResolvableString) assert str(cfg.class_type) == "test_pkg.parent.base_mod:BaseSymbol" + # a field declared on the subclass resolves against the subclass module + assert str(cfg.extra) == "other_pkg.child.child_mod:ChildSymbol" def test_config_update_different_iterable_lengths(): @@ -721,14 +668,6 @@ def test_config_update_different_iterable_lengths(): assert cfg.dof_vel == [9.0, 8.0, 7.0] -def test_config_update_dict_using_internal(): - """Test updating configclass from a dictionary using configclass method.""" - cfg = BasicDemoCfg() - cfg_dict = {"env": {"num_envs": 22, "viewer": {"eye": (2.0, 2.0, 2.0)}}} - cfg.from_dict(cfg_dict) - assert cfg.to_dict() == basic_demo_cfg_change_correct - - def test_config_update_dict_using_post_init(): cfg = BasicDemoPostInitCfg() assert cfg.to_dict() == basic_demo_post_init_cfg_correct @@ -742,27 +681,6 @@ def test_invalid_update_key(): update_class_from_dict(cfg, cfg_dict) -def test_multiple_instances(): - """Test multiple instances with twice instantiation.""" - # create two config instances - cfg1 = BasicDemoCfg() - cfg2 = BasicDemoCfg() - - # check variables - # mutable -- variables should be different - assert id(cfg1.env.viewer.eye) != id(cfg2.env.viewer.eye) - assert id(cfg1.env.viewer.lookat) != id(cfg2.env.viewer.lookat) - assert id(cfg1.robot_default_state) != id(cfg2.robot_default_state) - # immutable -- variables are the same - assert id(cfg1.robot_default_state.dof_pos) == id(cfg2.robot_default_state.dof_pos) - assert id(cfg1.env.num_envs) == id(cfg2.env.num_envs) - assert id(cfg1.device_id) == id(cfg2.device_id) - - # check values - assert cfg1.env.to_dict() == cfg2.env.to_dict() - assert cfg1.robot_default_state.to_dict() == cfg2.robot_default_state.to_dict() - - def test_alter_values_multiple_instances(): """Test alterations in multiple instances of the same configclass.""" # create two config instances @@ -773,12 +691,14 @@ def test_alter_values_multiple_instances(): cfg1.env.num_envs = 22 # immutable data: int cfg1.env.viewer.eye[0] = 1.0 # mutable data: list cfg1.env.viewer.lookat[2] = 12.0 # mutable data: list + cfg1.robot_default_state.dof_vel[0] = 3.0 # mutable data: list in a nested config # check variables # values should be different assert cfg1.env.num_envs != cfg2.env.num_envs assert cfg1.env.viewer.eye != cfg2.env.viewer.eye assert cfg1.env.viewer.lookat != cfg2.env.viewer.lookat + assert cfg2.robot_default_state.dof_vel == [0.0, 0.0, 0.0, 0.0, 0.0, 1.0] # mutable -- variables are different ids assert id(cfg1.env.viewer.eye) != id(cfg2.env.viewer.eye) assert id(cfg1.env.viewer.lookat) != id(cfg2.env.viewer.lookat) @@ -786,26 +706,6 @@ def test_alter_values_multiple_instances(): assert id(cfg1.env.num_envs) != id(cfg2.env.num_envs) -def test_multiple_instances_with_replace(): - """Test multiple instances with creation through replace function.""" - # create two config instances - cfg1 = BasicDemoCfg() - cfg2 = cfg1.replace() - - # check variable IDs - # mutable -- variables should be different - assert id(cfg1.env.viewer.eye) != id(cfg2.env.viewer.eye) - assert id(cfg1.env.viewer.lookat) != id(cfg2.env.viewer.lookat) - assert id(cfg1.robot_default_state) != id(cfg2.robot_default_state) - # immutable -- variables are the same - assert id(cfg1.robot_default_state.dof_pos) == id(cfg2.robot_default_state.dof_pos) - assert id(cfg1.env.num_envs) == id(cfg2.env.num_envs) - assert id(cfg1.device_id) == id(cfg2.device_id) - - # check values - assert cfg1.to_dict() == cfg2.to_dict() - - def test_borrowed_field_preserves_identity_without_changing_ordinary_copying(): @configclass class NativeCfg(ViewerCfg): @@ -825,6 +725,7 @@ def test_alter_values_multiple_instances_wth_replace(): # create two config instances cfg1 = BasicDemoCfg() cfg2 = cfg1.replace(device_id=1) + assert cfg2.to_dict() == {**cfg1.to_dict(), "device_id": 1} # alter configurations cfg1.env.num_envs = 22 # immutable data: int @@ -844,19 +745,6 @@ def test_alter_values_multiple_instances_wth_replace(): assert id(cfg1.device_id) != id(cfg2.device_id) -def test_configclass_type_ordering(): - """Checks ordering of config objects when no type annotation is provided.""" - - cfg_1 = TypeAnnotationOrderingDemoCfg() - cfg_2 = NonTypeAnnotationOrderingDemoCfg() - cfg_3 = InheritedNonTypeAnnotationOrderingDemoCfg() - - # check ordering - assert list(cfg_1.__dict__.keys()) == list(cfg_2.__dict__.keys()) - assert list(cfg_3.__dict__.keys()) == list(cfg_2.__dict__.keys()) - assert list(cfg_1.__dict__.keys()) == list(cfg_3.__dict__.keys()) - - def test_configclass_mixed_type_annotations_ordering(): """Checks that declaration order is preserved when only some attributes have type annotations. @@ -876,6 +764,14 @@ def test_configclass_mixed_type_annotations_ordering(): assert list(cfg_inherited.__dict__.keys()) == expected_inherited_order assert list(cfg_inherited.to_dict().keys()) == expected_inherited_order + # all-annotated, unannotated, and inherited unannotated declarations keep the same order + for cfg_cls in ( + TypeAnnotationOrderingDemoCfg, + NonTypeAnnotationOrderingDemoCfg, + InheritedNonTypeAnnotationOrderingDemoCfg, + ): + assert list(cfg_cls().__dict__.keys()) == ["anymal", "unitree", "franka"] + def test_functions_config(): """Tests having functions as values in the configuration instance.""" @@ -890,15 +786,6 @@ def test_functions_config(): assert cfg.func_in_dict["func"]() == 1 -def test_function_impl_config(): - """Tests having function defined in the class instance.""" - cfg = FunctionImplementedDemoCfg() - # change value - assert cfg.a == 5 - cfg.set_a(10) - assert cfg.a == 10 - - def test_class_function_impl_config(): """Tests having class function defined in the class instance.""" cfg = ClassFunctionImplementedDemoCfg() @@ -906,8 +793,19 @@ def test_class_function_impl_config(): # check that the annotations are correct assert cfg.__annotations__ == {"a": "int"} - # check all methods are callable - cfg.instance_method() + # check value is correct and settable through the property + assert cfg.a == 5 + assert cfg.a_proxy == 5 + cfg.a_proxy = 10 + assert cfg.a == 10 + assert cfg.a_proxy == 10 + + # an instance method can mutate a field + function_cfg = FunctionImplementedDemoCfg() + assert function_cfg.a == 5 + function_cfg.set_a(10) + assert function_cfg.a == 10 + new_cfg1 = cfg.class_method(20) # check value is correct assert new_cfg1.a == 20 @@ -918,26 +816,6 @@ def test_class_function_impl_config(): assert new_cfg2.a == 20 -def test_class_property_impl_config(): - """Tests having class property defined in the class instance.""" - cfg = ClassFunctionImplementedDemoCfg() - - # check that the annotations are correct - assert cfg.__annotations__ == {"a": "int"} - - # check all methods are callable - cfg.instance_method() - - # check value is correct - assert cfg.a == 5 - assert cfg.a_proxy == 5 - - # set through property - cfg.a_proxy = 10 - assert cfg.a == 10 - assert cfg.a_proxy == 10 - - def test_dict_conversion_functions_config(): """Tests conversion of config with functions into dictionary.""" cfg = FunctionsDemoCfg() @@ -987,42 +865,6 @@ class MissingTypeDemoCfg: b = 2 -def test_required_argument_for_missing_type_in_config(): - """Tests required positional argument for missing type annotation in config creation.""" - - @configclass - class MissingTypeDemoCfg: - a: int = 1 - b = 2 - c: int = MISSING - - # should complain that 'c' is missed in positional arguments - # TODO: Uncomment this when we move to 3.10. - # with self.assertRaises(TypeError): - # cfg = MissingTypeDemoCfg(a=1) - # should not complain - cfg = MissingTypeDemoCfg(a=1, c=3) - - assert cfg.a == 1 - assert cfg.b == 2 - - -def test_config_inheritance(): - """Tests that inheritance works properly.""" - # check variables - cfg_a = ChildADemoCfg(a=20, d=3, e=ViewerCfg(), j=["c", "d"]) - - assert cfg_a.func == dummy_function1 - assert cfg_a.a == 20 - assert cfg_a.d == 3 - assert cfg_a.j == ["c", "d"] - - # check post init - assert cfg_a.b == 3 - assert cfg_a.i == ["a", "b"] - assert cfg_a.m.rot == (0.0, 0.0, 0.0, 2.0) - - def test_config_inheritance_independence(): """Tests that subclass instantions have fully unique members, rather than references to members of the parent class""" @@ -1111,12 +953,11 @@ def test_nested_config_class_declarations(): assert cfg.x == 20 -def test_config_dumping(): +def test_config_dumping(tmp_path): """Check that config dumping works properly.""" # file for dumping - dirname = os.path.dirname(os.path.abspath(__file__)) - filename = os.path.join(dirname, "output", "configclass", "test_config.yaml") + filename = os.path.join(tmp_path, "configclass", "test_config.yaml") # create config cfg = ChildADemoCfg(a=20, d=3, e=ViewerCfg(), j=["c", "d"]) @@ -1139,19 +980,6 @@ def test_config_dumping(): assert cfg.to_dict() == cfg_loaded -def test_config_md5_hash(): - """Check that config md5 hash generation works properly.""" - - # create config - cfg = ChildADemoCfg(a=20, d=3, e=ViewerCfg(), j=["c", "d"]) - - # generate md5 hash - md5_hash_1 = dict_to_md5_hash(cfg.to_dict()) - md5_hash_2 = dict_to_md5_hash(cfg.to_dict()) - - assert md5_hash_1 == md5_hash_2 - - def test_validity(): """Check that invalid configurations raise errors.""" @@ -1219,32 +1047,6 @@ class ParentCfg: ParentCfg().validate() -def test_dir_resolution_in_subclass(): - """Test that {DIR} in inherited fields resolves relative to the declaring class's module.""" - - @configclass - class ParentCfg: - class_type: str = "{DIR}.my_module:MyClass" - name: str = "default" - - @configclass - class ChildCfg(ParentCfg): - extra: int = 42 - - # Pretend the parent lives in a real package and the child lives in a test file - ParentCfg.__module__ = "some_package.sub_package.parent_cfg" - ChildCfg.__module__ = "test_some_feature" - - parent = ParentCfg.__new__(ParentCfg) - child = ChildCfg.__new__(ChildCfg) - - # class_type should resolve to the parent's module dir in both cases - assert _field_module_dir(parent, "class_type") == "some_package.sub_package" - assert _field_module_dir(child, "class_type") == "some_package.sub_package" - # extra should resolve to the child's module dir - assert _field_module_dir(child, "extra") == "test_some_feature" - - # ============================================================================= # Tests: checked_apply # ============================================================================= diff --git a/source/isaaclab/test/utils/test_delay_buffer.py b/source/isaaclab/test/utils/test_delay_buffer.py index a2f2c8b31254..d2e46d19d13d 100644 --- a/source/isaaclab/test/utils/test_delay_buffer.py +++ b/source/isaaclab/test/utils/test_delay_buffer.py @@ -8,16 +8,16 @@ import pytest import torch -from isaaclab.test.utils import test_devices +from isaaclab.test.utils import DeviceScope, test_devices from isaaclab.utils import DelayBuffer pytestmark = pytest.mark.unit -@pytest.fixture(params=test_devices()) -def delay_buffer(request): +@pytest.fixture +def delay_buffer(): """Create a delay buffer for testing.""" - device: str = request.param + device: str = "cpu" batch_size: int = 10 history_length: int = 4 return DelayBuffer(history_length, batch_size=batch_size, device=device) @@ -135,12 +135,11 @@ def test_compute_result_independent_of_internal_buffer(delay_buffer): assert torch.all(second == 1) -def test_delay_buffer_cuda_graph(delay_buffer): +@pytest.mark.parametrize("device", test_devices(DeviceScope.CUDA)) +def test_delay_buffer_cuda_graph(delay_buffer, device): """Sampling and ring writes work during graph replay, including across partial resets.""" - if not delay_buffer.device.startswith("cuda"): - pytest.skip("CUDA graph replay requires CUDA.") - with torch.cuda.device(delay_buffer.device): - delay_buffer = DelayBuffer(4, delay_buffer.batch_size, delay_buffer.device, min_lag=1, hold_prob=0.5) + with torch.cuda.device(device): + delay_buffer = DelayBuffer(4, delay_buffer.batch_size, device, min_lag=1, hold_prob=0.5) data = torch.zeros(delay_buffer.batch_size, 1, device=delay_buffer.device) delay_buffer.compute(data) graph = torch.cuda.CUDAGraph() diff --git a/source/isaaclab/test/utils/test_device_selection.py b/source/isaaclab/test/utils/test_device_selection.py index 46d592c7f33b..8d4ddacd2f8d 100644 --- a/source/isaaclab/test/utils/test_device_selection.py +++ b/source/isaaclab/test/utils/test_device_selection.py @@ -58,6 +58,9 @@ def _set(available: list[str], runtime: str | None = None) -> None: (MULTI_GPU, "0001", "100", []), # cpu test skips the shard # a runtime that lists several GPUs hands back all in-scope ones. (MULTI_GPU, "111", "11X", ["cpu", "cuda:0", "cuda:1"]), + # mask grammar: a trailing X spans every non-default GPU; a short mask is padded with False. + (MULTI_GPU, "1111", "00X", ["cuda:1", "cuda:2"]), + (MULTI_GPU, "1111", "100", ["cpu"]), ], ) def test_resolves_scope_intersect_runtime(host, available, runtime, scope, expected): @@ -72,15 +75,6 @@ def test_argless_equals_default_scope(host): assert resolve_devices() == resolve_devices("11X") -def test_argless_runs_once_on_one_non_default_gpu(host): - # Run-once property: with a one-device-per-shard runtime, an argless test - # resolves to exactly one non-default GPU. - host(MULTI_GPU, "0001") - result = resolve_devices() - assert result == ["cuda:2"] - assert all(d not in ("cpu", "cuda:0") for d in result) - - @pytest.mark.parametrize( "scope, mask", [ @@ -99,12 +93,6 @@ def test_named_scope_matches_mask(host, scope, mask): assert resolve_devices(scope) == resolve_devices(mask) -def test_named_combination_matches_runtime_composition(): - assert DeviceScope.CPU_AND_DEFAULT_CUDA == DeviceScope.CPU | DeviceScope.DEFAULT_CUDA - assert DeviceScope.CUDA == DeviceScope.DEFAULT_CUDA | DeviceScope.NON_DEFAULT_CUDA - assert DeviceScope.ALL == DeviceScope.CPU | DeviceScope.DEFAULT_CUDA | DeviceScope.NON_DEFAULT_CUDA - - def test_helper_is_not_collected_by_pytest(): assert resolve_devices.__test__ is False @@ -142,13 +130,6 @@ def test_resolve_test_sim_device_rejects_invalid_or_ambiguous_runtime(host, runt # --------------------------------------------------------------------------- -def test_unset_runtime_never_raises(host): - # On a dev box / single-GPU lane (runtime unset), an out-of-scope result is a - # silent skip, not an error. - host(SINGLE_GPU, None) - assert resolve_devices("00X") == [] - - def test_runtime_naming_absent_device_raises(host): # A run that explicitly asked for cuda:2 on a host that only has cuda:0/cuda:1 # is misconfigured -> fail loudly instead of a vacuous green. @@ -157,13 +138,6 @@ def test_runtime_naming_absent_device_raises(host): resolve_devices("11X") -def test_in_range_runtime_with_empty_scope_does_not_raise(host): - # The runtime device exists (cuda:2 is present), the scope just doesn't include - # it -> skip, not raise. This is what keeps "110"/"100" tests green on a shard. - host(MULTI_GPU, "0001") - assert resolve_devices("110") == [] # would raise if the guard keyed off scope - - # --------------------------------------------------------------------------- # skip= : gate a specific device visibly # --------------------------------------------------------------------------- @@ -183,23 +157,3 @@ def test_skip_ignores_out_of_result_devices(host): # Skipping a device that isn't in the resolved set is a no-op (no stray params). host(SINGLE_GPU, None) assert resolve_devices("11X", skip={"cuda:3": "n/a"}) == ["cpu", "cuda:0"] - - -# --------------------------------------------------------------------------- -# _expand mask grammar -# --------------------------------------------------------------------------- - - -@pytest.mark.parametrize( - "mask, count, expected", - [ - ("110", 2, [True, True]), # exact length, no wildcard - ("100", 3, [True, False, False]), # short mask padded with False - ("11X", 4, [True, True, True, True]), # trailing X fills the rest True - ("00X", 4, [False, False, True, True]), # X spans the non-default GPUs - ("11X", 2, [True, True]), # X with nothing left to fill - ("0001", 3, [False, False, False]), # mask longer than host -> truncated - ], -) -def test_expand(mask, count, expected): - assert devices_mod._expand(mask, count) == expected diff --git a/source/isaaclab/test/utils/test_dict.py b/source/isaaclab/test/utils/test_dict.py index 074841e0efa4..b91720ab1a79 100644 --- a/source/isaaclab/test/utils/test_dict.py +++ b/source/isaaclab/test/utils/test_dict.py @@ -4,7 +4,6 @@ # SPDX-License-Identifier: BSD-3-Clause import enum -import random import numpy as np import pytest @@ -26,30 +25,20 @@ def _test_lambda_function(x): return x**2 -def test_print_dict(): +def test_print_dict(capsys): """Test printing of dictionary.""" # create a complex nested dictionary test_dict = { "a": 1, - "b": 2, - "c": {"d": 3, "e": 4, "f": {"g": 5, "h": 6}}, - "i": 7, + "c": {"d": 3, "f": {"g": 5}}, "j": lambda x: x**2, # noqa: E731 "k": dict_utils.class_to_dict, } # print the dictionary dict_utils.print_dict(test_dict) - - -def test_string_callable_function_conversion(): - """Test string <-> callable conversion for function.""" - - # convert function to string - test_string = dict_utils.callable_to_string(_test_function) - # convert string to function - test_function_2 = string_utils.string_to_callable(test_string) - # check that functions are the same - assert _test_function(2) == test_function_2(2) + # nested keys are indented by four spaces and callables are printed as strings + expected = "a: 1\nc: \n d: 3\n f: \n g: 5\nj: lambda x: x**2\nk: isaaclab.utils.dict:class_to_dict\n" + assert capsys.readouterr().out == expected def test_string_callable_function_with_lambda_in_name_conversion(): @@ -59,8 +48,8 @@ def test_string_callable_function_with_lambda_in_name_conversion(): test_string = dict_utils.callable_to_string(_test_lambda_function) # convert string to function test_function_2 = string_utils.string_to_callable(test_string) - # check that functions are the same - assert _test_function(2) == test_function_2(2) + # check that the string resolves to the same function + assert test_function_2 is _test_lambda_function def test_string_callable_lambda_conversion(): @@ -84,16 +73,21 @@ def test_dict_to_md5(): "a": 1, "b": 2, "c": {"d": 3, "e": 4, "f": {"g": 5, "h": 6}}, - "i": random.random(), + "i": 0.5, "k": dict_utils.callable_to_string(dict_utils.class_to_dict), } - # generate the MD5 hash - md5_hash_1 = dict_utils.dict_to_md5_hash(test_dict) - - # check that the hash is correct even after multiple calls - for _ in range(200): - md5_hash_2 = dict_utils.dict_to_md5_hash(test_dict) - assert md5_hash_1 == md5_hash_2 + # an independently built dictionary with a different key order has the same hash + reordered_dict = { + "k": "isaaclab.utils.dict:class_to_dict", + "i": 0.5, + "c": {"f": {"h": 6, "g": 5}, "e": 4, "d": 3}, + "b": 2, + "a": 1, + } + assert dict_utils.dict_to_md5_hash(test_dict) == dict_utils.dict_to_md5_hash(reordered_dict) + # changing a nested value changes the hash + reordered_dict["c"]["f"]["g"] = 7 + assert dict_utils.dict_to_md5_hash(test_dict) != dict_utils.dict_to_md5_hash(reordered_dict) class _CallableCfg: diff --git a/source/isaaclab/test/utils/test_episode_data.py b/source/isaaclab/test/utils/test_episode_data.py index 0a6359a0f2c2..1d9536dc05ac 100644 --- a/source/isaaclab/test/utils/test_episode_data.py +++ b/source/isaaclab/test/utils/test_episode_data.py @@ -11,7 +11,7 @@ pytestmark = pytest.mark.unit -@pytest.mark.parametrize("device", test_devices(DeviceScope.CPU_AND_DEFAULT_CUDA)) +@pytest.mark.parametrize("device", test_devices(DeviceScope.CPU)) def test_is_empty(device): """Test checking whether the episode is empty.""" episode = EpisodeData() @@ -21,7 +21,7 @@ def test_is_empty(device): assert not episode.is_empty() -@pytest.mark.parametrize("device", test_devices(DeviceScope.CPU_AND_DEFAULT_CUDA)) +@pytest.mark.parametrize("device", test_devices(DeviceScope.CPU)) def test_add_tensors(device): """Test appending tensor data to the episode.""" dummy_data_0 = torch.tensor([0], device=device) @@ -58,7 +58,7 @@ def test_add_tensors(device): assert torch.equal(second_data, expected_added_data) -@pytest.mark.parametrize("device", test_devices(DeviceScope.CPU_AND_DEFAULT_CUDA)) +@pytest.mark.parametrize("device", test_devices(DeviceScope.CPU)) def test_add_dict_tensors(device): """Test appending dict data to the episode.""" dummy_dict_data_0 = { @@ -105,7 +105,7 @@ def test_add_dict_tensors(device): assert torch.equal(key_1_1_data, torch.tensor([[2], [5]], device=device)) -@pytest.mark.parametrize("device", test_devices(DeviceScope.CPU_AND_DEFAULT_CUDA)) +@pytest.mark.parametrize("device", test_devices(DeviceScope.CPU)) def test_get_initial_state(device): """Test getting the initial state of the episode.""" dummy_initial_state = torch.tensor([1, 2, 3], device=device) @@ -117,7 +117,7 @@ def test_get_initial_state(device): assert torch.equal(initial_state, dummy_initial_state.unsqueeze(0)) -@pytest.mark.parametrize("device", test_devices(DeviceScope.CPU_AND_DEFAULT_CUDA)) +@pytest.mark.parametrize("device", test_devices(DeviceScope.CPU)) def test_get_next_action(device): """Test getting next actions.""" # dummy actions diff --git a/source/isaaclab/test/utils/test_hdf5_dataset_file_handler.py b/source/isaaclab/test/utils/test_hdf5_dataset_file_handler.py index 3cfa7a016d41..1fb78180ac5f 100644 --- a/source/isaaclab/test/utils/test_hdf5_dataset_file_handler.py +++ b/source/isaaclab/test/utils/test_hdf5_dataset_file_handler.py @@ -12,6 +12,7 @@ import pytest import torch +from isaaclab.test.utils import test_devices from isaaclab.utils.datasets import EpisodeData, HDF5DatasetFileHandler pytestmark = pytest.mark.unit @@ -113,7 +114,7 @@ def test_create_resets_env_args_when_reusing_handler(temp_dir): assert env_args == {"env_name": "second_env", "type": 2} -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("device", test_devices()) def test_write_and_load_episode(temp_dir, device): """Test writing and loading an episode to and from the dataset file.""" dataset_file_path = os.path.join(temp_dir, f"{uuid.uuid4()}.hdf5") diff --git a/source/isaaclab/test/utils/test_images.py b/source/isaaclab/test/utils/test_images.py index 639fbc9ccc76..697cc766bb56 100644 --- a/source/isaaclab/test/utils/test_images.py +++ b/source/isaaclab/test/utils/test_images.py @@ -7,21 +7,31 @@ from __future__ import annotations +from unittest.mock import patch + import pytest import torch import warp as wp +import isaaclab.utils.images as images_utils +from isaaclab.test.utils import test_devices + pytestmark = pytest.mark.unit wp.config.quiet = True wp.init() -@pytest.fixture(params=["cpu", "cuda:0"] if torch.cuda.is_available() else ["cpu"]) +@pytest.fixture(params=test_devices()) def device(request): + """Device for tests that reach the Warp kernel; PyTorch-only paths override it with CPU.""" return request.param +cpu_only = pytest.mark.parametrize("device", ["cpu"]) +"""Run a PyTorch-only path once on CPU instead of on every device.""" + + class TestPredicates: """The is_* dispatch predicates.""" @@ -68,14 +78,17 @@ def test_is_normals_like_rejects(self, data_type): class TestNormalizeCameraImageRGBLike: """RGB-like dispatch: rgb, albedo, simple_shading_*.""" - @pytest.mark.parametrize("data_type", ["rgb", "albedo", "simple_shading_diffuse_mdl"]) - def test_uint8_routes_to_warp_fast_path(self, device, data_type): - """uint8 contiguous 4D input produces float32 ``(x/255 - per-image mean)`` output.""" + def test_uint8_routes_to_warp_fast_path(self, device): + """uint8 contiguous 4D input goes through the Warp kernel and produces ``(x/255 - per-image mean)``.""" from isaaclab.utils.images import normalize_camera_image torch.manual_seed(0) src = torch.randint(0, 255, (2, 8, 8, 3), dtype=torch.uint8, device=device) - out = normalize_camera_image(src, data_type) + with patch.object( + images_utils, "normalize_image_uint8", wraps=images_utils.normalize_image_uint8 + ) as warp_normalize: + out = normalize_camera_image(src, "rgb") + warp_normalize.assert_called_once() expected = src.float() / 255.0 expected = expected - torch.mean(expected, dim=(1, 2), keepdim=True) @@ -93,6 +106,7 @@ def test_uint8_preallocated_output_reused(self, device): assert result is out assert result.data_ptr() == ptr + @cpu_only def test_float_input_takes_pytorch_fallback(self, device): """Non-uint8 input routes through the PyTorch fallback with equivalent math.""" from isaaclab.utils.images import normalize_camera_image @@ -106,6 +120,7 @@ def test_float_input_takes_pytorch_fallback(self, device): torch.testing.assert_close(out, expected) assert out.dtype == torch.float32 + @cpu_only def test_non_contiguous_uint8_takes_pytorch_fallback(self, device): """Strided uint8 input falls back instead of raising in the Warp wrapper.""" from isaaclab.utils.images import normalize_camera_image @@ -133,6 +148,7 @@ def test_bchw_uint8_routes_to_warp_fast_path(self, device): torch.testing.assert_close(out, expected, atol=1e-5, rtol=1e-5) assert out.dtype == torch.float32 + @cpu_only def test_bchw_float_input_takes_pytorch_fallback(self, device): """Non-uint8 BCHW input routes through the PyTorch fallback with BCHW reduction axes.""" from isaaclab.utils.images import normalize_camera_image @@ -162,6 +178,7 @@ def test_colorized_semantic_segmentation_is_normalized(self, device): torch.testing.assert_close(out, expected, atol=1e-5, rtol=1e-5) assert out.dtype == torch.float32 + @cpu_only def test_non_colorized_semantic_segmentation_is_cast_to_float(self, device): """int32 label-id segmentation is cast to float32 with the ids left untouched. @@ -181,6 +198,7 @@ def test_non_colorized_semantic_segmentation_is_cast_to_float(self, device): class TestNormalizeCameraImageDepth: """Depth-like dispatch: in-place ``inf -> 0``.""" + @cpu_only @pytest.mark.parametrize("data_type", ["depth", "distance_to_camera", "distance_to_plane"]) def test_inf_replaced_with_zero_in_place(self, device, data_type): from isaaclab.utils.images import normalize_camera_image @@ -195,6 +213,7 @@ def test_inf_replaced_with_zero_in_place(self, device, data_type): class TestNormalizeCameraImageNormals: """Normals dispatch: ``[-1, 1] -> [0, 1]``.""" + @cpu_only def test_range_remap(self, device): from isaaclab.utils.images import normalize_camera_image @@ -207,6 +226,7 @@ def test_range_remap(self, device): class TestNormalizeCameraImagePassthrough: """Unknown data_types return the input unchanged.""" + @cpu_only @pytest.mark.parametrize("data_type", ["instance_segmentation", "motion_vectors"]) def test_unknown_type_passthrough(self, device, data_type): from isaaclab.utils.images import normalize_camera_image @@ -216,6 +236,7 @@ def test_unknown_type_passthrough(self, device, data_type): assert out is src +@cpu_only class TestNormalizeCameraOutputForDisplay: """Display normalization for capture and golden-image workflows.""" @@ -227,13 +248,22 @@ def test_rgb_scales_to_unit_range(self, device): expected = torch.tensor([[[[0.0, 127.0 / 255.0, 1.0]]]], device=device) torch.testing.assert_close(out, expected) - def test_depth_scales_by_max(self, device): + @pytest.mark.parametrize("data_type", ["depth", "distance_to_camera", "distance_to_image_plane"]) + def test_depth_scales_by_max_and_ignores_nonfinite_values(self, device, data_type): from isaaclab.utils.images import normalize_camera_output_for_display - src = torch.tensor([[[[0.0], [2.0], [4.0]]]], device=device) - out = normalize_camera_output_for_display(src, "distance_to_camera") - expected = torch.tensor([[[[0.0], [0.5], [1.0]]]], device=device) + src = torch.tensor([0.0, 2.0, float("inf"), 4.0, float("nan")], device=device) + out = normalize_camera_output_for_display(src, data_type) + expected = torch.tensor([0.0, 0.5, 0.0, 1.0, 0.0], device=device) torch.testing.assert_close(out, expected) + assert torch.isfinite(out).all() + + def test_depth_handles_all_nonfinite_values(self, device): + from isaaclab.utils.images import normalize_camera_output_for_display + + src = torch.tensor([float("inf"), float("nan")], device=device) + out = normalize_camera_output_for_display(src, "depth") + torch.testing.assert_close(out, torch.zeros_like(src)) def test_albedo_keeps_rgb_channels(self, device): from isaaclab.utils.images import normalize_camera_output_for_display @@ -255,6 +285,7 @@ def test_motion_vectors_map_uv_to_rgb(self, device): torch.testing.assert_close(out, expected) +@cpu_only class TestMakeCameraOutputGrid: """Grid composition for multi-env camera capture.""" diff --git a/source/isaaclab/test/utils/test_images_display_nonfinite.py b/source/isaaclab/test/utils/test_images_display_nonfinite.py deleted file mode 100644 index 1a196aa423bc..000000000000 --- a/source/isaaclab/test/utils/test_images_display_nonfinite.py +++ /dev/null @@ -1,30 +0,0 @@ -# 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 - -import pytest -import torch - -from isaaclab.utils.images import normalize_camera_output_for_display - -pytestmark = pytest.mark.unit - - -@pytest.mark.parametrize("data_type", ["depth", "distance_to_camera", "distance_to_image_plane"]) -def test_depth_display_normalization_ignores_nonfinite_values(data_type): - src = torch.tensor([0.0, 2.0, float("inf"), 4.0, float("nan")]) - - out = normalize_camera_output_for_display(src, data_type) - - expected = torch.tensor([0.0, 0.5, 0.0, 1.0, 0.0]) - torch.testing.assert_close(out, expected) - assert torch.isfinite(out).all() - - -def test_depth_display_normalization_handles_all_nonfinite_values(): - src = torch.tensor([float("inf"), float("nan")]) - - out = normalize_camera_output_for_display(src, "depth") - - torch.testing.assert_close(out, torch.zeros_like(src)) diff --git a/source/isaaclab/test/utils/test_logger.py b/source/isaaclab/test/utils/test_logger.py index 8bfad15df0a1..31a0f0c517b5 100644 --- a/source/isaaclab/test/utils/test_logger.py +++ b/source/isaaclab/test/utils/test_logger.py @@ -9,22 +9,17 @@ import os import re import tempfile -import time +from types import SimpleNamespace import pytest +import isaaclab.utils.logger as logger_module from isaaclab.utils.logger import ColoredFormatter, RateLimitFilter, configure_logging pytestmark = pytest.mark.unit # Fixtures -@pytest.fixture -def formatter(): - """Fixture providing a ColoredFormatter instance.""" - return ColoredFormatter("%(levelname)s: %(message)s") - - @pytest.fixture def test_message(): """Fixture providing a test message string.""" @@ -42,124 +37,22 @@ def rate_limit_filter(): """ -def test_info_formatting(formatter, test_message): - """Test INFO level message formatting.""" - record = logging.LogRecord( - name="test", - level=logging.INFO, - pathname="test.py", - lineno=1, - msg=test_message, - args=(), - exc_info=None, - ) - formatted = formatter.format(record) - - # INFO should use reset color (no color) - assert "\033[0m" in formatted - assert test_message in formatted - assert "INFO" in formatted - - -def test_debug_formatting(formatter, test_message): - """Test DEBUG level message formatting.""" - record = logging.LogRecord( - name="test", - level=logging.DEBUG, - pathname="test.py", - lineno=1, - msg=test_message, - args=(), - exc_info=None, - ) - formatted = formatter.format(record) - - # DEBUG should use reset color (no color) - assert "\033[0m" in formatted - assert test_message in formatted - assert "DEBUG" in formatted - - -def test_warning_formatting(formatter, test_message): - """Test WARNING level message formatting.""" - record = logging.LogRecord( - name="test", - level=logging.WARNING, - pathname="test.py", - lineno=1, - msg=test_message, - args=(), - exc_info=None, - ) - formatted = formatter.format(record) - - # WARNING should use yellow/orange color - assert "\033[33m" in formatted - assert test_message in formatted - assert "WARNING" in formatted - # Should end with reset - assert formatted.endswith("\033[0m") - - -def test_error_formatting(formatter, test_message): - """Test ERROR level message formatting.""" - record = logging.LogRecord( - name="test", - level=logging.ERROR, - pathname="test.py", - lineno=1, - msg=test_message, - args=(), - exc_info=None, - ) - formatted = formatter.format(record) - - # ERROR should use red color - assert "\033[31m" in formatted - assert test_message in formatted - assert "ERROR" in formatted - # Should end with reset - assert formatted.endswith("\033[0m") - - -def test_critical_formatting(formatter, test_message): - """Test CRITICAL level message formatting.""" - record = logging.LogRecord( - name="test", - level=logging.CRITICAL, - pathname="test.py", - lineno=1, - msg=test_message, - args=(), - exc_info=None, - ) - formatted = formatter.format(record) - - # CRITICAL should use bold red color - assert "\033[1;31m" in formatted - assert test_message in formatted - assert "CRITICAL" in formatted - # Should end with reset - assert formatted.endswith("\033[0m") - - -def test_color_codes_are_ansi(): - """Test that color codes are valid ANSI escape sequences.""" - # Test all defined colors - for level_name, color_code in ColoredFormatter.COLORS.items(): - # ANSI color codes should match pattern \033[m or \033[;m (for bold, etc.) - assert re.match(r"\033\[[\d;]+m", color_code), f"Invalid ANSI color code for {level_name}" - - # Test reset code - assert re.match(r"\033\[[\d;]+m", ColoredFormatter.RESET), "Invalid ANSI reset code" - - -def test_custom_format_string(test_message): - """Test that custom format strings work correctly.""" +@pytest.mark.parametrize( + "level, color", + [ + (logging.DEBUG, "\033[0m"), + (logging.INFO, "\033[0m"), + (logging.WARNING, "\033[33m"), + (logging.ERROR, "\033[31m"), + (logging.CRITICAL, "\033[1;31m"), + ], +) +def test_level_formatting(test_message, level, color): + """Test that each level is wrapped in its ANSI color and the reset code, using a custom format string.""" custom_formatter = ColoredFormatter("%(name)s - %(levelname)s - %(message)s") record = logging.LogRecord( name="custom.logger", - level=logging.WARNING, + level=level, pathname="test.py", lineno=1, msg=test_message, @@ -168,10 +61,8 @@ def test_custom_format_string(test_message): ) formatted = custom_formatter.format(record) - assert "custom.logger" in formatted - assert "WARNING" in formatted - assert test_message in formatted - assert "\033[33m" in formatted # Warning color + # DEBUG and INFO use the reset code (no color); every message ends with the reset code + assert formatted == f"{color}custom.logger - {logging.getLevelName(level)} - {test_message}\033[0m" """ @@ -217,52 +108,14 @@ def test_non_warning_messages_pass_through(rate_limit_filter): ) assert rate_limit_filter.filter(debug_record) is True + # the default interval is 5 seconds + assert RateLimitFilter().interval == 5 -def test_first_warning_passes(rate_limit_filter): - """Test that the first WARNING message passes through.""" - record = logging.LogRecord( - name="test", - level=logging.WARNING, - pathname="test.py", - lineno=1, - msg="First warning", - args=(), - exc_info=None, - ) - assert rate_limit_filter.filter(record) is True - - -def test_duplicate_warning_within_interval_blocked(rate_limit_filter): - """Test that duplicate WARNING messages within interval are blocked.""" - message = "Duplicate warning" - # First warning should pass - record1 = logging.LogRecord( - name="test", - level=logging.WARNING, - pathname="test.py", - lineno=1, - msg=message, - args=(), - exc_info=None, - ) - assert rate_limit_filter.filter(record1) is True - - # Immediate duplicate should be blocked - record2 = logging.LogRecord( - name="test", - level=logging.WARNING, - pathname="test.py", - lineno=2, - msg=message, - args=(), - exc_info=None, - ) - assert rate_limit_filter.filter(record2) is False - - -def test_warning_after_interval_passes(): - """Test that WARNING messages pass after the rate limit interval.""" +def test_warning_after_interval_passes(monkeypatch): + """Test that duplicate WARNING messages are blocked within the interval and pass after it.""" + now = [100.0] + monkeypatch.setattr(logger_module, "time", SimpleNamespace(time=lambda: now[0])) message = "Rate limited warning" filter_short = RateLimitFilter(interval_seconds=1) @@ -290,8 +143,8 @@ def test_warning_after_interval_passes(): ) assert filter_short.filter(record2) is False - # Wait for interval to pass - time.sleep(1.1) + # Advance the clock past the interval + now[0] += 1.1 # After interval, same message should pass again record3 = logging.LogRecord( @@ -306,82 +159,6 @@ def test_warning_after_interval_passes(): assert filter_short.filter(record3) is True -def test_different_warnings_not_rate_limited(rate_limit_filter): - """Test that different WARNING messages are not rate limited together.""" - # First warning - record1 = logging.LogRecord( - name="test", - level=logging.WARNING, - pathname="test.py", - lineno=1, - msg="Warning A", - args=(), - exc_info=None, - ) - assert rate_limit_filter.filter(record1) is True - - # Different warning should also pass - record2 = logging.LogRecord( - name="test", - level=logging.WARNING, - pathname="test.py", - lineno=2, - msg="Warning B", - args=(), - exc_info=None, - ) - assert rate_limit_filter.filter(record2) is True - - -def test_custom_interval(): - """Test that custom interval seconds work correctly.""" - custom_filter = RateLimitFilter(interval_seconds=1) - assert custom_filter.interval == 1 - - long_filter = RateLimitFilter(interval_seconds=10) - assert long_filter.interval == 10 - - -def test_last_emitted_tracking(rate_limit_filter): - """Test that the filter correctly tracks last emission times.""" - message1 = "Message 1" - message2 = "Message 2" - - # Emit first message - record1 = logging.LogRecord( - name="test", - level=logging.WARNING, - pathname="test.py", - lineno=1, - msg=message1, - args=(), - exc_info=None, - ) - rate_limit_filter.filter(record1) - - # Check that message1 is tracked - assert message1 in rate_limit_filter.last_emitted - - # Emit second message - record2 = logging.LogRecord( - name="test", - level=logging.WARNING, - pathname="test.py", - lineno=2, - msg=message2, - args=(), - exc_info=None, - ) - rate_limit_filter.filter(record2) - - # Check that both messages are tracked - assert message1 in rate_limit_filter.last_emitted - assert message2 in rate_limit_filter.last_emitted - - # Timestamps should be different (though very close) - assert rate_limit_filter.last_emitted[message1] <= rate_limit_filter.last_emitted[message2] - - def test_formatted_message_warnings(rate_limit_filter): """Test rate limiting with formatted WARNING messages.""" # Test with string formatting @@ -421,51 +198,6 @@ def test_formatted_message_warnings(rate_limit_filter): assert rate_limit_filter.filter(record3) is True -""" -Integration Tests. - -Tests that the filter and formatter work together in a logger. -""" - - -def test_filter_and_formatter_together(): - """Test that filter and formatter work together in a logger.""" - # Create a logger with both filter and formatter - test_logger = logging.getLogger("test_integration") - test_logger.setLevel(logging.DEBUG) - - # Remove any existing handlers - test_logger.handlers.clear() - - # Create handler with colored formatter - handler = logging.StreamHandler() - handler.setFormatter(ColoredFormatter("%(levelname)s: %(message)s")) - - # Add rate limit filter - rate_filter = RateLimitFilter(interval_seconds=1) - handler.addFilter(rate_filter) - - test_logger.addHandler(handler) - - # Test that logger is set up correctly - assert len(test_logger.handlers) == 1 - assert isinstance(test_logger.handlers[0].formatter, ColoredFormatter) - - # Clean up - test_logger.handlers.clear() - - -def test_default_initialization(): - """Test that classes can be initialized with default parameters.""" - # ColoredFormatter with default format - formatter = ColoredFormatter() - assert formatter is not None - - # RateLimitFilter with default interval - filter_obj = RateLimitFilter() - assert filter_obj.interval == 5 # default is 5 seconds - - """ Tests for the configure_logging function. """ @@ -499,14 +231,14 @@ def test_configure_logging_basic(): def test_configure_logging_with_file(): - """Test configure_logging with file logging enabled.""" - # Setup logger with file logging + """Test configure_logging with file logging enabled in a custom (nested) log directory.""" with tempfile.TemporaryDirectory() as temp_dir: - logger = configure_logging(logging_level="DEBUG", save_logs_to_file=True, log_dir=temp_dir) + custom_log_dir = os.path.join(temp_dir, "custom_logs") + logger = configure_logging(logging_level="DEBUG", save_logs_to_file=True, log_dir=custom_log_dir) # Should return root logger assert logger is not None - # Root logger is always set to DEBUG + # Root logger level matches the requested level assert logger.level == logging.DEBUG # Should have two handlers (stream + file) @@ -517,16 +249,28 @@ def test_configure_logging_with_file(): assert isinstance(stream_handler, logging.StreamHandler) assert isinstance(stream_handler.formatter, ColoredFormatter) assert stream_handler.level == logging.DEBUG + stream_format = stream_handler.formatter._fmt # type: ignore + assert "%(asctime)s" in stream_format + assert "%(filename)s" in stream_format - # Check file handler + # Check file handler: always DEBUG, with a more detailed format including line numbers file_handler = logger.handlers[1] assert isinstance(file_handler, logging.FileHandler) assert file_handler.level == logging.DEBUG + file_format = file_handler.formatter._fmt # type: ignore + assert "%(asctime)s" in file_format + assert "%(lineno)d" in file_format - # Verify log file was created - log_files = [f for f in os.listdir(temp_dir) if f.startswith("isaaclab_")] + # Custom directory should be created and hold the log file + assert os.path.isdir(custom_log_dir) + assert os.path.dirname(file_handler.baseFilename) == custom_log_dir + log_files = [f for f in os.listdir(custom_log_dir) if f.startswith("isaaclab_")] assert len(log_files) == 1 + # Check filename format: isaaclab_YYYY-MM-DD_HH-MM-SS.log + pattern = r"isaaclab_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\.log" + assert re.match(pattern, log_files[0]), f"Log filename {log_files[0]} doesn't match expected pattern" + def test_configure_logging_levels(): """Test configure_logging with different logging levels.""" @@ -585,6 +329,8 @@ def test_configure_logging_default_log_dir(): assert len(logger.handlers) == 2 file_handler = logger.handlers[1] assert isinstance(file_handler, logging.FileHandler) + # The file handler always records DEBUG, regardless of the requested console level. + assert file_handler.level == logging.DEBUG # File should be in temp directory log_file_path = file_handler.baseFilename @@ -596,94 +342,6 @@ def test_configure_logging_default_log_dir(): os.remove(log_file_path) -def test_configure_logging_custom_log_dir(): - """Test configure_logging with custom log directory.""" - with tempfile.TemporaryDirectory() as temp_dir: - custom_log_dir = os.path.join(temp_dir, "custom_logs") - - logger = configure_logging(logging_level="INFO", save_logs_to_file=True, log_dir=custom_log_dir) - - # Custom directory should be created - assert os.path.exists(custom_log_dir) - assert os.path.isdir(custom_log_dir) - - # Root logger level matches the requested level - assert logger.level == logging.INFO - - # Log file should be in custom directory - file_handler = logger.handlers[1] - assert isinstance(file_handler, logging.FileHandler) - log_file_path = file_handler.baseFilename - assert os.path.dirname(log_file_path) == custom_log_dir - - -def test_configure_logging_log_file_format(): - """Test that log file has correct timestamp format.""" - with tempfile.TemporaryDirectory() as temp_dir: - logger = configure_logging(logging_level="INFO", save_logs_to_file=True, log_dir=temp_dir) - - # Root logger level matches the requested level - assert logger.level == logging.INFO - - # Get log file name - file_handler = logger.handlers[1] - assert isinstance(file_handler, logging.FileHandler) - log_file_path = file_handler.baseFilename - log_filename = os.path.basename(log_file_path) - - # Check filename format: isaaclab_YYYY-MM-DD_HH-MM-SS.log - pattern = r"isaaclab_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\.log" - assert re.match(pattern, log_filename), f"Log filename {log_filename} doesn't match expected pattern" - - -def test_configure_logging_file_formatter(): - """Test that file handler has more detailed formatter than stream handler.""" - with tempfile.TemporaryDirectory() as temp_dir: - logger = configure_logging(logging_level="INFO", save_logs_to_file=True, log_dir=temp_dir) - - # Root logger level matches the requested level - assert logger.level == logging.INFO - - stream_handler = logger.handlers[0] - file_handler = logger.handlers[1] - - # Stream formatter should exist and be ColoredFormatter - assert stream_handler.formatter is not None - assert isinstance(stream_handler.formatter, ColoredFormatter) - stream_format = stream_handler.formatter._fmt # type: ignore - assert stream_format is not None - assert "%(asctime)s" in stream_format - assert "%(filename)s" in stream_format - - # File formatter should exist and include line numbers - assert file_handler.formatter is not None - assert isinstance(file_handler.formatter, logging.Formatter) - file_format = file_handler.formatter._fmt # type: ignore - assert file_format is not None - assert "%(asctime)s" in file_format - assert "%(lineno)d" in file_format - - # File handler should always use DEBUG level - assert file_handler.level == logging.DEBUG - - -def test_configure_logging_multiple_calls(): - """Test that multiple configure_logging calls properly cleanup.""" - # First setup - logger1 = configure_logging(logging_level="INFO", save_logs_to_file=False) - handler_count_1 = len(logger1.handlers) - - # Second setup should remove previous handlers - logger2 = configure_logging(logging_level="DEBUG", save_logs_to_file=False) - handler_count_2 = len(logger2.handlers) - - # Should be same logger (root logger) - assert logger1 is logger2 - - # Should have same number of handlers (old ones removed) - assert handler_count_1 == handler_count_2 == 1 - - def test_configure_logging_actual_logging(): """Test that logger actually logs messages correctly.""" import io diff --git a/source/isaaclab/test/utils/test_math.py b/source/isaaclab/test/utils/test_math.py index 7aaefb69b627..41e411793f92 100644 --- a/source/isaaclab/test/utils/test_math.py +++ b/source/isaaclab/test/utils/test_math.py @@ -10,10 +10,9 @@ import pytest import scipy.spatial.transform as scipy_tf import torch -import torch.utils.benchmark as benchmark import isaaclab.utils.math as math_utils -from isaaclab.test.utils import DeviceScope, test_devices +from isaaclab.test.utils import test_devices pytestmark = pytest.mark.unit @@ -26,9 +25,9 @@ @pytest.mark.parametrize("device", test_devices()) -@pytest.mark.parametrize("size", ((5, 4, 3), (10, 2))) -def test_scale_unscale_transform(device, size): +def test_scale_unscale_transform(device): """Test scale_transform and unscale_transform.""" + size = (5, 4, 3) inputs = torch.tensor(range(math.prod(size)), device=device, dtype=torch.float32).reshape(size) @@ -63,9 +62,9 @@ def test_scale_unscale_transform(device, size): @pytest.mark.parametrize("device", test_devices()) -@pytest.mark.parametrize("size", ((5, 4, 3), (10, 2))) -def test_saturate(device, size): +def test_saturate(device): "Test saturate of a tensor of differed shapes and device." + size = (5, 4, 3) num_elements = math.prod(size) input = torch.tensor(range(num_elements), device=device, dtype=torch.float32).reshape(size) @@ -85,9 +84,9 @@ def test_saturate(device, size): @pytest.mark.parametrize("device", test_devices()) -@pytest.mark.parametrize("size", ((5, 4, 3), (10, 2))) -def test_normalize(device, size): +def test_normalize(device): """Test normalize of a tensor along its last dimension and check the norm of that dimension is close to 1.0.""" + size = (5, 4, 3) num_elements = math.prod(size) input = torch.tensor(range(num_elements), device=device, dtype=torch.float32).reshape(size) @@ -150,30 +149,6 @@ def test_is_identity_pose(device): assert math_utils.is_identity_pose(identity_pos, identity_rot) is False -@pytest.mark.parametrize("device", test_devices()) -def test_axis_angle_from_quat(device): - """Test axis_angle_from_quat method.""" - # Quaternions of the form (2,4) and (2,2,4) in xyzw format - quats = [ - torch.Tensor([[0.0, 0.0, 0.0, 1.0], [0.142006, 0.0, 0.5206887, 0.8418536]]).to(device), - torch.Tensor( - [ - [[0.0, 0.0, 0.0, 1.0], [0.142006, 0.0, 0.5206887, 0.8418536]], - [[0.0, 0.0, 0.0, 1.0], [0.0995007, 0.0995007, 0.0995007, 0.9850375]], - ] - ).to(device), - ] - - # Angles of the form (2,3) and (2,2,3) - angles = [ - torch.Tensor([[0.0, 0.0, 0.0], [0.3, 0.0, 1.1]]).to(device), - torch.Tensor([[[0.0, 0.0, 0.0], [0.3, 0.0, 1.1]], [[0.0, 0.0, 0.0], [0.2, 0.2, 0.2]]]).to(device), - ] - - for quat, angle in zip(quats, angles): - torch.testing.assert_close(math_utils.axis_angle_from_quat(quat), angle) - - @pytest.mark.parametrize("device", test_devices()) def test_axis_angle_from_quat_approximation(device): """Test the Taylor approximation from axis_angle_from_quat method. @@ -352,12 +327,12 @@ def test_convention_converter(device): @pytest.mark.parametrize("device", test_devices()) -@pytest.mark.parametrize("size", ((10, 4), (5, 3, 4))) -def test_convert_quat(device, size): +def test_convert_quat(device): """Test convert_quat from "xyzw" to "wxyz" and back to "xyzw" and verify the correct rolling of the tensor. Also check the correct exceptions are raised for bad inputs for the quaternion and the 'to'. """ + size = (5, 3, 4) quat = torch.zeros(size, device=device) quat[..., 0] = 1.0 @@ -401,7 +376,6 @@ def test_quat_conjugate(device): @pytest.mark.parametrize("device", test_devices()) -@pytest.mark.parametrize("num_envs", (1, 10)) @pytest.mark.parametrize( "euler_angles", [ @@ -413,8 +387,9 @@ def test_quat_conjugate(device): [0.1, math.pi, math.pi / 2], ], ) -def test_quat_from_euler_xyz(device, num_envs, euler_angles): +def test_quat_from_euler_xyz(device, euler_angles): """Test quat_from_euler_xyz against scipy.""" + num_envs = 10 angles = torch.tensor(euler_angles, device=device).unsqueeze(0).repeat((num_envs, 1)) quat_value = math_utils.quat_unique(math_utils.quat_from_euler_xyz(angles[:, 0], angles[:, 1], angles[:, 2])) @@ -540,41 +515,14 @@ def test_unproject_depth(device): torch.testing.assert_close(points, expected) -@pytest.mark.parametrize("device", test_devices()) -def test_combine_frame_transform(device): - """Test combine_frame_transforms function.""" - # create random poses - pose01 = torch.rand(1, 7, device=device) - pose01[:, 3:7] = torch.nn.functional.normalize(pose01[..., 3:7], dim=-1) - - pose12 = torch.rand(1, 7, device=device) - pose12[:, 3:7] = torch.nn.functional.normalize(pose12[..., 3:7], dim=-1) - - # apply combination of poses - pos02, quat02 = math_utils.combine_frame_transforms( - pose01[..., :3], pose01[..., 3:7], pose12[:, :3], pose12[:, 3:7] - ) - # apply combination of poses w.r.t. inverse to get original frame - pos01, quat01 = math_utils.combine_frame_transforms( - pos02, - quat02, - math_utils.quat_rotate(math_utils.quat_inv(pose12[:, 3:7]), -pose12[:, :3]), - math_utils.quat_inv(pose12[:, 3:7]), - ) - - torch.testing.assert_close(pose01, torch.cat((pos01, quat01), dim=-1)) - - -@pytest.mark.parametrize("device", test_devices()) -@pytest.mark.parametrize("seed", [0, 1, 2, 3, 4]) -def test_interpolate_poses(device, seed): +def test_interpolate_poses(): """Test interpolate_poses function. This test checks the output from the :meth:`~isaaclab.utils.math_utils.interpolate_poses` function against the output from :func:`scipy.spatial.transform.Slerp` and :func:`np.linspace`. """ - torch.manual_seed(seed) - np.random.seed(seed) + torch.manual_seed(0) + np.random.seed(0) for _ in range(100): mat1 = math_utils.generate_random_transformation_matrix() mat2 = math_utils.generate_random_transformation_matrix() @@ -646,6 +594,10 @@ def test_quat_to_and_from_angle_axis(device): dtype=torch.float32, ) torch.testing.assert_close(rot_vec_scipy, rot_vec_value) + # batched (..., 4) input keeps its leading dimensions + torch.testing.assert_close( + math_utils.axis_angle_from_quat(q_rand.reshape(n // 2, 2, 4)), rot_vec_scipy.reshape(n // 2, 2, 3) + ) axis = math_utils.normalize(rot_vec_value.clone()) angle = torch.linalg.norm(rot_vec_value.clone(), dim=-1) q_value = math_utils.quat_unique(math_utils.quat_from_angle_axis(angle, axis)) @@ -681,7 +633,7 @@ def test_quat_box_minus_and_quat_box_plus(device): # Perform closed-loop integration using quat_box_plus to accumulate rotations, # and then use quat_box_minus to compute the incremental differences between quaternions. # NOTE: Accuracy may decrease for very small angle increments due to numerical precision limits. - for n in (2, 10, 100, 1000): + for n in (2, 10, 100): # Define small incremental rotations around principal axes delta_angle = torch.tensor( [ @@ -713,8 +665,8 @@ def test_quat_box_minus_and_quat_box_plus(device): @pytest.mark.parametrize("device", test_devices()) -@pytest.mark.parametrize("t12_inputs", ["True", "False"]) -@pytest.mark.parametrize("q12_inputs", ["True", "False"]) +@pytest.mark.parametrize("t12_inputs", [True, False]) +@pytest.mark.parametrize("q12_inputs", [True, False]) def test_combine_frame_transforms(device, t12_inputs, q12_inputs): """Test combine_frame_transforms such that inputs for delta translation and delta rotation can be :obj:`None` or specified. @@ -752,8 +704,8 @@ def test_combine_frame_transforms(device, t12_inputs, q12_inputs): @pytest.mark.parametrize("device", test_devices()) -@pytest.mark.parametrize("t02_inputs", ["True", "False"]) -@pytest.mark.parametrize("q02_inputs", ["True", "False"]) +@pytest.mark.parametrize("t02_inputs", [True, False]) +@pytest.mark.parametrize("q02_inputs", [True, False]) def test_subtract_frame_transforms(device, t02_inputs, q02_inputs): """Test subtract_frame_transforms with specified and unspecified inputs for t02 and q02. @@ -806,17 +758,14 @@ def test_compute_pose_error(device, rot_error_type): diff_pos, diff_rot = math_utils.compute_pose_error(t01, q01, t02, q02, rot_error_type=rot_error_type) torch.testing.assert_close(t02 - t01, diff_pos) + # the rotation error is R_02 * R_01^-1, computed independently with scipy + rot_error = scipy_tf.Rotation.from_quat(q02.cpu().numpy()) * scipy_tf.Rotation.from_quat(q01.cpu().numpy()).inv() if rot_error_type == "axis_angle": - torch.testing.assert_close(math_utils.quat_box_minus(q02, q01), diff_rot) + expected_rot = torch.tensor(rot_error.as_rotvec(), device=device, dtype=torch.float32) + torch.testing.assert_close(expected_rot, diff_rot, atol=1e-4, rtol=1e-4) else: - axis_angle = math_utils.quat_box_minus(q02, q01) - axis = math_utils.normalize(axis_angle) - angle = torch.linalg.norm(axis_angle, dim=-1) - - torch.testing.assert_close( - math_utils.quat_unique(math_utils.quat_from_angle_axis(angle, axis)), - math_utils.quat_unique(diff_rot), - ) + expected_quat = torch.tensor(rot_error.as_quat(), device=device, dtype=torch.float32) + torch.testing.assert_close(math_utils.quat_unique(expected_quat), math_utils.quat_unique(diff_rot)) @pytest.mark.parametrize("device", test_devices()) @@ -846,6 +795,16 @@ def test_rigid_body_twist_transform(device): torch.testing.assert_close(v_AA_, v_AA) torch.testing.assert_close(w_AA_, w_AA) + # Analytic case: spinning about z at 1 rad/s, a frame offset by 1 m along x moves along +y. + # Row 0 keeps the orientation; row 1 is rotated 90 deg about z, so +y in frame 0 is +x in frame 1. + v0 = torch.zeros((2, 3), device=device) + w0 = torch.tensor([[0.0, 0.0, 1.0], [0.0, 0.0, 1.0]], device=device) + t01 = torch.tensor([[1.0, 0.0, 0.0], [1.0, 0.0, 0.0]], device=device) + q01 = torch.tensor([[0.0, 0.0, 0.0, 1.0], [0.0, 0.0, math.sin(PI / 4), math.cos(PI / 4)]], device=device) + v1, w1 = math_utils.rigid_body_twist_transform(v0, w0, t01, q01) + torch.testing.assert_close(v1, torch.tensor([[0.0, 1.0, 0.0], [1.0, 0.0, 0.0]], device=device)) + torch.testing.assert_close(w1, w0) + @pytest.mark.parametrize("device", test_devices()) def test_yaw_quat(device): @@ -922,24 +881,14 @@ def test_matrix_from_quat(device): @pytest.mark.parametrize("device", test_devices()) -@pytest.mark.parametrize( - "euler_angles", - [ - [0.0, 0.0, 0.0], - [math.pi / 2.0, 0.0, 0.0], - [0.0, math.pi / 2.0, 0.0], - [0.0, 0.0, math.pi / 2.0], - [1.5708, -2.75, 0.1], - [0.1, math.pi, math.pi / 2], - ], -) +@pytest.mark.parametrize("euler_angles", [[0.0, 0.0, 0.0], [1.5708, -2.75, 0.1], [0.1, math.pi, math.pi / 2]]) @pytest.mark.parametrize( "convention", ("XYZ", "XZY", "YXZ", "YZX", "ZXY", "ZYX", "ZYZ", "YZY", "XYX", "XZX", "ZXZ", "YXY") ) def test_matrix_from_euler(device, euler_angles, convention): """Test matrix_from_euler against scipy for different permutations of the X,Y,Z euler angle conventions.""" - num_envs = 1024 + num_envs = 4 angles = torch.tensor(euler_angles, device=device).unsqueeze(0).repeat((num_envs, 1)) mat_value = math_utils.matrix_from_euler(angles, convention=convention) expected_mag = ( @@ -969,6 +918,9 @@ def test_quat_apply(device): scipy_result = torch.tensor(Rotation.apply(v_rand.to(device="cpu").numpy()), device=device, dtype=torch.float) apply_result = math_utils.quat_apply(q_rand, v_rand) torch.testing.assert_close(scipy_result.to(device=device), apply_result, atol=2e-4, rtol=2e-4) + # batched (..., 4) inputs keep their leading dimensions + batched_result = math_utils.quat_apply(q_rand.reshape(n // 8, 2, 4, 4), v_rand.reshape(n // 8, 2, 4, 3)) + torch.testing.assert_close(batched_result, scipy_result.reshape(n // 8, 2, 4, 3), atol=2e-4, rtol=2e-4) @pytest.mark.parametrize("device", test_devices()) @@ -989,6 +941,9 @@ def test_quat_apply_inverse(device): ) apply_result = math_utils.quat_apply_inverse(q_rand, v_rand) torch.testing.assert_close(scipy_result.to(device=device), apply_result, atol=2e-4, rtol=2e-4) + # batched (..., 4) inputs keep their leading dimensions + batched_result = math_utils.quat_apply_inverse(q_rand.reshape(n // 8, 2, 4, 4), v_rand.reshape(n // 8, 2, 4, 3)) + torch.testing.assert_close(batched_result, scipy_result.reshape(n // 8, 2, 4, 3), atol=2e-4, rtol=2e-4) @pytest.mark.parametrize("device", test_devices()) @@ -1020,223 +975,6 @@ def test_quat_inv(device): torch.testing.assert_close(math_utils.quat_mul(q_inv, q), id_batch, atol=1e-4, rtol=1e-4) -def test_quat_apply_benchmarks(): - """Test for quat_apply and quat_apply_inverse methods compared to old methods using torch.bmm and torch.einsum. - The new implementation uses :meth:`torch.einsum` instead of `torch.bmm` which allows - for more flexibility in the input dimensions and is faster than `torch.bmm`. - """ - - # define old implementation for quat_rotate and quat_rotate_inverse - # Updated for xyzw format (xyz at indices 0-2, w at index 3) - - @torch.jit.script - def bmm_quat_rotate(q: torch.Tensor, v: torch.Tensor) -> torch.Tensor: - shape = q.shape - q_vec = q[:, :3] # xyz - q_w = q[:, 3] # w - a = v * (2.0 * q_w**2 - 1.0).unsqueeze(-1) - b = torch.cross(q_vec, v, dim=-1) * q_w.unsqueeze(-1) * 2.0 - c = q_vec * torch.bmm(q_vec.view(shape[0], 1, 3), v.view(shape[0], 3, 1)).squeeze(-1) * 2.0 - return a + b + c - - @torch.jit.script - def bmm_quat_rotate_inverse(q: torch.Tensor, v: torch.Tensor) -> torch.Tensor: - shape = q.shape - q_vec = q[:, :3] # xyz - q_w = q[:, 3] # w - a = v * (2.0 * q_w**2 - 1.0).unsqueeze(-1) - b = torch.cross(q_vec, v, dim=-1) * q_w.unsqueeze(-1) * 2.0 - c = q_vec * torch.bmm(q_vec.view(shape[0], 1, 3), v.view(shape[0], 3, 1)).squeeze(-1) * 2.0 - return a - b + c - - @torch.jit.script - def einsum_quat_rotate(q: torch.Tensor, v: torch.Tensor) -> torch.Tensor: - q_vec = q[..., :3] # xyz - q_w = q[..., 3] # w - a = v * (2.0 * q_w**2 - 1.0).unsqueeze(-1) - b = torch.cross(q_vec, v, dim=-1) * q_w.unsqueeze(-1) * 2.0 - c = q_vec * torch.einsum("...i,...i->...", q_vec, v).unsqueeze(-1) * 2.0 - return a + b + c - - @torch.jit.script - def einsum_quat_rotate_inverse(q: torch.Tensor, v: torch.Tensor) -> torch.Tensor: - q_vec = q[..., :3] # xyz - q_w = q[..., 3] # w - a = v * (2.0 * q_w**2 - 1.0).unsqueeze(-1) - b = torch.cross(q_vec, v, dim=-1) * q_w.unsqueeze(-1) * 2.0 - c = q_vec * torch.einsum("...i,...i->...", q_vec, v).unsqueeze(-1) * 2.0 - return a - b + c - - # check that implementation produces the same result as the new implementation - for device in test_devices(DeviceScope.CPU_AND_DEFAULT_CUDA): - # prepare random quaternions and vectors - q_rand = math_utils.random_orientation(num=1024, device=device) - v_rand = math_utils.sample_uniform(-1000, 1000, (1024, 3), device=device) - - # compute the result using the old implementation - bmm_result = bmm_quat_rotate(q_rand, v_rand) - bmm_result_inv = bmm_quat_rotate_inverse(q_rand, v_rand) - - # compute the result using the old implementation - einsum_result = einsum_quat_rotate(q_rand, v_rand) - einsum_result_inv = einsum_quat_rotate_inverse(q_rand, v_rand) - - # compute the result using the new implementation - new_result = math_utils.quat_apply(q_rand, v_rand) - new_result_inv = math_utils.quat_apply_inverse(q_rand, v_rand) - - # check that the result is close to the expected value - torch.testing.assert_close(bmm_result, new_result, atol=1e-3, rtol=1e-3) - torch.testing.assert_close(bmm_result_inv, new_result_inv, atol=1e-3, rtol=1e-3) - torch.testing.assert_close(einsum_result, new_result, atol=1e-3, rtol=1e-3) - torch.testing.assert_close(einsum_result_inv, new_result_inv, atol=1e-3, rtol=1e-3) - - # check the performance of the new implementation - for device in test_devices(DeviceScope.CPU_AND_DEFAULT_CUDA): - # prepare random quaternions and vectors - # new implementation supports batched inputs - q_shape = (1024, 2, 5, 4) - v_shape = (1024, 2, 5, 3) - # sample random quaternions and vectors - num_quats = math.prod(q_shape[:-1]) - q_rand = math_utils.random_orientation(num=num_quats, device=device).reshape(q_shape) - v_rand = math_utils.sample_uniform(-1000, 1000, v_shape, device=device) - - # create functions to test - def iter_quat_apply(q: torch.Tensor, v: torch.Tensor) -> torch.Tensor: - """Iterative implementation of new quat_apply.""" - out = torch.empty_like(v) - for i in range(q.shape[1]): - for j in range(q.shape[2]): - out[:, i, j] = math_utils.quat_apply(q_rand[:, i, j], v_rand[:, i, j]) - return out - - def iter_quat_apply_inverse(q: torch.Tensor, v: torch.Tensor) -> torch.Tensor: - """Iterative implementation of new quat_apply_inverse.""" - out = torch.empty_like(v) - for i in range(q.shape[1]): - for j in range(q.shape[2]): - out[:, i, j] = math_utils.quat_apply_inverse(q_rand[:, i, j], v_rand[:, i, j]) - return out - - def iter_bmm_quat_rotate(q: torch.Tensor, v: torch.Tensor) -> torch.Tensor: - """Iterative implementation of old quat_rotate using torch.bmm.""" - out = torch.empty_like(v) - for i in range(q.shape[1]): - for j in range(q.shape[2]): - out[:, i, j] = bmm_quat_rotate(q_rand[:, i, j], v_rand[:, i, j]) - return out - - def iter_bmm_quat_rotate_inverse(q: torch.Tensor, v: torch.Tensor) -> torch.Tensor: - """Iterative implementation of old quat_rotate_inverse using torch.bmm.""" - out = torch.empty_like(v) - for i in range(q.shape[1]): - for j in range(q.shape[2]): - out[:, i, j] = bmm_quat_rotate_inverse(q_rand[:, i, j], v_rand[:, i, j]) - return out - - def iter_einsum_quat_rotate(q: torch.Tensor, v: torch.Tensor) -> torch.Tensor: - """Iterative implementation of old quat_rotate using torch.einsum.""" - out = torch.empty_like(v) - for i in range(q.shape[1]): - for j in range(q.shape[2]): - out[:, i, j] = einsum_quat_rotate(q_rand[:, i, j], v_rand[:, i, j]) - return out - - def iter_einsum_quat_rotate_inverse(q: torch.Tensor, v: torch.Tensor) -> torch.Tensor: - """Iterative implementation of old quat_rotate_inverse using torch.einsum.""" - out = torch.empty_like(v) - for i in range(q.shape[1]): - for j in range(q.shape[2]): - out[:, i, j] = einsum_quat_rotate_inverse(q_rand[:, i, j], v_rand[:, i, j]) - return out - - # benchmarks for iterative calls - timer_iter_quat_apply = benchmark.Timer( - stmt="iter_quat_apply(q_rand, v_rand)", - globals={"iter_quat_apply": iter_quat_apply, "q_rand": q_rand, "v_rand": v_rand}, - ) - timer_iter_quat_apply_inverse = benchmark.Timer( - stmt="iter_quat_apply_inverse(q_rand, v_rand)", - globals={"iter_quat_apply_inverse": iter_quat_apply_inverse, "q_rand": q_rand, "v_rand": v_rand}, - ) - - timer_iter_bmm_quat_rotate = benchmark.Timer( - stmt="iter_bmm_quat_rotate(q_rand, v_rand)", - globals={"iter_bmm_quat_rotate": iter_bmm_quat_rotate, "q_rand": q_rand, "v_rand": v_rand}, - ) - timer_iter_bmm_quat_rotate_inverse = benchmark.Timer( - stmt="iter_bmm_quat_rotate_inverse(q_rand, v_rand)", - globals={ - "iter_bmm_quat_rotate_inverse": iter_bmm_quat_rotate_inverse, - "q_rand": q_rand, - "v_rand": v_rand, - }, - ) - - timer_iter_einsum_quat_rotate = benchmark.Timer( - stmt="iter_einsum_quat_rotate(q_rand, v_rand)", - globals={"iter_einsum_quat_rotate": iter_einsum_quat_rotate, "q_rand": q_rand, "v_rand": v_rand}, - ) - timer_iter_einsum_quat_rotate_inverse = benchmark.Timer( - stmt="iter_einsum_quat_rotate_inverse(q_rand, v_rand)", - globals={ - "iter_einsum_quat_rotate_inverse": iter_einsum_quat_rotate_inverse, - "q_rand": q_rand, - "v_rand": v_rand, - }, - ) - - # create benchmaks for size independent calls - timer_quat_apply = benchmark.Timer( - stmt="math_utils.quat_apply(q_rand, v_rand)", - globals={"math_utils": math_utils, "q_rand": q_rand, "v_rand": v_rand}, - ) - timer_quat_apply_inverse = benchmark.Timer( - stmt="math_utils.quat_apply_inverse(q_rand, v_rand)", - globals={"math_utils": math_utils, "q_rand": q_rand, "v_rand": v_rand}, - ) - timer_einsum_quat_rotate = benchmark.Timer( - stmt="einsum_quat_rotate(q_rand, v_rand)", - globals={"einsum_quat_rotate": einsum_quat_rotate, "q_rand": q_rand, "v_rand": v_rand}, - ) - timer_einsum_quat_rotate_inverse = benchmark.Timer( - stmt="einsum_quat_rotate_inverse(q_rand, v_rand)", - globals={"einsum_quat_rotate_inverse": einsum_quat_rotate_inverse, "q_rand": q_rand, "v_rand": v_rand}, - ) - - # run the benchmark - print("--------------------------------") - print(f"Device: {device}") - print("Time for quat_apply:", timer_quat_apply.timeit(number=1000)) - print("Time for einsum_quat_rotate:", timer_einsum_quat_rotate.timeit(number=1000)) - print("Time for iter_quat_apply:", timer_iter_quat_apply.timeit(number=1000)) - print("Time for iter_bmm_quat_rotate:", timer_iter_bmm_quat_rotate.timeit(number=1000)) - print("Time for iter_einsum_quat_rotate:", timer_iter_einsum_quat_rotate.timeit(number=1000)) - print("--------------------------------") - print("Time for quat_apply_inverse:", timer_quat_apply_inverse.timeit(number=1000)) - print("Time for einsum_quat_rotate_inverse:", timer_einsum_quat_rotate_inverse.timeit(number=1000)) - print("Time for iter_quat_apply_inverse:", timer_iter_quat_apply_inverse.timeit(number=1000)) - print("Time for iter_bmm_quat_rotate_inverse:", timer_iter_bmm_quat_rotate_inverse.timeit(number=1000)) - print("Time for iter_einsum_quat_rotate_inverse:", timer_iter_einsum_quat_rotate_inverse.timeit(number=1000)) - print("--------------------------------") - - # check output values are the same - torch.testing.assert_close(math_utils.quat_apply(q_rand, v_rand), iter_quat_apply(q_rand, v_rand)) - torch.testing.assert_close( - math_utils.quat_apply(q_rand, v_rand), iter_bmm_quat_rotate(q_rand, v_rand), atol=1e-3, rtol=1e-3 - ) - torch.testing.assert_close( - math_utils.quat_apply_inverse(q_rand, v_rand), iter_quat_apply_inverse(q_rand, v_rand) - ) - torch.testing.assert_close( - math_utils.quat_apply_inverse(q_rand, v_rand), - iter_bmm_quat_rotate_inverse(q_rand, v_rand), - atol=1e-3, - rtol=1e-3, - ) - - def test_interpolate_rotations(): """Test interpolate_rotations function. @@ -1344,77 +1082,43 @@ def test_euler_xyz_from_quat(): @pytest.mark.parametrize("device", test_devices()) -def test_create_rotation_matrix_from_view_lookat_along_up_axis_z(device): - """Camera above target on +Z axis with Z-up should return a valid orthonormal frame.""" - eyes = torch.tensor([[0.0, 0.0, 5.0]], device=device) - targets = torch.tensor([[0.0, 0.0, 0.0]], device=device) - R = math_utils.create_rotation_matrix_from_view(eyes, targets, up_axis="Z", device=device) - assert R is not None - identity = torch.eye(3, device=device).expand(1, 3, 3) - torch.testing.assert_close(R @ R.transpose(-1, -2), identity, atol=1e-5, rtol=1e-5) - torch.testing.assert_close(torch.linalg.det(R), torch.ones(1, device=device), atol=1e-5, rtol=1e-5) - - -@pytest.mark.parametrize("device", test_devices()) -def test_create_rotation_matrix_from_view_lookat_along_up_axis_y(device): - """Camera at +Y looking at origin with Y-up should return a valid orthonormal frame.""" - eyes = torch.tensor([[0.0, 5.0, 0.0]], device=device) - targets = torch.tensor([[0.0, 0.0, 0.0]], device=device) - R = math_utils.create_rotation_matrix_from_view(eyes, targets, up_axis="Y", device=device) - assert R is not None - identity = torch.eye(3, device=device).expand(1, 3, 3) - torch.testing.assert_close(R @ R.transpose(-1, -2), identity, atol=1e-5, rtol=1e-5) - torch.testing.assert_close(torch.linalg.det(R), torch.ones(1, device=device), atol=1e-5, rtol=1e-5) - - -@pytest.mark.parametrize("device", test_devices()) -def test_create_rotation_matrix_from_view_lookat_along_negative_up_axis(device): - """Camera below target looking up (-Z alignment with Z-up) should return a valid orthonormal frame.""" - eyes = torch.tensor([[0.0, 0.0, -5.0]], device=device) +@pytest.mark.parametrize( + "eye, up_axis", + [((0.0, 0.0, 5.0), "Z"), ((0.0, 5.0, 0.0), "Y"), ((0.0, 0.0, -5.0), "Z")], + ids=["along_up_axis_z", "along_up_axis_y", "along_negative_up_axis"], +) +def test_create_rotation_matrix_from_view_lookat_along_up_axis(device, eye, up_axis): + """Camera looking along (or against) the up axis should return a valid orthonormal frame.""" + eyes = torch.tensor([eye], device=device) targets = torch.tensor([[0.0, 0.0, 0.0]], device=device) - R = math_utils.create_rotation_matrix_from_view(eyes, targets, up_axis="Z", device=device) + R = math_utils.create_rotation_matrix_from_view(eyes, targets, up_axis=up_axis, device=device) assert R is not None identity = torch.eye(3, device=device).expand(1, 3, 3) torch.testing.assert_close(R @ R.transpose(-1, -2), identity, atol=1e-5, rtol=1e-5) torch.testing.assert_close(torch.linalg.det(R), torch.ones(1, device=device), atol=1e-5, rtol=1e-5) -@pytest.mark.parametrize("device", test_devices()) -def test_create_rotation_matrix_from_view_zero_forward_returns_nan(device): - """When eyes == targets the forward direction is undefined; all entries of the row are NaN.""" - eyes = torch.tensor([[1.0, 2.0, 3.0]], device=device) - targets = eyes.clone() - R = math_utils.create_rotation_matrix_from_view(eyes, targets, up_axis="Z", device=device) - assert torch.isnan(R).all() - - @pytest.mark.parametrize("device", test_devices()) def test_create_rotation_matrix_from_view_batched_partial_failure(device): - """Mixed batch with one degenerate row should produce NaN in that row and a valid rotation in the other.""" + """Mixed batch with one degenerate row (eyes == targets) should produce an all-NaN row and a valid rotation.""" eyes = torch.tensor([[1.0, 2.0, 3.0], [0.0, 0.0, 5.0]], device=device) targets = torch.tensor([[1.0, 2.0, 3.0], [0.0, 0.0, 0.0]], device=device) R = math_utils.create_rotation_matrix_from_view(eyes, targets, up_axis="Z", device=device) - assert torch.isnan(R[0]).any() + assert torch.isnan(R[0]).all() torch.testing.assert_close(R[1] @ R[1].T, torch.eye(3, device=device), atol=1e-5, rtol=1e-5) torch.testing.assert_close(torch.linalg.det(R[1]), torch.tensor(1.0, device=device), atol=1e-5, rtol=1e-5) @pytest.mark.parametrize("device", test_devices()) -def test_quat_from_matrix_unit_norm_on_valid_input(device): - """quat_from_matrix should produce unit quaternions for any valid rotation matrix.""" - n = 100 - q_rand = math_utils.random_orientation(num=n, device=device) - rot_mat = math_utils.matrix_from_quat(q_rand) - q_value = math_utils.quat_from_matrix(rot_mat) - norms = torch.linalg.norm(q_value, dim=-1) - torch.testing.assert_close(norms, torch.ones(n, device=device), atol=1e-5, rtol=1e-5) - - -@pytest.mark.parametrize("device", test_devices()) -def test_quat_from_matrix_singular_matrix_returns_nan(device): - """quat_from_matrix on a singular (non-rotation) matrix should signal NaN, not garbage.""" - singular = torch.tensor([[[0.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.0, 0.0, 1.0]]], device=device) - q = math_utils.quat_from_matrix(singular) +@pytest.mark.parametrize( + "diagonal", + [(0.0, 0.0, 1.0), (1.0, 1.0, -1.0), (1.01, 1.0, 1.0)], + ids=["singular", "reflection", "non_orthonormal"], +) +def test_quat_from_matrix_invalid_returns_nan(device, diagonal): + """quat_from_matrix on a singular, reflection (det = -1) or non-orthonormal matrix should signal NaN.""" + matrix = torch.diag(torch.tensor(diagonal, device=device)).unsqueeze(0) + q = math_utils.quat_from_matrix(matrix) assert torch.isnan(q).all() @@ -1439,19 +1143,3 @@ def test_create_rotation_matrix_from_view_non_finite_returns_nan(device): targets = torch.tensor([[0.0, 0.0, 0.0]], device=device) R = math_utils.create_rotation_matrix_from_view(eyes, targets, up_axis="Z", device=device) assert torch.isnan(R).all() - - -@pytest.mark.parametrize("device", test_devices()) -def test_quat_from_matrix_reflection_returns_nan(device): - """A reflection matrix (det = -1) is not a proper rotation; the safeguard should signal NaN.""" - reflection = torch.diag(torch.tensor([1.0, 1.0, -1.0], device=device)).unsqueeze(0) - q = math_utils.quat_from_matrix(reflection) - assert torch.isnan(q).all() - - -@pytest.mark.parametrize("device", test_devices()) -def test_quat_from_matrix_non_orthonormal_returns_nan(device): - """A non-orthonormal matrix (1% scale error on one axis) is not a valid rotation; expect NaN.""" - R = torch.diag(torch.tensor([1.01, 1.0, 1.0], device=device)).unsqueeze(0) - q = math_utils.quat_from_matrix(R) - assert torch.isnan(q).all() diff --git a/source/isaaclab/test/utils/test_modifiers.py b/source/isaaclab/test/utils/test_modifiers.py index ffd90c0e8748..c131ce76addf 100644 --- a/source/isaaclab/test/utils/test_modifiers.py +++ b/source/isaaclab/test/utils/test_modifiers.py @@ -25,124 +25,22 @@ class ModifierTestCfg: num_iter: int = 10 -def test_scale_modifier(): - """Test scale modifier.""" - # create test data - init_data = torch.tensor([1.0, 2.0, 3.0]) - scale = 2.0 - result = torch.tensor([2.0, 4.0, 6.0]) - - # create test config - test_cfg = ModifierTestCfg( - cfg=modifiers.ModifierCfg(func=modifiers.scale, params={"multiplier": scale}), - init_data=init_data, - result=result, - ) - - # test modifier - for _ in range(test_cfg.num_iter): - output = test_cfg.cfg.func(test_cfg.init_data, **test_cfg.cfg.params) - assert torch.allclose(output, test_cfg.result) - - -def test_bias_modifier(): - """Test bias modifier.""" - # create test data - init_data = torch.tensor([1.0, 2.0, 3.0]) - bias = 1.0 - result = torch.tensor([2.0, 3.0, 4.0]) - - # create test config - test_cfg = ModifierTestCfg( - cfg=modifiers.ModifierCfg(func=modifiers.bias, params={"value": bias}), - init_data=init_data, - result=result, - ) - - # test modifier - for _ in range(test_cfg.num_iter): - output = test_cfg.cfg.func(test_cfg.init_data, **test_cfg.cfg.params) - assert torch.allclose(output, test_cfg.result) - - -def test_clip_modifier(): - """Test clip modifier.""" - # create test data - init_data = torch.tensor([1.0, 2.0, 3.0]) - min_val = 1.5 - max_val = 2.5 - result = torch.tensor([1.5, 2.0, 2.5]) - - # create test config - test_cfg = ModifierTestCfg( - cfg=modifiers.ModifierCfg(func=modifiers.clip, params={"bounds": (min_val, max_val)}), - init_data=init_data, - result=result, - ) - - # test modifier - for _ in range(test_cfg.num_iter): - output = test_cfg.cfg.func(test_cfg.init_data, **test_cfg.cfg.params) - assert torch.allclose(output, test_cfg.result) - - -def test_clip_no_upper_bound_modifier(): - """Test clip modifier with no upper bound.""" - # create test data - init_data = torch.tensor([1.0, 2.0, 3.0]) - min_val = 1.5 - result = torch.tensor([1.5, 2.0, 3.0]) - - # create test config - test_cfg = ModifierTestCfg( - cfg=modifiers.ModifierCfg(func=modifiers.clip, params={"bounds": (min_val, None)}), - init_data=init_data, - result=result, - ) - - # test modifier - for _ in range(test_cfg.num_iter): - output = test_cfg.cfg.func(test_cfg.init_data, **test_cfg.cfg.params) - assert torch.allclose(output, test_cfg.result) - - -def test_clip_no_lower_bound_modifier(): - """Test clip modifier with no lower bound.""" - # create test data - init_data = torch.tensor([1.0, 2.0, 3.0]) - max_val = 2.5 - result = torch.tensor([1.0, 2.0, 2.5]) - - # create test config - test_cfg = ModifierTestCfg( - cfg=modifiers.ModifierCfg(func=modifiers.clip, params={"bounds": (None, max_val)}), - init_data=init_data, - result=result, - ) - - # test modifier - for _ in range(test_cfg.num_iter): - output = test_cfg.cfg.func(test_cfg.init_data, **test_cfg.cfg.params) - assert torch.allclose(output, test_cfg.result) - - -def test_torch_relu_modifier(): - """Test torch relu modifier.""" - # create test data - init_data = torch.tensor([-1.0, 0.0, 1.0]) - result = torch.tensor([0.0, 0.0, 1.0]) - - # create test config - test_cfg = ModifierTestCfg( - cfg=modifiers.ModifierCfg(func=torch.nn.functional.relu), - init_data=init_data, - result=result, - ) - - # test modifier - for _ in range(test_cfg.num_iter): - output = test_cfg.cfg.func(test_cfg.init_data) - assert torch.allclose(output, test_cfg.result) +@pytest.mark.parametrize( + "func, params, result", + [ + (modifiers.scale, {"multiplier": 2.0}, [2.0, 4.0, 6.0]), + (modifiers.bias, {"value": 1.0}, [2.0, 3.0, 4.0]), + (modifiers.clip, {"bounds": (1.5, 2.5)}, [1.5, 2.0, 2.5]), + (modifiers.clip, {"bounds": (1.5, None)}, [1.5, 2.0, 3.0]), + (modifiers.clip, {"bounds": (None, 2.5)}, [1.0, 2.0, 2.5]), + ], + ids=["scale", "bias", "clip", "clip_no_upper_bound", "clip_no_lower_bound"], +) +def test_stateless_modifiers(func, params, result): + """Test the stateless scale, bias, and clip modifiers.""" + cfg = modifiers.ModifierCfg(func=func, params=params) + output = cfg.func(torch.tensor([1.0, 2.0, 3.0]), **cfg.params) + assert torch.allclose(output, torch.tensor(result)) @pytest.mark.parametrize("device", test_devices()) diff --git a/source/isaaclab/test/utils/test_noise.py b/source/isaaclab/test/utils/test_noise.py index f80a05e8abad..bbcac7872d81 100644 --- a/source/isaaclab/test/utils/test_noise.py +++ b/source/isaaclab/test/utils/test_noise.py @@ -7,14 +7,15 @@ import torch import isaaclab.utils.noise as noise -from isaaclab.test.utils import DeviceScope, test_devices pytestmark = pytest.mark.unit +# Each operation runs once; the noise parameters start on the other device so the first call migrates them +# to the data device and the second call reuses the migrated parameters. +_DEVICE_MIGRATION_ROWS = [("cpu", "cuda:0", "add"), ("cuda:0", "cpu", "scale"), ("cpu", "cuda:0", "abs")] -@pytest.mark.parametrize("device", test_devices(DeviceScope.CPU_AND_DEFAULT_CUDA)) -@pytest.mark.parametrize("noise_device", ["cpu", "cuda:0"]) -@pytest.mark.parametrize("op", ["add", "scale", "abs"]) + +@pytest.mark.parametrize("device, noise_device, op", _DEVICE_MIGRATION_ROWS) def test_gaussian_noise(device, noise_device, op): """Test guassian_noise function.""" @@ -30,7 +31,7 @@ def test_gaussian_noise(device, noise_device, op): # create noise config noise_cfg = noise.GaussianNoiseCfg(std=std, mean=mean, operation=op) - for i in range(10): + for _ in range(2): # apply noise noisy_data = noise_cfg.func(data, cfg=noise_cfg) # calculate resulting noise compared to original data set @@ -45,9 +46,7 @@ def test_gaussian_noise(device, noise_device, op): torch.testing.assert_close(noise_cfg.mean, mean_result, atol=1e-2, rtol=1e-2) -@pytest.mark.parametrize("device", test_devices(DeviceScope.CPU_AND_DEFAULT_CUDA)) -@pytest.mark.parametrize("noise_device", ["cpu", "cuda:0"]) -@pytest.mark.parametrize("op", ["add", "scale", "abs"]) +@pytest.mark.parametrize("device, noise_device, op", _DEVICE_MIGRATION_ROWS) def test_uniform_noise(device, noise_device, op): """Test uniform_noise function.""" # Use ones for 'scale' so noisy_data is the noise term directly; @@ -62,7 +61,7 @@ def test_uniform_noise(device, noise_device, op): # create noise config noise_cfg = noise.UniformNoiseCfg(n_max=n_max, n_min=n_min, operation=op) - for i in range(10): + for _ in range(2): # apply noise noisy_data = noise_cfg.func(data, cfg=noise_cfg) # calculate resulting noise compared to original data set @@ -80,9 +79,7 @@ def test_uniform_noise(device, noise_device, op): assert all(torch.ge(noise_cfg.n_max + 1e-5, max_result).tolist()) -@pytest.mark.parametrize("device", test_devices(DeviceScope.CPU_AND_DEFAULT_CUDA)) -@pytest.mark.parametrize("noise_device", ["cpu", "cuda:0"]) -@pytest.mark.parametrize("op", ["add", "scale", "abs"]) +@pytest.mark.parametrize("device, noise_device, op", _DEVICE_MIGRATION_ROWS) def test_constant_noise(device, noise_device, op): """Test constant_noise""" # Use ones for 'scale' so noisy_data is the bias term directly; @@ -96,7 +93,7 @@ def test_constant_noise(device, noise_device, op): # create noise config noise_cfg = noise.ConstantNoiseCfg(bias=bias, operation=op) - for i in range(10): + for _ in range(2): # apply noise noisy_data = noise_cfg.func(data, cfg=noise_cfg) # calculate resulting noise compared to original data set diff --git a/source/isaaclab/test/utils/test_string.py b/source/isaaclab/test/utils/test_string.py index 1137621e7db0..4dbfb07b8bd0 100644 --- a/source/isaaclab/test/utils/test_string.py +++ b/source/isaaclab/test/utils/test_string.py @@ -46,12 +46,6 @@ def test_resolvable_string_dunder_introspection_stays_lazy(): ref() -def test_resolvable_string_runtime_resolution_still_works(): - """Test runtime call path still resolves the callable target.""" - ref = string_utils.ResolvableString("math:sin") - assert pytest.approx(ref(0.0), rel=0.0, abs=1e-9) == 0.0 - - def test_case_conversion(): """Test case conversion between camel case and snake case.""" # test camel case to snake case diff --git a/source/isaaclab/test/utils/test_timer.py b/source/isaaclab/test/utils/test_timer.py index f2748a3fdc14..cf28fd53afaf 100644 --- a/source/isaaclab/test/utils/test_timer.py +++ b/source/isaaclab/test/utils/test_timer.py @@ -3,6 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause +import statistics import time import pytest @@ -10,151 +11,101 @@ wp.init() +import isaaclab.utils.timer as timer_module from isaaclab.utils.timer import Timer, TimerError pytestmark = pytest.mark.unit -# number of decimal places to check -PRECISION_PLACES = 2 +class _FakeClock: + """Manually advanced replacement for :func:`time.perf_counter`.""" -def test_timer_as_object(): + def __init__(self): + self.now = 100.0 + + def __call__(self) -> float: + return self.now + + def advance(self, seconds: float): + self.now += seconds + + +@pytest.fixture +def clock(monkeypatch) -> _FakeClock: + """Patch the timer clock so elapsed times are exact and tests do not sleep.""" + fake_clock = _FakeClock() + monkeypatch.setattr(timer_module.time, "perf_counter", fake_clock) + return fake_clock + + +def test_timer_as_object(clock): """Test using a `Timer` as a regular object.""" Timer.reset() timer = Timer() timer.start() - assert abs(0 - timer.time_elapsed) < 10 ** (-PRECISION_PLACES) - time.sleep(1) - assert abs(1 - timer.time_elapsed) < 10 ** (-PRECISION_PLACES) + assert timer.time_elapsed == 0.0 + clock.advance(1.0) + assert timer.time_elapsed == 1.0 timer.stop() - assert abs(1 - timer.total_run_time) < 10 ** (-PRECISION_PLACES) + assert timer.total_run_time == 1.0 -def test_timer_as_context_manager(): +def test_timer_as_context_manager(clock): """Test using a `Timer` as a context manager.""" Timer.reset() with Timer() as timer: - assert abs(0 - timer.time_elapsed) < 10 ** (-PRECISION_PLACES) - time.sleep(1) - assert abs(1 - timer.time_elapsed) < 10 ** (-PRECISION_PLACES) + assert timer.time_elapsed == 0.0 + clock.advance(1.0) + assert timer.time_elapsed == 1.0 + assert timer.total_run_time == 1.0 -def test_timer_with_name_logs_to_global_dict(): - """Test that a named timer logs to the global timing_info dict with correct keys.""" +def test_named_timer_statistics(clock): + """Test that a named timer logs its single measurement to the global dict and the getters.""" Timer.reset() timer_name = "test_named_timer" with Timer(name=timer_name): - time.sleep(0.01) + clock.advance(0.5) - assert timer_name in Timer.timing_info info = Timer.timing_info[timer_name] - assert "last" in info - assert "mean" in info - assert "std" in info - assert "n" in info + assert set(info) == {"last", "mean", "std", "n"} assert info["n"] == 1 - - -def test_get_timer_info_returns_last_elapsed(): - """Test that get_timer_info returns the last elapsed time (backward compatibility).""" - Timer.reset() - timer_name = "test_get_info" - - with Timer(name=timer_name): - time.sleep(0.02) - + assert info["last"] == 0.5 + # For a single measurement, std is 0 and mean equals last + assert info["std"] == 0.0 + assert info["mean"] == info["last"] + # get_timer_info returns the last elapsed time (backward compatibility) last_time = Timer.get_timer_info(timer_name) assert isinstance(last_time, float) - assert last_time >= 0.02 - assert last_time == Timer.timing_info[timer_name]["last"] + assert last_time == info["last"] + assert Timer.get_timer_statistics(timer_name) == info -def test_get_timer_info_nonexistent_raises(): - """Test that get_timer_info raises TimerError for non-existent timer.""" +@pytest.mark.parametrize("getter", [Timer.get_timer_info, Timer.get_timer_statistics]) +def test_timer_getters_nonexistent_raise(getter): + """Test that the timer getters raise TimerError for a non-existent timer.""" Timer.reset() with pytest.raises(TimerError): - Timer.get_timer_info("nonexistent_timer") + getter("nonexistent_timer") -def test_get_timer_statistics(): - """Test get_timer_statistics returns correct keys and values for single measurement.""" - Timer.reset() - timer_name = "test_statistics" - - with Timer(name=timer_name): - time.sleep(0.02) - - stats = Timer.get_timer_statistics(timer_name) - assert "mean" in stats - assert "std" in stats - assert "n" in stats - assert stats["n"] == 1 - # For single measurement, std should be 0 - assert stats["std"] == 0.0 - - -def test_get_timer_statistics_nonexistent_raises(): - """Test that get_timer_statistics raises TimerError for non-existent timer.""" - Timer.reset() - - with pytest.raises(TimerError): - Timer.get_timer_statistics("nonexistent_timer") - - -def test_welford_statistics_multiple_iterations(): - """Test that Welford's algorithm correctly computes statistics over multiple iterations.""" +def test_welford_statistics_multiple_iterations(clock): + """Test that Welford's algorithm correctly computes statistics over multiple timer instances.""" Timer.reset() timer_name = "test_welford" - num_iterations = 5 - sleep_duration = 0.02 - measurements = [] + durations = [1.0, 2.0, 3.0, 4.0, 5.0] - for _ in range(num_iterations): + for duration in durations: with Timer(name=timer_name): - time.sleep(sleep_duration) - measurements.append(Timer.timing_info[timer_name]["last"]) + clock.advance(duration) stats = Timer.get_timer_statistics(timer_name) - - # Check n incremented correctly - assert stats["n"] == num_iterations - - # Check mean is approximately correct - expected_mean = sum(measurements) / len(measurements) - assert abs(stats["mean"] - expected_mean) < 1e-9 - - # Check std is non-negative and reasonable - assert stats["std"] >= 0 - # Std should be bounded by the range of measurements - measurement_range = max(measurements) - min(measurements) - assert stats["std"] <= measurement_range - - -def test_multiple_timer_instances_same_name(): - """Test that different timer instances with same name share statistics in global dict.""" - Timer.reset() - timer_name = "shared_timer" - - # First timer instance - timer1 = Timer(name=timer_name) - timer1.start() - time.sleep(0.01) - timer1.stop() - - assert Timer.timing_info[timer_name]["n"] == 1 - first_mean = Timer.timing_info[timer_name]["mean"] - - # Second timer instance with same name - timer2 = Timer(name=timer_name) - timer2.start() - time.sleep(0.02) - timer2.stop() - - assert Timer.timing_info[timer_name]["n"] == 2 - # Mean should have changed - assert Timer.timing_info[timer_name]["mean"] != first_mean + assert stats["n"] == len(durations) + assert stats["last"] == durations[-1] + assert stats["mean"] == pytest.approx(statistics.mean(durations)) + assert stats["std"] == pytest.approx(statistics.stdev(durations)) def test_global_enable_toggle(): @@ -212,38 +163,22 @@ def test_enable_display_output(capsys): Timer.enable_display_output = True -def test_time_unit_multiplier(): +@pytest.mark.parametrize("time_unit, multiplier", [("ms", 1e3), ("us", 1e6), ("ns", 1e9)]) +def test_time_unit_multiplier(clock, time_unit, multiplier): """Test that time_unit correctly scales the string representation.""" Timer.reset() - timer = Timer(time_unit="ms") + timer = Timer(time_unit=time_unit) timer.start() - time.sleep(0.01) + clock.advance(0.5) timer.stop() # total_run_time always returns seconds - assert timer.total_run_time >= 0.01 - # __str__ should show milliseconds - output = str(timer) - assert "ms" in output - # The numeric value should be >= 10 (0.01s = 10ms) - numeric_part = float(output.split()[0]) - assert numeric_part >= 10.0 - - -def test_time_unit_us_and_ns(): - """Test microsecond and nanosecond time units.""" - timer_us = Timer(time_unit="us") - timer_us.start() - time.sleep(0.001) - timer_us.stop() - assert "us" in str(timer_us) - - timer_ns = Timer(time_unit="ns") - timer_ns.start() - time.sleep(0.001) - timer_ns.stop() - assert "ns" in str(timer_ns) + assert timer.total_run_time == 0.5 + # __str__ should show the configured unit + value, unit = str(timer).split() + assert unit == time_unit + assert float(value) == pytest.approx(0.5 * multiplier) def test_invalid_time_unit_raises(): @@ -268,17 +203,3 @@ def test_reset_specific_timer(): assert "keep" in Timer.timing_info assert "remove" not in Timer.timing_info - - -def test_get_timer_statistics_includes_last(): - """Test that get_timer_statistics includes the 'last' key.""" - Timer.reset() - - with Timer(name="stats_last"): - time.sleep(0.01) - - stats = Timer.get_timer_statistics("stats_last") - assert "last" in stats - assert stats["last"] >= 0.01 - # For single measurement, mean equals last - assert stats["mean"] == stats["last"] diff --git a/source/isaaclab/test/utils/test_version.py b/source/isaaclab/test/utils/test_version.py index b692ce207193..655e70d48806 100644 --- a/source/isaaclab/test/utils/test_version.py +++ b/source/isaaclab/test/utils/test_version.py @@ -42,113 +42,26 @@ def test_get_isaac_sim_version(): assert version1.micro >= 0 -def test_get_isaac_sim_version_format(): - """Test that get_isaac_sim_version returns correct format.""" - isaac_version = get_isaac_sim_version() - - # Should be able to convert to string - version_str = str(isaac_version) - assert isinstance(version_str, str) - - # Should have proper format (e.g., "5.0.0") - parts = version_str.split(".") - assert len(parts) >= 3 - - # Can access components - assert hasattr(isaac_version, "major") - assert hasattr(isaac_version, "minor") - assert hasattr(isaac_version, "micro") - - -def test_version_caching_performance(): - """Test that caching improves performance for version checks.""" - # First call (will cache) - version1 = get_isaac_sim_version() - - # Subsequent calls should be instant (from cache) - for _ in range(100): - version = get_isaac_sim_version() - assert version == version1 - assert version is version1 # Should be the exact same object - - -def test_version_comparison_operators(): - """Test that Version objects support natural comparisons.""" - isaac_version = get_isaac_sim_version() - - # Should support comparison operators - assert isaac_version >= Version("4.0.0") - assert isaac_version == isaac_version - - # Test less than - if isaac_version.major >= 5: - assert isaac_version > Version("4.5.0") - assert isaac_version >= Version("5.0.0") - - # Test not equal - assert isaac_version != Version("0.0.1") - - @pytest.mark.parametrize( "v1,v2,expected", [ - # Equal versions + # Equal versions, including implicit trailing zeros ("1.0.0", "1.0.0", 0), - ("2.5.3", "2.5.3", 0), - # Equal with different lengths (implicit zeros) - ("1.0", "1.0.0", 0), ("1", "1.0.0.0", 0), - ("2.5", "2.5.0.0", 0), - # Major version differences - ("2.0.0", "1.0.0", 1), - ("1.0.0", "2.0.0", -1), + ("0", "0.0.0", 0), + # Major, minor, and patch differences in both directions (numeric, not lexical) ("2.0.0", "1.99.99", 1), - # Minor version differences - ("1.5.0", "1.4.0", 1), - ("1.4.0", "1.5.0", -1), + ("1.0.0", "2.0.0", -1), ("1.10.0", "1.9.99", 1), - # Patch version differences - ("1.0.5", "1.0.4", 1), - ("1.0.4", "1.0.5", -1), + ("1.4.0", "1.5.0", -1), ("2.5.10", "2.5.9", 1), - # Single/double digit versions - ("2", "1", 1), - ("1", "2", -1), - ("1.5", "1.4", 1), + ("1.0.4", "1.0.5", -1), # Extended versions - ("1.0.0.1", "1.0.0.0", 1), ("1.2.3.4.5", "1.2.3.4", 1), - # Zero versions - ("0.0.1", "0.0.0", 1), - ("0.1.0", "0.0.9", 1), - ("0", "0.0.0", 0), # Large numbers - ("100.200.300", "100.200.299", 1), ("999.999.999", "1000.0.0", -1), ], ) def test_version_comparisons(v1, v2, expected): """Test version comparisons with various scenarios.""" assert compare_versions(v1, v2) == expected - - -def test_symmetry(): - """Test anti-symmetric property: if v1 < v2, then v2 > v1.""" - test_pairs = [("1.0.0", "2.0.0"), ("1.5.3", "1.4.9"), ("1.0.0", "1.0.0")] - - for v1, v2 in test_pairs: - result1 = compare_versions(v1, v2) - result2 = compare_versions(v2, v1) - - if result1 == 0: - assert result2 == 0 - else: - assert result1 == -result2 - - -def test_transitivity(): - """Test transitive property: if v1 < v2 < v3, then v1 < v3.""" - v1, v2, v3 = "1.0.0", "2.0.0", "3.0.0" - assert compare_versions(v1, v2) == -1 - assert compare_versions(v2, v3) == -1 - assert compare_versions(v1, v3) == -1 diff --git a/source/isaaclab/test/utils/test_wrench_composer.py b/source/isaaclab/test/utils/test_wrench_composer.py index 17b64287baff..c6a7c2f84fa5 100644 --- a/source/isaaclab/test/utils/test_wrench_composer.py +++ b/source/isaaclab/test/utils/test_wrench_composer.py @@ -140,293 +140,63 @@ def random_unit_quaternion_np(rng: np.random.Generator, shape: tuple) -> np.ndar @pytest.mark.parametrize("device", test_devices()) -@pytest.mark.parametrize("num_envs", [1, 10, 100, 1000]) -@pytest.mark.parametrize("num_bodies", [1, 3, 5, 10]) -def test_wrench_composer_add_force(device: str, num_envs: int, num_bodies: int): - # Initialize random number generator - rng = np.random.default_rng(seed=0) - - for _ in range(10): - mock_asset = create_mock_asset(num_envs, num_bodies, device) - wrench_composer = WrenchComposer(mock_asset) - # Initialize hand-calculated composed force - hand_calculated_composed_force_np = np.zeros((num_envs, num_bodies, 3), dtype=np.float32) - for _ in range(10): - # Get random number of envs and bodies and their indices - num_envs_np = rng.integers(1, num_envs, endpoint=True) - num_bodies_np = rng.integers(1, num_bodies, endpoint=True) - env_ids_np = rng.choice(num_envs, size=num_envs_np, replace=False) - body_ids_np = rng.choice(num_bodies, size=num_bodies_np, replace=False) - # Convert to warp arrays - env_ids = wp.from_numpy(env_ids_np, dtype=wp.int32, device=device) - body_ids = wp.from_numpy(body_ids_np, dtype=wp.int32, device=device) - # Get random forces - forces_np = ( - np.random.uniform(low=-100.0, high=100.0, size=(num_envs_np * num_bodies_np * 3)) - .reshape(num_envs_np, num_bodies_np, 3) - .astype(np.float32) - ) - forces = wp.from_numpy(forces_np, dtype=wp.vec3f, device=device) - # Add forces to wrench composer - wrench_composer.add_forces_and_torques_index(forces=forces, body_ids=body_ids, env_ids=env_ids) - # Add forces to hand-calculated composed force - hand_calculated_composed_force_np[env_ids_np[:, None], body_ids_np[None, :], :] += forces_np - # Compose to body frame before checking output - wrench_composer.compose_to_body_frame() - # Get composed force from wrench composer - composed_force_np = wrench_composer.out_force_b.warp.numpy() - assert np.allclose(composed_force_np, hand_calculated_composed_force_np, atol=1, rtol=1e-7) - - -@pytest.mark.parametrize("device", test_devices()) -@pytest.mark.parametrize("num_envs", [1, 10, 100, 1000]) -@pytest.mark.parametrize("num_bodies", [1, 3, 5, 10]) -def test_wrench_composer_add_torque(device: str, num_envs: int, num_bodies: int): - # Initialize random number generator - rng = np.random.default_rng(seed=1) - - for _ in range(10): - mock_asset = create_mock_asset(num_envs, num_bodies, device) - wrench_composer = WrenchComposer(mock_asset) - # Initialize hand-calculated composed torque - hand_calculated_composed_torque_np = np.zeros((num_envs, num_bodies, 3), dtype=np.float32) - for _ in range(10): - # Get random number of envs and bodies and their indices - num_envs_np = rng.integers(1, num_envs, endpoint=True) - num_bodies_np = rng.integers(1, num_bodies, endpoint=True) - env_ids_np = rng.choice(num_envs, size=num_envs_np, replace=False) - body_ids_np = rng.choice(num_bodies, size=num_bodies_np, replace=False) - # Convert to warp arrays - env_ids = wp.from_numpy(env_ids_np, dtype=wp.int32, device=device) - body_ids = wp.from_numpy(body_ids_np, dtype=wp.int32, device=device) - # Get random torques - torques_np = ( - np.random.uniform(low=-100.0, high=100.0, size=(num_envs_np * num_bodies_np * 3)) - .reshape(num_envs_np, num_bodies_np, 3) - .astype(np.float32) - ) - torques = wp.from_numpy(torques_np, dtype=wp.vec3f, device=device) - # Add torques to wrench composer - wrench_composer.add_forces_and_torques_index(torques=torques, body_ids=body_ids, env_ids=env_ids) - # Add torques to hand-calculated composed torque - hand_calculated_composed_torque_np[env_ids_np[:, None], body_ids_np[None, :], :] += torques_np - # Compose to body frame before checking output - wrench_composer.compose_to_body_frame() - # Get composed torque from wrench composer - composed_torque_np = wrench_composer.out_torque_b.warp.numpy() - assert np.allclose(composed_torque_np, hand_calculated_composed_torque_np, atol=1, rtol=1e-7) - - -@pytest.mark.parametrize("device", test_devices()) -@pytest.mark.parametrize("num_envs", [1, 10, 100, 1000]) -@pytest.mark.parametrize("num_bodies", [1, 3, 5, 10]) -def test_add_forces_at_positions(device: str, num_envs: int, num_bodies: int): - """Test adding forces at local positions (offset from link frame).""" - rng = np.random.default_rng(seed=2) - - for _ in range(10): - # Initialize wrench composer - mock_asset = create_mock_asset(num_envs, num_bodies, device) - wrench_composer = WrenchComposer(mock_asset) - # Initialize hand-calculated composed force - hand_calculated_composed_force_np = np.zeros((num_envs, num_bodies, 3), dtype=np.float32) - # Initialize hand-calculated composed torque - hand_calculated_composed_torque_np = np.zeros((num_envs, num_bodies, 3), dtype=np.float32) - for _ in range(10): - # Get random number of envs and bodies and their indices - num_envs_np = rng.integers(1, num_envs, endpoint=True) - num_bodies_np = rng.integers(1, num_bodies, endpoint=True) - env_ids_np = rng.choice(num_envs, size=num_envs_np, replace=False) - body_ids_np = rng.choice(num_bodies, size=num_bodies_np, replace=False) - # Convert to warp arrays - env_ids = wp.from_numpy(env_ids_np, dtype=wp.int32, device=device) - body_ids = wp.from_numpy(body_ids_np, dtype=wp.int32, device=device) - # Get random forces - forces_np = ( - np.random.uniform(low=-100.0, high=100.0, size=(num_envs_np * num_bodies_np * 3)) - .reshape(num_envs_np, num_bodies_np, 3) - .astype(np.float32) - ) - positions_np = ( - np.random.uniform(low=-100.0, high=100.0, size=(num_envs_np * num_bodies_np * 3)) - .reshape(num_envs_np, num_bodies_np, 3) - .astype(np.float32) - ) - forces = wp.from_numpy(forces_np, dtype=wp.vec3f, device=device) - positions = wp.from_numpy(positions_np, dtype=wp.vec3f, device=device) - # Add forces at positions to wrench composer - wrench_composer.add_forces_and_torques_index( - forces=forces, positions=positions, body_ids=body_ids, env_ids=env_ids - ) - # Add forces to hand-calculated composed force - hand_calculated_composed_force_np[env_ids_np[:, None], body_ids_np[None, :], :] += forces_np - # Add torques to hand-calculated composed torque: torque = cross(position, force) - torques_from_forces = np.cross(positions_np, forces_np) - for i in range(num_envs_np): - for j in range(num_bodies_np): - hand_calculated_composed_torque_np[env_ids_np[i], body_ids_np[j], :] += torques_from_forces[i, j, :] - - # Compose to body frame before checking output - wrench_composer.compose_to_body_frame() - # Get composed force from wrench composer - composed_force_np = wrench_composer.out_force_b.warp.numpy() - assert np.allclose(composed_force_np, hand_calculated_composed_force_np, atol=1, rtol=1e-7) - # Get composed torque from wrench composer - composed_torque_np = wrench_composer.out_torque_b.warp.numpy() - assert np.allclose(composed_torque_np, hand_calculated_composed_torque_np, atol=1, rtol=1e-7) - - -@pytest.mark.parametrize("device", test_devices()) -@pytest.mark.parametrize("num_envs", [1, 10, 100, 1000]) -@pytest.mark.parametrize("num_bodies", [1, 3, 5, 10]) -def test_add_torques_at_position(device: str, num_envs: int, num_bodies: int): - rng = np.random.default_rng(seed=3) - - for _ in range(10): - mock_asset = create_mock_asset(num_envs, num_bodies, device) - wrench_composer = WrenchComposer(mock_asset) - # Initialize hand-calculated composed torque - hand_calculated_composed_torque_np = np.zeros((num_envs, num_bodies, 3), dtype=np.float32) - for _ in range(10): - # Get random number of envs and bodies and their indices - num_envs_np = rng.integers(1, num_envs, endpoint=True) - num_bodies_np = rng.integers(1, num_bodies, endpoint=True) - env_ids_np = rng.choice(num_envs, size=num_envs_np, replace=False) - body_ids_np = rng.choice(num_bodies, size=num_bodies_np, replace=False) - # Convert to warp arrays - env_ids = wp.from_numpy(env_ids_np, dtype=wp.int32, device=device) - body_ids = wp.from_numpy(body_ids_np, dtype=wp.int32, device=device) - # Get random torques - torques_np = ( - np.random.uniform(low=-100.0, high=100.0, size=(num_envs_np * num_bodies_np * 3)) - .reshape(num_envs_np, num_bodies_np, 3) - .astype(np.float32) - ) - positions_np = ( - np.random.uniform(low=-100.0, high=100.0, size=(num_envs_np * num_bodies_np * 3)) - .reshape(num_envs_np, num_bodies_np, 3) - .astype(np.float32) - ) - torques = wp.from_numpy(torques_np, dtype=wp.vec3f, device=device) - positions = wp.from_numpy(positions_np, dtype=wp.vec3f, device=device) - # Add torques at positions to wrench composer - wrench_composer.add_forces_and_torques_index( - torques=torques, positions=positions, body_ids=body_ids, env_ids=env_ids - ) - # Add torques to hand-calculated composed torque - hand_calculated_composed_torque_np[env_ids_np[:, None], body_ids_np[None, :], :] += torques_np - # Compose to body frame before checking output - wrench_composer.compose_to_body_frame() - # Get composed torque from wrench composer - composed_torque_np = wrench_composer.out_torque_b.warp.numpy() - assert np.allclose(composed_torque_np, hand_calculated_composed_torque_np, atol=1, rtol=1e-7) - - -@pytest.mark.parametrize("device", test_devices()) -@pytest.mark.parametrize("num_envs", [1, 10, 100, 1000]) -@pytest.mark.parametrize("num_bodies", [1, 3, 5, 10]) -def test_add_forces_and_torques_at_position(device: str, num_envs: int, num_bodies: int): - """Test adding forces and torques at local positions.""" +@pytest.mark.parametrize( + "num_envs, num_bodies, use_forces, use_torques, use_positions", + [ + pytest.param(7, 3, True, False, False, id="forces"), + pytest.param(7, 3, False, True, False, id="torques"), + pytest.param(7, 3, True, False, True, id="forces_at_positions"), + pytest.param(7, 3, False, True, True, id="torques_ignore_positions"), + pytest.param(7, 3, True, True, True, id="forces_and_torques_at_positions"), + pytest.param(1, 1, True, True, True, id="forces_and_torques_at_positions_single_body"), + ], +) +def test_add_forces_and_torques_at_position( + device: str, num_envs: int, num_bodies: int, use_forces: bool, use_torques: bool, use_positions: bool +): + """Test accumulating local forces and torques, optionally at local positions, over random index subsets.""" rng = np.random.default_rng(seed=4) + mock_asset = create_mock_asset(num_envs, num_bodies, device) + wrench_composer = WrenchComposer(mock_asset) + # Initialize hand-calculated composed force and torque + hand_calculated_composed_force_np = np.zeros((num_envs, num_bodies, 3), dtype=np.float32) + hand_calculated_composed_torque_np = np.zeros((num_envs, num_bodies, 3), dtype=np.float32) for _ in range(10): - mock_asset = create_mock_asset(num_envs, num_bodies, device) - wrench_composer = WrenchComposer(mock_asset) - # Initialize hand-calculated composed force and torque - hand_calculated_composed_force_np = np.zeros((num_envs, num_bodies, 3), dtype=np.float32) - hand_calculated_composed_torque_np = np.zeros((num_envs, num_bodies, 3), dtype=np.float32) - for _ in range(10): - # Get random number of envs and bodies and their indices - num_envs_np = rng.integers(1, num_envs, endpoint=True) - num_bodies_np = rng.integers(1, num_bodies, endpoint=True) - env_ids_np = rng.choice(num_envs, size=num_envs_np, replace=False) - body_ids_np = rng.choice(num_bodies, size=num_bodies_np, replace=False) - # Convert to warp arrays - env_ids = wp.from_numpy(env_ids_np, dtype=wp.int32, device=device) - body_ids = wp.from_numpy(body_ids_np, dtype=wp.int32, device=device) - # Get random forces and torques - forces_np = ( - np.random.uniform(low=-100.0, high=100.0, size=(num_envs_np * num_bodies_np * 3)) - .reshape(num_envs_np, num_bodies_np, 3) - .astype(np.float32) - ) - torques_np = ( - np.random.uniform(low=-100.0, high=100.0, size=(num_envs_np * num_bodies_np * 3)) - .reshape(num_envs_np, num_bodies_np, 3) - .astype(np.float32) - ) - positions_np = ( - np.random.uniform(low=-100.0, high=100.0, size=(num_envs_np * num_bodies_np * 3)) - .reshape(num_envs_np, num_bodies_np, 3) - .astype(np.float32) - ) - forces = wp.from_numpy(forces_np, dtype=wp.vec3f, device=device) - torques = wp.from_numpy(torques_np, dtype=wp.vec3f, device=device) - positions = wp.from_numpy(positions_np, dtype=wp.vec3f, device=device) - # Add forces and torques at positions to wrench composer - wrench_composer.add_forces_and_torques_index( - forces=forces, torques=torques, positions=positions, body_ids=body_ids, env_ids=env_ids - ) - # Add forces to hand-calculated composed force - hand_calculated_composed_force_np[env_ids_np[:, None], body_ids_np[None, :], :] += forces_np - # Add torques to hand-calculated composed torque: torque = cross(position, force) + torque - torques_from_forces = np.cross(positions_np, forces_np) - for i in range(num_envs_np): - for j in range(num_bodies_np): - hand_calculated_composed_torque_np[env_ids_np[i], body_ids_np[j], :] += torques_from_forces[i, j, :] - hand_calculated_composed_torque_np[env_ids_np[:, None], body_ids_np[None, :], :] += torques_np - # Compose to body frame before checking output - wrench_composer.compose_to_body_frame() - # Get composed force from wrench composer - composed_force_np = wrench_composer.out_force_b.warp.numpy() - assert np.allclose(composed_force_np, hand_calculated_composed_force_np, atol=1, rtol=1e-7) - # Get composed torque from wrench composer - composed_torque_np = wrench_composer.out_torque_b.warp.numpy() - assert np.allclose(composed_torque_np, hand_calculated_composed_torque_np, atol=1, rtol=1e-7) - - -@pytest.mark.parametrize("device", test_devices()) -@pytest.mark.parametrize("num_envs", [1, 10, 100, 1000]) -@pytest.mark.parametrize("num_bodies", [1, 3, 5, 10]) -def test_wrench_composer_reset(device: str, num_envs: int, num_bodies: int): - rng = np.random.default_rng(seed=5) - for _ in range(10): - mock_asset = create_mock_asset(num_envs, num_bodies, device) - wrench_composer = WrenchComposer(mock_asset) # Get random number of envs and bodies and their indices num_envs_np = rng.integers(1, num_envs, endpoint=True) num_bodies_np = rng.integers(1, num_bodies, endpoint=True) env_ids_np = rng.choice(num_envs, size=num_envs_np, replace=False) body_ids_np = rng.choice(num_bodies, size=num_bodies_np, replace=False) - # Convert to warp arrays - env_ids = wp.from_numpy(env_ids_np, dtype=wp.int32, device=device) - body_ids = wp.from_numpy(body_ids_np, dtype=wp.int32, device=device) - # Get random forces and torques - forces_np = ( - np.random.uniform(low=-100.0, high=100.0, size=(num_envs_np * num_bodies_np * 3)) - .reshape(num_envs_np, num_bodies_np, 3) - .astype(np.float32) + selected = (env_ids_np[:, None], body_ids_np[None, :]) + # Get random forces, torques, and positions + forces_np, torques_np, positions_np = rng.uniform(-100.0, 100.0, (3, num_envs_np, num_bodies_np, 3)).astype( + np.float32 ) - torques_np = ( - np.random.uniform(low=-100.0, high=100.0, size=(num_envs_np * num_bodies_np * 3)) - .reshape(num_envs_np, num_bodies_np, 3) - .astype(np.float32) + wrench = {} + if use_forces: + wrench["forces"] = wp.from_numpy(forces_np, dtype=wp.vec3f, device=device) + hand_calculated_composed_force_np[selected] += forces_np + if use_torques: + wrench["torques"] = wp.from_numpy(torques_np, dtype=wp.vec3f, device=device) + hand_calculated_composed_torque_np[selected] += torques_np + if use_positions: + wrench["positions"] = wp.from_numpy(positions_np, dtype=wp.vec3f, device=device) + # torque = cross(position, force); positions are ignored for torque-only input + if use_forces: + hand_calculated_composed_torque_np[selected] += np.cross(positions_np, forces_np) + # Add the wrench to the wrench composer + wrench_composer.add_forces_and_torques_index( + **wrench, + body_ids=wp.from_numpy(body_ids_np, dtype=wp.int32, device=device), + env_ids=wp.from_numpy(env_ids_np, dtype=wp.int32, device=device), ) - forces = wp.from_numpy(forces_np, dtype=wp.vec3f, device=device) - torques = wp.from_numpy(torques_np, dtype=wp.vec3f, device=device) - # Add forces and torques to wrench composer - wrench_composer.add_forces_and_torques_index(forces=forces, torques=torques, body_ids=body_ids, env_ids=env_ids) - # Reset wrench composer - wrench_composer.reset() - # Check all 7 buffers are zero (5 input + 2 output) - zeros = np.zeros((num_envs, num_bodies, 3), dtype=np.float32) - assert np.allclose(wrench_composer.global_force_w.numpy(), zeros, atol=1, rtol=1e-7) - assert np.allclose(wrench_composer.global_torque_w.numpy(), zeros, atol=1, rtol=1e-7) - assert np.allclose(wrench_composer.global_force_at_com_w.numpy(), zeros, atol=1, rtol=1e-7) - assert np.allclose(wrench_composer.local_force_b.numpy(), zeros, atol=1, rtol=1e-7) - assert np.allclose(wrench_composer.local_torque_b.numpy(), zeros, atol=1, rtol=1e-7) - assert np.allclose(wrench_composer.out_force_b.warp.numpy(), zeros, atol=1, rtol=1e-7) - assert np.allclose(wrench_composer.out_torque_b.warp.numpy(), zeros, atol=1, rtol=1e-7) + # Compose to body frame before checking output + wrench_composer.compose_to_body_frame() + composed_force_np = wrench_composer.out_force_b.warp.numpy() + composed_torque_np = wrench_composer.out_torque_b.warp.numpy() + np.testing.assert_allclose(composed_force_np, hand_calculated_composed_force_np, atol=1e-2, rtol=1e-5) + np.testing.assert_allclose(composed_torque_np, hand_calculated_composed_torque_np, atol=1e-2, rtol=1e-5) # ============================================================================ @@ -435,10 +205,9 @@ def test_wrench_composer_reset(device: str, num_envs: int, num_bodies: int): @pytest.mark.parametrize("device", test_devices()) -@pytest.mark.parametrize("num_envs", [1, 10, 100]) -@pytest.mark.parametrize("num_bodies", [1, 3, 5]) -def test_global_forces_with_rotation(device: str, num_envs: int, num_bodies: int): +def test_global_forces_with_rotation(device: str): """Test that global forces are correctly rotated to the local frame.""" + num_envs, num_bodies = 7, 3 rng = np.random.default_rng(seed=10) for method in ( @@ -485,10 +254,9 @@ def test_global_forces_with_rotation(device: str, num_envs: int, num_bodies: int @pytest.mark.parametrize("device", test_devices()) -@pytest.mark.parametrize("num_envs", [1, 10, 100]) -@pytest.mark.parametrize("num_bodies", [1, 3, 5]) -def test_global_torques_with_rotation(device: str, num_envs: int, num_bodies: int): +def test_global_torques_with_rotation(device: str): """Test that global torques are correctly rotated to the local frame.""" + num_envs, num_bodies = 7, 3 rng = np.random.default_rng(seed=11) for method in ( @@ -535,10 +303,9 @@ def test_global_torques_with_rotation(device: str, num_envs: int, num_bodies: in @pytest.mark.parametrize("device", test_devices()) -@pytest.mark.parametrize("num_envs", [1, 10, 50]) -@pytest.mark.parametrize("num_bodies", [1, 3, 5]) -def test_global_forces_at_global_position(device: str, num_envs: int, num_bodies: int): +def test_global_forces_at_global_position(device: str): """Test global forces at global positions with full coordinate transformation.""" + num_envs, num_bodies = 7, 3 rng = np.random.default_rng(seed=12) for method in ( @@ -603,48 +370,6 @@ def test_global_forces_at_global_position(device: str, num_envs: int, num_bodies ) -@pytest.mark.parametrize("device", test_devices()) -def test_local_vs_global_identity_quaternion(device: str): - """Test that local and global give same result with identity quaternion and zero position.""" - rng = np.random.default_rng(seed=13) - num_envs, num_bodies = 10, 5 - - # Create mock with identity pose (default) - mock_asset_local = create_mock_asset(num_envs, num_bodies, device) - mock_asset_global = create_mock_asset(num_envs, num_bodies, device) - - wrench_composer_local = WrenchComposer(mock_asset_local) - wrench_composer_global = WrenchComposer(mock_asset_global) - - # Generate random forces and torques - forces_np = rng.uniform(-100.0, 100.0, (num_envs, num_bodies, 3)).astype(np.float32) - torques_np = rng.uniform(-100.0, 100.0, (num_envs, num_bodies, 3)).astype(np.float32) - forces = wp.from_numpy(forces_np, dtype=wp.vec3f, device=device) - torques = wp.from_numpy(torques_np, dtype=wp.vec3f, device=device) - - # Apply as local - wrench_composer_local.add_forces_and_torques_index(forces=forces, torques=torques, is_global=False) - - # Apply as global (should be same with identity quaternion) - wrench_composer_global.add_forces_and_torques_index(forces=forces, torques=torques, is_global=True) - - # Compose to body frame before checking output - wrench_composer_local.compose_to_body_frame() - wrench_composer_global.compose_to_body_frame() - - # Results should be identical - assert np.allclose( - wrench_composer_local.out_force_b.warp.numpy(), - wrench_composer_global.out_force_b.warp.numpy(), - atol=1e-6, - ) - assert np.allclose( - wrench_composer_local.out_torque_b.warp.numpy(), - wrench_composer_global.out_torque_b.warp.numpy(), - atol=1e-6, - ) - - @pytest.mark.parametrize("device", test_devices()) def test_90_degree_rotation_global_force(device: str): """Test global force with a known 90-degree rotation for easy verification.""" @@ -731,10 +456,9 @@ def test_composition_mixed_local_and_global(device: str): @pytest.mark.parametrize("device", test_devices()) -@pytest.mark.parametrize("num_envs", [1, 10, 50]) -@pytest.mark.parametrize("num_bodies", [1, 3, 5]) -def test_local_forces_at_local_position(device: str, num_envs: int, num_bodies: int): +def test_local_forces_at_local_position(device: str): """Test local forces at local positions (offset from link frame).""" + num_envs, num_bodies = 7, 3 rng = np.random.default_rng(seed=15) for method in ( @@ -780,59 +504,15 @@ def test_local_forces_at_local_position(device: str, num_envs: int, num_bodies: assert np.allclose(composed_torque_np, expected_torques, atol=1e-4, rtol=1e-5) -@pytest.mark.parametrize("device", test_devices()) -def test_global_force_at_link_origin_no_torque(device: str): - """Test that a global force applied at the link origin produces no torque.""" - rng = np.random.default_rng(seed=16) - num_envs, num_bodies = 5, 3 - - # Create random link poses - link_pos_np = rng.uniform(-10.0, 10.0, (num_envs, num_bodies, 3)).astype(np.float32) - link_quat_np = random_unit_quaternion_np(rng, (num_envs, num_bodies)) - link_pos_torch = torch.from_numpy(link_pos_np) - link_quat_torch = torch.from_numpy(link_quat_np) - - mock_asset = create_mock_asset(num_envs, num_bodies, device, link_pos=link_pos_torch, link_quat=link_quat_torch) - wrench_composer = WrenchComposer(mock_asset) - - # Generate random global forces - forces_global_np = rng.uniform(-100.0, 100.0, (num_envs, num_bodies, 3)).astype(np.float32) - forces_global = wp.from_numpy(forces_global_np, dtype=wp.vec3f, device=device) - - # Position = link position (so offset is zero) - positions_at_link = wp.from_numpy(link_pos_np, dtype=wp.vec3f, device=device) - - # Apply global forces at link origin - wrench_composer.add_forces_and_torques_index(forces=forces_global, positions=positions_at_link, is_global=True) - - # Expected: force rotated to local, torque = 0 (since position offset is zero) - expected_forces = quat_rotate_inv_np(link_quat_np, forces_global_np) - expected_torques = np.zeros((num_envs, num_bodies, 3), dtype=np.float32) - - # Check raw global force buffer - global_force_np = wrench_composer.global_force_w.numpy() - assert np.allclose(global_force_np, forces_global_np, atol=1e-4, rtol=1e-5) - - # Compose to body frame before checking output - wrench_composer.compose_to_body_frame() - - composed_force_np = wrench_composer.out_force_b.warp.numpy() - composed_torque_np = wrench_composer.out_torque_b.warp.numpy() - - assert np.allclose(composed_force_np, expected_forces, atol=1e-4, rtol=1e-5) - assert np.allclose(composed_torque_np, expected_torques, atol=1e-4, rtol=1e-5) - - # ============================================================================ # add_raw_buffers_from Tests # ============================================================================ @pytest.mark.parametrize("device", test_devices()) -@pytest.mark.parametrize("num_envs", [1, 10, 100]) -@pytest.mark.parametrize("num_bodies", [1, 3, 5]) -def test_add_raw_buffers_from(device: str, num_envs: int, num_bodies: int): +def test_add_raw_buffers_from(device: str): """Test that add_raw_buffers_from merges all five input buffers correctly.""" + num_envs, num_bodies = 7, 3 rng = np.random.default_rng(seed=20) # Create two composers with random link poses @@ -923,6 +603,11 @@ def test_add_raw_buffers_from_inactive_is_noop(device: str): assert np.allclose(composer_a.local_force_b.numpy(), local_force_before, atol=1e-7) + # Merging an inactive source must not activate an inactive target + composer_c = WrenchComposer(create_mock_asset(num_envs, num_bodies, device)) + composer_c.add_raw_buffers_from(composer_b) + assert not composer_c.active + # ============================================================================ # Mask-based API Tests @@ -930,10 +615,9 @@ def test_add_raw_buffers_from_inactive_is_noop(device: str): @pytest.mark.parametrize("device", test_devices()) -@pytest.mark.parametrize("num_envs", [1, 10, 100]) -@pytest.mark.parametrize("num_bodies", [1, 3, 5]) -def test_add_forces_mask(device: str, num_envs: int, num_bodies: int): +def test_add_forces_mask(device: str): """Test that add_forces_and_torques_mask produces the same result as the index variant.""" + num_envs, num_bodies = 7, 3 rng = np.random.default_rng(seed=30) for _ in range(5): @@ -984,56 +668,6 @@ def test_add_forces_mask(device: str, num_envs: int, num_bodies: int): ), f"Mask vs index torque mismatch (envs={num_envs}, bodies={num_bodies})" -@pytest.mark.parametrize("device", test_devices()) -@pytest.mark.parametrize("num_envs", [1, 10, 100]) -@pytest.mark.parametrize("num_bodies", [1, 3, 5]) -def test_add_forces_mask_global(device: str, num_envs: int, num_bodies: int): - """Test mask-based API with global forces and positions.""" - rng = np.random.default_rng(seed=31) - - # Random link poses - link_pos_np = rng.uniform(-10.0, 10.0, (num_envs, num_bodies, 3)).astype(np.float32) - link_quat_np = random_unit_quaternion_np(rng, (num_envs, num_bodies)) - link_pos_torch = torch.from_numpy(link_pos_np) - link_quat_torch = torch.from_numpy(link_quat_np) - - # Select all envs and bodies to keep comparison simple - forces_np = rng.uniform(-100.0, 100.0, (num_envs, num_bodies, 3)).astype(np.float32) - positions_np = rng.uniform(-10.0, 10.0, (num_envs, num_bodies, 3)).astype(np.float32) - - # Index-based - mock_idx = create_mock_asset(num_envs, num_bodies, device, link_pos=link_pos_torch, link_quat=link_quat_torch) - composer_idx = WrenchComposer(mock_idx) - composer_idx.add_forces_and_torques_index( - forces=wp.from_numpy(forces_np, dtype=wp.vec3f, device=device), - positions=wp.from_numpy(positions_np, dtype=wp.vec3f, device=device), - is_global=True, - ) - - # Mask-based (all-True masks) - mock_mask = create_mock_asset(num_envs, num_bodies, device, link_pos=link_pos_torch, link_quat=link_quat_torch) - composer_mask = WrenchComposer(mock_mask) - env_mask = wp.from_numpy(np.ones(num_envs, dtype=np.bool_), dtype=wp.bool, device=device) - body_mask = wp.from_numpy(np.ones(num_bodies, dtype=np.bool_), dtype=wp.bool, device=device) - composer_mask.add_forces_and_torques_mask( - forces=wp.from_numpy(forces_np, dtype=wp.vec3f, device=device), - positions=wp.from_numpy(positions_np, dtype=wp.vec3f, device=device), - env_mask=env_mask, - body_mask=body_mask, - is_global=True, - ) - - composer_idx.compose_to_body_frame() - composer_mask.compose_to_body_frame() - - assert np.allclose( - composer_idx.out_force_b.warp.numpy(), composer_mask.out_force_b.warp.numpy(), atol=1e-4, rtol=1e-5 - ), "Mask vs index global force mismatch" - assert np.allclose( - composer_idx.out_torque_b.warp.numpy(), composer_mask.out_torque_b.warp.numpy(), atol=1e-4, rtol=1e-5 - ), "Mask vs index global torque mismatch" - - # ============================================================================ # set_forces_and_torques_index Tests # ============================================================================ @@ -1082,8 +716,9 @@ def test_index_dtype_combinations_preserve_selected_wrench_cells( @pytest.mark.parametrize("device", test_devices()) -def test_set_forces_overwrites_previous_add(device: str): - """Test that set_forces_and_torques_index clears previously accumulated values.""" +@pytest.mark.parametrize("method", ["set_forces_and_torques_index", "set_forces_and_torques_mask"]) +def test_set_forces_overwrites_previous_add(device: str, method: str): + """Test that set_forces_and_torques_index/mask clears previously accumulated values.""" num_envs, num_bodies = 4, 2 rng = np.random.default_rng(seed=40) @@ -1098,7 +733,7 @@ def test_set_forces_overwrites_previous_add(device: str): # Now set new forces -- should replace, not accumulate forces_b_np = rng.uniform(-100.0, 100.0, (num_envs, num_bodies, 3)).astype(np.float32) - composer.set_forces_and_torques_index( + getattr(composer, method)( forces=wp.from_numpy(forces_b_np, dtype=wp.vec3f, device=device), ) @@ -1106,7 +741,7 @@ def test_set_forces_overwrites_previous_add(device: str): assert is_global is False # Output should match forces_b only (forces_a should be gone) - assert np.allclose(force.numpy(), forces_b_np, atol=1e-4, rtol=1e-5), "set_forces did not clear previous add" + assert np.allclose(force.numpy(), forces_b_np, atol=1e-4, rtol=1e-5), f"{method} did not clear previous add" @pytest.mark.parametrize("device", test_devices()) @@ -1231,20 +866,37 @@ def test_full_reset_clears_active_flag(device: str, env_ids: slice | None): composer = WrenchComposer(mock_asset, supports_world_at_com=True) forces_np = np.ones((num_envs, num_bodies, 3), dtype=np.float32) + # Not parallel to the force, so the global torque buffer is non-zero before the reset. + positions_np = np.zeros((num_envs, num_bodies, 3), dtype=np.float32) + positions_np[..., 0] = 1.0 composer.add_forces_and_torques_index( forces=wp.from_numpy(forces_np, dtype=wp.vec3f, device=device), + torques=wp.from_numpy(forces_np, dtype=wp.vec3f, device=device), ) composer.add_forces_and_torques_index( forces=wp.from_numpy(forces_np, dtype=wp.vec3f, device=device), - positions=wp.from_numpy(forces_np, dtype=wp.vec3f, device=device), + torques=wp.from_numpy(forces_np, dtype=wp.vec3f, device=device), + positions=wp.from_numpy(positions_np, dtype=wp.vec3f, device=device), is_global=True, ) + composer.add_forces_and_torques_index( + forces=wp.from_numpy(forces_np, dtype=wp.vec3f, device=device), is_global=True + ) composer.get_forces_and_torques() assert composer.active composer.reset(env_ids=env_ids) assert not composer.active assert not composer._dirty + # Check all 7 buffers are zero (5 input + 2 output) + zeros = np.zeros((num_envs, num_bodies, 3), dtype=np.float32) + np.testing.assert_array_equal(composer.global_force_w.numpy(), zeros) + np.testing.assert_array_equal(composer.global_torque_w.numpy(), zeros) + np.testing.assert_array_equal(composer.global_force_at_com_w.numpy(), zeros) + np.testing.assert_array_equal(composer.local_force_b.numpy(), zeros) + np.testing.assert_array_equal(composer.local_torque_b.numpy(), zeros) + np.testing.assert_array_equal(composer.out_force_b.warp.numpy(), zeros) + np.testing.assert_array_equal(composer.out_torque_b.warp.numpy(), zeros) force, torque, is_global = _get_wrench_without_pose_reads(composer) assert is_global is False @@ -1262,42 +914,25 @@ def test_full_reset_clears_active_flag(device: str, env_ids: slice | None): @pytest.mark.parametrize("device", test_devices()) -def test_composed_force_emits_deprecation_warning(device: str): - """Test that accessing composed_force emits a DeprecationWarning.""" +@pytest.mark.parametrize( + "deprecated_name, name, input_name", + [("composed_force", "out_force_b", "forces"), ("composed_torque", "out_torque_b", "torques")], +) +def test_composed_wrench_emits_deprecation_warning(device: str, deprecated_name: str, name: str, input_name: str): + """Test that accessing composed_force/composed_torque emits a DeprecationWarning and aliases the output.""" num_envs, num_bodies = 2, 1 mock_asset = create_mock_asset(num_envs, num_bodies, device) composer = WrenchComposer(mock_asset) - forces_np = np.array([[[1.0, 2.0, 3.0]], [[4.0, 5.0, 6.0]]], dtype=np.float32) - composer.add_forces_and_torques_index( - forces=wp.from_numpy(forces_np, dtype=wp.vec3f, device=device), - ) - - with pytest.warns(DeprecationWarning, match="composed_force.*is deprecated"): - result = composer.composed_force - - # Should return the same data as out_force_b - assert np.allclose(result.warp.numpy(), composer.out_force_b.warp.numpy(), atol=1e-7) - - -@pytest.mark.parametrize("device", test_devices()) -def test_composed_torque_emits_deprecation_warning(device: str): - """Test that accessing composed_torque emits a DeprecationWarning.""" - num_envs, num_bodies = 2, 1 - - mock_asset = create_mock_asset(num_envs, num_bodies, device) - composer = WrenchComposer(mock_asset) - - torques_np = np.array([[[1.0, 2.0, 3.0]], [[4.0, 5.0, 6.0]]], dtype=np.float32) - composer.add_forces_and_torques_index( - torques=wp.from_numpy(torques_np, dtype=wp.vec3f, device=device), - ) + values_np = np.array([[[1.0, 2.0, 3.0]], [[4.0, 5.0, 6.0]]], dtype=np.float32) + composer.add_forces_and_torques_index(**{input_name: wp.from_numpy(values_np, dtype=wp.vec3f, device=device)}) - with pytest.warns(DeprecationWarning, match="composed_torque.*is deprecated"): - result = composer.composed_torque + with pytest.warns(DeprecationWarning, match=f"{deprecated_name}.*is deprecated"): + result = getattr(composer, deprecated_name) - assert np.allclose(result.warp.numpy(), composer.out_torque_b.warp.numpy(), atol=1e-7) + # Should return the same data as the new output property + assert np.allclose(result.warp.numpy(), getattr(composer, name).warp.numpy(), atol=1e-7) @pytest.mark.parametrize("device", test_devices()) @@ -1325,36 +960,6 @@ def test_deprecated_add_forces_and_torques_emits_warning(device: str): # ============================================================================ -@pytest.mark.parametrize("device", test_devices()) -def test_set_forces_mask_overwrites_previous_add(device: str): - """Test that set_forces_and_torques_mask clears previously accumulated values.""" - num_envs, num_bodies = 4, 2 - rng = np.random.default_rng(seed=60) - - mock_asset = create_mock_asset(num_envs, num_bodies, device) - composer = WrenchComposer(mock_asset) - - # Accumulate some forces via add - forces_a_np = rng.uniform(-100.0, 100.0, (num_envs, num_bodies, 3)).astype(np.float32) - composer.add_forces_and_torques_index( - forces=wp.from_numpy(forces_a_np, dtype=wp.vec3f, device=device), - ) - - # Now set new forces via mask -- should replace, not accumulate - forces_b_np = rng.uniform(-100.0, 100.0, (num_envs, num_bodies, 3)).astype(np.float32) - composer.set_forces_and_torques_mask( - forces=wp.from_numpy(forces_b_np, dtype=wp.vec3f, device=device), - ) - - force, _, is_global = _get_wrench_without_pose_reads(composer) - assert is_global is False - - # Output should match forces_b only (forces_a should be gone) - assert np.allclose(force.numpy(), forces_b_np, atol=1e-4, rtol=1e-5), ( - "set_forces_and_torques_mask did not clear previous add" - ) - - @pytest.mark.parametrize("device", test_devices()) def test_set_forces_mask_clears_targeted_envs_only(device: str): """Test that set_forces_and_torques_mask clears only the masked environments.""" @@ -1425,59 +1030,15 @@ def test_set_forces_mask_clears_targeted_envs_only(device: str): ) -@pytest.mark.parametrize("device", test_devices()) -def test_set_forces_mask_matches_set_forces_index(device: str): - """Test that set_forces_and_torques_mask produces the same result as the index variant.""" - num_envs, num_bodies = 6, 3 - rng = np.random.default_rng(seed=62) - - # Random link poses - link_pos_np = rng.uniform(-10.0, 10.0, (num_envs, num_bodies, 3)).astype(np.float32) - link_quat_np = random_unit_quaternion_np(rng, (num_envs, num_bodies)) - link_pos_torch = torch.from_numpy(link_pos_np) - link_quat_torch = torch.from_numpy(link_quat_np) - - # Use all envs/bodies to compare - forces_np = rng.uniform(-100.0, 100.0, (num_envs, num_bodies, 3)).astype(np.float32) - positions_np = rng.uniform(-10.0, 10.0, (num_envs, num_bodies, 3)).astype(np.float32) - - # Index-based - mock_idx = create_mock_asset(num_envs, num_bodies, device, link_pos=link_pos_torch, link_quat=link_quat_torch) - composer_idx = WrenchComposer(mock_idx) - composer_idx.set_forces_and_torques_index( - forces=wp.from_numpy(forces_np, dtype=wp.vec3f, device=device), - positions=wp.from_numpy(positions_np, dtype=wp.vec3f, device=device), - is_global=True, - ) - - # Mask-based (all-True) - mock_mask = create_mock_asset(num_envs, num_bodies, device, link_pos=link_pos_torch, link_quat=link_quat_torch) - composer_mask = WrenchComposer(mock_mask) - composer_mask.set_forces_and_torques_mask( - forces=wp.from_numpy(forces_np, dtype=wp.vec3f, device=device), - positions=wp.from_numpy(positions_np, dtype=wp.vec3f, device=device), - is_global=True, - ) - - composer_idx.compose_to_body_frame() - composer_mask.compose_to_body_frame() - - assert np.allclose( - composer_idx.out_force_b.warp.numpy(), composer_mask.out_force_b.warp.numpy(), atol=1e-4, rtol=1e-5 - ), "set mask vs index force mismatch" - assert np.allclose( - composer_idx.out_torque_b.warp.numpy(), composer_mask.out_torque_b.warp.numpy(), atol=1e-4, rtol=1e-5 - ), "set mask vs index torque mismatch" - - # ============================================================================ # Lazy Composition (_ensure_composed) Tests # ============================================================================ @pytest.mark.parametrize("device", test_devices()) -def test_out_force_b_triggers_lazy_composition(device: str): - """Test that accessing out_force_b without explicit compose_to_body_frame still returns correct results.""" +@pytest.mark.parametrize("input_name, output_name", [("forces", "out_force_b"), ("torques", "out_torque_b")]) +def test_output_property_triggers_lazy_composition(device: str, input_name: str, output_name: str): + """Test that reading out_force_b/out_torque_b without explicit compose_to_body_frame returns correct results.""" num_envs, num_bodies = 4, 2 rng = np.random.default_rng(seed=70) @@ -1487,83 +1048,43 @@ def test_out_force_b_triggers_lazy_composition(device: str): mock_asset = create_mock_asset(num_envs, num_bodies, device, link_quat=link_quat_torch) composer = WrenchComposer(mock_asset) - forces_global_np = rng.uniform(-100.0, 100.0, (num_envs, num_bodies, 3)).astype(np.float32) + values_global_np = rng.uniform(-100.0, 100.0, (num_envs, num_bodies, 3)).astype(np.float32) composer.add_forces_and_torques_index( - forces=wp.from_numpy(forces_global_np, dtype=wp.vec3f, device=device), + **{input_name: wp.from_numpy(values_global_np, dtype=wp.vec3f, device=device)}, is_global=True, ) # Do NOT call compose_to_body_frame -- rely on lazy composition - expected_forces_local = quat_rotate_inv_np(link_quat_np, forces_global_np) - composed_force_np = composer.out_force_b.warp.numpy() + expected_values_local = quat_rotate_inv_np(link_quat_np, values_global_np) + composed_np = getattr(composer, output_name).warp.numpy() - assert np.allclose(composed_force_np, expected_forces_local, atol=1e-4, rtol=1e-5), ( - "Lazy composition via out_force_b failed" + assert np.allclose(composed_np, expected_values_local, atol=1e-4, rtol=1e-5), ( + f"Lazy composition via {output_name} failed" ) @pytest.mark.parametrize("device", test_devices()) -def test_out_torque_b_triggers_lazy_composition(device: str): - """Test that accessing out_torque_b without explicit compose_to_body_frame still returns correct results.""" - num_envs, num_bodies = 4, 2 - rng = np.random.default_rng(seed=71) - - link_quat_np = random_unit_quaternion_np(rng, (num_envs, num_bodies)) - link_quat_torch = torch.from_numpy(link_quat_np) - - mock_asset = create_mock_asset(num_envs, num_bodies, device, link_quat=link_quat_torch) - composer = WrenchComposer(mock_asset) - - torques_global_np = rng.uniform(-100.0, 100.0, (num_envs, num_bodies, 3)).astype(np.float32) - composer.add_forces_and_torques_index( - torques=wp.from_numpy(torques_global_np, dtype=wp.vec3f, device=device), - is_global=True, - ) - - # Do NOT call compose_to_body_frame -- rely on lazy composition - expected_torques_local = quat_rotate_inv_np(link_quat_np, torques_global_np) - composed_torque_np = composer.out_torque_b.warp.numpy() - - assert np.allclose(composed_torque_np, expected_torques_local, atol=1e-4, rtol=1e-5), ( - "Lazy composition via out_torque_b failed" - ) - - -@pytest.mark.parametrize("device", test_devices()) -def test_lazy_composition_tracks_dirty_flag(device: str): - """Test that the dirty flag is correctly managed through add/compose/add cycles.""" +def test_lazy_composition_reflects_later_adds(device: str): + """Test that an add after a lazy composition is reflected by the next output read.""" num_envs, num_bodies = 2, 1 mock_asset = create_mock_asset(num_envs, num_bodies, device) composer = WrenchComposer(mock_asset) - # Initially clean - assert not composer._dirty - - # After add, dirty forces_np = np.ones((num_envs, num_bodies, 3), dtype=np.float32) composer.add_forces_and_torques_index( forces=wp.from_numpy(forces_np, dtype=wp.vec3f, device=device), ) - assert composer._dirty - - # After accessing out_force_b, clean (lazy compose happened) - _ = composer.out_force_b - assert not composer._dirty + # Reading out_force_b composes lazily + assert np.allclose(composer.out_force_b.warp.numpy(), forces_np, atol=1e-4, rtol=1e-5) - # After another add, dirty again + # Another add must be reflected by the next read of either output composer.add_forces_and_torques_index( forces=wp.from_numpy(forces_np, dtype=wp.vec3f, device=device), + torques=wp.from_numpy(forces_np, dtype=wp.vec3f, device=device), ) - assert composer._dirty - - # Accessing out_torque_b also triggers composition - _ = composer.out_torque_b - assert not composer._dirty - - # Verify accumulated result (2x forces) - expected = 2.0 * forces_np - assert np.allclose(composer.out_force_b.warp.numpy(), expected, atol=1e-4, rtol=1e-5) + assert np.allclose(composer.out_torque_b.warp.numpy(), forces_np, atol=1e-4, rtol=1e-5) + assert np.allclose(composer.out_force_b.warp.numpy(), 2.0 * forces_np, atol=1e-4, rtol=1e-5) @pytest.mark.parametrize("device", test_devices()) @@ -1678,58 +1199,6 @@ def test_global_force_with_com_offset(device: str): assert np.allclose(composer.out_force_b.warp.numpy(), forces_np, atol=1e-4, rtol=1e-5) -@pytest.mark.parametrize("device", test_devices()) -def test_global_force_at_com_no_torque_with_com_offset(device: str): - """Test that a global force at CoM position produces zero torque even with CoM offset.""" - num_envs, num_bodies = 2, 1 - - # Link at origin, CoM offset by [2, 3, 0] - link_pos_np = np.zeros((num_envs, num_bodies, 3), dtype=np.float32) - link_quat_np = np.zeros((num_envs, num_bodies, 4), dtype=np.float32) - link_quat_np[..., 3] = 1.0 - - com_pos_np = np.zeros((num_envs, num_bodies, 3), dtype=np.float32) - com_pos_np[..., 0] = 2.0 - com_pos_np[..., 1] = 3.0 - - mock_asset = create_mock_asset( - num_envs, - num_bodies, - device, - link_pos=torch.from_numpy(link_pos_np), - link_quat=torch.from_numpy(link_quat_np), - ) - com_pose = torch.cat( - ( - torch.from_numpy(com_pos_np), - torch.tensor([0.0, 0.0, 0.0, 1.0]).view(1, 1, 4).expand(num_envs, num_bodies, 4), - ), - dim=-1, - ) - mock_asset.data.set_body_com_pose_w(com_pose) - - composer = WrenchComposer(mock_asset) - - # Apply global force at the CoM position - forces_np = np.zeros((num_envs, num_bodies, 3), dtype=np.float32) - forces_np[..., 2] = 50.0 - positions_np = com_pos_np.copy() - - composer.add_forces_and_torques_index( - forces=wp.from_numpy(forces_np, dtype=wp.vec3f, device=device), - positions=wp.from_numpy(positions_np, dtype=wp.vec3f, device=device), - is_global=True, - ) - - composer.compose_to_body_frame() - - # Torque = cross(com, F) - cross(com, F) = 0 - expected_torque = np.zeros((num_envs, num_bodies, 3), dtype=np.float32) - assert np.allclose(composer.out_torque_b.warp.numpy(), expected_torque, atol=1e-4, rtol=1e-5), ( - "Force at CoM should produce zero torque regardless of CoM offset" - ) - - @pytest.mark.parametrize("device", test_devices()) def test_com_offset_with_rotation(device: str): """Test torque correction with both CoM offset and non-identity rotation.""" @@ -1794,26 +1263,6 @@ def test_com_offset_with_rotation(device: str): # ============================================================================ -@pytest.mark.parametrize("device", test_devices()) -def test_deprecated_set_forces_and_torques_emits_warning(device: str): - """Test that the deprecated set_forces_and_torques wrapper emits a warning and works.""" - num_envs, num_bodies = 4, 2 - rng = np.random.default_rng(seed=80) - - mock_asset = create_mock_asset(num_envs, num_bodies, device) - composer = WrenchComposer(mock_asset) - - forces_np = rng.uniform(-50.0, 50.0, (num_envs, num_bodies, 3)).astype(np.float32) - - with pytest.warns(DeprecationWarning, match="set_forces_and_torques.*is deprecated"): - composer.set_forces_and_torques( - forces=wp.from_numpy(forces_np, dtype=wp.vec3f, device=device), - ) - - composer.compose_to_body_frame() - assert np.allclose(composer.out_force_b.warp.numpy(), forces_np, atol=1e-4, rtol=1e-5) - - @pytest.mark.parametrize("device", test_devices()) def test_deprecated_set_forces_and_torques_clears_previous(device: str): """Test that deprecated set_forces_and_torques actually replaces previous values.""" @@ -1831,7 +1280,7 @@ def test_deprecated_set_forces_and_torques_clears_previous(device: str): # Then set via deprecated method -- should replace forces_b_np = rng.uniform(-50.0, 50.0, (num_envs, num_bodies, 3)).astype(np.float32) - with pytest.warns(DeprecationWarning): + with pytest.warns(DeprecationWarning, match="set_forces_and_torques.*is deprecated"): composer.set_forces_and_torques( forces=wp.from_numpy(forces_b_np, dtype=wp.vec3f, device=device), ) diff --git a/source/isaaclab/test/utils/test_wrench_composer_integration.py b/source/isaaclab/test/utils/test_wrench_composer_integration.py index d86e70e6cb87..8bc27119fb07 100644 --- a/source/isaaclab/test/utils/test_wrench_composer_integration.py +++ b/source/isaaclab/test/utils/test_wrench_composer_integration.py @@ -24,6 +24,7 @@ import isaaclab.sim as sim_utils from isaaclab.assets import RigidObject, RigidObjectCfg from isaaclab.sim import build_simulation_context +from isaaclab.test.utils import DeviceScope, test_devices from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR pytestmark = pytest.mark.integration @@ -58,7 +59,7 @@ def generate_cubes_scene( TORQUE_MAGNITUDE = 1.0 -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("device", test_devices()) def test_global_force_invariant_under_rotation(device): """Test that a permanent global force produces the same acceleration before and after body rotation. @@ -134,7 +135,7 @@ def test_global_force_invariant_under_rotation(device): assert abs(vel_after_phase2[2].item()) < 0.5, f"Unexpected Z velocity: {vel_after_phase2[2].item()}" -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("device", test_devices()) def test_local_force_follows_rotation(device): """Test that a permanent local force rotates with the body. @@ -192,58 +193,7 @@ def test_local_force_follows_rotation(device): ) -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -def test_global_force_at_offset_generates_torque(device): - """Test that a global force applied at an offset from CoM generates the expected torque. - - A global +X force applied at +1m Y offset from CoM should produce: - - Linear acceleration in +X - - Angular acceleration about -Z (from cross product: (0,1,0) × (10,0,0) = (0,0,-10)) - """ - with build_simulation_context(device=device, gravity_enabled=False, auto_add_lighting=True) as sim: - sim._app_control_on_stop_handle = None - cube_object, _ = generate_cubes_scene(num_cubes=1, device=device) - - sim.reset() - - body_ids, _ = cube_object.find_bodies(".*") - - # Force at offset: +1m in Y from CoM (global frame) - forces = torch.zeros(1, len(body_ids), 3, device=device) - forces[..., 0] = FORCE_MAGNITUDE # +X force - - torques = torch.zeros(1, len(body_ids), 3, device=device) - - # Position offset: CoM position + 1m in Y (global frame) - com_pos = cube_object.data.body_com_pos_w.torch[:, body_ids, :3].clone() - positions = com_pos.clone() - positions[..., 1] += 1.0 # +1m Y offset - - cube_object.permanent_wrench_composer.set_forces_and_torques_index( - forces=forces, - torques=torques, - positions=positions, - body_ids=body_ids, - is_global=True, - ) - - # Run 50 steps - for _ in range(50): - cube_object.write_data_to_sim() - sim.step() - cube_object.update(sim.cfg.dt) - - lin_vel = cube_object.data.root_lin_vel_w.torch[0] - ang_vel = cube_object.data.root_ang_vel_w.torch[0] - - # Linear velocity in +X should be positive - assert lin_vel[0].item() > 0.1, f"Expected positive X velocity, got {lin_vel[0].item()}" - - # Angular velocity about Z should be negative (cross product: r × F, r=(0,1,0), F=(10,0,0) -> (0,0,-10)) - assert ang_vel[2].item() < -0.1, f"Expected negative Z angular velocity, got {ang_vel[2].item()}" - - -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("device", test_devices()) def test_global_torque_invariant_under_rotation(device): """Test that a permanent global torque produces the same angular acceleration before and after rotation. @@ -306,7 +256,7 @@ def test_global_torque_invariant_under_rotation(device): ) -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("device", test_devices()) def test_global_force_torque_after_translation(device): """Test that global force torque updates dynamically when the body translates. @@ -412,89 +362,7 @@ def test_global_force_torque_after_translation(device): ) -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -def test_global_force_torque_reverses_on_opposite_side(device): - """Test that dynamic correction produces correct torque sign depending on body position. - - Phase 1: Cube at (-1, 0, 1). Global F=(0, 10, 0) at world point P=(0, 0, 1). - net torque_z = cross(P - link_pos, F)_z = cross((1,0,0), (0,10,0))_z = +10 - → positive Z angular velocity - - Phase 2: Teleport cube to (+1, 0, 1), zero velocity, don't re-apply force. - net torque_z = cross(P - link_pos, F)_z = cross((-1,0,0), (0,10,0))_z = -10 - → negative Z angular velocity - """ - with build_simulation_context(device=device, gravity_enabled=False, auto_add_lighting=True) as sim: - sim._app_control_on_stop_handle = None - cube_object, _ = generate_cubes_scene(num_cubes=1, height=1.0, device=device) - - sim.reset() - - body_ids, _ = cube_object.find_bodies(".*") - - # Move cube to (-1, 0, 1) - root_pose = cube_object.data.root_pose_w.torch.clone() - root_pose[0, 0] = -1.0 - root_pose[0, 1] = 0.0 - root_pose[0, 2] = 1.0 - root_pose[0, 3:7] = torch.tensor([0.0, 0.0, 0.0, 1.0], device=device) # identity (xyzw) - cube_object.write_root_pose_to_sim_index(root_pose=root_pose) - - root_vel = cube_object.data.root_vel_w.torch.clone() - root_vel[0, :] = 0.0 - cube_object.write_root_velocity_to_sim_index(root_velocity=root_vel) - sim.step() - cube_object.update(sim.cfg.dt) - - # Apply permanent global F=(0, 10, 0) at world point P=(0, 0, 1) - forces = torch.zeros(1, len(body_ids), 3, device=device) - forces[..., 1] = FORCE_MAGNITUDE - torques = torch.zeros(1, len(body_ids), 3, device=device) - positions = torch.zeros(1, len(body_ids), 3, device=device) - positions[..., 2] = 1.0 # P = (0, 0, 1) - - cube_object.permanent_wrench_composer.set_forces_and_torques_index( - forces=forces, - torques=torques, - positions=positions, - body_ids=body_ids, - is_global=True, - ) - - # Phase 1: run 50 steps — expect positive Z angular velocity - for _ in range(50): - cube_object.write_data_to_sim() - sim.step() - cube_object.update(sim.cfg.dt) - - omega_z_phase1 = cube_object.data.root_ang_vel_w.torch[0, 2].item() - assert omega_z_phase1 > 0.1, f"Phase 1: expected positive omega_z, got {omega_z_phase1}" - - # Phase 2: Teleport cube to (+1, 0, 1), zero velocity - root_pose2 = cube_object.data.root_pose_w.torch.clone() - root_pose2[0, 0] = 1.0 - root_pose2[0, 1] = 0.0 - root_pose2[0, 2] = 1.0 - root_pose2[0, 3:7] = torch.tensor([0.0, 0.0, 0.0, 1.0], device=device) # identity (xyzw) - cube_object.write_root_pose_to_sim_index(root_pose=root_pose2) - - root_vel2 = cube_object.data.root_vel_w.torch.clone() - root_vel2[0, :] = 0.0 - cube_object.write_root_velocity_to_sim_index(root_velocity=root_vel2) - sim.step() - cube_object.update(sim.cfg.dt) - - # Phase 2: run 50 steps — expect negative Z angular velocity - for _ in range(50): - cube_object.write_data_to_sim() - sim.step() - cube_object.update(sim.cfg.dt) - - omega_z_phase2 = cube_object.data.root_ang_vel_w.torch[0, 2].item() - assert omega_z_phase2 < -0.1, f"Phase 2: expected negative omega_z, got {omega_z_phase2}" - - -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("device", test_devices()) def test_global_force_no_position_no_torque(device): """Test that global force without positions produces no torque (applied at CoM). @@ -550,7 +418,7 @@ def test_global_force_no_position_no_torque(device): assert lin_vel_y > 0.1, f"Expected positive Y velocity from applied force, got {lin_vel_y}" -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("device", test_devices()) def test_multi_cube_different_torques_from_same_force(device): """Test kernel indexing across multiple envs with different CoM positions. @@ -624,7 +492,7 @@ def test_multi_cube_different_torques_from_same_force(device): ) -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("device", test_devices()) def test_global_force_torque_far_from_origin(device): """Test that global force torque correction produces correct physics at large world coordinates. @@ -728,7 +596,7 @@ def test_global_force_torque_far_from_origin(device): ) -@pytest.mark.parametrize("device", ["cuda:0"]) +@pytest.mark.parametrize("device", test_devices(DeviceScope.CUDA)) def test_global_force_no_position_no_rotation_large_offset(device): """Test that a global force without positions produces no rotation at large offsets. @@ -783,63 +651,3 @@ def test_global_force_no_position_no_rotation_large_offset(device): # Check: linear velocity in Y should be positive (force is in +Y) lin_vel = cube_object.data.root_lin_vel_w.torch[0] assert lin_vel[1] > 0.1, f"Expected positive Y velocity from applied force, got {lin_vel[1]}" - - -@pytest.mark.parametrize("device", ["cuda:0"]) -def test_global_force_at_com_position_no_rotation_large_offset(device): - """Test that a global force with position at CoM produces no rotation at large offsets. - - A cube is placed at (2000, 0, 1) and a global force F=(0, 10, 0) is applied - at the cube's position (i.e., at its CoM). This should produce zero torque, - serving as a control test alongside test_global_force_no_position_no_rotation_large_offset. - """ - with build_simulation_context( - device=device, add_ground_plane=False, auto_add_lighting=True, gravity_enabled=False - ) as sim: - sim._app_control_on_stop_handle = None - cube_object, _ = generate_cubes_scene(num_cubes=1, height=1.0, device=device) - - sim.reset() - - body_ids, _ = cube_object.find_bodies(".*") - - # Place cube at large X offset - root_pose = cube_object.data.default_root_pose.torch.clone() - root_pose[0, 0] = 2000.0 - root_pose[0, 1] = 0.0 - root_pose[0, 2] = 1.0 - cube_object.write_root_pose_to_sim_index(root_pose=root_pose) - - root_vel = cube_object.data.default_root_vel.torch.clone() - cube_object.write_root_velocity_to_sim_index(root_velocity=root_vel) - cube_object.reset() - - # Apply global force AT the cube's position (torque should cancel) - forces = torch.zeros(cube_object.num_instances, len(body_ids), 3, device=device) - forces[0, :, 1] = 10.0 - - positions = torch.zeros(cube_object.num_instances, len(body_ids), 3, device=device) - positions[0, :, 0] = 2000.0 - positions[0, :, 2] = 1.0 - - cube_object.permanent_wrench_composer.set_forces_and_torques_index( - forces=forces, - positions=positions, - body_ids=body_ids, - is_global=True, - ) - - for _ in range(50): - cube_object.write_data_to_sim() - sim.step() - cube_object.update(sim.cfg.dt) - - # Force at CoM → no rotation - ang_vel = cube_object.data.root_ang_vel_w.torch[0] - assert torch.allclose(ang_vel, torch.zeros(3, device=device), atol=0.01), ( - f"Expected near-zero angular velocity, got {ang_vel}. " - "Global force at CoM position should not produce torque." - ) - - lin_vel = cube_object.data.root_lin_vel_w.torch[0] - assert lin_vel[1] > 0.1, f"Expected positive Y velocity from applied force, got {lin_vel[1]}" diff --git a/source/isaaclab/test/utils/test_wrench_composer_vs_physx.py b/source/isaaclab/test/utils/test_wrench_composer_vs_physx.py index 60e36dd77393..07bd9cb4efd3 100644 --- a/source/isaaclab/test/utils/test_wrench_composer_vs_physx.py +++ b/source/isaaclab/test/utils/test_wrench_composer_vs_physx.py @@ -29,6 +29,7 @@ import isaaclab.sim as sim_utils from isaaclab.assets import RigidObject, RigidObjectCfg from isaaclab.sim import build_simulation_context +from isaaclab.test.utils import test_devices from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR pytestmark = pytest.mark.integration @@ -96,7 +97,7 @@ def generate_dual_cube_scene( ROT_45_Z = (0.0, 0.0, math.sin(math.pi / 8), math.cos(math.pi / 8)) # 45deg about Z in (x,y,z,w) -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("device", test_devices()) def test_composer_vs_physx_local_force(device): """Baseline: local force at identity orientation. Composer and raw PhysX should match exactly.""" with build_simulation_context(device=device, gravity_enabled=False, auto_add_lighting=True) as sim: @@ -161,79 +162,7 @@ def test_composer_vs_physx_local_force(device): ) -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -def test_composer_vs_physx_global_force(device): - """Global force with non-identity rotation (45 deg Z). Rotation matters for frame conversion.""" - with build_simulation_context(device=device, gravity_enabled=False, auto_add_lighting=True) as sim: - sim._app_control_on_stop_handle = None - cube_composer, cube_raw = generate_dual_cube_scene(num_cubes=1, device=device, initial_rot=ROT_45_Z) - - sim.reset() - - body_ids, _ = cube_composer.find_bodies(".*") - - # Composer path: global force +X - forces = torch.zeros(1, len(body_ids), 3, device=device) - forces[..., 0] = FORCE_MAGNITUDE - torques = torch.zeros(1, len(body_ids), 3, device=device) - - cube_composer.permanent_wrench_composer.set_forces_and_torques_index( - forces=forces, - torques=torques, - body_ids=body_ids, - is_global=True, - ) - - # Raw PhysX data - raw_forces = torch.zeros(1, 3, device=device) - raw_forces[:, 0] = FORCE_MAGNITUDE - raw_torques = torch.zeros(1, 3, device=device) - raw_indices = cube_raw._ALL_INDICES - - for _ in range(N_STEPS): - cube_composer.write_data_to_sim() - cube_raw.write_data_to_sim() - cube_raw.root_view.apply_forces_and_torques_at_position( - force_data=wp.from_torch(raw_forces.contiguous(), dtype=wp.float32), - torque_data=wp.from_torch(raw_torques.contiguous(), dtype=wp.float32), - position_data=None, - indices=raw_indices, - is_global=True, - ) - sim.step() - cube_composer.update(sim.cfg.dt) - cube_raw.update(sim.cfg.dt) - - # Linear velocities should match (same global force, same mass) - torch.testing.assert_close( - cube_composer.data.root_lin_vel_w.torch, - cube_raw.data.root_lin_vel_w.torch, - rtol=1e-4, - atol=1e-4, - ) - # Angular velocities should match - torch.testing.assert_close( - cube_composer.data.root_ang_vel_w.torch, - cube_raw.data.root_ang_vel_w.torch, - rtol=1e-4, - atol=1e-4, - ) - # Both should have ~zero angular velocity (force at CoM, no torque) - torch.testing.assert_close( - cube_composer.data.root_ang_vel_w.torch, - torch.zeros(1, 3, device=device), - rtol=0.0, - atol=1e-4, - ) - torch.testing.assert_close( - cube_raw.data.root_ang_vel_w.torch, - torch.zeros(1, 3, device=device), - rtol=0.0, - atol=1e-4, - ) - - -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("device", test_devices()) def test_composer_vs_physx_local_force_at_position(device): """Local force at a local offset. Both paths should produce identical cross-product torque.""" with build_simulation_context(device=device, gravity_enabled=False, auto_add_lighting=True) as sim: @@ -301,7 +230,7 @@ def test_composer_vs_physx_local_force_at_position(device): ) -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("device", test_devices()) def test_composer_vs_physx_global_force_at_position(device): """Global force at world position with non-identity rotation. Both rotation AND position correction matter.""" with build_simulation_context(device=device, gravity_enabled=False, auto_add_lighting=True) as sim: @@ -373,7 +302,7 @@ def test_composer_vs_physx_global_force_at_position(device): ) -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("device", test_devices()) def test_composer_vs_physx_local_torque(device): """Local torque at identity orientation. Should produce matching angular velocity.""" with build_simulation_context(device=device, gravity_enabled=False, auto_add_lighting=True) as sim: @@ -438,7 +367,7 @@ def test_composer_vs_physx_local_torque(device): ) -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("device", test_devices()) def test_composer_vs_physx_global_torque(device): """Global torque with non-identity rotation (45 deg Z). Composer rotates to body frame internally.""" with build_simulation_context(device=device, gravity_enabled=False, auto_add_lighting=True) as sim: @@ -493,79 +422,7 @@ def test_composer_vs_physx_global_torque(device): NUM_CUBES_MULTI = 4 -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -def test_composer_vs_physx_global_force_multi_env(device): - """Global force (no position) with multiple environments. - - Regression: checks that env-indexing and per-body quaternion handling work correctly - when there is more than one environment. - """ - with build_simulation_context(device=device, gravity_enabled=False, auto_add_lighting=True) as sim: - sim._app_control_on_stop_handle = None - cube_composer, cube_raw = generate_dual_cube_scene( - num_cubes=NUM_CUBES_MULTI, device=device, initial_rot=ROT_45_Z - ) - - sim.reset() - - body_ids, _ = cube_composer.find_bodies(".*") - - # Composer path: global force +X for all envs - forces = torch.zeros(NUM_CUBES_MULTI, len(body_ids), 3, device=device) - forces[..., 0] = FORCE_MAGNITUDE - torques = torch.zeros(NUM_CUBES_MULTI, len(body_ids), 3, device=device) - - cube_composer.permanent_wrench_composer.set_forces_and_torques_index( - forces=forces, - torques=torques, - body_ids=body_ids, - is_global=True, - ) - - # Raw PhysX data (one row per env) - raw_forces = torch.zeros(NUM_CUBES_MULTI, 3, device=device) - raw_forces[:, 0] = FORCE_MAGNITUDE - raw_torques = torch.zeros(NUM_CUBES_MULTI, 3, device=device) - raw_indices = cube_raw._ALL_INDICES - - for _ in range(N_STEPS): - cube_composer.write_data_to_sim() - cube_raw.write_data_to_sim() - cube_raw.root_view.apply_forces_and_torques_at_position( - force_data=wp.from_torch(raw_forces.contiguous(), dtype=wp.float32), - torque_data=wp.from_torch(raw_torques.contiguous(), dtype=wp.float32), - position_data=None, - indices=raw_indices, - is_global=True, - ) - sim.step() - cube_composer.update(sim.cfg.dt) - cube_raw.update(sim.cfg.dt) - - # Linear velocities should match across all envs - torch.testing.assert_close( - cube_composer.data.root_lin_vel_w.torch, - cube_raw.data.root_lin_vel_w.torch, - rtol=1e-4, - atol=1e-4, - ) - # Angular velocities should match - torch.testing.assert_close( - cube_composer.data.root_ang_vel_w.torch, - cube_raw.data.root_ang_vel_w.torch, - rtol=1e-4, - atol=1e-4, - ) - # All envs should have ~zero angular velocity - torch.testing.assert_close( - cube_composer.data.root_ang_vel_w.torch, - torch.zeros(NUM_CUBES_MULTI, 3, device=device), - rtol=0.0, - atol=1e-4, - ) - - -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("device", test_devices()) def test_composer_vs_physx_global_force_with_reset(device): """Global force (no position) with a mid-simulation reset of half the envs. @@ -699,7 +556,7 @@ def apply_global_force(): ) -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("device", test_devices()) def test_composer_vs_physx_payload_scenario(device): """Mirrors the apply_payload MDP: permanent global downward force at CoM with gravity. @@ -774,7 +631,7 @@ def test_composer_vs_physx_payload_scenario(device): ) -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("device", test_devices()) def test_composer_vs_physx_permanent_global_force_at_position_long_run(device): """Permanent global force at a world-frame offset, run long enough for significant body motion. diff --git a/source/isaaclab/test/utils/warp/test_image_ops.py b/source/isaaclab/test/utils/warp/test_image_ops.py index 377fab96066c..509e41b86d29 100644 --- a/source/isaaclab/test/utils/warp/test_image_ops.py +++ b/source/isaaclab/test/utils/warp/test_image_ops.py @@ -11,6 +11,8 @@ import torch import warp as wp +from isaaclab.test.utils import test_devices + pytestmark = pytest.mark.unit wp.config.quiet = True @@ -25,63 +27,50 @@ def _pytorch_reference(src: torch.Tensor, channel_dim: int = -1) -> torch.Tensor return x - torch.mean(x, dim=spatial_dims, keepdim=True) -@pytest.fixture(params=["cpu", "cuda:0"] if torch.cuda.is_available() else ["cpu"]) +@pytest.fixture(params=test_devices()) def device(request): - """Parametrize across CPU and CUDA.""" + """Parametrize kernel tests across devices; input validation runs on CPU only.""" return request.param -class TestNormalizeImageUint8: - """Tests for the Warp-backed fused uint8 normalize wrapper.""" +cpu_only = pytest.mark.parametrize("device", ["cpu"]) +"""Run a Python-side validation check once on CPU instead of on every device.""" - def test_matches_pytorch_reference_constant_input(self, device): - """A constant-valued uint8 input must normalize to all zeros (mean equals every pixel).""" - from isaaclab.utils.warp.ops import normalize_image_uint8 - src = torch.full((2, 4, 4, 6), 128, dtype=torch.uint8, device=device) - out = normalize_image_uint8(src) - torch.testing.assert_close(out, torch.zeros_like(out)) +class TestNormalizeImageUint8: + """Tests for the Warp-backed fused uint8 normalize wrapper.""" - def test_matches_pytorch_reference_random_input(self, device): - """Output must match the pure-PyTorch reference on randomized input.""" + @pytest.mark.parametrize("channel_dim", [-1, 3]) + def test_matches_pytorch_reference_random_input(self, device, channel_dim): + """Output must match the pure-PyTorch reference on randomized BHWC input, per channel.""" from isaaclab.utils.warp.ops import normalize_image_uint8 torch.manual_seed(0) src = torch.randint(0, 255, (3, 16, 16, 6), dtype=torch.uint8, device=device) - out = normalize_image_uint8(src) + out = normalize_image_uint8(src, channel_dim=channel_dim) expected = _pytorch_reference(src) torch.testing.assert_close(out, expected, atol=1e-5, rtol=1e-5) - def test_matches_pytorch_reference_disjoint_channel_slices(self, device): - """Two frames concatenated along C must each normalize independently per-channel-slice.""" - from isaaclab.utils.warp.ops import normalize_image_uint8 - - torch.manual_seed(1) - c = 3 - f1 = torch.randint(0, 255, (2, 8, 8, c), dtype=torch.uint8, device=device) - f2 = torch.randint(0, 255, (2, 8, 8, c), dtype=torch.uint8, device=device) - stacked = torch.cat([f1, f2], dim=-1).contiguous() - out = normalize_image_uint8(stacked) - torch.testing.assert_close(out[..., :c], _pytorch_reference(f1), atol=1e-5, rtol=1e-5) - torch.testing.assert_close(out[..., c:], _pytorch_reference(f2), atol=1e-5, rtol=1e-5) - - def test_preallocated_output_reused(self, device): + @pytest.mark.parametrize("channel_dim", [-1, 1], ids=["bhwc", "bchw"]) + def test_preallocated_output_reused(self, device, channel_dim): """When ``out`` is passed in, the wrapper writes into it and returns the same object.""" from isaaclab.utils.warp.ops import normalize_image_uint8 - src = torch.randint(0, 255, (2, 8, 8, 6), dtype=torch.uint8, device=device) + src = torch.randint(0, 255, (2, 6, 8, 8), dtype=torch.uint8, device=device) out = torch.empty(src.shape, dtype=torch.float32, device=device) ptr_before = out.data_ptr() - result = normalize_image_uint8(src, out=out) + result = normalize_image_uint8(src, channel_dim=channel_dim, out=out) assert result is out assert result.data_ptr() == ptr_before + torch.testing.assert_close(result, _pytorch_reference(src, channel_dim), atol=1e-5, rtol=1e-5) - src2 = torch.randint(0, 255, (2, 8, 8, 6), dtype=torch.uint8, device=device) - result2 = normalize_image_uint8(src2, out=out) + src2 = torch.randint(0, 255, (2, 6, 8, 8), dtype=torch.uint8, device=device) + result2 = normalize_image_uint8(src2, channel_dim=channel_dim, out=out) assert result2 is out assert result2.data_ptr() == ptr_before - torch.testing.assert_close(result2, _pytorch_reference(src2), atol=1e-5, rtol=1e-5) + torch.testing.assert_close(result2, _pytorch_reference(src2, channel_dim), atol=1e-5, rtol=1e-5) + @cpu_only def test_rejects_non_uint8_input(self, device): """Float input is a programming error and must raise.""" from isaaclab.utils.warp.ops import normalize_image_uint8 @@ -90,6 +79,7 @@ def test_rejects_non_uint8_input(self, device): with pytest.raises(ValueError, match="4D uint8"): normalize_image_uint8(src) + @cpu_only def test_rejects_wrong_ndim(self, device): """3D uint8 input is rejected (kernel expects (B, H, W, C)).""" from isaaclab.utils.warp.ops import normalize_image_uint8 @@ -98,6 +88,7 @@ def test_rejects_wrong_ndim(self, device): with pytest.raises(ValueError, match="4D uint8"): normalize_image_uint8(src) + @cpu_only def test_rejects_non_contiguous_input(self, device): """Non-contiguous src is rejected.""" from isaaclab.utils.warp.ops import normalize_image_uint8 @@ -108,66 +99,32 @@ def test_rejects_non_contiguous_input(self, device): with pytest.raises(ValueError, match="contiguous"): normalize_image_uint8(src) - def test_rejects_out_shape_mismatch(self, device): - """A pre-allocated ``out`` of the wrong shape must raise.""" + @cpu_only + @pytest.mark.parametrize( + "out_shape, out_dtype", + [((2, 4, 4, 3), torch.float32), ((2, 4, 4, 6), torch.float16)], + ids=["shape", "dtype"], + ) + def test_rejects_out_mismatch(self, device, out_shape, out_dtype): + """A pre-allocated ``out`` of the wrong shape or dtype must raise.""" from isaaclab.utils.warp.ops import normalize_image_uint8 src = torch.zeros((2, 4, 4, 6), dtype=torch.uint8, device=device) - bad_out = torch.empty((2, 4, 4, 3), dtype=torch.float32, device=device) + bad_out = torch.empty(out_shape, dtype=out_dtype, device=device) with pytest.raises(ValueError, match="out shape/dtype/device"): normalize_image_uint8(src, out=bad_out) - def test_rejects_out_dtype_mismatch(self, device): - """A pre-allocated ``out`` of the wrong dtype must raise.""" - from isaaclab.utils.warp.ops import normalize_image_uint8 - - src = torch.zeros((2, 4, 4, 6), dtype=torch.uint8, device=device) - bad_out = torch.empty(src.shape, dtype=torch.float16, device=device) - with pytest.raises(ValueError, match="out shape/dtype/device"): - normalize_image_uint8(src, out=bad_out) - - def test_bchw_matches_pytorch_reference(self, device): - """``channel_dim=1`` (BCHW) must match a BCHW-layout PyTorch reference.""" + @pytest.mark.parametrize("channel_dim", [1, -3]) + def test_bchw_matches_pytorch_reference(self, device, channel_dim): + """``channel_dim=1`` or ``-3`` (BCHW) must match a BCHW-layout PyTorch reference.""" from isaaclab.utils.warp.ops import normalize_image_uint8 torch.manual_seed(2) src = torch.randint(0, 255, (3, 6, 16, 16), dtype=torch.uint8, device=device) - out = normalize_image_uint8(src, channel_dim=1) + out = normalize_image_uint8(src, channel_dim=channel_dim) torch.testing.assert_close(out, _pytorch_reference(src, channel_dim=1), atol=1e-5, rtol=1e-5) - def test_bchw_negative_index_equivalent_to_positive(self, device): - """``channel_dim=-3`` must produce the same output as ``channel_dim=1`` for 4D input.""" - from isaaclab.utils.warp.ops import normalize_image_uint8 - - torch.manual_seed(3) - src = torch.randint(0, 255, (2, 4, 8, 8), dtype=torch.uint8, device=device) - out_pos = normalize_image_uint8(src, channel_dim=1) - out_neg = normalize_image_uint8(src, channel_dim=-3) - torch.testing.assert_close(out_pos, out_neg) - - def test_bhwc_explicit_positive_index_matches_default(self, device): - """``channel_dim=3`` and the default ``channel_dim=-1`` must agree on BHWC input.""" - from isaaclab.utils.warp.ops import normalize_image_uint8 - - torch.manual_seed(4) - src = torch.randint(0, 255, (2, 8, 8, 4), dtype=torch.uint8, device=device) - out_default = normalize_image_uint8(src) - out_explicit = normalize_image_uint8(src, channel_dim=3) - torch.testing.assert_close(out_default, out_explicit) - - def test_bchw_disjoint_channel_slices(self, device): - """K frames concatenated along C in BCHW must each normalize independently per-channel.""" - from isaaclab.utils.warp.ops import normalize_image_uint8 - - torch.manual_seed(5) - c = 3 - f1 = torch.randint(0, 255, (2, c, 8, 8), dtype=torch.uint8, device=device) - f2 = torch.randint(0, 255, (2, c, 8, 8), dtype=torch.uint8, device=device) - stacked = torch.cat([f1, f2], dim=1).contiguous() - out = normalize_image_uint8(stacked, channel_dim=1) - torch.testing.assert_close(out[:, :c], _pytorch_reference(f1, channel_dim=1), atol=1e-5, rtol=1e-5) - torch.testing.assert_close(out[:, c:], _pytorch_reference(f2, channel_dim=1), atol=1e-5, rtol=1e-5) - + @cpu_only @pytest.mark.parametrize("bad_dim", [0, 2, 4, -2, -4, -5]) def test_rejects_invalid_channel_dim(self, device, bad_dim): """Only ``channel_dim`` resolving to 1 or 3 is accepted for 4D input.""" @@ -177,17 +134,6 @@ def test_rejects_invalid_channel_dim(self, device, bad_dim): with pytest.raises(ValueError, match="channel_dim must resolve to 1 .BCHW. or 3 .BHWC."): normalize_image_uint8(src, channel_dim=bad_dim) - def test_multi_tile_h_axis(self, device): - """H > tile_size must produce NUM_TILES > 1 partial sums and reduce correctly.""" - from isaaclab.utils.warp.ops import _UINT8_SUM_TILE_HW, normalize_image_uint8 - - # H = 2 * TILE forces NUM_TILES=2 (evenly split). - h = 2 * _UINT8_SUM_TILE_HW - torch.manual_seed(2) - src = torch.randint(0, 255, (2, h, 8, 6), dtype=torch.uint8, device=device) - out = normalize_image_uint8(src) - torch.testing.assert_close(out, _pytorch_reference(src), atol=1e-5, rtol=1e-5) - def test_non_divisible_h_last_tile_clamps(self, device): """H not divisible by tile_size: last tile's row range must clamp via ``wp.min``.""" from isaaclab.utils.warp.ops import _UINT8_SUM_TILE_HW, normalize_image_uint8 @@ -209,31 +155,6 @@ def test_bchw_multi_tile_h_axis(self, device): out = normalize_image_uint8(src, channel_dim=1) torch.testing.assert_close(out, _pytorch_reference(src, channel_dim=1), atol=1e-5, rtol=1e-5) - def test_bchw_constant_input(self, device): - """A constant-valued BCHW uint8 input must normalize to all zeros.""" - from isaaclab.utils.warp.ops import normalize_image_uint8 - - src = torch.full((2, 6, 4, 4), 128, dtype=torch.uint8, device=device) - out = normalize_image_uint8(src, channel_dim=1) - torch.testing.assert_close(out, torch.zeros_like(out)) - - def test_bchw_preallocated_output_reused(self, device): - """``out`` kwarg on the BCHW path is written-into and the same object is returned.""" - from isaaclab.utils.warp.ops import normalize_image_uint8 - - src = torch.randint(0, 255, (2, 6, 8, 8), dtype=torch.uint8, device=device) - out = torch.empty(src.shape, dtype=torch.float32, device=device) - ptr_before = out.data_ptr() - result = normalize_image_uint8(src, channel_dim=1, out=out) - assert result is out - assert result.data_ptr() == ptr_before - torch.testing.assert_close(result, _pytorch_reference(src, channel_dim=1), atol=1e-5, rtol=1e-5) - - src2 = torch.randint(0, 255, (2, 6, 8, 8), dtype=torch.uint8, device=device) - result2 = normalize_image_uint8(src2, channel_dim=1, out=out) - assert result2.data_ptr() == ptr_before - torch.testing.assert_close(result2, _pytorch_reference(src2, channel_dim=1), atol=1e-5, rtol=1e-5) - def test_partials_cache_keyed_by_channel_dim(self, device): """BCHW and BHWC inputs of identical shape must land in separate cache slots.""" from isaaclab.utils.warp import ops as warp_ops diff --git a/source/isaaclab/test/utils/warp/test_particle_mesh.py b/source/isaaclab/test/utils/warp/test_particle_mesh.py index 8a79a35180ff..3e04457e6276 100644 --- a/source/isaaclab/test/utils/warp/test_particle_mesh.py +++ b/source/isaaclab/test/utils/warp/test_particle_mesh.py @@ -11,16 +11,15 @@ import torch import warp as wp +from isaaclab.test.utils import test_devices from isaaclab.utils.warp import ParticleMeshCounter, make_box_region_mesh, make_frustum_region_mesh pytestmark = pytest.mark.unit -@pytest.fixture(params=["cpu", "cuda:0"]) +@pytest.fixture(params=test_devices()) def device(request): """Parametrize tests across CPU and CUDA devices.""" - if request.param.startswith("cuda") and not torch.cuda.is_available(): - pytest.skip("CUDA device not available") return request.param @@ -59,6 +58,7 @@ def test_box_counts_and_mask_match_analytic(self, device): expected_mask = _box_inside_analytic(points, region_pos_e.expand(num_envs, 3), half) assert mask.shape == (num_envs, num_particles, 1) + assert mask.dtype == torch.bool assert torch.equal(mask[..., 0], expected_mask) assert torch.equal(counts[:, 0], expected_mask.sum(dim=1).float()) # sanity: the box covers a non-trivial fraction of the points @@ -177,15 +177,6 @@ def test_disjoint_regions(self, device): class TestParticleMeshCounterRobustness: """Buffer reuse, prebuilt meshes, and input validation.""" - def test_return_mask_consistency(self, device): - """The boolean mask sums to the reported counts.""" - torch.manual_seed(2) - counter = ParticleMeshCounter([make_box_region_mesh((0.12, 0.12, 0.12))], num_envs=3, device=device) - points = (torch.rand(3, 128, 3, device=device) - 0.5) * 0.6 - counts, mask = counter.count(points, torch.zeros(1, 3, device=device), return_mask=True) - assert mask.dtype == torch.bool - assert torch.equal(counts, mask.sum(dim=1).float()) - def test_buffer_reuse_changing_particle_count(self, device): """The internal buffer resizes correctly when the particle count changes between calls.""" counter = ParticleMeshCounter([make_box_region_mesh((0.1, 0.1, 0.1))], num_envs=1, device=device) @@ -224,33 +215,25 @@ def test_invalid_inputs_raise(self): class TestRegionMeshFactories: - """Shape/scale checks for the region-mesh factories.""" - - def test_box_mesh_shapes(self): - verts, faces = make_box_region_mesh((0.1, 0.2, 0.3)) - assert verts.shape == (8, 3) - assert faces.shape == (12, 3) - - def test_frustum_mesh_shapes(self): - n = 16 - verts, faces = make_frustum_region_mesh(0.02, 0.04, -0.01, 0.03, num_segments=n) - assert verts.shape == (2 * n + 2, 3) - assert faces.shape == (4 * n, 3) - - def test_frustum_rejects_too_few_segments(self): - with pytest.raises(ValueError): - make_frustum_region_mesh(0.02, 0.04, -0.01, 0.03, num_segments=2) - - def test_box_rejects_non_positive_half_extents(self): - with pytest.raises(ValueError): - make_box_region_mesh((0.1, 0.0, 0.1)) - with pytest.raises(ValueError): - make_box_region_mesh((-0.1, 0.1, 0.1)) - - def test_frustum_rejects_non_positive_radius(self): - with pytest.raises(ValueError): - make_frustum_region_mesh(0.0, 0.04, -0.01, 0.03) - - def test_frustum_rejects_inverted_z(self): + """Argument validation for the region-mesh factories.""" + + @pytest.mark.parametrize( + "factory, args, kwargs", + [ + (make_frustum_region_mesh, (0.02, 0.04, -0.01, 0.03), {"num_segments": 2}), + (make_box_region_mesh, ((0.1, 0.0, 0.1),), {}), + (make_box_region_mesh, ((-0.1, 0.1, 0.1),), {}), + (make_frustum_region_mesh, (0.0, 0.04, -0.01, 0.03), {}), + (make_frustum_region_mesh, (0.02, 0.04, 0.03, -0.01), {}), + ], + ids=[ + "frustum_too_few_segments", + "box_zero_half_extent", + "box_negative_half_extent", + "frustum_non_positive_radius", + "frustum_inverted_z", + ], + ) + def test_factory_rejects_invalid_args(self, factory, args, kwargs): with pytest.raises(ValueError): - make_frustum_region_mesh(0.02, 0.04, 0.03, -0.01) + factory(*args, **kwargs) diff --git a/source/isaaclab/test/utils/warp/test_proxy_array.py b/source/isaaclab/test/utils/warp/test_proxy_array.py index 7becc6b58468..247644674b71 100644 --- a/source/isaaclab/test/utils/warp/test_proxy_array.py +++ b/source/isaaclab/test/utils/warp/test_proxy_array.py @@ -25,9 +25,14 @@ def device(request): return request.param +cpu_only = pytest.mark.parametrize("device", ["cpu"]) +"""Run device-independent wrapper bookkeeping once on CPU instead of on every device.""" + + class TestProxyArrayBasic: """Tests for basic ProxyArray functionality.""" + @cpu_only def test_warp_returns_original(self, device): """Test that .warp returns the original warp array.""" from isaaclab.utils.warp.proxy_array import ProxyArray @@ -36,14 +41,7 @@ def test_warp_returns_original(self, device): ta = ProxyArray(arr) assert ta.warp is arr - def test_torch_returns_tensor(self, device): - """Test that .torch returns a torch.Tensor.""" - from isaaclab.utils.warp.proxy_array import ProxyArray - - arr = wp.zeros(10, dtype=wp.float32, device=device) - ta = ProxyArray(arr) - assert isinstance(ta.torch, torch.Tensor) - + @cpu_only def test_torch_is_cached(self, device): """Test that .torch returns the same tensor object on repeated access.""" from isaaclab.utils.warp.proxy_array import ProxyArray @@ -67,6 +65,7 @@ def test_torch_shares_memory(self, device): arr_np = arr.numpy() assert arr_np[0] == 42.0 + @cpu_only def test_immutable_warp_cannot_be_reassigned(self, device): """ProxyArray._warp cannot be reassigned; callers must construct a new wrapper.""" from isaaclab.utils.warp.proxy_array import ProxyArray @@ -79,18 +78,6 @@ def test_immutable_warp_cannot_be_reassigned(self, device): with pytest.raises(AttributeError, match="immutable"): ta.new_field = 42 # arbitrary attribute writes also blocked - def test_immutable_allows_internal_torch_cache(self, device): - """Lazy .torch caching still works — only _torch_cache is allowed as a post-init write.""" - from isaaclab.utils.warp.proxy_array import ProxyArray - - arr = wp.zeros(10, dtype=wp.float32, device=device) - ta = ProxyArray(arr) - # First access populates the cache; no exception. - first = ta.torch - # Subsequent accesses return the same cached tensor. - second = ta.torch - assert first is second - @pytest.mark.parametrize("cuda_device", test_devices(DeviceScope.CUDA)) def test_cuda_array_interface(self, cuda_device): """Test that __cuda_array_interface__ delegates to the underlying warp array.""" @@ -131,58 +118,44 @@ def _add_one(src: wp.array(dtype=wp.float32), dst: wp.array(dtype=wp.float32)): assert dst.torch[4].item() == 1.0 +@cpu_only class TestProxyArrayStructuredTypes: """Tests for ProxyArray with structured warp types (vec3f, quatf, etc).""" - def test_vec3f_shape(self, device): - """Test that vec3f arrays produce (N, 3) torch tensors.""" - from isaaclab.utils.warp.proxy_array import ProxyArray - - arr = wp.zeros(8, dtype=wp.vec3f, device=device) - ta = ProxyArray(arr) - assert ta.torch.shape == (8, 3) - - def test_quatf_shape(self, device): - """Test that quatf arrays produce (N, 4) torch tensors.""" - from isaaclab.utils.warp.proxy_array import ProxyArray - - arr = wp.zeros(8, dtype=wp.quatf, device=device) - ta = ProxyArray(arr) - assert ta.torch.shape == (8, 4) - - def test_transformf_shape(self, device): - """Test that transformf arrays produce (N, 7) torch tensors.""" - from isaaclab.utils.warp.proxy_array import ProxyArray - - arr = wp.zeros(8, dtype=wp.transformf, device=device) - ta = ProxyArray(arr) - assert ta.torch.shape == (8, 7) - - def test_spatial_vectorf_shape(self, device): - """Test that spatial_vectorf arrays produce (N, 6) torch tensors.""" - from isaaclab.utils.warp.proxy_array import ProxyArray - - arr = wp.zeros(8, dtype=wp.spatial_vectorf, device=device) - ta = ProxyArray(arr) - assert ta.torch.shape == (8, 6) - - def test_2d_vec3f_shape(self, device): - """Test that 2D vec3f arrays produce (N, M, 3) torch tensors.""" + @pytest.mark.parametrize( + "dtype, shape, expected_shape", + [ + (wp.vec3f, 8, (8, 3)), + (wp.quatf, 8, (8, 4)), + (wp.transformf, 8, (8, 7)), + (wp.spatial_vectorf, 8, (8, 6)), + (wp.vec3f, (4, 5), (4, 5, 3)), + ], + ids=["vec3f", "quatf", "transformf", "spatial_vectorf", "2d_vec3f"], + ) + def test_structured_type_shape(self, device, dtype, shape, expected_shape): + """Test that structured types expose their components as a trailing torch dimension.""" from isaaclab.utils.warp.proxy_array import ProxyArray - arr = wp.zeros((4, 5), dtype=wp.vec3f, device=device) + arr = wp.zeros(shape, dtype=dtype, device=device) ta = ProxyArray(arr) - assert ta.torch.shape == (4, 5, 3) + assert isinstance(ta.torch, torch.Tensor) + assert ta.torch.shape == expected_shape +@cpu_only class TestProxyArrayQuatfTorchAccessWarning: """Tests for the WARN_ON_TORCH_QUATF_ACCESS opt-in runtime detector.""" - def test_default_no_warning(self, device, monkeypatch): - """No env var → quatf .torch access is silent.""" + @pytest.mark.parametrize("env_value", [None, "0"], ids=["unset", "zero"]) + def test_default_no_warning(self, device, monkeypatch, env_value): + """No env var or ``"0"`` → quatf .torch access is silent (only ``"1"`` enables the detector).""" from isaaclab.utils.warp.proxy_array import ProxyArray - monkeypatch.delenv("WARN_ON_TORCH_QUATF_ACCESS", raising=False) + if env_value is None: + monkeypatch.delenv("WARN_ON_TORCH_QUATF_ACCESS", raising=False) + else: + monkeypatch.setenv("WARN_ON_TORCH_QUATF_ACCESS", env_value) ta = ProxyArray(wp.zeros(4, dtype=wp.quatf, device=device)) with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") @@ -218,59 +191,21 @@ def test_env_set_does_not_warn_on_non_quatf(self, device, monkeypatch): _ = ta.torch assert [x for x in w if issubclass(x.category, UserWarning)] == [] - def test_env_zero_does_not_warn(self, device, monkeypatch): - """WARN_ON_TORCH_QUATF_ACCESS=0 → silent (only ``"1"`` enables the detector).""" - from isaaclab.utils.warp.proxy_array import ProxyArray - - monkeypatch.setenv("WARN_ON_TORCH_QUATF_ACCESS", "0") - ta = ProxyArray(wp.zeros(4, dtype=wp.quatf, device=device)) - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - _ = ta.torch - assert [x for x in w if issubclass(x.category, UserWarning)] == [] - class TestProxyArrayConvenienceProperties: """Tests for convenience properties: shape, dtype, device, len, repr.""" - def test_shape(self, device): - """Test that .shape returns the warp array shape.""" - from isaaclab.utils.warp.proxy_array import ProxyArray - - arr = wp.zeros((3, 4), dtype=wp.float32, device=device) - ta = ProxyArray(arr) - assert ta.shape == (3, 4) - - def test_dtype(self, device): - """Test that .dtype returns the warp dtype.""" + @cpu_only + def test_convenience_properties(self, device): + """Test that shape, dtype, device, len(), and repr() forward the warp array's metadata.""" from isaaclab.utils.warp.proxy_array import ProxyArray - arr = wp.zeros(10, dtype=wp.float32, device=device) + arr = wp.zeros((7, 3), dtype=wp.float32, device=device) ta = ProxyArray(arr) + assert ta.shape == (7, 3) assert ta.dtype == wp.float32 - - def test_device(self, device): - """Test that .device returns the warp device string.""" - from isaaclab.utils.warp.proxy_array import ProxyArray - - arr = wp.zeros(10, dtype=wp.float32, device=device) - ta = ProxyArray(arr) assert ta.device == arr.device - - def test_len(self, device): - """Test that len() returns the first dimension size.""" - from isaaclab.utils.warp.proxy_array import ProxyArray - - arr = wp.zeros((7, 3), dtype=wp.float32, device=device) - ta = ProxyArray(arr) assert len(ta) == 7 - - def test_repr(self, device): - """Test that repr() contains ProxyArray and key info.""" - from isaaclab.utils.warp.proxy_array import ProxyArray - - arr = wp.zeros(5, dtype=wp.float32, device=device) - ta = ProxyArray(arr) r = repr(ta) assert "ProxyArray" in r assert "float32" in r @@ -285,6 +220,7 @@ def setup_method(self): ProxyArray._deprecation_warned = False + @cpu_only def test_torch_function_works_and_warns(self, device): """Test that __torch_function__ enables torch ops and emits a deprecation warning.""" from isaaclab.utils.warp.proxy_array import ProxyArray @@ -300,6 +236,7 @@ def test_torch_function_works_and_warns(self, device): assert isinstance(result, torch.Tensor) assert result.item() == pytest.approx(5.0) + @cpu_only def test_torch_cat_works_and_warns(self, device): """Test that torch.cat works with ProxyArray and emits a deprecation warning.""" from isaaclab.utils.warp.proxy_array import ProxyArray @@ -315,22 +252,7 @@ def test_torch_cat_works_and_warns(self, device): assert issubclass(w[0].category, DeprecationWarning) assert result.shape == (7,) - def test_arithmetic_operators_work_and_warn(self, device): - """Test that arithmetic operators work and emit deprecation warnings.""" - from isaaclab.utils.warp.proxy_array import ProxyArray - - arr = wp.ones(5, dtype=wp.float32, device=device) - ta = ProxyArray(arr) - - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - result = ta + 1.0 - assert len(w) == 1 - assert issubclass(w[0].category, DeprecationWarning) - assert isinstance(result, torch.Tensor) - expected = torch.full((5,), 2.0, device=device) - torch.testing.assert_close(result, expected) - + @cpu_only def test_warns_only_once(self, device): """Test that the deprecation warning is emitted only once per session.""" from isaaclab.utils.warp.proxy_array import ProxyArray @@ -346,6 +268,7 @@ def test_warns_only_once(self, device): # Only one warning despite three operations assert len(w) == 1 + @cpu_only def test_tensor_plus_proxy_array(self, device): """Test that torch.Tensor + ProxyArray works via __torch_function__.""" from isaaclab.utils.warp.proxy_array import ProxyArray @@ -502,15 +425,6 @@ class TestWpToTorchShim: ``AttributeError`` on ``requires_grad`` lookup. """ - def test_raw_wp_array_unchanged(self): - """``wp.to_torch(wp.array)`` must still produce a zero-copy torch view.""" - import isaaclab.utils.warp # noqa: F401 # ensure shim is installed - - arr = wp.array([1.0, 2.0, 3.0], dtype=wp.float32, device="cpu") - t = wp.to_torch(arr) - assert isinstance(t, torch.Tensor) - assert t.shape == (3,) - def test_proxy_array_returns_torch_with_warning(self): """``wp.to_torch(ProxyArray)`` returns the cached .torch view and warns once.""" import isaaclab.utils.warp as iw # noqa: F401 # ensure shim is installed @@ -547,10 +461,11 @@ def test_proxy_array_warning_is_one_shot(self): assert not deprecation, "shim must not re-warn after the first call" def test_requires_grad_forwarded_to_raw_wp_array(self): - """The ``requires_grad`` kwarg still reaches the original ``wp.to_torch``.""" - import isaaclab.utils.warp # noqa: F401 + """``wp.to_torch(wp.array)`` still produces a torch view and forwards ``requires_grad``.""" + import isaaclab.utils.warp # noqa: F401 # ensure shim is installed arr = wp.array([1.0, 2.0], dtype=wp.float32, device="cpu") t = wp.to_torch(arr, requires_grad=False) assert isinstance(t, torch.Tensor) + assert t.shape == (2,) assert t.requires_grad is False diff --git a/source/isaaclab/test/visualizers/test_streaming_view.py b/source/isaaclab/test/visualizers/test_streaming_view.py index 40d8ff2b08f8..cb87c32544fb 100644 --- a/source/isaaclab/test/visualizers/test_streaming_view.py +++ b/source/isaaclab/test/visualizers/test_streaming_view.py @@ -38,30 +38,25 @@ def _fake_seg(h: int = 48, w: int = 64) -> torch.Tensor: return t -def test_colorize_rgb_shape_and_dtype(): - out = CameraFrameColorizer.colorize(_fake_rgb(), "rgb") - assert out.shape == (48, 64, 3) - assert out.dtype == np.uint8 - - def test_colorize_rgb_drops_alpha_channel(): rgb_t = _fake_rgb() out = CameraFrameColorizer.colorize(rgb_t, "rgb") expected = rgb_t.numpy()[..., :3] + assert out.dtype == np.uint8 np.testing.assert_array_equal(out, expected) -def test_colorize_depth_shape_and_dtype(): - out = CameraFrameColorizer.colorize(_fake_depth(), "depth", depth_min=0.1, depth_max=10.0) - assert out.shape == (48, 64, 3) - assert out.dtype == np.uint8 +def test_colorize_depth_clamps_range(): + """Depth outside [depth_min, depth_max] maps to the color of the nearest bound.""" + def colorize(depth: float) -> np.ndarray: + return CameraFrameColorizer.colorize(torch.full((48, 64, 1), depth), "depth", depth_min=1.0, depth_max=5.0) -def test_colorize_depth_clamps_range(): - # All near-zero depth → should map to one color (blue end of turbo) - near = torch.full((48, 64, 1), 0.05) # below depth_min - out_near = CameraFrameColorizer.colorize(near, "depth", depth_min=1.0, depth_max=5.0) - assert (out_near == out_near[0, 0]).all(), "Uniform input should produce uniform output" + out_near = colorize(0.05) + assert out_near.shape == (48, 64, 3) + assert out_near.dtype == np.uint8 + # Only the near bound is observable: past the far bound the gradient already saturates without the clamp. + np.testing.assert_array_equal(out_near, colorize(1.0)) def test_colorize_depth_turbo_near_is_not_same_as_far(): @@ -73,28 +68,13 @@ def test_colorize_depth_turbo_near_is_not_same_as_far(): assert not np.array_equal(out_near, out_far), "Near and far depth should produce different colors" -def test_colorize_segmentation_shape_and_dtype(): - out = CameraFrameColorizer.colorize(_fake_seg(), "segmentation") - assert out.shape == (48, 64, 3) - assert out.dtype == np.uint8 - - def test_colorize_segmentation_background_is_dark_grey(): seg = torch.zeros(10, 10, 4, dtype=torch.uint8) # all background out = CameraFrameColorizer.colorize(seg, "segmentation") + assert out.dtype == np.uint8 np.testing.assert_array_equal(out, np.full((10, 10, 3), 40, dtype=np.uint8)) -def test_colorize_segmentation_classes_have_distinct_colors(): - seg = torch.zeros(20, 20, 4, dtype=torch.uint8) - seg[:10, :, 0] = 1 - seg[10:, :, 0] = 2 - out = CameraFrameColorizer.colorize(seg, "segmentation") - color1 = out[5, 10] - color2 = out[15, 10] - assert not np.array_equal(color1, color2), "Different class IDs must produce different colors" - - def test_colorize_unsupported_type_raises(): with pytest.raises(ValueError, match="not supported"): CameraFrameColorizer.colorize(_fake_rgb(), "optical_flow") @@ -162,15 +142,6 @@ def test_layout_6_envs_2_gt(): assert cols == 2 -def test_compose_grid_output_shape(): - h, w = 48, 64 - n_envs, n_gt = 3, 3 - frames = [np.zeros((h, w, 3), dtype=np.uint8) for _ in range(n_envs * n_gt)] - composite = compose_streaming_grid(frames, n_envs, n_gt) - # 3 envs in 1 col × 3 GT types → height=3*48=144, width=1*3*64=192 - assert composite.shape == (3 * h, 1 * 3 * w, 3) - - def test_compose_grid_single_gt_single_env(): h, w = 48, 64 frame = np.ones((h, w, 3), dtype=np.uint8) * 128 @@ -201,30 +172,6 @@ def test_compose_grid_pixels_placed_correctly(): # --------------------------------------------------------------------------- -def test_shadow_hand_num_envs_override(): - from isaaclab_tasks.core.reorient.config.shadow_hand.shadow_hand_direct_env_cfg import ShadowHandEnvCfg - - cfg = ShadowHandEnvCfg() - cfg.scene.num_envs = 8 - assert cfg.scene.num_envs == 8 - - -def test_streaming_cfg_fields_on_visualizer_cfg(): - from isaaclab.visualizers.visualizer_cfg import VisualizerCfg - - cfg = VisualizerCfg() - cfg.streaming_view = True - cfg.streaming_envs = [0, 1, 2] - cfg.streaming_gt_types = ["rgb", "depth"] - cfg.streaming_depth_max = 8.0 - - assert cfg.streaming_view is True - assert cfg.streaming_envs == [0, 1, 2] - assert cfg.streaming_gt_types == ["rgb", "depth"] - assert cfg.streaming_depth_max == 8.0 - assert cfg.streaming_cam_renderer is None - - def test_camera_colorizer_segmentation_golden_ratio_hues(): n_classes = 10 h, w = 10, 10 @@ -242,7 +189,7 @@ def test_camera_colorizer_segmentation_golden_ratio_hues(): ) -def test_compose_streaming_grid_no_env_split(): +def test_best_streaming_cols_never_exceeds_env_count(): for n_envs in [1, 2, 3, 4, 6, 8, 16]: for n_gt in [1, 2, 3]: cols = _best_streaming_cols(n_envs, n_gt, 240, 320) @@ -268,41 +215,14 @@ def test_streaming_gt_types_validated_on_setup(): # --------------------------------------------------------------------------- -def test_colorize_normals_shape_and_dtype(): - """Normals → RGB maps XYZ→uint8, output is (H, W, 3).""" - import torch as _torch - - normals = _torch.zeros(48, 64, 4) # XYZW - normals[..., 2] = 1.0 # all pointing up (+Z) - out = CameraFrameColorizer.colorize(normals, "normals") - assert out.shape == (48, 64, 3) - assert out.dtype == np.uint8 - - -def test_colorize_normals_up_is_blue(): - """Up-facing normal (0, 0, 1) → XYZ=(0,0,1) → RGB=(128,128,255).""" - import torch as _torch - - n = _torch.tensor([[[0.0, 0.0, 1.0, 0.0]]]) # (1,1,4) XYZW - out = CameraFrameColorizer.colorize(n, "normals") - # Z=1 → B = (1+1)/2*255 = 255; X=Y=0 → R=G = (0+1)/2*255 = 127 or 128 - assert out[0, 0, 2] > 200, f"Expected high B for up-normal, got {out[0, 0]}" - - -def test_colorize_normals_right_is_red(): - """Right-facing normal (1, 0, 0) → RGB=(255, 128, 128).""" - import torch as _torch - - n = _torch.tensor([[[1.0, 0.0, 0.0, 0.0]]]) +def test_colorize_normals_maps_xyz_to_rgb(): + """Normals map XYZ in [-1, 1] to RGB: up (0, 0, 1) is (128, 128, 255) and right (1, 0, 0) is (255, 128, 128).""" + n = torch.tensor([[[0.0, 0.0, 1.0, 0.0], [1.0, 0.0, 0.0, 0.0]]]) # (1, 2, 4) XYZW out = CameraFrameColorizer.colorize(n, "normals") - assert out[0, 0, 0] > 200, f"Expected high R for right-normal, got {out[0, 0]}" - - -def test_normals_in_supported_gt_types(): - """'normals' must be in SUPPORTED_GT_TYPES.""" - from isaaclab.envs.utils.camera_colorizer import SUPPORTED_GT_TYPES - - assert "normals" in SUPPORTED_GT_TYPES + assert out.shape == (1, 2, 3) + assert out.dtype == np.uint8 + # 0 maps to 127.5, which may land on either 127 or 128. + np.testing.assert_allclose(out, [[[127.5, 127.5, 255.0], [255.0, 127.5, 127.5]]], atol=1.0) def test_normals_sensor_key(): diff --git a/source/isaaclab/test/visualizers/test_visualizer.py b/source/isaaclab/test/visualizers/test_visualizer.py index a1681ba20c23..b88d567625d6 100644 --- a/source/isaaclab/test/visualizers/test_visualizer.py +++ b/source/isaaclab/test/visualizers/test_visualizer.py @@ -13,7 +13,6 @@ import pytest import torch -import isaaclab.visualizers as visualizers from isaaclab.envs.utils.camera_view import apply_camera_view_from_origins, prim_world_positions from isaaclab.utils.string import ResolvableString from isaaclab.visualizers.base_visualizer import BaseVisualizer @@ -43,20 +42,13 @@ def test_visualizer_cfg_names_its_implementation(module_name, cfg_name, implemen assert class_type.__name__ == implementation -def test_visualizer_construction_has_no_factory_api(): - assert not hasattr(visualizers, "Visualizer") - assert not any( - hasattr(VisualizerCfg, name) - for name in ("build", "build_visualizer", "clone_context", "create_visualizer", "get_visualizer_type") - ) - - def test_visualizer_cfg_streaming_view_is_opt_in(): cfg = VisualizerCfg() assert cfg.focal_length == 12.0 assert cfg.background_color == (0.3, 0.55, 0.82) assert cfg.streaming_view is False assert cfg.streaming_envs == 32 + assert cfg.streaming_cam_renderer is None def test_visualizer_cfg_validates_background_color(): @@ -66,13 +58,6 @@ def test_visualizer_cfg_validates_background_color(): VisualizerCfg(background_color=(0.0, 0.5, 1.1)) -def test_streaming_cfg_fields_on_visualizer_cfg(): - """streaming_view is opt-in (False) and streaming_cam_renderer defaults to None.""" - cfg = VisualizerCfg() - assert cfg.streaming_view is False - assert cfg.streaming_cam_renderer is None - - # # Base visualizer (env filtering, camera pose) # @@ -178,8 +163,8 @@ def test_compute_visualized_env_ids_cap_only_returns_none(): The cap is applied later by ``resolve_visible_env_indices``. """ - viz = _DummyVisualizer(_make_cfg(visible_env_indices=None)) - viz._scene_data_provider = _FakeProvider(num_envs=8) + viz = _DummyVisualizer(_make_cfg(max_visible_envs=3, visible_env_indices=None)) + viz._scene_data_provider = _FakeProvider(num_envs=10) assert viz._compute_visualized_env_ids() is None @@ -189,19 +174,6 @@ def test_compute_visualized_env_ids_from_visible_indices_filters_out_of_range(): assert viz._compute_visualized_env_ids() == [0, 3] -@pytest.mark.skipif(not _HAS_ISAACLAB_VIZ, reason="isaaclab_visualizers not installed") -def test_partial_visualization_cap_only_uses_resolver(): - """With ``visible_env_indices`` unset, :func:`resolve_visible_env_indices` applies ``max_visible_envs``.""" - from isaaclab_visualizers.newton_adapter import resolve_visible_env_indices - - cfg = _make_cfg(max_visible_envs=3, visible_env_indices=None) - viz = _DummyVisualizer(cfg) - viz._scene_data_provider = _FakeProvider(num_envs=10) - assert viz._compute_visualized_env_ids() is None - assert resolve_visible_env_indices(None, cfg.max_visible_envs, 10) == [0, 1, 2] - assert resolve_visible_env_indices(None, 3, 10) == [0, 1, 2] - - @pytest.mark.skipif(not _HAS_ISAACLAB_VIZ, reason="isaaclab_visualizers not installed") def test_compute_visualized_env_ids_random_cap_only_sorted_once(): """Cap-only random mode returns a sorted sample; explicit indices ignore the flag.""" @@ -224,19 +196,6 @@ def test_compute_visualized_env_ids_random_cap_only_sorted_once(): assert viz2._compute_visualized_env_ids() == [1, 5] -@pytest.mark.skipif(not _HAS_ISAACLAB_VIZ, reason="isaaclab_visualizers not installed") -def test_explicit_visible_env_indices_truncated_by_max_visible_envs(): - """Explicit indices from :meth:`_compute_visualized_env_ids`; ``max_visible_envs`` truncates from the end.""" - from isaaclab_visualizers.newton_adapter import resolve_visible_env_indices - - cfg = _make_cfg(visible_env_indices=[0, 2, 4], max_visible_envs=1) - viz = _DummyVisualizer(cfg) - viz._scene_data_provider = _FakeProvider(num_envs=10) - ids = viz._compute_visualized_env_ids() - assert ids == [0, 2, 4] - assert resolve_visible_env_indices(ids, cfg.max_visible_envs, 10) == [0] - - def test_resolve_camera_pose_from_usd_path_uses_provider_transforms(): transforms = { "order": ["/World/envs/env_%d/Camera"], diff --git a/source/isaaclab_newton/changelog.d/tendon-test-clone-plan.skip b/source/isaaclab_newton/changelog.d/tendon-test-clone-plan.skip new file mode 100644 index 000000000000..a5d078aaaa72 --- /dev/null +++ b/source/isaaclab_newton/changelog.d/tendon-test-clone-plan.skip @@ -0,0 +1 @@ +Replicated the tendon property test's clone plan before resetting Newton, matching the explicit builder lifecycle. diff --git a/source/isaaclab_ov/changelog.d/benchmark-mock-optional-runtime.skip b/source/isaaclab_ov/changelog.d/benchmark-mock-optional-runtime.skip new file mode 100644 index 000000000000..717e427aa23b --- /dev/null +++ b/source/isaaclab_ov/changelog.d/benchmark-mock-optional-runtime.skip @@ -0,0 +1 @@ +Deferred optional OvPhysX tensor types until mock binding sets are constructed or randomized, keeping signature inspection and basic mock reads independent of the runtime wheel. diff --git a/source/isaaclab_ov/isaaclab_ov/test/fixtures/views/mock_ovphysx_bindings.py b/source/isaaclab_ov/isaaclab_ov/test/fixtures/views/mock_ovphysx_bindings.py index 33e90eeacfcf..c82abded7ed6 100644 --- a/source/isaaclab_ov/isaaclab_ov/test/fixtures/views/mock_ovphysx_bindings.py +++ b/source/isaaclab_ov/isaaclab_ov/test/fixtures/views/mock_ovphysx_bindings.py @@ -11,8 +11,6 @@ import numpy as np -from isaaclab_ov import tensor_types as TT - class MockTensorBinding: """Mock of ovphysx.TensorBinding that stores data in numpy arrays. @@ -330,6 +328,8 @@ def __init__( *, asset_kind: Literal["articulation", "rigid_object"] = "articulation", ): + from isaaclab_ov import tensor_types as TT + if asset_kind == "rigid_object": if num_joints != 0 or num_bodies != 1 or num_fixed_tendons != 0 or num_spatial_tendons != 0: raise ValueError( @@ -478,6 +478,8 @@ def view(self) -> MockOvPhysxView: def set_random_data(self) -> None: """Fill all bindings with random data.""" + from isaaclab_ov import tensor_types as TT + for b in self.bindings.values(): if not b._write_only: b.set_random_data() diff --git a/tools/test_settings.py b/tools/test_settings.py index af8a40cb4225..bcba63b6b65e 100644 --- a/tools/test_settings.py +++ b/tools/test_settings.py @@ -115,13 +115,8 @@ TESTS_TO_SKIP = [ # lab - "test_argparser_launch.py", # app.close issue - "test_build_simulation_context_nonheadless.py", # headless - "test_env_var_launch.py", # app.close issue - "test_kwarg_launch.py", # app.close issue # lab_tasks "test_record_video.py", # Failing - "test_tiled_camera_env.py", # Need to improve the logic # curobo / skillgen - require cuRobo installation; run via test-curobo and test-skillgen CI jobs *CUROBO_TESTS, # quarantined tests - run in dedicated CI job that does not block PR merges

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 V*2r!bw}XH`WqEbE8X42D{{ct~CK~_% diff --git a/source/isaaclab_visualizers/test/golden_images/franka_cloth/newton-newton-viewport.png b/source/isaaclab_visualizers/test/golden_images/franka_cloth/newton-newton-viewport.png deleted file mode 100644 index 1b5abb980c7a7747f87ec14fb2a00001fbd1dde4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 71134 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 O0000 None: - source = Path(__file__).parents[1] / "isaaclab_visualizers" / "kit" / "kit_visualizer.py" - tree = ast.parse(source.read_text()) - kit_visualizer = next(node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == "KitVisualizer") - writer_hook = next( - node - for node in kit_visualizer.body - if isinstance(node, ast.FunctionDef) and node.name == "visual_material_writer" - ) - assert any( - isinstance(node, ast.Return) and ast.unparse(node.value) == "FabricVisualMaterialWriter" - for node in ast.walk(writer_hook) - ) diff --git a/source/isaaclab_visualizers/test/test_newton_adapter.py b/source/isaaclab_visualizers/test/test_newton_adapter.py index 45828483ba05..de8d4947d680 100644 --- a/source/isaaclab_visualizers/test/test_newton_adapter.py +++ b/source/isaaclab_visualizers/test/test_newton_adapter.py @@ -32,25 +32,20 @@ ) -def test_expand_infinite_plane_scale_expands_non_positive_extents(): - assert expand_infinite_plane_scale((0.0, 0.0, 1.0, 0.0)) == ( - VISUALIZER_INFINITE_PLANE_SIZE, - VISUALIZER_INFINITE_PLANE_SIZE, - 1.0, - 0.0, - ) - assert expand_infinite_plane_scale((-1.0, 25.0)) == ( - VISUALIZER_INFINITE_PLANE_SIZE, - 25.0, - ) - assert expand_infinite_plane_scale((25.0, 0.0)) == ( - 25.0, - VISUALIZER_INFINITE_PLANE_SIZE, - ) - - -def test_expand_infinite_plane_scale_preserves_finite_extents(): - assert expand_infinite_plane_scale((100.0, 50.0, 1.0)) == (100.0, 50.0, 1.0) +@pytest.mark.parametrize( + ("scale", "expected"), + [ + ( + (0.0, 0.0, 1.0, 0.0), + (VISUALIZER_INFINITE_PLANE_SIZE, VISUALIZER_INFINITE_PLANE_SIZE, 1.0, 0.0), + ), + ((-1.0, 25.0), (VISUALIZER_INFINITE_PLANE_SIZE, 25.0)), + ((25.0, 0.0), (25.0, VISUALIZER_INFINITE_PLANE_SIZE)), + ((100.0, 50.0, 1.0), (100.0, 50.0, 1.0)), + ], +) +def test_expand_infinite_plane_scale(scale, expected): + assert expand_infinite_plane_scale(scale) == expected def test_log_geo_with_expanded_plane_scale_delegates_with_adjusted_plane_scale(): @@ -74,30 +69,23 @@ def _log_geo(*args): assert calls == [("box", 2, (0.0, 25.0), 0.0, True, None, True)] -def test_resolve_visible_env_indices_truncates_explicit_list(): - assert resolve_visible_env_indices([1, 3, 5], 2, 10) == [1, 3] - assert resolve_visible_env_indices([1, 3], 1, 10) == [1] - - -def test_resolve_visible_env_indices_deduplicates_before_truncating(): - assert resolve_visible_env_indices([1, 1, 3, 5], 2, 10) == [1, 3] - - -def test_resolve_visible_env_indices_explicit_full_list_when_no_cap(): - assert resolve_visible_env_indices([1, 3], None, 10) == [1, 3] - - -def test_resolve_visible_env_indices_cap_when_no_filter(): - # When _compute_visualized_env_ids is None, cap is max_visible_envs. - assert resolve_visible_env_indices(None, 3, 10) == [0, 1, 2] - - -def test_resolve_visible_env_indices_all_when_no_cap(): - assert resolve_visible_env_indices(None, None, 10) is None - - -def test_resolve_visible_env_indices_num_envs_zero_falls_through_like_newton(): - assert resolve_visible_env_indices(None, 5, 0) is None +@pytest.mark.parametrize( + ("env_ids", "max_visible_envs", "num_envs", "expected"), + [ + ([1, 3, 5], 2, 10, [1, 3]), + ([1, 3], 1, 10, [1]), + # Deduplicates before truncating. + ([1, 1, 3, 5], 2, 10, [1, 3]), + ([1, 3], None, 10, [1, 3]), + # Without a filter, the cap selects the first envs. + (None, 3, 10, [0, 1, 2]), + (None, None, 10, None), + # Zero envs falls through like Newton. + (None, 5, 0, None), + ], +) +def test_resolve_visible_env_indices(env_ids, max_visible_envs, num_envs, expected): + assert resolve_visible_env_indices(env_ids, max_visible_envs, num_envs) == expected def test_apply_viewer_visible_worlds_delegates_to_resolved(): @@ -117,54 +105,6 @@ def set_visible_worlds(self, worlds): assert calls[-1] is None -def test_newton_visualizer_cfg_exposes_viewer_options(): - cfg = NewtonGLVisualizerCfg(enable_picking=False, show_particles=True, particle_color=(0.1, 0.2, 0.3)) - - assert cfg.enable_picking is False - assert cfg.show_particles is True - assert cfg.particle_color == (0.1, 0.2, 0.3) - - -def test_newton_visualizer_log_mesh_stages_for_next_frame(): - viewer = Mock() - visualizer = _make_newton_visualizer(None) - visualizer._viewer = viewer - points = wp.zeros(3, dtype=wp.vec3) - indices = wp.zeros(3, dtype=wp.int32) - normals = wp.zeros(3, dtype=wp.vec3) - - visualizer.log_mesh( - "/surface", - points, - indices, - normals=normals, - color=(0.1, 0.2, 0.3), - roughness=0.2, - dynamic=True, - opacity=0.35, - ) - - viewer.log_mesh.assert_not_called() - visualizer._log_pending_meshes() - - viewer.log_mesh.assert_called_once_with( - "/surface", - points, - indices, - normals=normals, - uvs=None, - texture=None, - hidden=False, - backface_culling=True, - color=(0.1, 0.2, 0.3), - roughness=0.2, - metallic=None, - dynamic=True, - opacity=0.35, - ) - assert visualizer._pending_mesh_submissions == {} - - def test_newton_visualizer_log_mesh_keeps_latest_submission_per_name(): viewer = Mock() visualizer = _make_newton_visualizer(None) @@ -233,12 +173,6 @@ def instance(cls): assert registry.groups == {} -def test_newton_visualizer_cfg_exposes_world_spacing(): - cfg = NewtonGLVisualizerCfg(world_spacing=(2.0, 2.0, 0.0)) - - assert cfg.world_spacing == (2.0, 2.0, 0.0) - - def test_newton_visualizer_set_camera_view_updates_cfg_without_viewer(): visualizer = NewtonGLVisualizer(NewtonGLVisualizerCfg()) @@ -755,7 +689,19 @@ def test_newton_visualizer_logs_staged_mesh_inside_frame(monkeypatch): monkeypatch.setattr(NewtonManager, "get_contacts", lambda: None) monkeypatch.setattr(NewtonManager, "get_num_envs", lambda: 1) - visualizer.log_mesh("/surface", points, indices, dynamic=True) + normals = wp.zeros(3, dtype=wp.vec3) + + visualizer.log_mesh( + "/surface", + points, + indices, + normals=normals, + color=(0.1, 0.2, 0.3), + roughness=0.2, + dynamic=True, + opacity=0.35, + ) + assert viewer.events == [] visualizer.step(0.1) assert viewer.events == ["begin_frame", "log_state", "log_mesh", "end_frame"] @@ -764,18 +710,19 @@ def test_newton_visualizer_logs_staged_mesh_inside_frame(monkeypatch): points, indices, { - "normals": None, + "normals": normals, "uvs": None, "texture": None, "hidden": False, "backface_culling": True, - "color": None, - "roughness": None, + "color": (0.1, 0.2, 0.3), + "roughness": 0.2, "metallic": None, "dynamic": True, - "opacity": None, + "opacity": 0.35, }, ) + assert visualizer._pending_mesh_submissions == {} def test_newton_visualizer_logs_staged_mesh_for_bodyless_state(monkeypatch): @@ -963,13 +910,8 @@ def log_mesh(self, name, vertices, indices, normals=None, uvs=None, texture=None def test_newton_visualizer_cfg_distinct_types(): assert NewtonGLVisualizerCfg().visualizer_type == "newton_gl" assert NewtonRTXVisualizerCfg().visualizer_type == "newton_rtx" - # shared fields present on both - assert NewtonGLVisualizerCfg().show_particles is False - assert NewtonRTXVisualizerCfg().show_particles is False - assert NewtonGLVisualizerCfg().background_color == (0.3, 0.55, 0.82) - assert NewtonRTXVisualizerCfg().background_color == (0.3, 0.55, 0.82) - with pytest.raises(ValueError, match="three normalized RGB values"): - NewtonGLVisualizerCfg(background_color=(0.0, -0.1, 1.0)) + # Public viewer options are accepted as cfg fields. + NewtonGLVisualizerCfg(enable_picking=False, show_particles=True, particle_color=(0.1, 0.2, 0.3)) @pytest.mark.parametrize("color", [(0.1, 0.2, 0.3), None]) @@ -1006,13 +948,6 @@ def test_newton_rtx_receives_background_color( assert kwargs["background_color"] == color -def test_eye_lookat_to_pitch_yaw_horizontal(): - # looking straight along +X from origin → pitch=0, yaw=0 - pitch, yaw = _eye_lookat_to_pitch_yaw((0.0, 0.0, 0.0), (1.0, 0.0, 0.0)) - assert abs(pitch) < 1e-6 - assert abs(yaw) < 1e-6 - - def test_eye_lookat_to_pitch_yaw_looking_up(): # looking straight up → pitch=90 pitch, yaw = _eye_lookat_to_pitch_yaw((0.0, 0.0, 0.0), (0.0, 0.0, 1.0)) @@ -1020,16 +955,11 @@ def test_eye_lookat_to_pitch_yaw_looking_up(): def test_eye_lookat_to_pitch_yaw_diagonal(): - import math - - # eye at (4, -4, 3), lookat at (0, 0, 0) — typical IsaacLab default camera - eye = (4.0, -4.0, 3.0) - lookat = (0.0, 0.0, 0.0) - pitch, yaw = _eye_lookat_to_pitch_yaw(eye, lookat) - dx, dy, dz = -4.0, 4.0, -3.0 - length = math.sqrt(dx**2 + dy**2 + dz**2) - assert abs(pitch - math.degrees(math.asin(dz / length))) < 1e-5 - assert abs(yaw - math.degrees(math.atan2(dy, dx))) < 1e-5 + # eye at (4, -4, 3), lookat at (0, 0, 0) — typical IsaacLab default camera: + # pitch = asin(-3 / sqrt(41)) [deg], yaw = 135 [deg] (toward -X, +Y). + pitch, yaw = _eye_lookat_to_pitch_yaw((4.0, -4.0, 3.0), (0.0, 0.0, 0.0)) + assert pitch == pytest.approx(-27.938, abs=1e-3) + assert yaw == pytest.approx(135.0, abs=1e-5) def test_eye_lookat_to_pitch_yaw_degenerate_returns_zero(): @@ -1054,15 +984,6 @@ def test_newton_rtx_visualizer_render_rgb_array_returns_none_when_viewer_unavail assert visualizer.render_rgb_array() is None -def test_newton_gl_visualizer_render_rgb_array_returns_frame(): - frame = np.zeros((4, 6, 3), dtype=np.uint8) - viewer = SimpleNamespace(get_frame=lambda: SimpleNamespace(numpy=lambda: frame)) - visualizer = NewtonGLVisualizer(NewtonGLVisualizerCfg()) - visualizer._viewer = viewer - - assert visualizer.render_rgb_array() is frame - - def test_newton_rtx_visualizer_set_camera_view_uses_set_camera(): """RTX camera pose must route through set_camera(pos, pitch, yaw), not camera.look_at.""" set_camera_calls = [] @@ -1087,29 +1008,6 @@ def set_camera(self, pos, pitch, yaw): assert abs(abs(yaw) - 180.0) < 1e-5 -def test_newton_gl_visualizer_set_camera_view_uses_look_at(): - """GL camera pose must use camera.look_at, not set_camera.""" - look_at_calls = [] - - class _FakeGLCamera: - def __init__(self): - self.pos = None - - def look_at(self, target): - look_at_calls.append(tuple(target)) - - class _FakeGLViewer: - def __init__(self): - self.camera = _FakeGLCamera() - - visualizer = NewtonGLVisualizer(NewtonGLVisualizerCfg()) - visualizer._viewer = _FakeGLViewer() - - visualizer.set_camera_view((1.0, 2.0, 3.0), (0.0, 0.0, 1.0)) - - assert look_at_calls == [(0.0, 0.0, 1.0)] - - def test_newton_rtx_visualizer_fov_deferred_on_initialize(): """_apply_camera_focal_length should set _rtx_fov_pending, not raise.""" visualizer = NewtonRTXVisualizer(NewtonRTXVisualizerCfg()) @@ -1155,18 +1053,6 @@ def test_newton_rtx_visualizer_fov_retries_when_camera_absent(): assert visualizer._rtx_fov_pending is True # still pending, retry next frame -def test_newton_rtx_visualizer_streaming_view_enabled(): - # RTX streaming/tiled camera capture (via render_tiled_rgb_array) only needs the owned - # camera sensor, not the viewer, so _uses_streaming_view tracks cfg.streaming_view like - # the other Newton backends. Only the live on-screen preview panel stays unavailable, - # since ViewerRTX.log_image has no display sink (_log_streaming_image stays a no-op). - visualizer = NewtonRTXVisualizer(NewtonRTXVisualizerCfg(streaming_view=True)) - assert visualizer._uses_streaming_view() is True - - visualizer_off = NewtonRTXVisualizer(NewtonRTXVisualizerCfg(streaming_view=False)) - assert visualizer_off._uses_streaming_view() is False - - @pytest.mark.parametrize("backend", ["physx", "isaacsim_physx"]) def test_newton_rtx_visualizer_rejects_kit_physics_backend(monkeypatch, backend): """OVRTX is kitless and must fail fast instead of crashing the render thread on first step(). @@ -1235,3 +1121,9 @@ def _create_visualizer_camera(**kwargs): # must be present on RTX (it lives on the shared NewtonVisualizer base, not NewtonGLVisualizer). visualizer._log_streaming_image() assert hasattr(visualizer, "render_tiled_rgb_array") + + # With the streaming view disabled, no owned camera is created. + visualizer_off = NewtonRTXVisualizer(NewtonRTXVisualizerCfg(streaming_view=False)) + visualizer_off._setup_streaming_view(num_envs=1) + assert len(create_calls) == 1 + assert visualizer_off._camera_sensor is None diff --git a/source/isaaclab_visualizers/test/test_newton_visual_material.py b/source/isaaclab_visualizers/test/test_newton_visual_material.py index 9390fedb377e..6fa0c7525dab 100644 --- a/source/isaaclab_visualizers/test/test_newton_visual_material.py +++ b/source/isaaclab_visualizers/test/test_newton_visual_material.py @@ -3,12 +3,19 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Newton visualizer material-writer contract.""" +"""Visualizer material-writer contracts.""" from isaaclab_newton.physics import NewtonManager +from isaaclab_physx.renderers.visual_material import FabricVisualMaterialWriter +from isaaclab_visualizers.kit.kit_visualizer import KitVisualizer from isaaclab_visualizers.newton.newton_visualizer import NewtonVisualizer def test_newton_visualizer_exposes_shared_writer_factory() -> None: visualizer = object.__new__(NewtonVisualizer) assert visualizer.visual_material_writer == NewtonManager.create_visual_material_writer + + +def test_kit_visualizer_publishes_fabric_visual_material_writer() -> None: + visualizer = object.__new__(KitVisualizer) + assert visualizer.visual_material_writer is FabricVisualMaterialWriter diff --git a/source/isaaclab_visualizers/test/test_newton_visualizer_viewer_release.py b/source/isaaclab_visualizers/test/test_newton_visualizer_viewer_release.py index 214a373813c2..e9618dec3269 100644 --- a/source/isaaclab_visualizers/test/test_newton_visualizer_viewer_release.py +++ b/source/isaaclab_visualizers/test/test_newton_visualizer_viewer_release.py @@ -92,50 +92,6 @@ def _make_visualizer(viewer: _SpyRTXViewer | _SpyGLViewer | None) -> NewtonVisua return visualizer -def test_release_viewer_closes_before_clearing_reference() -> None: - """The viewer must be closed while the visualizer still references it.""" - viewer = _SpyRTXViewer() - visualizer = _make_visualizer(viewer) - - visualizer._release_viewer() - - assert viewer.close_calls == 1 - assert viewer.referenced_by_owner_at_close == [True] - assert visualizer._viewer is None - - -def test_release_viewer_propagates_failure_and_still_clears_reference() -> None: - """A teardown failure must reach the caller, but must not retain the viewer.""" - viewer = _SpyRTXViewer(raises=True) - visualizer = _make_visualizer(viewer) - - with pytest.raises(RuntimeError, match="Failed to create window"): - visualizer._release_viewer() - - assert viewer.close_calls == 1 - assert visualizer._viewer is None - - -def test_release_viewer_without_viewer_is_a_no_op() -> None: - """Releasing when no viewer is held must be harmless.""" - visualizer = _make_visualizer(None) - - visualizer._release_viewer() - - assert visualizer._viewer is None - - -def test_release_viewer_is_idempotent() -> None: - """Releasing twice must not close the viewer twice.""" - viewer = _SpyRTXViewer() - visualizer = _make_visualizer(viewer) - - visualizer._release_viewer() - visualizer._release_viewer() - - assert viewer.close_calls == 1 - - def test_release_viewer_does_not_close_gl_viewer() -> None: """GL teardown must not prevent another viewer from starting in the same process.""" viewer = _SpyGLViewer() @@ -211,6 +167,7 @@ def test_close_completes_cleanup_when_viewer_teardown_fails(monkeypatch: pytest. with pytest.raises(RuntimeError, match="Failed to create window"): visualizer.close() + assert viewer.close_calls == 1 assert visualizer._viewer is None assert visualizer._camera_sensor is None assert visualizer._is_closed is True @@ -263,6 +220,10 @@ def test_step_failure_releases_the_viewer(monkeypatch: pytest.MonkeyPatch) -> No visualizer._viewer_picking_binding.apply(None) # type: ignore[arg-type] assert viewer.apply_forces_calls == 0 + # The later ``close()`` must not close the already-released viewer again. + visualizer.close() + assert viewer.close_calls == 1 + def test_step_contains_a_failing_viewer_teardown(monkeypatch: pytest.MonkeyPatch) -> None: """A viewer that fails to close must not abort training from ``step()``. diff --git a/source/isaaclab_visualizers/test/test_visualizer_integration_newton.py b/source/isaaclab_visualizers/test/test_visualizer_integration_newton.py index 4eaad4d3beb7..e395a856bd88 100644 --- a/source/isaaclab_visualizers/test/test_visualizer_integration_newton.py +++ b/source/isaaclab_visualizers/test/test_visualizer_integration_newton.py @@ -41,6 +41,7 @@ def test_cartpole_env_visualizers_motion_with_play_pause_newton( _viz_utils.assert_no_newton_imgui_bundle_warning(capsys, caplog) +@pytest.mark.flaky(max_runs=2, min_passes=1) def test_visualizer_tiled_integration_newton( caplog: pytest.LogCaptureFixture, capsys: pytest.CaptureFixture[str] ) -> None: diff --git a/source/isaaclab_visualizers/test/test_visualizer_tiled_integration_newton.py b/source/isaaclab_visualizers/test/test_visualizer_tiled_integration_newton.py deleted file mode 100644 index 09a79af80c84..000000000000 --- a/source/isaaclab_visualizers/test/test_visualizer_tiled_integration_newton.py +++ /dev/null @@ -1,44 +0,0 @@ -# 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 - -"""Cartpole env + tiled Kit/Newton visualizers on Newton MJWarp.""" - -import sys -from pathlib import Path - -from isaaclab.app import AppLauncher - -# launch Kit app -simulation_app = AppLauncher( - headless=True, - enable_cameras=True, - visualizer_intent={"has_any_visualizers": True, "has_kit_visualizer": True}, -).app - -import pytest # noqa: E402 - -_TEST_DIR = Path(__file__).resolve().parent -if str(_TEST_DIR) not in sys.path: - sys.path.insert(0, str(_TEST_DIR)) - -import visualizer_integration_utils as _viz_utils # noqa: E402 - -_viz_utils.set_visualizer_integration_simulation_app(simulation_app) - -run_cartpole_env_visualizers_tiled_camera_motion = _viz_utils.run_cartpole_env_visualizers_tiled_camera_motion - -pytestmark = [pytest.mark.isaacsim_ci, pytest.mark.flaky(max_runs=2, min_passes=1)] - - -def test_visualizer_tiled_integration_newton( - caplog: pytest.LogCaptureFixture, capsys: pytest.CaptureFixture[str] -) -> None: - """Cartpole env + tiled Kit/Newton visualizers on Newton MJWarp.""" - run_cartpole_env_visualizers_tiled_camera_motion("newton", caplog) - _viz_utils.assert_no_newton_imgui_bundle_warning(capsys, caplog) - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/source/isaaclab_visualizers/test/visualizer_golden_utils.py b/source/isaaclab_visualizers/test/visualizer_golden_utils.py index 0cf9a35b1277..9caa98b7bf43 100644 --- a/source/isaaclab_visualizers/test/visualizer_golden_utils.py +++ b/source/isaaclab_visualizers/test/visualizer_golden_utils.py @@ -65,12 +65,6 @@ # separately guards against structural regressions (wrong pose → SSIM ~0.85). "shadow_hand-newton-tiled": 2.0, "shadow_hand-newton-viewport": 2.0, - # 12 prior Newton tests leave RTX GI/specular state shifted; ~16% pixels exceed L2-norm-20. - # SSIM (0.960) still catches structural regressions (wrong pose → SSIM 0.69). - "franka_cloth-kit-tiled": 20.0, - # Kit RTX TAA accumulates ~14 prior test scenes; image is a contaminated blend. - # Observed 10.5–10.6%. SSIM separately catches pose regressions. - "franka_cloth-kit-viewport": 12.0, } _SSIM_THRESHOLD_OVERRIDES: dict[str, float] = { @@ -93,9 +87,6 @@ # Newton GL cross-GPU variation; observed SSIM 0.977–0.985 across GPU models. "shadow_hand-newton-tiled": 0.970, "shadow_hand-newton-viewport": 0.970, - # Kit RTX TAA accumulates 14 prior test scenes; blurry contaminated blend → SSIM ~0.867. - # Wrong-pose regressions drop SSIM below 0.80. - "franka_cloth-kit-viewport": 0.850, } _COMPARISON_IMAGES_DIR = os.path.join(os.getcwd(), "tests", "comparison-images") @@ -572,71 +563,6 @@ def _capture_frame(env, viz_type: str, capture_mode: str, backend: str, actions: _viz_utils._cleanup_visualizer_test_process(env) -def run_visualizer_golden_franka_cloth( - physics_backend: str, - visualizer_type: str, - mode: str, - comparison_scores: list[dict], -) -> None: - """Run a golden-image test for franka cloth + one ``(visualizer_type, mode)`` combination. - - Franka cloth uses Newton VBD cloth physics exclusively; *physics_backend* is - accepted for API consistency with the other scene runners but must be ``"newton"``. - - Args: - physics_backend: Must be ``"newton"``; franka cloth has no PhysX preset. - visualizer_type: ``"kit"`` (RTX viewport) or ``"newton"`` (OpenGL). - mode: ``"viewport"`` (main viewer frame) or ``"tiled"`` (composite tiled camera). - comparison_scores: Module-level accumulator forwarded to :func:`validate_visualizer_frame`. - """ - assert physics_backend == "newton", "FrankaCloth env has no PhysX preset; physics_backend must be 'newton'." - - import torch - import visualizer_integration_utils as _viz_utils - - import isaaclab.sim as sim_utils - - def _capture_frame(env, viz_type: str, capture_mode: str, actions: torch.Tensor): - if capture_mode == "tiled": - return _viz_utils._capture_visualizer_tiled_camera_rgb(_get_active_visualizer(env, viz_type)) - if viz_type == "kit": - # Warm up RTX TAA without advancing the cloth simulation. - return _viz_utils._capture_kit_viewport_with_pose_reapply( - env, - _get_active_visualizer(env, "kit"), - resolution=_viz_utils._FRANKA_CLOTH_KIT_INTEGRATION_RENDER_RESOLUTION, - max_warmup_frames=_viz_utils._FRANKA_CLOTH_KIT_VIEWPORT_WARMUP_FRAMES, - app_updates_only=True, - ) - newton_viz = _get_active_visualizer(env, "newton") - viewer = getattr(newton_viz, "_viewer", None) - assert viewer is not None, "NewtonVisualizer did not create a viewer." - _viz_utils._warm_newton_viewer(newton_viz) - return newton_viz.render_rgb_array() - - env = None - try: - _viz_utils._prepare_visualizer_test_process() - sim_utils.create_new_stage() - tiled = mode == "tiled" - env = _viz_utils._make_franka_cloth_env(visualizer_type, tiled_camera=tiled) - _viz_utils._configure_sim_for_visualizer_test(env) # type: ignore[arg-type] - actions = torch.zeros((env.num_envs, env.action_space.shape[-1]), device=env.device) - from isaaclab.utils.seed import configure_seed - - configure_seed(42, torch_deterministic=True) - env.reset() - - for _ in range(_viz_utils._FRANKA_CLOTH_WARMUP_STEPS): - env.step(action=actions) - - frame = _capture_frame(env, visualizer_type, mode, actions) - - validate_visualizer_frame("franka_cloth", physics_backend, visualizer_type, mode, frame, comparison_scores) - finally: - _viz_utils._cleanup_visualizer_test_process(env) - - def make_determinism_fixture(): """Create an autouse fixture that seeds RNG determinism for each test.""" diff --git a/source/isaaclab_visualizers/test/visualizer_integration_utils.py b/source/isaaclab_visualizers/test/visualizer_integration_utils.py index dc99ff79b925..8cc7f33ecb0e 100644 --- a/source/isaaclab_visualizers/test/visualizer_integration_utils.py +++ b/source/isaaclab_visualizers/test/visualizer_integration_utils.py @@ -43,7 +43,6 @@ from isaaclab.sim import SimulationContext from isaaclab_tasks.core.cartpole.cartpole_direct_camera_env import CartpoleCameraEnv -from isaaclab_tasks.core.cartpole.cartpole_manager_env_cfg import CartpolePhysicsCfg from isaaclab_tasks.core.reorient.reorient_direct_env import ReorientDirectEnv from isaaclab_tasks.utils import resolve_task_config @@ -141,9 +140,6 @@ _WARMUP_MAX_FRAMES = 50 """Hard cap on render frames pumped during convergence-based warmup.""" -_FRANKA_CLOTH_KIT_VIEWPORT_WARMUP_FRAMES = 20 -"""Bounded RTX TAA warmup for the Franka cloth viewport capture.""" - _WARMUP_STABLE_DIFF_PCT = 0.5 """Fraction of pixels (%) with inter-frame L2 > 1.0 below which two consecutive frames are considered stable (renderer TAA has converged). Used by :func:`_frames_converged`.""" @@ -396,41 +392,6 @@ def _get_visualizer_cfg(visualizer_kind: str, *, tiled_camera: bool = False, all ) -def _get_physics_cfg(backend_kind: str): - """Return physics config and expected backend substring for the given backend kind.""" - if backend_kind == "physx": - __import__("isaaclab_physx") - preset = CartpolePhysicsCfg() - physics_cfg = getattr(preset, "physx", None) - if physics_cfg is None: - from isaaclab_physx.physics import PhysxCfg - - physics_cfg = PhysxCfg() - return physics_cfg, "physx" - if backend_kind == "newton": - __import__("newton") - __import__("isaaclab_newton") - preset = CartpolePhysicsCfg() - physics_cfg = getattr(preset, "newton_mjwarp", None) - if physics_cfg is None: - from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg - - physics_cfg = NewtonCfg( - solver_cfg=MJWarpSolverCfg( - njmax=5, - nconmax=3, - cone="pyramidal", - impratio=1, - integrator="implicitfast", - ), - num_substeps=1, - debug_mode=False, - use_cuda_graph=True, - ) - return physics_cfg, "newton" - raise ValueError(f"Unknown backend: {backend_kind!r}") - - def _frame_to_numpy(frame) -> np.ndarray: """Convert viewer ``get_frame()`` output (numpy, torch, or Warp array) to host ``numpy.ndarray``. @@ -1059,8 +1020,6 @@ def _capture_kit_viewport_with_pose_reapply( kit_visualizer: KitVisualizer, resolution: tuple[int, int] | None = None, physics_backend: str = "", - max_warmup_frames: int | None = None, - app_updates_only: bool = False, ) -> np.ndarray: """Set the configured eye/lookat, warm RTX, then capture. @@ -1072,11 +1031,6 @@ def _capture_kit_viewport_with_pose_reapply( kit_visualizer: The active :class:`KitVisualizer` instance. resolution: Optional ``(width, height)`` override for the render product. physics_backend: ``"newton"`` to enable per-render camera reapply. - max_warmup_frames: When set, overrides the default convergence cap. Use a - small value when per-frame render cost is very high and test thresholds - are loose enough that convergence is not required (e.g. franka cloth RTX). - app_updates_only: When True, warms RTX with ``app.update()`` ticks instead - of ``env.sim.render()``. """ kit_visualizer.set_camera_view(kit_visualizer.cfg.eye, kit_visualizer.cfg.lookat) camera_path = getattr(kit_visualizer, "_controlled_camera_path", None) @@ -1100,13 +1054,7 @@ def _capture_kit_viewport_with_pose_reapply( break prev = curr else: - _warm_kit_rtx_render_product( - env, - annotator, - use_convergence=True, - max_frames_override=max_warmup_frames, - app_updates_only=app_updates_only, - ) + _warm_kit_rtx_render_product(env, annotator, use_convergence=True) return _capture_kit_viewport_rgb(annotator) finally: with contextlib.suppress(Exception): @@ -1118,8 +1066,6 @@ def _warm_kit_rtx_render_product( annotator, *, use_convergence: bool = False, - max_frames_override: int | None = None, - app_updates_only: bool = False, ) -> None: """Pump Kit/RTX until the annotator produces stable frames. @@ -1127,19 +1073,11 @@ def _warm_kit_rtx_render_product( :data:`_KIT_RTX_RENDER_PRODUCT_WARMUP_STEPS` iterations — the original behavior. When True (used by golden image captures), continues until two consecutive frames satisfy :func:`_frames_converged` or :data:`_WARMUP_MAX_FRAMES` is reached. - When ``max_frames_override`` is set, it replaces both caps above — useful when the - per-frame render cost is high and loose thresholds make convergence unnecessary. - When ``app_updates_only`` is True, warms RTX with ``app.update()`` calls without - advancing the visualizers. """ - if max_frames_override is not None: - max_frames = max_frames_override - else: - max_frames = _WARMUP_MAX_FRAMES if use_convergence else _KIT_RTX_RENDER_PRODUCT_WARMUP_STEPS + max_frames = _WARMUP_MAX_FRAMES if use_convergence else _KIT_RTX_RENDER_PRODUCT_WARMUP_STEPS prev: np.ndarray | None = None for i in range(max_frames): - if not app_updates_only: - env.sim.render() + env.sim.render() _update_active_simulation_app() with contextlib.suppress(Exception): annotator.get_data() @@ -1602,160 +1540,12 @@ def _make_anymal_d_env(visualizer_kind: str | tuple[str, ...], backend_kind: str return ManagerBasedRLEnv(env_cfg) -_FRANKA_CLOTH_INTEGRATION_NUM_ENVS = 1 -"""Vectorized env count for franka cloth + visualizer golden-image tests (viewport mode).""" - -_FRANKA_CLOTH_TILED_CAMERA_INTEGRATION_NUM_ENVS = 4 -"""Vectorized env count for franka cloth + visualizer golden-image tests (tiled mode).""" - -_FRANKA_CLOTH_INTEGRATION_VISUALIZER_EYE: tuple[float, float, float] = (0.85, -0.55, 0.42) -"""Franka cloth golden test camera eye: used for the Newton GL viewer and the tiled camera offset.""" - -_FRANKA_CLOTH_INTEGRATION_VISUALIZER_LOOKAT: tuple[float, float, float] = (0.45, 0.0, 0.2) -"""Franka cloth golden test camera lookat: cloth and cube on the table.""" - -_FRANKA_CLOTH_KIT_VIEWPORT_EYE: tuple[float, float, float] = (2.5, -2.0, 2.0) -"""Kit RTX viewport-specific eye for franka cloth. - -The Kit viewport uses a narrower FOV (~60°) than the Newton GL viewer, so the -same (0.85, -0.55, 0.42) eye that works for the Newton viewer clips the upper -arm joints out of frame. Pulling the camera back and up lets the RTX viewport -show the full robot arm, cloth, and table in one frame. -""" - -_FRANKA_CLOTH_KIT_VIEWPORT_LOOKAT: tuple[float, float, float] = (0.3, 0.0, 0.6) -"""Kit RTX viewport-specific lookat for franka cloth (aimed at mid-arm height).""" - -_FRANKA_CLOTH_INTEGRATION_TILED_CAMERA_EYE_OFFSET: tuple[float, float, float] = _FRANKA_CLOTH_INTEGRATION_VISUALIZER_EYE -"""Target-relative eye offset for franka cloth generated tiled cameras. - -Computed as the raw eye world position rather than ``eye - lookat``. The -tiled camera always looks AT the target prim (robot root at z≈0), so using -the full eye position as offset places the camera higher (z=0.42 m) and -farther back, giving a wide enough field of view to show both the robot arm -and the cloth on the floor. Using ``eye - lookat`` (the cartpole convention) -would only put the camera at z=0.22 m — too low to see the cloth. -""" - -_FRANKA_CLOTH_KIT_INTEGRATION_RENDER_RESOLUTION: tuple[int, int] = (400, 400) -"""Kit render product resolution for franka cloth viewport golden tests.""" - -_FRANKA_CLOTH_NEWTON_INTEGRATION_WINDOW_SIZE: tuple[int, int] = (400, 400) -"""Newton viewer framebuffer size for franka cloth golden tests.""" - -_FRANKA_CLOTH_VISUALIZER_TILED_CAMERA_NUM_TILES = 4 -"""Number of generated tiled camera tiles for franka cloth golden tests.""" - -_FRANKA_CLOTH_VISUALIZER_TILED_CAMERA_TARGET_PRIM_PATH = "/World/envs/*/Robot" -"""Franka robot prim followed by generated tiled cameras (stable reference near the cloth).""" - -_FRANKA_CLOTH_WARMUP_STEPS = 1 -"""Steps after reset before capturing the franka cloth scene. - -One step lets the cloth begin falling under gravity while remaining in a nearly-deterministic pose — the -VBD solver's non-deterministic parallel reductions accumulate over many steps, so capturing -at 1 step keeps inter-run pixel variance much lower than at 20 steps. -This mirrors the approach used in the kitless rendering tests in -``source/isaaclab_tasks/test/rendering_test_utils.py``. -""" - - -def _resolve_nucleus_url_to_local(url: str) -> str: - """Return the local /tmp path for a nucleus S3 URL if the file already exists there. - - Nucleus URLs follow the pattern ``https:///Assets/Isaac//...``. - ``retrieve_file_path`` downloads them into ``/tmp`` mirroring the URL path, so - the cached file lives at ``/tmp/Assets/Isaac//...``. When network is - unavailable (sandbox) and the asset was not previously fetched via omni.client - (so the hash-based omni.client cache is cold), ``check_file_path`` returns 0 - and the spawner raises ``FileNotFoundError`` even though the file is present - locally. This helper lets us point the spawner directly at the local copy. - """ - import re - import tempfile - - m = re.match(r"https?://[^/]+(/.*)", url) - if m: - local = os.path.join(tempfile.gettempdir(), m.group(1).lstrip("/")) - if os.path.isfile(local): - return local - return url - - def _compose_task_cfg(task_id: str, physics: str, *overrides: str): """Compose a registered task with concrete physics and optional Hydra overrides.""" env_cfg, _ = resolve_task_config(task_id, "", overrides=(f"physics={physics}", *overrides)) return env_cfg -def _make_franka_cloth_env(visualizer_kind: str | tuple[str, ...], *, tiled_camera: bool = False): - """Create a franka cloth env configured with the selected visualizer on the Newton backend. - - Franka cloth uses Newton VBD cloth physics exclusively. - """ - from isaaclab.envs import ManagerBasedRLEnv - - env_cfg = _compose_task_cfg("Isaac-Lift-Soft-Franka", "newton_mjwarp_vbd_proxy") - # Remap nucleus S3 URLs to local /tmp cache so the test works offline when the - # omni.client hash cache is cold (shadow hand / AnymalD are warm from prior runs). - env_cfg.scene.robot.spawn.usd_path = _resolve_nucleus_url_to_local(env_cfg.scene.robot.spawn.usd_path) - env_cfg.scene.table.spawn.usd_path = _resolve_nucleus_url_to_local(env_cfg.scene.table.spawn.usd_path) - env_cfg.scene.num_envs = ( - _FRANKA_CLOTH_TILED_CAMERA_INTEGRATION_NUM_ENVS if tiled_camera else _FRANKA_CLOTH_INTEGRATION_NUM_ENVS - ) - # Override from the default "asset_root" origin so absolute eye/lookat work correctly. - env_cfg.viewer.origin_type = "world" - env_cfg.viewer.eye = _FRANKA_CLOTH_INTEGRATION_VISUALIZER_EYE - env_cfg.viewer.lookat = _FRANKA_CLOTH_INTEGRATION_VISUALIZER_LOOKAT - env_cfg.seed = None - cam = {"eye": _FRANKA_CLOTH_INTEGRATION_VISUALIZER_EYE, "lookat": _FRANKA_CLOTH_INTEGRATION_VISUALIZER_LOOKAT} - tiled_cam = ( - { - "streaming_view": True, - "streaming_envs": _FRANKA_CLOTH_VISUALIZER_TILED_CAMERA_NUM_TILES, - "streaming_sensor_prim_path": None, - "streaming_cam_eye": _FRANKA_CLOTH_INTEGRATION_TILED_CAMERA_EYE_OFFSET, - "streaming_cam_target_prim_path": _FRANKA_CLOTH_VISUALIZER_TILED_CAMERA_TARGET_PRIM_PATH, - } - if tiled_camera - else {} - ) - # Kit RTX viewport uses a wider/higher camera to capture the full robot arm - # (the Newton GL viewer has a wider FOV so the shared eye works there but not for Kit RTX). - kit_cam = { - "eye": _FRANKA_CLOTH_KIT_VIEWPORT_EYE, - "lookat": _FRANKA_CLOTH_KIT_VIEWPORT_LOOKAT, - } - visualizer_kinds = (visualizer_kind,) if isinstance(visualizer_kind, str) else tuple(visualizer_kind) - visualizer_cfgs = [] - for kind in visualizer_kinds: - if kind == "newton": - __import__("newton") - nw, nh = _FRANKA_CLOTH_NEWTON_INTEGRATION_WINDOW_SIZE - visualizer_cfgs.append( - NewtonGLVisualizerCfg( - headless=True, - window_width=nw, - window_height=nh, - randomly_sample_visible_envs=False, - **tiled_cam, - **cam, - ) - ) - else: - visualizer_cfgs.append( - KitVisualizerCfg( - window_width=_FRANKA_CLOTH_KIT_INTEGRATION_RENDER_RESOLUTION[0], - window_height=_FRANKA_CLOTH_KIT_INTEGRATION_RENDER_RESOLUTION[1], - randomly_sample_visible_envs=False, - **tiled_cam, - **kit_cam, - ) - ) - env_cfg.sim.visualizer_cfgs = visualizer_cfgs[0] if len(visualizer_cfgs) == 1 else visualizer_cfgs - return ManagerBasedRLEnv(env_cfg) - - def _make_cartpole_camera_env( visualizer_kind: str | tuple[str, ...], backend_kind: str, diff --git a/tools/conftest.py b/tools/conftest.py index cd0c2ac9237d..a6120bb4619d 100644 --- a/tools/conftest.py +++ b/tools/conftest.py @@ -81,7 +81,6 @@ def pytest_ignore_collect(collection_path, config): "test_visualizer_integration_physx.py": 4, "test_visualizer_integration_newton.py": 4, "test_visualizer_tiled_integration_physx.py": 4, - "test_visualizer_tiled_integration_newton.py": 4, } """Extra fresh-process attempts for visualizer tests that can enter stale render states.""" From 0de4bf74014da3dd4ad71ed98e40ecc4610da241 Mon Sep 17 00:00:00 2001 From: Mustafa H <34825877+StafaH@users.noreply.github.com> Date: Fri, 25 Sep 2026 01:39:05 -0700 Subject: [PATCH 08/10] [Tests] Prune isaaclab_tasks tests with the test-audit skill (#8020) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # `isaaclab_tasks` changed test files: 12:27 → 8:45 (−30%) This times the 42 `isaaclab_tasks` test files this PR touches, rendering files included. Each run goes through `pytest tools` on the same machine with a warm Warp cache, once on `develop` and once on this PR. Four kitless and cable rendering files fail on both sides, only from local golden-image and renderer environment differences. | | `develop` | this PR | |---|---:|---:| | Wall time (changed files) | 12:27 | **8:45** | | Rendering nodes, Kit jobs | 83 | 36 | | Rendering nodes, kitless jobs | 166 | 68 | | Test functions | 379 | 309 | | Test lines | 12,664 | 11,656 | | Golden images | – | 137 removed | The only change outside test files: `.github/workflows/build.yaml` no longer lists the deleted `test_rendering_franka_soft.py`. ## What changed Pruned with the `isaaclab-auditing-tests` skill (#8007), campaign mode. **Method.** Every test was marked keep, fix, merge or delete. Whenever a deletion's keeper does not carry the assertions verbatim, a deliberate production mutation proves the keeper fails. An independent review then restored every gap it found: the Pink `daqp` dependency row and the dict-form `distributed=False` no-op. - **Rendering:** - The minimal-mode (simple-shading) golden layer went down to the Shadow Hand rows. Cartpole's flat materials render the same at shading modes 3 and 2, so its golden images could not catch a mode regression. - Newton-default and always-skipped OVPhysX rows were dropped. - `test_rendering_franka_soft.py` was deleted; every one of its nodes always skipped. - The yellow-background kitless file is covered by `test_ovrtx_usd.py`. - **Deleted:** tests covered elsewhere: - hydra (13), `preset_cli` (6), distributed device resolution (10), runtime compatibility (4) - quaternion helper replays (covered by `test_math.py`) - three `rl_device` GPU-to-GPU rows - two determinism rows (385 s → 94 s) - **Merged:** camera preset tables (19 → 4 and 6 → 2 rows), kit-decision selectors, lazy-stub import kinds, and the Kuka OVPhysX presets. - **Fixed tests that could not fail:** - the hydra unknown-preset test - the `parse_env_cfg` override test - the kitless distributed test, which now uses the real resolver - the fake hand's `preserve_order` - the sampler negative weight - the XR feed link - SO-101 output order, which used to skip where it should fail ## Follow-ups - The OVPhysX smoke, MPM, Kuka visual-material, yellow-background and partition-bounds rendering files are not wired into CI. - `CameraCfg.validate_config` needs a core test. ## Release backport - [x] Backport this pull request to the active release branch after it merges into `develop` ## Checklist - [x] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html) - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [x] My changes generate no new warnings - [x] I have added a changelog fragment under `source//changelog.d/` for every touched package - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- .github/workflows/build.yaml | 1 - .../changelog.d/test-prune-tasks-tests.skip | 1 + .../benchmark/test_render_benchmark_cfg.py | 47 ++--- .../test/contrib/stack/test_so101_pose_ik.py | 41 +---- .../stack/test_so101_teleop_success.py | 11 +- .../stack/test_surface_gripper_task_cfg.py | 8 +- .../test/contrib/test_automate_soft_dtw.py | 10 -- .../test/contrib/test_forge_utils.py | 11 +- .../test/contrib/test_franka_pour_env_cfg.py | 2 +- .../contrib/test_gear_assembly_env_cfg.py | 20 +-- .../contrib/test_so101_teleop_pipeline.py | 35 +--- .../core/test_agent_preset_composition.py | 11 +- .../core/test_cartpole_camera_observations.py | 17 +- .../test/core/test_cartpole_camera_presets.py | 11 +- .../test/core/test_dexterous_task_math.py | 57 +----- .../test_distributed_device_resolution.py | 169 ++---------------- .../test/core/test_environment_determinism.py | 18 +- .../core/test_environments_isaacsim_physx.py | 1 - source/isaaclab_tasks/test/core/test_hydra.py | 159 +++------------- .../test/core/test_lazy_export_stubs.py | 50 +----- .../core/test_lift_kuka_ovphysx_presets.py | 13 +- .../test/core/test_ovphysx_camera_presets.py | 41 ++--- .../test/core/test_parse_cfg.py | 5 +- .../test/core/test_preset_cli.py | 149 --------------- .../test/core/test_preset_kit_decision.py | 11 +- .../test/core/test_reach_franka_presets.py | 8 +- .../test/core/test_rendering_cartpole.py | 4 +- .../core/test_rendering_cartpole_kitless.py | 6 +- .../test/core/test_rendering_franka_cable.py | 4 +- .../test/core/test_rendering_franka_soft.py | 37 ---- ...test_rendering_lift_kuka_hetero_kitless.py | 10 +- .../core/test_rendering_registered_tasks.py | 3 - .../test/core/test_rendering_shadow_hand.py | 4 +- .../test_rendering_shadow_hand_kitless.py | 6 +- ...rendering_shadow_hand_yellow_bg_kitless.py | 25 --- .../test/core/test_reorient_utils.py | 3 + .../test/core/test_rl_device_separation.py | 35 +--- .../test/core/test_runtime_compatibility.py | 28 --- .../core/test_shadow_hand_camera_presets.py | 109 +++-------- .../test/core/test_xr_camera_feed_presets.py | 26 +-- ...nderer-simple_shading_constant_diffuse.png | 3 - ...tx_renderer-simple_shading_diffuse_mdl.png | 3 - ...m_rtx_renderer-simple_shading_full_mdl.png | 3 - ...nderer-simple_shading_constant_diffuse.png | 3 - ...tx_renderer-simple_shading_diffuse_mdl.png | 3 - ...ovrtx_renderer-simple_shading_full_mdl.png | 3 - ...nderer-simple_shading_constant_diffuse.png | 3 - ...tx_renderer-simple_shading_diffuse_mdl.png | 3 - ...m_rtx_renderer-simple_shading_full_mdl.png | 3 - ...nderer-simple_shading_constant_diffuse.png | 3 - ...tx_renderer-simple_shading_diffuse_mdl.png | 3 - ...ovrtx_renderer-simple_shading_full_mdl.png | 3 - ...nderer-simple_shading_constant_diffuse.png | 3 - ...tx_renderer-simple_shading_diffuse_mdl.png | 3 - ...m_rtx_renderer-simple_shading_full_mdl.png | 3 - ...nderer-simple_shading_constant_diffuse.png | 3 - ...tx_renderer-simple_shading_diffuse_mdl.png | 3 - ...ovrtx_renderer-simple_shading_full_mdl.png | 3 - .../ovphysx-newton_renderer-depth.png | 3 - ...ysx-newton_renderer-distance_to_camera.png | 3 - ...ewton_renderer-distance_to_image_plane.png | 3 - ...-newton_renderer-instance_segmentation.png | 3 - .../ovphysx-newton_renderer-normals.png | 3 - .../ovphysx-newton_renderer-rgb.png | 3 - .../ovphysx-newton_renderer-rgba.png | 3 - ...-newton_renderer-semantic_segmentation.png | 3 - .../ovphysx-ovrtx_renderer-albedo.png | 3 - .../ovphysx-ovrtx_renderer-depth.png | 3 - ...hysx-ovrtx_renderer-distance_to_camera.png | 3 - ...ovrtx_renderer-distance_to_image_plane.png | 3 - .../ovphysx-ovrtx_renderer-motion_vectors.png | 3 - .../ovphysx-ovrtx_renderer-normals.png | 3 - .../ovphysx-ovrtx_renderer-rgb.png | 3 - .../ovphysx-ovrtx_renderer-rgba.png | 3 - ...x-ovrtx_renderer-semantic_segmentation.png | 3 - ...nderer-simple_shading_constant_diffuse.png | 3 - ...tx_renderer-simple_shading_diffuse_mdl.png | 3 - ...ovrtx_renderer-simple_shading_full_mdl.png | 3 - .../newton-isaacsim_rtx_renderer-albedo.png | 3 - .../newton-isaacsim_rtx_renderer-depth.png | 3 - ...aacsim_rtx_renderer-distance_to_camera.png | 3 - ...m_rtx_renderer-distance_to_image_plane.png | 3 - ...renderer-instance_id_segmentation_fast.png | 3 - ...sim_rtx_renderer-instance_segmentation.png | 3 - .../newton-isaacsim_rtx_renderer-normals.png | 3 - .../newton-isaacsim_rtx_renderer-rgb.png | 3 - .../newton-isaacsim_rtx_renderer-rgba.png | 3 - ...sim_rtx_renderer-semantic_segmentation.png | 3 - ...nderer-simple_shading_constant_diffuse.png | 3 - ...tx_renderer-simple_shading_diffuse_mdl.png | 3 - ...m_rtx_renderer-simple_shading_full_mdl.png | 3 - ...nderer-simple_shading_constant_diffuse.png | 3 - ...tx_renderer-simple_shading_diffuse_mdl.png | 3 - ...ovrtx_renderer-simple_shading_full_mdl.png | 3 - .../ovphysx-newton_renderer-depth.png | 3 - ...ysx-newton_renderer-distance_to_camera.png | 3 - ...ewton_renderer-distance_to_image_plane.png | 3 - ...-newton_renderer-instance_segmentation.png | 3 - .../ovphysx-newton_renderer-normals.png | 3 - .../ovphysx-newton_renderer-rgb.png | 3 - .../ovphysx-newton_renderer-rgba.png | 3 - ...-newton_renderer-semantic_segmentation.png | 3 - .../ovphysx-ovrtx_renderer-albedo.png | 3 - .../ovphysx-ovrtx_renderer-depth.png | 3 - ...hysx-ovrtx_renderer-distance_to_camera.png | 3 - ...ovrtx_renderer-distance_to_image_plane.png | 3 - .../ovphysx-ovrtx_renderer-motion_vectors.png | 3 - .../ovphysx-ovrtx_renderer-normals.png | 3 - .../ovphysx-ovrtx_renderer-rgb.png | 3 - .../ovphysx-ovrtx_renderer-rgba.png | 3 - ...x-ovrtx_renderer-semantic_segmentation.png | 3 - ...nderer-simple_shading_constant_diffuse.png | 3 - ...tx_renderer-simple_shading_diffuse_mdl.png | 3 - ...ovrtx_renderer-simple_shading_full_mdl.png | 3 - .../physx-isaacsim_rtx_renderer-albedo.png | 3 - .../physx-isaacsim_rtx_renderer-depth.png | 3 - ...aacsim_rtx_renderer-distance_to_camera.png | 3 - ...m_rtx_renderer-distance_to_image_plane.png | 3 - ...renderer-instance_id_segmentation_fast.png | 3 - ...sim_rtx_renderer-instance_segmentation.png | 3 - ...x-isaacsim_rtx_renderer-motion_vectors.png | 3 - .../physx-isaacsim_rtx_renderer-normals.png | 3 - .../physx-isaacsim_rtx_renderer-rgb.png | 3 - .../physx-isaacsim_rtx_renderer-rgba.png | 3 - ...sim_rtx_renderer-semantic_segmentation.png | 3 - ...nderer-simple_shading_constant_diffuse.png | 3 - ...tx_renderer-simple_shading_diffuse_mdl.png | 3 - ...m_rtx_renderer-simple_shading_full_mdl.png | 3 - .../physx-newton_renderer-depth.png | 3 - ...ysx-newton_renderer-distance_to_camera.png | 3 - ...ewton_renderer-distance_to_image_plane.png | 3 - .../physx-newton_renderer-normals.png | 3 - .../franka_soft/physx-newton_renderer-rgb.png | 3 - .../physx-newton_renderer-rgba.png | 3 - ...nderer-simple_shading_constant_diffuse.png | 3 - ...tx_renderer-simple_shading_diffuse_mdl.png | 3 - ...m_rtx_renderer-simple_shading_full_mdl.png | 3 - ...nderer-simple_shading_constant_diffuse.png | 3 - ...tx_renderer-simple_shading_diffuse_mdl.png | 3 - ...ovrtx_renderer-simple_shading_full_mdl.png | 3 - ...nderer-simple_shading_constant_diffuse.png | 3 - ...tx_renderer-simple_shading_diffuse_mdl.png | 3 - ...m_rtx_renderer-simple_shading_full_mdl.png | 3 - ...nderer-simple_shading_constant_diffuse.png | 3 - ...tx_renderer-simple_shading_diffuse_mdl.png | 3 - ...m_rtx_renderer-simple_shading_full_mdl.png | 3 - ...nderer-simple_shading_constant_diffuse.png | 3 - ...tx_renderer-simple_shading_diffuse_mdl.png | 3 - ...ovrtx_renderer-simple_shading_full_mdl.png | 3 - ...nderer-simple_shading_constant_diffuse.png | 3 - ...tx_renderer-simple_shading_diffuse_mdl.png | 3 - ...ovrtx_renderer-simple_shading_full_mdl.png | 3 - ...nderer-simple_shading_constant_diffuse.png | 3 - ...tx_renderer-simple_shading_diffuse_mdl.png | 3 - ...m_rtx_renderer-simple_shading_full_mdl.png | 3 - ...nderer-simple_shading_constant_diffuse.png | 3 - ...tx_renderer-simple_shading_diffuse_mdl.png | 3 - ...m_rtx_renderer-simple_shading_full_mdl.png | 3 - ...nderer-simple_shading_constant_diffuse.png | 3 - ...lt_renderer-simple_shading_diffuse_mdl.png | 3 - ...fault_renderer-simple_shading_full_mdl.png | 3 - ...default_physics-default_renderer-depth.png | 3 - .../default_physics-default_renderer-rgb.png | 3 - .../default_physics-default_renderer-rgba.png | 3 - ...default_renderer-semantic_segmentation.png | 3 - ...default_physics-default_renderer-depth.png | 3 - .../default_physics-default_renderer-rgb.png | 3 - .../default_physics-default_renderer-rgba.png | 3 - ...default_renderer-semantic_segmentation.png | 3 - ...nderer-simple_shading_constant_diffuse.png | 3 - ...tx_renderer-simple_shading_diffuse_mdl.png | 3 - ...m_rtx_renderer-simple_shading_full_mdl.png | 3 - ...nderer-simple_shading_constant_diffuse.png | 3 - ...tx_renderer-simple_shading_diffuse_mdl.png | 3 - ...ovrtx_renderer-simple_shading_full_mdl.png | 3 - .../newton-ovrtx-rgb.png | 3 - .../newton-ovrtx-rgba.png | 3 - .../test/rendering_test_utils.py | 77 ++++---- source/isaaclab_tasks/test/test_curriculum.py | 34 ++-- .../test/test_maybe_save_stage_golden.py | 24 --- .../test/test_parametrization_helpers.py | 138 ++------------ source/isaaclab_tasks/test/test_signals.py | 28 +-- 182 files changed, 250 insertions(+), 1669 deletions(-) create mode 100644 source/isaaclab_tasks/changelog.d/test-prune-tasks-tests.skip delete mode 100644 source/isaaclab_tasks/test/core/test_rendering_franka_soft.py delete mode 100644 source/isaaclab_tasks/test/core/test_rendering_shadow_hand_yellow_bg_kitless.py delete mode 100644 source/isaaclab_tasks/test/golden_images/cartpole/newton-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png delete mode 100644 source/isaaclab_tasks/test/golden_images/cartpole/newton-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png delete mode 100644 source/isaaclab_tasks/test/golden_images/cartpole/newton-isaacsim_rtx_renderer-simple_shading_full_mdl.png delete mode 100644 source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-simple_shading_constant_diffuse.png delete mode 100644 source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png delete mode 100644 source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-simple_shading_full_mdl.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_cable/newton-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_cable/newton-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_cable/newton-isaacsim_rtx_renderer-simple_shading_full_mdl.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_cable/newton-ovrtx_renderer-simple_shading_constant_diffuse.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_cable/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_cable/newton-ovrtx_renderer-simple_shading_full_mdl.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_cloth/newton-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_cloth/newton-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_cloth/newton-isaacsim_rtx_renderer-simple_shading_full_mdl.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_cloth/newton-ovrtx_renderer-simple_shading_constant_diffuse.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_cloth/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_cloth/newton-ovrtx_renderer-simple_shading_full_mdl.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-newton_renderer-depth.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-newton_renderer-distance_to_camera.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-newton_renderer-distance_to_image_plane.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-newton_renderer-instance_segmentation.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-newton_renderer-normals.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-newton_renderer-rgb.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-newton_renderer-rgba.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-newton_renderer-semantic_segmentation.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-ovrtx_renderer-albedo.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-ovrtx_renderer-depth.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-ovrtx_renderer-distance_to_camera.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-ovrtx_renderer-distance_to_image_plane.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-ovrtx_renderer-motion_vectors.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-ovrtx_renderer-normals.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-ovrtx_renderer-rgb.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-ovrtx_renderer-rgba.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-ovrtx_renderer-semantic_segmentation.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-ovrtx_renderer-simple_shading_constant_diffuse.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-ovrtx_renderer-simple_shading_diffuse_mdl.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_cloth/ovphysx-ovrtx_renderer-simple_shading_full_mdl.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/newton-isaacsim_rtx_renderer-albedo.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/newton-isaacsim_rtx_renderer-depth.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/newton-isaacsim_rtx_renderer-distance_to_camera.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/newton-isaacsim_rtx_renderer-distance_to_image_plane.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/newton-isaacsim_rtx_renderer-instance_id_segmentation_fast.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/newton-isaacsim_rtx_renderer-instance_segmentation.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/newton-isaacsim_rtx_renderer-normals.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/newton-isaacsim_rtx_renderer-rgb.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/newton-isaacsim_rtx_renderer-rgba.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/newton-isaacsim_rtx_renderer-semantic_segmentation.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/newton-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/newton-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/newton-isaacsim_rtx_renderer-simple_shading_full_mdl.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/newton-ovrtx_renderer-simple_shading_constant_diffuse.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/newton-ovrtx_renderer-simple_shading_full_mdl.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-newton_renderer-depth.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-newton_renderer-distance_to_camera.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-newton_renderer-distance_to_image_plane.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-newton_renderer-instance_segmentation.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-newton_renderer-normals.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-newton_renderer-rgb.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-newton_renderer-rgba.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-newton_renderer-semantic_segmentation.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-ovrtx_renderer-albedo.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-ovrtx_renderer-depth.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-ovrtx_renderer-distance_to_camera.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-ovrtx_renderer-distance_to_image_plane.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-ovrtx_renderer-motion_vectors.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-ovrtx_renderer-normals.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-ovrtx_renderer-rgb.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-ovrtx_renderer-rgba.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-ovrtx_renderer-semantic_segmentation.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-ovrtx_renderer-simple_shading_constant_diffuse.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-ovrtx_renderer-simple_shading_diffuse_mdl.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/ovphysx-ovrtx_renderer-simple_shading_full_mdl.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-albedo.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-depth.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-distance_to_camera.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-distance_to_image_plane.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-instance_id_segmentation_fast.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-instance_segmentation.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-motion_vectors.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-normals.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-rgb.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-rgba.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-semantic_segmentation.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/physx-isaacsim_rtx_renderer-simple_shading_full_mdl.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/physx-newton_renderer-depth.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/physx-newton_renderer-distance_to_camera.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/physx-newton_renderer-distance_to_image_plane.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/physx-newton_renderer-normals.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/physx-newton_renderer-rgb.png delete mode 100644 source/isaaclab_tasks/test/golden_images/franka_soft/physx-newton_renderer-rgba.png delete mode 100755 source/isaaclab_tasks/test/golden_images/lift_kuka_hetero/newton-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png delete mode 100644 source/isaaclab_tasks/test/golden_images/lift_kuka_hetero/newton-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png delete mode 100644 source/isaaclab_tasks/test/golden_images/lift_kuka_hetero/newton-isaacsim_rtx_renderer-simple_shading_full_mdl.png delete mode 100644 source/isaaclab_tasks/test/golden_images/lift_kuka_hetero/newton-ovrtx_renderer-simple_shading_constant_diffuse.png delete mode 100644 source/isaaclab_tasks/test/golden_images/lift_kuka_hetero/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png delete mode 100644 source/isaaclab_tasks/test/golden_images/lift_kuka_hetero/newton-ovrtx_renderer-simple_shading_full_mdl.png delete mode 100755 source/isaaclab_tasks/test/golden_images/lift_kuka_hetero/physx-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png delete mode 100644 source/isaaclab_tasks/test/golden_images/lift_kuka_hetero/physx-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png delete mode 100644 source/isaaclab_tasks/test/golden_images/lift_kuka_hetero/physx-isaacsim_rtx_renderer-simple_shading_full_mdl.png delete mode 100755 source/isaaclab_tasks/test/golden_images/lift_kuka_homo/newton-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png delete mode 100644 source/isaaclab_tasks/test/golden_images/lift_kuka_homo/newton-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png delete mode 100644 source/isaaclab_tasks/test/golden_images/lift_kuka_homo/newton-isaacsim_rtx_renderer-simple_shading_full_mdl.png delete mode 100644 source/isaaclab_tasks/test/golden_images/lift_kuka_homo/newton-ovrtx_renderer-simple_shading_constant_diffuse.png delete mode 100644 source/isaaclab_tasks/test/golden_images/lift_kuka_homo/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png delete mode 100644 source/isaaclab_tasks/test/golden_images/lift_kuka_homo/newton-ovrtx_renderer-simple_shading_full_mdl.png delete mode 100644 source/isaaclab_tasks/test/golden_images/lift_kuka_homo/ovphysx-ovrtx_renderer-simple_shading_constant_diffuse.png delete mode 100644 source/isaaclab_tasks/test/golden_images/lift_kuka_homo/ovphysx-ovrtx_renderer-simple_shading_diffuse_mdl.png delete mode 100644 source/isaaclab_tasks/test/golden_images/lift_kuka_homo/ovphysx-ovrtx_renderer-simple_shading_full_mdl.png delete mode 100755 source/isaaclab_tasks/test/golden_images/lift_kuka_homo/physx-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png delete mode 100644 source/isaaclab_tasks/test/golden_images/lift_kuka_homo/physx-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png delete mode 100644 source/isaaclab_tasks/test/golden_images/lift_kuka_homo/physx-isaacsim_rtx_renderer-simple_shading_full_mdl.png delete mode 100644 source/isaaclab_tasks/test/golden_images/mpm_particles/newton-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png delete mode 100644 source/isaaclab_tasks/test/golden_images/mpm_particles/newton-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png delete mode 100644 source/isaaclab_tasks/test/golden_images/mpm_particles/newton-isaacsim_rtx_renderer-simple_shading_full_mdl.png delete mode 100644 source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Cartpole-Camera-Direct/default_physics-default_renderer-simple_shading_constant_diffuse.png delete mode 100644 source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Cartpole-Camera-Direct/default_physics-default_renderer-simple_shading_diffuse_mdl.png delete mode 100644 source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Cartpole-Camera-Direct/default_physics-default_renderer-simple_shading_full_mdl.png delete mode 100644 source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Reorient-Cube-Shadow-Camera/default_physics-default_renderer-depth.png delete mode 100644 source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Reorient-Cube-Shadow-Camera/default_physics-default_renderer-rgb.png delete mode 100644 source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Reorient-Cube-Shadow-Camera/default_physics-default_renderer-rgba.png delete mode 100644 source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Reorient-Cube-Shadow-Camera/default_physics-default_renderer-semantic_segmentation.png delete mode 100644 source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Repose-Cube-Shadow-Vision-Direct-v0/default_physics-default_renderer-depth.png delete mode 100644 source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Repose-Cube-Shadow-Vision-Direct-v0/default_physics-default_renderer-rgb.png delete mode 100644 source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Repose-Cube-Shadow-Vision-Direct-v0/default_physics-default_renderer-rgba.png delete mode 100644 source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Repose-Cube-Shadow-Vision-Direct-v0/default_physics-default_renderer-semantic_segmentation.png delete mode 100755 source/isaaclab_tasks/test/golden_images/shadow_hand/newton-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png delete mode 100644 source/isaaclab_tasks/test/golden_images/shadow_hand/newton-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png delete mode 100644 source/isaaclab_tasks/test/golden_images/shadow_hand/newton-isaacsim_rtx_renderer-simple_shading_full_mdl.png delete mode 100644 source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-simple_shading_constant_diffuse.png delete mode 100644 source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png delete mode 100644 source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-simple_shading_full_mdl.png delete mode 100644 source/isaaclab_tasks/test/golden_images/shadow_hand_yellow_bg/newton-ovrtx-rgb.png delete mode 100644 source/isaaclab_tasks/test/golden_images/shadow_hand_yellow_bg/newton-ovrtx-rgba.png diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 9a8e7938e4a5..0eb5d8c4602b 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -815,7 +815,6 @@ jobs: test_rendering_lift_kuka_hetero.py, test_rendering_lift_kuka_homo.py, test_rendering_franka_cloth.py, - test_rendering_franka_soft.py, test_rendering_franka_cable.py, test_rendering_registered_tasks.py, test_rendering_shadow_hand.py diff --git a/source/isaaclab_tasks/changelog.d/test-prune-tasks-tests.skip b/source/isaaclab_tasks/changelog.d/test-prune-tasks-tests.skip new file mode 100644 index 000000000000..1f5443bfc501 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/test-prune-tasks-tests.skip @@ -0,0 +1 @@ +Pruned redundant isaaclab_tasks tests, parametrize rows, and golden images in a test-audit campaign. diff --git a/source/isaaclab_tasks/test/benchmark/test_render_benchmark_cfg.py b/source/isaaclab_tasks/test/benchmark/test_render_benchmark_cfg.py index ba072f6a81f6..b5b0b49015ed 100644 --- a/source/isaaclab_tasks/test/benchmark/test_render_benchmark_cfg.py +++ b/source/isaaclab_tasks/test/benchmark/test_render_benchmark_cfg.py @@ -37,15 +37,6 @@ def _load_cfg(): return load_cfg_from_registry(_TASK, "env_cfg_entry_point") -def test_task_registered_with_direct_entry_point(): - spec = gym.spec(_TASK) - - assert spec.kwargs["env_cfg_entry_point"] == ( - "isaaclab_tasks.benchmark.render_benchmark.render_benchmark_env_cfg:RenderBenchmarkFrankaCabinetEnvCfg" - ) - assert spec.disable_env_checker is True - - def test_default_scene_and_articulations(): cfg = _load_cfg() @@ -56,6 +47,7 @@ def test_default_scene_and_articulations(): assert cfg.scene.cabinet.prim_path == "{ENV_REGEX_NS}/Cabinet" assert cfg.joint_animation_amplitude == pytest.approx(0.4) assert cfg.benchmark_mode == "render" + assert gym.spec(_TASK).disable_env_checker is True @pytest.mark.parametrize( @@ -146,7 +138,6 @@ def test_benchmark_mode_orders_joint_updates_and_rendering(mode): ("presets", "expected_type"), [ ((), NewtonCfg), - (("newton_mjwarp",), NewtonCfg), (("physx",), PhysxAutoCfg), (("isaacsim_physx",), PhysxCfg), ], @@ -157,33 +148,29 @@ def test_physics_presets_resolve_to_expected_backend(presets, expected_type): assert isinstance(cfg.sim.physics, expected_type) -_CAMERA_DATA_TYPE_PRESETS = [ - ("default", ["rgb"]), - ("rgb", ["rgb"]), - ("albedo", ["albedo"]), - ("depth", ["depth"]), - ("simple_shading_constant_diffuse", ["simple_shading_constant_diffuse"]), - ("simple_shading_diffuse_mdl", ["simple_shading_diffuse_mdl"]), - ("simple_shading_full_mdl", ["simple_shading_full_mdl"]), -] - - @pytest.fixture(scope="module") def render_benchmark_presets(): """Collect every preset once, before any preset selection mutates the config in place.""" return collect_presets(_load_cfg()) -@pytest.mark.parametrize("preset_name,expected_data_types", _CAMERA_DATA_TYPE_PRESETS) -def test_camera_presets_resolve_to_expected_data_types(render_benchmark_presets, preset_name, expected_data_types): +def test_camera_presets_resolve_to_expected_data_types(render_benchmark_presets): + """Each camera preset requests the data type it is named after (``default`` is RGB).""" camera_presets = render_benchmark_presets["scene.tiled_camera"] - assert preset_name in camera_presets, f"Preset '{preset_name}' not found in tiled_camera presets" - resolved = camera_presets[preset_name] - - assert resolved.data_types == expected_data_types - assert resolved.width > 0 - assert resolved.height > 0 - assert isinstance(resolved.renderer_cfg.newton_renderer, NewtonWarpRendererCfg) + assert set(camera_presets) == { + "default", + "rgb", + "albedo", + "depth", + "simple_shading_constant_diffuse", + "simple_shading_diffuse_mdl", + "simple_shading_full_mdl", + } + for preset_name, resolved in camera_presets.items(): + assert resolved.data_types == (["rgb"] if preset_name == "default" else [preset_name]) + assert resolved.width > 0 + assert resolved.height > 0 + assert isinstance(resolved.renderer_cfg.newton_renderer, NewtonWarpRendererCfg) def test_camera_resolution_defaults_to_256(): diff --git a/source/isaaclab_tasks/test/contrib/stack/test_so101_pose_ik.py b/source/isaaclab_tasks/test/contrib/stack/test_so101_pose_ik.py index fbe0d0855388..01fbd819f859 100644 --- a/source/isaaclab_tasks/test/contrib/stack/test_so101_pose_ik.py +++ b/source/isaaclab_tasks/test/contrib/stack/test_so101_pose_ik.py @@ -98,47 +98,14 @@ def test_mask_none_leaves_orientation_unmasked(): torch.testing.assert_close(c.compute(*inputs), base.compute(*inputs)) -def test_compute_returns_joint_targets_shape(): - """End-to-end compute (adaptive DLS + orientation weight + mask + JLA) returns one target per - joint.""" - c = _make_controller(orientation_weight=0.5, joint_limit_avoidance_gain=0.5) - c.set_orientation_joint_mask(torch.tensor([0.0, 0.0, 0.0, 1.0, 1.0])) - c.set_joint_pos_limits(torch.full((_NUM_JOINTS,), -1.0), torch.full((_NUM_JOINTS,), 1.0)) - ee_pos = torch.tensor([[0.3, 0.0, 0.2]]) - ee_quat = torch.tensor([_ID_QUAT]) - c.set_command(torch.tensor([[0.31, 0.0, 0.2] + _ID_QUAT])) - jac = torch.zeros(1, 6, _NUM_JOINTS) - for i in range(_NUM_JOINTS): - jac[0, i, i] = 1.0 - out = c.compute(ee_pos, ee_quat, jac, torch.zeros(1, _NUM_JOINTS)) - assert out.shape == (1, _NUM_JOINTS) - - -def test_action_cfg_points_at_custom_action_and_controller(): - """The action cfg wires the custom action class and the SO-101 controller cfg.""" +def test_env_cfg_arm_action_is_pose_and_ordering_matches_pipeline(): + """The IK-Abs env wires the full-pose arm action, the wrist-only orientation mask, and the + 8D action ordering.""" pytest.importorskip("pxr") # the action term imports UsdPhysics at module load from isaaclab.utils.string import string_to_callable from isaaclab_tasks.contrib.stack.config.so101.pose_ik_action import SO101PoseIKActionCfg from isaaclab_tasks.contrib.stack.config.so101.pose_ik_action_term import SO101PoseIKAction - - cfg = SO101PoseIKActionCfg( - asset_name="robot", - joint_names=["shoulder_pan", "shoulder_lift", "elbow_flex", "wrist_flex", "wrist_roll"], - body_name="gripper", - controller=SO101PoseIKControllerCfg(command_type="pose", use_relative_mode=False, ik_method="adaptive_dls"), - ) - # ``class_type`` is a lazy ``{DIR}.pose_ik_action_term:SO101PoseIKAction`` string (so the cfg - # stays importable without Kit); resolve it to confirm it points at the custom term. - assert string_to_callable(str(cfg.class_type)) is SO101PoseIKAction - assert isinstance(cfg.controller, SO101PoseIKControllerCfg) - - -def test_env_cfg_arm_action_is_pose_and_ordering_matches_pipeline(): - """The IK-Abs env wires the full-pose arm action, the wrist-only orientation mask, and the - 8D action ordering.""" - pytest.importorskip("pxr") - from isaaclab_tasks.contrib.stack.config.so101.pose_ik_action import SO101PoseIKActionCfg from isaaclab_tasks.contrib.stack.config.so101.stack_ik_abs_env_cfg import ( SO101CubeStackEnvCfg, SO101IkActionsCfg, @@ -146,6 +113,8 @@ def test_env_cfg_arm_action_is_pose_and_ordering_matches_pipeline(): cfg = SO101CubeStackEnvCfg() assert isinstance(cfg.actions.arm_action, SO101PoseIKActionCfg) + # ``class_type`` is a lazy string (so the cfg stays importable without Kit); it must resolve to the custom term. + assert string_to_callable(str(cfg.actions.arm_action.class_type)) is SO101PoseIKAction controller = cfg.actions.arm_action.controller assert controller.command_type == "pose" assert controller.ik_method == "adaptive_dls" diff --git a/source/isaaclab_tasks/test/contrib/stack/test_so101_teleop_success.py b/source/isaaclab_tasks/test/contrib/stack/test_so101_teleop_success.py index e02737ab9df9..339e4b8d04d3 100644 --- a/source/isaaclab_tasks/test/contrib/stack/test_so101_teleop_success.py +++ b/source/isaaclab_tasks/test/contrib/stack/test_so101_teleop_success.py @@ -14,7 +14,6 @@ from types import SimpleNamespace -import pytest import torch from isaaclab_tasks.contrib.stack.config.so101.stack_joint_teleop_env_cfg import SO101CubeStackEnvCfg @@ -70,19 +69,17 @@ def _success(gripper_pos: float, stacked: bool = True) -> bool: return bool(term.func(_make_env(gripper_pos, stacked=stacked), **term.params)[0]) -@pytest.mark.parametrize("gripper_pos", [1.745, 1.5, 1.3]) -def test_success_fires_once_the_gripper_is_open_enough(gripper_pos): +def test_success_fires_once_the_gripper_is_open_enough(): """A completed stack terminates without opening the jaw to the very top of its range. 1.3 rad fails under the previous 0.2 rad tolerance, which is the regression this guards. """ - assert _success(gripper_pos) + assert _success(1.3) -@pytest.mark.parametrize("gripper_pos", [0.0, 0.5, 1.2]) -def test_success_does_not_fire_while_the_gripper_is_still_closing(gripper_pos): +def test_success_does_not_fire_while_the_gripper_is_still_closing(): """The window still has a lower bound, so success cannot fire while the cube is held.""" - assert not _success(gripper_pos) + assert not _success(1.2) def test_success_requires_the_documented_stack_order(): diff --git a/source/isaaclab_tasks/test/contrib/stack/test_surface_gripper_task_cfg.py b/source/isaaclab_tasks/test/contrib/stack/test_surface_gripper_task_cfg.py index 95c860ae9bb2..6f297072e2b1 100644 --- a/source/isaaclab_tasks/test/contrib/stack/test_surface_gripper_task_cfg.py +++ b/source/isaaclab_tasks/test/contrib/stack/test_surface_gripper_task_cfg.py @@ -17,18 +17,12 @@ @pytest.mark.parametrize("task_name", _SURFACE_GRIPPER_TASKS) def test_surface_gripper_tasks_default_to_cpu(task_name: str) -> None: - """Surface-gripper tasks should select their only supported simulation device.""" + """Surface-gripper tasks select their only supported simulation device and reject a GPU override.""" env_cfg = load_cfg_from_registry(task_name, "env_cfg_entry_point") assert env_cfg.sim.device == "cpu" env_cfg.validate() - -@pytest.mark.parametrize("task_name", _SURFACE_GRIPPER_TASKS) -def test_surface_gripper_tasks_reject_gpu_override(task_name: str) -> None: - """An explicit unsupported GPU device should fail during config validation.""" - env_cfg = load_cfg_from_registry(task_name, "env_cfg_entry_point") env_cfg.sim.device = "cuda:0" - with pytest.raises(ValueError, match="only supported on the CPU simulation device"): env_cfg.validate() diff --git a/source/isaaclab_tasks/test/contrib/test_automate_soft_dtw.py b/source/isaaclab_tasks/test/contrib/test_automate_soft_dtw.py index bd330e24a357..eefc0cf7d427 100644 --- a/source/isaaclab_tasks/test/contrib/test_automate_soft_dtw.py +++ b/source/isaaclab_tasks/test/contrib/test_automate_soft_dtw.py @@ -19,16 +19,6 @@ def _load_soft_dtw_module(): return module -def test_soft_dtw_use_cuda_does_not_require_numba(): - soft_dtw = _load_soft_dtw_module() - criterion = soft_dtw.SoftDTW(use_cuda=True, device="cuda", gamma=0.01) - - x = torch.zeros((1, 2, 3), dtype=torch.float32) - y = torch.zeros((1, 2, 3), dtype=torch.float32) - - assert criterion(x, y).shape == (1,) - - def test_soft_dtw_hard_dtw_value(): soft_dtw = _load_soft_dtw_module() criterion = soft_dtw.SoftDTW(use_cuda=False, device="cpu", gamma=0.0) diff --git a/source/isaaclab_tasks/test/contrib/test_forge_utils.py b/source/isaaclab_tasks/test/contrib/test_forge_utils.py index 0380c2f732bc..fe9264ab4f97 100644 --- a/source/isaaclab_tasks/test/contrib/test_forge_utils.py +++ b/source/isaaclab_tasks/test/contrib/test_forge_utils.py @@ -39,8 +39,7 @@ def _wrench_in_frame(points, forces, rot, pos): return (rot_t @ force.unsqueeze(-1)).squeeze(-1), (rot_t @ torque.unsqueeze(-1)).squeeze(-1) -@pytest.mark.parametrize("rotate_frames", [False, True]) -def test_change_FT_frame_matches_point_force_reference(rotate_frames): +def test_change_FT_frame_matches_point_force_reference(): forge_utils = _load_forge_utils_module() generator = torch.Generator().manual_seed(0) num = 64 @@ -51,12 +50,8 @@ def test_change_FT_frame_matches_point_force_reference(rotate_frames): source_pos = torch.randn((num, 3), generator=generator, dtype=torch.float64) target_pos = torch.randn((num, 3), generator=generator, dtype=torch.float64) - if rotate_frames: - source_rot = _random_rotation_matrices(num, generator) - target_rot = _random_rotation_matrices(num, generator) - else: - source_rot = torch.eye(3, dtype=torch.float64).expand(num, 3, 3) - target_rot = source_rot + source_rot = _random_rotation_matrices(num, generator) + target_rot = _random_rotation_matrices(num, generator) source_F, source_T = _wrench_in_frame(points, forces, source_rot, source_pos) expected_F, expected_T = _wrench_in_frame(points, forces, target_rot, target_pos) 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 093e7551d56b..ee55af5a70c7 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 @@ -65,7 +65,7 @@ def test_source_fill_level_controls_height_and_particle_count(): assert media_particle_count(media) == 7 * 7 * 21 -@pytest.mark.parametrize("fill_level", [0.0, -0.1, 1.1, float("nan")]) +@pytest.mark.parametrize("fill_level", [0.0, 1.1, float("nan")]) def test_source_fill_level_rejects_empty_or_out_of_range_tasks(fill_level): """A pouring episode needs a finite, non-empty fill no higher than the cup.""" cfg = FrankaPourResetDatasetEnvCfg() diff --git a/source/isaaclab_tasks/test/contrib/test_gear_assembly_env_cfg.py b/source/isaaclab_tasks/test/contrib/test_gear_assembly_env_cfg.py index ea7856925b7e..d0db96383f3f 100644 --- a/source/isaaclab_tasks/test/contrib/test_gear_assembly_env_cfg.py +++ b/source/isaaclab_tasks/test/contrib/test_gear_assembly_env_cfg.py @@ -7,30 +7,16 @@ import pytest -from isaaclab_tasks.contrib.deploy.gear_assembly.config.rizon_4s.joint_pos_env_cfg import Rizon4sGearAssemblyEnvCfg from isaaclab_tasks.contrib.deploy.gear_assembly.config.rizon_4s.ros_inference_env_cfg import ( Rizon4sGearAssemblyROSInferenceEnvCfg, ) -from isaaclab_tasks.contrib.deploy.gear_assembly.config.ur_10e.joint_pos_env_cfg import ( - UR10e2F85GearAssemblyEnvCfg, - UR10e2F140GearAssemblyEnvCfg, -) -from isaaclab_tasks.contrib.deploy.gear_assembly.config.ur_10e.ros_inference_env_cfg import ( - UR10e2F85GearAssemblyROSInferenceEnvCfg, - UR10e2F140GearAssemblyROSInferenceEnvCfg, -) +from isaaclab_tasks.contrib.deploy.gear_assembly.config.ur_10e.joint_pos_env_cfg import UR10e2F140GearAssemblyEnvCfg @pytest.mark.parametrize( "env_cfg_cls", - ( - Rizon4sGearAssemblyEnvCfg, - Rizon4sGearAssemblyROSInferenceEnvCfg, - UR10e2F85GearAssemblyEnvCfg, - UR10e2F85GearAssemblyROSInferenceEnvCfg, - UR10e2F140GearAssemblyEnvCfg, - UR10e2F140GearAssemblyROSInferenceEnvCfg, - ), + # Every class inherits the base scene default; Rizon 4s ROS inference is the only one that writes num_envs. + (UR10e2F140GearAssemblyEnvCfg, Rizon4sGearAssemblyROSInferenceEnvCfg), ) def test_gear_assembly_defaults_limit_parallel_environments(env_cfg_cls): """GearAssembly defaults must fit recurrent PPO training on development GPUs.""" diff --git a/source/isaaclab_tasks/test/contrib/test_so101_teleop_pipeline.py b/source/isaaclab_tasks/test/contrib/test_so101_teleop_pipeline.py index 220dbc29cb7c..d7a88d79f4ad 100644 --- a/source/isaaclab_tasks/test/contrib/test_so101_teleop_pipeline.py +++ b/source/isaaclab_tasks/test/contrib/test_so101_teleop_pipeline.py @@ -6,8 +6,7 @@ """Sim-free tests for the SO-101 XR teleop pipeline wiring. These tests build the IsaacTeleop pipeline standalone (no ``gym.make``, USD, GPU, or XR -device) and verify the flattened action width and element order, plus the external-input -contract the Lab-side device wiring depends on. +device) and verify the flattened action width and element order. The retargeter math itself (trigger -> closedness, clutch rebasing, orientation calibration) is unit tested in Isaac Teleop (``test_so101_retargeters.py``); this file guards only the Lab-side @@ -18,18 +17,13 @@ ``[pos_x, pos_y, pos_z, quat_x, quat_y, quat_z, quat_w, gripper]``. - Each entry in ``output_order`` must resolve to a declared element name; an undeclared name resolves silently to a constant 0.0 instead of raising. -- The clutch declares only the controller input (no ``robot_ee_pos`` / ``robot_base_pos``): the - base-frame rebase is handled upstream by ``target_frame_prim_path`` and the engage home is the - clutch's static reset-origin config, so no live end-effector or base feed is wired. """ import pytest pytest.importorskip("isaacteleop") -from isaacteleop.retargeters import SO101ClutchRetargeter from isaacteleop.retargeters.SO101.gripper_retargeter import GRIPPER_ELEMENT_LABEL -from isaacteleop.retargeting_engine.deviceio_source_nodes import ControllersSource from isaaclab_tasks.contrib.stack.config.so101.stack_ik_abs_env_cfg import _build_so101_stack_pipeline @@ -47,12 +41,10 @@ def test_so101_pipeline_output_order(): combiner = _build_so101_stack_pipeline() # Walk the OutputCombiner -> subgraph -> TensorReorderer to read the resolved output order. - # NOTE: this depends on isaacteleop internals; the width==8 test is the public-API guard. - try: - reorderer = combiner.output_mapping["action"].module._target_module - output_order = reorderer._output_order - except AttributeError: - pytest.skip("relies on isaacteleop internals; public accessor unavailable") + # This depends on isaacteleop internals (no public accessor); fail rather than skip if they change, + # since this is the only check of the element order. + reorderer = combiner.output_mapping["action"].module._target_module + output_order = reorderer._output_order expected_order = [ "pos_x", @@ -69,20 +61,3 @@ def test_so101_pipeline_output_order(): f" declared pos/quat/{GRIPPER_ELEMENT_LABEL!r} labels so they do not become silent 0.0" " fillers" ) - - -def test_clutch_consumes_controller_only(): - """The clutch declares only the controller; no ``robot_ee_pos`` / ``robot_base_pos`` inputs. - - The world->base rebase happens upstream in the device via ``target_frame_prim_path`` (set to - the robot base), and the engage home is the clutch's static ``home_base_T_ee`` reset-origin, so - the clutch needs no live end-effector or base feed. This guards that the builder does not wire - inputs the device no longer provides. - """ - clutch = SO101ClutchRetargeter(name="ee_pose") - assert list(clutch.input_spec()) == [ControllersSource.RIGHT] - assert not hasattr(SO101ClutchRetargeter, "ROBOT_EE_POS_INPUT") - assert not hasattr(SO101ClutchRetargeter, "ROBOT_BASE_POS_INPUT") - # The builder still flattens to the 8D action contract. - combiner = _build_so101_stack_pipeline() - assert combiner.output_types()["action"].types[0].shape == (8,) diff --git a/source/isaaclab_tasks/test/core/test_agent_preset_composition.py b/source/isaaclab_tasks/test/core/test_agent_preset_composition.py index c27fa2d42fc6..0b2067e23966 100644 --- a/source/isaaclab_tasks/test/core/test_agent_preset_composition.py +++ b/source/isaaclab_tasks/test/core/test_agent_preset_composition.py @@ -113,4 +113,13 @@ def test_missing_agent_entrypoint_diagnostic_does_not_invent_algorithm_labels(): def test_preset_composed_tasks_expose_only_canonical_config_roots(task, expected): registered = {name for name in gym.spec(task).kwargs if name.endswith("_cfg_entry_point")} assert registered == expected - assert "agent_preset_compatibility" not in gym.spec(task).kwargs + + +def test_task_registrations_have_no_preset_to_agent_maps(): + """A shared preset name must compose config roots without a registry-side pairing table.""" + offenders = [ + task_id + for task_id, spec in gym.registry.items() + if task_id.startswith("Isaac") and "agent_preset_compatibility" in spec.kwargs + ] + assert not offenders diff --git a/source/isaaclab_tasks/test/core/test_cartpole_camera_observations.py b/source/isaaclab_tasks/test/core/test_cartpole_camera_observations.py index 1d3fc3b31b2c..1b12beffc5b9 100644 --- a/source/isaaclab_tasks/test/core/test_cartpole_camera_observations.py +++ b/source/isaaclab_tasks/test/core/test_cartpole_camera_observations.py @@ -24,9 +24,8 @@ pytestmark = pytest.mark.unit -@pytest.fixture(params=["cpu", "cuda:0"] if torch.cuda.is_available() else ["cpu"]) -def device(request): - return request.param +# The term is device-agnostic torch bookkeeping, so CPU suffices. +_DEVICE = "cpu" def _observe(images: torch.Tensor, frame_stack: int, device: str) -> torch.Tensor: @@ -48,12 +47,12 @@ def _to_expected_layout(images: torch.Tensor, frame_stack: int) -> torch.Tensor: @pytest.mark.parametrize("frame_stack", [1, 2]) -def test_colorized_segmentation_is_normalized_like_rgb(device, frame_stack): +def test_colorized_segmentation_is_normalized_like_rgb(frame_stack): """Colorized uint8 RGBA segmentation gets the same ``(x / 255) - per-image mean`` as RGB.""" torch.manual_seed(0) - images = torch.randint(0, 255, (2, 8, 8, 4), dtype=torch.uint8, device=device) + images = torch.randint(0, 255, (2, 8, 8, 4), dtype=torch.uint8, device=_DEVICE) - observation = _observe(images, frame_stack, device) + observation = _observe(images, frame_stack, _DEVICE) expected = images.float() / 255.0 expected = expected - torch.mean(expected, dim=(1, 2), keepdim=True) @@ -62,11 +61,11 @@ def test_colorized_segmentation_is_normalized_like_rgb(device, frame_stack): @pytest.mark.parametrize("frame_stack", [1, 2]) -def test_non_colorized_segmentation_is_cast_to_float(device, frame_stack): +def test_non_colorized_segmentation_is_cast_to_float(frame_stack): """Non-colorized int32 label ids are cast to float32 and, carrying no scale, left unrescaled.""" - images = torch.arange(2 * 8 * 8, dtype=torch.int32, device=device).reshape(2, 8, 8, 1) % 5 + images = torch.arange(2 * 8 * 8, dtype=torch.int32, device=_DEVICE).reshape(2, 8, 8, 1) % 5 - observation = _observe(images, frame_stack, device) + observation = _observe(images, frame_stack, _DEVICE) assert observation.dtype == torch.float32 torch.testing.assert_close(observation, _to_expected_layout(images.float(), frame_stack)) diff --git a/source/isaaclab_tasks/test/core/test_cartpole_camera_presets.py b/source/isaaclab_tasks/test/core/test_cartpole_camera_presets.py index 7e2b0a39aba3..5ffd5d590db9 100644 --- a/source/isaaclab_tasks/test/core/test_cartpole_camera_presets.py +++ b/source/isaaclab_tasks/test/core/test_cartpole_camera_presets.py @@ -25,15 +25,12 @@ pytest.param(CartpoleManagerCameraEnvCfg, id="manager"), ] -_SIMPLE_SHADING_PRESETS = ["simple_shading_constant_diffuse", "simple_shading_diffuse_mdl", "simple_shading_full_mdl"] - @pytest.mark.parametrize("cfg_cls", _ENV_CFG_CLASSES) -@pytest.mark.parametrize("data_type_preset", _SIMPLE_SHADING_PRESETS) -def test_newton_renderer_rejects_simple_shading(cfg_cls, data_type_preset): - """The Newton Warp renderer cannot shade, so these combinations must not resolve.""" - cfg = resolve_presets(cfg_cls(), {"newton_renderer", data_type_preset}) - with pytest.raises(ValueError, match=data_type_preset): +def test_newton_renderer_rejects_simple_shading(cfg_cls): + """The Newton Warp renderer cannot shade, so this combination must not resolve.""" + cfg = resolve_presets(cfg_cls(), {"newton_renderer", "simple_shading_full_mdl"}) + with pytest.raises(ValueError, match="simple_shading_full_mdl"): cfg.validate() diff --git a/source/isaaclab_tasks/test/core/test_dexterous_task_math.py b/source/isaaclab_tasks/test/core/test_dexterous_task_math.py index a89ff349573b..1306c355dcbf 100644 --- a/source/isaaclab_tasks/test/core/test_dexterous_task_math.py +++ b/source/isaaclab_tasks/test/core/test_dexterous_task_math.py @@ -5,9 +5,9 @@ """Behavior tests for the dexterous-task math helpers. -Each test asserts behavior on hand-computed cases (known rotations, distances, -and layouts) so quaternion-layout or formula regressions fail loudly without -re-implementing the tested math as a reference. +Each test asserts behavior on hand-computed cases (known distances and layouts) +so quaternion-layout or formula regressions fail loudly without re-implementing +the tested math as a reference. """ import math @@ -15,67 +15,20 @@ import pytest import torch -import isaaclab.utils.math as math_utils -from isaaclab.utils.math import quat_error_magnitude - from isaaclab_tasks.core.handover.mdp.rewards import evaluate_handover_success, handover_reward from isaaclab_tasks.core.reorient.mdp.observations import compute_cube_keypoints, cube_keypoints_from_quat -_DEVICES = ["cpu"] + (["cuda:0"] if torch.cuda.is_available() else []) +# Elementwise torch helpers with no device-specific branch, so CPU suffices. +_DEVICES = ["cpu"] -_SIN45 = math.sin(math.pi / 4) -_COS45 = math.cos(math.pi / 4) # (x, y, z, w) storage everywhere _IDENTITY = (0.0, 0.0, 0.0, 1.0) -_ROT90_X = (_SIN45, 0.0, 0.0, _COS45) -_ROT180_Z = (0.0, 0.0, 1.0, 0.0) def _quats(device, *quats): return torch.tensor(quats, dtype=torch.float32, device=device) -@pytest.mark.parametrize("device", _DEVICES) -def test_rotation_distance_recovers_known_angles(device): - distance = quat_error_magnitude( - _quats(device, _IDENTITY, _ROT90_X, _ROT180_Z), _quats(device, _IDENTITY, _IDENTITY, _IDENTITY) - ) - torch.testing.assert_close(distance, torch.tensor([0.0, math.pi / 2, math.pi], device=device), atol=1e-5, rtol=0.0) - - -@pytest.mark.parametrize("device", _DEVICES) -def test_reorient_success_thresholds_on_rotation_distance(device): - distance = quat_error_magnitude(_quats(device, _IDENTITY, _ROT90_X), _quats(device, _IDENTITY, _IDENTITY)) - success = distance <= 0.4 - assert success.tolist() == [True, False] - torch.testing.assert_close(distance, torch.tensor([0.0, math.pi / 2], device=device), atol=1e-5, rtol=0.0) - - -@pytest.mark.parametrize("device", _DEVICES) -def test_goal_quat_error_composes_the_conjugate_product(device): - # error(q, identity) = q; error(q, q) = identity; the 180-degree case exercises w = 0. - # This is the composition the goal_quat_diff and object_goal observation terms rely on. - asset = _quats(device, _ROT90_X, _ROT90_X, _ROT180_Z) - goal = _quats(device, _IDENTITY, _ROT90_X, _IDENTITY) - out = math_utils.quat_mul(asset, math_utils.quat_conjugate(goal)) - torch.testing.assert_close(out[0], torch.tensor(_ROT90_X, device=device), atol=1e-6, rtol=0.0) - torch.testing.assert_close(out[1], torch.tensor(_IDENTITY, device=device), atol=1e-6, rtol=0.0) - # 180-degree rotations keep w = 0, so quat_unique must leave them unchanged - unique = math_utils.quat_unique(out) - torch.testing.assert_close(unique[2].abs(), torch.tensor(_ROT180_Z, device=device).abs(), atol=1e-6, rtol=0.0) - - -@pytest.mark.parametrize("device", _DEVICES) -def test_goal_quat_error_flips_sign_for_negative_real_part(device): - # error(270-degree X rotation, identity) has w = -cos45 < 0, exercising the sign flip - rot270_x = (_SIN45, 0.0, 0.0, -_COS45) - out = math_utils.quat_mul(_quats(device, rot270_x), math_utils.quat_conjugate(_quats(device, _IDENTITY))) - torch.testing.assert_close(out[0], torch.tensor(rot270_x, device=device), atol=1e-6, rtol=0.0) - unique = math_utils.quat_unique(out) - expected = (-_SIN45, 0.0, 0.0, _COS45) - torch.testing.assert_close(unique[0], torch.tensor(expected, device=device), atol=1e-6, rtol=0.0) - - @pytest.mark.parametrize("device", _DEVICES) def test_handover_success_measures_env_frame_distance(device): object_pos = torch.tensor([[0.0, 0.0, 3.0], [0.0, 0.0, 0.0]], device=device) diff --git a/source/isaaclab_tasks/test/core/test_distributed_device_resolution.py b/source/isaaclab_tasks/test/core/test_distributed_device_resolution.py index cf278ef9ce01..f8ab1836f334 100644 --- a/source/isaaclab_tasks/test/core/test_distributed_device_resolution.py +++ b/source/isaaclab_tasks/test/core/test_distributed_device_resolution.py @@ -27,6 +27,8 @@ import types from unittest.mock import patch +import pytest + import isaaclab.app.sim_launcher as sim_launcher # --------------------------------------------------------------------------- @@ -95,20 +97,6 @@ def _make_env_vars( class TestResolveDistributedDeviceNamespace: """Tests for _resolve_distributed_device with argparse.Namespace args.""" - @patch.object(sim_launcher, "set_cuda_device") - @patch("torch.cuda.device_count", return_value=4) - def test_normal_multi_gpu_rank0(self, mock_count, mock_set_device): - """4 visible GPUs, world_size=4, rank 0 → cuda:0.""" - env_cfg = _DummyEnvCfg() - args = _make_distributed_args() - env = _make_env_vars(local_rank=0, world_size=4) - - with patch.dict(os.environ, env, clear=False): - sim_launcher._resolve_distributed_device(env_cfg, args) - - assert env_cfg.sim.device == "cuda:0" - mock_set_device.assert_called_once_with("cuda:0") - @patch.object(sim_launcher, "set_cuda_device") @patch("torch.cuda.device_count", return_value=4) def test_normal_multi_gpu_rank3(self, mock_count, mock_set_device): @@ -123,20 +111,6 @@ def test_normal_multi_gpu_rank3(self, mock_count, mock_set_device): assert env_cfg.sim.device == "cuda:3" mock_set_device.assert_called_once_with("cuda:3") - @patch.object(sim_launcher, "set_cuda_device") - @patch("torch.cuda.device_count", return_value=1) - def test_cuda_visible_devices_restricted_rank0(self, mock_count, mock_set_device): - """1 visible GPU (CUDA_VISIBLE_DEVICES set), world_size=2, rank 0 → cuda:0.""" - env_cfg = _DummyEnvCfg() - args = _make_distributed_args() - env = _make_env_vars(local_rank=0, world_size=2) - - with patch.dict(os.environ, env, clear=False): - sim_launcher._resolve_distributed_device(env_cfg, args) - - assert env_cfg.sim.device == "cuda:0" - mock_set_device.assert_called_once_with("cuda:0") - @patch.object(sim_launcher, "set_cuda_device") @patch("torch.cuda.device_count", return_value=1) def test_cuda_visible_devices_restricted_rank1(self, mock_count, mock_set_device): @@ -166,20 +140,6 @@ def test_jax_local_rank_added(self, mock_count, mock_set_device): assert env_cfg.sim.device == "cuda:1" mock_set_device.assert_called_once_with("cuda:1") - @patch.object(sim_launcher, "set_cuda_device") - @patch("torch.cuda.device_count", return_value=1) - def test_jax_local_rank_with_restricted_gpus(self, mock_count, mock_set_device): - """JAX_LOCAL_RANK + restricted GPUs → fallback to cuda:0.""" - env_cfg = _DummyEnvCfg() - args = _make_distributed_args() - env = _make_env_vars(local_rank=0, world_size=2, jax_local_rank=1) - - with patch.dict(os.environ, env, clear=False): - sim_launcher._resolve_distributed_device(env_cfg, args) - - assert env_cfg.sim.device == "cuda:0" - mock_set_device.assert_called_once_with("cuda:0") - # --------------------------------------------------------------------------- # _resolve_distributed_device — dict launcher_args @@ -203,20 +163,6 @@ def test_dict_args_distributed(self, mock_count, mock_set_device): assert env_cfg.sim.device == "cuda:2" mock_set_device.assert_called_once_with("cuda:2") - @patch.object(sim_launcher, "set_cuda_device") - @patch("torch.cuda.device_count", return_value=1) - def test_dict_args_restricted(self, mock_count, mock_set_device): - """Dict args with restricted GPUs should fall back to cuda:0.""" - env_cfg = _DummyEnvCfg() - args = {"distributed": True} - env = _make_env_vars(local_rank=3, world_size=4) - - with patch.dict(os.environ, env, clear=False): - sim_launcher._resolve_distributed_device(env_cfg, args) - - assert env_cfg.sim.device == "cuda:0" - mock_set_device.assert_called_once_with("cuda:0") - # --------------------------------------------------------------------------- # _resolve_distributed_device — non-distributed (no-op) @@ -226,22 +172,11 @@ def test_dict_args_restricted(self, mock_count, mock_set_device): class TestResolveDistributedDeviceNoop: """Tests that non-distributed runs skip device resolution.""" + @pytest.mark.parametrize("args", [argparse.Namespace(distributed=False), {"distributed": False}]) @patch.object(sim_launcher, "set_cuda_device") - def test_not_distributed_namespace(self, mock_set_device): - """distributed=False → device unchanged, set_device not called.""" + def test_not_distributed(self, mock_set_device, args): + """distributed=False as a namespace or dict → device unchanged, set_device not called.""" env_cfg = _DummyEnvCfg(device="cuda:0") - args = argparse.Namespace(distributed=False) - - sim_launcher._resolve_distributed_device(env_cfg, args) - - assert env_cfg.sim.device == "cuda:0" - mock_set_device.assert_not_called() - - @patch.object(sim_launcher, "set_cuda_device") - def test_not_distributed_dict(self, mock_set_device): - """Dict with distributed=False → no-op.""" - env_cfg = _DummyEnvCfg(device="cuda:0") - args = {"distributed": False} sim_launcher._resolve_distributed_device(env_cfg, args) @@ -296,45 +231,6 @@ class _BareEnvCfg: # Should still call set_device even without sim_cfg mock_set_device.assert_called_once_with("cuda:1") - @patch.object(sim_launcher, "set_cuda_device") - @patch("torch.cuda.device_count", return_value=2) - def test_world_size_equals_visible_gpus(self, mock_count, mock_set_device): - """Exact match: 2 visible GPUs, world_size=2 → use local_rank directly.""" - env_cfg = _DummyEnvCfg() - args = _make_distributed_args() - env = _make_env_vars(local_rank=1, world_size=2) - - with patch.dict(os.environ, env, clear=False): - sim_launcher._resolve_distributed_device(env_cfg, args) - - assert env_cfg.sim.device == "cuda:1" - - @patch.object(sim_launcher, "set_cuda_device") - @patch("torch.cuda.device_count", return_value=8) - def test_more_gpus_than_world_size(self, mock_count, mock_set_device): - """8 visible GPUs but only 2 ranks → use local_rank directly.""" - env_cfg = _DummyEnvCfg() - args = _make_distributed_args() - env = _make_env_vars(local_rank=1, world_size=2) - - with patch.dict(os.environ, env, clear=False): - sim_launcher._resolve_distributed_device(env_cfg, args) - - assert env_cfg.sim.device == "cuda:1" - - @patch.object(sim_launcher, "set_cuda_device") - @patch("torch.cuda.device_count", return_value=0) - def test_zero_visible_gpus(self, mock_count, mock_set_device): - """0 visible GPUs → fallback to cuda:0 (will fail later at CUDA init).""" - env_cfg = _DummyEnvCfg() - args = _make_distributed_args() - env = _make_env_vars(local_rank=0, world_size=2) - - with patch.dict(os.environ, env, clear=False): - sim_launcher._resolve_distributed_device(env_cfg, args) - - assert env_cfg.sim.device == "cuda:0" - @patch.object(sim_launcher, "set_cuda_device") @patch("torch.cuda.device_count", return_value=4) def test_missing_env_vars_default_to_zero(self, mock_count, mock_set_device): @@ -382,34 +278,6 @@ def test_multi_node_rank3_sees_4_gpus(self, mock_count, mock_set_device): assert env_cfg.sim.device == "cuda:3" mock_set_device.assert_called_once_with("cuda:3") - @patch.object(sim_launcher, "set_cuda_device") - @patch("torch.cuda.device_count", return_value=4) - def test_multi_node_rank0_sees_4_gpus(self, mock_count, mock_set_device): - """2 nodes × 4 GPUs, WORLD_SIZE=8, local_rank=0 → cuda:0.""" - env_cfg = _DummyEnvCfg() - args = _make_distributed_args() - env = _make_env_vars(local_rank=0, world_size=8, rank=4) - - with patch.dict(os.environ, env, clear=False): - sim_launcher._resolve_distributed_device(env_cfg, args) - - assert env_cfg.sim.device == "cuda:0" - mock_set_device.assert_called_once_with("cuda:0") - - @patch.object(sim_launcher, "set_cuda_device") - @patch("torch.cuda.device_count", return_value=1) - def test_multi_node_restricted_gpus(self, mock_count, mock_set_device): - """Multi-node with CUDA_VISIBLE_DEVICES=, local_rank=1 → cuda:0.""" - env_cfg = _DummyEnvCfg() - args = _make_distributed_args() - env = _make_env_vars(local_rank=1, world_size=8, rank=5) - - with patch.dict(os.environ, env, clear=False): - sim_launcher._resolve_distributed_device(env_cfg, args) - - assert env_cfg.sim.device == "cuda:0" - mock_set_device.assert_called_once_with("cuda:0") - # --------------------------------------------------------------------------- # launch_simulation integration — verify device propagation from AppLauncher @@ -463,27 +331,22 @@ def __init__(self, launcher_args): assert env_cfg.sim.device == "cuda:3" - def test_kitless_path_uses_resolve_distributed_device(self, monkeypatch): + @patch.object(sim_launcher, "set_cuda_device") + @patch("torch.cuda.device_count", return_value=2) + def test_kitless_path_uses_resolve_distributed_device(self, mock_count, mock_set_device, monkeypatch): """When Kit is NOT needed, _resolve_distributed_device sets the device.""" - resolved_devices = [] - - def _fake_resolve(env_cfg, launcher_args): - env_cfg.sim.device = "cuda:1" - resolved_devices.append("cuda:1") - # Force the kitless path (needs_kit=False) without constructing a real backend cfg. _force_kitless(monkeypatch) - monkeypatch.setattr( - sim_launcher, - "_resolve_distributed_device", - _fake_resolve, - ) - env_cfg = _DummyEnvCfg(device="cuda:0") args = _make_distributed_args() - with sim_launcher.launch_simulation(env_cfg, args): - pass + with patch.object( + sim_launcher, "_resolve_distributed_device", wraps=sim_launcher._resolve_distributed_device + ) as spy: + with patch.dict(os.environ, _make_env_vars(local_rank=1, world_size=2), clear=False): + with sim_launcher.launch_simulation(env_cfg, args): + pass + spy.assert_called_once() assert env_cfg.sim.device == "cuda:1" - assert len(resolved_devices) == 1 + mock_set_device.assert_called_once_with("cuda:1") diff --git a/source/isaaclab_tasks/test/core/test_environment_determinism.py b/source/isaaclab_tasks/test/core/test_environment_determinism.py index c808b8a05cb2..5fc568c3cbc0 100644 --- a/source/isaaclab_tasks/test/core/test_environment_determinism.py +++ b/source/isaaclab_tasks/test/core/test_environment_determinism.py @@ -36,29 +36,25 @@ def setup_environment(): @pytest.mark.parametrize( - ("task_name", "deterministic_mode"), + ("task_name", "deterministic_mode", "device"), [ # Newton defaults to ``wp.DeterministicMode.NOT_GUARANTEED``, under which Warp's atomics may # accumulate in any order, so two runs are not bit-reproducible. ``run_to_run`` is exactly # the guarantee this test asserts: one device, one process, run twice. ``deterministic_mode`` # is a Newton setting, so the PhysX-backed task leaves it unset. - ("Isaac-Open-Drawer-Franka", "run_to_run"), - ("IsaacContrib-Lift-Cube-Franka", None), + # Warp CPU kernels run serially, so the Newton deterministic mode only matters on CUDA. + pytest.param("Isaac-Open-Drawer-Franka", "run_to_run", "cuda", id="cuda-Isaac-Open-Drawer-Franka-run_to_run"), + pytest.param("IsaacContrib-Lift-Cube-Franka", None, "cuda", id="cuda-IsaacContrib-Lift-Cube-Franka-None"), + pytest.param("IsaacContrib-Lift-Cube-Franka", None, "cpu", id="cpu-IsaacContrib-Lift-Cube-Franka-None"), ], ) -@pytest.mark.parametrize("device", ["cuda", "cpu"]) def test_manipulation_env_determinism(task_name, deterministic_mode, device): """Check deterministic environment creation for manipulation.""" _test_environment_determinism(task_name, device, deterministic_mode=deterministic_mode) -@pytest.mark.parametrize( - "task_name", - [ - "Isaac-Velocity-Flat-AnymalD", - "Isaac-Velocity-Rough-AnymalD", - ], -) +# Rough terrain is a superset of the flat plane (same robot and MDP plus terrain generator and height scanner). +@pytest.mark.parametrize("task_name", ["Isaac-Velocity-Rough-AnymalD"]) @pytest.mark.parametrize("device", ["cuda", "cpu"]) def test_locomotion_env_determinism(task_name, device): """Check deterministic Isaac Sim PhysX environment creation for locomotion.""" diff --git a/source/isaaclab_tasks/test/core/test_environments_isaacsim_physx.py b/source/isaaclab_tasks/test/core/test_environments_isaacsim_physx.py index b2dbe5ffed6e..fb3a43bdc8bb 100644 --- a/source/isaaclab_tasks/test/core/test_environments_isaacsim_physx.py +++ b/source/isaaclab_tasks/test/core/test_environments_isaacsim_physx.py @@ -26,7 +26,6 @@ "Isaac-Lift-Cloth-Franka-Camera", # Already covered by test_rendering_franka_cloth.py "Isaac-Lift-KukaAllegro-Camera", # Already covered by test_rendering_lift_kuka_hetero.py "Isaac-Reorient-Cube-Shadow-Camera-Direct", # Already covered by test_rendering_shadow_hand.py - "Isaac-Velocity-Flat-AnymalD", # Already covered by test_environment_determinism.py "Isaac-Velocity-Rough-AnymalD", # Already covered by test_environment_determinism.py ] diff --git a/source/isaaclab_tasks/test/core/test_hydra.py b/source/isaaclab_tasks/test/core/test_hydra.py index 0101307e10e2..953f8ad406b4 100644 --- a/source/isaaclab_tasks/test/core/test_hydra.py +++ b/source/isaaclab_tasks/test/core/test_hydra.py @@ -463,13 +463,6 @@ def test_parse_overrides_mixed(): assert "env.decimation=10" in glob -def test_parse_overrides_root_preset(): - """Root-level PresetCfg parsed as agent=.""" - presets = {"env": {}, "agent": collect_presets(RootAgentCfg())} - _, sel, _, _ = parse_overrides(["agent=fast"], presets) - assert sel == [("agent", "", "fast")] - - # ============================================================================= # Tests: apply_overrides -- PresetCfg (nested + broadcast + root) # ============================================================================= @@ -485,15 +478,6 @@ def test_presetcfg_auto_default(class_presets): assert isinstance(agent_cfg.policy, SmallPolicyCfg) -def test_presetcfg_cli_selection(class_presets): - """Path selection replaces with chosen preset.""" - env_cfg, agent_cfg, presets = class_presets - hydra_cfg = {"env": env_cfg.to_dict(), "agent": agent_cfg.to_dict()} - apply_overrides(env_cfg, agent_cfg, hydra_cfg, [], [("env", "backend", "newton_mjwarp")], [], presets) - assert isinstance(env_cfg.backend, NewtonCfg) - assert env_cfg.backend.dt == 0.002 - - def test_presetcfg_global_broadcast(class_presets): """Global preset 'fast' broadcasts across env and agent PresetCfg fields.""" env_cfg, agent_cfg, presets = class_presets @@ -509,6 +493,7 @@ def test_presetcfg_path_selection_others_default(class_presets): hydra_cfg = {"env": env_cfg.to_dict(), "agent": agent_cfg.to_dict()} apply_overrides(env_cfg, agent_cfg, hydra_cfg, [], [("env", "backend", "newton_mjwarp")], [], presets) assert isinstance(env_cfg.backend, NewtonCfg) + assert env_cfg.backend.dt == 0.002 assert isinstance(env_cfg.observations, NoiselessObservationsCfg) assert isinstance(agent_cfg.policy, SmallPolicyCfg) @@ -731,14 +716,6 @@ class ScalarPresetEnvCfg: actuator: ActuatorWithPresetCfg = ActuatorWithPresetCfg() -def test_scalar_presetcfg_collect(): - """Scalar PresetCfg fields collected with correct values.""" - presets = collect_presets(ScalarPresetEnvCfg()) - assert "actuator.armature" in presets - assert presets["actuator.armature"]["default"] == 0.0 - assert presets["actuator.armature"]["newton_mjwarp"] == 0.01 - - def test_scalar_presetcfg_resolve_default(): """resolve_presets replaces scalar PresetCfg with its default value.""" cfg = ScalarPresetEnvCfg() @@ -747,25 +724,6 @@ def test_scalar_presetcfg_resolve_default(): assert not isinstance(resolved.actuator.armature, PresetCfg) -def test_scalar_presetcfg_auto_default(): - """Scalar PresetCfg auto-applies default=0.0 when no CLI override.""" - env_cfg, _ = _apply(ScalarPresetEnvCfg()) - assert env_cfg.actuator.armature == 0.0 - - -def test_scalar_presetcfg_global_newton_mjwarp(): - """Global preset=newton_mjwarp replaces scalar PresetCfg with MJWarp value.""" - env_cfg, _ = _apply(ScalarPresetEnvCfg(), global_presets=["newton_mjwarp"]) - assert env_cfg.actuator.armature == 0.01 - - -def test_scalar_presetcfg_path_selection(): - """Path selection replaces scalar PresetCfg with chosen value.""" - env_cfg, _ = _apply(ScalarPresetEnvCfg(), preset_sel=[("env", "actuator.armature", "newton_mjwarp")]) - assert env_cfg.actuator.armature == 0.01 - assert env_cfg.actuator.stiffness == 40.0 - - # ============================================================================= # Tests: PresetCfg inside dict values (e.g., actuators["legs"].armature) # ============================================================================= @@ -804,12 +762,6 @@ def test_resolve_presets_traverses_dict_values(): assert not isinstance(resolved.robot.actuators["legs"].armature, PresetCfg) -def test_dict_preset_auto_default(): - """Dict-held PresetCfg auto-applies default when no CLI override.""" - env_cfg, _ = _apply(DictPresetEnvCfg()) - assert env_cfg.robot.actuators["legs"].armature == 0.0 - - def test_dict_preset_global_newton_mjwarp(): """Global preset=newton_mjwarp replaces dict-held scalar PresetCfg.""" env_cfg, _ = _apply(DictPresetEnvCfg(), global_presets=["newton_mjwarp"]) @@ -823,38 +775,6 @@ def test_dict_preset_path_selection(): assert env_cfg.robot.actuators["legs"].stiffness == 40.0 -def test_dict_preset_with_factory(): - """preset() factory works inside dict-held configclass values.""" - - @configclass - class ActuatorCfgFactory: - joint_names: list = [".*"] - armature: object = None - - def __post_init__(self): - if self.armature is None: - self.armature = preset(default=0.0, newton_mjwarp=0.01, physx=0.0) - - @configclass - class RobotCfgFactory: - actuators: dict = None - - def __post_init__(self): - if self.actuators is None: - self.actuators = {"legs": ActuatorCfgFactory()} - - @configclass - class EnvCfgFactory: - robot: RobotCfgFactory = RobotCfgFactory() - - cfg = EnvCfgFactory() - presets = collect_presets(cfg) - assert "robot.actuators.legs.armature" in presets - assert presets["robot.actuators.legs.armature"]["default"] == 0.0 - assert presets["robot.actuators.legs.armature"]["newton_mjwarp"] == 0.01 - assert presets["robot.actuators.legs.armature"]["physx"] == 0.0 - - # ============================================================================= # Tests: rough terrain config regressions # ============================================================================= @@ -920,14 +840,6 @@ def test_resolve_presets_deep_nested_dicts(): assert not isinstance(inner.params["robot_cfg"].joint_names, PresetCfg) -def test_deep_nested_dict_auto_default(): - """Deeply nested dict presets auto-apply default when no CLI override.""" - env_cfg, _ = _apply(DeepDictEnvCfg()) - inner = env_cfg.events.params["terms"]["step_one"] - assert inner.params["offset"] == (0.0, 0.0, 0.01) - assert inner.params["fraction"] == (0.05, 0.5) - - def test_deep_nested_dict_global_preset(): """Global preset=task_b replaces deeply nested dict presets.""" env_cfg, _ = _apply(DeepDictEnvCfg(), global_presets=["task_b"]) @@ -1083,14 +995,6 @@ def test_preset_factory_requires_default(): preset(high=1.0, low=-1.0) -def test_preset_factory_string_values(): - """preset() works with string values.""" - p = preset(default="cpu", gpu="cuda:0") - assert isinstance(p, PresetCfg) - assert p.default == "cpu" - assert p.gpu == "cuda:0" - - # ============================================================================= # Tests: _collect_fields class-vs-instance priority # ============================================================================= @@ -1224,13 +1128,6 @@ class AliasedEnvCfg: # ============================================================================= -def test_parse_overrides_multiple_global_presets(): - """Multiple comma-separated global presets are split correctly.""" - presets = {"env": {"backend": {"default": None, "newton_mjwarp": None}}, "agent": {}} - global_p, _, _, _ = parse_overrides(["presets=fast,newton_mjwarp,debug"], presets) - assert global_p == ["fast", "newton_mjwarp", "debug"] - - def test_parse_overrides_maps_legacy_newton_preset_to_newton_mjwarp(): """Legacy ``newton`` preset selections resolve to ``newton_mjwarp`` when available.""" presets = {"env": {"backend": {"default": None, "newton_mjwarp": None}}, "agent": {}} @@ -1295,11 +1192,13 @@ def test_parse_overrides_preset_scalar_detection(): assert ("env.backend.substeps", "4") in preset_scalar -def test_parse_overrides_root_level_env_preset(): - """Root-level PresetCfg (path='') makes env= a valid preset selection.""" - presets = {"env": {"": {"default": None, "fast": None}}, "agent": {}} - _, sel, _, _ = parse_overrides(["env=fast"], presets) - assert sel == [("env", "", "fast")] +@pytest.mark.parametrize("section", ["env", "agent"]) +def test_parse_overrides_root_level_preset(section): + """Root-level PresetCfg (path='') makes