From f267f96b33142013c89882f84a43a7ce5615c3cc Mon Sep 17 00:00:00 2001 From: nvsekkin <72572910+nvsekkin@users.noreply.github.com> Date: Thu, 30 Apr 2026 06:32:17 -0700 Subject: [PATCH 01/40] CI: reliable pipeline cancellation (per-PR concurrency + interruptible Docker waits) (#5429) # Description Fixes two CI reliability problems: 1. **Stale pipelines not cancelled on new commits.** 2. **Manual cancellation didn't actually stop tests.** ## What changed - **Concurrency**: per-PR `cancel-in-progress` group on `build.yaml`, `docs.yaml`, `license-check.yaml`, `check-links.yml`; added missing `concurrency` blocks to `install-ci.yml`, `pre-commit.yaml`, `labeler.yml`. - **`run-tests` action**: backgrounded `docker wait` + bash `wait` builtin (signal-interruptible), `trap HUP|INT|TERM` that `docker kill`s the container, plus an `if: cancelled()` force-kill step as a safety net. - **`build.yaml` job conditions**: dropped `always() &&` from test jobs. `if: always()` makes a job uncancellable, which silently defeated the concurrency fix above. Behavior on success/failure is unchanged. ## Type of change - Bug fix (non-breaking change which fixes an issue) ## 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` - [ ] 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 - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Co-authored-by: Antoine RICHARD --- .github/actions/run-tests/action.yml | 49 ++++++++++++++++++++++++---- .github/workflows/build.yaml | 40 +++++++++++------------ .github/workflows/check-links.yml | 2 +- .github/workflows/docs.yaml | 2 +- .github/workflows/install-ci.yml | 5 +++ .github/workflows/labeler.yml | 4 +++ .github/workflows/license-check.yaml | 2 +- .github/workflows/pre-commit.yaml | 4 +++ 8 files changed, 79 insertions(+), 29 deletions(-) diff --git a/.github/actions/run-tests/action.yml b/.github/actions/run-tests/action.yml index feafa86d7650..e989b65d3a3e 100644 --- a/.github/actions/run-tests/action.yml +++ b/.github/actions/run-tests/action.yml @@ -77,6 +77,21 @@ runs: local shard_index="${11}" local shard_count="${12}" local volume_mount_source="${13}" + local logs_pid="" + local wait_pid="" + local docker_wait_file="/tmp/.docker_exit_${container_name}" + + # Kill the container immediately if the runner is cancelled. + # The GitHub Actions runner can deliver HUP, INT, or TERM on cancellation + # (see actions/runner#1309). Trap all three so the cleanup always fires. + # shellcheck disable=SC2064 + trap "echo '::warning::Cancellation signal - killing container ${container_name}'; \ + docker kill '${container_name}' 2>/dev/null || true; \ + docker rm -f '${container_name}' 2>/dev/null || true; \ + rm -f '${docker_wait_file}'; \ + if [ -n \"\$logs_pid\" ]; then kill \"\$logs_pid\" 2>/dev/null || true; fi; \ + if [ -n \"\$wait_pid\" ]; then kill \"\$wait_pid\" 2>/dev/null || true; fi; \ + exit 130" HUP INT TERM echo "Running tests in: $test_path" if [ -n "$pytest_options" ]; then @@ -189,16 +204,30 @@ runs: ./isaaclab.sh -p -m pytest --ignore=tools/conftest.py --ignore=source/isaaclab/test/install_ci $test_path $pytest_options -v --junitxml=tests/$result_file " - # Stream container logs to CI output (this is the killable foreground process). - docker logs -f $container_name & - local logs_pid=$! + # Stream container logs in background. + docker logs -f "$container_name" & + logs_pid=$! + + # Background `docker wait` + bash `wait` (interruptible by signals). + docker wait "$container_name" > "$docker_wait_file" & + wait_pid=$! + wait $wait_pid 2>/dev/null + local wait_status=$? + wait_pid="" - # Wait for the container to exit and capture its exit code. - DOCKER_EXIT=$(docker wait $container_name) || DOCKER_EXIT=1 + # If interrupted by signal, trap already handled cleanup. + if [ $wait_status -gt 128 ]; then + kill $logs_pid 2>/dev/null || true + exit 130 + fi + + DOCKER_EXIT=$(cat "$docker_wait_file" 2>/dev/null) || DOCKER_EXIT=1 + rm -f "$docker_wait_file" # Stop following logs. kill $logs_pid 2>/dev/null || true wait $logs_pid 2>/dev/null || true + logs_pid="" if [ $DOCKER_EXIT -eq 0 ]; then echo "🟢 Docker container completed successfully" @@ -268,6 +297,14 @@ runs: # Call the function with provided parameters run_tests "${{ inputs.test-path }}" "${{ inputs.result-file }}" "${{ inputs.container-name }}" "${{ inputs.image-tag }}" "${{ inputs.reports-dir }}" "${{ inputs.pytest-options }}" "${{ inputs.filter-pattern }}" "${{ inputs.curobo-only }}" "${{ inputs.include-files }}" "${{ inputs.quarantined-only }}" "${{ inputs.shard-index }}" "${{ inputs.shard-count }}" "${{ inputs.volume-mount-source }}" + - name: Kill container on cancellation + if: cancelled() + shell: bash + run: | + echo "::warning::Job cancelled - force-killing container" + docker kill "${{ inputs.container-name }}" 2>/dev/null || true + docker rm -f "${{ inputs.container-name }}" 2>/dev/null || true + - name: Write job summary if: always() shell: bash @@ -295,7 +332,7 @@ runs: retention-days: 7 - name: Clean up Docker container - if: always() + if: always() && !cancelled() shell: bash run: | echo "🔵 Cleaning up Docker container..." diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 81fa3547bdef..8b8fb60f6bbf 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -63,7 +63,7 @@ on: # Concurrency control to prevent parallel runs on the same PR concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true permissions: @@ -147,7 +147,7 @@ jobs: timeout-minutes: 180 continue-on-error: true needs: [build, config] - if: always() && needs.build.result == 'success' + if: needs.build.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -169,7 +169,7 @@ jobs: timeout-minutes: 180 continue-on-error: true needs: [build, config] - if: always() && needs.build.result == 'success' + if: needs.build.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -191,7 +191,7 @@ jobs: timeout-minutes: 180 continue-on-error: true needs: [build, config] - if: always() && needs.build.result == 'success' + if: needs.build.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -212,7 +212,7 @@ jobs: runs-on: [self-hosted, gpu] timeout-minutes: 180 needs: [build, config] - if: always() && needs.build.result == 'success' + if: needs.build.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -233,7 +233,7 @@ jobs: runs-on: [self-hosted, gpu] timeout-minutes: 180 needs: [build, config] - if: always() && needs.build.result == 'success' + if: needs.build.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -254,7 +254,7 @@ jobs: runs-on: [self-hosted, gpu] timeout-minutes: 180 needs: [build, config] - if: always() && needs.build.result == 'success' + if: needs.build.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -275,7 +275,7 @@ jobs: runs-on: [self-hosted, gpu] timeout-minutes: 180 needs: [build, config] - if: always() && needs.build.result == 'success' + if: needs.build.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -294,7 +294,7 @@ jobs: runs-on: [self-hosted, gpu] timeout-minutes: 180 needs: [build, config] - if: always() && needs.build.result == 'success' + if: needs.build.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -313,7 +313,7 @@ jobs: runs-on: [self-hosted, gpu] timeout-minutes: 180 needs: [build, config] - if: always() && needs.build.result == 'success' + if: needs.build.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -332,7 +332,7 @@ jobs: runs-on: [self-hosted, gpu] timeout-minutes: 180 needs: [build, config] - if: always() && needs.build.result == 'success' + if: needs.build.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -351,7 +351,7 @@ jobs: runs-on: [self-hosted, gpu] timeout-minutes: 180 needs: [build, config] - if: always() && needs.build.result == 'success' + if: needs.build.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -370,7 +370,7 @@ jobs: runs-on: [self-hosted, gpu] timeout-minutes: 180 needs: [build, config] - if: always() && needs.build.result == 'success' + if: needs.build.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -389,7 +389,7 @@ jobs: runs-on: [self-hosted, gpu] timeout-minutes: 180 needs: [build, config] - if: always() && needs.build.result == 'success' + if: needs.build.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -408,7 +408,7 @@ jobs: runs-on: [self-hosted, gpu] timeout-minutes: 180 needs: [build, config] - if: always() && needs.build.result == 'success' + if: needs.build.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -427,7 +427,7 @@ jobs: runs-on: [self-hosted, gpu] timeout-minutes: 180 needs: [build, config] - if: always() && needs.build.result == 'success' + if: needs.build.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -447,7 +447,7 @@ jobs: timeout-minutes: 120 continue-on-error: true needs: [build-curobo, config] - if: always() && needs.build-curobo.result == 'success' + if: needs.build-curobo.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -469,7 +469,7 @@ jobs: timeout-minutes: 120 continue-on-error: true needs: [build-curobo, config] - if: always() && needs.build-curobo.result == 'success' + if: needs.build-curobo.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -491,7 +491,7 @@ jobs: timeout-minutes: 300 continue-on-error: true needs: [build, config] - if: always() && needs.build.result == 'success' + if: needs.build.result == 'success' steps: - uses: actions/checkout@v6 with: @@ -515,7 +515,7 @@ jobs: # continue-on-error: true # needs: [build, config] # if: >- -# always() && needs.build.result == 'success' && +# needs.build.result == 'success' && # vars.RUN_QUARANTINED_TESTS == 'true' # steps: # - uses: actions/checkout@v6 diff --git a/.github/workflows/check-links.yml b/.github/workflows/check-links.yml index a78438782340..b19fe2be267a 100644 --- a/.github/workflows/check-links.yml +++ b/.github/workflows/check-links.yml @@ -22,7 +22,7 @@ on: - cron: '0 0 * * 0' # Every Sunday at midnight UTC concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 1be69475745c..32fdfb9378a7 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -18,7 +18,7 @@ on: types: [opened, synchronize, reopened] concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: diff --git a/.github/workflows/install-ci.yml b/.github/workflows/install-ci.yml index 083e02094f7b..2a33e60751e3 100644 --- a/.github/workflows/install-ci.yml +++ b/.github/workflows/install-ci.yml @@ -31,6 +31,11 @@ on: test_filter: description: 'pytest -k filter expression (e.g. "uv" or "bugs")' default: '' + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + permissions: contents: read jobs: diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 593aec9a2cb0..fe6fe42e12fc 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -7,6 +7,10 @@ name: "Pull Request Labeler" on: - pull_request_target +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + jobs: labeler: permissions: diff --git a/.github/workflows/license-check.yaml b/.github/workflows/license-check.yaml index 0b296f9e74eb..140de1c0e274 100644 --- a/.github/workflows/license-check.yaml +++ b/.github/workflows/license-check.yaml @@ -10,7 +10,7 @@ on: types: [opened, synchronize, reopened] concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: diff --git a/.github/workflows/pre-commit.yaml b/.github/workflows/pre-commit.yaml index aa3a38daffff..0b4cc5ac3d40 100644 --- a/.github/workflows/pre-commit.yaml +++ b/.github/workflows/pre-commit.yaml @@ -9,6 +9,10 @@ on: pull_request: types: [opened, synchronize, reopened] +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + jobs: pre-commit: runs-on: ubuntu-latest From 103ae68e6e04e11628b99712b1549093bd95dc1a Mon Sep 17 00:00:00 2001 From: Antoine Richard Date: Thu, 30 Apr 2026 16:21:55 +0200 Subject: [PATCH 02/40] Add PhysX joint wrench sensor Add the PhysX backend implementation for JointWrenchSensor and migrate body incoming wrench observations off ArticulationData. Remove the old articulation data accessor and document the Isaac Lab 3.0 migration path. --- docs/source/api/lab/isaaclab.sensors.rst | 6 + .../migration/migrating_to_isaaclab_3-0.rst | 72 ++++ source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 20 ++ .../articulation/base_articulation_data.py | 17 - .../isaaclab/envs/mdp/observations.py | 21 +- .../joint_wrench/base_joint_wrench_sensor.py | 33 +- .../base_joint_wrench_sensor_data.py | 8 +- .../joint_wrench/joint_wrench_sensor.py | 6 +- .../joint_wrench/joint_wrench_sensor_data.py | 5 +- .../assets/mock_articulation.py | 18 - .../test/assets/test_articulation_iface.py | 17 - .../test_mock_data_properties.py | 1 - source/isaaclab_newton/config/extension.toml | 2 +- source/isaaclab_newton/docs/CHANGELOG.rst | 12 + .../assets/articulation/articulation_data.py | 17 - .../joint_wrench/joint_wrench_sensor_data.py | 8 +- .../test/assets/test_articulation.py | 102 ------ .../test/sensors/test_joint_wrench_sensor.py | 5 +- source/isaaclab_ovphysx/config/extension.toml | 2 +- source/isaaclab_ovphysx/docs/CHANGELOG.rst | 12 + .../assets/articulation/articulation_data.py | 20 -- .../assets/benchmark_articulation_data.py | 1 - source/isaaclab_physx/config/extension.toml | 2 +- source/isaaclab_physx/docs/CHANGELOG.rst | 18 + .../assets/articulation/articulation_data.py | 31 -- .../isaaclab_physx/sensors/__init__.pyi | 3 + .../sensors/joint_wrench/__init__.py | 10 + .../sensors/joint_wrench/__init__.pyi | 9 + .../joint_wrench/joint_wrench_sensor.py | 166 +++++++++ .../joint_wrench/joint_wrench_sensor_data.py | 67 ++++ .../sensors/joint_wrench/kernels.py | 38 ++ .../test/assets/test_articulation.py | 102 ------ .../test/sensors/test_joint_wrench_sensor.py | 331 ++++++++++++++++++ source/isaaclab_tasks/config/extension.toml | 2 +- source/isaaclab_tasks/docs/CHANGELOG.rst | 14 + .../inhand_manipulation_env.py | 45 ++- .../shadow_hand/shadow_hand_vision_env.py | 10 +- .../manager_based/classic/ant/ant_env_cfg.py | 9 +- .../classic/humanoid/humanoid_env_cfg.py | 6 +- 40 files changed, 895 insertions(+), 375 deletions(-) create mode 100644 source/isaaclab_physx/isaaclab_physx/sensors/joint_wrench/__init__.py create mode 100644 source/isaaclab_physx/isaaclab_physx/sensors/joint_wrench/__init__.pyi create mode 100644 source/isaaclab_physx/isaaclab_physx/sensors/joint_wrench/joint_wrench_sensor.py create mode 100644 source/isaaclab_physx/isaaclab_physx/sensors/joint_wrench/joint_wrench_sensor_data.py create mode 100644 source/isaaclab_physx/isaaclab_physx/sensors/joint_wrench/kernels.py create mode 100644 source/isaaclab_physx/test/sensors/test_joint_wrench_sensor.py diff --git a/docs/source/api/lab/isaaclab.sensors.rst b/docs/source/api/lab/isaaclab.sensors.rst index 15fa68e71349..1beccd5481f1 100644 --- a/docs/source/api/lab/isaaclab.sensors.rst +++ b/docs/source/api/lab/isaaclab.sensors.rst @@ -37,6 +37,7 @@ Imu ImuCfg JointWrenchSensor + JointWrenchSensorData JointWrenchSensorCfg Sensor Base @@ -200,6 +201,11 @@ Joint Wrench Sensor :inherited-members: :show-inheritance: +.. autoclass:: JointWrenchSensorData + :members: + :inherited-members: + :exclude-members: __init__ + .. autoclass:: JointWrenchSensorCfg :members: :inherited-members: diff --git a/docs/source/migration/migrating_to_isaaclab_3-0.rst b/docs/source/migration/migrating_to_isaaclab_3-0.rst index f90d47496d47..2e9fd76fcd80 100644 --- a/docs/source/migration/migrating_to_isaaclab_3-0.rst +++ b/docs/source/migration/migrating_to_isaaclab_3-0.rst @@ -158,6 +158,8 @@ The following sensor classes also remain in the ``isaaclab`` package with unchan - :class:`~isaaclab.sensors.Imu`, :class:`~isaaclab.sensors.ImuCfg`, :class:`~isaaclab.sensors.ImuData` - :class:`~isaaclab.sensors.Pva`, :class:`~isaaclab.sensors.PvaCfg`, :class:`~isaaclab.sensors.PvaData` - :class:`~isaaclab.sensors.FrameTransformer`, :class:`~isaaclab.sensors.FrameTransformerCfg`, :class:`~isaaclab.sensors.FrameTransformerData` +- :class:`~isaaclab.sensors.JointWrenchSensor`, :class:`~isaaclab.sensors.JointWrenchSensorCfg`, + :class:`~isaaclab.sensors.JointWrenchSensorData` These sensor classes now use factory patterns that automatically instantiate the appropriate backend implementation (PhysX by default), maintaining full backward compatibility. @@ -179,6 +181,7 @@ you can import from ``isaaclab_physx.sensors``: from isaaclab_physx.sensors import Imu, ImuData from isaaclab_physx.sensors import Pva, PvaData from isaaclab_physx.sensors import FrameTransformer, FrameTransformerData + from isaaclab_physx.sensors import JointWrenchSensor, JointWrenchSensorData New ``isaaclab_newton`` Extension @@ -188,6 +191,8 @@ A new extension ``isaaclab_newton`` provides Newton physics backend implementati - :class:`~isaaclab_newton.assets.Articulation` and :class:`~isaaclab_newton.assets.ArticulationData` - :class:`~isaaclab_newton.assets.RigidObject` and :class:`~isaaclab_newton.assets.RigidObjectData` +- :class:`~isaaclab_newton.sensors.JointWrenchSensor` and + :class:`~isaaclab_newton.sensors.JointWrenchSensorData` These classes implement the same base interfaces as their PhysX counterparts (:class:`~isaaclab.assets.BaseArticulation`, :class:`~isaaclab.assets.BaseRigidObject`), @@ -331,6 +336,73 @@ If you need to track sensor poses in world frame, please use a dedicated sensor sensor_quat = frame_transformer.data.target_quat_w +Articulation Joint Wrench Data Moved to ``JointWrenchSensor`` +------------------------------------------------------------- + +The ``ArticulationData.body_incoming_joint_wrench_b`` property has been removed. In +Isaac Lab 3.0, incoming joint reaction wrenches are exposed through +:class:`~isaaclab.sensors.JointWrenchSensor`, which has PhysX and Newton backend +implementations and returns separate force [N] and torque [N·m] buffers. + +**Before (Isaac Lab 2.x):** + +.. code-block:: python + + wrench_b = robot.data.body_incoming_joint_wrench_b.torch[:, body_ids] + +**After (Isaac Lab 3.x):** + +.. code-block:: python + + import torch + from isaaclab.scene import InteractiveSceneCfg + from isaaclab.sensors import JointWrenchSensorCfg + + class MySceneCfg(InteractiveSceneCfg): + robot = ROBOT_CFG.replace(prim_path="{ENV_REGEX_NS}/Robot") + joint_wrench = JointWrenchSensorCfg(prim_path="{ENV_REGEX_NS}/Robot") + + sensor = env.scene.sensors["joint_wrench"] + data = sensor.data + wrench_b = torch.cat( + ( + data.force.torch[:, body_ids], + data.torque.torch[:, body_ids], + ), + dim=-1, + ) + +Use :attr:`~isaaclab.sensors.BaseJointWrenchSensor.body_names` or +:meth:`~isaaclab.sensors.BaseJointWrenchSensor.find_bodies` to map sensor entries to +articulation body names. PhysX reports one entry for every link, including the articulation +root link. Newton reports the child bodies of reportable incoming joints. + +For manager-based environments, update observations that used the articulation data property to +depend on the joint-wrench sensor instead: + +.. code-block:: python + + import isaaclab.envs.mdp as mdp + from isaaclab.managers import SceneEntityCfg + from isaaclab.managers import ObservationTermCfg as ObsTerm + from isaaclab.scene import InteractiveSceneCfg + from isaaclab.sensors import JointWrenchSensorCfg + + class MySceneCfg(InteractiveSceneCfg): + robot = ROBOT_CFG.replace(prim_path="{ENV_REGEX_NS}/Robot") + joint_wrench = JointWrenchSensorCfg(prim_path="{ENV_REGEX_NS}/Robot") + + feet_body_forces = ObsTerm( + func=mdp.body_incoming_wrench, + params={ + "sensor_cfg": SceneEntityCfg( + "joint_wrench", + body_names=["left_foot", "right_foot"], + ) + }, + ) + + Multi-Backend Support: PresetCfg Pattern ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index 322e44e742c9..70eb0d6ffa90 100644 --- a/source/isaaclab/config/extension.toml +++ b/source/isaaclab/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "4.6.23" +version = "4.6.24" # Description title = "Isaac Lab framework for Robot Learning" diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index 07007ad709f2..737a207efe56 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,26 @@ Changelog --------- +4.6.24 (2026-04-30) +~~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Changed :func:`~isaaclab.envs.mdp.body_incoming_wrench` to read from + :class:`~isaaclab.sensors.JointWrenchSensor`. Pass + ``sensor_cfg=SceneEntityCfg("joint_wrench", body_names=...)`` instead of an + articulation asset config. + +Removed +^^^^^^^ + +* Removed ``BaseArticulationData.body_incoming_joint_wrench_b``. + Add :class:`~isaaclab.sensors.JointWrenchSensorCfg` to the scene and read + :attr:`~isaaclab.sensors.JointWrenchSensorData.force` and + :attr:`~isaaclab.sensors.JointWrenchSensorData.torque` instead. + + 4.6.23 (2026-04-30) ~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab/isaaclab/assets/articulation/base_articulation_data.py b/source/isaaclab/isaaclab/assets/articulation/base_articulation_data.py index fdd79ce6d474..89cd04b4b93a 100644 --- a/source/isaaclab/isaaclab/assets/articulation/base_articulation_data.py +++ b/source/isaaclab/isaaclab/assets/articulation/base_articulation_data.py @@ -603,23 +603,6 @@ def body_com_pose_b(self) -> ProxyArray: """ raise NotImplementedError - @property - @abstractmethod - def body_incoming_joint_wrench_b(self) -> ProxyArray: - """Joint reaction wrench applied from body parent to child body in parent body frame. - - Shape is (num_instances, num_bodies), dtype = wp.spatial_vectorf. In torch this resolves to - (num_instances, num_bodies, 6). All body reaction wrenches are provided including the root body to the - world of an articulation. - - For more information on joint wrenches, please check the `PhysX documentation`_ and the - underlying `PhysX Tensor API`_. - - .. _PhysX documentation: https://nvidia-omniverse.github.io/PhysX/physx/5.5.1/docs/Articulations.html#link-incoming-joint-force - .. _PhysX Tensor API: https://docs.omniverse.nvidia.com/kit/docs/omni_physics/latest/extensions/runtime/source/omni.physics.tensors/docs/api/python.html#omni.physics.tensors.api.ArticulationView.get_link_incoming_joint_force - """ - raise NotImplementedError - ## # Joint state properties. ## diff --git a/source/isaaclab/isaaclab/envs/mdp/observations.py b/source/isaaclab/isaaclab/envs/mdp/observations.py index 4fc24fc5b944..8f97da3595dd 100644 --- a/source/isaaclab/isaaclab/envs/mdp/observations.py +++ b/source/isaaclab/isaaclab/envs/mdp/observations.py @@ -23,7 +23,7 @@ if TYPE_CHECKING: from isaaclab.assets import Articulation, RigidObject from isaaclab.envs import ManagerBasedEnv, ManagerBasedRLEnv - from isaaclab.sensors import Camera, Imu, Pva, RayCaster, RayCasterCamera + from isaaclab.sensors import Camera, Imu, JointWrenchSensor, Pva, RayCaster, RayCasterCamera from isaaclab.envs.utils.io_descriptors import ( generic_io_descriptor, @@ -304,16 +304,21 @@ def height_scan(env: ManagerBasedEnv, sensor_cfg: SceneEntityCfg, offset: float return sensor.data.pos_w.torch[:, 2].unsqueeze(1) - sensor.data.ray_hits_w.torch[..., 2] - offset -def body_incoming_wrench(env: ManagerBasedEnv, asset_cfg: SceneEntityCfg) -> torch.Tensor: - """Incoming spatial wrench on bodies of an articulation in the simulation world frame. +def body_incoming_wrench(env: ManagerBasedEnv, sensor_cfg: SceneEntityCfg) -> torch.Tensor: + """Incoming spatial wrench [N, N·m] on bodies of an articulation in the sensor convention. - This is the 6-D wrench (force and torque) applied to the body link by the incoming joint force. + This is the 6-D wrench (force followed by torque) applied to the body link by the incoming joint force. """ # extract the used quantities (to enable type-hinting) - asset: Articulation = env.scene[asset_cfg.name] - # obtain the link incoming forces in world frame - body_incoming_joint_wrench_b = asset.data.body_incoming_joint_wrench_b.torch[:, asset_cfg.body_ids] - return body_incoming_joint_wrench_b.view(env.num_envs, -1) + sensor: JointWrenchSensor = env.scene.sensors[sensor_cfg.name] + sensor_data = sensor.data + force_data = sensor_data.force + torque_data = sensor_data.torque + if force_data is None or torque_data is None: + raise RuntimeError("Joint wrench sensor data is not initialized. Call sim.reset() before reading observations.") + force = force_data.torch[:, sensor_cfg.body_ids] + torque = torque_data.torch[:, sensor_cfg.body_ids] + return torch.cat((force, torque), dim=-1).view(env.num_envs, -1) def pva_orientation(env: ManagerBasedEnv, asset_cfg: SceneEntityCfg = SceneEntityCfg("pva")) -> torch.Tensor: diff --git a/source/isaaclab/isaaclab/sensors/joint_wrench/base_joint_wrench_sensor.py b/source/isaaclab/isaaclab/sensors/joint_wrench/base_joint_wrench_sensor.py index e11811b451bb..158ba4a75dd7 100644 --- a/source/isaaclab/isaaclab/sensors/joint_wrench/base_joint_wrench_sensor.py +++ b/source/isaaclab/isaaclab/sensors/joint_wrench/base_joint_wrench_sensor.py @@ -6,10 +6,13 @@ from __future__ import annotations from abc import abstractmethod +from collections.abc import Sequence from typing import TYPE_CHECKING import warp as wp +import isaaclab.utils.string as string_utils + from ..sensor_base import SensorBase from .base_joint_wrench_sensor_data import BaseJointWrenchSensorData @@ -20,11 +23,10 @@ class BaseJointWrenchSensor(SensorBase): """The joint reaction wrench sensor. - Reports the incoming joint wrench on each joint of an articulation as a - split force [N] / torque [N·m] pair expressed in the - ``INCOMING_JOINT_FRAME`` convention (child-side joint frame, child-side - joint anchor reference point). Backends convert from their native - representation to this convention internally. + Reports incoming joint wrenches for the bodies selected by the backend as + split force [N] / torque [N·m] pairs expressed in the + ``INCOMING_JOINT_FRAME`` convention. Use :attr:`body_names` or + :meth:`find_bodies` to map entries to articulation bodies. """ cfg: JointWrenchSensorCfg @@ -57,6 +59,27 @@ def body_names(self) -> list[str]: """Ordered names of the bodies whose incoming joint wrench is reported.""" raise NotImplementedError + @property + def num_bodies(self) -> int: + """Number of bodies whose incoming joint wrench is reported.""" + return len(self.body_names) + + """ + Operations + """ + + def find_bodies(self, name_keys: str | Sequence[str], preserve_order: bool = False) -> tuple[list[int], list[str]]: + """Find reported bodies based on name keys. + + Args: + name_keys: A regular expression or list of regular expressions to match the body names. + preserve_order: Whether to preserve the order of the name keys in the output. Defaults to False. + + Returns: + The matching body indices and names. + """ + return string_utils.resolve_matching_names(name_keys, self.body_names, preserve_order) + """ Implementation - Abstract methods to be implemented by backend-specific subclasses. """ diff --git a/source/isaaclab/isaaclab/sensors/joint_wrench/base_joint_wrench_sensor_data.py b/source/isaaclab/isaaclab/sensors/joint_wrench/base_joint_wrench_sensor_data.py index fd1380212990..282021a36784 100644 --- a/source/isaaclab/isaaclab/sensors/joint_wrench/base_joint_wrench_sensor_data.py +++ b/source/isaaclab/isaaclab/sensors/joint_wrench/base_joint_wrench_sensor_data.py @@ -22,8 +22,8 @@ def force(self) -> ProxyArray | None: Expressed in the frame selected by :attr:`~isaaclab.sensors.JointWrenchSensorCfg.convention`. Shape is - ``(num_envs, num_joints)``, dtype ``wp.vec3f``. In torch this resolves - to ``(num_envs, num_joints, 3)``. ``None`` before the simulation is + ``(num_envs, num_bodies)``, dtype ``wp.vec3f``. In torch this resolves + to ``(num_envs, num_bodies, 3)``. ``None`` before the simulation is initialized. """ raise NotImplementedError @@ -35,8 +35,8 @@ def torque(self) -> ProxyArray | None: Expressed in the frame selected by :attr:`~isaaclab.sensors.JointWrenchSensorCfg.convention`. Shape is - ``(num_envs, num_joints)``, dtype ``wp.vec3f``. In torch this resolves - to ``(num_envs, num_joints, 3)``. ``None`` before the simulation is + ``(num_envs, num_bodies)``, dtype ``wp.vec3f``. In torch this resolves + to ``(num_envs, num_bodies, 3)``. ``None`` before the simulation is initialized. """ raise NotImplementedError diff --git a/source/isaaclab/isaaclab/sensors/joint_wrench/joint_wrench_sensor.py b/source/isaaclab/isaaclab/sensors/joint_wrench/joint_wrench_sensor.py index f4a88d94b978..40103b20e9f1 100644 --- a/source/isaaclab/isaaclab/sensors/joint_wrench/joint_wrench_sensor.py +++ b/source/isaaclab/isaaclab/sensors/joint_wrench/joint_wrench_sensor.py @@ -15,13 +15,15 @@ if TYPE_CHECKING: from isaaclab_newton.sensors.joint_wrench import JointWrenchSensor as NewtonJointWrenchSensor from isaaclab_newton.sensors.joint_wrench import JointWrenchSensorData as NewtonJointWrenchSensorData + from isaaclab_physx.sensors.joint_wrench import JointWrenchSensor as PhysXJointWrenchSensor + from isaaclab_physx.sensors.joint_wrench import JointWrenchSensorData as PhysXJointWrenchSensorData class JointWrenchSensor(FactoryBase, BaseJointWrenchSensor): """Factory for creating joint-wrench sensor instances.""" - data: BaseJointWrenchSensorData | NewtonJointWrenchSensorData + data: BaseJointWrenchSensorData | PhysXJointWrenchSensorData | NewtonJointWrenchSensorData - def __new__(cls, *args, **kwargs) -> BaseJointWrenchSensor | NewtonJointWrenchSensor: + def __new__(cls, *args, **kwargs) -> BaseJointWrenchSensor | PhysXJointWrenchSensor | NewtonJointWrenchSensor: """Create a new instance of a joint-wrench sensor based on the backend.""" return super().__new__(cls, *args, **kwargs) diff --git a/source/isaaclab/isaaclab/sensors/joint_wrench/joint_wrench_sensor_data.py b/source/isaaclab/isaaclab/sensors/joint_wrench/joint_wrench_sensor_data.py index 66bc9d1214bf..5872640d143c 100644 --- a/source/isaaclab/isaaclab/sensors/joint_wrench/joint_wrench_sensor_data.py +++ b/source/isaaclab/isaaclab/sensors/joint_wrench/joint_wrench_sensor_data.py @@ -15,11 +15,14 @@ if TYPE_CHECKING: from isaaclab_newton.sensors.joint_wrench import JointWrenchSensorData as NewtonJointWrenchSensorData + from isaaclab_physx.sensors.joint_wrench import JointWrenchSensorData as PhysXJointWrenchSensorData class JointWrenchSensorData(FactoryBase, BaseJointWrenchSensorData): """Factory for creating joint-wrench sensor data instances.""" - def __new__(cls, *args, **kwargs) -> BaseJointWrenchSensorData | NewtonJointWrenchSensorData: + def __new__( + cls, *args, **kwargs + ) -> BaseJointWrenchSensorData | PhysXJointWrenchSensorData | NewtonJointWrenchSensorData: """Create a new instance of joint-wrench sensor data based on the backend.""" return super().__new__(cls, *args, **kwargs) diff --git a/source/isaaclab/isaaclab/test/mock_interfaces/assets/mock_articulation.py b/source/isaaclab/isaaclab/test/mock_interfaces/assets/mock_articulation.py index aa72bf2bbfd9..b7a29a55b389 100644 --- a/source/isaaclab/isaaclab/test/mock_interfaces/assets/mock_articulation.py +++ b/source/isaaclab/isaaclab/test/mock_interfaces/assets/mock_articulation.py @@ -134,7 +134,6 @@ def __init__( # Body properties self._body_mass: wp.array | None = None self._body_inertia: wp.array | None = None - self._body_incoming_joint_wrench_b: wp.array | None = None # Tendon properties (fixed) self._fixed_tendon_stiffness: wp.array | None = None @@ -187,7 +186,6 @@ def __init__( self._body_com_pose_b_ta: ProxyArray | None = None self._body_mass_ta: ProxyArray | None = None self._body_inertia_ta: ProxyArray | None = None - self._body_incoming_joint_wrench_b_ta: ProxyArray | None = None self._fixed_tendon_stiffness_ta: ProxyArray | None = None self._fixed_tendon_damping_ta: ProxyArray | None = None self._fixed_tendon_limit_stiffness_ta: ProxyArray | None = None @@ -830,18 +828,6 @@ def body_inertia(self) -> ProxyArray: self._body_inertia_ta = ProxyArray(self._body_inertia) return self._body_inertia_ta - @property - def body_incoming_joint_wrench_b(self) -> ProxyArray: - """Body incoming joint wrenches. dtype=wp.spatial_vectorf, shape: (N, num_bodies).""" - if self._body_incoming_joint_wrench_b is None: - self._body_incoming_joint_wrench_b = wp.zeros( - (self._num_instances, self._num_bodies, 6), dtype=wp.float32, device=self.device - ).view(wp.spatial_vectorf) - self._body_incoming_joint_wrench_b_ta = None - if self._body_incoming_joint_wrench_b_ta is None: - self._body_incoming_joint_wrench_b_ta = ProxyArray(self._body_incoming_joint_wrench_b) - return self._body_incoming_joint_wrench_b_ta - # -- Derived properties -- @property @@ -1176,10 +1162,6 @@ def set_body_inertia(self, value: torch.Tensor) -> None: self._body_inertia = wp.from_torch(value.to(self.device).contiguous()) self._body_inertia_ta = None - def set_body_incoming_joint_wrench_b(self, value: torch.Tensor) -> None: - self._body_incoming_joint_wrench_b = wp.from_torch(value.to(self.device).contiguous()) - self._body_incoming_joint_wrench_b_ta = None - def set_fixed_tendon_stiffness(self, value: torch.Tensor) -> None: self._fixed_tendon_stiffness = wp.from_torch(value.to(self.device).contiguous()) self._fixed_tendon_stiffness_ta = None diff --git a/source/isaaclab/test/assets/test_articulation_iface.py b/source/isaaclab/test/assets/test_articulation_iface.py index 9682df92f3a5..115492da01b8 100644 --- a/source/isaaclab/test/assets/test_articulation_iface.py +++ b/source/isaaclab/test/assets/test_articulation_iface.py @@ -1018,23 +1018,6 @@ def test_body_inertia(self, backend, num_instances, num_joints, num_bodies, devi name="body_inertia", ) - @_backends - @_default_dims - @_default_devices - def test_body_incoming_joint_wrench_b( - self, backend, num_instances, num_joints, num_bodies, device, articulation_iface - ): - if backend == "newton": - pytest.xfail("Newton does not support joint wrench reporting") - art, _ = articulation_iface - art.data.update(dt=0.01) - _check_proxy_array( - art.data.body_incoming_joint_wrench_b, - expected_shape=(num_instances, num_bodies), - expected_dtype=wp.spatial_vectorf, - name="body_incoming_joint_wrench_b", - ) - @_backends @_default_dims @_default_devices diff --git a/source/isaaclab/test/test_mock_interfaces/test_mock_data_properties.py b/source/isaaclab/test/test_mock_interfaces/test_mock_data_properties.py index c688975ca3eb..b68bb8141e1c 100644 --- a/source/isaaclab/test/test_mock_interfaces/test_mock_data_properties.py +++ b/source/isaaclab/test/test_mock_interfaces/test_mock_data_properties.py @@ -438,7 +438,6 @@ def test_body_state_shapes(self, data, property_name, expected_shape): [ ("body_mass", (4, 13)), ("body_inertia", (4, 13, 9)), - ("body_incoming_joint_wrench_b", (4, 13, 6)), ], ) def test_body_property_shapes(self, data, property_name, expected_shape): diff --git a/source/isaaclab_newton/config/extension.toml b/source/isaaclab_newton/config/extension.toml index 0a8eed8000c2..2e598bf86303 100644 --- a/source/isaaclab_newton/config/extension.toml +++ b/source/isaaclab_newton/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "0.5.26" +version = "0.5.27" # Description title = "Newton simulation interfaces for IsaacLab core package" diff --git a/source/isaaclab_newton/docs/CHANGELOG.rst b/source/isaaclab_newton/docs/CHANGELOG.rst index 9ef33bc51848..2325473353f1 100644 --- a/source/isaaclab_newton/docs/CHANGELOG.rst +++ b/source/isaaclab_newton/docs/CHANGELOG.rst @@ -1,6 +1,18 @@ Changelog --------- +0.5.27 (2026-04-30) +~~~~~~~~~~~~~~~~~~~ + +Removed +^^^^^^^ + +* Removed the unimplemented ``ArticulationData.body_incoming_joint_wrench_b`` + accessor. Add :class:`~isaaclab.sensors.JointWrenchSensorCfg` to the scene + and read :attr:`~isaaclab.sensors.JointWrenchSensorData.force` and + :attr:`~isaaclab.sensors.JointWrenchSensorData.torque` instead. + + 0.5.26 (2026-04-30) ~~~~~~~~~~~~~~~~~~~ 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 ff74915db71b..a22ba73e1725 100644 --- a/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation_data.py +++ b/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation_data.py @@ -805,22 +805,6 @@ def body_com_pose_b(self) -> ProxyArray: self._body_com_pose_b.timestamp = self._sim_timestamp return self._body_com_pose_b_ta - @property - def body_incoming_joint_wrench_b(self) -> ProxyArray: - """Joint reaction wrench applied from body parent to child body in parent body frame. - - Shape is (num_instances, num_bodies), dtype = wp.spatial_vectorf. In torch this resolves to - (num_instances, num_bodies, 6). All body reaction wrenches are provided including the root body to the - world of an articulation. - - For more information on joint wrenches, please check the `PhysX documentation`_ and the - underlying `PhysX Tensor API`_. - - .. _PhysX documentation: https://nvidia-omniverse.github.io/PhysX/physx/5.5.1/docs/Articulations.html#link-incoming-joint-force - .. _PhysX Tensor API: https://docs.omniverse.nvidia.com/kit/docs/omni_physics/latest/extensions/runtime/source/omni.physics.tensors/docs/api/python.html#omni.physics.tensors.api.ArticulationView.get_link_incoming_joint_force - """ - raise NotImplementedError("Not implemented for Newton") - """ Joint state properties. """ @@ -1526,7 +1510,6 @@ def _create_buffers(self) -> None: shape=(self._num_instances, self._num_joints), dtype=wp.float32, device=self.device ) # Empty memory pre-allocations - self._body_incoming_joint_wrench_b = None self._root_link_lin_vel_b = None self._root_link_ang_vel_b = None self._root_com_lin_vel_b = None diff --git a/source/isaaclab_newton/isaaclab_newton/sensors/joint_wrench/joint_wrench_sensor_data.py b/source/isaaclab_newton/isaaclab_newton/sensors/joint_wrench/joint_wrench_sensor_data.py index 640fd784978c..76c5a565bdfb 100644 --- a/source/isaaclab_newton/isaaclab_newton/sensors/joint_wrench/joint_wrench_sensor_data.py +++ b/source/isaaclab_newton/isaaclab_newton/sensors/joint_wrench/joint_wrench_sensor_data.py @@ -27,8 +27,8 @@ def force(self) -> ProxyArray | None: Expressed in the frame selected by :attr:`~isaaclab.sensors.JointWrenchSensorCfg.convention`. Shape is - ``(num_envs, num_joints)``, dtype ``wp.vec3f``. In torch this resolves - to ``(num_envs, num_joints, 3)``. ``None`` before the simulation is + ``(num_envs, num_bodies)``, dtype ``wp.vec3f``. In torch this resolves + to ``(num_envs, num_bodies, 3)``. ``None`` before the simulation is initialized. """ if self._force is None: @@ -43,8 +43,8 @@ def torque(self) -> ProxyArray | None: Expressed in the frame selected by :attr:`~isaaclab.sensors.JointWrenchSensorCfg.convention`. Shape is - ``(num_envs, num_joints)``, dtype ``wp.vec3f``. In torch this resolves - to ``(num_envs, num_joints, 3)``. ``None`` before the simulation is + ``(num_envs, num_bodies)``, dtype ``wp.vec3f``. In torch this resolves + to ``(num_envs, num_bodies, 3)``. ``None`` before the simulation is initialized. """ if self._torque is None: diff --git a/source/isaaclab_newton/test/assets/test_articulation.py b/source/isaaclab_newton/test/assets/test_articulation.py index 5a4e77fd9eaf..a5eacf95045e 100644 --- a/source/isaaclab_newton/test/assets/test_articulation.py +++ b/source/isaaclab_newton/test/assets/test_articulation.py @@ -2006,108 +2006,6 @@ def test_write_root_state( torch.testing.assert_close(rand_state[..., 7:], articulation.data.root_link_vel_w.torch) -@pytest.mark.isaacsim_ci -@pytest.mark.parametrize("num_articulations", [1, 2]) -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -@pytest.mark.parametrize("articulation_type", ["single_joint_implicit"]) -@pytest.mark.xfail(reason="Newton body_parent_f uses different convention than PhysX get_link_incoming_joint_force") -def test_body_incoming_joint_wrench_b_single_joint(sim, num_articulations, device, articulation_type): - """Test the data.body_incoming_joint_wrench_b buffer is populated correctly and statically correct for single joint. - - This test verifies that: - 1. The body incoming joint wrench buffer has correct shape - 2. The wrench values are statically correct for a single joint - 3. The wrench values match expected values from gravity and external forces - - 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=articulation_type) - articulation, _ = generate_articulation( - articulation_cfg=articulation_cfg, num_articulations=num_articulations, device=device - ) - - # Play the simulator - sim.reset() - - # Resolve body indices by name (ordering may differ across physics backends) - arm_idx = articulation.body_names.index("Arm") - root_idx = articulation.body_names.index("CenterPivot") - # apply external force - external_force_vector_b = torch.zeros((num_articulations, articulation.num_bodies, 3), device=device) - external_force_vector_b[:, arm_idx, 1] = 10.0 # 10 N in Y direction - external_torque_vector_b = torch.zeros((num_articulations, articulation.num_bodies, 3), device=device) - external_torque_vector_b[:, arm_idx, 2] = 10.0 # 10 Nm in z direction - - # apply action to the articulation - joint_pos = torch.ones_like(articulation.data.joint_pos.torch) * 1.5708 / 2.0 - articulation.write_joint_position_to_sim_index( - position=torch.ones_like(articulation.data.joint_pos.torch), - ) - articulation.write_joint_velocity_to_sim_index( - velocity=torch.zeros_like(articulation.data.joint_vel.torch), - ) - articulation.set_joint_position_target_index(target=joint_pos) - articulation.write_data_to_sim() - for _ in range(50): - articulation.permanent_wrench_composer.set_forces_and_torques_index( - forces=external_force_vector_b, torques=external_torque_vector_b - ) - articulation.write_data_to_sim() - # perform step - sim.step() - # update buffers - articulation.update(sim.cfg.dt) - - # check shape - assert articulation.data.body_incoming_joint_wrench_b.torch.shape == ( - num_articulations, - articulation.num_bodies, - 6, - ) - - # calculate expected static - mass = articulation.data.body_mass.torch.to("cpu") - pos_w = articulation.data.body_pos_w.torch - quat_w = articulation.data.body_quat_w.torch - - mass_link2 = mass[:, arm_idx].view(num_articulations, -1) - gravity = torch.tensor(sim.cfg.gravity, device="cpu").repeat(num_articulations, 1).view((num_articulations, 3)) - - # NOTE: the com and link pose for single joint are colocated - weight_vector_w = mass_link2 * gravity - # expected wrench from link mass and external wrench - # The incoming joint wrench is the constraint/support force from parent onto child (body1=Arm), - # expressed in Arm's frame. In static equilibrium this equals -(gravity + external forces on Arm). - total_force_w = weight_vector_w.to(device) + math_utils.quat_apply( - quat_w[:, arm_idx, :], external_force_vector_b[:, arm_idx, :] - ) - total_torque_w = torch.cross( - pos_w[:, arm_idx, :].to(device) - pos_w[:, root_idx, :].to(device), - total_force_w, - dim=-1, - ) + math_utils.quat_apply(quat_w[:, arm_idx, :], external_torque_vector_b[:, arm_idx, :]) - expected_wrench = torch.zeros((num_articulations, 6), device=device) - expected_wrench[:, :3] = math_utils.quat_apply( - math_utils.quat_conjugate(quat_w[:, arm_idx, :]), - -total_force_w, - ) - expected_wrench[:, 3:] = math_utils.quat_apply( - math_utils.quat_conjugate(quat_w[:, arm_idx, :]), - -total_torque_w, - ) - - # check value of last joint wrench - torch.testing.assert_close( - expected_wrench, - articulation.data.body_incoming_joint_wrench_b.torch[:, arm_idx, :].squeeze(1), - atol=1e-2, - rtol=1e-3, - ) - - @pytest.mark.isaacsim_ci @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("articulation_type", ["humanoid"]) diff --git a/source/isaaclab_newton/test/sensors/test_joint_wrench_sensor.py b/source/isaaclab_newton/test/sensors/test_joint_wrench_sensor.py index 7e0ac0ea6960..dba86b2c0c8c 100644 --- a/source/isaaclab_newton/test/sensors/test_joint_wrench_sensor.py +++ b/source/isaaclab_newton/test/sensors/test_joint_wrench_sensor.py @@ -314,9 +314,8 @@ def test_force_and_torque_components_at_rest(sim): def test_wrench_with_external_force_and_torque(sim): """Full analytical wrench validation with external force and torque applied. - Mirrors the PhysX ``test_body_incoming_joint_wrench_b_single_joint`` pattern: - apply a known wrench, settle, compute the expected reaction wrench analytically, - and compare component-by-component. + Applies a known wrench, settles, computes the expected reaction wrench analytically, + and compares component-by-component. """ scene = InteractiveScene(_SingleJointSceneCfg(num_envs=1)) sim.reset() diff --git a/source/isaaclab_ovphysx/config/extension.toml b/source/isaaclab_ovphysx/config/extension.toml index 8648b7fa9587..1e541402d828 100644 --- a/source/isaaclab_ovphysx/config/extension.toml +++ b/source/isaaclab_ovphysx/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "0.1.2" +version = "0.1.3" # Description title = "OvPhysX simulation interfaces for IsaacLab core package" diff --git a/source/isaaclab_ovphysx/docs/CHANGELOG.rst b/source/isaaclab_ovphysx/docs/CHANGELOG.rst index b2eb969d7845..109440081a95 100644 --- a/source/isaaclab_ovphysx/docs/CHANGELOG.rst +++ b/source/isaaclab_ovphysx/docs/CHANGELOG.rst @@ -1,6 +1,18 @@ Changelog --------- +0.1.3 (2026-04-30) +~~~~~~~~~~~~~~~~~~ + +Removed +^^^^^^^ + +* Removed ``ArticulationData.body_incoming_joint_wrench_b`` + to match the shared articulation data API. Code that needs incoming joint + reaction wrenches should use a backend joint-wrench sensor instead of the + articulation data object. + + 0.1.2 (2026-04-23) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation_data.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation_data.py index fe264924c184..10c7e4b7ecd6 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation_data.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation_data.py @@ -810,23 +810,6 @@ def body_com_pose_b(self) -> ProxyArray: self._body_com_pose_b_ta = ProxyArray(self._body_com_pose_b.data) return self._body_com_pose_b_ta - @property - def body_incoming_joint_wrench_b(self) -> ProxyArray: - """Incoming joint wrenches on each body in the body frame [N, N*m]. - - Shape is (num_instances, num_bodies), dtype = wp.spatial_vectorf. In torch this resolves to - (num_instances, num_bodies, 6). - - All body reaction wrenches are provided including the root body to the world of an articulation. - """ - self._read_spatial_vector_binding( - TT.LINK_INCOMING_JOINT_FORCE, - self._body_incoming_joint_wrench_buf, - ) - if self._body_incoming_joint_wrench_b_ta is None: - self._body_incoming_joint_wrench_b_ta = ProxyArray(self._body_incoming_joint_wrench_buf.data) - return self._body_incoming_joint_wrench_b_ta - """ Joint state properties. """ @@ -1361,8 +1344,6 @@ def _create_buffers(self) -> None: # noqa: C901 self._body_com_pose_w = TimestampedBuffer((N, L), dev, wp.transformf) self._body_com_vel_w = TimestampedBuffer((N, L), dev, wp.spatial_vectorf) self._body_com_acc_w = TimestampedBuffer((N, L), dev, wp.spatial_vectorf) - self._body_incoming_joint_wrench_buf = TimestampedBuffer((N, L), dev, wp.spatial_vectorf) - # -- Joint state buffers self._joint_pos_buf = TimestampedBuffer((N, D), dev, wp.float32) self._joint_vel_buf = TimestampedBuffer((N, D), dev, wp.float32) @@ -1584,7 +1565,6 @@ def _pin_proxy_arrays(self) -> None: self._body_com_pose_w_ta: ProxyArray | None = None self._body_com_acc_w_ta: ProxyArray | None = None self._body_com_pose_b_ta: ProxyArray | None = None - self._body_incoming_joint_wrench_b_ta: ProxyArray | None = None # Body properties self._body_mass_ta: ProxyArray | None = None self._body_inertia_ta: ProxyArray | None = None diff --git a/source/isaaclab_physx/benchmark/assets/benchmark_articulation_data.py b/source/isaaclab_physx/benchmark/assets/benchmark_articulation_data.py index 5778f16017bc..f463f73ea6aa 100644 --- a/source/isaaclab_physx/benchmark/assets/benchmark_articulation_data.py +++ b/source/isaaclab_physx/benchmark/assets/benchmark_articulation_data.py @@ -131,7 +131,6 @@ "spatial_tendon_damping", "spatial_tendon_limit_stiffness", "spatial_tendon_offset", - "body_incoming_joint_wrench_b", } # Removed default_* properties that raise RuntimeError diff --git a/source/isaaclab_physx/config/extension.toml b/source/isaaclab_physx/config/extension.toml index 3c0711934431..5c63b0e6322f 100644 --- a/source/isaaclab_physx/config/extension.toml +++ b/source/isaaclab_physx/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "0.5.28" +version = "0.5.29" # Description title = "PhysX simulation interfaces for IsaacLab core package" diff --git a/source/isaaclab_physx/docs/CHANGELOG.rst b/source/isaaclab_physx/docs/CHANGELOG.rst index 5f284dc3c891..d403a94f541f 100644 --- a/source/isaaclab_physx/docs/CHANGELOG.rst +++ b/source/isaaclab_physx/docs/CHANGELOG.rst @@ -1,6 +1,24 @@ Changelog --------- +0.5.29 (2026-04-30) +~~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :class:`~isaaclab_physx.sensors.JointWrenchSensor` for reading PhysX + incoming joint reaction wrenches as split force [N] and torque [N·m] buffers. + +Removed +^^^^^^^ + +* Removed ``ArticulationData.body_incoming_joint_wrench_b``. + Add :class:`~isaaclab.sensors.JointWrenchSensorCfg` to the scene and read + :attr:`~isaaclab.sensors.JointWrenchSensorData.force` and + :attr:`~isaaclab.sensors.JointWrenchSensorData.torque` instead. + + 0.5.28 (2026-04-29) ~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation_data.py b/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation_data.py index 0e27e05bd141..8c2056d9cbfa 100644 --- a/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation_data.py +++ b/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation_data.py @@ -850,33 +850,6 @@ def body_com_pose_b(self) -> ProxyArray: self._body_com_pose_b_ta = ProxyArray(self._body_com_pose_b.data) return self._body_com_pose_b_ta - @property - def body_incoming_joint_wrench_b(self) -> ProxyArray: - """Joint reaction wrench applied to each body through its incoming joint, expressed in that body's frame. - - Shape is (num_instances, num_bodies, 6). All body reaction wrenches are provided including the root body to the - world of an articulation. - - .. note:: - PhysX expresses this wrench in the frame of ``body1`` as defined in the USD joint, which corresponds to - the child body's own frame when the USD convention is ``body0`` = parent, ``body1`` = child. - - For more information on joint wrenches, please check the `PhysX documentation`_ and the underlying - `PhysX Tensor API`_. - - .. _`PhysX documentation`: https://nvidia-omniverse.github.io/PhysX/physx/5.5.1/docs/Articulations.html#link-incoming-joint-force - .. _`PhysX Tensor API`: https://docs.omniverse.nvidia.com/kit/docs/omni_physics/latest/extensions/runtime/source/omni.physics.tensors/docs/api/python.html#omni.physics.tensors.api.ArticulationView.get_link_incoming_joint_force - """ - - if self._body_incoming_joint_wrench_b.timestamp < self._sim_timestamp: - self._body_incoming_joint_wrench_b.data = self._root_view.get_link_incoming_joint_force().view( - wp.spatial_vectorf - ) - self._body_incoming_joint_wrench_b.timestamp = self._sim_timestamp - if self._body_incoming_joint_wrench_b_ta is None: - self._body_incoming_joint_wrench_b_ta = ProxyArray(self._body_incoming_joint_wrench_b.data) - return self._body_incoming_joint_wrench_b_ta - """ Joint state properties. """ @@ -1382,9 +1355,6 @@ def _create_buffers(self) -> None: self._joint_pos = TimestampedBuffer((self._num_instances, self._num_joints), self.device, wp.float32) self._joint_vel = TimestampedBuffer((self._num_instances, self._num_joints), self.device, wp.float32) self._joint_acc = TimestampedBuffer((self._num_instances, self._num_joints), self.device, wp.float32) - self._body_incoming_joint_wrench_b = TimestampedBuffer( - (self._num_instances, self._num_bodies, self._num_joints), self.device, wp.spatial_vectorf - ) # -- derived properties (these are cached to avoid repeated memory allocations) self._projected_gravity_b = TimestampedBuffer((self._num_instances), self.device, wp.vec3f) self._heading_w = TimestampedBuffer((self._num_instances), self.device, wp.float32) @@ -1555,7 +1525,6 @@ def _pin_proxy_arrays(self) -> None: self._body_com_vel_w_ta: ProxyArray | None = None self._body_com_acc_w_ta: ProxyArray | None = None self._body_com_pose_b_ta: ProxyArray | None = None - self._body_incoming_joint_wrench_b_ta: ProxyArray | None = None # Body properties self._body_mass_ta: ProxyArray | None = None self._body_inertia_ta: ProxyArray | None = None diff --git a/source/isaaclab_physx/isaaclab_physx/sensors/__init__.pyi b/source/isaaclab_physx/isaaclab_physx/sensors/__init__.pyi index 0eba5ef7bdcf..e536b281952b 100644 --- a/source/isaaclab_physx/isaaclab_physx/sensors/__init__.pyi +++ b/source/isaaclab_physx/isaaclab_physx/sensors/__init__.pyi @@ -11,6 +11,8 @@ __all__ = [ "FrameTransformerData", "Imu", "ImuData", + "JointWrenchSensor", + "JointWrenchSensorData", "Pva", "PvaData", ] @@ -18,4 +20,5 @@ __all__ = [ from .contact_sensor import ContactSensor, ContactSensorData, ContactSensorCfg from .frame_transformer import FrameTransformer, FrameTransformerData from .imu import Imu, ImuData +from .joint_wrench import JointWrenchSensor, JointWrenchSensorData from .pva import Pva, PvaData diff --git a/source/isaaclab_physx/isaaclab_physx/sensors/joint_wrench/__init__.py b/source/isaaclab_physx/isaaclab_physx/sensors/joint_wrench/__init__.py new file mode 100644 index 000000000000..539f0250a568 --- /dev/null +++ b/source/isaaclab_physx/isaaclab_physx/sensors/joint_wrench/__init__.py @@ -0,0 +1,10 @@ +# 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 + +"""Sub-module for the PhysX joint-wrench sensor.""" + +from isaaclab.utils.module import lazy_export + +lazy_export() diff --git a/source/isaaclab_physx/isaaclab_physx/sensors/joint_wrench/__init__.pyi b/source/isaaclab_physx/isaaclab_physx/sensors/joint_wrench/__init__.pyi new file mode 100644 index 000000000000..b2bcd3582d44 --- /dev/null +++ b/source/isaaclab_physx/isaaclab_physx/sensors/joint_wrench/__init__.pyi @@ -0,0 +1,9 @@ +# 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 + +__all__ = ["JointWrenchSensor", "JointWrenchSensorData"] + +from .joint_wrench_sensor import JointWrenchSensor +from .joint_wrench_sensor_data import JointWrenchSensorData diff --git a/source/isaaclab_physx/isaaclab_physx/sensors/joint_wrench/joint_wrench_sensor.py b/source/isaaclab_physx/isaaclab_physx/sensors/joint_wrench/joint_wrench_sensor.py new file mode 100644 index 000000000000..e2124f244834 --- /dev/null +++ b/source/isaaclab_physx/isaaclab_physx/sensors/joint_wrench/joint_wrench_sensor.py @@ -0,0 +1,166 @@ +# 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 + +# pyright: reportPrivateUsage=false + +from __future__ import annotations + +import logging +from collections.abc import Sequence +from typing import TYPE_CHECKING + +import warp as wp + +from isaaclab.sensors.joint_wrench import BaseJointWrenchSensor + +from isaaclab_physx.physics import PhysxManager as SimulationManager + +from .joint_wrench_sensor_data import JointWrenchSensorData +from .kernels import joint_wrench_reset_kernel, joint_wrench_split_kernel + +if TYPE_CHECKING: + import omni.physics.tensors.api as physx + + from isaaclab.sensors.joint_wrench import JointWrenchSensorCfg + +logger = logging.getLogger(__name__) + + +class JointWrenchSensor(BaseJointWrenchSensor): + """PhysX joint reaction wrench sensor. + + The sensor reads PhysX's incoming joint wrench for every articulation link + and exposes the linear force [N] and angular torque [N·m] components. PhysX + reports each wrench in the frame of ``body1`` from the USD joint, which is + the child body's frame for the standard ``body0`` = parent, ``body1`` = + child convention. The root body's entry is included. + + :attr:`~isaaclab.sensors.SensorBaseCfg.prim_path` must point at the + articulation root prim in every environment. + """ + + cfg: JointWrenchSensorCfg + """The configuration parameters.""" + + __backend_name__: str = "physx" + """The name of the backend for the joint wrench sensor.""" + + def __init__(self, cfg: JointWrenchSensorCfg): + """Initialize the PhysX joint-wrench sensor. + + Args: + cfg: The configuration parameters. + """ + super().__init__(cfg) + + self._data = JointWrenchSensorData() + self._physics_sim_view = None + self._root_view: physx.ArticulationView | None = None + self._num_bodies: int = 0 + + def __str__(self) -> str: + """String representation of the sensor instance.""" + return ( + f"Joint wrench sensor @ '{self.cfg.prim_path}': \n" + f"\tbackend : physx\n" + f"\tupdate period (s) : {self.cfg.update_period}\n" + f"\tnumber of bodies : {self._num_bodies}\n" + f"\tbody names : {self.body_names}\n" + ) + + """ + Properties + """ + + @property + def body_names(self) -> list[str]: + """Ordered names of the bodies whose incoming joint wrench is reported.""" + return self._data._body_names + + @property + def data(self) -> JointWrenchSensorData: + """The joint-wrench sensor data.""" + self._update_outdated_buffers() + return self._data + + """ + Operations + """ + + def reset(self, env_ids: Sequence[int] | None = None, env_mask: wp.array | None = None) -> None: + """Reset the sensor buffers for the given environments. + + Args: + env_ids: The environment ids to reset. + env_mask: The mask used to reset the environments. Shape is ``(num_envs,)``. + """ + if self._data._force is None or self._data._torque is None: + return + env_mask = self._resolve_indices_and_mask(env_ids, env_mask) + super().reset(None, env_mask) + wp.launch( + joint_wrench_reset_kernel, + dim=(self._num_envs, self._num_bodies), + inputs=[env_mask, self._data._force, self._data._torque], + device=self._device, + ) + + """ + Implementation + """ + + def _initialize_impl(self) -> None: + """PHYSICS_READY callback: builds the articulation view and allocates buffers.""" + super()._initialize_impl() + + self._physics_sim_view = SimulationManager.get_physics_sim_view() + self._root_view = self._physics_sim_view.create_articulation_view(self.cfg.prim_path.replace(".*", "*")) + if self._root_view._backend is None: + raise RuntimeError(f"Failed to create articulation view at: {self.cfg.prim_path}. Check PhysX logs.") + + self._num_bodies = self._root_view.shared_metatype.link_count + if self._num_bodies == 0: + raise RuntimeError(f"Joint wrench sensor matched zero bodies at '{self.cfg.prim_path}'.") + + self._data._body_names = list(self._root_view.shared_metatype.link_names) + self._data.create_buffers(num_envs=self._num_envs, num_bodies=self._num_bodies, device=self._device) + + logger.info(f"Joint wrench sensor initialized: {self._num_envs} envs, {self._num_bodies} bodies") + + def _update_buffers_impl(self, env_mask: wp.array) -> None: + """Read PhysX incoming joint wrenches and split them into force / torque buffers. + + Args: + env_mask: A mask containing which environments need to be updated. Shape is ``(num_envs,)``. + """ + if self._root_view is None: + raise RuntimeError( + f"Joint wrench sensor '{self.cfg.prim_path}': not initialized." + " Access sensor data only after sim.reset() has been called." + ) + + incoming_joint_wrench = self._root_view.get_link_incoming_joint_force().view(wp.spatial_vectorf) + wp.launch( + joint_wrench_split_kernel, + dim=(self._num_envs, self._num_bodies), + inputs=[env_mask, incoming_joint_wrench, self._data._force, self._data._torque], + device=self._device, + ) + + def _invalidate_initialize_callback(self, event) -> None: + """Drop view, cached sizes, and buffers when physics stops. + + Args: + event: An invalidate event. + """ + super()._invalidate_initialize_callback(event) + self._physics_sim_view = None + self._root_view = None + self._num_bodies = 0 + self._data._force = None + self._data._torque = None + self._data._body_names = [] + self._data._force_ta = None + self._data._torque_ta = None diff --git a/source/isaaclab_physx/isaaclab_physx/sensors/joint_wrench/joint_wrench_sensor_data.py b/source/isaaclab_physx/isaaclab_physx/sensors/joint_wrench/joint_wrench_sensor_data.py new file mode 100644 index 000000000000..80cea4c1a5ce --- /dev/null +++ b/source/isaaclab_physx/isaaclab_physx/sensors/joint_wrench/joint_wrench_sensor_data.py @@ -0,0 +1,67 @@ +# 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 + +import warp as wp + +from isaaclab.sensors.joint_wrench import BaseJointWrenchSensorData +from isaaclab.utils.warp import ProxyArray + + +class JointWrenchSensorData(BaseJointWrenchSensorData): + """Data container for the PhysX joint-wrench sensor.""" + + def __init__(self): + self._force: wp.array | None = None + self._torque: wp.array | None = None + self._body_names: list[str] = [] + self._force_ta: ProxyArray | None = None + self._torque_ta: ProxyArray | None = None + + @property + def force(self) -> ProxyArray | None: + """Linear component of the incoming joint wrench [N]. + + Expressed in the frame selected by + :attr:`~isaaclab.sensors.JointWrenchSensorCfg.convention`. Shape is + ``(num_envs, num_bodies)``, dtype ``wp.vec3f``. In torch this resolves + to ``(num_envs, num_bodies, 3)``. ``None`` before the simulation is + initialized. + """ + if self._force is None: + return None + if self._force_ta is None: + self._force_ta = ProxyArray(self._force) + return self._force_ta + + @property + def torque(self) -> ProxyArray | None: + """Angular component of the incoming joint wrench [N·m]. + + Expressed in the frame selected by + :attr:`~isaaclab.sensors.JointWrenchSensorCfg.convention`. Shape is + ``(num_envs, num_bodies)``, dtype ``wp.vec3f``. In torch this resolves + to ``(num_envs, num_bodies, 3)``. ``None`` before the simulation is + initialized. + """ + if self._torque is None: + return None + if self._torque_ta is None: + self._torque_ta = ProxyArray(self._torque) + return self._torque_ta + + def create_buffers(self, num_envs: int, num_bodies: int, device: str) -> None: + """Allocate internal buffers. + + Args: + num_envs: Number of environments. + num_bodies: Number of bodies with incoming joint wrench reports. + device: Device for array storage. + """ + self._force = wp.zeros((num_envs, num_bodies), dtype=wp.vec3f, device=device) + self._torque = wp.zeros((num_envs, num_bodies), dtype=wp.vec3f, device=device) + self._force_ta = None + self._torque_ta = None diff --git a/source/isaaclab_physx/isaaclab_physx/sensors/joint_wrench/kernels.py b/source/isaaclab_physx/isaaclab_physx/sensors/joint_wrench/kernels.py new file mode 100644 index 000000000000..3abc7f8c67b4 --- /dev/null +++ b/source/isaaclab_physx/isaaclab_physx/sensors/joint_wrench/kernels.py @@ -0,0 +1,38 @@ +# 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 warp as wp + + +@wp.kernel +def joint_wrench_split_kernel( + env_mask: wp.array(dtype=wp.bool), + incoming_joint_wrench: wp.array(dtype=wp.spatial_vectorf, ndim=2), + out_force: wp.array(dtype=wp.vec3f, ndim=2), + out_torque: wp.array(dtype=wp.vec3f, ndim=2), +): + """Split PhysX incoming joint spatial wrenches into force and torque components.""" + env, body = wp.tid() + if not env_mask[env]: + return + + wrench = incoming_joint_wrench[env, body] + out_force[env, body] = wp.spatial_top(wrench) + out_torque[env, body] = wp.spatial_bottom(wrench) + + +@wp.kernel +def joint_wrench_reset_kernel( + env_mask: wp.array(dtype=wp.bool), + out_force: wp.array(dtype=wp.vec3f, ndim=2), + out_torque: wp.array(dtype=wp.vec3f, ndim=2), +): + """Zero force and torque entries for the environments selected by ``env_mask``.""" + env, body = wp.tid() + if not env_mask[env]: + return + + out_force[env, body] = wp.vec3f(0.0, 0.0, 0.0) + out_torque[env, body] = wp.vec3f(0.0, 0.0, 0.0) diff --git a/source/isaaclab_physx/test/assets/test_articulation.py b/source/isaaclab_physx/test/assets/test_articulation.py index 6f57999db307..3687dae5961d 100644 --- a/source/isaaclab_physx/test/assets/test_articulation.py +++ b/source/isaaclab_physx/test/assets/test_articulation.py @@ -1830,108 +1830,6 @@ 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("num_articulations", [1, 2]) -@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) -@pytest.mark.isaacsim_ci -def test_body_incoming_joint_wrench_b_single_joint(sim, num_articulations, device): - """Test the data.body_incoming_joint_wrench_b buffer is populated correctly and statically correct for single joint. - - This test verifies that: - 1. The body incoming joint wrench buffer has correct shape - 2. The wrench values are statically correct for a single joint - 3. The wrench values match expected values from gravity and external forces - - 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, _ = generate_articulation( - articulation_cfg=articulation_cfg, num_articulations=num_articulations, device=device - ) - - # Play the simulator - sim.reset() - - # Resolve body indices by name (ordering may differ across physics backends) - arm_idx = articulation.body_names.index("Arm") - root_idx = articulation.body_names.index("CenterPivot") - # apply external force - external_force_vector_b = torch.zeros((num_articulations, articulation.num_bodies, 3), device=device) - external_force_vector_b[:, arm_idx, 1] = 10.0 # 10 N in Y direction - external_torque_vector_b = torch.zeros((num_articulations, articulation.num_bodies, 3), device=device) - external_torque_vector_b[:, arm_idx, 2] = 10.0 # 10 Nm in z direction - - # apply action to the articulation - joint_pos = torch.ones_like(articulation.data.joint_pos.torch) * 1.5708 / 2.0 - articulation.write_joint_position_to_sim_index( - position=torch.ones_like(articulation.data.joint_pos.torch), - ) - articulation.write_joint_velocity_to_sim_index( - velocity=torch.zeros_like(articulation.data.joint_vel.torch), - ) - articulation.set_joint_position_target_index(target=joint_pos) - articulation.write_data_to_sim() - for _ in range(50): - articulation.permanent_wrench_composer.set_forces_and_torques_index( - forces=external_force_vector_b, torques=external_torque_vector_b - ) - articulation.write_data_to_sim() - # perform step - sim.step() - # update buffers - articulation.update(sim.cfg.dt) - - # check shape - assert articulation.data.body_incoming_joint_wrench_b.torch.shape == ( - num_articulations, - articulation.num_bodies, - 6, - ) - - # calculate expected static - mass = articulation.data.body_mass.torch.to("cpu") - pos_w = articulation.data.body_pos_w.torch - quat_w = articulation.data.body_quat_w.torch - - mass_link2 = mass[:, arm_idx].view(num_articulations, -1) - gravity = torch.tensor(sim.cfg.gravity, device="cpu").repeat(num_articulations, 1).view((num_articulations, 3)) - - # NOTE: the com and link pose for single joint are colocated - weight_vector_w = mass_link2 * gravity - # expected wrench from link mass and external wrench - # PhysX reports the incoming joint wrench as the force FROM body0 ONTO body1 (body1's frame). - # The USD asset defines body0=CenterPivot, body1=Arm, so the wrench is the constraint/support - # force from CenterPivot onto Arm, expressed in Arm's frame. - # In static equilibrium this equals -(gravity + external forces on Arm). - total_force_w = weight_vector_w.to(device) + math_utils.quat_apply( - quat_w[:, arm_idx, :], external_force_vector_b[:, arm_idx, :] - ) - total_torque_w = torch.cross( - pos_w[:, arm_idx, :].to(device) - pos_w[:, root_idx, :].to(device), - total_force_w, - dim=-1, - ) + math_utils.quat_apply(quat_w[:, arm_idx, :], external_torque_vector_b[:, arm_idx, :]) - expected_wrench = torch.zeros((num_articulations, 6), device=device) - expected_wrench[:, :3] = math_utils.quat_apply( - math_utils.quat_conjugate(quat_w[:, arm_idx, :]), - -total_force_w, - ) - expected_wrench[:, 3:] = math_utils.quat_apply( - math_utils.quat_conjugate(quat_w[:, arm_idx, :]), - -total_torque_w, - ) - - # check value of last joint wrench - torch.testing.assert_close( - expected_wrench, - articulation.data.body_incoming_joint_wrench_b.torch[:, arm_idx, :].squeeze(1), - atol=1e-2, - rtol=1e-3, - ) - - @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.isaacsim_ci def test_setting_articulation_root_prim_path(sim, device): diff --git a/source/isaaclab_physx/test/sensors/test_joint_wrench_sensor.py b/source/isaaclab_physx/test/sensors/test_joint_wrench_sensor.py new file mode 100644 index 000000000000..abdfcc754b1b --- /dev/null +++ b/source/isaaclab_physx/test/sensors/test_joint_wrench_sensor.py @@ -0,0 +1,331 @@ +# 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.""" + +from isaaclab.app import AppLauncher + +# launch omniverse app +simulation_app = AppLauncher(headless=True).app + +"""Rest everything follows.""" + +import pytest +import torch +import warp as wp +from isaaclab_physx.physics import PhysxCfg + +import isaaclab.sim as sim_utils +from isaaclab.actuators import ImplicitActuatorCfg +from isaaclab.assets import Articulation, ArticulationCfg +from isaaclab.scene import InteractiveScene, InteractiveSceneCfg +from isaaclab.sensors import JointWrenchSensor, JointWrenchSensorCfg +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 + + +def _make_single_joint_articulation_cfg() -> ArticulationCfg: + """Single-joint revolute test articulation (root ``CenterPivot`` + arm ``Arm``).""" + return ArticulationCfg( + prim_path="{ENV_REGEX_NS}/Robot", + spawn=sim_utils.UsdFileCfg( + usd_path=f"{ISAAC_NUCLEUS_DIR}/Robots/IsaacSim/SimpleArticulation/revolute_articulation.usd", + joint_drive_props=sim_utils.JointDrivePropertiesCfg(max_effort=80.0, max_velocity=5.0), + ), + actuators={ + "joint": ImplicitActuatorCfg( + joint_names_expr=[".*"], + stiffness=2000.0, + damping=100.0, + ), + }, + init_state=ArticulationCfg.InitialStateCfg(pos=(0.0, 0.0, 1.0)), + ) + + +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"], effort_limit_sim=400.0, stiffness=0.0, damping=10.0 + ), + "pole_actuator": ImplicitActuatorCfg( + joint_names_expr=["cart_to_pole"], effort_limit_sim=400.0, stiffness=0.0, damping=pole_damping + ), + }, + ) + + +@configclass +class _SingleJointSceneCfg(InteractiveSceneCfg): + """Scene with a single-joint articulation and the joint-wrench sensor.""" + + env_spacing = 2.0 + terrain = TerrainImporterCfg(prim_path="/World/ground", terrain_type="plane") + robot = _make_single_joint_articulation_cfg() + 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") + + +@pytest.fixture +def sim(): + """Simulation context using the PhysX backend.""" + sim_cfg = SimulationCfg( + dt=1.0 / 120.0, + physics=PhysxCfg(), + ) + with sim_utils.build_simulation_context(sim_cfg=sim_cfg) as sim_ctx: + sim_ctx._app_control_on_stop_handle = None + yield sim_ctx + + +def _physx_incoming_joint_wrench(sensor: JointWrenchSensor) -> torch.Tensor: + """Read the raw PhysX incoming joint wrench tensor. + + PhysX reports spatial vectors as force followed by torque. Shape is + ``(num_envs, num_bodies, 6)``. + """ + raw_wrench = sensor._root_view.get_link_incoming_joint_force().view(wp.spatial_vectorf) + return wp.to_torch(raw_wrench) + + +def _assert_sensor_matches_physx_tensor(sensor: JointWrenchSensor) -> None: + """Compare the sensor buffers to the raw PhysX tensor API.""" + raw_wrench = _physx_incoming_joint_wrench(sensor) + sensor_data = sensor.data + + torch.testing.assert_close(sensor_data.force.torch, raw_wrench[..., :3]) + torch.testing.assert_close(sensor_data.torque.torch, raw_wrench[..., 3:]) + + +# --------------------------------------------------------------------------- +# Sensor data — pre-init contract +# --------------------------------------------------------------------------- + + +def test_data_before_init_is_none(): + """``force``/``torque`` return ``None`` before :meth:`create_buffers` runs.""" + from isaaclab_physx.sensors.joint_wrench import JointWrenchSensorData + + data = JointWrenchSensorData() + assert data.force is None + assert data.torque is None + + +# --------------------------------------------------------------------------- +# Initialization and shapes +# --------------------------------------------------------------------------- + + +def test_initialization_and_shapes(sim): + """Sensor initializes on sim reset and exposes correctly-shaped buffers.""" + scene = InteractiveScene(_SingleJointSceneCfg(num_envs=2)) + sim.reset() + + robot: Articulation = scene["robot"] + sensor: JointWrenchSensor = scene["wrench"] + sim.step() + scene.update(sim.get_physics_dt()) + + # PhysX reports one incoming joint wrench per articulation link, including the root link. + 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 sensor.find_bodies("Arm") == ([robot.body_names.index("Arm")], ["Arm"]) + _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) + + +# --------------------------------------------------------------------------- +# 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.""" + scene = InteractiveScene(_SingleJointSceneCfg(num_envs=4)) + sim.reset() + + sensor: JointWrenchSensor = scene["wrench"] + for _ in range(100): + sim.step() + scene.update(sim.get_physics_dt()) + + force_before = sensor.data.force.torch.clone() + assert torch.any(force_before != 0), "Expected non-zero data before reset" + + sensor.reset(env_ids=[0, 2]) + + force_after = wp.to_torch(sensor._data._force) + torch.testing.assert_close(force_after[0], torch.zeros_like(force_after[0])) + torch.testing.assert_close(force_after[2], torch.zeros_like(force_after[2])) + torch.testing.assert_close(force_after[1], force_before[1]) + torch.testing.assert_close(force_after[3], force_before[3]) diff --git a/source/isaaclab_tasks/config/extension.toml b/source/isaaclab_tasks/config/extension.toml index 2e7ee00764d7..c6e5ea6bc181 100644 --- a/source/isaaclab_tasks/config/extension.toml +++ b/source/isaaclab_tasks/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "1.5.30" +version = "1.5.32" # Description title = "Isaac Lab Environments" diff --git a/source/isaaclab_tasks/docs/CHANGELOG.rst b/source/isaaclab_tasks/docs/CHANGELOG.rst index 2bb26e429744..54f41b239963 100644 --- a/source/isaaclab_tasks/docs/CHANGELOG.rst +++ b/source/isaaclab_tasks/docs/CHANGELOG.rst @@ -1,6 +1,20 @@ Changelog --------- +1.5.32 (2026-04-30) +~~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Updated classic Ant/Humanoid manager-based environments and direct in-hand + manipulation environments to read body incoming wrenches from + :class:`~isaaclab.sensors.JointWrenchSensor` instead of + ``ArticulationData.body_incoming_joint_wrench_b``. Add a + :class:`~isaaclab.sensors.JointWrenchSensorCfg` to the scene and pass its + :class:`~isaaclab.managers.SceneEntityCfg` as ``sensor_cfg``. + + 1.5.31 (2026-04-29) ~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/inhand_manipulation/inhand_manipulation_env.py b/source/isaaclab_tasks/isaaclab_tasks/direct/inhand_manipulation/inhand_manipulation_env.py index e997d7743379..5969d8c9c4be 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/inhand_manipulation/inhand_manipulation_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/inhand_manipulation/inhand_manipulation_env.py @@ -16,6 +16,7 @@ from isaaclab.assets import Articulation, RigidObject from isaaclab.envs import DirectRLEnv from isaaclab.markers import VisualizationMarkers +from isaaclab.sensors import JointWrenchSensor, JointWrenchSensorCfg from isaaclab.sim.spawners.from_files import GroundPlaneCfg, spawn_ground_plane from isaaclab.utils.math import quat_conjugate, quat_from_angle_axis, quat_mul, sample_uniform, saturate @@ -50,6 +51,12 @@ def __init__(self, cfg: AllegroHandEnvCfg | ShadowHandEnvCfg, render_mode: str | self.finger_bodies.sort() self.num_fingertips = len(self.finger_bodies) + self.finger_wrench_bodies = [] + if getattr(self, "_joint_wrench_sensor", None) is not None: + for body_name in self.cfg.fingertip_body_names: + self.finger_wrench_bodies.append(self._joint_wrench_sensor.body_names.index(body_name)) + self.finger_wrench_bodies.sort() + # joint limits joint_pos_limits = self.hand.data.joint_limits.torch.to(self.device) self.hand_dof_lower_limits = joint_pos_limits[..., 0] @@ -97,6 +104,9 @@ def _setup_scene(self): # add hand, in-hand object, and goal object self.hand = Articulation(self.cfg.robot_cfg) self.object: Articulation | RigidObject = self.cfg.object_cfg.class_type(self.cfg.object_cfg) + self._joint_wrench_sensor = None + if self.cfg.asymmetric_obs: + self._joint_wrench_sensor = self._create_joint_wrench_sensor() # add ground plane spawn_ground_plane(prim_path="/World/ground", cfg=GroundPlaneCfg()) # clone and replicate (no need to filter for this environment) @@ -104,10 +114,16 @@ def _setup_scene(self): # add articulation to scene - we must register to scene to randomize with EventManager self.scene.articulations["robot"] = self.hand self.scene.rigid_objects["object"] = self.object + if self._joint_wrench_sensor is not None: + self.scene.sensors["joint_wrench"] = self._joint_wrench_sensor # add lights light_cfg = sim_utils.DomeLightCfg(intensity=2000.0, color=(0.75, 0.75, 0.75)) light_cfg.func("/World/Light", light_cfg) + def _create_joint_wrench_sensor(self) -> JointWrenchSensor: + """Create the joint-wrench sensor used for fingertip force/torque observations.""" + return JointWrenchSensor(JointWrenchSensorCfg(prim_path=self.cfg.robot_cfg.prim_path)) + def _pre_physics_step(self, actions: torch.Tensor) -> None: self.actions = actions.clone() @@ -135,13 +151,7 @@ def _apply_action(self) -> None: def _get_observations(self) -> dict: if self.cfg.asymmetric_obs: - # Newton does not implement body_incoming_joint_wrench_b; fall back to zeros. - try: - self.fingertip_force_sensors = self.hand.data.body_incoming_joint_wrench_b.torch[:, self.finger_bodies] - except NotImplementedError: - self.fingertip_force_sensors = torch.zeros( - self.num_envs, len(self.finger_bodies), 6, dtype=torch.float32, device=self.device - ) + self._update_fingertip_force_sensors() if self.cfg.obs_type == "openai": obs = self.compute_reduced_observations() @@ -158,6 +168,27 @@ def _get_observations(self) -> dict: observations = {"policy": obs, "critic": states} return observations + def _update_fingertip_force_sensors(self) -> None: + """Update fingertip force/torque observations from the joint-wrench sensor.""" + if getattr(self, "_joint_wrench_sensor", None) is None: + self.fingertip_force_sensors = torch.zeros( + self.num_envs, len(self.finger_bodies), 6, dtype=torch.float32, device=self.device + ) + return + + sensor_data = self._joint_wrench_sensor.data + force_data = sensor_data.force + torque_data = sensor_data.torque + if force_data is None or torque_data is None: + self.fingertip_force_sensors = torch.zeros( + self.num_envs, len(self.finger_bodies), 6, dtype=torch.float32, device=self.device + ) + return + + force = force_data.torch[:, self.finger_wrench_bodies] + torque = torque_data.torch[:, self.finger_wrench_bodies] + self.fingertip_force_sensors = torch.cat((force, torque), dim=-1) + def _get_rewards(self) -> torch.Tensor: ( total_reward, diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/shadow_hand/shadow_hand_vision_env.py b/source/isaaclab_tasks/isaaclab_tasks/direct/shadow_hand/shadow_hand_vision_env.py index 6783823b56f7..7da4db48e853 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/shadow_hand/shadow_hand_vision_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/shadow_hand/shadow_hand_vision_env.py @@ -49,12 +49,14 @@ def _setup_scene(self): # add hand, in-hand object, and goal object self.hand = Articulation(self.cfg.robot_cfg) self.object: Articulation | RigidObject = self.cfg.object_cfg.class_type(self.cfg.object_cfg) + self._joint_wrench_sensor = self._create_joint_wrench_sensor() self._tiled_camera = Camera(self.cfg.tiled_camera) # clone and replicate (no need to filter for this environment) self.scene.clone_environments(copy_from_source=False) # add articulation to scene - we must register to scene to randomize with EventManager self.scene.articulations["robot"] = self.hand self.scene.rigid_objects["object"] = self.object + self.scene.sensors["joint_wrench"] = self._joint_wrench_sensor self.scene.sensors["tiled_camera"] = self._tiled_camera # add lights light_cfg = sim_utils.DomeLightCfg(intensity=2000.0, color=(0.75, 0.75, 0.75)) @@ -127,13 +129,7 @@ def _get_observations(self) -> dict: # vision observations from CMM image_obs = self._compute_image_observations() obs = torch.cat((state_obs, image_obs), dim=-1) - # asymmetric critic states — Newton does not implement body_incoming_joint_wrench_b - try: - self.fingertip_force_sensors = self.hand.data.body_incoming_joint_wrench_b.torch[:, self.finger_bodies] - except NotImplementedError: - self.fingertip_force_sensors = torch.zeros( - self.num_envs, len(self.finger_bodies), 6, dtype=torch.float32, device=self.device - ) + self._update_fingertip_force_sensors() state = self._compute_states() observations = {"policy": obs, "critic": state} diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/ant/ant_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/ant/ant_env_cfg.py index e2f9cca14377..706d115cebc8 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/ant/ant_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/ant/ant_env_cfg.py @@ -16,6 +16,7 @@ from isaaclab.managers import SceneEntityCfg from isaaclab.managers import TerminationTermCfg as DoneTerm from isaaclab.scene import InteractiveSceneCfg +from isaaclab.sensors import JointWrenchSensorCfg from isaaclab.terrains import TerrainImporterCfg from isaaclab.utils import configclass @@ -90,6 +91,9 @@ class MySceneCfg(InteractiveSceneCfg): # robot robot = ANT_CFG.replace(prim_path="{ENV_REGEX_NS}/Robot") + # sensors + joint_wrench = JointWrenchSensorCfg(prim_path="{ENV_REGEX_NS}/Robot") + # lights light = AssetBaseCfg( prim_path="/World/light", @@ -130,8 +134,9 @@ class PolicyCfg(ObsGroup): func=mdp.body_incoming_wrench, scale=0.1, params={ - "asset_cfg": SceneEntityCfg( - "robot", body_names=["front_left_foot", "front_right_foot", "left_back_foot", "right_back_foot"] + "sensor_cfg": SceneEntityCfg( + "joint_wrench", + body_names=["front_left_foot", "front_right_foot", "left_back_foot", "right_back_foot"], ) }, ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/humanoid/humanoid_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/humanoid/humanoid_env_cfg.py index c610159a5c51..fe68a3058336 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/humanoid/humanoid_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/humanoid/humanoid_env_cfg.py @@ -16,6 +16,7 @@ from isaaclab.managers import SceneEntityCfg from isaaclab.managers import TerminationTermCfg as DoneTerm from isaaclab.scene import InteractiveSceneCfg +from isaaclab.sensors import JointWrenchSensorCfg from isaaclab.terrains import TerrainImporterCfg from isaaclab.utils import configclass @@ -64,6 +65,9 @@ class MySceneCfg(InteractiveSceneCfg): # robot robot = HUMANOID_CFG.replace(prim_path="{ENV_REGEX_NS}/Robot") + # sensors + joint_wrench = JointWrenchSensorCfg(prim_path="{ENV_REGEX_NS}/Robot") + # lights light = AssetBaseCfg( prim_path="/World/light", @@ -117,7 +121,7 @@ class PolicyCfg(ObsGroup): feet_body_forces = ObsTerm( func=mdp.body_incoming_wrench, scale=0.01, - params={"asset_cfg": SceneEntityCfg("robot", body_names=["left_foot", "right_foot"])}, + params={"sensor_cfg": SceneEntityCfg("joint_wrench", body_names=["left_foot", "right_foot"])}, ) actions = ObsTerm(func=mdp.last_action) From 7d83171570c5ca2f1470853a052af7bf9f420086 Mon Sep 17 00:00:00 2001 From: Antoine Richard Date: Thu, 30 Apr 2026 16:46:19 +0200 Subject: [PATCH 03/40] Resolve PhysX joint wrench articulation roots Resolve nested USD articulation roots before creating the PhysX articulation view so classic Ant and Humanoid environments can use the joint wrench sensor from the asset prim path. --- .../joint_wrench/joint_wrench_sensor.py | 42 +++++++++++++++++-- .../test/sensors/test_joint_wrench_sensor.py | 28 +++++++++++++ 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/source/isaaclab_physx/isaaclab_physx/sensors/joint_wrench/joint_wrench_sensor.py b/source/isaaclab_physx/isaaclab_physx/sensors/joint_wrench/joint_wrench_sensor.py index e2124f244834..155437758bbe 100644 --- a/source/isaaclab_physx/isaaclab_physx/sensors/joint_wrench/joint_wrench_sensor.py +++ b/source/isaaclab_physx/isaaclab_physx/sensors/joint_wrench/joint_wrench_sensor.py @@ -13,7 +13,10 @@ import warp as wp +from pxr import UsdPhysics + from isaaclab.sensors.joint_wrench import BaseJointWrenchSensor +from isaaclab.sim.utils.queries import find_first_matching_prim, get_all_matching_child_prims from isaaclab_physx.physics import PhysxManager as SimulationManager @@ -37,8 +40,9 @@ class JointWrenchSensor(BaseJointWrenchSensor): the child body's frame for the standard ``body0`` = parent, ``body1`` = child convention. The root body's entry is included. - :attr:`~isaaclab.sensors.SensorBaseCfg.prim_path` must point at the - articulation root prim in every environment. + :attr:`~isaaclab.sensors.SensorBaseCfg.prim_path` must point at either + the articulation root prim or a parent prim containing a single + articulation root in every environment. """ cfg: JointWrenchSensorCfg @@ -116,9 +120,10 @@ def _initialize_impl(self) -> None: super()._initialize_impl() self._physics_sim_view = SimulationManager.get_physics_sim_view() - self._root_view = self._physics_sim_view.create_articulation_view(self.cfg.prim_path.replace(".*", "*")) + root_prim_path_expr = self._resolve_articulation_root_prim_path() + self._root_view = self._physics_sim_view.create_articulation_view(root_prim_path_expr.replace(".*", "*")) if self._root_view._backend is None: - raise RuntimeError(f"Failed to create articulation view at: {self.cfg.prim_path}. Check PhysX logs.") + raise RuntimeError(f"Failed to create articulation view at: {root_prim_path_expr}. Check PhysX logs.") self._num_bodies = self._root_view.shared_metatype.link_count if self._num_bodies == 0: @@ -129,6 +134,35 @@ def _initialize_impl(self) -> None: logger.info(f"Joint wrench sensor initialized: {self._num_envs} envs, {self._num_bodies} bodies") + def _resolve_articulation_root_prim_path(self) -> str: + """Resolve the articulation root prim path expression from the configured asset prim path.""" + first_env_matching_prim = find_first_matching_prim(self.cfg.prim_path) + if first_env_matching_prim is None: + raise RuntimeError(f"Failed to find prim for expression: '{self.cfg.prim_path}'.") + first_env_matching_prim_path = first_env_matching_prim.GetPath().pathString + + first_env_root_prims = get_all_matching_child_prims( + first_env_matching_prim_path, + predicate=lambda prim: prim.HasAPI(UsdPhysics.ArticulationRootAPI) + and prim.GetAttribute("physxArticulation:articulationEnabled").Get() is not False, + traverse_instance_prims=False, + ) + if len(first_env_root_prims) == 0: + raise RuntimeError( + f"Failed to find an articulation when resolving '{first_env_matching_prim_path}'." + " Please ensure that the prim has 'USD ArticulationRootAPI' applied." + ) + if len(first_env_root_prims) > 1: + raise RuntimeError( + f"Failed to find a single articulation when resolving '{first_env_matching_prim_path}'." + f" Found multiple '{first_env_root_prims}' under '{first_env_matching_prim_path}'." + " Please ensure that there is only one articulation in the prim path tree." + ) + + first_env_root_prim_path = first_env_root_prims[0].GetPath().pathString + root_prim_path_relative_to_prim_path = first_env_root_prim_path[len(first_env_matching_prim_path) :] + return self.cfg.prim_path + root_prim_path_relative_to_prim_path + def _update_buffers_impl(self, env_mask: wp.array) -> None: """Read PhysX incoming joint wrenches and split them into force / torque buffers. 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 abdfcc754b1b..b3f1890e4db4 100644 --- a/source/isaaclab_physx/test/sensors/test_joint_wrench_sensor.py +++ b/source/isaaclab_physx/test/sensors/test_joint_wrench_sensor.py @@ -27,6 +27,8 @@ from isaaclab.utils import configclass from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR, ISAACLAB_NUCLEUS_DIR +from isaaclab_assets.robots.ant import ANT_CFG + def _make_single_joint_articulation_cfg() -> ArticulationCfg: """Single-joint revolute test articulation (root ``CenterPivot`` + arm ``Arm``).""" @@ -103,6 +105,16 @@ class _CartpoleDampedSceneCfg(InteractiveSceneCfg): 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.""" + + env_spacing = 4.0 + terrain = TerrainImporterCfg(prim_path="/World/ground", terrain_type="plane") + robot = ANT_CFG.replace(prim_path="{ENV_REGEX_NS}/Robot") + wrench = JointWrenchSensorCfg(prim_path="{ENV_REGEX_NS}/Robot") + + @pytest.fixture def sim(): """Simulation context using the PhysX backend.""" @@ -192,6 +204,22 @@ def test_multi_body_articulation(sim): _assert_sensor_matches_physx_tensor(sensor) +def test_nested_articulation_root_resolution(sim): + """Sensor accepts an asset prim path whose articulation root is nested in the USD asset.""" + scene = InteractiveScene(_NestedRootAntSceneCfg(num_envs=1)) + sim.reset() + + robot: Articulation = scene["robot"] + sensor: JointWrenchSensor = scene["wrench"] + sim.step() + scene.update(sim.get_physics_dt()) + + 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_physx_tensor(sensor) + + # --------------------------------------------------------------------------- # Physical correctness # --------------------------------------------------------------------------- From a7f68eefc5c2fef0a6785bff5eda6b3bd4d51ea1 Mon Sep 17 00:00:00 2001 From: Antoine Richard Date: Thu, 30 Apr 2026 16:57:11 +0200 Subject: [PATCH 04/40] Enable classic Newton wrench observations Resolve nested articulation roots for the Newton joint wrench sensor and let classic Ant and Humanoid Newton presets use the same wrench observation terms as PhysX. --- source/isaaclab_newton/docs/CHANGELOG.rst | 6 +++ .../joint_wrench/joint_wrench_sensor.py | 47 +++++++++++++++---- .../test/sensors/test_joint_wrench_sensor.py | 28 +++++++++++ source/isaaclab_physx/docs/CHANGELOG.rst | 2 + source/isaaclab_tasks/docs/CHANGELOG.rst | 3 +- .../manager_based/classic/ant/ant_env_cfg.py | 29 +----------- .../classic/humanoid/humanoid_env_cfg.py | 29 +----------- 7 files changed, 79 insertions(+), 65 deletions(-) diff --git a/source/isaaclab_newton/docs/CHANGELOG.rst b/source/isaaclab_newton/docs/CHANGELOG.rst index 2325473353f1..26f55c065970 100644 --- a/source/isaaclab_newton/docs/CHANGELOG.rst +++ b/source/isaaclab_newton/docs/CHANGELOG.rst @@ -12,6 +12,12 @@ Removed and read :attr:`~isaaclab.sensors.JointWrenchSensorData.force` and :attr:`~isaaclab.sensors.JointWrenchSensorData.torque` instead. +Fixed +^^^^^ + +* Fixed :class:`~isaaclab_newton.sensors.JointWrenchSensor` initialization for + USD assets whose articulation root is nested below the configured asset prim. + 0.5.26 (2026-04-30) ~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_newton/isaaclab_newton/sensors/joint_wrench/joint_wrench_sensor.py b/source/isaaclab_newton/isaaclab_newton/sensors/joint_wrench/joint_wrench_sensor.py index 4f59487e882d..bd53751d6a5f 100644 --- a/source/isaaclab_newton/isaaclab_newton/sensors/joint_wrench/joint_wrench_sensor.py +++ b/source/isaaclab_newton/isaaclab_newton/sensors/joint_wrench/joint_wrench_sensor.py @@ -13,7 +13,10 @@ from newton import JointType from newton.selection import ArticulationView +from pxr import UsdPhysics + from isaaclab.sensors.joint_wrench import BaseJointWrenchSensor +from isaaclab.sim.utils.queries import find_first_matching_prim, get_all_matching_child_prims from isaaclab_newton.physics import NewtonManager @@ -34,12 +37,10 @@ class JointWrenchSensor(BaseJointWrenchSensor): (child-side joint frame, child-side joint anchor as reference point) before storing it in per-joint force / torque buffers. - :attr:`~isaaclab.sensors.SensorBaseCfg.prim_path` must point at the - articulation root prim (the one carrying ``ArticulationRootAPI``) in - every environment; the sensor uses it as the - :class:`~newton.selection.ArticulationView` pattern directly. ``FREE`` - and ``FIXED`` joints are excluded — neither has a meaningful joint - anchor. + :attr:`~isaaclab.sensors.SensorBaseCfg.prim_path` must point at either + the articulation root prim or a parent prim containing a single + articulation root in every environment. ``FREE`` and ``FIXED`` joints are + excluded — neither has a meaningful joint anchor. """ cfg: JointWrenchSensorCfg @@ -126,9 +127,10 @@ def _initialize_impl(self) -> None: model = NewtonManager.get_model() state_0 = NewtonManager.get_state_0() + root_prim_path_expr = self._resolve_articulation_root_prim_path() self._root_view = ArticulationView( model, - self.cfg.prim_path.replace(".*", "*"), + root_prim_path_expr.replace(".*", "*"), verbose=False, exclude_joint_types=[JointType.FREE, JointType.FIXED], ) @@ -136,7 +138,7 @@ def _initialize_impl(self) -> None: if self._num_joints == 0: raise RuntimeError( "Joint wrench sensor matched zero reportable joints (all joints are FREE or FIXED)." - f" Check the articulation at '{self.cfg.prim_path}'." + f" Check the articulation at '{root_prim_path_expr}'." ) try: @@ -168,6 +170,35 @@ def _initialize_impl(self) -> None: logger.info(f"Joint wrench sensor initialized: {self._num_envs} envs, {self._num_joints} joints") + def _resolve_articulation_root_prim_path(self) -> str: + """Resolve the articulation root prim path expression from the configured asset prim path.""" + first_env_matching_prim = find_first_matching_prim(self.cfg.prim_path) + if first_env_matching_prim is None: + raise RuntimeError(f"Failed to find prim for expression: '{self.cfg.prim_path}'.") + first_env_matching_prim_path = first_env_matching_prim.GetPath().pathString + + first_env_root_prims = get_all_matching_child_prims( + first_env_matching_prim_path, + predicate=lambda prim: prim.HasAPI(UsdPhysics.ArticulationRootAPI) + and prim.GetAttribute("physxArticulation:articulationEnabled").Get() is not False, + traverse_instance_prims=False, + ) + if len(first_env_root_prims) == 0: + raise RuntimeError( + f"Failed to find an articulation when resolving '{first_env_matching_prim_path}'." + " Please ensure that the prim has 'USD ArticulationRootAPI' applied." + ) + if len(first_env_root_prims) > 1: + raise RuntimeError( + f"Failed to find a single articulation when resolving '{first_env_matching_prim_path}'." + f" Found multiple '{first_env_root_prims}' under '{first_env_matching_prim_path}'." + " Please ensure that there is only one articulation in the prim path tree." + ) + + first_env_root_prim_path = first_env_root_prims[0].GetPath().pathString + root_prim_path_relative_to_prim_path = first_env_root_prim_path[len(first_env_matching_prim_path) :] + return self.cfg.prim_path + root_prim_path_relative_to_prim_path + def _update_buffers_impl(self, env_mask: wp.array) -> None: """Convert Newton's body_parent_f into INCOMING_JOINT_FRAME force and torque buffers. diff --git a/source/isaaclab_newton/test/sensors/test_joint_wrench_sensor.py b/source/isaaclab_newton/test/sensors/test_joint_wrench_sensor.py index dba86b2c0c8c..34e1db15b184 100644 --- a/source/isaaclab_newton/test/sensors/test_joint_wrench_sensor.py +++ b/source/isaaclab_newton/test/sensors/test_joint_wrench_sensor.py @@ -26,6 +26,8 @@ from isaaclab.utils import math as math_utils from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR, ISAACLAB_NUCLEUS_DIR +from isaaclab_assets.robots.ant import ANT_CFG + def _make_single_joint_articulation_cfg() -> ArticulationCfg: """Single-joint revolute test articulation (root ``CenterPivot`` + arm ``Arm``).""" @@ -102,6 +104,16 @@ class _CartpoleDampedSceneCfg(InteractiveSceneCfg): 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.""" + + env_spacing = 4.0 + terrain = TerrainImporterCfg(prim_path="/World/ground", terrain_type="plane") + robot = ANT_CFG.replace(prim_path="{ENV_REGEX_NS}/Robot") + wrench = JointWrenchSensorCfg(prim_path="{ENV_REGEX_NS}/Robot") + + @pytest.fixture def sim(): """Simulation context using the Newton backend.""" @@ -170,6 +182,22 @@ def test_multi_body_articulation(sim): assert "rail" not in [n.lower() for n in sensor.body_names] +def test_nested_articulation_root_resolution(sim): + """Sensor accepts an asset prim path whose articulation root is nested in the USD asset.""" + scene = InteractiveScene(_NestedRootAntSceneCfg(num_envs=1)) + sim.reset() + + robot: Articulation = scene["robot"] + sensor: JointWrenchSensor = scene["wrench"] + sim.step() + scene.update(sim.get_physics_dt()) + + assert len(sensor.body_names) == robot.num_joints + assert set(sensor.body_names).issubset(set(robot.body_names)) + assert sensor.data.force.torch.shape == (1, robot.num_joints, 3) + assert sensor.data.torque.torch.shape == (1, robot.num_joints, 3) + + # --------------------------------------------------------------------------- # Physical correctness # --------------------------------------------------------------------------- diff --git a/source/isaaclab_physx/docs/CHANGELOG.rst b/source/isaaclab_physx/docs/CHANGELOG.rst index d403a94f541f..e0154fd393cf 100644 --- a/source/isaaclab_physx/docs/CHANGELOG.rst +++ b/source/isaaclab_physx/docs/CHANGELOG.rst @@ -9,6 +9,8 @@ Added * Added :class:`~isaaclab_physx.sensors.JointWrenchSensor` for reading PhysX incoming joint reaction wrenches as split force [N] and torque [N·m] buffers. + The sensor accepts asset prim paths whose articulation root is nested below + the configured prim. Removed ^^^^^^^ diff --git a/source/isaaclab_tasks/docs/CHANGELOG.rst b/source/isaaclab_tasks/docs/CHANGELOG.rst index 54f41b239963..aaa1f8a9bec7 100644 --- a/source/isaaclab_tasks/docs/CHANGELOG.rst +++ b/source/isaaclab_tasks/docs/CHANGELOG.rst @@ -12,7 +12,8 @@ Changed :class:`~isaaclab.sensors.JointWrenchSensor` instead of ``ArticulationData.body_incoming_joint_wrench_b``. Add a :class:`~isaaclab.sensors.JointWrenchSensorCfg` to the scene and pass its - :class:`~isaaclab.managers.SceneEntityCfg` as ``sensor_cfg``. + :class:`~isaaclab.managers.SceneEntityCfg` as ``sensor_cfg``. The classic + Ant/Humanoid Newton presets now use the same wrench observations as PhysX. 1.5.31 (2026-04-29) diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/ant/ant_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/ant/ant_env_cfg.py index 706d115cebc8..2e8f356ef975 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/ant/ant_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/ant/ant_env_cfg.py @@ -150,38 +150,11 @@ def __post_init__(self): policy: PolicyCfg = PolicyCfg() -@configclass -class _AntNewtonObservationsCfg: - """Newton-compatible observations: excludes feet_body_forces (not implemented in Newton).""" - - @configclass - class PolicyCfg(ObsGroup): - """Observations for the policy.""" - - base_height = ObsTerm(func=mdp.base_pos_z) - base_lin_vel = ObsTerm(func=mdp.base_lin_vel) - base_ang_vel = ObsTerm(func=mdp.base_ang_vel) - base_yaw_roll = ObsTerm(func=mdp.base_yaw_roll) - base_angle_to_target = ObsTerm(func=mdp.base_angle_to_target, params={"target_pos": (1000.0, 0.0, 0.0)}) - base_up_proj = ObsTerm(func=mdp.base_up_proj) - base_heading_proj = ObsTerm(func=mdp.base_heading_proj, params={"target_pos": (1000.0, 0.0, 0.0)}) - joint_pos_norm = ObsTerm(func=mdp.joint_pos_limit_normalized) - joint_vel_rel = ObsTerm(func=mdp.joint_vel_rel, scale=0.2) - actions = ObsTerm(func=mdp.last_action) - - def __post_init__(self): - self.enable_corruption = False - self.concatenate_terms = True - - # observation groups - policy: PolicyCfg = PolicyCfg() - - @configclass class AntObservationsCfg(PresetCfg): default: ObservationsCfg = ObservationsCfg() physx: ObservationsCfg = ObservationsCfg() - newton: _AntNewtonObservationsCfg = _AntNewtonObservationsCfg() + newton: ObservationsCfg = ObservationsCfg() @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/humanoid/humanoid_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/humanoid/humanoid_env_cfg.py index fe68a3058336..91f0c86a4eed 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/humanoid/humanoid_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/humanoid/humanoid_env_cfg.py @@ -133,38 +133,11 @@ def __post_init__(self): policy: PolicyCfg = PolicyCfg() -@configclass -class _HumanoidNewtonObservationsCfg: - """Newton-compatible observations: excludes feet_body_forces (not implemented in Newton).""" - - @configclass - class PolicyCfg(ObsGroup): - """Observations for the policy.""" - - base_height = ObsTerm(func=mdp.base_pos_z) - base_lin_vel = ObsTerm(func=mdp.base_lin_vel) - base_ang_vel = ObsTerm(func=mdp.base_ang_vel, scale=0.25) - base_yaw_roll = ObsTerm(func=mdp.base_yaw_roll) - base_angle_to_target = ObsTerm(func=mdp.base_angle_to_target, params={"target_pos": (1000.0, 0.0, 0.0)}) - base_up_proj = ObsTerm(func=mdp.base_up_proj) - base_heading_proj = ObsTerm(func=mdp.base_heading_proj, params={"target_pos": (1000.0, 0.0, 0.0)}) - joint_pos_norm = ObsTerm(func=mdp.joint_pos_limit_normalized) - joint_vel_rel = ObsTerm(func=mdp.joint_vel_rel, scale=0.1) - actions = ObsTerm(func=mdp.last_action) - - def __post_init__(self): - self.enable_corruption = False - self.concatenate_terms = True - - # observation groups - policy: PolicyCfg = PolicyCfg() - - @configclass class HumanoidObservationsCfg(PresetCfg): default: ObservationsCfg = ObservationsCfg() physx: ObservationsCfg = ObservationsCfg() - newton: _HumanoidNewtonObservationsCfg = _HumanoidNewtonObservationsCfg() + newton: ObservationsCfg = ObservationsCfg() @configclass From da5ff06e4d62064e3c30a6cb7d59b5c46c2df7bc Mon Sep 17 00:00:00 2001 From: Antoine Richard Date: Thu, 30 Apr 2026 17:04:38 +0200 Subject: [PATCH 05/40] Honor PhysX joint wrench frame convention Convert PhysX incoming joint wrenches from body1 frame at the body origin into the shared child-side joint frame with torque referenced at the child-side joint anchor. --- .../migration/migrating_to_isaaclab_3-0.rst | 4 +- .../joint_wrench/joint_wrench_sensor_cfg.py | 4 +- source/isaaclab_physx/docs/CHANGELOG.rst | 3 +- .../joint_wrench/joint_wrench_sensor.py | 65 +++++++++++++++-- .../sensors/joint_wrench/kernels.py | 15 +++- .../test/sensors/test_joint_wrench_sensor.py | 73 ++++++++++++++++++- 6 files changed, 148 insertions(+), 16 deletions(-) diff --git a/docs/source/migration/migrating_to_isaaclab_3-0.rst b/docs/source/migration/migrating_to_isaaclab_3-0.rst index 2e9fd76fcd80..a559443274c2 100644 --- a/docs/source/migration/migrating_to_isaaclab_3-0.rst +++ b/docs/source/migration/migrating_to_isaaclab_3-0.rst @@ -343,6 +343,8 @@ The ``ArticulationData.body_incoming_joint_wrench_b`` property has been removed. Isaac Lab 3.0, incoming joint reaction wrenches are exposed through :class:`~isaaclab.sensors.JointWrenchSensor`, which has PhysX and Newton backend implementations and returns separate force [N] and torque [N·m] buffers. +The sensor reports wrenches in the child-side incoming joint frame, with torque +referenced at the child-side joint anchor. **Before (Isaac Lab 2.x):** @@ -364,7 +366,7 @@ implementations and returns separate force [N] and torque [N·m] buffers. sensor = env.scene.sensors["joint_wrench"] data = sensor.data - wrench_b = torch.cat( + wrench_j = torch.cat( ( data.force.torch[:, body_ids], data.torque.torch[:, body_ids], diff --git a/source/isaaclab/isaaclab/sensors/joint_wrench/joint_wrench_sensor_cfg.py b/source/isaaclab/isaaclab/sensors/joint_wrench/joint_wrench_sensor_cfg.py index aae63076156b..4bdb0ab74f06 100644 --- a/source/isaaclab/isaaclab/sensors/joint_wrench/joint_wrench_sensor_cfg.py +++ b/source/isaaclab/isaaclab/sensors/joint_wrench/joint_wrench_sensor_cfg.py @@ -25,6 +25,6 @@ class JointWrenchSensorCfg(SensorBaseCfg): """Coordinate convention for the reported wrench. Defaults to ``"incoming_joint_frame"``. - ``"incoming_joint_frame"`` — child-side joint frame, child-side joint anchor as reference point. - Matches what a real 6-axis F/T sensor mounted at the joint would measure. This is the same - as PhysX convention in IsaacLab2.3 + Matches what a real 6-axis F/T sensor mounted at the joint would measure. Backends convert + their native solver outputs to this convention. """ diff --git a/source/isaaclab_physx/docs/CHANGELOG.rst b/source/isaaclab_physx/docs/CHANGELOG.rst index e0154fd393cf..528d6c9867af 100644 --- a/source/isaaclab_physx/docs/CHANGELOG.rst +++ b/source/isaaclab_physx/docs/CHANGELOG.rst @@ -10,7 +10,8 @@ Added * Added :class:`~isaaclab_physx.sensors.JointWrenchSensor` for reading PhysX incoming joint reaction wrenches as split force [N] and torque [N·m] buffers. The sensor accepts asset prim paths whose articulation root is nested below - the configured prim. + the configured prim and converts PhysX's native body-frame wrench to the + shared child-side joint-frame convention. Removed ^^^^^^^ diff --git a/source/isaaclab_physx/isaaclab_physx/sensors/joint_wrench/joint_wrench_sensor.py b/source/isaaclab_physx/isaaclab_physx/sensors/joint_wrench/joint_wrench_sensor.py index 155437758bbe..9ae39c621240 100644 --- a/source/isaaclab_physx/isaaclab_physx/sensors/joint_wrench/joint_wrench_sensor.py +++ b/source/isaaclab_physx/isaaclab_physx/sensors/joint_wrench/joint_wrench_sensor.py @@ -11,9 +11,10 @@ from collections.abc import Sequence from typing import TYPE_CHECKING +import numpy as np import warp as wp -from pxr import UsdPhysics +from pxr import Usd, UsdPhysics from isaaclab.sensors.joint_wrench import BaseJointWrenchSensor from isaaclab.sim.utils.queries import find_first_matching_prim, get_all_matching_child_prims @@ -35,10 +36,9 @@ class JointWrenchSensor(BaseJointWrenchSensor): """PhysX joint reaction wrench sensor. The sensor reads PhysX's incoming joint wrench for every articulation link - and exposes the linear force [N] and angular torque [N·m] components. PhysX - reports each wrench in the frame of ``body1`` from the USD joint, which is - the child body's frame for the standard ``body0`` = parent, ``body1`` = - child convention. The root body's entry is included. + and exposes the linear force [N] and angular torque [N·m] components in + the child-side joint frame, with torque referenced at the child-side joint + anchor. The root body's entry is included. :attr:`~isaaclab.sensors.SensorBaseCfg.prim_path` must point at either the articulation root prim or a parent prim containing a single @@ -62,6 +62,8 @@ def __init__(self, cfg: JointWrenchSensorCfg): self._data = JointWrenchSensorData() self._physics_sim_view = None self._root_view: physx.ArticulationView | None = None + self._joint_pos_b: wp.array | None = None + self._joint_quat_b: wp.array | None = None self._num_bodies: int = 0 def __str__(self) -> str: @@ -130,6 +132,7 @@ def _initialize_impl(self) -> None: raise RuntimeError(f"Joint wrench sensor matched zero bodies at '{self.cfg.prim_path}'.") self._data._body_names = list(self._root_view.shared_metatype.link_names) + self._create_joint_frame_buffers() self._data.create_buffers(num_envs=self._num_envs, num_bodies=self._num_bodies, device=self._device) logger.info(f"Joint wrench sensor initialized: {self._num_envs} envs, {self._num_bodies} bodies") @@ -163,6 +166,45 @@ def _resolve_articulation_root_prim_path(self) -> str: root_prim_path_relative_to_prim_path = first_env_root_prim_path[len(first_env_matching_prim_path) :] return self.cfg.prim_path + root_prim_path_relative_to_prim_path + def _create_joint_frame_buffers(self) -> None: + """Create child-side joint frame transforms indexed by PhysX link order.""" + joint_pos_b = np.zeros((self._num_bodies, 3), dtype=np.float32) + joint_quat_b = np.zeros((self._num_bodies, 4), dtype=np.float32) + joint_quat_b[:, 3] = 1.0 + + first_env_matching_prim = find_first_matching_prim(self.cfg.prim_path) + if first_env_matching_prim is None: + raise RuntimeError(f"Failed to find prim for expression: '{self.cfg.prim_path}'.") + link_name_to_index = {name: index for index, name in enumerate(self._data._body_names)} + + for prim in Usd.PrimRange(first_env_matching_prim): + joint = UsdPhysics.Joint(prim) + if not joint or joint.GetJointEnabledAttr().Get() is False: + continue + body1_targets = joint.GetBody1Rel().GetTargets() + if len(body1_targets) == 0: + continue + body_index = link_name_to_index.get(body1_targets[0].name) + if body_index is None: + continue + + local_pos1 = joint.GetLocalPos1Attr().Get() + if local_pos1 is not None: + joint_pos_b[body_index] = (float(local_pos1[0]), float(local_pos1[1]), float(local_pos1[2])) + + local_rot1 = joint.GetLocalRot1Attr().Get() + if local_rot1 is not None: + local_rot1_imag = local_rot1.GetImaginary() + joint_quat_b[body_index] = ( + float(local_rot1_imag[0]), + float(local_rot1_imag[1]), + float(local_rot1_imag[2]), + float(local_rot1.GetReal()), + ) + + self._joint_pos_b = wp.array(joint_pos_b, dtype=wp.vec3f, device=self._device) + self._joint_quat_b = wp.array(joint_quat_b, dtype=wp.quatf, device=self._device) + def _update_buffers_impl(self, env_mask: wp.array) -> None: """Read PhysX incoming joint wrenches and split them into force / torque buffers. @@ -174,12 +216,21 @@ def _update_buffers_impl(self, env_mask: wp.array) -> None: f"Joint wrench sensor '{self.cfg.prim_path}': not initialized." " Access sensor data only after sim.reset() has been called." ) + if self._joint_pos_b is None or self._joint_quat_b is None: + raise RuntimeError(f"Joint wrench sensor '{self.cfg.prim_path}': joint frame buffers are not initialized.") incoming_joint_wrench = self._root_view.get_link_incoming_joint_force().view(wp.spatial_vectorf) wp.launch( joint_wrench_split_kernel, dim=(self._num_envs, self._num_bodies), - inputs=[env_mask, incoming_joint_wrench, self._data._force, self._data._torque], + inputs=[ + env_mask, + incoming_joint_wrench, + self._joint_pos_b, + self._joint_quat_b, + self._data._force, + self._data._torque, + ], device=self._device, ) @@ -192,6 +243,8 @@ def _invalidate_initialize_callback(self, event) -> None: super()._invalidate_initialize_callback(event) self._physics_sim_view = None self._root_view = None + self._joint_pos_b = None + self._joint_quat_b = None self._num_bodies = 0 self._data._force = None self._data._torque = None diff --git a/source/isaaclab_physx/isaaclab_physx/sensors/joint_wrench/kernels.py b/source/isaaclab_physx/isaaclab_physx/sensors/joint_wrench/kernels.py index 3abc7f8c67b4..bb3d0c7cf67c 100644 --- a/source/isaaclab_physx/isaaclab_physx/sensors/joint_wrench/kernels.py +++ b/source/isaaclab_physx/isaaclab_physx/sensors/joint_wrench/kernels.py @@ -10,17 +10,26 @@ def joint_wrench_split_kernel( env_mask: wp.array(dtype=wp.bool), incoming_joint_wrench: wp.array(dtype=wp.spatial_vectorf, ndim=2), + joint_pos_b: wp.array(dtype=wp.vec3f), + joint_quat_b: wp.array(dtype=wp.quatf), out_force: wp.array(dtype=wp.vec3f, ndim=2), out_torque: wp.array(dtype=wp.vec3f, ndim=2), ): - """Split PhysX incoming joint spatial wrenches into force and torque components.""" + """Convert PhysX incoming joint spatial wrenches into the child-side joint frame.""" env, body = wp.tid() if not env_mask[env]: return wrench = incoming_joint_wrench[env, body] - out_force[env, body] = wp.spatial_top(wrench) - out_torque[env, body] = wp.spatial_bottom(wrench) + force_b = wp.spatial_top(wrench) + torque_b = wp.spatial_bottom(wrench) + + # PhysX reports the wrench in body1's frame, referenced at body1's origin. + # Shift torque to the child-side joint anchor and rotate both components + # into the child-side joint frame. + torque_joint_anchor_b = torque_b - wp.cross(joint_pos_b[body], force_b) + out_force[env, body] = wp.quat_rotate_inv(joint_quat_b[body], force_b) + out_torque[env, body] = wp.quat_rotate_inv(joint_quat_b[body], torque_joint_anchor_b) @wp.kernel 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 b3f1890e4db4..4f10f2506ebb 100644 --- a/source/isaaclab_physx/test/sensors/test_joint_wrench_sensor.py +++ b/source/isaaclab_physx/test/sensors/test_joint_wrench_sensor.py @@ -12,11 +12,15 @@ """Rest everything follows.""" +import math + import pytest import torch import warp as wp from isaaclab_physx.physics import PhysxCfg +from pxr import Gf, UsdPhysics + import isaaclab.sim as sim_utils from isaaclab.actuators import ImplicitActuatorCfg from isaaclab.assets import Articulation, ArticulationCfg @@ -25,6 +29,7 @@ from isaaclab.sim import SimulationCfg from isaaclab.terrains import TerrainImporterCfg from isaaclab.utils import configclass +from isaaclab.utils import math as math_utils from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR, ISAACLAB_NUCLEUS_DIR from isaaclab_assets.robots.ant import ANT_CFG @@ -138,12 +143,49 @@ def _physx_incoming_joint_wrench(sensor: JointWrenchSensor) -> torch.Tensor: def _assert_sensor_matches_physx_tensor(sensor: JointWrenchSensor) -> None: - """Compare the sensor buffers to the raw PhysX tensor API.""" + """Compare sensor buffers to the raw PhysX tensor transformed into joint frames.""" raw_wrench = _physx_incoming_joint_wrench(sensor) sensor_data = sensor.data - torch.testing.assert_close(sensor_data.force.torch, raw_wrench[..., :3]) - torch.testing.assert_close(sensor_data.torque.torch, raw_wrench[..., 3:]) + expected_force, expected_torque = _physx_incoming_joint_wrench_in_joint_frame(sensor, raw_wrench) + torch.testing.assert_close(sensor_data.force.torch, expected_force) + torch.testing.assert_close(sensor_data.torque.torch, expected_torque) + + +def _physx_incoming_joint_wrench_in_joint_frame( + sensor: JointWrenchSensor, raw_wrench: torch.Tensor +) -> tuple[torch.Tensor, torch.Tensor]: + """Transform raw PhysX body-frame incoming joint wrenches into the configured convention.""" + force_b = raw_wrench[..., :3] + torque_b = raw_wrench[..., 3:] + joint_pos_b = wp.to_torch(sensor._joint_pos_b).unsqueeze(0) + joint_quat_b = wp.to_torch(sensor._joint_quat_b).unsqueeze(0) + torque_joint_anchor_b = torque_b - torch.cross(joint_pos_b.expand_as(force_b), force_b, dim=-1) + + flat_joint_quat_b = joint_quat_b.expand_as(raw_wrench[..., :4]).reshape(-1, 4) + expected_force = math_utils.quat_apply_inverse(flat_joint_quat_b, force_b.reshape(-1, 3)).reshape(force_b.shape) + expected_torque = math_utils.quat_apply_inverse(flat_joint_quat_b, torque_joint_anchor_b.reshape(-1, 3)).reshape( + torque_b.shape + ) + return expected_force, expected_torque + + +def _set_child_joint_frame(scene: InteractiveScene, child_body_name: str) -> None: + """Set a non-identity child-side joint frame for the requested body in env 0.""" + for prim in scene.stage.Traverse(): + if not prim.GetPath().pathString.startswith("/World/envs/env_0/Robot"): + continue + joint = UsdPhysics.Joint(prim) + if joint and any(target.name == child_body_name for target in joint.GetBody1Rel().GetTargets()): + joint.GetLocalPos1Attr().Set(Gf.Vec3f(0.25, -0.15, 0.1)) + joint.GetLocalRot1Attr().Set( + Gf.Quatf( + math.cos(math.pi / 4.0), + Gf.Vec3f(math.sin(math.pi / 4.0), 0.0, 0.0), + ) + ) + return + raise RuntimeError(f"Failed to find a USD joint with child body '{child_body_name}'.") # --------------------------------------------------------------------------- @@ -243,6 +285,31 @@ def test_force_and_torque_components_at_rest(sim): assert torch.any(raw_wrench[:, arm_idx, :] != 0.0) +def test_non_identity_joint_frame_transform(sim): + """PhysX raw body-frame wrench is converted to the child-side joint frame.""" + scene = InteractiveScene(_SingleJointSceneCfg(num_envs=1)) + _set_child_joint_frame(scene, "Arm") + sim.reset() + + sensor: JointWrenchSensor = scene["wrench"] + robot: Articulation = scene["robot"] + arm_idx = robot.body_names.index("Arm") + + for _ in range(400): + sim.step() + scene.update(sim.get_physics_dt()) + + raw_wrench = _physx_incoming_joint_wrench(sensor) + expected_force, expected_torque = _physx_incoming_joint_wrench_in_joint_frame(sensor, raw_wrench) + torch.testing.assert_close(sensor.data.force.torch, expected_force) + torch.testing.assert_close(sensor.data.torque.torch, expected_torque) + + raw_force = raw_wrench[:, arm_idx, :3] + raw_torque = raw_wrench[:, arm_idx, 3:] + assert not torch.allclose(sensor.data.force.torch[:, arm_idx], raw_force) + assert not torch.allclose(sensor.data.torque.torch[:, arm_idx], raw_torque) + + def test_wrench_with_external_force_and_torque(sim): """Full wrench validation with external force and torque applied.""" scene = InteractiveScene(_SingleJointSceneCfg(num_envs=1)) From e57269f91b55ee8f0dfb8657adff2cf0fb755611 Mon Sep 17 00:00:00 2001 From: Antoine RICHARD Date: Thu, 30 Apr 2026 20:54:28 +0200 Subject: [PATCH 06/40] Performance optimization on asset writes (#5329) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description Improves performance across the board for asset writes. ## Type of change - Bug fix (non-breaking change which fixes an issue) ## Micro benchmarks ``` ┌─────────────────────────────────┬────────┬───────┬─────────┐ │ Method │ Before │ After │ Speedup │ ├─────────────────────────────────┼────────┼───────┼─────────┤ │ write_root_link_pose_to_sim │ 94 │ 81 │ 1.16x │ ├─────────────────────────────────┼────────┼───────┼─────────┤ │ write_root_com_pose_to_sim │ 111 │ 90 │ 1.23x │ ├─────────────────────────────────┼────────┼───────┼─────────┤ │ write_root_link_velocity_to_sim │ 128 │ 124 │ 1.03x │ ├─────────────────────────────────┼────────┼───────┼─────────┤ │ write_root_com_velocity_to_sim │ 110 │ 108 │ 1.02x │ ├─────────────────────────────────┼────────┼───────┼─────────┤ │ write_joint_state_to_sim │ 180 │ 127 │ 1.42x │ ├─────────────────────────────────┼────────┼───────┼─────────┤ │ write_joint_position_to_sim │ 98 │ 108 │ ~1.0x │ ├─────────────────────────────────┼────────┼───────┼─────────┤ │ set_joint_position_target │ 94 │ 107 │ ~1.0x │ ├─────────────────────────────────┼────────┼───────┼─────────┤ │ write_joint_stiffness_to_sim │ 171 │ 152 │ 1.13x │ ├─────────────────────────────────┼────────┼───────┼─────────┤ │ write_joint_damping_to_sim │ 177 │ 151 │ 1.17x │ ├─────────────────────────────────┼────────┼───────┼─────────┤ │ write_joint_armature_to_sim │ 170 │ 153 │ 1.11x │ ├─────────────────────────────────┼────────┼───────┼─────────┤ │ set_masses │ 177 │ 152 │ 1.16x │ ├─────────────────────────────────┼────────┼───────┼─────────┤ │ set_coms │ 350 │ 272 │ 1.29x │ ├─────────────────────────────────┼────────┼───────┼─────────┤ │ set_inertias │ 350 │ 280 │ 1.25x │ └─────────────────────────────────┴────────┴───────┴─────────┘ ``` ## Checklist - [ ] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html) - [ ] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [ ] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 19 +- source/isaaclab/isaaclab/assets/asset_base.py | 20 +- .../isaaclab/assets/asset_base_cfg.py | 13 + .../test/assets/test_articulation_iface.py | 104 +- .../test_rigid_object_collection_iface.py | 34 + .../test/assets/test_rigid_object_iface.py | 39 + .../assets/benchmark_articulation.py | 1296 +++++++++++++++++ .../assets/benchmark_articulation_data.py | 342 +++++ .../assets/benchmark_rigid_object.py | 623 ++++++++ .../benchmark_rigid_object_collection.py | 654 +++++++++ .../benchmark_rigid_object_collection_data.py | 252 ++++ .../assets/benchmark_rigid_object_data.py | 285 ++++ source/isaaclab_newton/docs/CHANGELOG.rst | 13 +- .../assets/articulation/articulation.py | 143 +- .../assets/articulation/kernels.py | 61 + .../isaaclab_newton/assets/kernels.py | 138 +- .../assets/rigid_object/rigid_object.py | 42 +- .../rigid_object_collection.py | 36 +- .../assets/benchmark_articulation.py | 119 +- .../assets/benchmark_rigid_object.py | 62 + .../benchmark_rigid_object_collection.py | 62 + source/isaaclab_physx/config/extension.toml | 2 +- source/isaaclab_physx/docs/CHANGELOG.rst | 17 +- .../assets/articulation/articulation.py | 291 +++- .../assets/articulation/kernels.py | 41 + .../deformable_object/deformable_object.py | 50 +- .../deformable_object_data.py | 6 +- .../isaaclab_physx/assets/kernels.py | 195 +-- .../assets/rigid_object/rigid_object.py | 136 +- .../rigid_object_collection.py | 26 +- 31 files changed, 4578 insertions(+), 545 deletions(-) create mode 100644 source/isaaclab_newton/benchmark/assets/benchmark_articulation.py create mode 100644 source/isaaclab_newton/benchmark/assets/benchmark_articulation_data.py create mode 100644 source/isaaclab_newton/benchmark/assets/benchmark_rigid_object.py create mode 100644 source/isaaclab_newton/benchmark/assets/benchmark_rigid_object_collection.py create mode 100644 source/isaaclab_newton/benchmark/assets/benchmark_rigid_object_collection_data.py create mode 100644 source/isaaclab_newton/benchmark/assets/benchmark_rigid_object_data.py diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index 551e1ff12bf5..322e44e742c9 100644 --- a/source/isaaclab/config/extension.toml +++ b/source/isaaclab/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "4.6.22" +version = "4.6.23" # Description title = "Isaac Lab framework for Robot Learning" diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index 0731a7b2d3d5..8d07df40fdea 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,23 @@ Changelog --------- +4.6.23 (2026-04-30) +~~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :attr:`~isaaclab.assets.AssetBaseCfg.disable_shape_checks` configuration option + to skip shape/dtype validation in setter and writer methods, reducing per-call overhead + in production workloads. + +Fixed +^^^^^ + +* Fixed cross-backend asset interface regression tests to cover tensor views passed to + backend index resolution helpers. + + 4.6.22 (2026-04-27) ~~~~~~~~~~~~~~~~~~~ @@ -425,7 +442,7 @@ Changed 4.6.7 (2026-04-20) -~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~ Added ^^^^^ diff --git a/source/isaaclab/isaaclab/assets/asset_base.py b/source/isaaclab/isaaclab/assets/asset_base.py index f5f121c5ad2b..a7da0d36fe12 100644 --- a/source/isaaclab/isaaclab/assets/asset_base.py +++ b/source/isaaclab/isaaclab/assets/asset_base.py @@ -53,6 +53,9 @@ class AssetBase(ABC): :meth:`_debug_vis_callback` methods. """ + _check_shapes: bool = __debug__ + """Class-level default for shape validation. Overridden per-instance in ``__init__``.""" + def __init__(self, cfg: AssetBaseCfg): """Initialize the asset base. @@ -66,6 +69,13 @@ def __init__(self, cfg: AssetBaseCfg): cfg.validate() # store inputs self.cfg = cfg.copy() + # Resolve shape-check flag once: True means checks are active. + # cfg.disable_shape_checks: None -> follow __debug__ + # True -> force disable checks; False -> force enable checks. + if self.cfg.disable_shape_checks is None: + self._check_shapes = __debug__ + else: + self._check_shapes = not self.cfg.disable_shape_checks # flag for whether the asset is initialized self._is_initialized = False # get stage handle @@ -257,13 +267,16 @@ def assert_shape_and_dtype( ) -> None: """Assert the shape and dtype of a tensor or warp array. + Controlled by :attr:`AssetBaseCfg.disable_shape_checks`. When checks are + disabled this method is a no-op. + Args: tensor: The tensor or warp array to assert the shape of. Floats are skipped. shape: The expected leading dimensions (e.g. ``(num_envs, num_joints)``). dtype: The expected warp dtype. name: Optional parameter name for error messages. """ - if __debug__: + if self._check_shapes: cls = type(self).__name__ prefix = f"{cls}: '{name}' " if name else f"{cls}: " if isinstance(tensor, (int, float)): @@ -294,6 +307,9 @@ def assert_shape_and_dtype_mask( ``(mask_0.shape[0], mask_1.shape[0], ...)`` (i.e. the *total* size of each dimension, not the number of selected entries). + Controlled by :attr:`AssetBaseCfg.disable_shape_checks`. When checks are + disabled this method is a no-op. + Args: tensor: The tensor or warp array to assert the shape of. Floats are skipped. masks: Tuple of mask arrays whose ``shape[0]`` dimensions form the expected leading shape. @@ -301,7 +317,7 @@ def assert_shape_and_dtype_mask( name: Optional parameter name for error messages. trailing_dims: Extra trailing dimensions to append (e.g. ``(9,)`` for inertias with ``wp.float32``). """ - if __debug__: + if self._check_shapes: shape = (*tuple(m.shape[0] for m in masks), *trailing_dims) self.assert_shape_and_dtype(tensor, shape, dtype, name) diff --git a/source/isaaclab/isaaclab/assets/asset_base_cfg.py b/source/isaaclab/isaaclab/assets/asset_base_cfg.py index 37d551ddec7d..4575acc08452 100644 --- a/source/isaaclab/isaaclab/assets/asset_base_cfg.py +++ b/source/isaaclab/isaaclab/assets/asset_base_cfg.py @@ -75,3 +75,16 @@ class InitialStateCfg: debug_vis: bool = False """Whether to enable debug visualization for the asset. Defaults to ``False``.""" + + disable_shape_checks: bool | None = None + """Disable shape/dtype validation in setter and writer methods. + + When ``True``, :meth:`~AssetBase.assert_shape_and_dtype` and + :meth:`~AssetBase.assert_shape_and_dtype_mask` become no-ops, + eliminating per-call assertion overhead. + + When ``False``, shape checks are always enabled, even under ``python -O``. + + When ``None`` (the default), shape checks follow Python's ``__debug__`` + flag — enabled in normal mode, disabled with ``python -O``. + """ diff --git a/source/isaaclab/test/assets/test_articulation_iface.py b/source/isaaclab/test/assets/test_articulation_iface.py index 9682df92f3a5..3283a0e73459 100644 --- a/source/isaaclab/test/assets/test_articulation_iface.py +++ b/source/isaaclab/test/assets/test_articulation_iface.py @@ -160,31 +160,63 @@ def create_physx_articulation( # Set up other required attributes object.__setattr__(articulation, "actuators", {}) object.__setattr__(articulation, "_has_implicit_actuators", False) - object.__setattr__(articulation, "_ALL_INDICES", torch.arange(num_instances, dtype=torch.int32, device=device)) - object.__setattr__(articulation, "_ALL_BODY_INDICES", torch.arange(num_bodies, dtype=torch.int32, device=device)) - object.__setattr__(articulation, "_ALL_JOINT_INDICES", torch.arange(num_joints, dtype=torch.int32, device=device)) + object.__setattr__(articulation, "_ALL_INDICES", wp.array(np.arange(num_instances, dtype=np.int32), device=device)) + object.__setattr__( + articulation, "_ALL_BODY_INDICES", wp.array(np.arange(num_bodies, dtype=np.int32), device=device) + ) + object.__setattr__( + articulation, "_ALL_JOINT_INDICES", wp.array(np.arange(num_joints, dtype=np.int32), device=device) + ) # Tendon index arrays - all_fixed_tendon_indices = wp.from_torch( - torch.arange(num_fixed_tendons, dtype=torch.int32, device=device), dtype=wp.int32 + object.__setattr__( + articulation, + "_ALL_FIXED_TENDON_INDICES", + wp.array(np.arange(num_fixed_tendons, dtype=np.int32), device=device), ) - all_spatial_tendon_indices = wp.from_torch( - torch.arange(num_spatial_tendons, dtype=torch.int32, device=device), dtype=wp.int32 + object.__setattr__( + articulation, + "_ALL_SPATIAL_TENDON_INDICES", + wp.array(np.arange(num_spatial_tendons, dtype=np.int32), device=device), ) - object.__setattr__(articulation, "_ALL_FIXED_TENDON_INDICES", all_fixed_tendon_indices) - object.__setattr__(articulation, "_ALL_SPATIAL_TENDON_INDICES", all_spatial_tendon_indices) # Warp arrays for set_external_force_and_torque - all_indices = torch.arange(num_instances, dtype=torch.int32, device=device) - all_body_indices = torch.arange(num_bodies, dtype=torch.int32, device=device) - object.__setattr__(articulation, "_ALL_INDICES_WP", wp.from_torch(all_indices, dtype=wp.int32)) - object.__setattr__(articulation, "_ALL_BODY_INDICES_WP", wp.from_torch(all_body_indices, dtype=wp.int32)) + object.__setattr__( + articulation, "_ALL_INDICES_WP", wp.array(np.arange(num_instances, dtype=np.int32), device=device) + ) + object.__setattr__( + articulation, "_ALL_BODY_INDICES_WP", wp.array(np.arange(num_bodies, dtype=np.int32), device=device) + ) # Initialize joint targets object.__setattr__(articulation, "_joint_pos_target_sim", torch.zeros(num_instances, num_joints, device=device)) object.__setattr__(articulation, "_joint_vel_target_sim", torch.zeros(num_instances, num_joints, device=device)) object.__setattr__(articulation, "_joint_effort_target_sim", torch.zeros(num_instances, num_joints, device=device)) + # Cached .view(wp.float32) wrappers + object.__setattr__(articulation, "_root_link_pose_w_f32", None) + object.__setattr__(articulation, "_root_com_vel_w_f32", None) + object.__setattr__(articulation, "_root_link_vel_w_f32", None) + object.__setattr__(articulation, "_inst_wrench_force_f32", None) + object.__setattr__(articulation, "_inst_wrench_torque_f32", None) + object.__setattr__(articulation, "_perm_wrench_force_f32", None) + object.__setattr__(articulation, "_perm_wrench_torque_f32", None) + + # Pre-allocated pinned CPU buffers for PhysX TensorAPI writes + N, J, B = num_instances, num_joints, num_bodies + cpu_env_ids = wp.array(np.arange(N, dtype=np.int32), device="cpu") + object.__setattr__(articulation, "_cpu_env_ids_all", cpu_env_ids) + object.__setattr__(articulation, "_cpu_joint_stiffness", wp.zeros((N, J), dtype=wp.float32, device="cpu")) + object.__setattr__(articulation, "_cpu_joint_damping", wp.zeros((N, J), dtype=wp.float32, device="cpu")) + object.__setattr__(articulation, "_cpu_joint_pos_limits", wp.zeros((N, J, 2), dtype=wp.float32, device="cpu")) + object.__setattr__(articulation, "_cpu_joint_vel_limits", wp.zeros((N, J), dtype=wp.float32, device="cpu")) + object.__setattr__(articulation, "_cpu_joint_effort_limits", wp.zeros((N, J), dtype=wp.float32, device="cpu")) + object.__setattr__(articulation, "_cpu_joint_armature", wp.zeros((N, J), dtype=wp.float32, device="cpu")) + object.__setattr__(articulation, "_cpu_joint_friction_props", wp.zeros((N, J, 3), dtype=wp.float32, device="cpu")) + object.__setattr__(articulation, "_cpu_body_mass", wp.zeros((N, B), dtype=wp.float32, device="cpu")) + object.__setattr__(articulation, "_cpu_body_coms", wp.zeros((N, B, 7), dtype=wp.float32, device="cpu")) + object.__setattr__(articulation, "_cpu_body_inertia", wp.zeros((N, B, 9), dtype=wp.float32, device="cpu")) + return articulation, mock_view @@ -484,6 +516,52 @@ def _check_proxy_array(arr, *, expected_shape: tuple, expected_dtype: type, name ) _default_devices = pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +_index_resolution_backends = pytest.mark.parametrize( + "backend", [backend for backend in ("physx", "newton") if backend in BACKENDS], indirect=False +) + + +# --------------------------------------------------------------------------- +# Tests: Index resolution helpers +# --------------------------------------------------------------------------- + + +class TestArticulationIndexResolution: + """Test backend-specific index resolution helpers.""" + + @_index_resolution_backends + def test_resolve_env_ids_handles_tensor_view_shape(self, backend): + art, _ = get_articulation(backend, num_instances=4, device="cpu") + + env_ids = torch.arange(4, dtype=torch.int32, device="cpu") + resolved_full = art._resolve_env_ids(env_ids) + resolved_view = art._resolve_env_ids(env_ids[:2]) + + assert resolved_full.shape[0] == 4 + assert resolved_view.shape[0] == 2 + + @_index_resolution_backends + def test_resolve_joint_ids_handles_tensor_view_shape(self, backend): + art, _ = get_articulation(backend, num_joints=4, device="cpu") + + joint_ids = torch.arange(4, dtype=torch.int32, device="cpu") + resolved_full = art._resolve_joint_ids(joint_ids) + resolved_view = art._resolve_joint_ids(joint_ids[:2]) + + assert resolved_full.shape[0] == 4 + assert resolved_view.shape[0] == 2 + + @_index_resolution_backends + def test_resolve_body_ids_handles_tensor_view_shape(self, backend): + art, _ = get_articulation(backend, num_bodies=4, device="cpu") + + body_ids = torch.arange(4, dtype=torch.int32, device="cpu") + resolved_full = art._resolve_body_ids(body_ids) + resolved_view = art._resolve_body_ids(body_ids[:2]) + + assert resolved_full.shape[0] == 4 + assert resolved_view.shape[0] == 2 + # --------------------------------------------------------------------------- # Tests: Articulation properties 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 4d067f44c1ce..42c2e8d731b5 100644 --- a/source/isaaclab/test/assets/test_rigid_object_collection_iface.py +++ b/source/isaaclab/test/assets/test_rigid_object_collection_iface.py @@ -271,6 +271,9 @@ def _check_proxy_array(arr, *, expected_shape: tuple, expected_dtype: type, name _default_bodies = pytest.mark.parametrize("num_bodies", [1, 3]) _default_devices = pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +_index_resolution_backends = pytest.mark.parametrize( + "backend", [backend for backend in ("physx", "newton") if backend in BACKENDS], indirect=False +) # --------------------------------------------------------------------------- @@ -351,6 +354,37 @@ def _make_item_mask(total: int, selected: list[int], device: str) -> wp.array: return wp.array(mask_np, dtype=wp.bool, device=device) +# --------------------------------------------------------------------------- +# Tests: Index resolution helpers +# --------------------------------------------------------------------------- + + +class TestCollectionIndexResolution: + """Test backend-specific index resolution helpers.""" + + @_index_resolution_backends + def test_resolve_env_ids_handles_tensor_view_shape(self, backend): + obj, _ = get_rigid_object_collection(backend, num_instances=4, device="cpu") + + env_ids = torch.arange(4, dtype=torch.int32, device="cpu") + resolved_full = obj._resolve_env_ids(env_ids) + resolved_view = obj._resolve_env_ids(env_ids[:2]) + + assert resolved_full.shape[0] == 4 + assert resolved_view.shape[0] == 2 + + @_index_resolution_backends + def test_resolve_body_ids_handles_tensor_view_shape(self, backend): + obj, _ = get_rigid_object_collection(backend, num_bodies=4, device="cpu") + + body_ids = torch.arange(4, dtype=torch.int32, device="cpu") + resolved_full = obj._resolve_body_ids(body_ids) + resolved_view = obj._resolve_body_ids(body_ids[:2]) + + assert resolved_full.shape[0] == 4 + assert resolved_view.shape[0] == 2 + + # --------------------------------------------------------------------------- # Tests: Collection properties # --------------------------------------------------------------------------- diff --git a/source/isaaclab/test/assets/test_rigid_object_iface.py b/source/isaaclab/test/assets/test_rigid_object_iface.py index c7e01ad8ada7..178feeddb603 100644 --- a/source/isaaclab/test/assets/test_rigid_object_iface.py +++ b/source/isaaclab/test/assets/test_rigid_object_iface.py @@ -112,6 +112,22 @@ def create_physx_rigid_object( object.__setattr__(rigid_object, "_ALL_INDICES", wp.array(np.arange(num_instances, dtype=np.int32), device=device)) object.__setattr__(rigid_object, "_ALL_BODY_INDICES", wp.array(np.array([0], dtype=np.int32), device=device)) + # Cached .view(wp.float32) wrappers + object.__setattr__(rigid_object, "_root_link_pose_w_f32", None) + object.__setattr__(rigid_object, "_root_com_vel_w_f32", None) + object.__setattr__(rigid_object, "_inst_wrench_force_f32", None) + object.__setattr__(rigid_object, "_inst_wrench_torque_f32", None) + object.__setattr__(rigid_object, "_perm_wrench_force_f32", None) + object.__setattr__(rigid_object, "_perm_wrench_torque_f32", None) + + # Pre-allocated pinned CPU buffers for PhysX TensorAPI writes + N, B = num_instances, 1 # rigid object has 1 body + cpu_env_ids = wp.array(np.arange(N, dtype=np.int32), device="cpu") + object.__setattr__(rigid_object, "_cpu_env_ids_all", cpu_env_ids) + object.__setattr__(rigid_object, "_cpu_body_mass", wp.zeros((N, B), dtype=wp.float32, device="cpu")) + object.__setattr__(rigid_object, "_cpu_body_coms", wp.zeros((N, B, 7), dtype=wp.float32, device="cpu")) + object.__setattr__(rigid_object, "_cpu_body_inertia", wp.zeros((N, B, 9), dtype=wp.float32, device="cpu")) + return rigid_object, mock_view @@ -246,6 +262,29 @@ def _check_proxy_array(arr, *, expected_shape: tuple, expected_dtype: type, name _default_dims = pytest.mark.parametrize("num_instances", [1, 2, 100]) _default_devices = pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +_index_resolution_backends = pytest.mark.parametrize( + "backend", [backend for backend in ("physx", "newton") if backend in BACKENDS], indirect=False +) + + +# --------------------------------------------------------------------------- +# Tests: Index resolution helpers +# --------------------------------------------------------------------------- + + +class TestRigidObjectIndexResolution: + """Test backend-specific index resolution helpers.""" + + @_index_resolution_backends + def test_resolve_env_ids_handles_tensor_view_shape(self, backend): + obj, _ = get_rigid_object(backend, num_instances=4, device="cpu") + + env_ids = torch.arange(4, dtype=torch.int32, device="cpu") + resolved_full = obj._resolve_env_ids(env_ids) + resolved_view = obj._resolve_env_ids(env_ids[:2]) + + assert resolved_full.shape[0] == 4 + assert resolved_view.shape[0] == 2 # --------------------------------------------------------------------------- diff --git a/source/isaaclab_newton/benchmark/assets/benchmark_articulation.py b/source/isaaclab_newton/benchmark/assets/benchmark_articulation.py new file mode 100644 index 000000000000..c9f11a5defb5 --- /dev/null +++ b/source/isaaclab_newton/benchmark/assets/benchmark_articulation.py @@ -0,0 +1,1296 @@ +# 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 + +"""Micro-benchmarking framework for Articulation class (Newton backend). + +This module provides a benchmarking framework to measure the performance of setter and writer +methods in the Articulation class. Each method is benchmarked under three scenarios: + +1. **Torch List**: Inputs are PyTorch tensors with list indices (via deprecated wrappers). +2. **Torch Tensor**: Inputs are PyTorch tensors with tensor indices (via deprecated wrappers). +3. **Warp Mask**: Inputs are warp arrays with boolean masks (via ``_mask`` methods). + +Usage: + python benchmark_articulation.py [--num_iterations N] [--warmup_steps W] + [--num_instances I] [--num_bodies B] [--num_joints J] + +Example: + python benchmark_articulation.py --num_iterations 1000 --warmup_steps 10 + python benchmark_articulation.py --mode torch_list # Only run list-based benchmarks + python benchmark_articulation.py --mode warp_mask # Only run warp mask benchmarks +""" + +from __future__ import annotations + +"""Launch Isaac Sim Simulator first.""" + +import argparse + +from isaaclab.app import AppLauncher + +# add argparse arguments +parser = argparse.ArgumentParser(description="Benchmark Articulation methods (Newton backend).") +parser.add_argument("--num_iterations", type=int, default=1000, help="Number of iterations") +parser.add_argument("--warmup_steps", type=int, default=10, help="Number of warmup steps") +parser.add_argument("--num_instances", type=int, default=4096, help="Number of instances") +parser.add_argument("--num_bodies", type=int, default=12, help="Number of bodies") +parser.add_argument("--num_joints", type=int, default=11, help="Number of joints") +parser.add_argument("--mode", type=str, default="all", help="Benchmark mode (all, torch_list, torch_tensor, warp_mask)") +parser.add_argument("--output_dir", type=str, default=".", help="Output directory for results") +parser.add_argument("--backend", type=str, default="json", choices=["json", "osmo", "omniperf"], help="Metrics backend") +parser.add_argument("--no_shape_checks", action="store_true", help="Disable shape/dtype assertions") + +# append AppLauncher cli args +AppLauncher.add_app_launcher_args(parser) +# parse the arguments +args = parser.parse_args() + +# launch omniverse app +app_launcher = AppLauncher(headless=True, args=args) +simulation_app = app_launcher.app + +"""Rest everything follows.""" + +import logging +import warnings + +import numpy as np +import torch +import warp as wp +from isaaclab_newton.test.mock_interfaces import ( + MockNewtonArticulationView, + MockWrenchComposer, + create_mock_newton_manager, +) + +from isaaclab.assets.articulation.articulation_cfg import ArticulationCfg +from isaaclab.test.benchmark import MethodBenchmarkDefinition, MethodBenchmarkRunner, MethodBenchmarkRunnerConfig + +# Suppress deprecation warnings during benchmarking +warnings.filterwarnings("ignore", category=DeprecationWarning) +warnings.filterwarnings("ignore", category=UserWarning) + +# Also suppress logging warnings +logging.getLogger("isaaclab_newton").setLevel(logging.ERROR) +logging.getLogger("isaaclab").setLevel(logging.ERROR) + + +# ============================================================================= +# Index Helpers +# ============================================================================= + + +def make_tensor_env_ids(num_instances: int, device: str) -> torch.Tensor: + """Create a tensor of environment IDs.""" + return torch.arange(num_instances, dtype=torch.int32, device=device) + + +def make_tensor_joint_ids(num_joints: int, device: str) -> torch.Tensor: + """Create a tensor of joint IDs.""" + return torch.arange(num_joints, dtype=torch.int32, device=device) + + +def make_tensor_body_ids(num_bodies: int, device: str) -> torch.Tensor: + """Create a tensor of body IDs.""" + return torch.arange(num_bodies, dtype=torch.int32, device=device) + + +# ============================================================================= +# Test Articulation Factory +# ============================================================================= + + +def create_test_articulation( + num_instances: int = 2, + num_joints: int = 6, + num_bodies: int = 7, + device: str = "cuda:0", +): + """Create a test Articulation instance with mocked dependencies.""" + from isaaclab_newton.assets.articulation.articulation import Articulation + + joint_names = [f"joint_{i}" for i in range(num_joints)] + body_names = [f"body_{i}" for i in range(num_bodies)] + + articulation = object.__new__(Articulation) + + articulation.cfg = ArticulationCfg( + prim_path="/World/Robot", + soft_joint_pos_limit_factor=1.0, + actuators={}, + ) + + # Create Newton mock view + mock_view = MockNewtonArticulationView( + num_instances=num_instances, + num_bodies=num_bodies, + num_joints=num_joints, + device=device, + joint_names=joint_names, + body_names=body_names, + ) + mock_view.set_random_mock_data() + mock_view._noop_setters = True + + object.__setattr__(articulation, "_root_view", mock_view) + object.__setattr__(articulation, "_device", device) + object.__setattr__(articulation, "_check_shapes", not args.no_shape_checks) + + # Create ArticulationData instance (NewtonManager already mocked at call site) + from isaaclab_newton.assets.articulation.articulation_data import ArticulationData + + data = ArticulationData(mock_view, device) + object.__setattr__(articulation, "_data", data) + + # Create mock wrench composers + mock_inst_wrench = MockWrenchComposer(articulation) + mock_perm_wrench = MockWrenchComposer(articulation) + object.__setattr__(articulation, "_instantaneous_wrench_composer", mock_inst_wrench) + object.__setattr__(articulation, "_permanent_wrench_composer", mock_perm_wrench) + + # Set up other required attributes + object.__setattr__(articulation, "actuators", {}) + object.__setattr__(articulation, "_has_implicit_actuators", False) + object.__setattr__(articulation, "_ALL_INDICES", wp.array(np.arange(num_instances, dtype=np.int32), device=device)) + object.__setattr__( + articulation, "_ALL_BODY_INDICES", wp.array(np.arange(num_bodies, dtype=np.int32), device=device) + ) + object.__setattr__( + articulation, "_ALL_JOINT_INDICES", wp.array(np.arange(num_joints, dtype=np.int32), device=device) + ) + object.__setattr__(articulation, "_ALL_ENV_MASK", wp.ones((num_instances,), dtype=wp.bool, device=device)) + object.__setattr__(articulation, "_ALL_JOINT_MASK", wp.ones((num_joints,), dtype=wp.bool, device=device)) + object.__setattr__(articulation, "_ALL_BODY_MASK", wp.ones((num_bodies,), dtype=wp.bool, device=device)) + object.__setattr__(articulation, "_ALL_FIXED_TENDON_INDICES", wp.array([], dtype=wp.int32, device=device)) + object.__setattr__(articulation, "_ALL_FIXED_TENDON_MASK", wp.zeros((0,), dtype=wp.bool, device=device)) + object.__setattr__(articulation, "_ALL_SPATIAL_TENDON_INDICES", wp.array([], dtype=wp.int32, device=device)) + object.__setattr__(articulation, "_ALL_SPATIAL_TENDON_MASK", wp.zeros((0,), dtype=wp.bool, device=device)) + + # Initialize joint targets + object.__setattr__( + articulation, "_joint_pos_target_sim", wp.zeros((num_instances, num_joints), dtype=wp.float32, device=device) + ) + object.__setattr__( + articulation, "_joint_vel_target_sim", wp.zeros((num_instances, num_joints), dtype=wp.float32, device=device) + ) + object.__setattr__( + articulation, + "_joint_effort_target_sim", + wp.zeros((num_instances, num_joints), dtype=wp.float32, device=device), + ) + + return articulation, mock_view + + +# ============================================================================= +# Input Generators (Torch-only for Newton backend) +# ============================================================================= + + +# --- Root Link Pose --- +def gen_root_link_pose_torch_list(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "root_pose": torch.rand(config.num_instances, 7, device=config.device, dtype=torch.float32), + "env_ids": list(range(config.num_instances)), + } + + +def gen_root_link_pose_torch_tensor(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "root_pose": torch.rand(config.num_instances, 7, device=config.device, dtype=torch.float32), + "env_ids": make_tensor_env_ids(config.num_instances, config.device), + } + + +# --- Root COM Pose --- +def gen_root_com_pose_torch_list(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "root_pose": torch.rand(config.num_instances, 7, device=config.device, dtype=torch.float32), + "env_ids": list(range(config.num_instances)), + } + + +def gen_root_com_pose_torch_tensor(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "root_pose": torch.rand(config.num_instances, 7, device=config.device, dtype=torch.float32), + "env_ids": make_tensor_env_ids(config.num_instances, config.device), + } + + +# --- Root Link Velocity --- +def gen_root_link_velocity_torch_list(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "root_velocity": torch.rand(config.num_instances, 6, device=config.device, dtype=torch.float32), + "env_ids": list(range(config.num_instances)), + } + + +def gen_root_link_velocity_torch_tensor(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "root_velocity": torch.rand(config.num_instances, 6, device=config.device, dtype=torch.float32), + "env_ids": make_tensor_env_ids(config.num_instances, config.device), + } + + +# --- Root COM Velocity --- +def gen_root_com_velocity_torch_list(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "root_velocity": torch.rand(config.num_instances, 6, device=config.device, dtype=torch.float32), + "env_ids": list(range(config.num_instances)), + } + + +def gen_root_com_velocity_torch_tensor(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "root_velocity": torch.rand(config.num_instances, 6, device=config.device, dtype=torch.float32), + "env_ids": make_tensor_env_ids(config.num_instances, config.device), + } + + +# --- Root State (Deprecated) --- +def gen_root_state_torch_list(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "root_state": torch.rand(config.num_instances, 13, device=config.device, dtype=torch.float32), + "env_ids": list(range(config.num_instances)), + } + + +def gen_root_state_torch_tensor(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "root_state": torch.rand(config.num_instances, 13, device=config.device, dtype=torch.float32), + "env_ids": make_tensor_env_ids(config.num_instances, config.device), + } + + +# --- Root COM State (Deprecated) --- +def gen_root_com_state_torch_list(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "root_state": torch.rand(config.num_instances, 13, device=config.device, dtype=torch.float32), + "env_ids": list(range(config.num_instances)), + } + + +def gen_root_com_state_torch_tensor(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "root_state": torch.rand(config.num_instances, 13, device=config.device, dtype=torch.float32), + "env_ids": make_tensor_env_ids(config.num_instances, config.device), + } + + +# --- Root Link State (Deprecated) --- +def gen_root_link_state_torch_list(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "root_state": torch.rand(config.num_instances, 13, device=config.device, dtype=torch.float32), + "env_ids": list(range(config.num_instances)), + } + + +def gen_root_link_state_torch_tensor(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "root_state": torch.rand(config.num_instances, 13, device=config.device, dtype=torch.float32), + "env_ids": make_tensor_env_ids(config.num_instances, config.device), + } + + +# --- Joint State --- +def gen_joint_state_torch_list(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "position": torch.rand(config.num_instances, config.num_joints, device=config.device, dtype=torch.float32), + "velocity": torch.rand(config.num_instances, config.num_joints, device=config.device, dtype=torch.float32), + "env_ids": list(range(config.num_instances)), + "joint_ids": list(range(config.num_joints)), + } + + +def gen_joint_state_torch_tensor(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "position": torch.rand(config.num_instances, config.num_joints, device=config.device, dtype=torch.float32), + "velocity": torch.rand(config.num_instances, config.num_joints, device=config.device, dtype=torch.float32), + "env_ids": make_tensor_env_ids(config.num_instances, config.device), + "joint_ids": make_tensor_joint_ids(config.num_joints, config.device), + } + + +# --- Joint Position --- +def gen_joint_position_torch_list(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "position": torch.rand(config.num_instances, config.num_joints, device=config.device, dtype=torch.float32), + "env_ids": list(range(config.num_instances)), + "joint_ids": list(range(config.num_joints)), + } + + +def gen_joint_position_torch_tensor(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "position": torch.rand(config.num_instances, config.num_joints, device=config.device, dtype=torch.float32), + "env_ids": make_tensor_env_ids(config.num_instances, config.device), + "joint_ids": make_tensor_joint_ids(config.num_joints, config.device), + } + + +# --- Joint Velocity --- +def gen_joint_velocity_torch_list(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "velocity": torch.rand(config.num_instances, config.num_joints, device=config.device, dtype=torch.float32), + "env_ids": list(range(config.num_instances)), + "joint_ids": list(range(config.num_joints)), + } + + +def gen_joint_velocity_torch_tensor(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "velocity": torch.rand(config.num_instances, config.num_joints, device=config.device, dtype=torch.float32), + "env_ids": make_tensor_env_ids(config.num_instances, config.device), + "joint_ids": make_tensor_joint_ids(config.num_joints, config.device), + } + + +# --- Joint Stiffness --- +def gen_joint_stiffness_torch_list(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "stiffness": torch.rand(config.num_instances, config.num_joints, device=config.device, dtype=torch.float32), + "env_ids": list(range(config.num_instances)), + "joint_ids": list(range(config.num_joints)), + } + + +def gen_joint_stiffness_torch_tensor(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "stiffness": torch.rand(config.num_instances, config.num_joints, device=config.device, dtype=torch.float32), + "env_ids": make_tensor_env_ids(config.num_instances, config.device), + "joint_ids": make_tensor_joint_ids(config.num_joints, config.device), + } + + +# --- Joint Damping --- +def gen_joint_damping_torch_list(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "damping": torch.rand(config.num_instances, config.num_joints, device=config.device, dtype=torch.float32), + "env_ids": list(range(config.num_instances)), + "joint_ids": list(range(config.num_joints)), + } + + +def gen_joint_damping_torch_tensor(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "damping": torch.rand(config.num_instances, config.num_joints, device=config.device, dtype=torch.float32), + "env_ids": make_tensor_env_ids(config.num_instances, config.device), + "joint_ids": make_tensor_joint_ids(config.num_joints, config.device), + } + + +# --- Joint Position Limit --- +def gen_joint_position_limit_torch_list(config: MethodBenchmarkRunnerConfig) -> dict: + lower = torch.rand(config.num_instances, config.num_joints, 1, device=config.device, dtype=torch.float32) * -3.14 + upper = torch.rand(config.num_instances, config.num_joints, 1, device=config.device, dtype=torch.float32) * 3.14 + return { + "limits": torch.cat([lower, upper], dim=-1), + "env_ids": list(range(config.num_instances)), + "joint_ids": list(range(config.num_joints)), + } + + +def gen_joint_position_limit_torch_tensor(config: MethodBenchmarkRunnerConfig) -> dict: + lower = torch.rand(config.num_instances, config.num_joints, 1, device=config.device, dtype=torch.float32) * -3.14 + upper = torch.rand(config.num_instances, config.num_joints, 1, device=config.device, dtype=torch.float32) * 3.14 + return { + "limits": torch.cat([lower, upper], dim=-1), + "env_ids": make_tensor_env_ids(config.num_instances, config.device), + "joint_ids": make_tensor_joint_ids(config.num_joints, config.device), + } + + +# --- Joint Velocity Limit --- +def gen_joint_velocity_limit_torch_list(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "limits": torch.rand(config.num_instances, config.num_joints, device=config.device, dtype=torch.float32) * 10.0, + "env_ids": list(range(config.num_instances)), + "joint_ids": list(range(config.num_joints)), + } + + +def gen_joint_velocity_limit_torch_tensor(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "limits": torch.rand(config.num_instances, config.num_joints, device=config.device, dtype=torch.float32) * 10.0, + "env_ids": make_tensor_env_ids(config.num_instances, config.device), + "joint_ids": make_tensor_joint_ids(config.num_joints, config.device), + } + + +# --- Joint Effort Limit --- +def gen_joint_effort_limit_torch_list(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "limits": ( + torch.rand(config.num_instances, config.num_joints, device=config.device, dtype=torch.float32) * 100.0 + ), + "env_ids": list(range(config.num_instances)), + "joint_ids": list(range(config.num_joints)), + } + + +def gen_joint_effort_limit_torch_tensor(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "limits": ( + torch.rand(config.num_instances, config.num_joints, device=config.device, dtype=torch.float32) * 100.0 + ), + "env_ids": make_tensor_env_ids(config.num_instances, config.device), + "joint_ids": make_tensor_joint_ids(config.num_joints, config.device), + } + + +# --- Joint Armature --- +def gen_joint_armature_torch_list(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "armature": ( + torch.rand(config.num_instances, config.num_joints, device=config.device, dtype=torch.float32) * 0.1 + ), + "env_ids": list(range(config.num_instances)), + "joint_ids": list(range(config.num_joints)), + } + + +def gen_joint_armature_torch_tensor(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "armature": ( + torch.rand(config.num_instances, config.num_joints, device=config.device, dtype=torch.float32) * 0.1 + ), + "env_ids": make_tensor_env_ids(config.num_instances, config.device), + "joint_ids": make_tensor_joint_ids(config.num_joints, config.device), + } + + +# --- Joint Friction Coefficient --- +def gen_joint_friction_coefficient_torch_list(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "joint_friction_coeff": ( + torch.rand(config.num_instances, config.num_joints, device=config.device, dtype=torch.float32) * 0.5 + ), + "env_ids": list(range(config.num_instances)), + "joint_ids": list(range(config.num_joints)), + } + + +def gen_joint_friction_coefficient_torch_tensor(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "joint_friction_coeff": ( + torch.rand(config.num_instances, config.num_joints, device=config.device, dtype=torch.float32) * 0.5 + ), + "env_ids": make_tensor_env_ids(config.num_instances, config.device), + "joint_ids": make_tensor_joint_ids(config.num_joints, config.device), + } + + +# --- Set Joint Position Target --- +def gen_set_joint_position_target_torch_list(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "target": torch.rand(config.num_instances, config.num_joints, device=config.device, dtype=torch.float32), + "env_ids": list(range(config.num_instances)), + "joint_ids": list(range(config.num_joints)), + } + + +def gen_set_joint_position_target_torch_tensor(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "target": torch.rand(config.num_instances, config.num_joints, device=config.device, dtype=torch.float32), + "env_ids": make_tensor_env_ids(config.num_instances, config.device), + "joint_ids": make_tensor_joint_ids(config.num_joints, config.device), + } + + +# --- Set Joint Velocity Target --- +def gen_set_joint_velocity_target_torch_list(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "target": torch.rand(config.num_instances, config.num_joints, device=config.device, dtype=torch.float32), + "env_ids": list(range(config.num_instances)), + "joint_ids": list(range(config.num_joints)), + } + + +def gen_set_joint_velocity_target_torch_tensor(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "target": torch.rand(config.num_instances, config.num_joints, device=config.device, dtype=torch.float32), + "env_ids": make_tensor_env_ids(config.num_instances, config.device), + "joint_ids": make_tensor_joint_ids(config.num_joints, config.device), + } + + +# --- Set Joint Effort Target --- +def gen_set_joint_effort_target_torch_list(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "target": torch.rand(config.num_instances, config.num_joints, device=config.device, dtype=torch.float32), + "env_ids": list(range(config.num_instances)), + "joint_ids": list(range(config.num_joints)), + } + + +def gen_set_joint_effort_target_torch_tensor(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "target": torch.rand(config.num_instances, config.num_joints, device=config.device, dtype=torch.float32), + "env_ids": make_tensor_env_ids(config.num_instances, config.device), + "joint_ids": make_tensor_joint_ids(config.num_joints, config.device), + } + + +# --- Set Masses --- +def gen_set_masses_torch_list(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "masses": torch.rand(config.num_instances, config.num_bodies, device=config.device, dtype=torch.float32), + "env_ids": list(range(config.num_instances)), + "body_ids": list(range(config.num_bodies)), + } + + +def gen_set_masses_torch_tensor(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "masses": torch.rand(config.num_instances, config.num_bodies, device=config.device, dtype=torch.float32), + "env_ids": make_tensor_env_ids(config.num_instances, config.device), + "body_ids": make_tensor_body_ids(config.num_bodies, config.device), + } + + +# --- Set CoMs --- +def gen_set_coms_torch_list(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "coms": torch.rand(config.num_instances, config.num_bodies, 3, device=config.device, dtype=torch.float32), + "env_ids": list(range(config.num_instances)), + "body_ids": list(range(config.num_bodies)), + } + + +def gen_set_coms_torch_tensor(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "coms": torch.rand(config.num_instances, config.num_bodies, 3, device=config.device, dtype=torch.float32), + "env_ids": make_tensor_env_ids(config.num_instances, config.device), + "body_ids": make_tensor_body_ids(config.num_bodies, config.device), + } + + +# --- Set Inertias --- +def gen_set_inertias_torch_list(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "inertias": torch.rand(config.num_instances, config.num_bodies, 9, device=config.device, dtype=torch.float32), + "env_ids": list(range(config.num_instances)), + "body_ids": list(range(config.num_bodies)), + } + + +def gen_set_inertias_torch_tensor(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "inertias": torch.rand(config.num_instances, config.num_bodies, 9, device=config.device, dtype=torch.float32), + "env_ids": make_tensor_env_ids(config.num_instances, config.device), + "body_ids": make_tensor_body_ids(config.num_bodies, config.device), + } + + +# --- Set External Force and Torque --- +def gen_set_external_force_and_torque_torch_list(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "forces": torch.rand(config.num_instances, config.num_bodies, 3, device=config.device, dtype=torch.float32), + "torques": torch.rand(config.num_instances, config.num_bodies, 3, device=config.device, dtype=torch.float32), + "env_ids": list(range(config.num_instances)), + } + + +def gen_set_external_force_and_torque_torch_tensor(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "forces": torch.rand(config.num_instances, config.num_bodies, 3, device=config.device, dtype=torch.float32), + "torques": torch.rand(config.num_instances, config.num_bodies, 3, device=config.device, dtype=torch.float32), + "env_ids": make_tensor_env_ids(config.num_instances, config.device), + } + + +# ============================================================================= +# Warp Mask Input Generators (for _mask methods) +# ============================================================================= + + +def _env_mask(config: MethodBenchmarkRunnerConfig) -> wp.array: + return wp.ones((config.num_instances,), dtype=wp.bool, device=config.device) + + +def _joint_mask(config: MethodBenchmarkRunnerConfig) -> wp.array: + return wp.ones((config.num_joints,), dtype=wp.bool, device=config.device) + + +def _body_mask(config: MethodBenchmarkRunnerConfig) -> wp.array: + return wp.ones((config.num_bodies,), dtype=wp.bool, device=config.device) + + +# --- Root Link Pose (mask) --- +def gen_root_link_pose_warp_mask(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "root_pose": torch.rand(config.num_instances, 7, device=config.device, dtype=torch.float32), + "env_mask": _env_mask(config), + } + + +# --- Root COM Pose (mask) --- +def gen_root_com_pose_warp_mask(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "root_pose": torch.rand(config.num_instances, 7, device=config.device, dtype=torch.float32), + "env_mask": _env_mask(config), + } + + +# --- Root Link Velocity (mask) --- +def gen_root_link_velocity_warp_mask(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "root_velocity": torch.rand(config.num_instances, 6, device=config.device, dtype=torch.float32), + "env_mask": _env_mask(config), + } + + +# --- Root COM Velocity (mask) --- +def gen_root_com_velocity_warp_mask(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "root_velocity": torch.rand(config.num_instances, 6, device=config.device, dtype=torch.float32), + "env_mask": _env_mask(config), + } + + +# --- Joint State (mask) --- +def gen_joint_state_warp_mask(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "position": torch.rand(config.num_instances, config.num_joints, device=config.device, dtype=torch.float32), + "velocity": torch.rand(config.num_instances, config.num_joints, device=config.device, dtype=torch.float32), + "joint_mask": _joint_mask(config), + "env_mask": _env_mask(config), + } + + +# --- Joint Position (mask) --- +def gen_joint_position_warp_mask(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "position": torch.rand(config.num_instances, config.num_joints, device=config.device, dtype=torch.float32), + "joint_mask": _joint_mask(config), + "env_mask": _env_mask(config), + } + + +# --- Joint Velocity (mask) --- +def gen_joint_velocity_warp_mask(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "velocity": torch.rand(config.num_instances, config.num_joints, device=config.device, dtype=torch.float32), + "joint_mask": _joint_mask(config), + "env_mask": _env_mask(config), + } + + +# --- Joint Stiffness (mask) --- +def gen_joint_stiffness_warp_mask(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "stiffness": torch.rand(config.num_instances, config.num_joints, device=config.device, dtype=torch.float32), + "joint_mask": _joint_mask(config), + "env_mask": _env_mask(config), + } + + +# --- Joint Damping (mask) --- +def gen_joint_damping_warp_mask(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "damping": torch.rand(config.num_instances, config.num_joints, device=config.device, dtype=torch.float32), + "joint_mask": _joint_mask(config), + "env_mask": _env_mask(config), + } + + +# --- Joint Position Limit (mask) --- +def gen_joint_position_limit_warp_mask(config: MethodBenchmarkRunnerConfig) -> dict: + lower = torch.rand(config.num_instances, config.num_joints, 1, device=config.device, dtype=torch.float32) * -3.14 + upper = torch.rand(config.num_instances, config.num_joints, 1, device=config.device, dtype=torch.float32) * 3.14 + return { + "limits": torch.cat([lower, upper], dim=-1), + "joint_mask": _joint_mask(config), + "env_mask": _env_mask(config), + } + + +# --- Joint Velocity Limit (mask) --- +def gen_joint_velocity_limit_warp_mask(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "limits": torch.rand(config.num_instances, config.num_joints, device=config.device, dtype=torch.float32) * 10.0, + "joint_mask": _joint_mask(config), + "env_mask": _env_mask(config), + } + + +# --- Joint Effort Limit (mask) --- +def gen_joint_effort_limit_warp_mask(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "limits": ( + torch.rand(config.num_instances, config.num_joints, device=config.device, dtype=torch.float32) * 100.0 + ), + "joint_mask": _joint_mask(config), + "env_mask": _env_mask(config), + } + + +# --- Joint Armature (mask) --- +def gen_joint_armature_warp_mask(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "armature": ( + torch.rand(config.num_instances, config.num_joints, device=config.device, dtype=torch.float32) * 0.1 + ), + "joint_mask": _joint_mask(config), + "env_mask": _env_mask(config), + } + + +# --- Joint Friction Coefficient (mask) --- +def gen_joint_friction_coefficient_warp_mask(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "joint_friction_coeff": ( + torch.rand(config.num_instances, config.num_joints, device=config.device, dtype=torch.float32) * 0.5 + ), + "joint_mask": _joint_mask(config), + "env_mask": _env_mask(config), + } + + +# --- Set Joint Position Target (mask) --- +def gen_set_joint_position_target_warp_mask(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "target": torch.rand(config.num_instances, config.num_joints, device=config.device, dtype=torch.float32), + "joint_mask": _joint_mask(config), + "env_mask": _env_mask(config), + } + + +# --- Set Joint Velocity Target (mask) --- +def gen_set_joint_velocity_target_warp_mask(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "target": torch.rand(config.num_instances, config.num_joints, device=config.device, dtype=torch.float32), + "joint_mask": _joint_mask(config), + "env_mask": _env_mask(config), + } + + +# --- Set Joint Effort Target (mask) --- +def gen_set_joint_effort_target_warp_mask(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "target": torch.rand(config.num_instances, config.num_joints, device=config.device, dtype=torch.float32), + "joint_mask": _joint_mask(config), + "env_mask": _env_mask(config), + } + + +# --- Set Masses (mask) --- +def gen_set_masses_warp_mask(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "masses": torch.rand(config.num_instances, config.num_bodies, device=config.device, dtype=torch.float32), + "body_mask": _body_mask(config), + "env_mask": _env_mask(config), + } + + +# --- Set CoMs (mask) --- +def gen_set_coms_warp_mask(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "coms": torch.rand(config.num_instances, config.num_bodies, 3, device=config.device, dtype=torch.float32), + "body_mask": _body_mask(config), + "env_mask": _env_mask(config), + } + + +# --- Set Inertias (mask) --- +def gen_set_inertias_warp_mask(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "inertias": torch.rand(config.num_instances, config.num_bodies, 9, device=config.device, dtype=torch.float32), + "body_mask": _body_mask(config), + "env_mask": _env_mask(config), + } + + +# ============================================================================= +# Benchmarks +# ============================================================================= + +BENCHMARKS = [ + # --- Root State (Deprecated, no _mask equivalent) --- + MethodBenchmarkDefinition( + name="write_root_state_to_sim", + method_name="write_root_state_to_sim", + input_generators={ + "torch_list": gen_root_state_torch_list, + "torch_tensor": gen_root_state_torch_tensor, + }, + category="root_state", + ), + MethodBenchmarkDefinition( + name="write_root_com_state_to_sim", + method_name="write_root_com_state_to_sim", + input_generators={ + "torch_list": gen_root_com_state_torch_list, + "torch_tensor": gen_root_com_state_torch_tensor, + }, + category="root_state", + ), + MethodBenchmarkDefinition( + name="write_root_link_state_to_sim", + method_name="write_root_link_state_to_sim", + input_generators={ + "torch_list": gen_root_link_state_torch_list, + "torch_tensor": gen_root_link_state_torch_tensor, + }, + category="root_state", + ), + # --- Root Pose / Velocity --- + MethodBenchmarkDefinition( + name="write_root_link_pose_to_sim", + method_name="write_root_link_pose_to_sim", + input_generators={ + "torch_list": gen_root_link_pose_torch_list, + "torch_tensor": gen_root_link_pose_torch_tensor, + }, + category="root_pose", + ), + MethodBenchmarkDefinition( + name="write_root_link_pose_to_sim_mask", + method_name="write_root_link_pose_to_sim_mask", + input_generators={"warp_mask": gen_root_link_pose_warp_mask}, + category="root_pose", + ), + MethodBenchmarkDefinition( + name="write_root_com_pose_to_sim", + method_name="write_root_com_pose_to_sim", + input_generators={ + "torch_list": gen_root_com_pose_torch_list, + "torch_tensor": gen_root_com_pose_torch_tensor, + }, + category="root_pose", + ), + MethodBenchmarkDefinition( + name="write_root_com_pose_to_sim_mask", + method_name="write_root_com_pose_to_sim_mask", + input_generators={"warp_mask": gen_root_com_pose_warp_mask}, + category="root_pose", + ), + MethodBenchmarkDefinition( + name="write_root_link_velocity_to_sim", + method_name="write_root_link_velocity_to_sim", + input_generators={ + "torch_list": gen_root_link_velocity_torch_list, + "torch_tensor": gen_root_link_velocity_torch_tensor, + }, + category="root_velocity", + ), + MethodBenchmarkDefinition( + name="write_root_link_velocity_to_sim_mask", + method_name="write_root_link_velocity_to_sim_mask", + input_generators={"warp_mask": gen_root_link_velocity_warp_mask}, + category="root_velocity", + ), + MethodBenchmarkDefinition( + name="write_root_com_velocity_to_sim", + method_name="write_root_com_velocity_to_sim", + input_generators={ + "torch_list": gen_root_com_velocity_torch_list, + "torch_tensor": gen_root_com_velocity_torch_tensor, + }, + category="root_velocity", + ), + MethodBenchmarkDefinition( + name="write_root_com_velocity_to_sim_mask", + method_name="write_root_com_velocity_to_sim_mask", + input_generators={"warp_mask": gen_root_com_velocity_warp_mask}, + category="root_velocity", + ), + # --- Joint State --- + MethodBenchmarkDefinition( + name="write_joint_state_to_sim", + method_name="write_joint_state_to_sim", + input_generators={ + "torch_list": gen_joint_state_torch_list, + "torch_tensor": gen_joint_state_torch_tensor, + }, + category="joint_state", + ), + MethodBenchmarkDefinition( + name="write_joint_state_to_sim_mask", + method_name="write_joint_state_to_sim_mask", + input_generators={"warp_mask": gen_joint_state_warp_mask}, + category="joint_state", + ), + MethodBenchmarkDefinition( + name="write_joint_position_to_sim", + method_name="write_joint_position_to_sim", + input_generators={ + "torch_list": gen_joint_position_torch_list, + "torch_tensor": gen_joint_position_torch_tensor, + }, + category="joint_state", + ), + MethodBenchmarkDefinition( + name="write_joint_position_to_sim_mask", + method_name="write_joint_position_to_sim_mask", + input_generators={"warp_mask": gen_joint_position_warp_mask}, + category="joint_state", + ), + MethodBenchmarkDefinition( + name="write_joint_velocity_to_sim", + method_name="write_joint_velocity_to_sim", + input_generators={ + "torch_list": gen_joint_velocity_torch_list, + "torch_tensor": gen_joint_velocity_torch_tensor, + }, + category="joint_state", + ), + MethodBenchmarkDefinition( + name="write_joint_velocity_to_sim_mask", + method_name="write_joint_velocity_to_sim_mask", + input_generators={"warp_mask": gen_joint_velocity_warp_mask}, + category="joint_state", + ), + # --- Joint Params --- + MethodBenchmarkDefinition( + name="write_joint_stiffness_to_sim", + method_name="write_joint_stiffness_to_sim", + input_generators={ + "torch_list": gen_joint_stiffness_torch_list, + "torch_tensor": gen_joint_stiffness_torch_tensor, + }, + category="joint_params", + ), + MethodBenchmarkDefinition( + name="write_joint_stiffness_to_sim_mask", + method_name="write_joint_stiffness_to_sim_mask", + input_generators={"warp_mask": gen_joint_stiffness_warp_mask}, + category="joint_params", + ), + MethodBenchmarkDefinition( + name="write_joint_damping_to_sim", + method_name="write_joint_damping_to_sim", + input_generators={ + "torch_list": gen_joint_damping_torch_list, + "torch_tensor": gen_joint_damping_torch_tensor, + }, + category="joint_params", + ), + MethodBenchmarkDefinition( + name="write_joint_damping_to_sim_mask", + method_name="write_joint_damping_to_sim_mask", + input_generators={"warp_mask": gen_joint_damping_warp_mask}, + category="joint_params", + ), + MethodBenchmarkDefinition( + name="write_joint_position_limit_to_sim", + method_name="write_joint_position_limit_to_sim", + input_generators={ + "torch_list": gen_joint_position_limit_torch_list, + "torch_tensor": gen_joint_position_limit_torch_tensor, + }, + category="joint_params", + ), + MethodBenchmarkDefinition( + name="write_joint_position_limit_to_sim_mask", + method_name="write_joint_position_limit_to_sim_mask", + input_generators={"warp_mask": gen_joint_position_limit_warp_mask}, + category="joint_params", + ), + MethodBenchmarkDefinition( + name="write_joint_velocity_limit_to_sim", + method_name="write_joint_velocity_limit_to_sim", + input_generators={ + "torch_list": gen_joint_velocity_limit_torch_list, + "torch_tensor": gen_joint_velocity_limit_torch_tensor, + }, + category="joint_params", + ), + MethodBenchmarkDefinition( + name="write_joint_velocity_limit_to_sim_mask", + method_name="write_joint_velocity_limit_to_sim_mask", + input_generators={"warp_mask": gen_joint_velocity_limit_warp_mask}, + category="joint_params", + ), + MethodBenchmarkDefinition( + name="write_joint_effort_limit_to_sim", + method_name="write_joint_effort_limit_to_sim", + input_generators={ + "torch_list": gen_joint_effort_limit_torch_list, + "torch_tensor": gen_joint_effort_limit_torch_tensor, + }, + category="joint_params", + ), + MethodBenchmarkDefinition( + name="write_joint_effort_limit_to_sim_mask", + method_name="write_joint_effort_limit_to_sim_mask", + input_generators={"warp_mask": gen_joint_effort_limit_warp_mask}, + category="joint_params", + ), + MethodBenchmarkDefinition( + name="write_joint_armature_to_sim", + method_name="write_joint_armature_to_sim", + input_generators={ + "torch_list": gen_joint_armature_torch_list, + "torch_tensor": gen_joint_armature_torch_tensor, + }, + category="joint_params", + ), + MethodBenchmarkDefinition( + name="write_joint_armature_to_sim_mask", + method_name="write_joint_armature_to_sim_mask", + input_generators={"warp_mask": gen_joint_armature_warp_mask}, + category="joint_params", + ), + MethodBenchmarkDefinition( + name="write_joint_friction_coefficient_to_sim", + method_name="write_joint_friction_coefficient_to_sim", + input_generators={ + "torch_list": gen_joint_friction_coefficient_torch_list, + "torch_tensor": gen_joint_friction_coefficient_torch_tensor, + }, + category="joint_params", + ), + MethodBenchmarkDefinition( + name="write_joint_friction_coefficient_to_sim_mask", + method_name="write_joint_friction_coefficient_to_sim_mask", + input_generators={"warp_mask": gen_joint_friction_coefficient_warp_mask}, + category="joint_params", + ), + # --- Joint Targets --- + MethodBenchmarkDefinition( + name="set_joint_position_target", + method_name="set_joint_position_target", + input_generators={ + "torch_list": gen_set_joint_position_target_torch_list, + "torch_tensor": gen_set_joint_position_target_torch_tensor, + }, + category="joint_targets", + ), + MethodBenchmarkDefinition( + name="set_joint_position_target_mask", + method_name="set_joint_position_target_mask", + input_generators={"warp_mask": gen_set_joint_position_target_warp_mask}, + category="joint_targets", + ), + MethodBenchmarkDefinition( + name="set_joint_velocity_target", + method_name="set_joint_velocity_target", + input_generators={ + "torch_list": gen_set_joint_velocity_target_torch_list, + "torch_tensor": gen_set_joint_velocity_target_torch_tensor, + }, + category="joint_targets", + ), + MethodBenchmarkDefinition( + name="set_joint_velocity_target_mask", + method_name="set_joint_velocity_target_mask", + input_generators={"warp_mask": gen_set_joint_velocity_target_warp_mask}, + category="joint_targets", + ), + MethodBenchmarkDefinition( + name="set_joint_effort_target", + method_name="set_joint_effort_target", + input_generators={ + "torch_list": gen_set_joint_effort_target_torch_list, + "torch_tensor": gen_set_joint_effort_target_torch_tensor, + }, + category="joint_targets", + ), + MethodBenchmarkDefinition( + name="set_joint_effort_target_mask", + method_name="set_joint_effort_target_mask", + input_generators={"warp_mask": gen_set_joint_effort_target_warp_mask}, + category="joint_targets", + ), + # --- Body Properties --- + MethodBenchmarkDefinition( + name="set_masses", + method_name="set_masses", + input_generators={ + "torch_list": gen_set_masses_torch_list, + "torch_tensor": gen_set_masses_torch_tensor, + }, + category="body_props", + ), + MethodBenchmarkDefinition( + name="set_masses_mask", + method_name="set_masses_mask", + input_generators={"warp_mask": gen_set_masses_warp_mask}, + category="body_props", + ), + MethodBenchmarkDefinition( + name="set_coms", + method_name="set_coms", + input_generators={ + "torch_list": gen_set_coms_torch_list, + "torch_tensor": gen_set_coms_torch_tensor, + }, + category="body_props", + ), + MethodBenchmarkDefinition( + name="set_coms_mask", + method_name="set_coms_mask", + input_generators={"warp_mask": gen_set_coms_warp_mask}, + category="body_props", + ), + MethodBenchmarkDefinition( + name="set_inertias", + method_name="set_inertias", + input_generators={ + "torch_list": gen_set_inertias_torch_list, + "torch_tensor": gen_set_inertias_torch_tensor, + }, + category="body_props", + ), + MethodBenchmarkDefinition( + name="set_inertias_mask", + method_name="set_inertias_mask", + input_generators={"warp_mask": gen_set_inertias_warp_mask}, + category="body_props", + ), + MethodBenchmarkDefinition( + name="set_external_force_and_torque", + method_name="set_external_force_and_torque", + input_generators={ + "torch_list": gen_set_external_force_and_torque_torch_list, + "torch_tensor": gen_set_external_force_and_torque_torch_tensor, + }, + category="external_wrench", + ), +] + + +# ============================================================================= +# Fill-Ratio Benchmarks (5%, 95%, 100% of env_ids filled) +# ============================================================================= + +FILL_RATIOS = {"5pct": 0.05, "95pct": 0.95, "100pct": 1.0} + + +def _make_fill_ratio_generator(base_gen_fn, fill_ratio): + """Create a generator that subsets env_ids to a given fill ratio. + + Only env_ids are subsetted — joint_ids and body_ids remain full-range. + Data tensors keyed on env count are sliced to match. + """ + + def generator(config): + n = max(1, int(config.num_instances * fill_ratio)) + base_inputs = base_gen_fn(config) + inputs = {} + for key, val in base_inputs.items(): + if key == "env_ids": + inputs[key] = ( + torch.randperm(config.num_instances, device=config.device)[:n].sort().values.to(torch.int32) + ) + elif isinstance(val, torch.Tensor) and val.dim() >= 1 and val.shape[0] == config.num_instances: + inputs[key] = val[:n] + else: + inputs[key] = val + return inputs + + return generator + + +def _make_fill_ratio_mask_generator(base_mask_gen_fn, fill_ratio): + """Create a mask generator with a given fill ratio. + + Sets a random subset of the env_mask entries to True. Data stays full-sized (mask methods expect full data). + """ + + def generator(config): + base_inputs = base_mask_gen_fn(config) + n = max(1, int(config.num_instances * fill_ratio)) + # Create a mask with n random entries set to True + perm = torch.randperm(config.num_instances, device=config.device) + mask_tensor = torch.zeros(config.num_instances, dtype=torch.bool, device=config.device) + mask_tensor[perm[:n]] = True + base_inputs["env_mask"] = wp.from_torch(mask_tensor, dtype=wp.bool) + return base_inputs + + return generator + + +def _build_fill_benchmarks(): + """Auto-generate fill-ratio benchmark definitions from existing generators.""" + fill_benchmarks = [] + for bm in BENCHMARKS: + generators = {} + # Add tensor fill variants from torch_tensor generators + if "torch_tensor" in bm.input_generators: + base_gen = bm.input_generators["torch_tensor"] + for suffix, ratio in FILL_RATIOS.items(): + generators[f"tensor_{suffix}"] = _make_fill_ratio_generator(base_gen, ratio) + # Add mask fill variants from warp_mask generators + if "warp_mask" in bm.input_generators: + base_gen = bm.input_generators["warp_mask"] + for suffix, ratio in FILL_RATIOS.items(): + generators[f"mask_{suffix}"] = _make_fill_ratio_mask_generator(base_gen, ratio) + if generators: + fill_benchmarks.append( + MethodBenchmarkDefinition( + name=bm.name, + method_name=bm.method_name, + input_generators=generators, + category=f"{bm.category}_fill", + ) + ) + return fill_benchmarks + + +FILL_BENCHMARKS = _build_fill_benchmarks() + + +def main(): + """Main entry point for the benchmarking script.""" + config = MethodBenchmarkRunnerConfig( + num_iterations=args.num_iterations, + warmup_steps=args.warmup_steps, + num_instances=args.num_instances, + num_bodies=args.num_bodies, + num_joints=args.num_joints, + device=args.device, + mode=args.mode, + ) + + # Patch the NewtonManager for both articulation and articulation_data modules + with ( + create_mock_newton_manager( + "isaaclab_newton.assets.articulation.articulation_data.SimulationManager", + gravity=(0.0, 0.0, -9.81), + ), + create_mock_newton_manager( + "isaaclab_newton.assets.articulation.articulation.SimulationManager", + gravity=(0.0, 0.0, -9.81), + ), + ): + # Create the test articulation + articulation, _ = create_test_articulation( + num_instances=config.num_instances, + num_bodies=config.num_bodies, + num_joints=config.num_joints, + device=config.device, + ) + + print( + f"Benchmarking Articulation (Newton) with {config.num_instances} instances, {config.num_bodies} bodies," + f" {config.num_joints} joints..." + ) + + # Create runner and run benchmarks + runner = MethodBenchmarkRunner( + benchmark_name="newton_articulation_benchmark", + config=config, + backend_type=args.backend, + output_path=args.output_dir, + use_recorders=True, + ) + + runner.run_benchmarks(BENCHMARKS, articulation) + + print("\n" + "=" * 80) + print("Fill-Ratio Benchmarks (env_ids at 5%, 95%, 100% fill)") + print("=" * 80) + + runner.run_benchmarks(FILL_BENCHMARKS, articulation) + runner.finalize() + + # Close the simulation app + simulation_app.close() + + +if __name__ == "__main__": + main() diff --git a/source/isaaclab_newton/benchmark/assets/benchmark_articulation_data.py b/source/isaaclab_newton/benchmark/assets/benchmark_articulation_data.py new file mode 100644 index 000000000000..59fb541767bc --- /dev/null +++ b/source/isaaclab_newton/benchmark/assets/benchmark_articulation_data.py @@ -0,0 +1,342 @@ +# 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 + +"""Micro-benchmarking framework for ArticulationData class (Newton backend). + +This module provides a benchmarking framework to measure the performance of all properties +in the Newton ArticulationData class. Each property is run multiple times with randomized mock data, +and timing statistics (mean and standard deviation) are reported. + +Usage: + python benchmark_articulation_data.py [--num_iterations N] [--warmup_steps W] + [--num_instances I] [--num_bodies B] [--num_joints J] + +Example: + python benchmark_articulation_data.py --num_iterations 10000 --warmup_steps 10 +""" + +from __future__ import annotations + +"""Launch Isaac Sim Simulator first.""" + +import argparse + +from isaaclab.app import AppLauncher + +# add argparse arguments +parser = argparse.ArgumentParser( + description="Micro-benchmarking framework for ArticulationData class (Newton backend).", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, +) +parser.add_argument("--num_iterations", type=int, default=1000, help="Number of iterations") +parser.add_argument("--warmup_steps", type=int, default=10, help="Number of warmup steps") +parser.add_argument("--num_instances", type=int, default=4096, help="Number of instances") +parser.add_argument("--num_bodies", type=int, default=12, help="Number of bodies") +parser.add_argument("--num_joints", type=int, default=11, help="Number of joints") +parser.add_argument("--output_dir", type=str, default=".", help="Output directory for results") +parser.add_argument("--backend", type=str, default="json", choices=["json", "osmo", "omniperf"], help="Metrics backend") + +# append AppLauncher cli args +AppLauncher.add_app_launcher_args(parser) +# parse the arguments +args = parser.parse_args() + +# launch omniverse app +app_launcher = AppLauncher(headless=True, args=args) +simulation_app = app_launcher.app + +"""Rest everything follows.""" + +import warnings + +import numpy as np +import warp as wp +from isaaclab_newton.test.mock_interfaces import MockNewtonArticulationView, create_mock_newton_manager + +from isaaclab.test.benchmark import MethodBenchmarkRunner, MethodBenchmarkRunnerConfig + +# Suppress deprecation warnings during benchmarking +warnings.filterwarnings("ignore", category=DeprecationWarning) +warnings.filterwarnings("ignore", category=UserWarning) + + +# ============================================================================= +# Skip Lists +# ============================================================================= + +# List of deprecated properties - skip these +DEPRECATED_PROPERTIES = { + "root_pose_w", + "root_pos_w", + "root_quat_w", + "root_vel_w", + "root_lin_vel_w", + "root_ang_vel_w", + "root_lin_vel_b", + "root_ang_vel_b", + "body_pose_w", + "body_pos_w", + "body_quat_w", + "body_vel_w", + "body_lin_vel_w", + "body_ang_vel_w", + "body_acc_w", + "body_lin_acc_w", + "body_ang_acc_w", + "com_pos_b", + "com_quat_b", + "joint_limits", + "joint_friction", + "fixed_tendon_limit", + "applied_torque", + "computed_torque", + "joint_dynamic_friction", + "joint_effort_target", + "joint_viscous_friction", + "joint_velocity_limits", + # Combined state properties marked as deprecated + "root_state_w", + "root_link_state_w", + "root_com_state_w", + "body_state_w", + "body_link_state_w", + "body_com_state_w", +} + +# List of properties that raise NotImplementedError - skip these +NOT_IMPLEMENTED_PROPERTIES = { + "fixed_tendon_stiffness", + "fixed_tendon_damping", + "fixed_tendon_limit_stiffness", + "fixed_tendon_rest_length", + "fixed_tendon_offset", + "fixed_tendon_pos_limits", + "spatial_tendon_stiffness", + "spatial_tendon_damping", + "spatial_tendon_limit_stiffness", + "spatial_tendon_offset", + "body_incoming_joint_wrench_b", +} + +# Removed default_* properties that raise RuntimeError +REMOVED_PROPERTIES = { + "default_fixed_tendon_damping", + "default_fixed_tendon_limit", + "default_fixed_tendon_limit_stiffness", + "default_fixed_tendon_offset", + "default_fixed_tendon_pos_limits", + "default_fixed_tendon_rest_length", + "default_fixed_tendon_stiffness", + "default_inertia", + "default_joint_armature", + "default_joint_damping", + "default_joint_dynamic_friction_coeff", + "default_joint_friction", + "default_joint_friction_coeff", + "default_joint_limits", + "default_joint_pos_limits", + "default_joint_stiffness", + "default_joint_viscous_friction_coeff", + "default_mass", + "default_spatial_tendon_damping", + "default_spatial_tendon_limit_stiffness", + "default_spatial_tendon_offset", + "default_spatial_tendon_stiffness", +} + +# Private/internal properties and methods to skip +INTERNAL_PROPERTIES = { + "_create_simulation_bindings", + "_create_buffers", + "update", + "is_primed", + "device", + "body_names", + "joint_names", + "fixed_tendon_names", + "spatial_tendon_names", + "GRAVITY_VEC_W", + "GRAVITY_VEC_W_TORCH", + "FORWARD_VEC_B", + "FORWARD_VEC_B_TORCH", + "ALL_ENV_MASK", + "ALL_BODY_MASK", + "ALL_JOINT_MASK", + "ENV_MASK", + "BODY_MASK", + "JOINT_MASK", +} + +# Dependency mapping for properties +PROPERTY_DEPENDENCIES = { + "root_link_lin_vel_w": ["root_link_vel_w"], + "root_link_ang_vel_w": ["root_link_vel_w"], + "root_link_lin_vel_b": ["root_link_vel_b"], + "root_link_ang_vel_b": ["root_link_vel_b"], + "root_com_pos_w": ["root_com_pose_w"], + "root_com_quat_w": ["root_com_pose_w"], + "root_com_lin_vel_b": ["root_com_vel_b"], + "root_com_ang_vel_b": ["root_com_vel_b"], + "root_com_lin_vel_w": ["root_com_vel_w"], + "root_com_ang_vel_w": ["root_com_vel_w"], + "root_link_pos_w": ["root_link_pose_w"], + "root_link_quat_w": ["root_link_pose_w"], + "body_link_lin_vel_w": ["body_link_vel_w"], + "body_link_ang_vel_w": ["body_link_vel_w"], + "body_link_pos_w": ["body_link_pose_w"], + "body_link_quat_w": ["body_link_pose_w"], + "body_com_pos_w": ["body_com_pose_w"], + "body_com_quat_w": ["body_com_pose_w"], + "body_com_lin_vel_w": ["body_com_vel_w"], + "body_com_ang_vel_w": ["body_com_vel_w"], + "body_com_lin_acc_w": ["body_com_acc_w"], + "body_com_ang_acc_w": ["body_com_acc_w"], + "body_com_quat_b": ["body_com_pose_b"], +} + + +# ============================================================================= +# Benchmark Functions +# ============================================================================= + + +def get_benchmarkable_properties(articulation_data) -> list[str]: + """Get list of properties that can be benchmarked.""" + all_properties = [] + + for name in dir(articulation_data): + if name.startswith("_"): + continue + if name in DEPRECATED_PROPERTIES: + continue + if name in NOT_IMPLEMENTED_PROPERTIES: + continue + if name in REMOVED_PROPERTIES: + continue + if name in INTERNAL_PROPERTIES: + continue + + try: + attr = getattr(type(articulation_data), name, None) + if isinstance(attr, property): + all_properties.append(name) + except Exception: + pass + + return sorted(all_properties) + + +def setup_mock_environment(config: MethodBenchmarkRunnerConfig) -> MockNewtonArticulationView: + """Set up the mock environment for benchmarking.""" + mock_view = MockNewtonArticulationView( + num_instances=config.num_instances, + num_bodies=config.num_bodies, + num_joints=config.num_joints, + device=config.device, + ) + return mock_view + + +def main(): + """Main entry point for the benchmarking script.""" + config = MethodBenchmarkRunnerConfig( + num_iterations=args.num_iterations, + warmup_steps=args.warmup_steps, + num_instances=args.num_instances, + num_bodies=args.num_bodies, + num_joints=args.num_joints, + device=args.device, + ) + + # Patch the NewtonManager for the articulation_data module + with create_mock_newton_manager( + "isaaclab_newton.assets.articulation.articulation_data.SimulationManager", + gravity=(0.0, 0.0, -9.81), + ): + # Setup mock environment + mock_view = setup_mock_environment(config) + mock_view.set_random_mock_data() + + # Import ArticulationData inside the patch context + from isaaclab_newton.assets.articulation.articulation_data import ArticulationData + + # Create ArticulationData instance + articulation_data = ArticulationData(mock_view, config.device) + + # Get list of properties to benchmark + properties = get_benchmarkable_properties(articulation_data) + + # Generator that updates mock data and invalidates timestamp + def gen_mock_data(cfg: MethodBenchmarkRunnerConfig) -> dict: + N, L, J = cfg.num_instances, cfg.num_bodies, cfg.num_joints + dev = cfg.device + + # Update root transforms + root_tf_np = np.random.randn(N, 1, 7).astype(np.float32) + root_tf_np[..., 3:7] /= np.linalg.norm(root_tf_np[..., 3:7], axis=-1, keepdims=True) + mock_view.set_mock_root_transforms(wp.array(root_tf_np, dtype=wp.transformf, device=dev)) + + # Update root velocities + root_vel_np = np.random.randn(N, 1, 6).astype(np.float32) + mock_view.set_mock_root_velocities(wp.array(root_vel_np, dtype=wp.spatial_vectorf, device=dev)) + + # Update link transforms + link_tf_np = np.random.randn(N, 1, L, 7).astype(np.float32) + link_tf_np[..., 3:7] /= np.linalg.norm(link_tf_np[..., 3:7], axis=-1, keepdims=True) + mock_view.set_mock_link_transforms(wp.array(link_tf_np, dtype=wp.transformf, device=dev)) + + # Update link velocities + link_vel_np = np.random.randn(N, 1, L, 6).astype(np.float32) + mock_view.set_mock_link_velocities(wp.array(link_vel_np, dtype=wp.spatial_vectorf, device=dev)) + + # Update DOF state + mock_view.set_mock_dof_positions( + wp.array(np.random.randn(N, 1, J).astype(np.float32), dtype=wp.float32, device=dev) + ) + mock_view.set_mock_dof_velocities( + wp.array(np.random.randn(N, 1, J).astype(np.float32), dtype=wp.float32, device=dev) + ) + + # Update body properties + mock_view.set_mock_coms( + wp.array(np.random.randn(N, 1, L, 3).astype(np.float32), dtype=wp.vec3f, device=dev) + ) + mock_view.set_mock_inertias( + wp.array(np.random.randn(N, 1, L, 9).astype(np.float32), dtype=wp.mat33f, device=dev) + ) + mock_view.set_mock_masses( + wp.array((np.random.rand(N, 1, L) * 10 + 0.1).astype(np.float32), dtype=wp.float32, device=dev) + ) + + # Invalidate timestamp to trigger recomputation + articulation_data._sim_timestamp += 1.0 + return {} + + # Create runner + runner = MethodBenchmarkRunner( + benchmark_name="newton_articulation_data_benchmark", + config=config, + backend_type=args.backend, + output_path=args.output_dir, + use_recorders=True, + ) + + # Run property benchmarks + runner.run_property_benchmarks( + target_data=articulation_data, + properties=properties, + gen_mock_data=gen_mock_data, + dependencies=PROPERTY_DEPENDENCIES, + category="property", + ) + + runner.finalize() + + # Close the simulation app + simulation_app.close() + + +if __name__ == "__main__": + main() diff --git a/source/isaaclab_newton/benchmark/assets/benchmark_rigid_object.py b/source/isaaclab_newton/benchmark/assets/benchmark_rigid_object.py new file mode 100644 index 000000000000..e0df122d4f00 --- /dev/null +++ b/source/isaaclab_newton/benchmark/assets/benchmark_rigid_object.py @@ -0,0 +1,623 @@ +# 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 + +"""Micro-benchmarking framework for RigidObject class (Newton backend). + +This module provides a benchmarking framework to measure the performance of setter and writer +methods in the RigidObject class. Each method is benchmarked under three scenarios: + +1. **Torch List**: Inputs are PyTorch tensors with list indices (via deprecated wrappers). +2. **Torch Tensor**: Inputs are PyTorch tensors with tensor indices (via deprecated wrappers). +3. **Warp Mask**: Inputs are warp arrays with boolean masks (via ``_mask`` methods). + +Usage: + python benchmark_rigid_object.py [--num_iterations N] [--warmup_steps W] + [--num_instances I] [--num_bodies B] + +Example: + python benchmark_rigid_object.py --num_iterations 1000 --warmup_steps 10 + python benchmark_rigid_object.py --mode torch_list # Only run list-based benchmarks + python benchmark_rigid_object.py --mode warp_mask # Only run warp mask benchmarks +""" + +from __future__ import annotations + +"""Launch Isaac Sim Simulator first.""" + +import argparse + +from isaaclab.app import AppLauncher + +# add argparse arguments +parser = argparse.ArgumentParser(description="Benchmark RigidObject methods (Newton backend).") +parser.add_argument("--num_iterations", type=int, default=1000, help="Number of iterations") +parser.add_argument("--warmup_steps", type=int, default=10, help="Number of warmup steps") +parser.add_argument("--num_instances", type=int, default=4096, help="Number of instances") +parser.add_argument("--num_bodies", type=int, default=1, help="Number of bodies") +parser.add_argument("--mode", type=str, default="all", help="Benchmark mode (all, torch_list, torch_tensor, warp_mask)") +parser.add_argument("--output_dir", type=str, default=".", help="Output directory for results") +parser.add_argument("--backend", type=str, default="json", choices=["json", "osmo", "omniperf"], help="Metrics backend") +parser.add_argument("--no_shape_checks", action="store_true", help="Disable shape/dtype assertions") + +# append AppLauncher cli args +AppLauncher.add_app_launcher_args(parser) +# parse the arguments +args = parser.parse_args() + +# launch omniverse app +app_launcher = AppLauncher(headless=True, args=args) +simulation_app = app_launcher.app + +"""Rest everything follows.""" + +import logging +import warnings + +import numpy as np +import torch +import warp as wp +from isaaclab_newton.test.mock_interfaces import ( + MockNewtonArticulationView, + MockWrenchComposer, + create_mock_newton_manager, +) + +from isaaclab.assets.rigid_object.rigid_object_cfg import RigidObjectCfg +from isaaclab.test.benchmark import MethodBenchmarkDefinition, MethodBenchmarkRunner, MethodBenchmarkRunnerConfig + +# Suppress deprecation warnings during benchmarking +warnings.filterwarnings("ignore", category=DeprecationWarning) +warnings.filterwarnings("ignore", category=UserWarning) + +# Also suppress logging warnings +logging.getLogger("isaaclab_newton").setLevel(logging.ERROR) +logging.getLogger("isaaclab").setLevel(logging.ERROR) + + +# ============================================================================= +# Index Helpers +# ============================================================================= + + +def make_tensor_env_ids(num_instances: int, device: str) -> torch.Tensor: + """Create a tensor of environment IDs.""" + return torch.arange(num_instances, dtype=torch.int32, device=device) + + +def make_tensor_body_ids(num_bodies: int, device: str) -> torch.Tensor: + """Create a tensor of body IDs.""" + return torch.arange(num_bodies, dtype=torch.int32, device=device) + + +# ============================================================================= +# Test RigidObject Factory +# ============================================================================= + + +def create_test_rigid_object( + num_instances: int = 2, + num_bodies: int = 1, + device: str = "cuda:0", +): + """Create a test RigidObject instance with mocked dependencies.""" + from isaaclab_newton.assets.rigid_object.rigid_object import RigidObject + + body_names = [f"body_{i}" for i in range(num_bodies)] + + rigid_object = object.__new__(RigidObject) + + rigid_object.cfg = RigidObjectCfg( + prim_path="/World/Object", + ) + + # Create Newton mock view + mock_view = MockNewtonArticulationView( + num_instances=num_instances, + num_bodies=num_bodies, + num_joints=0, + device=device, + joint_names=[], + body_names=body_names, + ) + mock_view.set_random_mock_data() + mock_view._noop_setters = True + + object.__setattr__(rigid_object, "_root_view", mock_view) + object.__setattr__(rigid_object, "_device", device) + object.__setattr__(rigid_object, "_check_shapes", not args.no_shape_checks) + + # Create RigidObjectData instance (NewtonManager already mocked at call site) + from isaaclab_newton.assets.rigid_object.rigid_object_data import RigidObjectData + + data = RigidObjectData(mock_view, device) + object.__setattr__(rigid_object, "_data", data) + + # Create mock wrench composers + mock_inst_wrench = MockWrenchComposer(rigid_object) + mock_perm_wrench = MockWrenchComposer(rigid_object) + object.__setattr__(rigid_object, "_instantaneous_wrench_composer", mock_inst_wrench) + object.__setattr__(rigid_object, "_permanent_wrench_composer", mock_perm_wrench) + + # Set up other required attributes + object.__setattr__(rigid_object, "actuators", {}) + object.__setattr__(rigid_object, "_ALL_INDICES", wp.array(np.arange(num_instances, dtype=np.int32), device=device)) + object.__setattr__( + rigid_object, "_ALL_BODY_INDICES", wp.array(np.arange(num_bodies, dtype=np.int32), device=device) + ) + object.__setattr__(rigid_object, "_ALL_ENV_MASK", wp.ones((num_instances,), dtype=wp.bool, device=device)) + object.__setattr__(rigid_object, "_ALL_BODY_MASK", wp.ones((num_bodies,), dtype=wp.bool, device=device)) + + # set information about rigid body into data + data.body_names = body_names + + return rigid_object, mock_view + + +# ============================================================================= +# Input Generators (Torch-only for Newton backend) +# ============================================================================= + + +# --- Root Link Pose --- +def gen_root_link_pose_torch_list(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "root_pose": torch.rand(config.num_instances, 7, device=config.device, dtype=torch.float32), + "env_ids": list(range(config.num_instances)), + } + + +def gen_root_link_pose_torch_tensor(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "root_pose": torch.rand(config.num_instances, 7, device=config.device, dtype=torch.float32), + "env_ids": make_tensor_env_ids(config.num_instances, config.device), + } + + +# --- Root COM Pose --- +def gen_root_com_pose_torch_list(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "root_pose": torch.rand(config.num_instances, 7, device=config.device, dtype=torch.float32), + "env_ids": list(range(config.num_instances)), + } + + +def gen_root_com_pose_torch_tensor(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "root_pose": torch.rand(config.num_instances, 7, device=config.device, dtype=torch.float32), + "env_ids": make_tensor_env_ids(config.num_instances, config.device), + } + + +# --- Root Link Velocity --- +def gen_root_link_velocity_torch_list(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "root_velocity": torch.rand(config.num_instances, 6, device=config.device, dtype=torch.float32), + "env_ids": list(range(config.num_instances)), + } + + +def gen_root_link_velocity_torch_tensor(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "root_velocity": torch.rand(config.num_instances, 6, device=config.device, dtype=torch.float32), + "env_ids": make_tensor_env_ids(config.num_instances, config.device), + } + + +# --- Root COM Velocity --- +def gen_root_com_velocity_torch_list(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "root_velocity": torch.rand(config.num_instances, 6, device=config.device, dtype=torch.float32), + "env_ids": list(range(config.num_instances)), + } + + +def gen_root_com_velocity_torch_tensor(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "root_velocity": torch.rand(config.num_instances, 6, device=config.device, dtype=torch.float32), + "env_ids": make_tensor_env_ids(config.num_instances, config.device), + } + + +# --- Set Masses --- +def gen_set_masses_torch_list(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "masses": torch.rand(config.num_instances, config.num_bodies, device=config.device, dtype=torch.float32), + "env_ids": list(range(config.num_instances)), + "body_ids": list(range(config.num_bodies)), + } + + +def gen_set_masses_torch_tensor(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "masses": torch.rand(config.num_instances, config.num_bodies, device=config.device, dtype=torch.float32), + "env_ids": make_tensor_env_ids(config.num_instances, config.device), + "body_ids": make_tensor_body_ids(config.num_bodies, config.device), + } + + +# --- Set CoMs --- +def gen_set_coms_torch_list(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "coms": torch.rand(config.num_instances, config.num_bodies, 3, device=config.device, dtype=torch.float32), + "env_ids": list(range(config.num_instances)), + "body_ids": list(range(config.num_bodies)), + } + + +def gen_set_coms_torch_tensor(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "coms": torch.rand(config.num_instances, config.num_bodies, 3, device=config.device, dtype=torch.float32), + "env_ids": make_tensor_env_ids(config.num_instances, config.device), + "body_ids": make_tensor_body_ids(config.num_bodies, config.device), + } + + +# --- Set Inertias --- +def gen_set_inertias_torch_list(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "inertias": torch.rand(config.num_instances, config.num_bodies, 9, device=config.device, dtype=torch.float32), + "env_ids": list(range(config.num_instances)), + "body_ids": list(range(config.num_bodies)), + } + + +def gen_set_inertias_torch_tensor(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "inertias": torch.rand(config.num_instances, config.num_bodies, 9, device=config.device, dtype=torch.float32), + "env_ids": make_tensor_env_ids(config.num_instances, config.device), + "body_ids": make_tensor_body_ids(config.num_bodies, config.device), + } + + +# --- Set External Force and Torque --- +def gen_set_external_force_and_torque_torch_list(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "forces": torch.rand(config.num_instances, config.num_bodies, 3, device=config.device, dtype=torch.float32), + "torques": torch.rand(config.num_instances, config.num_bodies, 3, device=config.device, dtype=torch.float32), + "env_ids": list(range(config.num_instances)), + } + + +def gen_set_external_force_and_torque_torch_tensor(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "forces": torch.rand(config.num_instances, config.num_bodies, 3, device=config.device, dtype=torch.float32), + "torques": torch.rand(config.num_instances, config.num_bodies, 3, device=config.device, dtype=torch.float32), + "env_ids": make_tensor_env_ids(config.num_instances, config.device), + } + + +# ============================================================================= +# Warp Mask Input Generators (for _mask methods) +# ============================================================================= + + +def _env_mask(config: MethodBenchmarkRunnerConfig) -> wp.array: + return wp.ones((config.num_instances,), dtype=wp.bool, device=config.device) + + +def _body_mask(config: MethodBenchmarkRunnerConfig) -> wp.array: + return wp.ones((config.num_bodies,), dtype=wp.bool, device=config.device) + + +# --- Root Link Pose (mask) --- +def gen_root_link_pose_warp_mask(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "root_pose": torch.rand(config.num_instances, 7, device=config.device, dtype=torch.float32), + "env_mask": _env_mask(config), + } + + +# --- Root COM Pose (mask) --- +def gen_root_com_pose_warp_mask(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "root_pose": torch.rand(config.num_instances, 7, device=config.device, dtype=torch.float32), + "env_mask": _env_mask(config), + } + + +# --- Root Link Velocity (mask) --- +def gen_root_link_velocity_warp_mask(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "root_velocity": torch.rand(config.num_instances, 6, device=config.device, dtype=torch.float32), + "env_mask": _env_mask(config), + } + + +# --- Root COM Velocity (mask) --- +def gen_root_com_velocity_warp_mask(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "root_velocity": torch.rand(config.num_instances, 6, device=config.device, dtype=torch.float32), + "env_mask": _env_mask(config), + } + + +# --- Set Masses (mask) --- +def gen_set_masses_warp_mask(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "masses": torch.rand(config.num_instances, config.num_bodies, device=config.device, dtype=torch.float32), + "body_mask": _body_mask(config), + "env_mask": _env_mask(config), + } + + +# --- Set CoMs (mask) --- +def gen_set_coms_warp_mask(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "coms": torch.rand(config.num_instances, config.num_bodies, 3, device=config.device, dtype=torch.float32), + "body_mask": _body_mask(config), + "env_mask": _env_mask(config), + } + + +# --- Set Inertias (mask) --- +def gen_set_inertias_warp_mask(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "inertias": torch.rand(config.num_instances, config.num_bodies, 9, device=config.device, dtype=torch.float32), + "body_mask": _body_mask(config), + "env_mask": _env_mask(config), + } + + +# ============================================================================= +# Benchmarks +# ============================================================================= + +BENCHMARKS = [ + # --- Root Pose / Velocity --- + MethodBenchmarkDefinition( + name="write_root_link_pose_to_sim", + method_name="write_root_link_pose_to_sim", + input_generators={ + "torch_list": gen_root_link_pose_torch_list, + "torch_tensor": gen_root_link_pose_torch_tensor, + }, + category="root_pose", + ), + MethodBenchmarkDefinition( + name="write_root_link_pose_to_sim_mask", + method_name="write_root_link_pose_to_sim_mask", + input_generators={"warp_mask": gen_root_link_pose_warp_mask}, + category="root_pose", + ), + MethodBenchmarkDefinition( + name="write_root_com_pose_to_sim", + method_name="write_root_com_pose_to_sim", + input_generators={ + "torch_list": gen_root_com_pose_torch_list, + "torch_tensor": gen_root_com_pose_torch_tensor, + }, + category="root_pose", + ), + MethodBenchmarkDefinition( + name="write_root_com_pose_to_sim_mask", + method_name="write_root_com_pose_to_sim_mask", + input_generators={"warp_mask": gen_root_com_pose_warp_mask}, + category="root_pose", + ), + MethodBenchmarkDefinition( + name="write_root_link_velocity_to_sim", + method_name="write_root_link_velocity_to_sim", + input_generators={ + "torch_list": gen_root_link_velocity_torch_list, + "torch_tensor": gen_root_link_velocity_torch_tensor, + }, + category="root_velocity", + ), + MethodBenchmarkDefinition( + name="write_root_link_velocity_to_sim_mask", + method_name="write_root_link_velocity_to_sim_mask", + input_generators={"warp_mask": gen_root_link_velocity_warp_mask}, + category="root_velocity", + ), + MethodBenchmarkDefinition( + name="write_root_com_velocity_to_sim", + method_name="write_root_com_velocity_to_sim", + input_generators={ + "torch_list": gen_root_com_velocity_torch_list, + "torch_tensor": gen_root_com_velocity_torch_tensor, + }, + category="root_velocity", + ), + MethodBenchmarkDefinition( + name="write_root_com_velocity_to_sim_mask", + method_name="write_root_com_velocity_to_sim_mask", + input_generators={"warp_mask": gen_root_com_velocity_warp_mask}, + category="root_velocity", + ), + # --- Body Properties --- + MethodBenchmarkDefinition( + name="set_masses", + method_name="set_masses", + input_generators={ + "torch_list": gen_set_masses_torch_list, + "torch_tensor": gen_set_masses_torch_tensor, + }, + category="body_props", + ), + MethodBenchmarkDefinition( + name="set_masses_mask", + method_name="set_masses_mask", + input_generators={"warp_mask": gen_set_masses_warp_mask}, + category="body_props", + ), + MethodBenchmarkDefinition( + name="set_coms", + method_name="set_coms", + input_generators={ + "torch_list": gen_set_coms_torch_list, + "torch_tensor": gen_set_coms_torch_tensor, + }, + category="body_props", + ), + MethodBenchmarkDefinition( + name="set_coms_mask", + method_name="set_coms_mask", + input_generators={"warp_mask": gen_set_coms_warp_mask}, + category="body_props", + ), + MethodBenchmarkDefinition( + name="set_inertias", + method_name="set_inertias", + input_generators={ + "torch_list": gen_set_inertias_torch_list, + "torch_tensor": gen_set_inertias_torch_tensor, + }, + category="body_props", + ), + MethodBenchmarkDefinition( + name="set_inertias_mask", + method_name="set_inertias_mask", + input_generators={"warp_mask": gen_set_inertias_warp_mask}, + category="body_props", + ), + MethodBenchmarkDefinition( + name="set_external_force_and_torque", + method_name="set_external_force_and_torque", + input_generators={ + "torch_list": gen_set_external_force_and_torque_torch_list, + "torch_tensor": gen_set_external_force_and_torque_torch_tensor, + }, + category="external_wrench", + ), +] + + +# ============================================================================= +# Fill-Ratio Benchmarks (5%, 95%, 100% of env_ids filled) +# ============================================================================= + +FILL_RATIOS = {"5pct": 0.05, "95pct": 0.95, "100pct": 1.0} + + +def _make_fill_ratio_generator(base_gen_fn, fill_ratio): + """Create a generator that subsets env_ids to a given fill ratio. + + Only env_ids are subsetted — body_ids remain full-range. + Data tensors keyed on env count are sliced to match. + """ + + def generator(config): + n = max(1, int(config.num_instances * fill_ratio)) + base_inputs = base_gen_fn(config) + inputs = {} + for key, val in base_inputs.items(): + if key == "env_ids": + inputs[key] = ( + torch.randperm(config.num_instances, device=config.device)[:n].sort().values.to(torch.int32) + ) + elif isinstance(val, torch.Tensor) and val.dim() >= 1 and val.shape[0] == config.num_instances: + inputs[key] = val[:n] + else: + inputs[key] = val + return inputs + + return generator + + +def _make_fill_ratio_mask_generator(base_mask_gen_fn, fill_ratio): + """Create a mask generator with a given fill ratio. + + Sets a random subset of the env_mask entries to True. Data stays full-sized (mask methods expect full data). + """ + + def generator(config): + base_inputs = base_mask_gen_fn(config) + n = max(1, int(config.num_instances * fill_ratio)) + # Create a mask with n random entries set to True + perm = torch.randperm(config.num_instances, device=config.device) + mask_tensor = torch.zeros(config.num_instances, dtype=torch.bool, device=config.device) + mask_tensor[perm[:n]] = True + base_inputs["env_mask"] = wp.from_torch(mask_tensor, dtype=wp.bool) + return base_inputs + + return generator + + +def _build_fill_benchmarks(): + """Auto-generate fill-ratio benchmark definitions from existing generators.""" + fill_benchmarks = [] + for bm in BENCHMARKS: + generators = {} + # Add tensor fill variants from torch_tensor generators + if "torch_tensor" in bm.input_generators: + base_gen = bm.input_generators["torch_tensor"] + for suffix, ratio in FILL_RATIOS.items(): + generators[f"tensor_{suffix}"] = _make_fill_ratio_generator(base_gen, ratio) + # Add mask fill variants from warp_mask generators + if "warp_mask" in bm.input_generators: + base_gen = bm.input_generators["warp_mask"] + for suffix, ratio in FILL_RATIOS.items(): + generators[f"mask_{suffix}"] = _make_fill_ratio_mask_generator(base_gen, ratio) + if generators: + fill_benchmarks.append( + MethodBenchmarkDefinition( + name=bm.name, + method_name=bm.method_name, + input_generators=generators, + category=f"{bm.category}_fill", + ) + ) + return fill_benchmarks + + +FILL_BENCHMARKS = _build_fill_benchmarks() + + +def main(): + """Main entry point for the benchmarking script.""" + config = MethodBenchmarkRunnerConfig( + num_iterations=args.num_iterations, + warmup_steps=args.warmup_steps, + num_instances=args.num_instances, + num_bodies=args.num_bodies, + num_joints=0, + device=args.device, + mode=args.mode, + ) + + # Patch the NewtonManager for both rigid_object and rigid_object_data modules + with ( + create_mock_newton_manager( + "isaaclab_newton.assets.rigid_object.rigid_object_data.SimulationManager", + gravity=(0.0, 0.0, -9.81), + ), + create_mock_newton_manager( + "isaaclab_newton.assets.rigid_object.rigid_object.SimulationManager", + gravity=(0.0, 0.0, -9.81), + ), + ): + # Create the test rigid object + rigid_object, _ = create_test_rigid_object( + num_instances=config.num_instances, + num_bodies=config.num_bodies, + device=config.device, + ) + + print(f"Benchmarking RigidObject (Newton) with {config.num_instances} instances, {config.num_bodies} bodies...") + + # Create runner and run benchmarks + runner = MethodBenchmarkRunner( + benchmark_name="newton_rigid_object_benchmark", + config=config, + backend_type=args.backend, + output_path=args.output_dir, + use_recorders=True, + ) + + runner.run_benchmarks(BENCHMARKS, rigid_object) + + print("\n" + "=" * 80) + print("Fill-Ratio Benchmarks (env_ids at 5%, 95%, 100% fill)") + print("=" * 80) + + runner.run_benchmarks(FILL_BENCHMARKS, rigid_object) + runner.finalize() + + # Close the simulation app + simulation_app.close() + + +if __name__ == "__main__": + main() diff --git a/source/isaaclab_newton/benchmark/assets/benchmark_rigid_object_collection.py b/source/isaaclab_newton/benchmark/assets/benchmark_rigid_object_collection.py new file mode 100644 index 000000000000..f75c7d0d03b8 --- /dev/null +++ b/source/isaaclab_newton/benchmark/assets/benchmark_rigid_object_collection.py @@ -0,0 +1,654 @@ +# 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 + +"""Micro-benchmarking framework for RigidObjectCollection class (Newton backend). + +This module provides a benchmarking framework to measure the performance of setter and writer +methods in the RigidObjectCollection class. Each method is benchmarked under three scenarios: + +1. **Torch List**: Inputs are PyTorch tensors with list indices (via deprecated wrappers). +2. **Torch Tensor**: Inputs are PyTorch tensors with tensor indices (via deprecated wrappers). +3. **Warp Mask**: Inputs are warp arrays with boolean masks (via ``_mask`` methods). + +Usage: + python benchmark_rigid_object_collection.py [--num_iterations N] [--warmup_steps W] + [--num_instances I] [--num_bodies B] + +Example: + python benchmark_rigid_object_collection.py --num_iterations 1000 --warmup_steps 10 + python benchmark_rigid_object_collection.py --mode torch_list # Only run list-based benchmarks + python benchmark_rigid_object_collection.py --mode warp_mask # Only run warp mask benchmarks +""" + +from __future__ import annotations + +"""Launch Isaac Sim Simulator first.""" + +import argparse + +from isaaclab.app import AppLauncher + +# add argparse arguments +parser = argparse.ArgumentParser(description="Benchmark RigidObjectCollection methods (Newton backend).") +parser.add_argument("--num_iterations", type=int, default=1000, help="Number of iterations") +parser.add_argument("--warmup_steps", type=int, default=10, help="Number of warmup steps") +parser.add_argument("--num_instances", type=int, default=4096, help="Number of instances") +parser.add_argument("--num_bodies", type=int, default=3, help="Number of bodies (object types)") +parser.add_argument("--mode", type=str, default="all", help="Benchmark mode (all, torch_list, torch_tensor, warp_mask)") +parser.add_argument("--output_dir", type=str, default=".", help="Output directory for results") +parser.add_argument("--backend", type=str, default="json", choices=["json", "osmo", "omniperf"], help="Metrics backend") +parser.add_argument("--no_shape_checks", action="store_true", help="Disable shape/dtype assertions") + +# append AppLauncher cli args +AppLauncher.add_app_launcher_args(parser) +# parse the arguments +args = parser.parse_args() + +# launch omniverse app +app_launcher = AppLauncher(headless=True, args=args) +simulation_app = app_launcher.app + +"""Rest everything follows.""" + +import logging +import warnings + +import numpy as np +import torch +import warp as wp +from isaaclab_newton.test.mock_interfaces import ( + MockWrenchComposer, + create_mock_newton_manager, +) +from isaaclab_newton.test.mock_interfaces.views import MockNewtonCollectionView + +from isaaclab.assets.rigid_object_collection.rigid_object_collection_cfg import RigidObjectCollectionCfg +from isaaclab.test.benchmark import MethodBenchmarkDefinition, MethodBenchmarkRunner, MethodBenchmarkRunnerConfig + +# Suppress deprecation warnings during benchmarking +warnings.filterwarnings("ignore", category=DeprecationWarning) +warnings.filterwarnings("ignore", category=UserWarning) + +# Also suppress logging warnings +logging.getLogger("isaaclab_newton").setLevel(logging.ERROR) +logging.getLogger("isaaclab").setLevel(logging.ERROR) + + +# ============================================================================= +# Index Helpers +# ============================================================================= + + +def make_tensor_env_ids(num_instances: int, device: str) -> torch.Tensor: + """Create a tensor of environment IDs.""" + return torch.arange(num_instances, dtype=torch.int32, device=device) + + +def make_tensor_body_ids(num_bodies: int, device: str) -> torch.Tensor: + """Create a tensor of body IDs.""" + return torch.arange(num_bodies, dtype=torch.int32, device=device) + + +# ============================================================================= +# Test RigidObjectCollection Factory +# ============================================================================= + + +def create_test_collection( + num_instances: int = 2, + num_bodies: int = 3, + device: str = "cuda:0", +): + """Create a test RigidObjectCollection instance with mocked dependencies.""" + from isaaclab_newton.assets.rigid_object_collection.rigid_object_collection import RigidObjectCollection + + object_names = [f"object_{i}" for i in range(num_bodies)] + + collection = object.__new__(RigidObjectCollection) + + # Create a minimal config with dummy rigid objects + from isaaclab.assets.rigid_object.rigid_object_cfg import RigidObjectCfg + + rigid_objects = {name: RigidObjectCfg(prim_path=f"/World/{name}") for name in object_names} + collection.cfg = RigidObjectCollectionCfg(rigid_objects=rigid_objects) + + # Create Newton mock view + mock_view = MockNewtonCollectionView( + num_envs=num_instances, + num_bodies=num_bodies, + device=device, + body_names=object_names, + ) + mock_view.set_random_mock_data() + mock_view._noop_setters = True + + object.__setattr__(collection, "_root_view", mock_view) + object.__setattr__(collection, "_device", device) + object.__setattr__(collection, "_body_names_list", object_names) + object.__setattr__(collection, "_check_shapes", not args.no_shape_checks) + + # Create RigidObjectCollectionData instance (NewtonManager already mocked at call site) + from isaaclab_newton.assets.rigid_object_collection.rigid_object_collection_data import RigidObjectCollectionData + + data = RigidObjectCollectionData(mock_view, num_bodies, device) + object.__setattr__(collection, "_data", data) + + # Create mock wrench composers + mock_inst_wrench = MockWrenchComposer(collection) + mock_perm_wrench = MockWrenchComposer(collection) + object.__setattr__(collection, "_instantaneous_wrench_composer", mock_inst_wrench) + object.__setattr__(collection, "_permanent_wrench_composer", mock_perm_wrench) + + # Set up other required attributes + object.__setattr__( + collection, "_ALL_ENV_INDICES", wp.array(np.arange(num_instances, dtype=np.int32), device=device) + ) + object.__setattr__(collection, "_ALL_BODY_INDICES", wp.array(np.arange(num_bodies, dtype=np.int32), device=device)) + object.__setattr__(collection, "_ALL_ENV_MASK", wp.ones((num_instances,), dtype=wp.bool, device=device)) + object.__setattr__(collection, "_ALL_BODY_MASK", wp.ones((num_bodies,), dtype=wp.bool, device=device)) + + # Temporary 2D wrench buffer for write_data_to_sim + object.__setattr__( + collection, + "_wrench_buffer", + wp.zeros((num_instances, num_bodies), dtype=wp.spatial_vectorf, device=device), + ) + + return collection, mock_view + + +# ============================================================================= +# Input Generators (Torch-only for Newton backend) +# ============================================================================= + + +# --- Body Link Pose --- +def gen_body_link_pose_torch_list(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "body_poses": torch.rand(config.num_instances, config.num_bodies, 7, device=config.device, dtype=torch.float32), + "env_ids": list(range(config.num_instances)), + "body_ids": list(range(config.num_bodies)), + } + + +def gen_body_link_pose_torch_tensor(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "body_poses": torch.rand(config.num_instances, config.num_bodies, 7, device=config.device, dtype=torch.float32), + "env_ids": make_tensor_env_ids(config.num_instances, config.device), + "body_ids": make_tensor_body_ids(config.num_bodies, config.device), + } + + +# --- Body COM Pose --- +def gen_body_com_pose_torch_list(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "body_poses": torch.rand(config.num_instances, config.num_bodies, 7, device=config.device, dtype=torch.float32), + "env_ids": list(range(config.num_instances)), + "body_ids": list(range(config.num_bodies)), + } + + +def gen_body_com_pose_torch_tensor(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "body_poses": torch.rand(config.num_instances, config.num_bodies, 7, device=config.device, dtype=torch.float32), + "env_ids": make_tensor_env_ids(config.num_instances, config.device), + "body_ids": make_tensor_body_ids(config.num_bodies, config.device), + } + + +# --- Body Link Velocity --- +def gen_body_link_velocity_torch_list(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "body_velocities": torch.rand( + config.num_instances, config.num_bodies, 6, device=config.device, dtype=torch.float32 + ), + "env_ids": list(range(config.num_instances)), + "body_ids": list(range(config.num_bodies)), + } + + +def gen_body_link_velocity_torch_tensor(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "body_velocities": torch.rand( + config.num_instances, config.num_bodies, 6, device=config.device, dtype=torch.float32 + ), + "env_ids": make_tensor_env_ids(config.num_instances, config.device), + "body_ids": make_tensor_body_ids(config.num_bodies, config.device), + } + + +# --- Body COM Velocity --- +def gen_body_com_velocity_torch_list(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "body_velocities": torch.rand( + config.num_instances, config.num_bodies, 6, device=config.device, dtype=torch.float32 + ), + "env_ids": list(range(config.num_instances)), + "body_ids": list(range(config.num_bodies)), + } + + +def gen_body_com_velocity_torch_tensor(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "body_velocities": torch.rand( + config.num_instances, config.num_bodies, 6, device=config.device, dtype=torch.float32 + ), + "env_ids": make_tensor_env_ids(config.num_instances, config.device), + "body_ids": make_tensor_body_ids(config.num_bodies, config.device), + } + + +# --- Set Masses --- +def gen_set_masses_torch_list(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "masses": torch.rand(config.num_instances, config.num_bodies, device=config.device, dtype=torch.float32), + "env_ids": list(range(config.num_instances)), + "body_ids": list(range(config.num_bodies)), + } + + +def gen_set_masses_torch_tensor(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "masses": torch.rand(config.num_instances, config.num_bodies, device=config.device, dtype=torch.float32), + "env_ids": make_tensor_env_ids(config.num_instances, config.device), + "body_ids": make_tensor_body_ids(config.num_bodies, config.device), + } + + +# --- Set CoMs --- +def gen_set_coms_torch_list(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "coms": torch.rand(config.num_instances, config.num_bodies, 3, device=config.device, dtype=torch.float32), + "env_ids": list(range(config.num_instances)), + "body_ids": list(range(config.num_bodies)), + } + + +def gen_set_coms_torch_tensor(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "coms": torch.rand(config.num_instances, config.num_bodies, 3, device=config.device, dtype=torch.float32), + "env_ids": make_tensor_env_ids(config.num_instances, config.device), + "body_ids": make_tensor_body_ids(config.num_bodies, config.device), + } + + +# --- Set Inertias --- +def gen_set_inertias_torch_list(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "inertias": torch.rand(config.num_instances, config.num_bodies, 9, device=config.device, dtype=torch.float32), + "env_ids": list(range(config.num_instances)), + "body_ids": list(range(config.num_bodies)), + } + + +def gen_set_inertias_torch_tensor(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "inertias": torch.rand(config.num_instances, config.num_bodies, 9, device=config.device, dtype=torch.float32), + "env_ids": make_tensor_env_ids(config.num_instances, config.device), + "body_ids": make_tensor_body_ids(config.num_bodies, config.device), + } + + +# --- Set External Force and Torque --- +def gen_set_external_force_and_torque_torch_list(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "forces": torch.rand(config.num_instances, config.num_bodies, 3, device=config.device, dtype=torch.float32), + "torques": torch.rand(config.num_instances, config.num_bodies, 3, device=config.device, dtype=torch.float32), + "env_ids": list(range(config.num_instances)), + } + + +def gen_set_external_force_and_torque_torch_tensor(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "forces": torch.rand(config.num_instances, config.num_bodies, 3, device=config.device, dtype=torch.float32), + "torques": torch.rand(config.num_instances, config.num_bodies, 3, device=config.device, dtype=torch.float32), + "env_ids": make_tensor_env_ids(config.num_instances, config.device), + } + + +# ============================================================================= +# Warp Mask Input Generators (for _mask methods) +# ============================================================================= + + +def _env_mask(config: MethodBenchmarkRunnerConfig) -> wp.array: + return wp.ones((config.num_instances,), dtype=wp.bool, device=config.device) + + +def _body_mask(config: MethodBenchmarkRunnerConfig) -> wp.array: + return wp.ones((config.num_bodies,), dtype=wp.bool, device=config.device) + + +# --- Body Link Pose (mask) --- +def gen_body_link_pose_warp_mask(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "body_poses": torch.rand(config.num_instances, config.num_bodies, 7, device=config.device, dtype=torch.float32), + "env_mask": _env_mask(config), + } + + +# --- Body COM Pose (mask) --- +def gen_body_com_pose_warp_mask(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "body_poses": torch.rand(config.num_instances, config.num_bodies, 7, device=config.device, dtype=torch.float32), + "env_mask": _env_mask(config), + } + + +# --- Body Link Velocity (mask) --- +def gen_body_link_velocity_warp_mask(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "body_velocities": torch.rand( + config.num_instances, config.num_bodies, 6, device=config.device, dtype=torch.float32 + ), + "env_mask": _env_mask(config), + } + + +# --- Body COM Velocity (mask) --- +def gen_body_com_velocity_warp_mask(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "body_velocities": torch.rand( + config.num_instances, config.num_bodies, 6, device=config.device, dtype=torch.float32 + ), + "env_mask": _env_mask(config), + } + + +# --- Set Masses (mask) --- +def gen_set_masses_warp_mask(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "masses": torch.rand(config.num_instances, config.num_bodies, device=config.device, dtype=torch.float32), + "body_mask": _body_mask(config), + "env_mask": _env_mask(config), + } + + +# --- Set CoMs (mask) --- +def gen_set_coms_warp_mask(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "coms": torch.rand(config.num_instances, config.num_bodies, 3, device=config.device, dtype=torch.float32), + "body_mask": _body_mask(config), + "env_mask": _env_mask(config), + } + + +# --- Set Inertias (mask) --- +def gen_set_inertias_warp_mask(config: MethodBenchmarkRunnerConfig) -> dict: + return { + "inertias": torch.rand(config.num_instances, config.num_bodies, 9, device=config.device, dtype=torch.float32), + "body_mask": _body_mask(config), + "env_mask": _env_mask(config), + } + + +# ============================================================================= +# Benchmarks +# ============================================================================= + +BENCHMARKS = [ + # --- Body Link Pose --- + MethodBenchmarkDefinition( + name="write_body_link_pose_to_sim", + method_name="write_body_link_pose_to_sim", + input_generators={ + "torch_list": gen_body_link_pose_torch_list, + "torch_tensor": gen_body_link_pose_torch_tensor, + }, + category="body_pose", + ), + MethodBenchmarkDefinition( + name="write_body_link_pose_to_sim_mask", + method_name="write_body_link_pose_to_sim_mask", + input_generators={"warp_mask": gen_body_link_pose_warp_mask}, + category="body_pose", + ), + # --- Body COM Pose --- + MethodBenchmarkDefinition( + name="write_body_com_pose_to_sim", + method_name="write_body_com_pose_to_sim", + input_generators={ + "torch_list": gen_body_com_pose_torch_list, + "torch_tensor": gen_body_com_pose_torch_tensor, + }, + category="body_pose", + ), + MethodBenchmarkDefinition( + name="write_body_com_pose_to_sim_mask", + method_name="write_body_com_pose_to_sim_mask", + input_generators={"warp_mask": gen_body_com_pose_warp_mask}, + category="body_pose", + ), + # --- Body Link Velocity --- + MethodBenchmarkDefinition( + name="write_body_link_velocity_to_sim", + method_name="write_body_link_velocity_to_sim", + input_generators={ + "torch_list": gen_body_link_velocity_torch_list, + "torch_tensor": gen_body_link_velocity_torch_tensor, + }, + category="body_velocity", + ), + MethodBenchmarkDefinition( + name="write_body_link_velocity_to_sim_mask", + method_name="write_body_link_velocity_to_sim_mask", + input_generators={"warp_mask": gen_body_link_velocity_warp_mask}, + category="body_velocity", + ), + # --- Body COM Velocity --- + MethodBenchmarkDefinition( + name="write_body_com_velocity_to_sim", + method_name="write_body_com_velocity_to_sim", + input_generators={ + "torch_list": gen_body_com_velocity_torch_list, + "torch_tensor": gen_body_com_velocity_torch_tensor, + }, + category="body_velocity", + ), + MethodBenchmarkDefinition( + name="write_body_com_velocity_to_sim_mask", + method_name="write_body_com_velocity_to_sim_mask", + input_generators={"warp_mask": gen_body_com_velocity_warp_mask}, + category="body_velocity", + ), + # --- Body Properties --- + MethodBenchmarkDefinition( + name="set_masses", + method_name="set_masses", + input_generators={ + "torch_list": gen_set_masses_torch_list, + "torch_tensor": gen_set_masses_torch_tensor, + }, + category="body_props", + ), + MethodBenchmarkDefinition( + name="set_masses_mask", + method_name="set_masses_mask", + input_generators={"warp_mask": gen_set_masses_warp_mask}, + category="body_props", + ), + MethodBenchmarkDefinition( + name="set_coms", + method_name="set_coms", + input_generators={ + "torch_list": gen_set_coms_torch_list, + "torch_tensor": gen_set_coms_torch_tensor, + }, + category="body_props", + ), + MethodBenchmarkDefinition( + name="set_coms_mask", + method_name="set_coms_mask", + input_generators={"warp_mask": gen_set_coms_warp_mask}, + category="body_props", + ), + MethodBenchmarkDefinition( + name="set_inertias", + method_name="set_inertias", + input_generators={ + "torch_list": gen_set_inertias_torch_list, + "torch_tensor": gen_set_inertias_torch_tensor, + }, + category="body_props", + ), + MethodBenchmarkDefinition( + name="set_inertias_mask", + method_name="set_inertias_mask", + input_generators={"warp_mask": gen_set_inertias_warp_mask}, + category="body_props", + ), + # --- External Force and Torque --- + MethodBenchmarkDefinition( + name="set_external_force_and_torque", + method_name="set_external_force_and_torque", + input_generators={ + "torch_list": gen_set_external_force_and_torque_torch_list, + "torch_tensor": gen_set_external_force_and_torque_torch_tensor, + }, + category="external_wrench", + ), +] + + +# ============================================================================= +# Fill-Ratio Benchmarks (5%, 95%, 100% of env_ids filled) +# ============================================================================= + +FILL_RATIOS = {"5pct": 0.05, "95pct": 0.95, "100pct": 1.0} + + +def _make_fill_ratio_generator(base_gen_fn, fill_ratio): + """Create a generator that subsets env_ids to a given fill ratio. + + Only env_ids are subsetted -- body_ids remain full-range. + Data tensors keyed on env count are sliced to match. + """ + + def generator(config): + n = max(1, int(config.num_instances * fill_ratio)) + base_inputs = base_gen_fn(config) + inputs = {} + for key, val in base_inputs.items(): + if key == "env_ids": + inputs[key] = ( + torch.randperm(config.num_instances, device=config.device)[:n].sort().values.to(torch.int32) + ) + elif isinstance(val, torch.Tensor) and val.dim() >= 1 and val.shape[0] == config.num_instances: + inputs[key] = val[:n] + else: + inputs[key] = val + return inputs + + return generator + + +def _make_fill_ratio_mask_generator(base_mask_gen_fn, fill_ratio): + """Create a mask generator with a given fill ratio. + + Sets a random subset of the env_mask entries to True. Data stays full-sized (mask methods expect full data). + """ + + def generator(config): + base_inputs = base_mask_gen_fn(config) + n = max(1, int(config.num_instances * fill_ratio)) + # Create a mask with n random entries set to True + perm = torch.randperm(config.num_instances, device=config.device) + mask_tensor = torch.zeros(config.num_instances, dtype=torch.bool, device=config.device) + mask_tensor[perm[:n]] = True + base_inputs["env_mask"] = wp.from_torch(mask_tensor, dtype=wp.bool) + return base_inputs + + return generator + + +def _build_fill_benchmarks(): + """Auto-generate fill-ratio benchmark definitions from existing generators.""" + fill_benchmarks = [] + for bm in BENCHMARKS: + generators = {} + # Add tensor fill variants from torch_tensor generators + if "torch_tensor" in bm.input_generators: + base_gen = bm.input_generators["torch_tensor"] + for suffix, ratio in FILL_RATIOS.items(): + generators[f"tensor_{suffix}"] = _make_fill_ratio_generator(base_gen, ratio) + # Add mask fill variants from warp_mask generators + if "warp_mask" in bm.input_generators: + base_gen = bm.input_generators["warp_mask"] + for suffix, ratio in FILL_RATIOS.items(): + generators[f"mask_{suffix}"] = _make_fill_ratio_mask_generator(base_gen, ratio) + if generators: + fill_benchmarks.append( + MethodBenchmarkDefinition( + name=bm.name, + method_name=bm.method_name, + input_generators=generators, + category=f"{bm.category}_fill", + ) + ) + return fill_benchmarks + + +FILL_BENCHMARKS = _build_fill_benchmarks() + + +def main(): + """Main entry point for the benchmarking script.""" + config = MethodBenchmarkRunnerConfig( + num_iterations=args.num_iterations, + warmup_steps=args.warmup_steps, + num_instances=args.num_instances, + num_bodies=args.num_bodies, + num_joints=0, + device=args.device, + mode=args.mode, + ) + + # Patch the NewtonManager for both collection and collection_data modules + with ( + create_mock_newton_manager( + "isaaclab_newton.assets.rigid_object_collection.rigid_object_collection_data.SimulationManager", + gravity=(0.0, 0.0, -9.81), + ), + create_mock_newton_manager( + "isaaclab_newton.assets.rigid_object_collection.rigid_object_collection.SimulationManager", + gravity=(0.0, 0.0, -9.81), + ), + ): + # Create the test collection + collection, _ = create_test_collection( + num_instances=config.num_instances, + num_bodies=config.num_bodies, + device=config.device, + ) + + print( + f"Benchmarking RigidObjectCollection (Newton) with {config.num_instances} instances, " + f"{config.num_bodies} bodies..." + ) + + # Create runner and run benchmarks + runner = MethodBenchmarkRunner( + benchmark_name="newton_rigid_object_collection_benchmark", + config=config, + backend_type=args.backend, + output_path=args.output_dir, + use_recorders=True, + ) + + runner.run_benchmarks(BENCHMARKS, collection) + + print("\n" + "=" * 80) + print("Fill-Ratio Benchmarks (env_ids at 5%, 95%, 100% fill)") + print("=" * 80) + + runner.run_benchmarks(FILL_BENCHMARKS, collection) + runner.finalize() + + # Close the simulation app + simulation_app.close() + + +if __name__ == "__main__": + main() diff --git a/source/isaaclab_newton/benchmark/assets/benchmark_rigid_object_collection_data.py b/source/isaaclab_newton/benchmark/assets/benchmark_rigid_object_collection_data.py new file mode 100644 index 000000000000..e397b42d2ec4 --- /dev/null +++ b/source/isaaclab_newton/benchmark/assets/benchmark_rigid_object_collection_data.py @@ -0,0 +1,252 @@ +# 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 + +"""Micro-benchmarking framework for RigidObjectCollectionData class (Newton backend). + +This module provides a benchmarking framework to measure the performance of all properties +in the Newton RigidObjectCollectionData class. Each property is run multiple times with +randomized mock data, and timing statistics (mean and standard deviation) are reported. + +Usage: + python benchmark_rigid_object_collection_data.py [--num_iterations N] [--warmup_steps W] + [--num_instances I] [--num_bodies B] + +Example: + python benchmark_rigid_object_collection_data.py --num_iterations 10000 --warmup_steps 10 +""" + +from __future__ import annotations + +"""Launch Isaac Sim Simulator first.""" + +import argparse + +from isaaclab.app import AppLauncher + +# add argparse arguments +parser = argparse.ArgumentParser( + description="Micro-benchmarking framework for RigidObjectCollectionData class (Newton backend).", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, +) +parser.add_argument("--num_iterations", type=int, default=1000, help="Number of iterations") +parser.add_argument("--warmup_steps", type=int, default=10, help="Number of warmup steps") +parser.add_argument("--num_instances", type=int, default=4096, help="Number of instances") +parser.add_argument("--num_bodies", type=int, default=3, help="Number of bodies (object types)") +parser.add_argument("--output_dir", type=str, default=".", help="Output directory for results") +parser.add_argument("--backend", type=str, default="json", choices=["json", "osmo", "omniperf"], help="Metrics backend") + +# append AppLauncher cli args +AppLauncher.add_app_launcher_args(parser) +# parse the arguments +args = parser.parse_args() + +# launch omniverse app +app_launcher = AppLauncher(headless=True, args=args) +simulation_app = app_launcher.app + +"""Rest everything follows.""" + +import warnings + +import numpy as np +import warp as wp +from isaaclab_newton.test.mock_interfaces import create_mock_newton_manager +from isaaclab_newton.test.mock_interfaces.views import MockNewtonCollectionView + +from isaaclab.test.benchmark import MethodBenchmarkRunner, MethodBenchmarkRunnerConfig + +# Suppress deprecation warnings during benchmarking +warnings.filterwarnings("ignore", category=DeprecationWarning) +warnings.filterwarnings("ignore", category=UserWarning) + + +# ============================================================================= +# Skip Lists +# ============================================================================= + +# List of deprecated properties - skip these +DEPRECATED_PROPERTIES = { + "default_body_state", + "body_state_w", + "body_link_state_w", + "body_com_state_w", +} + +# List of properties that raise NotImplementedError - skip these +NOT_IMPLEMENTED_PROPERTIES = set() + +# Removed default_* properties that raise RuntimeError +REMOVED_PROPERTIES = { + "default_inertia", + "default_mass", +} + +# Private/internal properties and methods to skip +INTERNAL_PROPERTIES = { + "_create_simulation_bindings", + "_create_buffers", + "update", + "is_primed", + "device", + "body_names", + "object_names", + "GRAVITY_VEC_W", + "GRAVITY_VEC_W_TORCH", + "FORWARD_VEC_B", + "FORWARD_VEC_B_TORCH", + "ALL_ENV_MASK", + "ENV_MASK", + "ALL_OBJECT_MASK", + "OBJECT_MASK", + "num_bodies", + "num_instances", +} + +# Dependency mapping for properties +PROPERTY_DEPENDENCIES = { + "body_link_pos_w": ["body_link_pose_w"], + "body_link_quat_w": ["body_link_pose_w"], + "body_link_lin_vel_w": ["body_link_vel_w"], + "body_link_ang_vel_w": ["body_link_vel_w"], + "body_com_pos_w": ["body_com_pose_w"], + "body_com_quat_w": ["body_com_pose_w"], + "body_com_lin_vel_w": ["body_com_vel_w"], + "body_com_ang_vel_w": ["body_com_vel_w"], + "body_com_lin_acc_w": ["body_com_acc_w"], + "body_com_ang_acc_w": ["body_com_acc_w"], + "body_com_quat_b": ["body_com_pose_b"], +} + + +# ============================================================================= +# Benchmark Functions +# ============================================================================= + + +def get_benchmarkable_properties(data) -> list[str]: + """Get list of properties that can be benchmarked.""" + all_properties = [] + + for name in dir(data): + if name.startswith("_"): + continue + if name in DEPRECATED_PROPERTIES: + continue + if name in NOT_IMPLEMENTED_PROPERTIES: + continue + if name in REMOVED_PROPERTIES: + continue + if name in INTERNAL_PROPERTIES: + continue + + try: + attr = getattr(type(data), name, None) + if isinstance(attr, property): + all_properties.append(name) + except Exception: + pass + + return sorted(all_properties) + + +def setup_mock_environment(config: MethodBenchmarkRunnerConfig) -> MockNewtonCollectionView: + """Set up the mock environment for benchmarking.""" + mock_view = MockNewtonCollectionView( + num_envs=config.num_instances, + num_bodies=config.num_bodies, + device=config.device, + ) + return mock_view + + +def main(): + """Main entry point for the benchmarking script.""" + config = MethodBenchmarkRunnerConfig( + num_iterations=args.num_iterations, + warmup_steps=args.warmup_steps, + num_instances=args.num_instances, + num_bodies=args.num_bodies, + num_joints=0, + device=args.device, + ) + + # Patch the NewtonManager for the collection_data module + with create_mock_newton_manager( + "isaaclab_newton.assets.rigid_object_collection.rigid_object_collection_data.SimulationManager", + gravity=(0.0, 0.0, -9.81), + ): + # Setup mock environment + mock_view = setup_mock_environment(config) + mock_view.set_random_mock_data() + + # Import RigidObjectCollectionData inside the patch context + from isaaclab_newton.assets.rigid_object_collection.rigid_object_collection_data import ( + RigidObjectCollectionData, + ) + + # Create RigidObjectCollectionData instance + data = RigidObjectCollectionData(mock_view, config.num_bodies, config.device) + + # Get list of properties to benchmark + properties = get_benchmarkable_properties(data) + + N, B = config.num_instances, config.num_bodies + dev = config.device + + # Generator that updates mock data and invalidates timestamp + def gen_mock_data(cfg: MethodBenchmarkRunnerConfig) -> dict: + # Update root transforms (shape: N, B, 7) + root_tf_np = np.random.randn(N, B, 7).astype(np.float32) + root_tf_np[..., 3:7] /= np.linalg.norm(root_tf_np[..., 3:7], axis=-1, keepdims=True) + mock_view._root_transforms = wp.array(root_tf_np, dtype=wp.transformf, device=dev) + + # Update root velocities (shape: N, B, 6) + root_vel_np = np.random.randn(N, B, 6).astype(np.float32) + mock_view._root_velocities = wp.array(root_vel_np, dtype=wp.spatial_vectorf, device=dev) + + # Update body properties (attributes have trailing link dim of 1: N, B, 1) + mock_view._attributes["body_com"] = wp.array( + np.random.randn(N, B, 1, 3).astype(np.float32), dtype=wp.vec3f, device=dev + ) + mock_view._attributes["body_mass"] = wp.array( + (np.random.rand(N, B, 1) * 10 + 0.1).astype(np.float32), dtype=wp.float32, device=dev + ) + mock_view._attributes["body_inertia"] = wp.array( + np.random.randn(N, B, 1, 9).astype(np.float32), dtype=wp.mat33f, device=dev + ) + + # Re-create simulation bindings to pick up the new mock data + data._create_simulation_bindings() + + # Invalidate timestamp to trigger recomputation + data._sim_timestamp += 1.0 + return {} + + # Create runner + runner = MethodBenchmarkRunner( + benchmark_name="newton_rigid_object_collection_data_benchmark", + config=config, + backend_type=args.backend, + output_path=args.output_dir, + use_recorders=True, + ) + + # Run property benchmarks + runner.run_property_benchmarks( + target_data=data, + properties=properties, + gen_mock_data=gen_mock_data, + dependencies=PROPERTY_DEPENDENCIES, + category="property", + ) + + runner.finalize() + + # Close the simulation app + simulation_app.close() + + +if __name__ == "__main__": + main() diff --git a/source/isaaclab_newton/benchmark/assets/benchmark_rigid_object_data.py b/source/isaaclab_newton/benchmark/assets/benchmark_rigid_object_data.py new file mode 100644 index 000000000000..b8889e1fcd2c --- /dev/null +++ b/source/isaaclab_newton/benchmark/assets/benchmark_rigid_object_data.py @@ -0,0 +1,285 @@ +# 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 + +"""Micro-benchmarking framework for RigidObjectData class (Newton backend). + +This module provides a benchmarking framework to measure the performance of all properties +in the Newton RigidObjectData class. Each property is run multiple times with randomized mock data, +and timing statistics (mean and standard deviation) are reported. + +Usage: + python benchmark_rigid_object_data.py [--num_iterations N] [--warmup_steps W] + [--num_instances I] + +Example: + python benchmark_rigid_object_data.py --num_iterations 10000 --warmup_steps 10 +""" + +from __future__ import annotations + +"""Launch Isaac Sim Simulator first.""" + +import argparse + +from isaaclab.app import AppLauncher + +# add argparse arguments +parser = argparse.ArgumentParser( + description="Micro-benchmarking framework for RigidObjectData class (Newton backend).", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, +) +parser.add_argument("--num_iterations", type=int, default=1000, help="Number of iterations") +parser.add_argument("--warmup_steps", type=int, default=10, help="Number of warmup steps") +parser.add_argument("--num_instances", type=int, default=4096, help="Number of instances") +parser.add_argument("--output_dir", type=str, default=".", help="Output directory for results") +parser.add_argument("--backend", type=str, default="json", choices=["json", "osmo", "omniperf"], help="Metrics backend") + +# append AppLauncher cli args +AppLauncher.add_app_launcher_args(parser) +# parse the arguments +args = parser.parse_args() + +# launch omniverse app +app_launcher = AppLauncher(headless=True, args=args) +simulation_app = app_launcher.app + +"""Rest everything follows.""" + +import warnings + +import numpy as np +import warp as wp +from isaaclab_newton.test.mock_interfaces import MockNewtonArticulationView, create_mock_newton_manager + +from isaaclab.test.benchmark import MethodBenchmarkRunner, MethodBenchmarkRunnerConfig + +# Suppress deprecation warnings during benchmarking +warnings.filterwarnings("ignore", category=DeprecationWarning) +warnings.filterwarnings("ignore", category=UserWarning) + + +# ============================================================================= +# Skip Lists +# ============================================================================= + +# List of deprecated properties - skip these +DEPRECATED_PROPERTIES = { + "default_root_state", + "root_pose_w", + "root_pos_w", + "root_quat_w", + "root_vel_w", + "root_lin_vel_w", + "root_ang_vel_w", + "root_lin_vel_b", + "root_ang_vel_b", + "body_pose_w", + "body_pos_w", + "body_quat_w", + "body_vel_w", + "body_lin_vel_w", + "body_ang_vel_w", + "body_acc_w", + "body_lin_acc_w", + "body_ang_acc_w", + "com_pos_b", + "com_quat_b", + # Combined state properties marked as deprecated + "root_state_w", + "root_link_state_w", + "root_com_state_w", + "body_state_w", + "body_link_state_w", + "body_com_state_w", +} + +# List of properties that raise NotImplementedError - skip these +NOT_IMPLEMENTED_PROPERTIES = set() + +# Removed default_* properties that raise RuntimeError +REMOVED_PROPERTIES = { + "default_inertia", + "default_mass", +} + +# Private/internal properties and methods to skip +INTERNAL_PROPERTIES = { + "_create_simulation_bindings", + "_create_buffers", + "update", + "is_primed", + "device", + "body_names", + "GRAVITY_VEC_W", + "GRAVITY_VEC_W_TORCH", + "FORWARD_VEC_B", + "FORWARD_VEC_B_TORCH", + "ALL_ENV_MASK", + "ENV_MASK", +} + +# Dependency mapping for properties +PROPERTY_DEPENDENCIES = { + "root_link_lin_vel_w": ["root_link_vel_w"], + "root_link_ang_vel_w": ["root_link_vel_w"], + "root_link_lin_vel_b": ["root_link_vel_b"], + "root_link_ang_vel_b": ["root_link_vel_b"], + "root_com_pos_w": ["root_com_pose_w"], + "root_com_quat_w": ["root_com_pose_w"], + "root_com_lin_vel_b": ["root_com_vel_b"], + "root_com_ang_vel_b": ["root_com_vel_b"], + "root_com_lin_vel_w": ["root_com_vel_w"], + "root_com_ang_vel_w": ["root_com_vel_w"], + "root_link_pos_w": ["root_link_pose_w"], + "root_link_quat_w": ["root_link_pose_w"], + "body_link_lin_vel_w": ["body_link_vel_w"], + "body_link_ang_vel_w": ["body_link_vel_w"], + "body_link_pos_w": ["body_link_pose_w"], + "body_link_quat_w": ["body_link_pose_w"], + "body_com_pos_w": ["body_com_pose_w"], + "body_com_quat_w": ["body_com_pose_w"], + "body_com_lin_vel_w": ["body_com_vel_w"], + "body_com_ang_vel_w": ["body_com_vel_w"], + "body_com_lin_acc_w": ["body_com_acc_w"], + "body_com_ang_acc_w": ["body_com_acc_w"], + "body_com_quat_b": ["body_com_pose_b"], +} + + +# ============================================================================= +# Benchmark Functions +# ============================================================================= + + +def get_benchmarkable_properties(rigid_object_data) -> list[str]: + """Get list of properties that can be benchmarked.""" + all_properties = [] + + for name in dir(rigid_object_data): + if name.startswith("_"): + continue + if name in DEPRECATED_PROPERTIES: + continue + if name in NOT_IMPLEMENTED_PROPERTIES: + continue + if name in REMOVED_PROPERTIES: + continue + if name in INTERNAL_PROPERTIES: + continue + + try: + attr = getattr(type(rigid_object_data), name, None) + if isinstance(attr, property): + all_properties.append(name) + except Exception: + pass + + return sorted(all_properties) + + +def setup_mock_environment(config: MethodBenchmarkRunnerConfig) -> MockNewtonArticulationView: + """Set up the mock environment for benchmarking.""" + mock_view = MockNewtonArticulationView( + num_instances=config.num_instances, + num_bodies=config.num_bodies, + num_joints=0, + device=config.device, + ) + return mock_view + + +def main(): + """Main entry point for the benchmarking script.""" + config = MethodBenchmarkRunnerConfig( + num_iterations=args.num_iterations, + warmup_steps=args.warmup_steps, + num_instances=args.num_instances, + num_bodies=1, + num_joints=0, + device=args.device, + ) + + # Patch the NewtonManager for the rigid_object_data module + with create_mock_newton_manager( + "isaaclab_newton.assets.rigid_object.rigid_object_data.SimulationManager", + gravity=(0.0, 0.0, -9.81), + ): + # Setup mock environment + mock_view = setup_mock_environment(config) + mock_view.set_random_mock_data() + + # Import RigidObjectData inside the patch context + from isaaclab_newton.assets.rigid_object.rigid_object_data import RigidObjectData + + # Create RigidObjectData instance + rigid_object_data = RigidObjectData(mock_view, config.device) + + # Get list of properties to benchmark + properties = get_benchmarkable_properties(rigid_object_data) + + # Generator that updates mock data and invalidates timestamp + def gen_mock_data(cfg: MethodBenchmarkRunnerConfig) -> dict: + N, L = cfg.num_instances, cfg.num_bodies + dev = cfg.device + + # Update root transforms + root_tf_np = np.random.randn(N, 1, 7).astype(np.float32) + root_tf_np[..., 3:7] /= np.linalg.norm(root_tf_np[..., 3:7], axis=-1, keepdims=True) + mock_view.set_mock_root_transforms(wp.array(root_tf_np, dtype=wp.transformf, device=dev)) + + # Update root velocities + root_vel_np = np.random.randn(N, 1, 6).astype(np.float32) + mock_view.set_mock_root_velocities(wp.array(root_vel_np, dtype=wp.spatial_vectorf, device=dev)) + + # Update link transforms + link_tf_np = np.random.randn(N, 1, L, 7).astype(np.float32) + link_tf_np[..., 3:7] /= np.linalg.norm(link_tf_np[..., 3:7], axis=-1, keepdims=True) + mock_view.set_mock_link_transforms(wp.array(link_tf_np, dtype=wp.transformf, device=dev)) + + # Update link velocities + link_vel_np = np.random.randn(N, 1, L, 6).astype(np.float32) + mock_view.set_mock_link_velocities(wp.array(link_vel_np, dtype=wp.spatial_vectorf, device=dev)) + + # Update body properties + mock_view.set_mock_coms( + wp.array(np.random.randn(N, 1, L, 3).astype(np.float32), dtype=wp.vec3f, device=dev) + ) + mock_view.set_mock_inertias( + wp.array(np.random.randn(N, 1, L, 9).astype(np.float32), dtype=wp.mat33f, device=dev) + ) + mock_view.set_mock_masses( + wp.array((np.random.rand(N, 1, L) * 10 + 0.1).astype(np.float32), dtype=wp.float32, device=dev) + ) + + # Invalidate timestamp to trigger recomputation + rigid_object_data._sim_timestamp += 1.0 + return {} + + # Create runner + runner = MethodBenchmarkRunner( + benchmark_name="newton_rigid_object_data_benchmark", + config=config, + backend_type=args.backend, + output_path=args.output_dir, + use_recorders=True, + ) + + # Run property benchmarks + runner.run_property_benchmarks( + target_data=rigid_object_data, + properties=properties, + gen_mock_data=gen_mock_data, + dependencies=PROPERTY_DEPENDENCIES, + category="property", + ) + + runner.finalize() + + # Close the simulation app + simulation_app.close() + + +if __name__ == "__main__": + main() diff --git a/source/isaaclab_newton/docs/CHANGELOG.rst b/source/isaaclab_newton/docs/CHANGELOG.rst index 710b3920ca77..e883be590630 100644 --- a/source/isaaclab_newton/docs/CHANGELOG.rst +++ b/source/isaaclab_newton/docs/CHANGELOG.rst @@ -9,6 +9,16 @@ Added * Added :class:`~isaaclab_newton.physics.KaminoSolverCfg` to support Newton's Kamino solver backend, a Proximal-ADMM based solver for constrained rigid multi-body dynamics. +* Added fused :meth:`~isaaclab_newton.assets.Articulation.write_joint_state_to_sim_index` + and :meth:`~isaaclab_newton.assets.Articulation.write_joint_state_to_sim_mask` that + write joint position and velocity in a single kernel launch instead of two. + +Changed +^^^^^^^ + +* Removed dead state-buffer output parameters from 8 root pose/velocity warp kernels + in :mod:`~isaaclab_newton.assets.kernels`, reducing kernel argument marshalling + overhead. Fixed ^^^^^ @@ -131,7 +141,7 @@ Changed 0.5.17 (2026-04-20) -~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~ Fixed ^^^^^ @@ -147,7 +157,6 @@ Changed (0xFFEEEEEE, 93% gray, fully opaque) background via ``SensorTiledCamera.ClearData`` in :class:`~isaaclab_newton.renderers.NewtonWarpRenderer`. - 0.5.16 (2026-04-17) ~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation.py b/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation.py index a2254d404898..c3c6eca044f7 100644 --- a/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation.py +++ b/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation.py @@ -469,8 +469,6 @@ def write_root_link_pose_to_sim_index( ], outputs=[ self.data.root_link_pose_w, - None, # self.data._root_link_state_w.data, - None, # self.data._root_state_w.data, ], device=self.device, ) @@ -528,8 +526,6 @@ def write_root_link_pose_to_sim_mask( ], outputs=[ self.data.root_link_pose_w, - None, # self.data._root_link_state_w.data, - None, # self.data._root_state_w.data, ], device=self.device, ) @@ -593,9 +589,6 @@ def write_root_com_pose_to_sim_index( outputs=[ self.data.root_com_pose_w, self.data.root_link_pose_w, - None, # self.data._root_com_state_w.data, - None, # self.data._root_link_state_w.data, - None, # self.data._root_state_w.data, ], device=self.device, ) @@ -657,9 +650,6 @@ def write_root_com_pose_to_sim_mask( outputs=[ self.data.root_com_pose_w, self.data.root_link_pose_w, - None, # self.data._root_com_state_w.data, - None, # self.data._root_link_state_w.data, - None, # self.data._root_state_w.data, ], device=self.device, ) @@ -773,8 +763,6 @@ def write_root_com_velocity_to_sim_index( outputs=[ self.data.root_com_vel_w, self.data.body_com_acc_w, - None, # self.data._root_state_w.data, - None, # self.data._root_com_state_w.data, ], device=self.device, ) @@ -821,8 +809,6 @@ def write_root_com_velocity_to_sim_mask( outputs=[ self.data.root_com_vel_w, self.data.body_com_acc_w, - None, # self.data._root_state_w.data, - None, # self.data._root_com_state_w.data, ], device=self.device, ) @@ -875,9 +861,6 @@ def write_root_link_velocity_to_sim_index( self.data.root_link_vel_w, self.data.root_com_vel_w, self.data.body_com_acc_w, - None, # self.data._root_link_state_w.data, - None, # self.data._root_state_w.data, - None, # self.data._root_com_state_w.data, ], device=self.device, ) @@ -929,9 +912,6 @@ def write_root_link_velocity_to_sim_mask( self.data.root_link_vel_w, self.data.root_com_vel_w, self.data.body_com_acc_w, - None, # self.data._root_link_state_w.data, - None, # self.data._root_state_w.data, - None, # self.data._root_com_state_w.data, ], device=self.device, ) @@ -943,6 +923,66 @@ def write_root_link_velocity_to_sim_mask( if self.data._root_com_state_w is not None: self.data._root_com_state_w.timestamp = -1.0 + def write_joint_state_to_sim_index( + self, + *, + position: torch.Tensor | wp.array, + velocity: torch.Tensor | wp.array, + joint_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + ): + """Write joint positions and velocities in a single fused kernel launch. + + .. 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. + + Args: + position: Joint positions. Shape is (len(env_ids), len(joint_ids)). + velocity: Joint velocities. Shape is (len(env_ids), len(joint_ids)). + joint_ids: Joint indices. If None, then all joints are used. + env_ids: Environment indices. If None, then all indices are used. + """ + env_ids = self._resolve_env_ids(env_ids) + joint_ids = self._resolve_joint_ids(joint_ids) + self.assert_shape_and_dtype(position, (env_ids.shape[0], joint_ids.shape[0]), wp.float32, "position") + self.assert_shape_and_dtype(velocity, (env_ids.shape[0], joint_ids.shape[0]), wp.float32, "velocity") + wp.launch( + articulation_kernels.write_joint_state_data_index, + dim=(env_ids.shape[0], joint_ids.shape[0]), + inputs=[ + position, + velocity, + env_ids, + joint_ids, + ], + outputs=[ + self.data.joint_pos, + self.data.joint_vel, + self.data._previous_joint_vel, + self.data.joint_acc, + ], + device=self.device, + ) + # Invalidate FK timestamp so body poses are recomputed on next access. + self.data._fk_timestamp = -1.0 + SimulationManager.invalidate_fk() + if self.data._body_link_vel_w is not None: + self.data._body_link_vel_w.timestamp = -1.0 + if self.data._body_com_pose_b is not None: + self.data._body_com_pose_b.timestamp = -1.0 + if self.data._body_com_pose_w is not None: + self.data._body_com_pose_w.timestamp = -1.0 + if self.data._body_state_w is not None: + self.data._body_state_w.timestamp = -1.0 + if self.data._body_link_state_w is not None: + self.data._body_link_state_w.timestamp = -1.0 + if self.data._body_com_state_w is not None: + self.data._body_com_state_w.timestamp = -1.0 + def write_joint_state_to_sim_mask( self, *, @@ -966,9 +1006,42 @@ def write_joint_state_to_sim_mask( joint_mask: Joint mask. If None, then all joints are used. Shape is (num_joints,). env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). """ - # set into simulation - self.write_joint_position_to_sim_mask(position, env_mask=env_mask, joint_mask=joint_mask) - self.write_joint_velocity_to_sim_mask(velocity, env_mask=env_mask, joint_mask=joint_mask) + env_mask = self._resolve_mask(env_mask, self._ALL_ENV_MASK) + joint_mask = self._resolve_mask(joint_mask, self._ALL_JOINT_MASK) + self.assert_shape_and_dtype_mask(position, (env_mask, joint_mask), wp.float32, "position") + self.assert_shape_and_dtype_mask(velocity, (env_mask, joint_mask), wp.float32, "velocity") + wp.launch( + articulation_kernels.write_joint_state_data_mask, + dim=(env_mask.shape[0], joint_mask.shape[0]), + inputs=[ + position, + velocity, + env_mask, + joint_mask, + ], + outputs=[ + self.data.joint_pos, + self.data.joint_vel, + self.data._previous_joint_vel, + self.data.joint_acc, + ], + device=self.device, + ) + # Invalidate FK timestamp so body poses are recomputed on next access. + self.data._fk_timestamp = -1.0 + SimulationManager.invalidate_fk() + if self.data._body_link_vel_w is not None: + self.data._body_link_vel_w.timestamp = -1.0 + if self.data._body_com_pose_b is not None: + self.data._body_com_pose_b.timestamp = -1.0 + if self.data._body_com_pose_w is not None: + self.data._body_com_pose_w.timestamp = -1.0 + if self.data._body_state_w is not None: + self.data._body_state_w.timestamp = -1.0 + if self.data._body_link_state_w is not None: + self.data._body_link_state_w.timestamp = -1.0 + if self.data._body_com_state_w is not None: + self.data._body_com_state_w.timestamp = -1.0 def write_joint_position_to_sim_index( self, @@ -3679,9 +3752,6 @@ def format_limits(_, v: tuple[float, float]) -> str: def _resolve_env_ids(self, env_ids: Sequence[int] | torch.Tensor | wp.array | None) -> wp.array: """Resolve environment indices to a warp array. - .. note:: - We need to convert torch tensors to warp arrays since the TensorAPI views only support warp arrays. - Args: env_ids: Environment indices. If None, then all indices are used. @@ -3691,7 +3761,6 @@ def _resolve_env_ids(self, env_ids: Sequence[int] | torch.Tensor | wp.array | No if (env_ids is None) or (env_ids == slice(None)): return self._ALL_INDICES if isinstance(env_ids, torch.Tensor): - # Convert int64 to int32 if needed, as warp expects int32 if env_ids.dtype == torch.int64: env_ids = env_ids.to(torch.int32) return wp.from_torch(env_ids, dtype=wp.int32) @@ -3702,9 +3771,6 @@ def _resolve_env_ids(self, env_ids: Sequence[int] | torch.Tensor | wp.array | No def _resolve_joint_ids(self, joint_ids: Sequence[int] | torch.Tensor | wp.array | None) -> wp.array | torch.Tensor: """Resolve joint indices to a warp array or tensor. - .. note:: - We do not need to convert torch tensors to warp arrays since they never get passed to the TensorAPI views. - Args: joint_ids: Joint indices. If None, then all indices are used. @@ -3715,6 +3781,10 @@ def _resolve_joint_ids(self, joint_ids: Sequence[int] | torch.Tensor | wp.array return wp.array(joint_ids, dtype=wp.int32, device=self.device) if (joint_ids is None) or (joint_ids == slice(None)): return self._ALL_JOINT_INDICES + if isinstance(joint_ids, torch.Tensor): + if joint_ids.dtype == torch.int64: + joint_ids = joint_ids.to(torch.int32) + return wp.from_torch(joint_ids, dtype=wp.int32) return joint_ids def _resolve_body_ids(self, body_ids: Sequence[int] | torch.Tensor | wp.array | None) -> wp.array | torch.Tensor: @@ -3730,6 +3800,10 @@ def _resolve_body_ids(self, body_ids: Sequence[int] | torch.Tensor | wp.array | return wp.array(body_ids, dtype=wp.int32, device=self.device) if (body_ids is None) or (body_ids == slice(None)): return self._ALL_BODY_INDICES + if isinstance(body_ids, torch.Tensor): + if body_ids.dtype == torch.int64: + body_ids = body_ids.to(torch.int32) + return wp.from_torch(body_ids, dtype=wp.int32) return body_ids def _resolve_fixed_tendon_ids( @@ -3868,14 +3942,11 @@ def write_joint_state_to_sim( joint_ids: Sequence[int] | torch.Tensor | wp.array | None = None, env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ): - """Deprecated, same as :meth:`write_joint_position_to_sim_index` and - :meth:`write_joint_velocity_to_sim_index`.""" + """Deprecated, same as :meth:`write_joint_state_to_sim_index`.""" warnings.warn( "The function 'write_joint_state_to_sim' will be deprecated in a future release. Please" - " use 'write_joint_position_to_sim_index' and 'write_joint_velocity_to_sim_index' instead.", + " use 'write_joint_state_to_sim_index' instead.", DeprecationWarning, stacklevel=2, ) - # set into simulation - self.write_joint_position_to_sim_index(position=position, joint_ids=joint_ids, env_ids=env_ids) - self.write_joint_velocity_to_sim_index(velocity=velocity, joint_ids=joint_ids, env_ids=env_ids) + self.write_joint_state_to_sim_index(position=position, velocity=velocity, joint_ids=joint_ids, env_ids=env_ids) diff --git a/source/isaaclab_newton/isaaclab_newton/assets/articulation/kernels.py b/source/isaaclab_newton/isaaclab_newton/assets/articulation/kernels.py index 6dc714d4e9ce..5e66b867c09a 100644 --- a/source/isaaclab_newton/isaaclab_newton/assets/articulation/kernels.py +++ b/source/isaaclab_newton/isaaclab_newton/assets/articulation/kernels.py @@ -123,6 +123,67 @@ def write_joint_vel_data_mask( joint_acc[i, j] = 0.0 +@wp.kernel +def write_joint_state_data_index( + pos_data: wp.array2d(dtype=wp.float32), + vel_data: wp.array2d(dtype=wp.float32), + env_ids: wp.array(dtype=wp.int32), + joint_ids: wp.array(dtype=wp.int32), + joint_pos: wp.array2d(dtype=wp.float32), + joint_vel: wp.array2d(dtype=wp.float32), + prev_joint_vel: wp.array2d(dtype=wp.float32), + joint_acc: wp.array2d(dtype=wp.float32), +): + """Write joint position and velocity data in a single kernel launch. + + Args: + pos_data: Input joint positions. Shape is (num_selected_envs, num_selected_joints). + vel_data: Input joint velocities. Shape is (num_selected_envs, num_selected_joints). + env_ids: Environment indices. Shape is (num_selected_envs,). + joint_ids: Joint indices. Shape is (num_selected_joints,). + joint_pos: Output joint positions. Shape is (num_envs, num_joints). + joint_vel: Output joint velocities. Shape is (num_envs, num_joints). + prev_joint_vel: Output previous joint velocities. Shape is (num_envs, num_joints). + joint_acc: Output joint accelerations (reset to 0). Shape is (num_envs, num_joints). + """ + i, j = wp.tid() + joint_pos[env_ids[i], joint_ids[j]] = pos_data[i, j] + joint_vel[env_ids[i], joint_ids[j]] = vel_data[i, j] + prev_joint_vel[env_ids[i], joint_ids[j]] = vel_data[i, j] + joint_acc[env_ids[i], joint_ids[j]] = 0.0 + + +@wp.kernel +def write_joint_state_data_mask( + pos_data: wp.array2d(dtype=wp.float32), + vel_data: wp.array2d(dtype=wp.float32), + env_mask: wp.array(dtype=wp.bool), + joint_mask: wp.array(dtype=wp.bool), + joint_pos: wp.array2d(dtype=wp.float32), + joint_vel: wp.array2d(dtype=wp.float32), + prev_joint_vel: wp.array2d(dtype=wp.float32), + joint_acc: wp.array2d(dtype=wp.float32), +): + """Write joint position and velocity data in a single kernel launch using masks. + + Args: + pos_data: Input joint positions. Shape is (num_envs, num_joints). + vel_data: Input joint velocities. Shape is (num_envs, num_joints). + env_mask: Environment mask. Shape is (num_envs,). + joint_mask: Joint mask. Shape is (num_joints,). + joint_pos: Output joint positions. Shape is (num_envs, num_joints). + joint_vel: Output joint velocities. Shape is (num_envs, num_joints). + prev_joint_vel: Output previous joint velocities. Shape is (num_envs, num_joints). + joint_acc: Output joint accelerations (reset to 0). Shape is (num_envs, num_joints). + """ + i, j = wp.tid() + if env_mask[i] and joint_mask[j]: + joint_pos[i, j] = pos_data[i, j] + joint_vel[i, j] = vel_data[i, j] + prev_joint_vel[i, j] = vel_data[i, j] + joint_acc[i, j] = 0.0 + + @wp.kernel def write_joint_limit_data_to_buffer_index( in_data: wp.array2d(dtype=wp.vec2f), diff --git a/source/isaaclab_newton/isaaclab_newton/assets/kernels.py b/source/isaaclab_newton/isaaclab_newton/assets/kernels.py index 9a9aaf402d87..a6435af0dbb8 100644 --- a/source/isaaclab_newton/isaaclab_newton/assets/kernels.py +++ b/source/isaaclab_newton/isaaclab_newton/assets/kernels.py @@ -472,29 +472,18 @@ def set_root_link_pose_to_sim_index( data: wp.array(dtype=wp.transformf), env_ids: wp.array(dtype=wp.int32), root_link_pose_w: wp.array(dtype=wp.transformf), - root_link_state_w: wp.array(dtype=vec13f), - root_state_w: wp.array(dtype=vec13f), ): """Write root link pose data to simulation buffers. - This kernel writes root link poses from the input array to the output buffers - and optionally updates the corresponding state vectors. + This kernel writes root link poses from the input array to the output buffer. Args: data: Input array of root link poses. Shape is (num_selected_envs,). env_ids: Input array of environment indices to write to. Shape is (num_selected_envs,). root_link_pose_w: Output array where root link poses are written. Shape is (num_envs,). - root_link_state_w: Output array where root link states are updated (pose portion). - Shape is (num_envs,). Can be None if not needed. - root_state_w: Output array where root states are updated (pose portion). - Shape is (num_envs,). Can be None if not needed. """ i = wp.tid() root_link_pose_w[env_ids[i]] = data[i] - if root_link_state_w: - root_link_state_w[env_ids[i]] = set_state_transforms_func(root_link_state_w[env_ids[i]], data[i]) - if root_state_w: - root_state_w[env_ids[i]] = set_state_transforms_func(root_state_w[env_ids[i]], data[i]) @wp.kernel @@ -502,30 +491,19 @@ def set_root_link_pose_to_sim_mask( data: wp.array(dtype=wp.transformf), env_mask: wp.array(dtype=wp.bool), root_link_pose_w: wp.array(dtype=wp.transformf), - root_link_state_w: wp.array(dtype=vec13f), - root_state_w: wp.array(dtype=vec13f), ): """Write root link pose data to simulation buffers. - This kernel writes root link poses from the input array to the output buffers - and optionally updates the corresponding state vectors. + This kernel writes root link poses from the input array to the output buffer. Args: data: Input array of root link poses. Shape is (num_instances,). env_mask: Input array of environment mask. Shape is (num_instances,). root_link_pose_w: Output array where root link poses are written. Shape is (num_envs,). - root_link_state_w: Output array where root link states are updated (pose portion). - Shape is (num_envs,). Can be None if not needed. - root_state_w: Output array where root states are updated (pose portion). - Shape is (num_envs,). Can be None if not needed. """ i = wp.tid() if env_mask[i]: root_link_pose_w[i] = data[i] - if root_link_state_w: - root_link_state_w[i] = set_state_transforms_func(root_link_state_w[i], data[i]) - if root_state_w: - root_state_w[i] = set_state_transforms_func(root_state_w[i], data[i]) @wp.kernel @@ -535,15 +513,11 @@ def set_root_com_pose_to_sim_index( env_ids: wp.array(dtype=wp.int32), root_com_pose_w: wp.array(dtype=wp.transformf), root_link_pose_w: wp.array(dtype=wp.transformf), - root_com_state_w: wp.array(dtype=vec13f), - root_link_state_w: wp.array(dtype=vec13f), - root_state_w: wp.array(dtype=vec13f), ): """Write root COM pose data to simulation buffers. - This kernel writes root COM poses from the input array to the output buffers, - computes the corresponding link pose from the COM pose, and optionally updates - the corresponding state vectors. + This kernel writes root COM poses from the input array to the output buffers + and computes the corresponding link pose from the COM pose. Args: data: Input array of root COM poses. Shape is (num_selected_envs,). @@ -553,27 +527,13 @@ def set_root_com_pose_to_sim_index( root_com_pose_w: Output array where root COM poses are written. Shape is (num_envs,). root_link_pose_w: Output array where root link poses (derived from COM) are written. Shape is (num_envs,). - root_com_state_w: Output array where root COM states are updated (pose portion). - Shape is (num_envs,). Can be None if not needed. - root_link_state_w: Output array where root link states are updated (pose portion). - Shape is (num_envs,). Can be None if not needed. - root_state_w: Output array where root states are updated (pose portion). - Shape is (num_envs,). Can be None if not needed. """ i = wp.tid() root_com_pose_w[env_ids[i]] = data[i] - if root_com_state_w: - root_com_state_w[env_ids[i]] = set_state_transforms_func(root_com_state_w[env_ids[i]], data[i]) # Get the com pose in the link frame root_link_pose_w[env_ids[i]] = get_com_pose_in_link_frame_func( root_com_pose_w[env_ids[i]], body_com_pos_b[env_ids[i], 0] ) - if root_link_state_w: - root_link_state_w[env_ids[i]] = set_state_transforms_func( - root_link_state_w[env_ids[i]], root_link_pose_w[env_ids[i]] - ) - if root_state_w: - root_state_w[env_ids[i]] = set_state_transforms_func(root_state_w[env_ids[i]], root_link_pose_w[env_ids[i]]) @wp.kernel @@ -583,15 +543,11 @@ def set_root_com_pose_to_sim_mask( env_mask: wp.array(dtype=wp.bool), root_com_pose_w: wp.array(dtype=wp.transformf), root_link_pose_w: wp.array(dtype=wp.transformf), - root_com_state_w: wp.array(dtype=vec13f), - root_link_state_w: wp.array(dtype=vec13f), - root_state_w: wp.array(dtype=vec13f), ): """Write root COM pose data to simulation buffers. - This kernel writes root COM poses from the input array to the output buffers, - computes the corresponding link pose from the COM pose, and optionally updates - the corresponding state vectors. + This kernel writes root COM poses from the input array to the output buffers + and computes the corresponding link pose from the COM pose. Args: data: Input array of root COM poses. Shape is (num_instances,). @@ -601,24 +557,12 @@ def set_root_com_pose_to_sim_mask( root_com_pose_w: Output array where root COM poses are written. Shape is (num_envs,). root_link_pose_w: Output array where root link poses (derived from COM) are written. Shape is (num_envs,). - root_com_state_w: Output array where root COM states are updated (pose portion). - Shape is (num_envs,). Can be None if not needed. - root_link_state_w: Output array where root link states are updated (pose portion). - Shape is (num_envs,). Can be None if not needed. - root_state_w: Output array where root states are updated (pose portion). - Shape is (num_envs,). Can be None if not needed. """ i = wp.tid() if env_mask[i]: root_com_pose_w[i] = data[i] - if root_com_state_w: - root_com_state_w[i] = set_state_transforms_func(root_com_state_w[i], data[i]) # Get the com pose in the link frame root_link_pose_w[i] = get_com_pose_in_link_frame_func(root_com_pose_w[i], body_com_pos_b[i, 0]) - if root_link_state_w: - root_link_state_w[i] = set_state_transforms_func(root_link_state_w[i], root_link_pose_w[i]) - if root_state_w: - root_state_w[i] = set_state_transforms_func(root_state_w[i], root_link_pose_w[i]) @wp.kernel @@ -628,14 +572,11 @@ def set_root_com_velocity_to_sim_index( num_bodies: wp.int32, root_com_velocity_w: wp.array(dtype=wp.spatial_vectorf), body_acc_w: wp.array2d(dtype=wp.spatial_vectorf), - root_state_w: wp.array(dtype=vec13f), - root_com_state_w: wp.array(dtype=vec13f), ): """Write root COM velocity data to simulation buffers. - This kernel writes root COM velocities from the input array to the output buffers, - optionally updates the corresponding state vectors, and zeros out the body - acceleration buffer to prevent reporting stale values. + This kernel writes root COM velocities from the input array to the output buffers + and zeros out the body acceleration buffer to prevent reporting stale values. Args: data: Input array of root COM spatial velocities. Shape is (num_selected_envs,). @@ -644,17 +585,9 @@ def set_root_com_velocity_to_sim_index( root_com_velocity_w: Output array where root COM velocities are written. Shape is (num_envs,). body_acc_w: Output array where body accelerations are zeroed. Shape is (num_envs, num_bodies). - root_state_w: Output array where root states are updated (velocity portion). - Shape is (num_envs,). Can be None if not needed. - root_com_state_w: Output array where root COM states are updated (velocity portion). - Shape is (num_envs,). Can be None if not needed. """ i = wp.tid() root_com_velocity_w[env_ids[i]] = data[i] - if root_state_w: - root_state_w[env_ids[i]] = set_state_velocities_func(root_state_w[env_ids[i]], data[i]) - if root_com_state_w: - root_com_state_w[env_ids[i]] = set_state_velocities_func(root_com_state_w[env_ids[i]], data[i]) # Make the acceleration zero to prevent reporting old values for j in range(num_bodies): body_acc_w[env_ids[i], j] = wp.spatial_vectorf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0) @@ -667,14 +600,11 @@ def set_root_com_velocity_to_sim_mask( num_bodies: wp.int32, root_com_velocity_w: wp.array(dtype=wp.spatial_vectorf), body_acc_w: wp.array2d(dtype=wp.spatial_vectorf), - root_state_w: wp.array(dtype=vec13f), - root_com_state_w: wp.array(dtype=vec13f), ): """Write root COM velocity data to simulation buffers. - This kernel writes root COM velocities from the input array to the output buffers, - optionally updates the corresponding state vectors, and zeros out the body - acceleration buffer to prevent reporting stale values. + This kernel writes root COM velocities from the input array to the output buffers + and zeros out the body acceleration buffer to prevent reporting stale values. Args: data: Input array of root COM spatial velocities. Shape is (num_instances,). @@ -683,18 +613,10 @@ def set_root_com_velocity_to_sim_mask( root_com_velocity_w: Output array where root COM velocities are written. Shape is (num_envs,). body_acc_w: Output array where body accelerations are zeroed. Shape is (num_envs, num_bodies). - root_state_w: Output array where root states are updated (velocity portion). - Shape is (num_envs,). Can be None if not needed. - root_com_state_w: Output array where root COM states are updated (velocity portion). - Shape is (num_envs,). Can be None if not needed. """ i = wp.tid() if env_mask[i]: root_com_velocity_w[i] = data[i] - if root_state_w: - root_state_w[i] = set_state_velocities_func(root_state_w[i], data[i]) - if root_com_state_w: - root_com_state_w[i] = set_state_velocities_func(root_com_state_w[i], data[i]) # Make the acceleration zero to prevent reporting old values for j in range(num_bodies): body_acc_w[i, j] = wp.spatial_vectorf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0) @@ -710,15 +632,12 @@ def set_root_link_velocity_to_sim_index( root_link_velocity_w: wp.array(dtype=wp.spatial_vectorf), root_com_velocity_w: wp.array(dtype=wp.spatial_vectorf), body_acc_w: wp.array2d(dtype=wp.spatial_vectorf), - root_link_state_w: wp.array(dtype=vec13f), - root_state_w: wp.array(dtype=vec13f), - root_com_state_w: wp.array(dtype=vec13f), ): """Write root link velocity data to simulation buffers. This kernel writes root link velocities from the input array to the output buffers, - computes the corresponding COM velocity from the link velocity, optionally updates - the corresponding state vectors, and zeros out the body acceleration buffer. + computes the corresponding COM velocity from the link velocity, and zeros out + the body acceleration buffer. Args: data: Input array of root link spatial velocities. Shape is (num_selected_envs,). @@ -733,27 +652,13 @@ def set_root_link_velocity_to_sim_index( are written. Shape is (num_envs,). body_acc_w: Output array where body accelerations are zeroed. Shape is (num_envs, num_bodies). - root_link_state_w: Output array where root link states are updated (velocity portion). - Shape is (num_envs,). Can be None if not needed. - root_state_w: Output array where root states are updated (velocity portion). - Shape is (num_envs,). Can be None if not needed. - root_com_state_w: Output array where root COM states are updated (velocity portion). - Shape is (num_envs,). Can be None if not needed. """ i = wp.tid() root_link_velocity_w[env_ids[i]] = data[i] - if root_link_state_w: - root_link_state_w[env_ids[i]] = set_state_velocities_func(root_link_state_w[env_ids[i]], data[i]) # Get the link velocity in the com frame root_com_velocity_w[env_ids[i]] = get_link_velocity_in_com_frame_func( root_link_velocity_w[env_ids[i]], link_pose_w[env_ids[i]], body_com_pos_b[env_ids[i], 0] ) - if root_com_state_w: - root_com_state_w[env_ids[i]] = set_state_velocities_func( - root_com_state_w[env_ids[i]], root_com_velocity_w[env_ids[i]] - ) - if root_state_w: - root_state_w[env_ids[i]] = set_state_velocities_func(root_state_w[env_ids[i]], root_com_velocity_w[env_ids[i]]) # Make the acceleration zero to prevent reporting old values for j in range(num_bodies): body_acc_w[env_ids[i], j] = wp.spatial_vectorf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0) @@ -769,15 +674,12 @@ def set_root_link_velocity_to_sim_mask( root_link_velocity_w: wp.array(dtype=wp.spatial_vectorf), root_com_velocity_w: wp.array(dtype=wp.spatial_vectorf), body_acc_w: wp.array2d(dtype=wp.spatial_vectorf), - root_link_state_w: wp.array(dtype=vec13f), - root_state_w: wp.array(dtype=vec13f), - root_com_state_w: wp.array(dtype=vec13f), ): """Write root link velocity data to simulation buffers. This kernel writes root link velocities from the input array to the output buffers, - computes the corresponding COM velocity from the link velocity, optionally updates - the corresponding state vectors, and zeros out the body acceleration buffer. + computes the corresponding COM velocity from the link velocity, and zeros out + the body acceleration buffer. Args: data: Input array of root link spatial velocities. Shape is (num_instances,). @@ -792,26 +694,14 @@ def set_root_link_velocity_to_sim_mask( are written. Shape is (num_envs,). body_acc_w: Output array where body accelerations are zeroed. Shape is (num_envs, num_bodies). - root_link_state_w: Output array where root link states are updated (velocity portion). - Shape is (num_envs,). Can be None if not needed. - root_state_w: Output array where root states are updated (velocity portion). - Shape is (num_envs,). Can be None if not needed. - root_com_state_w: Output array where root COM states are updated (velocity portion). - Shape is (num_envs,). Can be None if not needed. """ i = wp.tid() if env_mask[i]: root_link_velocity_w[i] = data[i] - if root_link_state_w: - root_link_state_w[i] = set_state_velocities_func(root_link_state_w[i], data[i]) # Get the link velocity in the com frame root_com_velocity_w[i] = get_link_velocity_in_com_frame_func( root_link_velocity_w[i], link_pose_w[i], body_com_pos_b[i, 0] ) - if root_com_state_w: - root_com_state_w[i] = set_state_velocities_func(root_com_state_w[i], root_com_velocity_w[i]) - if root_state_w: - root_state_w[i] = set_state_velocities_func(root_state_w[i], root_com_velocity_w[i]) # Make the acceleration zero to prevent reporting old values for j in range(num_bodies): body_acc_w[i, j] = wp.spatial_vectorf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0) 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 e59a070d23c8..7e31fa22b60f 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 @@ -326,8 +326,6 @@ def write_root_link_pose_to_sim_index( ], outputs=[ self.data.root_link_pose_w, - None, # self.data._root_link_state_w.data, - None, # self.data._root_state_w.data, ], device=self.device, ) @@ -376,8 +374,6 @@ def write_root_link_pose_to_sim_mask( ], outputs=[ self.data.root_link_pose_w, - None, # self.data._root_link_state_w.data, - None, # self.data._root_state_w.data, ], device=self.device, ) @@ -431,9 +427,6 @@ def write_root_com_pose_to_sim_index( outputs=[ self.data.root_com_pose_w, self.data.root_link_pose_w, - None, # self.data._root_com_state_w.data, - None, # self.data._root_link_state_w.data, - None, # self.data._root_state_w.data, ], device=self.device, ) @@ -486,9 +479,6 @@ def write_root_com_pose_to_sim_mask( outputs=[ self.data.root_com_pose_w, self.data.root_link_pose_w, - None, # self.data._root_com_state_w.data, - None, # self.data._root_link_state_w.data, - None, # self.data._root_state_w.data, ], device=self.device, ) @@ -545,8 +535,6 @@ def write_root_com_velocity_to_sim_index( outputs=[ self.data.root_com_vel_w, self.data.body_com_acc_w, - None, # self.data._root_state_w.data, - None, # self.data._root_com_state_w.data, ], device=self.device, ) @@ -598,8 +586,6 @@ def write_root_com_velocity_to_sim_mask( outputs=[ self.data.root_com_vel_w, self.data.body_com_acc_w, - None, # self.data._root_state_w.data, - None, # self.data._root_com_state_w.data, ], device=self.device, ) @@ -657,9 +643,6 @@ def write_root_link_velocity_to_sim_index( self.data.root_link_vel_w, self.data.root_com_vel_w, self.data.body_com_acc_w, - None, # self.data._root_link_state_w.data, - None, # self.data._root_state_w.data, - None, # self.data._root_com_state_w.data, ], device=self.device, ) @@ -716,9 +699,6 @@ def write_root_link_velocity_to_sim_mask( self.data.root_link_vel_w, self.data.root_com_vel_w, self.data.body_com_acc_w, - None, # self.data._root_link_state_w.data, - None, # self.data._root_state_w.data, - None, # self.data._root_com_state_w.data, ], device=self.device, ) @@ -1108,9 +1088,6 @@ def _process_cfg(self) -> None: def _resolve_env_ids(self, env_ids: Sequence[int] | torch.Tensor | wp.array | None) -> wp.array | torch.Tensor: """Resolve environment indices to a warp array or tensor. - .. note:: - We need to convert torch tensors to warp arrays since the TensorAPI views only support warp arrays. - Args: env_ids: Environment indices. If None, then all indices are used. @@ -1119,28 +1096,31 @@ def _resolve_env_ids(self, env_ids: Sequence[int] | torch.Tensor | wp.array | No """ if (env_ids is None) or (env_ids == slice(None)): return self._ALL_INDICES - elif isinstance(env_ids, list): - return wp.array(env_ids, dtype=wp.int32, device=self.device) if isinstance(env_ids, torch.Tensor): - return wp.from_torch(env_ids.to(torch.int32), dtype=wp.int32) + if env_ids.dtype == torch.int64: + env_ids = env_ids.to(torch.int32) + return wp.from_torch(env_ids, dtype=wp.int32) + if isinstance(env_ids, list): + return wp.array(env_ids, dtype=wp.int32, device=self.device) return env_ids def _resolve_body_ids(self, body_ids: Sequence[int] | torch.Tensor | wp.array | None) -> wp.array | torch.Tensor: """Resolve body indices to a warp array or tensor. - .. note:: - We do not need to convert torch tensors to warp arrays since they never get passed to the TensorAPI views. - Args: body_ids: Body indices. If None, then all indices are used. Returns: A warp array of body indices or a tensor of body indices. """ + if isinstance(body_ids, list): + return wp.array(body_ids, dtype=wp.int32, device=self.device) if (body_ids is None) or (body_ids == slice(None)): return self._ALL_BODY_INDICES - elif isinstance(body_ids, list): - return wp.array(body_ids, dtype=wp.int32, device=self.device) + if isinstance(body_ids, torch.Tensor): + if body_ids.dtype == torch.int64: + body_ids = body_ids.to(torch.int32) + return wp.from_torch(body_ids, dtype=wp.int32) return body_ids """ 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 ddb10a2378e9..8c499d75396c 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 @@ -1195,27 +1195,45 @@ def _process_cfg(self) -> None: self.data.default_body_vel = wp.array(default_body_vels, dtype=wp.spatial_vectorf, device=self.device) def _resolve_env_ids(self, env_ids) -> wp.array: - """Resolve environment indices to a warp array.""" - if isinstance(env_ids, list): - return wp.array(env_ids, dtype=wp.int32, device=self.device) + """Resolve environment indices to a warp array. + + Args: + env_ids: Environment indices. If None, then all indices are used. + + Returns: + A warp array of environment indices. + """ if (env_ids is None) or (env_ids == slice(None)): return self._ALL_ENV_INDICES if isinstance(env_ids, torch.Tensor): - return wp.from_torch(env_ids.to(torch.int32), dtype=wp.int32) + if env_ids.dtype == torch.int64: + env_ids = env_ids.to(torch.int32) + return wp.from_torch(env_ids, dtype=wp.int32) + if isinstance(env_ids, list): + return wp.array(env_ids, dtype=wp.int32, device=self.device) return env_ids def _resolve_body_ids(self, body_ids) -> wp.array: - """Resolve body indices to a warp array.""" - if body_ids is None or (body_ids == slice(None)): + """Resolve body indices to a warp array. + + Args: + body_ids: Body indices. If None, then all indices are used. + + Returns: + A warp array of body indices. + """ + if isinstance(body_ids, list): + return wp.array(body_ids, dtype=wp.int32, device=self.device) + if (body_ids is None) or (body_ids == slice(None)): return self._ALL_BODY_INDICES if isinstance(body_ids, slice): return wp.from_torch( torch.arange(self.num_bodies, dtype=torch.int32, device=self.device)[body_ids], dtype=wp.int32 ) - if isinstance(body_ids, list): - return wp.array(body_ids, dtype=wp.int32, device=self.device) if isinstance(body_ids, torch.Tensor): - return wp.from_torch(body_ids.to(torch.int32), dtype=wp.int32) + if body_ids.dtype == torch.int64: + body_ids = body_ids.to(torch.int32) + return wp.from_torch(body_ids, dtype=wp.int32) return body_ids def _resolve_env_mask(self, env_mask: wp.array | None) -> wp.array | torch.Tensor: diff --git a/source/isaaclab_physx/benchmark/assets/benchmark_articulation.py b/source/isaaclab_physx/benchmark/assets/benchmark_articulation.py index 9b2620022b51..60969e714da0 100644 --- a/source/isaaclab_physx/benchmark/assets/benchmark_articulation.py +++ b/source/isaaclab_physx/benchmark/assets/benchmark_articulation.py @@ -39,6 +39,7 @@ parser.add_argument("--mode", type=str, default="all", help="Benchmark mode (all, torch_list, torch_tensor)") parser.add_argument("--output_dir", type=str, default=".", help="Output directory for results") parser.add_argument("--backend", type=str, default="json", choices=["json", "osmo", "omniperf"], help="Metrics backend") +parser.add_argument("--no_shape_checks", action="store_true", help="Disable shape/dtype assertions") # append AppLauncher cli args AppLauncher.add_app_launcher_args(parser) @@ -124,6 +125,7 @@ def create_test_articulation( object.__setattr__(articulation, "_root_view", mock_view) object.__setattr__(articulation, "_device", device) + object.__setattr__(articulation, "_check_shapes", not args.no_shape_checks) # Create ArticulationData instance (SimulationManager already mocked at module level) data = ArticulationData(mock_view, device) @@ -138,21 +140,62 @@ def create_test_articulation( # Set up other required attributes object.__setattr__(articulation, "actuators", {}) object.__setattr__(articulation, "_has_implicit_actuators", False) - object.__setattr__(articulation, "_ALL_INDICES", torch.arange(num_instances, dtype=torch.int32, device=device)) - object.__setattr__(articulation, "_ALL_BODY_INDICES", torch.arange(num_bodies, dtype=torch.int32, device=device)) - object.__setattr__(articulation, "_ALL_JOINT_INDICES", torch.arange(num_joints, dtype=torch.int32, device=device)) + + # Use warp arrays for _ALL_* indices (matching real _create_buffers) + import numpy as np + + all_indices_wp = wp.array(np.arange(num_instances, dtype=np.int32), device=device) + all_joint_indices_wp = wp.array(np.arange(num_joints, dtype=np.int32), device=device) + all_body_indices_wp = wp.array(np.arange(num_bodies, dtype=np.int32), device=device) + object.__setattr__(articulation, "_ALL_INDICES", all_indices_wp) + object.__setattr__(articulation, "_ALL_JOINT_INDICES", all_joint_indices_wp) + object.__setattr__(articulation, "_ALL_BODY_INDICES", all_body_indices_wp) # Warp arrays for set_external_force_and_torque - all_indices = torch.arange(num_instances, dtype=torch.int32, device=device) - all_body_indices = torch.arange(num_bodies, dtype=torch.int32, device=device) - object.__setattr__(articulation, "_ALL_INDICES_WP", wp.from_torch(all_indices, dtype=wp.int32)) - object.__setattr__(articulation, "_ALL_BODY_INDICES_WP", wp.from_torch(all_body_indices, dtype=wp.int32)) + object.__setattr__(articulation, "_ALL_INDICES_WP", all_indices_wp) + object.__setattr__(articulation, "_ALL_BODY_INDICES_WP", all_body_indices_wp) # Initialize joint targets object.__setattr__(articulation, "_joint_pos_target_sim", torch.zeros(num_instances, num_joints, device=device)) object.__setattr__(articulation, "_joint_vel_target_sim", torch.zeros(num_instances, num_joints, device=device)) object.__setattr__(articulation, "_joint_effort_target_sim", torch.zeros(num_instances, num_joints, device=device)) + # Cached .view() wrappers + object.__setattr__(articulation, "_root_link_pose_w_f32", None) + object.__setattr__(articulation, "_root_com_vel_w_f32", None) + object.__setattr__(articulation, "_root_link_vel_w_f32", None) + + # Pre-allocated pinned CPU buffers for PhysX TensorAPI writes + N, J, B = num_instances, num_joints, num_bodies + object.__setattr__(articulation, "_cpu_env_ids_all", wp.zeros(N, dtype=wp.int32, device="cpu", pinned=True)) + wp.copy(articulation._cpu_env_ids_all, all_indices_wp) + object.__setattr__( + articulation, "_cpu_joint_stiffness", wp.zeros((N, J), dtype=wp.float32, device="cpu", pinned=True) + ) + object.__setattr__( + articulation, "_cpu_joint_damping", wp.zeros((N, J), dtype=wp.float32, device="cpu", pinned=True) + ) + object.__setattr__( + articulation, "_cpu_joint_pos_limits", wp.zeros((N, J, 2), dtype=wp.float32, device="cpu", pinned=True) + ) + object.__setattr__( + articulation, "_cpu_joint_vel_limits", wp.zeros((N, J), dtype=wp.float32, device="cpu", pinned=True) + ) + object.__setattr__( + articulation, "_cpu_joint_effort_limits", wp.zeros((N, J), dtype=wp.float32, device="cpu", pinned=True) + ) + object.__setattr__( + articulation, "_cpu_joint_armature", wp.zeros((N, J), dtype=wp.float32, device="cpu", pinned=True) + ) + object.__setattr__( + articulation, "_cpu_joint_friction_props", wp.zeros((N, J, 3), dtype=wp.float32, device="cpu", pinned=True) + ) + object.__setattr__(articulation, "_cpu_body_mass", wp.zeros((N, B), dtype=wp.float32, device="cpu", pinned=True)) + object.__setattr__(articulation, "_cpu_body_coms", wp.zeros((N, B, 7), dtype=wp.float32, device="cpu", pinned=True)) + object.__setattr__( + articulation, "_cpu_body_inertia", wp.zeros((N, B, 9), dtype=wp.float32, device="cpu", pinned=True) + ) + return articulation, mock_view, None @@ -857,6 +900,62 @@ def gen_set_external_force_and_torque_torch_tensor(config: MethodBenchmarkRunner ] +# ============================================================================= +# Fill-Ratio Benchmarks (5%, 95%, 100% of env_ids filled) +# ============================================================================= + +FILL_RATIOS = {"5pct": 0.05, "95pct": 0.95, "100pct": 1.0} + + +def _make_fill_ratio_generator(base_gen_fn, fill_ratio): + """Create a generator that subsets env_ids to a given fill ratio. + + Only env_ids are subsetted — joint_ids and body_ids remain full-range. + Data tensors keyed on env count are sliced to match. + """ + + def generator(config): + n = max(1, int(config.num_instances * fill_ratio)) + base_inputs = base_gen_fn(config) + inputs = {} + for key, val in base_inputs.items(): + if key == "env_ids": + inputs[key] = ( + torch.randperm(config.num_instances, device=config.device)[:n].sort().values.to(torch.int32) + ) + elif isinstance(val, torch.Tensor) and val.dim() >= 1 and val.shape[0] == config.num_instances: + inputs[key] = val[:n] + else: + inputs[key] = val + return inputs + + return generator + + +def _build_fill_benchmarks(): + """Auto-generate fill-ratio benchmark definitions from the torch_tensor generators.""" + fill_benchmarks = [] + for bm in BENCHMARKS: + if "torch_tensor" not in bm.input_generators: + continue + base_gen = bm.input_generators["torch_tensor"] + generators = {} + for suffix, ratio in FILL_RATIOS.items(): + generators[f"tensor_{suffix}"] = _make_fill_ratio_generator(base_gen, ratio) + fill_benchmarks.append( + MethodBenchmarkDefinition( + name=bm.name, + method_name=bm.method_name, + input_generators=generators, + category=f"{bm.category}_fill", + ) + ) + return fill_benchmarks + + +FILL_BENCHMARKS = _build_fill_benchmarks() + + def main(): """Main entry point for the benchmarking script.""" config = MethodBenchmarkRunnerConfig( @@ -892,6 +991,12 @@ def main(): ) runner.run_benchmarks(BENCHMARKS, articulation) + + print("\n" + "=" * 80) + print("Fill-Ratio Benchmarks (env_ids at 5%, 95%, 100% fill)") + print("=" * 80) + + runner.run_benchmarks(FILL_BENCHMARKS, articulation) runner.finalize() # Close the simulation app diff --git a/source/isaaclab_physx/benchmark/assets/benchmark_rigid_object.py b/source/isaaclab_physx/benchmark/assets/benchmark_rigid_object.py index eaadfe31f0a9..cf76cf6e8489 100644 --- a/source/isaaclab_physx/benchmark/assets/benchmark_rigid_object.py +++ b/source/isaaclab_physx/benchmark/assets/benchmark_rigid_object.py @@ -481,6 +481,62 @@ def gen_external_force_and_torque_torch_tensor(config: MethodBenchmarkRunnerConf ] +# ============================================================================= +# Fill-Ratio Benchmarks (5%, 95%, 100% of env_ids filled) +# ============================================================================= + +FILL_RATIOS = {"5pct": 0.05, "95pct": 0.95, "100pct": 1.0} + + +def _make_fill_ratio_generator(base_gen_fn, fill_ratio): + """Create a generator that subsets env_ids to a given fill ratio. + + Only env_ids are subsetted — body_ids remain full-range. + Data tensors keyed on env count are sliced to match. + """ + + def generator(config): + n = max(1, int(config.num_instances * fill_ratio)) + base_inputs = base_gen_fn(config) + inputs = {} + for key, val in base_inputs.items(): + if key == "env_ids": + inputs[key] = ( + torch.randperm(config.num_instances, device=config.device)[:n].sort().values.to(torch.int32) + ) + elif isinstance(val, torch.Tensor) and val.dim() >= 1 and val.shape[0] == config.num_instances: + inputs[key] = val[:n] + else: + inputs[key] = val + return inputs + + return generator + + +def _build_fill_benchmarks(): + """Auto-generate fill-ratio benchmark definitions from existing generators.""" + fill_benchmarks = [] + for bm in BENCHMARKS: + generators = {} + if "torch_tensor" in bm.input_generators: + base_gen = bm.input_generators["torch_tensor"] + for suffix, ratio in FILL_RATIOS.items(): + generators[f"tensor_{suffix}"] = _make_fill_ratio_generator(base_gen, ratio) + if generators: + fill_benchmarks.append( + MethodBenchmarkDefinition( + name=bm.name, + method_name=bm.method_name, + input_generators=generators, + category=f"{bm.category}_fill", + ) + ) + return fill_benchmarks + + +FILL_BENCHMARKS = _build_fill_benchmarks() + + def main(): """Main entry point for the benchmarking script.""" config = MethodBenchmarkRunnerConfig( @@ -512,6 +568,12 @@ def main(): ) runner.run_benchmarks(BENCHMARKS, rigid_object) + + print("\n" + "=" * 80) + print("Fill-Ratio Benchmarks (env_ids at 5%, 95%, 100% fill)") + print("=" * 80) + + runner.run_benchmarks(FILL_BENCHMARKS, rigid_object) runner.finalize() # Close the simulation app diff --git a/source/isaaclab_physx/benchmark/assets/benchmark_rigid_object_collection.py b/source/isaaclab_physx/benchmark/assets/benchmark_rigid_object_collection.py index 2617be3d2667..dc69c349adf4 100644 --- a/source/isaaclab_physx/benchmark/assets/benchmark_rigid_object_collection.py +++ b/source/isaaclab_physx/benchmark/assets/benchmark_rigid_object_collection.py @@ -382,6 +382,62 @@ def gen_inertias_torch_tensor(config: MethodBenchmarkRunnerConfig) -> dict: ] +# ============================================================================= +# Fill-Ratio Benchmarks (5%, 95%, 100% of env_ids filled) +# ============================================================================= + +FILL_RATIOS = {"5pct": 0.05, "95pct": 0.95, "100pct": 1.0} + + +def _make_fill_ratio_generator(base_gen_fn, fill_ratio): + """Create a generator that subsets env_ids to a given fill ratio. + + Only env_ids are subsetted — body_ids remain full-range. + Data tensors keyed on env count are sliced to match. + """ + + def generator(config): + n = max(1, int(config.num_instances * fill_ratio)) + base_inputs = base_gen_fn(config) + inputs = {} + for key, val in base_inputs.items(): + if key == "env_ids": + inputs[key] = ( + torch.randperm(config.num_instances, device=config.device)[:n].sort().values.to(torch.int32) + ) + elif isinstance(val, torch.Tensor) and val.dim() >= 1 and val.shape[0] == config.num_instances: + inputs[key] = val[:n] + else: + inputs[key] = val + return inputs + + return generator + + +def _build_fill_benchmarks(): + """Auto-generate fill-ratio benchmark definitions from existing generators.""" + fill_benchmarks = [] + for bm in BENCHMARKS: + generators = {} + if "torch_tensor" in bm.input_generators: + base_gen = bm.input_generators["torch_tensor"] + for suffix, ratio in FILL_RATIOS.items(): + generators[f"tensor_{suffix}"] = _make_fill_ratio_generator(base_gen, ratio) + if generators: + fill_benchmarks.append( + MethodBenchmarkDefinition( + name=bm.name, + method_name=bm.method_name, + input_generators=generators, + category=f"{bm.category}_fill", + ) + ) + return fill_benchmarks + + +FILL_BENCHMARKS = _build_fill_benchmarks() + + def main(): """Main entry point for the benchmarking script.""" config = MethodBenchmarkRunnerConfig( @@ -415,6 +471,12 @@ def main(): ) runner.run_benchmarks(BENCHMARKS, collection) + + print("\n" + "=" * 80) + print("Fill-Ratio Benchmarks (env_ids at 5%, 95%, 100% fill)") + print("=" * 80) + + runner.run_benchmarks(FILL_BENCHMARKS, collection) runner.finalize() # Close the simulation app diff --git a/source/isaaclab_physx/config/extension.toml b/source/isaaclab_physx/config/extension.toml index 3c0711934431..5c63b0e6322f 100644 --- a/source/isaaclab_physx/config/extension.toml +++ b/source/isaaclab_physx/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "0.5.28" +version = "0.5.29" # Description title = "PhysX simulation interfaces for IsaacLab core package" diff --git a/source/isaaclab_physx/docs/CHANGELOG.rst b/source/isaaclab_physx/docs/CHANGELOG.rst index 5f284dc3c891..14425eb74869 100644 --- a/source/isaaclab_physx/docs/CHANGELOG.rst +++ b/source/isaaclab_physx/docs/CHANGELOG.rst @@ -1,6 +1,21 @@ Changelog --------- +0.5.29 (2026-04-30) +~~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Added fused :meth:`~isaaclab_physx.assets.Articulation.write_joint_state_to_sim_index` + that writes joint position and velocity in a single kernel launch instead of two. +* Cached ``.view(wp.float32)`` results in root pose/velocity writers and wrench + composer views in ``write_data_to_sim`` to avoid per-call wrapper allocations. +* Pre-allocated pinned CPU buffers for all joint property and body property writers, + replacing per-call ``wp.clone(device="cpu")`` allocations with ``wp.copy`` into + reusable pinned memory. + + 0.5.28 (2026-04-29) ~~~~~~~~~~~~~~~~~~~ @@ -151,7 +166,7 @@ Changed 0.5.19 (2026-04-20) -~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~ Fixed ^^^^^ diff --git a/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation.py b/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation.py index f2459728de98..913914e29f30 100644 --- a/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation.py +++ b/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation.py @@ -239,12 +239,16 @@ def write_data_to_sim(self): if self._instantaneous_wrench_composer.active: composer = self._instantaneous_wrench_composer composer.add_raw_buffers_from(self._permanent_wrench_composer) + get_force_data = self._get_inst_wrench_force_f32 + get_torque_data = self._get_inst_wrench_torque_f32 else: composer = self._permanent_wrench_composer + get_force_data = self._get_perm_wrench_force_f32 + get_torque_data = self._get_perm_wrench_torque_f32 composer.compose_to_body_frame() self.root_view.apply_forces_and_torques_at_position( - force_data=composer.out_force_b.warp.flatten().view(wp.float32), - torque_data=composer.out_torque_b.warp.flatten().view(wp.float32), + force_data=get_force_data(), + torque_data=get_torque_data(), position_data=None, indices=self._ALL_INDICES, is_global=False, @@ -370,7 +374,7 @@ def write_root_pose_to_sim_index( The root pose comprises of the cartesian position and quaternion orientation in (x, y, z, w). .. note:: - This method expect partial data. + This method expects partial data. .. tip:: For maximum performance we recommend using the index method. This is because in PhysX, the tensor API @@ -394,7 +398,7 @@ def write_root_pose_to_sim_mask( The root pose comprises of the cartesian position and quaternion orientation in (x, y, z, w). .. note:: - This method expect full data. + This method expects full data. .. tip:: For maximum performance we recommend using the index method. This is because in PhysX, the tensor API @@ -419,7 +423,7 @@ def write_root_link_pose_to_sim_index( The root pose comprises of the cartesian position and quaternion orientation in (x, y, z, w). .. note:: - This method expect partial data or full data. + This method expects partial data or full data. .. tip:: For maximum performance we recommend using the index method. This is because in PhysX, the tensor API @@ -448,8 +452,6 @@ def write_root_link_pose_to_sim_index( ], outputs=[ self.data.root_link_pose_w, - None, # self.data._root_link_state_w.data, - None, # self.data._root_state_w.data, ], device=self.device, ) @@ -463,7 +465,7 @@ def write_root_link_pose_to_sim_index( self.data._body_link_state_w.timestamp = -1.0 self.data._body_com_state_w.timestamp = -1.0 # set into simulation - self.root_view.set_root_transforms(self.data._root_link_pose_w.data.view(wp.float32), indices=env_ids) + self.root_view.set_root_transforms(self._get_root_link_pose_w_f32(), indices=env_ids) def write_root_link_pose_to_sim_mask( self, @@ -476,7 +478,7 @@ def write_root_link_pose_to_sim_mask( The root pose comprises of the cartesian position and quaternion orientation in (x, y, z, w). .. note:: - This method expect full data. + This method expects full data. .. tip:: For maximum performance we recommend using the index method. This is because in PhysX, the tensor API @@ -505,7 +507,7 @@ def write_root_com_pose_to_sim_index( The orientation is the orientation of the principal axes of inertia. .. note:: - This method expect partial data or full data. + This method expects partial data or full data. .. tip:: For maximum performance we recommend using the index method. This is because in PhysX, the tensor API @@ -538,9 +540,6 @@ def write_root_com_pose_to_sim_index( outputs=[ self.data.root_com_pose_w, self.data.root_link_pose_w, - None, # self.data._root_com_state_w.data, - None, # self.data._root_link_state_w.data, - None, # self.data._root_state_w.data, ], device=self.device, ) @@ -555,7 +554,7 @@ def write_root_com_pose_to_sim_index( self.data._body_link_state_w.timestamp = -1.0 self.data._body_com_state_w.timestamp = -1.0 # set into simulation - self.root_view.set_root_transforms(self.data._root_link_pose_w.data.view(wp.float32), indices=env_ids) + self.root_view.set_root_transforms(self._get_root_link_pose_w_f32(), indices=env_ids) def write_root_com_pose_to_sim_mask( self, @@ -569,7 +568,7 @@ def write_root_com_pose_to_sim_mask( The orientation is the orientation of the principal axes of inertia. .. note:: - This method expect full data. + This method expects full data. .. tip:: For maximum performance we recommend using the index method. This is because in PhysX, the tensor API @@ -599,7 +598,7 @@ def write_root_velocity_to_sim_index( This sets the velocity of the root's center of mass rather than the root's frame. .. note:: - This method expect partial data. + This method expects partial data. .. tip:: For maximum performance we recommend using the index method. This is because in PhysX, the tensor API @@ -626,7 +625,7 @@ def write_root_velocity_to_sim_mask( This sets the velocity of the root's center of mass rather than the root's frame. .. note:: - This method expect full data. + This method expects full data. .. tip:: For maximum performance we recommend using the index method. This is because in PhysX, the tensor API @@ -654,7 +653,7 @@ def write_root_com_velocity_to_sim_index( This sets the velocity of the root's center of mass rather than the root's frame. .. note:: - This method expect partial data or full data. + This method expects partial data or full data. .. tip:: For maximum performance we recommend using the index method. This is because in PhysX, the tensor API @@ -685,8 +684,6 @@ def write_root_com_velocity_to_sim_index( outputs=[ self.data.root_com_vel_w, self.data.body_com_acc_w, - None, # self.data._root_state_w.data, - None, # self.data._root_com_state_w.data, ], device=self.device, ) @@ -694,7 +691,7 @@ def write_root_com_velocity_to_sim_index( self.data._root_state_w.timestamp = -1.0 self.data._root_com_state_w.timestamp = -1.0 # set into simulation - self.root_view.set_root_velocities(self.data._root_com_vel_w.data.view(wp.float32), indices=env_ids) + self.root_view.set_root_velocities(self._get_root_com_vel_w_f32(), indices=env_ids) def write_root_com_velocity_to_sim_mask( self, @@ -710,7 +707,7 @@ def write_root_com_velocity_to_sim_mask( This sets the velocity of the root's center of mass rather than the root's frame. .. note:: - This method expect full data. + This method expects full data. .. tip:: For maximum performance we recommend using the index method. This is because in PhysX, the tensor API @@ -741,7 +738,7 @@ def write_root_link_velocity_to_sim_index( This sets the velocity of the root's frame rather than the root's center of mass. .. note:: - This method expect partial data or full data. + This method expects partial data or full data. .. tip:: For maximum performance we recommend using the index method. This is because in PhysX, the tensor API @@ -776,9 +773,6 @@ def write_root_link_velocity_to_sim_index( self.data.root_link_vel_w, self.data.root_com_vel_w, self.data.body_com_acc_w, - None, # self.data._root_link_state_w.data, - None, # self.data._root_state_w.data, - None, # self.data._root_com_state_w.data, ], device=self.device, ) @@ -787,7 +781,7 @@ def write_root_link_velocity_to_sim_index( self.data._root_state_w.timestamp = -1.0 self.data._root_com_state_w.timestamp = -1.0 # set into simulation - self.root_view.set_root_velocities(self.data._root_link_vel_w.data.view(wp.float32), indices=env_ids) + self.root_view.set_root_velocities(self._get_root_link_vel_w_f32(), indices=env_ids) def write_root_link_velocity_to_sim_mask( self, @@ -803,7 +797,7 @@ def write_root_link_velocity_to_sim_mask( This sets the velocity of the root's frame rather than the root's center of mass. .. note:: - This method expect full data. + This method expects full data. .. tip:: For maximum performance we recommend using the index method. This is because in PhysX, the tensor API @@ -819,6 +813,71 @@ def write_root_link_velocity_to_sim_mask( # Set full data to True to ensure the the right code path is taken inside the kernel. self.write_root_link_velocity_to_sim_index(root_velocity=root_velocity, env_ids=env_ids, full_data=True) + def write_joint_state_to_sim_index( + self, + *, + position: torch.Tensor | wp.array, + velocity: torch.Tensor | wp.array, + joint_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, + full_data: bool = False, + ): + """Write joint positions and velocities in a single fused kernel launch. + + .. note:: + This method expects partial data or full 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: + position: Joint positions. Shape is (len(env_ids), len(joint_ids)) or (num_instances, num_joints). + velocity: Joint velocities. Shape is (len(env_ids), len(joint_ids)) or (num_instances, num_joints). + joint_ids: Joint indices. If None, then all joints are used. + env_ids: Environment indices. If None, then all indices are used. + full_data: Whether to expect full data. Defaults to False. + """ + # resolve all indices + env_ids = self._resolve_env_ids(env_ids) + joint_ids = self._resolve_joint_ids(joint_ids) + if full_data: + self.assert_shape_and_dtype(position, (self.num_instances, self.num_joints), wp.float32, "position") + self.assert_shape_and_dtype(velocity, (self.num_instances, self.num_joints), wp.float32, "velocity") + else: + self.assert_shape_and_dtype(position, (env_ids.shape[0], joint_ids.shape[0]), wp.float32, "position") + self.assert_shape_and_dtype(velocity, (env_ids.shape[0], joint_ids.shape[0]), wp.float32, "velocity") + wp.launch( + articulation_kernels.write_joint_state_data, + dim=(env_ids.shape[0], joint_ids.shape[0]), + inputs=[ + position, + velocity, + env_ids, + joint_ids, + full_data, + ], + outputs=[ + self.data.joint_pos, + self.data.joint_vel, + self.data._previous_joint_vel, + self.data.joint_acc, + ], + device=self.device, + ) + # Invalidate buffers + self.data._body_com_vel_w.timestamp = -1.0 + self.data._body_link_vel_w.timestamp = -1.0 + self.data._body_com_pose_b.timestamp = -1.0 + self.data._body_com_pose_w.timestamp = -1.0 + self.data._body_link_pose_w.timestamp = -1.0 + self.data._body_state_w.timestamp = -1.0 + self.data._body_link_state_w.timestamp = -1.0 + self.data._body_com_state_w.timestamp = -1.0 + # set into simulation + self.root_view.set_dof_positions(self.data._joint_pos.data, indices=env_ids) + self.root_view.set_dof_velocities(self.data._joint_vel.data, indices=env_ids) + def write_joint_state_to_sim_mask( self, *, @@ -830,7 +889,7 @@ def write_joint_state_to_sim_mask( """Write joint positions and velocities over selected environment mask into the simulation. .. note:: - This method expect full data. + This method expects full data. .. tip:: For maximum performance we recommend using the index method. This is because in PhysX, the tensor API @@ -842,9 +901,12 @@ def write_joint_state_to_sim_mask( joint_mask: Joint mask. If None, then all joints are used. env_mask: Environment mask. If None, then all the instances are updated. Shape is (num_instances,). """ - # set into simulation - self.write_joint_position_to_sim_mask(position=position, env_mask=env_mask, joint_mask=joint_mask) - self.write_joint_velocity_to_sim_mask(velocity=velocity, env_mask=env_mask, joint_mask=joint_mask) + # resolve masks to indices (PhysX only supports index-based TensorAPI) + env_ids = self._resolve_env_mask(env_mask) + joint_ids = self._resolve_joint_mask(joint_mask) + self.write_joint_state_to_sim_index( + position=position, velocity=velocity, joint_ids=joint_ids, env_ids=env_ids, full_data=True + ) def write_joint_position_to_sim_index( self, @@ -857,7 +919,7 @@ def write_joint_position_to_sim_index( """Write joint positions over selected environment indices into the simulation. .. note:: - This method expect partial data or full data. + This method expects partial data or full data. .. tip:: For maximum performance we recommend using the index method. This is because in PhysX, the tensor API @@ -914,7 +976,7 @@ def write_joint_position_to_sim_mask( """Write joint positions over selected environment mask into the simulation. .. note:: - This method expect full data. + This method expects full data. .. tip:: For maximum performance we recommend using the index method. This is because in PhysX, the tensor API @@ -942,7 +1004,7 @@ def write_joint_velocity_to_sim_index( """Write joint velocities to the simulation. .. note:: - This method expect partial data or full data. + This method expects partial data or full data. .. tip:: For maximum performance we recommend using the index method. This is because in PhysX, the tensor API @@ -991,7 +1053,7 @@ def write_joint_velocity_to_sim_mask( """Write joint velocities over selected environment mask into the simulation. .. note:: - This method expect full data. + This method expects full data. .. tip:: For maximum performance we recommend using the index method. This is because in PhysX, the tensor API @@ -1023,7 +1085,7 @@ def write_joint_stiffness_to_sim_index( """Write joint stiffness over selected environment indices into the simulation. .. note:: - This method expect partial data or full data. + This method expects partial data or full data. .. tip:: For maximum performance we recommend using the index method. This is because in PhysX, the tensor API @@ -1074,7 +1136,8 @@ def write_joint_stiffness_to_sim_index( ) # Set into simulation, note that when updating "model" properties with PhysX we need to do it on CPU. cpu_env_ids = self._get_cpu_env_ids(env_ids) - self.root_view.set_dof_stiffnesses(wp.clone(self.data._joint_stiffness, device="cpu"), indices=cpu_env_ids) + wp.copy(self._cpu_joint_stiffness, self.data._joint_stiffness) + self.root_view.set_dof_stiffnesses(self._cpu_joint_stiffness, indices=cpu_env_ids) def write_joint_stiffness_to_sim_mask( self, @@ -1086,7 +1149,7 @@ def write_joint_stiffness_to_sim_mask( """Write joint stiffness over selected environment mask into the simulation. .. note:: - This method expect full data. + This method expects full data. .. tip:: For maximum performance we recommend using the index method. This is because in PhysX, the tensor API @@ -1168,7 +1231,8 @@ def write_joint_damping_to_sim_index( ) # Set into simulation, note that when updating "model" properties with PhysX we need to do it on CPU. cpu_env_ids = self._get_cpu_env_ids(env_ids) - self.root_view.set_dof_dampings(wp.clone(self.data._joint_damping, device="cpu"), indices=cpu_env_ids) + wp.copy(self._cpu_joint_damping, self.data._joint_damping) + self.root_view.set_dof_dampings(self._cpu_joint_damping, indices=cpu_env_ids) def write_joint_damping_to_sim_mask( self, @@ -1268,7 +1332,8 @@ def write_joint_position_limit_to_sim_index( logger.info(violation_message) # Set into simulation, note that when updating "model" properties with PhysX we need to do it on CPU. cpu_env_ids = self._get_cpu_env_ids(env_ids) - self.root_view.set_dof_limits(wp.clone(self.data._joint_pos_limits, device="cpu"), indices=cpu_env_ids) + wp.copy(self._cpu_joint_pos_limits, self.data._joint_pos_limits) + self.root_view.set_dof_limits(self._cpu_joint_pos_limits, indices=cpu_env_ids) def write_joint_position_limit_to_sim_mask( self, @@ -1372,7 +1437,8 @@ def write_joint_velocity_limit_to_sim_index( ) # Set into simulation, note that when updating "model" properties with PhysX we need to do it on CPU. cpu_env_ids = self._get_cpu_env_ids(env_ids) - self.root_view.set_dof_max_velocities(wp.clone(self.data._joint_vel_limits, device="cpu"), indices=cpu_env_ids) + wp.copy(self._cpu_joint_vel_limits, self.data._joint_vel_limits) + self.root_view.set_dof_max_velocities(self._cpu_joint_vel_limits, indices=cpu_env_ids) def write_joint_velocity_limit_to_sim_mask( self, @@ -1473,7 +1539,8 @@ def write_joint_effort_limit_to_sim_index( ) # Set into simulation, note that when updating "model" properties with PhysX we need to do it on CPU. cpu_env_ids = self._get_cpu_env_ids(env_ids) - self.root_view.set_dof_max_forces(wp.clone(self.data._joint_effort_limits, device="cpu"), indices=cpu_env_ids) + wp.copy(self._cpu_joint_effort_limits, self.data._joint_effort_limits) + self.root_view.set_dof_max_forces(self._cpu_joint_effort_limits, indices=cpu_env_ids) def write_joint_effort_limit_to_sim_mask( self, @@ -1572,7 +1639,8 @@ def write_joint_armature_to_sim_index( if isinstance(env_ids, torch.Tensor): env_ids = wp.from_torch(env_ids, dtype=wp.int32) cpu_env_ids = self._get_cpu_env_ids(env_ids) - self.root_view.set_dof_armatures(wp.clone(self.data._joint_armature, device="cpu"), indices=cpu_env_ids) + wp.copy(self._cpu_joint_armature, self.data._joint_armature) + self.root_view.set_dof_armatures(self._cpu_joint_armature, indices=cpu_env_ids) def write_joint_armature_to_sim_mask( self, @@ -1711,7 +1779,8 @@ def write_joint_friction_coefficient_to_sim_index( ) # Set into simulation, note that when updating "model" properties with PhysX we need to do it on CPU. cpu_env_ids = self._get_cpu_env_ids(env_ids) - self.root_view.set_dof_friction_properties(wp.clone(friction_props, device="cpu"), indices=cpu_env_ids) + wp.copy(self._cpu_joint_friction_props, friction_props) + self.root_view.set_dof_friction_properties(self._cpu_joint_friction_props, indices=cpu_env_ids) def write_joint_friction_coefficient_to_sim_mask( self, @@ -1828,7 +1897,8 @@ def write_joint_dynamic_friction_coefficient_to_sim_index( ) # Set into simulation, note that when updating "model" properties with PhysX we need to do it on CPU. cpu_env_ids = self._get_cpu_env_ids(env_ids) - self.root_view.set_dof_friction_properties(wp.clone(friction_props, device="cpu"), indices=cpu_env_ids) + wp.copy(self._cpu_joint_friction_props, friction_props) + self.root_view.set_dof_friction_properties(self._cpu_joint_friction_props, indices=cpu_env_ids) def write_joint_dynamic_friction_coefficient_to_sim_mask( self, @@ -1928,7 +1998,8 @@ def write_joint_viscous_friction_coefficient_to_sim_index( ) # Set into simulation, note that when updating "model" properties with PhysX we need to do it on CPU. cpu_env_ids = self._get_cpu_env_ids(env_ids) - self.root_view.set_dof_friction_properties(wp.clone(friction_props, device="cpu"), indices=cpu_env_ids) + wp.copy(self._cpu_joint_friction_props, friction_props) + self.root_view.set_dof_friction_properties(self._cpu_joint_friction_props, indices=cpu_env_ids) def write_joint_viscous_friction_coefficient_to_sim_mask( self, @@ -2015,7 +2086,8 @@ def set_masses_index( # Set into simulation, note that when updating "model" properties with PhysX we need to do it on CPU. cpu_env_ids = self._get_cpu_env_ids(env_ids) - self.root_view.set_masses(wp.clone(self.data._body_mass, device="cpu"), indices=cpu_env_ids) + wp.copy(self._cpu_body_mass, self.data._body_mass) + self.root_view.set_masses(self._cpu_body_mass, indices=cpu_env_ids) def set_masses_mask( self, @@ -2094,12 +2166,11 @@ def set_coms_index( # Set into simulation, note that when updating "model" properties with PhysX we need to do it on CPU. # Convert from wp.transformf to flat (N, M, 7) array for PhysX cpu_env_ids = self._get_cpu_env_ids(env_ids) - body_com_flat = ( - wp.clone(self.data._body_com_pose_b.data, device="cpu") - .view(wp.float32) - .reshape((self.num_instances, self.num_bodies, 7)) + wp.copy( + self._cpu_body_coms, + self.data._body_com_pose_b.data.view(wp.float32).reshape((self.num_instances, self.num_bodies, 7)), ) - self.root_view.set_coms(body_com_flat, indices=cpu_env_ids) + self.root_view.set_coms(self._cpu_body_coms, indices=cpu_env_ids) def set_coms_mask( self, @@ -2177,7 +2248,8 @@ def set_inertias_index( ) # Set into simulation, note that when updating "model" properties with PhysX we need to do it on CPU. cpu_env_ids = self._get_cpu_env_ids(env_ids) - self.root_view.set_inertias(wp.clone(self.data._body_inertia, device="cpu"), indices=cpu_env_ids) + wp.copy(self._cpu_body_inertia, self.data._body_inertia) + self.root_view.set_inertias(self._cpu_body_inertia, indices=cpu_env_ids) def set_inertias_mask( self, @@ -3668,6 +3740,35 @@ def _create_buffers(self): device=self.device, ) + # Cached .view(wp.float32) wrappers for structured warp arrays. + # These avoid per-call wp.array metadata allocation in writers. + # Reset to None each time _create_buffers runs (during initialization). + self._root_link_pose_w_f32: wp.array | None = None + self._root_com_vel_w_f32: wp.array | None = None + self._root_link_vel_w_f32: wp.array | None = None + # Cached wrench views for write_data_to_sim + self._inst_wrench_force_f32: wp.array | None = None + self._inst_wrench_torque_f32: wp.array | None = None + self._perm_wrench_force_f32: wp.array | None = None + self._perm_wrench_torque_f32: wp.array | None = None + + # Pre-allocated pinned CPU buffers for PhysX TensorAPI writes. + # PhysX requires CPU arrays for "model" property updates (stiffness, damping, etc.). + # Pinned memory enables DMA fast path and avoids per-call malloc. + N, J, B = self.num_instances, self.num_joints, self.num_bodies + self._cpu_env_ids_all = wp.zeros(N, dtype=wp.int32, device="cpu", pinned=True) + wp.copy(self._cpu_env_ids_all, self._ALL_INDICES) + self._cpu_joint_stiffness = wp.zeros((N, J), dtype=wp.float32, device="cpu", pinned=True) + self._cpu_joint_damping = wp.zeros((N, J), dtype=wp.float32, device="cpu", pinned=True) + self._cpu_joint_pos_limits = wp.zeros((N, J, 2), dtype=wp.float32, device="cpu", pinned=True) + self._cpu_joint_vel_limits = wp.zeros((N, J), dtype=wp.float32, device="cpu", pinned=True) + self._cpu_joint_effort_limits = wp.zeros((N, J), dtype=wp.float32, device="cpu", pinned=True) + self._cpu_joint_armature = wp.zeros((N, J), dtype=wp.float32, device="cpu", pinned=True) + self._cpu_joint_friction_props = wp.zeros((N, J, 3), dtype=wp.float32, device="cpu", pinned=True) + self._cpu_body_mass = wp.zeros((N, B), dtype=wp.float32, device="cpu", pinned=True) + self._cpu_body_coms = wp.zeros((N, B, 7), dtype=wp.float32, device="cpu", pinned=True) + self._cpu_body_inertia = wp.zeros((N, B, 9), dtype=wp.float32, device="cpu", pinned=True) + def _process_cfg(self): """Post processing of configuration parameters.""" # default state @@ -4212,17 +4313,23 @@ def format_limits(_, v: tuple[float, float]) -> str: ) def _get_cpu_env_ids(self, env_ids: wp.array | torch.Tensor) -> wp.array: - """ - Get the CPU environment indices. + """Get the CPU environment indices. + + For the full-index case (all environments), returns the pre-allocated + pinned CPU buffer. For partial indices (e.g. during partial resets), clones to CPU. Args: env_ids: Environment indices. Returns: - A warp array of environment indices. + A warp array of environment indices on CPU. """ if isinstance(env_ids, torch.Tensor): env_ids = wp.from_torch(env_ids, dtype=wp.int32) + # Fast path: if these are all indices, use pre-allocated pinned buffer + if env_ids.ptr == self._ALL_INDICES.ptr: + return self._cpu_env_ids_all + # Slow path: partial indices (reset), clone to CPU return wp.clone(env_ids, device="cpu") def _resolve_env_mask(self, env_mask: wp.array | None) -> torch.Tensor | wp.array: @@ -4244,12 +4351,55 @@ def _resolve_env_mask(self, env_mask: wp.array | None) -> torch.Tensor | wp.arra env_ids = self._ALL_INDICES return env_ids + def _get_root_link_pose_w_f32(self) -> wp.array: + """Get a cached float32 view of root_link_pose_w for PhysX TensorAPI. Invalidated in ``_create_buffers``.""" + if self._root_link_pose_w_f32 is None: + self._root_link_pose_w_f32 = self.data._root_link_pose_w.data.view(wp.float32) + return self._root_link_pose_w_f32 + + def _get_root_com_vel_w_f32(self) -> wp.array: + """Get a cached float32 view of root_com_vel_w for PhysX TensorAPI. Invalidated in ``_create_buffers``.""" + if self._root_com_vel_w_f32 is None: + self._root_com_vel_w_f32 = self.data._root_com_vel_w.data.view(wp.float32) + return self._root_com_vel_w_f32 + + def _get_root_link_vel_w_f32(self) -> wp.array: + """Get a cached float32 view of root_link_vel_w for PhysX TensorAPI. Invalidated in ``_create_buffers``.""" + if self._root_link_vel_w_f32 is None: + self._root_link_vel_w_f32 = self.data._root_link_vel_w.data.view(wp.float32) + return self._root_link_vel_w_f32 + + def _get_inst_wrench_force_f32(self) -> wp.array: + """Get a cached flattened float32 view of instantaneous wrench force. Invalidated in ``_create_buffers``.""" + if self._inst_wrench_force_f32 is None: + self._inst_wrench_force_f32 = self._instantaneous_wrench_composer.out_force_b.warp.flatten().view( + wp.float32 + ) + return self._inst_wrench_force_f32 + + def _get_inst_wrench_torque_f32(self) -> wp.array: + """Get a cached flattened float32 view of instantaneous wrench torque. Invalidated in ``_create_buffers``.""" + if self._inst_wrench_torque_f32 is None: + self._inst_wrench_torque_f32 = self._instantaneous_wrench_composer.out_torque_b.warp.flatten().view( + wp.float32 + ) + return self._inst_wrench_torque_f32 + + def _get_perm_wrench_force_f32(self) -> wp.array: + """Get a cached flattened float32 view of permanent wrench force. Invalidated in ``_create_buffers``.""" + if self._perm_wrench_force_f32 is None: + self._perm_wrench_force_f32 = self._permanent_wrench_composer.out_force_b.warp.flatten().view(wp.float32) + return self._perm_wrench_force_f32 + + def _get_perm_wrench_torque_f32(self) -> wp.array: + """Get a cached flattened float32 view of permanent wrench torque. Invalidated in ``_create_buffers``.""" + if self._perm_wrench_torque_f32 is None: + self._perm_wrench_torque_f32 = self._permanent_wrench_composer.out_torque_b.warp.flatten().view(wp.float32) + return self._perm_wrench_torque_f32 + def _resolve_env_ids(self, env_ids: Sequence[int] | torch.Tensor | wp.array | None) -> wp.array: """Resolve environment indices to a warp array. - .. note:: - We need to convert torch tensors to warp arrays since the TensorAPI views only support warp arrays. - Args: env_ids: Environment indices. If None, then all indices are used. @@ -4259,7 +4409,6 @@ def _resolve_env_ids(self, env_ids: Sequence[int] | torch.Tensor | wp.array | No if (env_ids is None) or (env_ids == slice(None)): return self._ALL_INDICES if isinstance(env_ids, torch.Tensor): - # Convert int64 to int32 if needed, as warp expects int32 if env_ids.dtype == torch.int64: env_ids = env_ids.to(torch.int32) return wp.from_torch(env_ids, dtype=wp.int32) @@ -4287,9 +4436,6 @@ def _resolve_joint_mask(self, joint_mask: wp.array | None) -> torch.Tensor | wp. def _resolve_joint_ids(self, joint_ids: Sequence[int] | torch.Tensor | wp.array | None) -> wp.array | torch.Tensor: """Resolve joint indices to a warp array or tensor. - .. note:: - We do not need to convert torch tensors to warp arrays since they never get passed to the TensorAPI views. - Args: joint_ids: Joint indices. If None, then all indices are used. @@ -4300,6 +4446,10 @@ def _resolve_joint_ids(self, joint_ids: Sequence[int] | torch.Tensor | wp.array return wp.array(joint_ids, dtype=wp.int32, device=self.device) if (joint_ids is None) or (joint_ids == slice(None)): return self._ALL_JOINT_INDICES + if isinstance(joint_ids, torch.Tensor): + if joint_ids.dtype == torch.int64: + joint_ids = joint_ids.to(torch.int32) + return wp.from_torch(joint_ids, dtype=wp.int32) return joint_ids def _resolve_body_mask(self, body_mask: wp.array | None) -> torch.Tensor | wp.array: @@ -4332,6 +4482,10 @@ def _resolve_body_ids(self, body_ids: Sequence[int] | torch.Tensor | wp.array | return wp.array(body_ids, dtype=wp.int32, device=self.device) if (body_ids is None) or (body_ids == slice(None)): return self._ALL_BODY_INDICES + if isinstance(body_ids, torch.Tensor): + if body_ids.dtype == torch.int64: + body_ids = body_ids.to(torch.int32) + return wp.from_torch(body_ids, dtype=wp.int32) return body_ids def _resolve_fixed_tendon_mask(self, fixed_tendon_mask: wp.array | None) -> torch.Tensor | wp.array: @@ -4544,14 +4698,11 @@ def write_joint_state_to_sim( joint_ids: Sequence[int] | torch.Tensor | wp.array | None = None, env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, ): - """Deprecated, same as :meth:`write_joint_position_to_sim_index` and - :meth:`write_joint_velocity_to_sim_index`.""" + """Deprecated, same as :meth:`write_joint_state_to_sim_index`.""" warnings.warn( "The function 'write_joint_state_to_sim' will be deprecated in a future release. Please" - " use 'write_joint_position_to_sim_index' and 'write_joint_velocity_to_sim_index' instead.", + " use 'write_joint_state_to_sim_index' instead.", DeprecationWarning, stacklevel=2, ) - # set into simulation - self.write_joint_position_to_sim_index(position=position, joint_ids=joint_ids, env_ids=env_ids) - self.write_joint_velocity_to_sim_index(velocity=velocity, joint_ids=joint_ids, env_ids=env_ids) + self.write_joint_state_to_sim_index(position=position, velocity=velocity, joint_ids=joint_ids, env_ids=env_ids) diff --git a/source/isaaclab_physx/isaaclab_physx/assets/articulation/kernels.py b/source/isaaclab_physx/isaaclab_physx/assets/articulation/kernels.py index b9516356d836..5686c864dd94 100644 --- a/source/isaaclab_physx/isaaclab_physx/assets/articulation/kernels.py +++ b/source/isaaclab_physx/isaaclab_physx/assets/articulation/kernels.py @@ -101,6 +101,47 @@ def write_joint_vel_data( joint_acc[env_ids[i], joint_ids[j]] = 0.0 +@wp.kernel +def write_joint_state_data( + pos_data: wp.array2d(dtype=wp.float32), + vel_data: wp.array2d(dtype=wp.float32), + env_ids: wp.array(dtype=wp.int32), + joint_ids: wp.array(dtype=wp.int32), + full_data: bool, + joint_pos: wp.array2d(dtype=wp.float32), + joint_vel: wp.array2d(dtype=wp.float32), + prev_joint_vel: wp.array2d(dtype=wp.float32), + joint_acc: wp.array2d(dtype=wp.float32), +): + """Write joint position and velocity data in a single kernel launch. + + Args: + pos_data: Input joint positions. Shape is (num_envs, num_joints) if full_data, + otherwise (num_selected_envs, num_selected_joints). + vel_data: Input joint velocities. Shape is (num_envs, num_joints) if full_data, + otherwise (num_selected_envs, num_selected_joints). + env_ids: Environment indices. Shape is (num_selected_envs,). + joint_ids: Joint indices. Shape is (num_selected_joints,). + full_data: If True, data has full (num_envs, num_joints) shape and env_ids/joint_ids + index into it. If False, data is pre-sliced and indexed by thread position. + joint_pos: Output joint positions. Shape is (num_envs, num_joints). + joint_vel: Output joint velocities. Shape is (num_envs, num_joints). + prev_joint_vel: Output previous joint velocities. Shape is (num_envs, num_joints). + joint_acc: Output joint accelerations (reset to 0). Shape is (num_envs, num_joints). + """ + i, j = wp.tid() + if full_data: + p = pos_data[env_ids[i], joint_ids[j]] + v = vel_data[env_ids[i], joint_ids[j]] + else: + p = pos_data[i, j] + v = vel_data[i, j] + joint_pos[env_ids[i], joint_ids[j]] = p + joint_vel[env_ids[i], joint_ids[j]] = v + prev_joint_vel[env_ids[i], joint_ids[j]] = v + joint_acc[env_ids[i], joint_ids[j]] = 0.0 + + @wp.kernel def write_joint_limit_data_to_buffer( in_data: wp.array2d(dtype=wp.vec2f), 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 d6476ba24a27..a240cb8203b5 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 @@ -267,7 +267,7 @@ def write_nodal_pos_to_sim_index( self._data._nodal_state_w.timestamp = -1.0 self._data._root_pos_w.timestamp = -1.0 # set into simulation - self.root_view.set_simulation_nodal_positions(self._data._nodal_pos_w.data.view(wp.float32), indices=env_ids) + self.root_view.set_simulation_nodal_positions(self._get_nodal_pos_w_f32(), indices=env_ids) def write_nodal_pos_to_sim_mask( self, @@ -335,7 +335,7 @@ def write_nodal_velocity_to_sim_index( self._data._nodal_state_w.timestamp = -1.0 self._data._root_vel_w.timestamp = -1.0 # set into simulation - self.root_view.set_simulation_nodal_velocities(self._data._nodal_vel_w.data.view(wp.float32), indices=env_ids) + self.root_view.set_simulation_nodal_velocities(self._get_nodal_vel_w_f32(), indices=env_ids) def write_nodal_velocity_to_sim_mask( self, @@ -527,14 +527,45 @@ def transform_nodal_pos( Internal helper. """ + def _get_nodal_pos_w_f32(self) -> wp.array: + """Get a cached float32 view of nodal_pos_w for PhysX TensorAPI. + + Safe because ``DeformableObjectData`` copies into a stable pre-allocated + buffer via ``wp.copy`` (the pointer never changes). + Invalidated in ``_create_buffers``. + """ + if self._nodal_pos_w_f32 is None: + self._nodal_pos_w_f32 = self._data._nodal_pos_w.data.view(wp.float32) + return self._nodal_pos_w_f32 + + def _get_nodal_vel_w_f32(self) -> wp.array: + """Get a cached float32 view of nodal_vel_w for PhysX TensorAPI. + + Safe because ``DeformableObjectData`` copies into a stable pre-allocated + buffer via ``wp.copy`` (the pointer never changes). + Invalidated in ``_create_buffers``. + """ + if self._nodal_vel_w_f32 is None: + self._nodal_vel_w_f32 = self._data._nodal_vel_w.data.view(wp.float32) + return self._nodal_vel_w_f32 + def _resolve_env_ids(self, env_ids): - """Resolve environment indices to a warp int32 array.""" - if env_ids is None or (isinstance(env_ids, slice) and env_ids == slice(None)): + """Resolve environment indices to a warp array. + + Args: + env_ids: Environment indices. If None, then all indices are used. + + Returns: + A warp array of environment indices. + """ + if (env_ids is None) or (env_ids == slice(None)): return self._ALL_INDICES - elif isinstance(env_ids, list): + if isinstance(env_ids, torch.Tensor): + if env_ids.dtype == torch.int64: + env_ids = env_ids.to(torch.int32) + return wp.from_torch(env_ids, dtype=wp.int32) + if isinstance(env_ids, list): return wp.array(env_ids, dtype=wp.int32, device=self.device) - elif isinstance(env_ids, torch.Tensor): - return wp.from_torch(env_ids.to(torch.int32), dtype=wp.int32) return env_ids def _initialize_impl(self): @@ -690,6 +721,11 @@ def _create_buffers(self): # constants self._ALL_INDICES = wp.array(np.arange(self.num_instances, dtype=np.int32), device=self.device) + # Cached .view(wp.float32) wrappers for structured warp arrays. + # Safe because DeformableObjectData uses wp.copy into stable buffers. + self._nodal_pos_w_f32: wp.array | None = None + self._nodal_vel_w_f32: wp.array | None = None + # default state # we use the initial nodal positions at spawn time as the default state # note: these are all in the simulation frame diff --git a/source/isaaclab_physx/isaaclab_physx/assets/deformable_object/deformable_object_data.py b/source/isaaclab_physx/isaaclab_physx/assets/deformable_object/deformable_object_data.py index 7f5d604205b5..30307c1b08ff 100644 --- a/source/isaaclab_physx/isaaclab_physx/assets/deformable_object/deformable_object_data.py +++ b/source/isaaclab_physx/isaaclab_physx/assets/deformable_object/deformable_object_data.py @@ -115,11 +115,12 @@ def nodal_pos_w(self) -> ProxyArray: """Nodal positions in simulation world frame. Shape is (num_instances, max_sim_vertices_per_body) vec3f.""" if self._nodal_pos_w.timestamp < self._sim_timestamp: # get_simulation_nodal_positions() returns (N, V, 3) float32 — view as (N, V) vec3f - self._nodal_pos_w.data = ( + src = ( self._root_view.get_simulation_nodal_positions() .view(wp.vec3f) .reshape((self._num_instances, self._max_sim_vertices)) ) + wp.copy(self._nodal_pos_w.data, src) self._nodal_pos_w.timestamp = self._sim_timestamp # Rebind ProxyArray since .data was replaced with a new wp.array if self._nodal_pos_w_ta is not None: @@ -132,11 +133,12 @@ def nodal_pos_w(self) -> ProxyArray: def nodal_vel_w(self) -> ProxyArray: """Nodal velocities in simulation world frame. Shape is (num_instances, max_sim_vertices_per_body) vec3f.""" if self._nodal_vel_w.timestamp < self._sim_timestamp: - self._nodal_vel_w.data = ( + src = ( self._root_view.get_simulation_nodal_velocities() .view(wp.vec3f) .reshape((self._num_instances, self._max_sim_vertices)) ) + wp.copy(self._nodal_vel_w.data, src) self._nodal_vel_w.timestamp = self._sim_timestamp # Rebind ProxyArray since .data was replaced with a new wp.array if self._nodal_vel_w_ta is not None: diff --git a/source/isaaclab_physx/isaaclab_physx/assets/kernels.py b/source/isaaclab_physx/isaaclab_physx/assets/kernels.py index 383bd41d0928..d54f395739ae 100644 --- a/source/isaaclab_physx/isaaclab_physx/assets/kernels.py +++ b/source/isaaclab_physx/isaaclab_physx/assets/kernels.py @@ -469,14 +469,9 @@ def set_root_link_pose_to_sim( env_ids: wp.array(dtype=wp.int32), from_mask: bool, root_link_pose_w: wp.array(dtype=wp.transformf), - root_link_state_w: wp.array(dtype=vec13f), - root_state_w: wp.array(dtype=vec13f), ): """Write root link pose data to simulation buffers. - This kernel writes root link poses from the input array to the output buffers - and optionally updates the corresponding state vectors. - Args: data: Input array of root link poses. Shape is (num_envs,) or (num_selected_envs,) depending on from_mask. @@ -484,25 +479,13 @@ def set_root_link_pose_to_sim( from_mask: Input flag indicating whether to use masked indexing. If True, env_ids are used to index into data. If False, data is indexed sequentially. root_link_pose_w: Output array where root link poses are written. Shape is (num_envs,). - root_link_state_w: Output array where root link states are updated (pose portion). - Shape is (num_envs,). Can be None if not needed. - root_state_w: Output array where root states are updated (pose portion). - Shape is (num_envs,). Can be None if not needed. """ # If from mask, then we get complete data. Otherwise, we get partial data. i = wp.tid() if from_mask: root_link_pose_w[env_ids[i]] = data[env_ids[i]] - if root_link_state_w: - root_link_state_w[env_ids[i]] = set_state_transforms_func(root_link_state_w[env_ids[i]], data[env_ids[i]]) - if root_state_w: - root_state_w[env_ids[i]] = set_state_transforms_func(root_state_w[env_ids[i]], data[env_ids[i]]) else: root_link_pose_w[env_ids[i]] = data[i] - if root_link_state_w: - root_link_state_w[env_ids[i]] = set_state_transforms_func(root_link_state_w[env_ids[i]], data[i]) - if root_state_w: - root_state_w[env_ids[i]] = set_state_transforms_func(root_state_w[env_ids[i]], data[i]) @wp.kernel @@ -513,15 +496,11 @@ def set_root_com_pose_to_sim( from_mask: bool, root_com_pose_w: wp.array(dtype=wp.transformf), root_link_pose_w: wp.array(dtype=wp.transformf), - root_com_state_w: wp.array(dtype=vec13f), - root_link_state_w: wp.array(dtype=vec13f), - root_state_w: wp.array(dtype=vec13f), ): """Write root COM pose data to simulation buffers. - This kernel writes root COM poses from the input array to the output buffers, - computes the corresponding link pose from the COM pose, and optionally updates - the corresponding state vectors. + This kernel writes root COM poses from the input array to the output buffers + and computes the corresponding link pose from the COM pose. Args: data: Input array of root COM poses. Shape is (num_envs,) or (num_selected_envs,) @@ -534,33 +513,17 @@ def set_root_com_pose_to_sim( root_com_pose_w: Output array where root COM poses are written. Shape is (num_envs,). root_link_pose_w: Output array where root link poses (derived from COM) are written. Shape is (num_envs,). - root_com_state_w: Output array where root COM states are updated (pose portion). - Shape is (num_envs,). Can be None if not needed. - root_link_state_w: Output array where root link states are updated (pose portion). - Shape is (num_envs,). Can be None if not needed. - root_state_w: Output array where root states are updated (pose portion). - Shape is (num_envs,). Can be None if not needed. """ i = wp.tid() # If from mask, then we get complete data. Otherwise, we get partial data. if from_mask: root_com_pose_w[env_ids[i]] = data[env_ids[i]] - if root_com_state_w: - root_com_state_w[env_ids[i]] = set_state_transforms_func(root_com_state_w[env_ids[i]], data[env_ids[i]]) else: root_com_pose_w[env_ids[i]] = data[i] - if root_com_state_w: - root_com_state_w[env_ids[i]] = set_state_transforms_func(root_com_state_w[env_ids[i]], data[i]) # Get the com pose in the link frame root_link_pose_w[env_ids[i]] = get_com_pose_in_link_frame_func( root_com_pose_w[env_ids[i]], body_com_pose_b[env_ids[i], 0] ) - if root_link_state_w: - root_link_state_w[env_ids[i]] = set_state_transforms_func( - root_link_state_w[env_ids[i]], root_link_pose_w[env_ids[i]] - ) - if root_state_w: - root_state_w[env_ids[i]] = set_state_transforms_func(root_state_w[env_ids[i]], root_link_pose_w[env_ids[i]]) @wp.kernel @@ -571,14 +534,11 @@ def set_root_com_velocity_to_sim( from_mask: bool, root_com_velocity_w: wp.array(dtype=wp.spatial_vectorf), body_acc_w: wp.array2d(dtype=wp.spatial_vectorf), - root_state_w: wp.array(dtype=vec13f), - root_com_state_w: wp.array(dtype=vec13f), ): """Write root COM velocity data to simulation buffers. - This kernel writes root COM velocities from the input array to the output buffers, - optionally updates the corresponding state vectors, and zeros out the body - acceleration buffer to prevent reporting stale values. + This kernel writes root COM velocities from the input array to the output buffers + and zeros out the body acceleration buffer to prevent reporting stale values. Args: data: Input array of root COM spatial velocities. Shape is (num_envs,) or @@ -590,25 +550,13 @@ def set_root_com_velocity_to_sim( root_com_velocity_w: Output array where root COM velocities are written. Shape is (num_envs,). body_acc_w: Output array where body accelerations are zeroed. Shape is (num_envs, num_bodies). - root_state_w: Output array where root states are updated (velocity portion). - Shape is (num_envs,). Can be None if not needed. - root_com_state_w: Output array where root COM states are updated (velocity portion). - Shape is (num_envs,). Can be None if not needed. """ i = wp.tid() # If from mask, then we get complete data. Otherwise, we get partial data. if from_mask: root_com_velocity_w[env_ids[i]] = data[env_ids[i]] - if root_state_w: - root_state_w[env_ids[i]] = set_state_velocities_func(root_state_w[env_ids[i]], data[env_ids[i]]) - if root_com_state_w: - root_com_state_w[env_ids[i]] = set_state_velocities_func(root_com_state_w[env_ids[i]], data[env_ids[i]]) else: root_com_velocity_w[env_ids[i]] = data[i] - if root_state_w: - root_state_w[env_ids[i]] = set_state_velocities_func(root_state_w[env_ids[i]], data[i]) - if root_com_state_w: - root_com_state_w[env_ids[i]] = set_state_velocities_func(root_com_state_w[env_ids[i]], data[i]) # Make the acceleration zero to prevent reporting old values for j in range(num_bodies): body_acc_w[env_ids[i], j] = wp.spatial_vectorf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0) @@ -625,15 +573,12 @@ def set_root_link_velocity_to_sim( root_link_velocity_w: wp.array(dtype=wp.spatial_vectorf), root_com_velocity_w: wp.array(dtype=wp.spatial_vectorf), body_acc_w: wp.array2d(dtype=wp.spatial_vectorf), - root_link_state_w: wp.array(dtype=vec13f), - root_state_w: wp.array(dtype=vec13f), - root_com_state_w: wp.array(dtype=vec13f), ): """Write root link velocity data to simulation buffers. This kernel writes root link velocities from the input array to the output buffers, - computes the corresponding COM velocity from the link velocity, optionally updates - the corresponding state vectors, and zeros out the body acceleration buffer. + computes the corresponding COM velocity from the link velocity, and zeros out the + body acceleration buffer. Args: data: Input array of root link spatial velocities. Shape is (num_envs,) or @@ -651,33 +596,17 @@ def set_root_link_velocity_to_sim( are written. Shape is (num_envs,). body_acc_w: Output array where body accelerations are zeroed. Shape is (num_envs, num_bodies). - root_link_state_w: Output array where root link states are updated (velocity portion). - Shape is (num_envs,). Can be None if not needed. - root_state_w: Output array where root states are updated (velocity portion). - Shape is (num_envs,). Can be None if not needed. - root_com_state_w: Output array where root COM states are updated (velocity portion). - Shape is (num_envs,). Can be None if not needed. """ # If from mask, then we get complete data. Otherwise, we get partial data. i = wp.tid() if from_mask: root_link_velocity_w[env_ids[i]] = data[env_ids[i]] - if root_link_state_w: - root_link_state_w[env_ids[i]] = set_state_velocities_func(root_link_state_w[env_ids[i]], data[env_ids[i]]) else: root_link_velocity_w[env_ids[i]] = data[i] - if root_link_state_w: - root_link_state_w[env_ids[i]] = set_state_velocities_func(root_link_state_w[env_ids[i]], data[i]) # Get the link velocity in the com frame root_com_velocity_w[env_ids[i]] = get_link_velocity_in_com_frame_func( root_link_velocity_w[env_ids[i]], link_pose_w[env_ids[i]], body_com_pose_b[env_ids[i], 0] ) - if root_com_state_w: - root_com_state_w[env_ids[i]] = set_state_velocities_func( - root_com_state_w[env_ids[i]], root_com_velocity_w[env_ids[i]] - ) - if root_state_w: - root_state_w[env_ids[i]] = set_state_velocities_func(root_state_w[env_ids[i]], root_com_velocity_w[env_ids[i]]) # Make the acceleration zero to prevent reporting old values for j in range(num_bodies): body_acc_w[env_ids[i], j] = wp.spatial_vectorf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0) @@ -695,14 +624,9 @@ def set_body_link_pose_to_sim( body_ids: wp.array(dtype=wp.int32), from_mask: bool, body_link_pose_w: wp.array2d(dtype=wp.transformf), - body_link_state_w: wp.array2d(dtype=vec13f), - body_state_w: wp.array2d(dtype=vec13f), ): """Write body link pose data to simulation buffers. - This kernel writes body link poses from the input array to the output buffers - and optionally updates the corresponding state vectors, for each body in each environment. - Args: data: Input array of body link poses. Shape is (num_envs, num_bodies) or (num_selected_envs, num_selected_bodies) depending on from_mask. @@ -711,32 +635,12 @@ def set_body_link_pose_to_sim( from_mask: Input flag indicating whether to use masked indexing. body_link_pose_w: Output array where body link poses are written. Shape is (num_envs, num_bodies). - body_link_state_w: Output array where body link states are updated (pose portion). - Shape is (num_envs, num_bodies). Can be None if not needed. - body_state_w: Output array where body states are updated (pose portion). - Shape is (num_envs, num_bodies). Can be None if not needed. """ i, j = wp.tid() if from_mask: body_link_pose_w[env_ids[i], body_ids[j]] = data[env_ids[i], body_ids[j]] - if body_link_state_w: - body_link_state_w[env_ids[i], body_ids[j]] = set_state_transforms_func( - body_link_state_w[env_ids[i], body_ids[j]], data[env_ids[i], body_ids[j]] - ) - if body_state_w: - body_state_w[env_ids[i], body_ids[j]] = set_state_transforms_func( - body_state_w[env_ids[i], body_ids[j]], data[env_ids[i], body_ids[j]] - ) else: body_link_pose_w[env_ids[i], body_ids[j]] = data[i, j] - if body_link_state_w: - body_link_state_w[env_ids[i], body_ids[j]] = set_state_transforms_func( - body_link_state_w[env_ids[i], body_ids[j]], data[i, j] - ) - if body_state_w: - body_state_w[env_ids[i], body_ids[j]] = set_state_transforms_func( - body_state_w[env_ids[i], body_ids[j]], data[i, j] - ) @wp.kernel @@ -748,15 +652,12 @@ def set_body_com_pose_to_sim( from_mask: bool, body_com_pose_w: wp.array2d(dtype=wp.transformf), body_link_pose_w: wp.array2d(dtype=wp.transformf), - body_com_state_w: wp.array2d(dtype=vec13f), - body_link_state_w: wp.array2d(dtype=vec13f), - body_state_w: wp.array2d(dtype=vec13f), ): """Write body COM pose data to simulation buffers. - This kernel writes body COM poses from the input array to the output buffers, - computes the corresponding link poses from the COM poses, and optionally updates - the corresponding state vectors, for each body in each environment. + This kernel writes body COM poses from the input array to the output buffers + and computes the corresponding link poses from the COM poses, for each body + in each environment. Args: data: Input array of body COM poses. Shape is (num_envs, num_bodies) or @@ -770,38 +671,16 @@ def set_body_com_pose_to_sim( Shape is (num_envs, num_bodies). body_link_pose_w: Output array where body link poses (derived from COM) are written. Shape is (num_envs, num_bodies). - body_com_state_w: Output array where body COM states are updated (pose portion). - Shape is (num_envs, num_bodies). Can be None if not needed. - body_link_state_w: Output array where body link states are updated (pose portion). - Shape is (num_envs, num_bodies). Can be None if not needed. - body_state_w: Output array where body states are updated (pose portion). - Shape is (num_envs, num_bodies). Can be None if not needed. """ i, j = wp.tid() if from_mask: body_com_pose_w[env_ids[i], body_ids[j]] = data[env_ids[i], body_ids[j]] - if body_com_state_w: - body_com_state_w[env_ids[i], body_ids[j]] = set_state_transforms_func( - body_com_state_w[env_ids[i], body_ids[j]], data[env_ids[i], body_ids[j]] - ) else: body_com_pose_w[env_ids[i], body_ids[j]] = data[i, j] - if body_com_state_w: - body_com_state_w[env_ids[i], body_ids[j]] = set_state_transforms_func( - body_com_state_w[env_ids[i], body_ids[j]], data[i, j] - ) # Get the link pose from com pose body_link_pose_w[env_ids[i], body_ids[j]] = get_com_pose_in_link_frame_func( body_com_pose_w[env_ids[i], body_ids[j]], body_com_pose_b[env_ids[i], body_ids[j]] ) - if body_link_state_w: - body_link_state_w[env_ids[i], body_ids[j]] = set_state_transforms_func( - body_link_state_w[env_ids[i], body_ids[j]], body_link_pose_w[env_ids[i], body_ids[j]] - ) - if body_state_w: - body_state_w[env_ids[i], body_ids[j]] = set_state_transforms_func( - body_state_w[env_ids[i], body_ids[j]], body_link_pose_w[env_ids[i], body_ids[j]] - ) @wp.kernel @@ -812,14 +691,11 @@ def set_body_com_velocity_to_sim( from_mask: bool, body_com_velocity_w: wp.array2d(dtype=wp.spatial_vectorf), body_acc_w: wp.array2d(dtype=wp.spatial_vectorf), - body_state_w: wp.array2d(dtype=vec13f), - body_com_state_w: wp.array2d(dtype=vec13f), ): """Write body COM velocity data to simulation buffers. - This kernel writes body COM velocities from the input array to the output buffers, - optionally updates the corresponding state vectors, and zeros out the body - acceleration buffer, for each body in each environment. + This kernel writes body COM velocities from the input array to the output buffers + and zeros out the body acceleration buffer, for each body in each environment. Args: data: Input array of body COM spatial velocities. Shape is (num_envs, num_bodies) or @@ -831,32 +707,12 @@ def set_body_com_velocity_to_sim( Shape is (num_envs, num_bodies). body_acc_w: Output array where body accelerations are zeroed. Shape is (num_envs, num_bodies). - body_state_w: Output array where body states are updated (velocity portion). - Shape is (num_envs, num_bodies). Can be None if not needed. - body_com_state_w: Output array where body COM states are updated (velocity portion). - Shape is (num_envs, num_bodies). Can be None if not needed. """ i, j = wp.tid() if from_mask: body_com_velocity_w[env_ids[i], body_ids[j]] = data[env_ids[i], body_ids[j]] - if body_state_w: - body_state_w[env_ids[i], body_ids[j]] = set_state_velocities_func( - body_state_w[env_ids[i], body_ids[j]], data[env_ids[i], body_ids[j]] - ) - if body_com_state_w: - body_com_state_w[env_ids[i], body_ids[j]] = set_state_velocities_func( - body_com_state_w[env_ids[i], body_ids[j]], data[env_ids[i], body_ids[j]] - ) else: body_com_velocity_w[env_ids[i], body_ids[j]] = data[i, j] - if body_state_w: - body_state_w[env_ids[i], body_ids[j]] = set_state_velocities_func( - body_state_w[env_ids[i], body_ids[j]], data[i, j] - ) - if body_com_state_w: - body_com_state_w[env_ids[i], body_ids[j]] = set_state_velocities_func( - body_com_state_w[env_ids[i], body_ids[j]], data[i, j] - ) # Make the acceleration zero to prevent reporting old values body_acc_w[env_ids[i], body_ids[j]] = wp.spatial_vectorf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0) @@ -872,15 +728,12 @@ def set_body_link_velocity_to_sim( body_link_velocity_w: wp.array2d(dtype=wp.spatial_vectorf), body_com_velocity_w: wp.array2d(dtype=wp.spatial_vectorf), body_acc_w: wp.array2d(dtype=wp.spatial_vectorf), - body_link_state_w: wp.array2d(dtype=vec13f), - body_state_w: wp.array2d(dtype=vec13f), - body_com_state_w: wp.array2d(dtype=vec13f), ): """Write body link velocity data to simulation buffers. This kernel writes body link velocities from the input array to the output buffers, - computes the corresponding COM velocities from the link velocities, optionally updates - the corresponding state vectors, and zeros out the body acceleration buffer. + computes the corresponding COM velocities from the link velocities, and zeros out + the body acceleration buffer. Args: data: Input array of body link spatial velocities. Shape is (num_envs, num_bodies) @@ -898,40 +751,18 @@ def set_body_link_velocity_to_sim( are written. Shape is (num_envs, num_bodies). body_acc_w: Output array where body accelerations are zeroed. Shape is (num_envs, num_bodies). - body_link_state_w: Output array where body link states are updated (velocity portion). - Shape is (num_envs, num_bodies). Can be None if not needed. - body_state_w: Output array where body states are updated (velocity portion). - Shape is (num_envs, num_bodies). Can be None if not needed. - body_com_state_w: Output array where body COM states are updated (velocity portion). - Shape is (num_envs, num_bodies). Can be None if not needed. """ i, j = wp.tid() if from_mask: body_link_velocity_w[env_ids[i], body_ids[j]] = data[env_ids[i], body_ids[j]] - if body_link_state_w: - body_link_state_w[env_ids[i], body_ids[j]] = set_state_velocities_func( - body_link_state_w[env_ids[i], body_ids[j]], data[env_ids[i], body_ids[j]] - ) else: body_link_velocity_w[env_ids[i], body_ids[j]] = data[i, j] - if body_link_state_w: - body_link_state_w[env_ids[i], body_ids[j]] = set_state_velocities_func( - body_link_state_w[env_ids[i], body_ids[j]], data[i, j] - ) # Get the link velocity in the com frame body_com_velocity_w[env_ids[i], body_ids[j]] = get_link_velocity_in_com_frame_func( body_link_velocity_w[env_ids[i], body_ids[j]], body_link_pose_w[env_ids[i], body_ids[j]], body_com_pose_b[env_ids[i], body_ids[j]], ) - if body_com_state_w: - body_com_state_w[env_ids[i], body_ids[j]] = set_state_velocities_func( - body_com_state_w[env_ids[i], body_ids[j]], body_com_velocity_w[env_ids[i], body_ids[j]] - ) - if body_state_w: - body_state_w[env_ids[i], body_ids[j]] = set_state_velocities_func( - body_state_w[env_ids[i], body_ids[j]], body_com_velocity_w[env_ids[i], body_ids[j]] - ) # Make the acceleration zero to prevent reporting old values body_acc_w[env_ids[i], body_ids[j]] = wp.spatial_vectorf(0.0, 0.0, 0.0, 0.0, 0.0, 0.0) diff --git a/source/isaaclab_physx/isaaclab_physx/assets/rigid_object/rigid_object.py b/source/isaaclab_physx/isaaclab_physx/assets/rigid_object/rigid_object.py index 6c0be5d2a6a1..930b8836859a 100644 --- a/source/isaaclab_physx/isaaclab_physx/assets/rigid_object/rigid_object.py +++ b/source/isaaclab_physx/isaaclab_physx/assets/rigid_object/rigid_object.py @@ -152,12 +152,16 @@ def write_data_to_sim(self) -> None: if self._instantaneous_wrench_composer.active: composer = self._instantaneous_wrench_composer composer.add_raw_buffers_from(self._permanent_wrench_composer) + get_force_data = self._get_inst_wrench_force_f32 + get_torque_data = self._get_inst_wrench_torque_f32 else: composer = self._permanent_wrench_composer + get_force_data = self._get_perm_wrench_force_f32 + get_torque_data = self._get_perm_wrench_torque_f32 composer.compose_to_body_frame() self.root_view.apply_forces_and_torques_at_position( - force_data=composer.out_force_b.warp.flatten().view(wp.float32), - torque_data=composer.out_torque_b.warp.flatten().view(wp.float32), + force_data=get_force_data(), + torque_data=get_torque_data(), position_data=None, indices=self._ALL_INDICES, is_global=False, @@ -330,8 +334,6 @@ def write_root_link_pose_to_sim_index( ], outputs=[ self.data.root_link_pose_w, - None, # self.data._root_link_state_w.data, - None, # self.data._root_state_w.data, ], device=self.device, ) @@ -341,7 +343,7 @@ def write_root_link_pose_to_sim_index( self.data._root_state_w.timestamp = -1.0 self.data._root_com_state_w.timestamp = -1.0 # set into simulation - self.root_view.set_transforms(self.data._root_link_pose_w.data.view(wp.float32), indices=env_ids) + self.root_view.set_transforms(self._get_root_link_pose_w_f32(), indices=env_ids) def write_root_link_pose_to_sim_mask( self, @@ -414,9 +416,6 @@ def write_root_com_pose_to_sim_index( outputs=[ self.data.root_com_pose_w, self.data.root_link_pose_w, - None, # self.data._root_com_state_w.data, - None, # self.data._root_link_state_w.data, - None, # self.data._root_state_w.data, ], device=self.device, ) @@ -425,7 +424,7 @@ def write_root_com_pose_to_sim_index( self.data._root_link_state_w.timestamp = -1.0 self.data._root_state_w.timestamp = -1.0 # set into simulation - self.root_view.set_transforms(self.data._root_link_pose_w.data.view(wp.float32), indices=env_ids) + self.root_view.set_transforms(self._get_root_link_pose_w_f32(), indices=env_ids) def write_root_com_pose_to_sim_mask( self, @@ -502,8 +501,6 @@ def write_root_com_velocity_to_sim_index( outputs=[ self.data.root_com_vel_w, self.data.body_com_acc_w, - None, # self.data._root_state_w.data, - None, # self.data._root_com_state_w.data, ], device=self.device, ) @@ -513,7 +510,7 @@ def write_root_com_velocity_to_sim_index( self.data._root_com_state_w.timestamp = -1.0 self.data._root_state_w.timestamp = -1.0 # set into simulation - self.root_view.set_velocities(self.data._root_com_vel_w.data.view(wp.float32), indices=env_ids) + self.root_view.set_velocities(self._get_root_com_vel_w_f32(), indices=env_ids) def write_root_com_velocity_to_sim_mask( self, @@ -596,9 +593,6 @@ def write_root_link_velocity_to_sim_index( self.data.root_link_vel_w, self.data.root_com_vel_w, self.data.body_com_acc_w, - None, # self.data._root_link_state_w.data, - None, # self.data._root_state_w.data, - None, # self.data._root_com_state_w.data, ], device=self.device, ) @@ -607,7 +601,7 @@ def write_root_link_velocity_to_sim_index( self.data._root_state_w.timestamp = -1.0 self.data._root_com_state_w.timestamp = -1.0 # set into simulation - self.root_view.set_velocities(self.data._root_com_vel_w.data.view(wp.float32), indices=env_ids) + self.root_view.set_velocities(self._get_root_com_vel_w_f32(), indices=env_ids) def write_root_link_velocity_to_sim_mask( self, @@ -692,11 +686,9 @@ def set_masses_index( ) # Set into simulation, note that when updating "model" properties with PhysX we need to do it on CPU. - if isinstance(env_ids, wp.array): - cpu_env_ids = wp.clone(env_ids, device="cpu") - else: - cpu_env_ids = wp.clone(wp.from_torch(env_ids, dtype=wp.int32), device="cpu") - self.root_view.set_masses(wp.clone(self.data._body_mass, device="cpu"), indices=cpu_env_ids) + cpu_env_ids = self._get_cpu_env_ids(env_ids) + wp.copy(self._cpu_body_mass, self.data._body_mass) + self.root_view.set_masses(self._cpu_body_mass, indices=cpu_env_ids) def set_masses_mask( self, @@ -779,7 +771,8 @@ def set_coms_index( ) # Set into simulation, note that when updating "model" properties with PhysX we need to do it on CPU. cpu_env_ids = self._get_cpu_env_ids(env_ids) - self.root_view.set_coms(wp.clone(self.data._body_com_pose_b.data, device="cpu"), indices=cpu_env_ids) + wp.copy(self._cpu_body_coms, self.data._body_com_pose_b.data) + self.root_view.set_coms(self._cpu_body_coms, indices=cpu_env_ids) def set_coms_mask( self, @@ -861,11 +854,9 @@ def set_inertias_index( device=self.device, ) # Set into simulation, note that when updating "model" properties with PhysX we need to do it on CPU. - if isinstance(env_ids, wp.array): - cpu_env_ids = wp.clone(env_ids, device="cpu") - else: - cpu_env_ids = wp.clone(wp.from_torch(env_ids, dtype=wp.int32), device="cpu") - self.root_view.set_inertias(wp.clone(self.data._body_inertia, device="cpu").flatten(), indices=cpu_env_ids) + cpu_env_ids = self._get_cpu_env_ids(env_ids) + wp.copy(self._cpu_body_inertia, self.data._body_inertia) + self.root_view.set_inertias(self._cpu_body_inertia.flatten(), indices=cpu_env_ids) def set_inertias_mask( self, @@ -980,6 +971,27 @@ def _create_buffers(self): # set information about rigid body into data self._data.body_names = self.body_names + # Cached .view(wp.float32) wrappers for structured warp arrays. + # These avoid per-call wp.array metadata allocation in writers. + # Reset to None each time _create_buffers runs (during initialization). + self._root_link_pose_w_f32: wp.array | None = None + self._root_com_vel_w_f32: wp.array | None = None + # Cached wrench views for write_data_to_sim + self._inst_wrench_force_f32: wp.array | None = None + self._inst_wrench_torque_f32: wp.array | None = None + self._perm_wrench_force_f32: wp.array | None = None + self._perm_wrench_torque_f32: wp.array | None = None + + # Pre-allocated pinned CPU buffers for PhysX TensorAPI writes. + # PhysX requires CPU arrays for "model" property updates (masses, coms, inertias). + # Pinned memory enables DMA fast path and avoids per-call malloc. + N, B = self.num_instances, self.num_bodies + self._cpu_env_ids_all = wp.zeros(N, dtype=wp.int32, device="cpu", pinned=True) + wp.copy(self._cpu_env_ids_all, self._ALL_INDICES) + self._cpu_body_mass = wp.zeros((N, B), dtype=wp.float32, device="cpu", pinned=True) + self._cpu_body_coms = wp.zeros((N, B, 7), dtype=wp.float32, device="cpu", pinned=True) + self._cpu_body_inertia = wp.zeros((N, B, 9), dtype=wp.float32, device="cpu", pinned=True) + def _process_cfg(self) -> None: """Post processing of configuration parameters.""" # default state @@ -1027,24 +1039,65 @@ def _resolve_body_mask(self, body_mask: wp.array | None) -> torch.Tensor | wp.ar return body_ids def _get_cpu_env_ids(self, env_ids: wp.array | torch.Tensor) -> wp.array: - """Get the CPU environment indices. + """Get CPU environment indices. Uses pre-allocated pinned buffer for full-index case. Args: env_ids: Environment indices. Returns: - A warp array of environment indices. + A warp array of environment indices on CPU. """ if isinstance(env_ids, torch.Tensor): env_ids = wp.from_torch(env_ids, dtype=wp.int32) + # Fast path: if these are all indices, use pre-allocated pinned buffer + if env_ids.ptr == self._ALL_INDICES.ptr: + return self._cpu_env_ids_all + # Slow path: partial indices (reset), clone to CPU return wp.clone(env_ids, device="cpu") + def _get_root_link_pose_w_f32(self) -> wp.array: + """Get a cached float32 view of root_link_pose_w for PhysX TensorAPI. Invalidated in ``_create_buffers``.""" + if self._root_link_pose_w_f32 is None: + self._root_link_pose_w_f32 = self.data._root_link_pose_w.data.view(wp.float32) + return self._root_link_pose_w_f32 + + def _get_root_com_vel_w_f32(self) -> wp.array: + """Get a cached float32 view of root_com_vel_w for PhysX TensorAPI. Invalidated in ``_create_buffers``.""" + if self._root_com_vel_w_f32 is None: + self._root_com_vel_w_f32 = self.data._root_com_vel_w.data.view(wp.float32) + return self._root_com_vel_w_f32 + + def _get_inst_wrench_force_f32(self) -> wp.array: + """Get a cached flattened float32 view of instantaneous wrench force. Invalidated in ``_create_buffers``.""" + if self._inst_wrench_force_f32 is None: + self._inst_wrench_force_f32 = self._instantaneous_wrench_composer.out_force_b.warp.flatten().view( + wp.float32 + ) + return self._inst_wrench_force_f32 + + def _get_inst_wrench_torque_f32(self) -> wp.array: + """Get a cached flattened float32 view of instantaneous wrench torque. Invalidated in ``_create_buffers``.""" + if self._inst_wrench_torque_f32 is None: + self._inst_wrench_torque_f32 = self._instantaneous_wrench_composer.out_torque_b.warp.flatten().view( + wp.float32 + ) + return self._inst_wrench_torque_f32 + + def _get_perm_wrench_force_f32(self) -> wp.array: + """Get a cached flattened float32 view of permanent wrench force. Invalidated in ``_create_buffers``.""" + if self._perm_wrench_force_f32 is None: + self._perm_wrench_force_f32 = self._permanent_wrench_composer.out_force_b.warp.flatten().view(wp.float32) + return self._perm_wrench_force_f32 + + def _get_perm_wrench_torque_f32(self) -> wp.array: + """Get a cached flattened float32 view of permanent wrench torque. Invalidated in ``_create_buffers``.""" + if self._perm_wrench_torque_f32 is None: + self._perm_wrench_torque_f32 = self._permanent_wrench_composer.out_torque_b.warp.flatten().view(wp.float32) + return self._perm_wrench_torque_f32 + def _resolve_env_ids(self, env_ids: Sequence[int] | torch.Tensor | wp.array | None) -> wp.array | torch.Tensor: """Resolve environment indices to a warp array or tensor. - .. note:: - We need to convert torch tensors to warp arrays since the TensorAPI views only support warp arrays. - Args: env_ids: Environment indices. If None, then all indices are used. @@ -1053,28 +1106,31 @@ def _resolve_env_ids(self, env_ids: Sequence[int] | torch.Tensor | wp.array | No """ if (env_ids is None) or (env_ids == slice(None)): return self._ALL_INDICES - elif isinstance(env_ids, list): - return wp.array(env_ids, dtype=wp.int32, device=self.device) if isinstance(env_ids, torch.Tensor): - return wp.from_torch(env_ids.to(torch.int32), dtype=wp.int32) + if env_ids.dtype == torch.int64: + env_ids = env_ids.to(torch.int32) + return wp.from_torch(env_ids, dtype=wp.int32) + if isinstance(env_ids, list): + return wp.array(env_ids, dtype=wp.int32, device=self.device) return env_ids def _resolve_body_ids(self, body_ids: Sequence[int] | torch.Tensor | wp.array | None) -> wp.array | torch.Tensor: """Resolve body indices to a warp array or tensor. - .. note:: - We do not need to convert torch tensors to warp arrays since they never get passed to the TensorAPI views. - Args: body_ids: Body indices. If None, then all indices are used. Returns: A warp array of body indices or a tensor of body indices. """ + if isinstance(body_ids, list): + return wp.array(body_ids, dtype=wp.int32, device=self.device) if (body_ids is None) or (body_ids == slice(None)): return self._ALL_BODY_INDICES - elif isinstance(body_ids, list): - return wp.array(body_ids, dtype=wp.int32, device=self.device) + if isinstance(body_ids, torch.Tensor): + if body_ids.dtype == torch.int64: + body_ids = body_ids.to(torch.int32) + return wp.from_torch(body_ids, dtype=wp.int32) return body_ids """ diff --git a/source/isaaclab_physx/isaaclab_physx/assets/rigid_object_collection/rigid_object_collection.py b/source/isaaclab_physx/isaaclab_physx/assets/rigid_object_collection/rigid_object_collection.py index 596af5c2bf11..6d07ddbf1bc1 100644 --- a/source/isaaclab_physx/isaaclab_physx/assets/rigid_object_collection/rigid_object_collection.py +++ b/source/isaaclab_physx/isaaclab_physx/assets/rigid_object_collection/rigid_object_collection.py @@ -414,8 +414,6 @@ def write_body_link_pose_to_sim_index( ], outputs=[ self.data.body_link_pose_w, - None, # self.data._body_link_state_w.data, - None, # self.data._body_state_w.data, ], device=self.device, ) @@ -510,9 +508,6 @@ def write_body_com_pose_to_sim_index( outputs=[ self.data.body_com_pose_w, self.data.body_link_pose_w, - None, # self.data._body_com_state_w.data, - None, # self.data._body_link_state_w.data, - None, # self.data._body_state_w.data, ], device=self.device, ) @@ -610,8 +605,6 @@ def write_body_com_velocity_to_sim_index( outputs=[ self.data.body_com_vel_w, self.data.body_com_acc_w, - None, # self.data._body_state_w.data, - None, # self.data._body_com_state_w.data, ], device=self.device, ) @@ -719,9 +712,6 @@ def write_body_link_velocity_to_sim_index( self.data.body_link_vel_w, self.data.body_com_vel_w, self.data.body_com_acc_w, - None, # self.data._body_link_state_w.data, - None, # self.data._body_state_w.data, - None, # self.data._body_com_state_w.data, ], device=self.device, ) @@ -1168,26 +1158,30 @@ def reshape_data_to_view_3d(self, data: wp.array, data_dim: int, device: str = " def _resolve_env_ids(self, env_ids) -> wp.array: """Resolve environment indices to a warp array.""" - if isinstance(env_ids, list): - return wp.array(env_ids, dtype=wp.int32, device=self.device) if (env_ids is None) or (env_ids == slice(None)): return self._ALL_ENV_INDICES if isinstance(env_ids, torch.Tensor): - return wp.from_torch(env_ids.to(torch.int32), dtype=wp.int32) + if env_ids.dtype == torch.int64: + env_ids = env_ids.to(torch.int32) + return wp.from_torch(env_ids, dtype=wp.int32) + if isinstance(env_ids, list): + return wp.array(env_ids, dtype=wp.int32, device=self.device) return env_ids def _resolve_body_ids(self, body_ids) -> wp.array: """Resolve body indices to a warp array.""" + if isinstance(body_ids, list): + return wp.array(body_ids, dtype=wp.int32, device=self.device) if body_ids is None or (body_ids == slice(None)): return self._ALL_BODY_INDICES if isinstance(body_ids, slice): return wp.from_torch( torch.arange(self.num_bodies, dtype=torch.int32, device=self.device)[body_ids], dtype=wp.int32 ) - if isinstance(body_ids, list): - return wp.array(body_ids, dtype=wp.int32, device=self.device) if isinstance(body_ids, torch.Tensor): - return wp.from_torch(body_ids.to(torch.int32), dtype=wp.int32) + if body_ids.dtype == torch.int64: + body_ids = body_ids.to(torch.int32) + return wp.from_torch(body_ids, dtype=wp.int32) return body_ids def _resolve_env_mask(self, env_mask: wp.array | None) -> torch.Tensor | wp.array: From 951978be61079dab88956ab6e20996655457702e Mon Sep 17 00:00:00 2001 From: rwiltz <165190220+rwiltz@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:04:16 -0400 Subject: [PATCH 07/40] Gate isaacteleop dependency on Linux to fix Windows install (#5441) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description Added platform_system == 'Linux' marker to the isaacteleop dependency in isaaclab_teleop/setup.py so pip no longer fails to resolve it on Windows. The isaaclab_teleop package itself remains in the teleop and all extras unconditionally — only the native isaacteleop library is skipped on non-Linux platforms. Fixes # (issue) ## Type of change - Bug fix (non-breaking change which fixes an issue) ## Screenshots Please attach before and after screenshots of the change if applicable. ## 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 updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- source/isaaclab_teleop/config/extension.toml | 2 +- source/isaaclab_teleop/docs/CHANGELOG.rst | 10 ++++++++++ source/isaaclab_teleop/setup.py | 3 ++- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/source/isaaclab_teleop/config/extension.toml b/source/isaaclab_teleop/config/extension.toml index 61273888c608..947103618b9a 100644 --- a/source/isaaclab_teleop/config/extension.toml +++ b/source/isaaclab_teleop/config/extension.toml @@ -1,6 +1,6 @@ [package] # Semantic Versioning is used: https://semver.org/ -version = "0.3.8" +version = "0.3.9" # Description title = "Isaac Lab Teleop" diff --git a/source/isaaclab_teleop/docs/CHANGELOG.rst b/source/isaaclab_teleop/docs/CHANGELOG.rst index 8a12edb8aef1..3856e6ec1346 100644 --- a/source/isaaclab_teleop/docs/CHANGELOG.rst +++ b/source/isaaclab_teleop/docs/CHANGELOG.rst @@ -1,6 +1,16 @@ Changelog --------- +0.3.9 (2026-04-29) +~~~~~~~~~~~~~~~~~~ + +Fixed +^^^^^ + +* Fixed installation failure on Windows by adding ``platform_system == 'Linux'`` + marker to the ``isaacteleop`` dependency, which is only available on Linux. + + 0.3.8 (2026-04-24) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_teleop/setup.py b/source/isaaclab_teleop/setup.py index 124cd979239b..7ecec8340abb 100644 --- a/source/isaaclab_teleop/setup.py +++ b/source/isaaclab_teleop/setup.py @@ -19,7 +19,8 @@ # Minimum dependencies required prior to installation INSTALL_REQUIRES = [ - "isaacteleop[retargeters,ui,cloudxr]~=1.2.0", + # IsaacTeleop is only available on Linux x86_64 + f"isaacteleop[retargeters,ui,cloudxr]~=1.2.0 ; platform_system == 'Linux' and ({SUPPORTED_ARCHS})", # required by isaaclab.devices.openxr.retargeters.humanoid.fourier.gr1_t2_dex_retargeting_utils f"dex-retargeting==0.5.0 ; platform_system == 'Linux' and ({SUPPORTED_ARCHS})", ] From 9817fce78e67f062c0e8a43f6d80f744635f2db0 Mon Sep 17 00:00:00 2001 From: Antoine RICHARD Date: Thu, 30 Apr 2026 21:07:58 +0200 Subject: [PATCH 08/40] Fix required CI checks for docs-only PRs (#5454) ## Summary - Remove PR-level path filters from required Docker and installation workflows so checks are always reported. - Add change-detection jobs that skip expensive self-hosted tests for docs-only PRs. - Add gate checks for branch protection: `Docker Tests Gate` and `Installation Tests Gate`. - Add a regression test that verifies required workflows do not use path filters and expose gate jobs. ## Testing - `./isaaclab.sh -p -m pytest .github/workflows/test_required_ci_gates.py -q` - `./isaaclab.sh -f` ## Follow-up for branch protection After this merges, update the required checks to require the gate checks instead of the individual Docker shards and installation job: - require `Docker Tests Gate` - require `Installation Tests Gate` - remove required checks for `Installation Tests`, `isaaclab (core) [1/3]`, `isaaclab (core) [2/3]`, `isaaclab (core) [3/3]`, `isaaclab_assets`, `isaaclab_contrib`, and `isaaclab_newton` --- .github/workflows/build.yaml | 135 ++++++++++++++++++-- .github/workflows/install-ci.yml | 70 ++++++++-- .github/workflows/test_required_ci_gates.py | 96 ++++++++++++++ 3 files changed, 283 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/test_required_ci_gates.py diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 8b8fb60f6bbf..de8f4898267f 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -48,13 +48,6 @@ name: Docker + Tests on: pull_request: types: [opened, synchronize, reopened] - paths: - - 'source/**' - - 'docker/**' - - 'tools/**' - - 'apps/**' - - '.github/workflows/build.yaml' - - '.github/actions/**' branches: - main - develop @@ -77,6 +70,34 @@ env: CI_IMAGE_TAG: isaac-lab-ci:${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.ref_name }}-${{ github.sha }} jobs: + changes: + name: Detect Docker Test Changes + runs-on: ubuntu-latest + outputs: + run_docker_tests: ${{ steps.detect.outputs.run_docker_tests }} + steps: + - id: detect + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + + if [ "${{ github.event_name }}" != "pull_request" ]; then + echo "run_docker_tests=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + changed_files="$(gh api --paginate "repos/${{ github.repository }}/pulls/${PR_NUMBER}/files" --jq '.[].filename')" + printf '%s\n' "$changed_files" + + # config.yaml controls the base image names and tags consumed by the Docker build jobs. + if printf '%s\n' "$changed_files" | grep -qE '^(source/|docker/|tools/|apps/|\.github/workflows/build\.yaml$|\.github/workflows/config\.yaml$|\.github/actions/)'; then + echo "run_docker_tests=true" >> "$GITHUB_OUTPUT" + else + echo "run_docker_tests=false" >> "$GITHUB_OUTPUT" + fi + config: name: Load Config runs-on: ubuntu-latest @@ -102,7 +123,8 @@ jobs: build: name: Build Base Docker Image runs-on: [self-hosted, gpu] - needs: [config] + needs: [changes, config] + if: needs.changes.outputs.run_docker_tests == 'true' steps: - name: Checkout Code uses: actions/checkout@v6 @@ -122,7 +144,8 @@ jobs: build-curobo: name: Build cuRobo Docker Image runs-on: [self-hosted, gpu] - needs: [config] + needs: [changes, config] + if: needs.changes.outputs.run_docker_tests == 'true' steps: - name: Checkout Code uses: actions/checkout@v6 @@ -507,6 +530,100 @@ jobs: container-name: isaac-lab-environments-training-test #endregion + docker-tests-gate: + name: Docker Tests Gate + runs-on: ubuntu-latest + needs: + - changes + - build + - build-curobo + - test-isaaclab-tasks + - test-isaaclab-tasks-2 + - test-isaaclab-tasks-3 + - test-isaaclab-core + - test-isaaclab-core-2 + - test-isaaclab-core-3 + - test-isaaclab-rl + - test-isaaclab-mimic + - test-isaaclab-assets + - test-isaaclab-contrib + - test-isaaclab-teleop + - test-isaaclab-visualizers + - test-isaaclab-newton + - test-isaaclab-physx + - test-isaaclab-ov + - test-curobo + - test-skillgen + - test-environments-training + if: always() + steps: + - name: Check Docker test results + env: + CHANGES_RESULT: ${{ needs.changes.result }} + RUN_DOCKER_TESTS: ${{ needs.changes.outputs.run_docker_tests }} + BUILD_RESULT: ${{ needs.build.result }} + BUILD_CUROBO_RESULT: ${{ needs.build-curobo.result }} + TASKS_1_RESULT: ${{ needs.test-isaaclab-tasks.result }} + TASKS_2_RESULT: ${{ needs.test-isaaclab-tasks-2.result }} + TASKS_3_RESULT: ${{ needs.test-isaaclab-tasks-3.result }} + CORE_1_RESULT: ${{ needs.test-isaaclab-core.result }} + CORE_2_RESULT: ${{ needs.test-isaaclab-core-2.result }} + CORE_3_RESULT: ${{ needs.test-isaaclab-core-3.result }} + RL_RESULT: ${{ needs.test-isaaclab-rl.result }} + MIMIC_RESULT: ${{ needs.test-isaaclab-mimic.result }} + ASSETS_RESULT: ${{ needs.test-isaaclab-assets.result }} + CONTRIB_RESULT: ${{ needs.test-isaaclab-contrib.result }} + TELEOP_RESULT: ${{ needs.test-isaaclab-teleop.result }} + VISUALIZERS_RESULT: ${{ needs.test-isaaclab-visualizers.result }} + NEWTON_RESULT: ${{ needs.test-isaaclab-newton.result }} + PHYSX_RESULT: ${{ needs.test-isaaclab-physx.result }} + OV_RESULT: ${{ needs.test-isaaclab-ov.result }} + CUROBO_RESULT: ${{ needs.test-curobo.result }} + SKILLGEN_RESULT: ${{ needs.test-skillgen.result }} + ENVIRONMENTS_TRAINING_RESULT: ${{ needs.test-environments-training.result }} + run: | + set -euo pipefail + + if [ "$CHANGES_RESULT" != "success" ]; then + echo "Change detection failed with result: $CHANGES_RESULT" + exit 1 + fi + + if [ "$RUN_DOCKER_TESTS" != "true" ]; then + echo "Docker tests are not required for this change." + exit 0 + fi + + failures=() + [ "$BUILD_RESULT" = "success" ] || failures+=("Build Base Docker Image: $BUILD_RESULT") + [ "$BUILD_CUROBO_RESULT" = "success" ] || failures+=("Build cuRobo Docker Image: $BUILD_CUROBO_RESULT") + [ "$TASKS_1_RESULT" = "success" ] || failures+=("isaaclab_tasks [1/3]: $TASKS_1_RESULT") + [ "$TASKS_2_RESULT" = "success" ] || failures+=("isaaclab_tasks [2/3]: $TASKS_2_RESULT") + [ "$TASKS_3_RESULT" = "success" ] || failures+=("isaaclab_tasks [3/3]: $TASKS_3_RESULT") + [ "$CORE_1_RESULT" = "success" ] || failures+=("isaaclab (core) [1/3]: $CORE_1_RESULT") + [ "$CORE_2_RESULT" = "success" ] || failures+=("isaaclab (core) [2/3]: $CORE_2_RESULT") + [ "$CORE_3_RESULT" = "success" ] || failures+=("isaaclab (core) [3/3]: $CORE_3_RESULT") + [ "$RL_RESULT" = "success" ] || failures+=("isaaclab_rl: $RL_RESULT") + [ "$MIMIC_RESULT" = "success" ] || failures+=("isaaclab_mimic: $MIMIC_RESULT") + [ "$ASSETS_RESULT" = "success" ] || failures+=("isaaclab_assets: $ASSETS_RESULT") + [ "$CONTRIB_RESULT" = "success" ] || failures+=("isaaclab_contrib: $CONTRIB_RESULT") + [ "$TELEOP_RESULT" = "success" ] || failures+=("isaaclab_teleop: $TELEOP_RESULT") + [ "$VISUALIZERS_RESULT" = "success" ] || failures+=("isaaclab_visualizers: $VISUALIZERS_RESULT") + [ "$NEWTON_RESULT" = "success" ] || failures+=("isaaclab_newton: $NEWTON_RESULT") + [ "$PHYSX_RESULT" = "success" ] || failures+=("isaaclab_physx: $PHYSX_RESULT") + [ "$OV_RESULT" = "success" ] || failures+=("isaaclab_ov: $OV_RESULT") + [ "$CUROBO_RESULT" = "success" ] || failures+=("test-curobo: $CUROBO_RESULT") + [ "$SKILLGEN_RESULT" = "success" ] || failures+=("test-skillgen: $SKILLGEN_RESULT") + [ "$ENVIRONMENTS_TRAINING_RESULT" = "success" ] || failures+=("environments_training: $ENVIRONMENTS_TRAINING_RESULT") + + if [ "${#failures[@]}" -gt 0 ]; then + printf 'Docker checks did not pass:\n' + printf ' - %s\n' "${failures[@]}" + exit 1 + fi + + echo "Docker checks passed." + #region disabled quarantined tests # test-quarantined: # name: "Quarantined Tests" diff --git a/.github/workflows/install-ci.yml b/.github/workflows/install-ci.yml index 2a33e60751e3..f2e4ebb537ce 100644 --- a/.github/workflows/install-ci.yml +++ b/.github/workflows/install-ci.yml @@ -7,15 +7,6 @@ name: Installation Tests on: pull_request: types: [opened, synchronize, reopened] - paths: - - 'apps/**' - - 'VERSION' - - 'tools/**' - - 'source/**' - - '**/pyproject.toml' - - '**/environment.yaml' - - '.github/actions/run-package-tests/**' - - '.github/workflows/install-ci.yml' push: branches: - main @@ -38,9 +29,39 @@ concurrency: permissions: contents: read + pull-requests: read jobs: + changes: + name: Detect Installation Test Changes + runs-on: ubuntu-latest + outputs: + run_install_tests: ${{ steps.detect.outputs.run_install_tests }} + steps: + - id: detect + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + + if [ "${{ github.event_name }}" != "pull_request" ]; then + echo "run_install_tests=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + changed_files="$(gh api --paginate "repos/${{ github.repository }}/pulls/${PR_NUMBER}/files" --jq '.[].filename')" + printf '%s\n' "$changed_files" + + if printf '%s\n' "$changed_files" | grep -qE '^(apps/|tools/|source/|\.github/actions/run-package-tests/|\.github/workflows/install-ci\.yml$|VERSION$)|(^|/)pyproject\.toml$|(^|/)environment\.ya?ml$'; then + echo "run_install_tests=true" >> "$GITHUB_OUTPUT" + else + echo "run_install_tests=false" >> "$GITHUB_OUTPUT" + fi + install-tests: name: Installation Tests + needs: [changes] + if: needs.changes.outputs.run_install_tests == 'true' runs-on: [self-hosted, gpu] timeout-minutes: 90 steps: @@ -61,3 +82,34 @@ jobs: fi tools/run_install_ci.py docker $RUNNER_ARGS -- --tb=short "${PYTEST_EXTRA_ARGS[@]}" + + installation-tests-gate: + name: Installation Tests Gate + needs: [changes, install-tests] + if: always() + runs-on: ubuntu-latest + steps: + - name: Check installation test result + env: + CHANGES_RESULT: ${{ needs.changes.result }} + RUN_INSTALL_TESTS: ${{ needs.changes.outputs.run_install_tests }} + INSTALL_TESTS_RESULT: ${{ needs.install-tests.result }} + run: | + set -euo pipefail + + if [ "$CHANGES_RESULT" != "success" ]; then + echo "Change detection failed with result: $CHANGES_RESULT" + exit 1 + fi + + if [ "$RUN_INSTALL_TESTS" != "true" ]; then + echo "Installation tests are not required for this change." + exit 0 + fi + + if [ "$INSTALL_TESTS_RESULT" != "success" ]; then + echo "Installation Tests did not pass: $INSTALL_TESTS_RESULT" + exit 1 + fi + + echo "Installation Tests passed." diff --git a/.github/workflows/test_required_ci_gates.py b/.github/workflows/test_required_ci_gates.py new file mode 100644 index 000000000000..1eba84acf0ca --- /dev/null +++ b/.github/workflows/test_required_ci_gates.py @@ -0,0 +1,96 @@ +# 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 + +"""Regression tests for required CI checks that must always report.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import yaml + +_WORKFLOW_DIR = Path(__file__).resolve().parent + + +def _load_workflow(name: str) -> dict[str, Any]: + with (_WORKFLOW_DIR / name).open(encoding="utf-8") as f: + return yaml.safe_load(f) + + +def _on_config(workflow: dict[str, Any]) -> dict[str, Any]: + # PyYAML follows YAML 1.1, where the key "on" is parsed as True. + return workflow.get("on", workflow.get(True, {})) + + +def _as_list(value: str | list[str]) -> list[str]: + if isinstance(value, list): + return value + return [value] + + +def _assert_job_if_is_exactly(job: dict[str, Any], expected: str) -> None: + assert job["if"] == expected + + +def test_required_docker_test_workflow_reports_for_docs_only_prs(): + workflow = _load_workflow("build.yaml") + + pull_request = _on_config(workflow)["pull_request"] + assert "paths" not in pull_request + + jobs = workflow["jobs"] + assert jobs["changes"]["outputs"]["run_docker_tests"] == "${{ steps.detect.outputs.run_docker_tests }}" + + for job_name in ("build", "build-curobo"): + job = jobs[job_name] + assert "changes" in _as_list(job["needs"]) + _assert_job_if_is_exactly(job, "needs.changes.outputs.run_docker_tests == 'true'") + + gate = jobs["docker-tests-gate"] + assert gate["name"] == "Docker Tests Gate" + assert gate["if"] == "always()" + assert gate["needs"] == [ + "changes", + "build", + "build-curobo", + "test-isaaclab-tasks", + "test-isaaclab-tasks-2", + "test-isaaclab-tasks-3", + "test-isaaclab-core", + "test-isaaclab-core-2", + "test-isaaclab-core-3", + "test-isaaclab-rl", + "test-isaaclab-mimic", + "test-isaaclab-assets", + "test-isaaclab-contrib", + "test-isaaclab-teleop", + "test-isaaclab-visualizers", + "test-isaaclab-newton", + "test-isaaclab-physx", + "test-isaaclab-ov", + "test-curobo", + "test-skillgen", + "test-environments-training", + ] + + +def test_required_installation_workflow_reports_for_docs_only_prs(): + workflow = _load_workflow("install-ci.yml") + + pull_request = _on_config(workflow)["pull_request"] + assert "paths" not in pull_request + + jobs = workflow["jobs"] + assert jobs["changes"]["outputs"]["run_install_tests"] == "${{ steps.detect.outputs.run_install_tests }}" + + install_tests = jobs["install-tests"] + assert "changes" in _as_list(install_tests["needs"]) + _assert_job_if_is_exactly(install_tests, "needs.changes.outputs.run_install_tests == 'true'") + + gate = jobs["installation-tests-gate"] + assert gate["name"] == "Installation Tests Gate" + assert gate["if"] == "always()" + assert gate["needs"] == ["changes", "install-tests"] From 88d2407c9f218c73cad170d4b4b77b619535b267 Mon Sep 17 00:00:00 2001 From: HuiDong Chen Date: Fri, 1 May 2026 03:35:45 +0800 Subject: [PATCH 09/40] OVRTX Correctness (#5401) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. Fixes # (issue) ## Type of change - Bug fix (non-breaking change which fixes an issue) - New feature (non-breaking change which adds functionality) - Breaking change (existing functionality will not work without user modification) - Documentation update ## Screenshots Please attach before and after screenshots of the change if applicable. ## 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` - [ ] 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 - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Co-authored-by: Piotr Barejko --- .github/actions/run-tests/action.yml | 3 +- source/isaaclab_ov/config/extension.toml | 2 +- source/isaaclab_ov/docs/CHANGELOG.rst | 23 + .../isaaclab_ov/renderers/ovrtx_renderer.py | 100 ++- .../renderers/ovrtx_renderer_cfg.py | 8 - .../renderers/ovrtx_renderer_kernels.py | 99 ++- .../isaaclab_ov/renderers/ovrtx_usd.py | 53 +- .../test/test_ovrtx_renderer_kernels.py | 467 +++++++++++- source/isaaclab_tasks/config/extension.toml | 2 +- source/isaaclab_tasks/docs/CHANGELOG.rst | 12 + .../cartpole/newton-ovrtx_renderer-albedo.png | 3 + .../cartpole/newton-ovrtx_renderer-depth.png | 3 + .../cartpole/newton-ovrtx_renderer-rgb.png | 3 + .../cartpole/newton-ovrtx_renderer-rgba.png | 3 + ...n-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-ovrtx_renderer-albedo.png | 3 + .../newton-ovrtx_renderer-depth.png | 3 + .../newton-ovrtx_renderer-rgb.png | 3 + .../newton-ovrtx_renderer-rgba.png | 3 + ...n-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-ovrtx_renderer-albedo.png | 3 + .../newton-ovrtx_renderer-depth.png | 3 + .../shadow_hand/newton-ovrtx_renderer-rgb.png | 3 + .../newton-ovrtx_renderer-rgba.png | 3 + ...n-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 + ...correctness.py => rendering_test_utils.py} | 719 +++++++----------- .../test/test_rendering_cartpole.py | 37 + .../test/test_rendering_cartpole_kitless.py | 33 + .../test/test_rendering_dexsuite_kuka.py | 38 + .../test_rendering_dexsuite_kuka_kitless.py | 34 + .../test/test_rendering_registered_tasks.py | 119 +++ .../test/test_rendering_shadow_hand.py | 37 + .../test_rendering_shadow_hand_kitless.py | 33 + 42 files changed, 1376 insertions(+), 515 deletions(-) create mode 100644 source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-albedo.png create mode 100644 source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-depth.png create mode 100644 source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-rgb.png create mode 100644 source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-rgba.png create mode 100644 source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-semantic_segmentation.png create mode 100644 source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-simple_shading_constant_diffuse.png create mode 100644 source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png create mode 100644 source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-simple_shading_full_mdl.png create mode 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-albedo.png create mode 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-depth.png create mode 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-rgb.png create mode 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-rgba.png create mode 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-semantic_segmentation.png create mode 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-simple_shading_constant_diffuse.png create mode 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png create mode 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-simple_shading_full_mdl.png create mode 100644 source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-albedo.png create mode 100644 source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-depth.png create mode 100644 source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-rgb.png create mode 100644 source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-rgba.png create mode 100644 source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-semantic_segmentation.png create mode 100644 source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-simple_shading_constant_diffuse.png create mode 100644 source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png create mode 100644 source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-simple_shading_full_mdl.png rename source/isaaclab_tasks/test/{test_rendering_correctness.py => rendering_test_utils.py} (53%) create mode 100644 source/isaaclab_tasks/test/test_rendering_cartpole.py create mode 100644 source/isaaclab_tasks/test/test_rendering_cartpole_kitless.py create mode 100644 source/isaaclab_tasks/test/test_rendering_dexsuite_kuka.py create mode 100644 source/isaaclab_tasks/test/test_rendering_dexsuite_kuka_kitless.py create mode 100644 source/isaaclab_tasks/test/test_rendering_registered_tasks.py create mode 100644 source/isaaclab_tasks/test/test_rendering_shadow_hand.py create mode 100644 source/isaaclab_tasks/test/test_rendering_shadow_hand_kitless.py diff --git a/.github/actions/run-tests/action.yml b/.github/actions/run-tests/action.yml index e989b65d3a3e..a005a4d9ed36 100644 --- a/.github/actions/run-tests/action.yml +++ b/.github/actions/run-tests/action.yml @@ -125,6 +125,7 @@ runs: -e ISAAC_SIM_LOW_MEMORY=1 \ -e PYTHONUNBUFFERED=1 \ -e PYTHONIOENCODING=utf-8 \ + -e GITHUB_ACTIONS=${GITHUB_ACTIONS:-} \ -e TEST_RESULT_FILE=$result_file" if [ "$curobo_only" = "true" ]; then @@ -278,7 +279,7 @@ runs: fi fi - # Copy comparison images (saved by test_rendering_correctness.py). + # Copy comparison images (saved by source/isaaclab_tasks/test/test_rendering_*.py). local img_dir="$reports_dir/comparison-images" if [ -n "$volume_mount_source" ] && [ -d "${volume_mount_source}/tests/comparison-images" ]; then cp -r "${volume_mount_source}/tests/comparison-images" "$img_dir" diff --git a/source/isaaclab_ov/config/extension.toml b/source/isaaclab_ov/config/extension.toml index 67f5582202d9..ba8f5046c4eb 100644 --- a/source/isaaclab_ov/config/extension.toml +++ b/source/isaaclab_ov/config/extension.toml @@ -1,5 +1,5 @@ [package] -version = "0.1.2" +version = "0.1.3" title = "Omniverse renderers for IsaacLab" description = "Extension providing Omniverse renderers (OVRTX, ovphysx, etc.) for tiled camera rendering." readme = "docs/README.md" diff --git a/source/isaaclab_ov/docs/CHANGELOG.rst b/source/isaaclab_ov/docs/CHANGELOG.rst index 2babcbdabd9a..e8afeda30bda 100644 --- a/source/isaaclab_ov/docs/CHANGELOG.rst +++ b/source/isaaclab_ov/docs/CHANGELOG.rst @@ -1,6 +1,29 @@ Changelog --------- +0.1.3 (2026-04-30) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Simple-shading outputs, with RTX Minimal mode resolved from the requested camera data types and written on + the injected render product in USD. +* Expanded unit tests for OVRTX Warp kernels in ``test_ovrtx_renderer_kernels.py``. + +Changed +^^^^^^^ + +* OVRTX integration now branches ``read_gpu_transforms``, depth tile extraction, and semantic ID coloring kernels on + ovrtx **0.3.0** vs older versions so tiled buffers and transforms stay correct across ovrtx versions. +* RGB tiling reads ``LdrColor`` and supports both 3- and 4-channel buffers. + +Removed +^^^^^^^ + +* Removed ``OVRTXRendererCfg.simple_shading_mode``. Request simple shading via the simple-shading data types on the + camera instead; the renderer derives RTX minimal mode from the data types. + 0.1.2 (2026-03-23) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py index 0b52324b0238..4111ddea47fc 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py @@ -41,10 +41,6 @@ from ovrtx import Device, PrimMode, Renderer, RendererConfig, Semantic from packaging.version import Version -# In previous versions of ovrtx, there was a bug where we would have to set read_gpu_transforms to False. -# In later versions, we can read transforms from GPU. -_OVRTX_READ_GPU_TRANSFORMS = Version(ovrtx.__version__) > Version("0.2.0") - from isaaclab.renderers import BaseRenderer, RenderBufferKind, RenderBufferSpec from isaaclab.utils.math import convert_camera_frame_orientation_convention @@ -53,8 +49,10 @@ DEVICE, create_camera_transforms_kernel, extract_all_depth_tiles_kernel, + extract_all_depth_tiles_kernel_legacy, extract_all_rgba_tiles_kernel, generate_random_colors_from_ids_kernel, + generate_random_colors_from_ids_kernel_legacy, sync_newton_transforms_kernel, ) from .ovrtx_usd import ( @@ -68,6 +66,47 @@ from isaaclab.sensors.camera.camera_data import CameraData +# Shared integration floor for this module; reuse for ovrtx features that share one support floor. +_OVRTX_VERSION = Version(ovrtx.__version__) +_IS_OVRTX_0_3_0_OR_NEWER = Version("0.3.0") <= _OVRTX_VERSION + +# The resolved integer value is assigned to the ``omni:rtx:minimal:mode`` attribute of the render product. +_RTX_MINIMAL_MODES = { + RenderBufferKind.SIMPLE_SHADING_CONSTANT_DIFFUSE.value: 1, + RenderBufferKind.SIMPLE_SHADING_DIFFUSE_MDL.value: 2, + RenderBufferKind.SIMPLE_SHADING_FULL_MDL.value: 3, +} + + +def _resolve_rtx_minimal_mode(data_types: list[str]) -> int | None: + """Resolve the RTX minimal mode from data types. + + RTX minimal mode is used to control the rendering quality. The higher the mode, the higher the quality. + + If multiple simple shading data types are requested, the first one in the list is used and a warning is logged. + + If no simple shading data types are requested, None is returned. + + Args: + data_types: List of data types. + + Returns: + The resolved RTX minimal mode if simple shading data types are requested, otherwise None. + """ + filtered_data_types = [data_type for data_type in data_types if data_type in _RTX_MINIMAL_MODES] + if not filtered_data_types: + return None + + if len(filtered_data_types) > 1: + logger.warning( + "Multiple simple shading data types requested (%s). Using the first in the list (%s).", + filtered_data_types, + filtered_data_types[0], + ) + + return _RTX_MINIMAL_MODES[filtered_data_types[0]] + + class OVRTXRenderData: """OVRTX-specific RenderData. Holds warp output buffers and a weakref to the sensor. @@ -103,6 +142,9 @@ def supported_output_types(self) -> dict[RenderBufferKind, RenderBufferSpec]: RenderBufferKind.RGBA: RenderBufferSpec(4, torch.uint8), RenderBufferKind.RGB: RenderBufferSpec(3, torch.uint8), RenderBufferKind.ALBEDO: RenderBufferSpec(4, torch.uint8), + RenderBufferKind.SIMPLE_SHADING_CONSTANT_DIFFUSE: RenderBufferSpec(3, torch.uint8), + RenderBufferKind.SIMPLE_SHADING_DIFFUSE_MDL: RenderBufferSpec(3, torch.uint8), + RenderBufferKind.SIMPLE_SHADING_FULL_MDL: RenderBufferSpec(3, torch.uint8), RenderBufferKind.SEMANTIC_SEGMENTATION: RenderBufferSpec(4, torch.uint8), RenderBufferKind.DEPTH: RenderBufferSpec(1, torch.float32), RenderBufferKind.DISTANCE_TO_IMAGE_PLANE: RenderBufferSpec(1, torch.float32), @@ -168,7 +210,7 @@ def initialize(self, sensor: SensorBase): OVRTX_CONFIG = RendererConfig( log_file_path=self.cfg.log_file_path, log_level=self.cfg.log_level, - read_gpu_transforms=_OVRTX_READ_GPU_TRANSFORMS, + read_gpu_transforms=_IS_OVRTX_0_3_0_OR_NEWER, ) self._renderer = Renderer(OVRTX_CONFIG) assert self._renderer, "Renderer should be valid after creation" @@ -176,6 +218,7 @@ def initialize(self, sensor: SensorBase): if usd_scene_path is not None: logger.info("Injecting camera definitions...") + combined_usd_path, render_product_path = inject_cameras_into_usd( usd_scene_path, self.cfg, @@ -183,6 +226,7 @@ def initialize(self, sensor: SensorBase): height=height, num_envs=num_envs, data_types=data_types, + minimal_mode=_resolve_rtx_minimal_mode(data_types), camera_rel_path=self._camera_rel_path, ) self._render_product_paths.append(render_product_path) @@ -443,7 +487,11 @@ def _generate_random_colors_from_ids(self, input_ids: wp.array) -> wp.array: output_colors = self._output_semantic_color_buffer wp.launch( - kernel=generate_random_colors_from_ids_kernel, + kernel=( + generate_random_colors_from_ids_kernel + if _IS_OVRTX_0_3_0_OR_NEWER + else generate_random_colors_from_ids_kernel_legacy + ), dim=input_ids.shape, inputs=[input_ids, output_colors], device=DEVICE, @@ -460,15 +508,21 @@ def _extract_rgba_tiles( suffix: str = "", ) -> None: """Extract per-env RGBA tiles from tiled buffer into output_buffers (single kernel launch).""" + output_buffer = output_buffers[buffer_key] + num_channels = output_buffer.shape[-1] + if num_channels not in (3, 4): + raise ValueError(f"Expected RGB (3 channels) or RGBA (4 channels), got {num_channels}") + wp.launch( kernel=extract_all_rgba_tiles_kernel, dim=(render_data.num_envs, render_data.height, render_data.width), inputs=[ tiled_data, - output_buffers[buffer_key], + output_buffer, render_data.num_cols, render_data.width, render_data.height, + num_channels, ], device=DEVICE, ) @@ -477,10 +531,12 @@ def _extract_depth_tiles( self, render_data: OVRTXRenderData, tiled_depth_data: wp.array, output_buffers: dict ) -> None: """Extract per-env depth tiles into output_buffers (single kernel launch).""" + kernel = extract_all_depth_tiles_kernel if _IS_OVRTX_0_3_0_OR_NEWER else extract_all_depth_tiles_kernel_legacy + for depth_type in ["depth", "distance_to_image_plane", "distance_to_camera"]: if depth_type in output_buffers: wp.launch( - kernel=extract_all_depth_tiles_kernel, + kernel=kernel, dim=(render_data.num_envs, render_data.height, render_data.width), inputs=[ tiled_depth_data, @@ -494,17 +550,23 @@ def _extract_depth_tiles( def _process_render_frame(self, render_data: OVRTXRenderData, frame, output_buffers: dict) -> None: """Extract RGB, depth, albedo, and semantic from a single render frame into output_buffers.""" - rgb_render_var = ( - "SimpleShadingSD" - if "SimpleShadingSD" in frame.render_vars - else "LdrColor" - if "LdrColor" in frame.render_vars - else None - ) - if rgb_render_var and "rgba" in output_buffers: - with frame.render_vars[rgb_render_var].map(device=Device.CUDA) as mapping: - tiled_data = wp.from_dlpack(mapping.tensor) - self._extract_rgba_tiles(render_data, tiled_data, output_buffers, "rgba", suffix="rgb") + if "LdrColor" in frame.render_vars: + buffer_key = None + + if "rgba" in output_buffers: + buffer_key = "rgba" + else: + # The output buffers must contain only one simple shading data type at most after resolution of the data + # types during creation of the output buffers (OVRTXRenderData._create_warp_buffers). + for dt in _RTX_MINIMAL_MODES: + if dt in output_buffers: + buffer_key = dt + break + + if buffer_key is not None: + with frame.render_vars["LdrColor"].map(device=Device.CUDA) as mapping: + tiled_data = wp.from_dlpack(mapping.tensor) + self._extract_rgba_tiles(render_data, tiled_data, output_buffers, buffer_key) for depth_var in ["DistanceToImagePlaneSD", "DepthSD"]: if depth_var not in frame.render_vars: 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 1a53a07687a9..2461d6932fc9 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_cfg.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_cfg.py @@ -24,14 +24,6 @@ class OVRTXRendererCfg(RendererCfg): renderer_type: str = "ovrtx" """Type identifier for OVRTX renderer.""" - simple_shading_mode: bool = True - """Whether to use simple shading mode (default: True). - - When enabled, uses SimpleShadingSD RenderVar instead of LdrColor for RGB rendering. - Provides faster, simpler rendering suitable for many vision-based tasks. - Set to False to use full RTX path-traced rendering with LdrColor. - """ - temp_usd_dir: str = str(Path(tempfile.gettempdir()) / "ovrtx") """Directory for temporary combined USD files (scene + injected cameras). Used by the OVRTX renderer when building the render scope; must be writable. 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 d7647df4c3d7..c287f1257632 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_kernels.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_kernels.py @@ -76,32 +76,58 @@ def extract_tile_from_tiled_buffer_kernel( @wp.kernel def extract_all_rgba_tiles_kernel( tiled_buffer: wp.array(dtype=wp.uint8, ndim=3), # type: ignore - output_buffer: wp.array(dtype=wp.uint8, ndim=4), # type: ignore (num_envs, H, W, 4) + output_buffer: wp.array(dtype=wp.uint8, ndim=4), # type: ignore num_cols: int, tile_width: int, tile_height: int, + num_channels: int, ): - """Extract ALL RGBA tiles from a tiled buffer in a single kernel launch.""" + """Extract ALL RGBA or RGB tiles from a tiled buffer in a single kernel launch. + + Args: + tiled_buffer: 3D uint8 array of shape (H, W, 4) for RGBA or (H, W, 3) for RGB. + output_buffer: 4D uint8 array of shape (num_envs, H, W, 4) for RGBA or (num_envs, H, W, 3) for RGB. + num_cols: number of columns in the tiled buffer. + tile_width: width of each tile. + tile_height: height of each tile. + num_channels: number of channels in the output buffer. Use 3 for RGB or 4 for RGBA. + If a value other than 3 or 4 is given, it will be treated as 3 (RGB). + """ env_idx, y, x = wp.tid() tile_x = env_idx % num_cols tile_y = env_idx // num_cols src_x = tile_x * tile_width + x src_y = tile_y * tile_height + y + + # RGB output_buffer[env_idx, y, x, 0] = tiled_buffer[src_y, src_x, 0] output_buffer[env_idx, y, x, 1] = tiled_buffer[src_y, src_x, 1] output_buffer[env_idx, y, x, 2] = tiled_buffer[src_y, src_x, 2] - output_buffer[env_idx, y, x, 3] = tiled_buffer[src_y, src_x, 3] + + # Alpha (if it is RGBA) + if num_channels == 4: + output_buffer[env_idx, y, x, 3] = tiled_buffer[src_y, src_x, 3] @wp.kernel -def extract_all_depth_tiles_kernel( +def extract_all_depth_tiles_kernel_legacy( tiled_buffer: wp.array(dtype=wp.float32, ndim=2), # type: ignore - output_buffer: wp.array(dtype=wp.float32, ndim=4), # type: ignore (num_envs, H, W, 1) + output_buffer: wp.array(dtype=wp.float32, ndim=4), # type: ignore num_cols: int, tile_width: int, tile_height: int, ): - """Extract ALL depth tiles from a tiled buffer in a single kernel launch.""" + """Extract all depth tiles from a tiled buffer in a single kernel launch. + + Used with ovrtx older than 0.3.0. + + Args: + tiled_buffer: 2D float32 array of shape (H, W) for depth. + output_buffer: 4D float32 array of shape (num_envs, H, W, 1) for depth. + num_cols: number of columns in the tiled buffer. + tile_width: width of each tile. + tile_height: height of each tile. + """ env_idx, y, x = wp.tid() tile_x = env_idx % num_cols tile_y = env_idx // num_cols @@ -110,6 +136,31 @@ def extract_all_depth_tiles_kernel( output_buffer[env_idx, y, x, 0] = tiled_buffer[src_y, src_x] +@wp.kernel +def extract_all_depth_tiles_kernel( + tiled_buffer: wp.array(dtype=wp.float32, ndim=3), # type: ignore + output_buffer: wp.array(dtype=wp.float32, ndim=4), # type: ignore + num_cols: int, + tile_width: int, + tile_height: int, +): + """Extract all depth tiles from a tiled buffer in a single kernel launch. + + Args: + tiled_buffer: 3D float32 array of shape (H, W, 1) for depth. + output_buffer: 4D float32 array of shape (num_envs, H, W, 1) for depth. + num_cols: number of columns in the tiled buffer. + tile_width: width of each tile. + tile_height: height of each tile. + """ + env_idx, y, x = wp.tid() + tile_x = env_idx % num_cols + tile_y = env_idx // num_cols + src_x = tile_x * tile_width + x + src_y = tile_y * tile_height + y + output_buffer[env_idx, y, x, 0] = tiled_buffer[src_y, src_x, 0] + + @wp.kernel def extract_depth_tile_from_tiled_buffer_kernel( tiled_buffer: wp.array(dtype=wp.float32, ndim=2), # type: ignore @@ -151,6 +202,13 @@ def random_color_from_id(input_id: wp.uint32) -> wp.uint32: Returns: uint32 color: ``r | (g<<8) | (b<<16) | (a<<24)`` """ + if input_id == wp.uint32(0): + # BACKGROUND special case + return wp.uint32(0) + if input_id == wp.uint32(1): + # UNLABELLED special case + return wp.uint32(0xFF000000) + hash_val = color_hash(input_id) # Golden ratio inverse = 1.0 / 1.618033988749895 (Replicator constant) @@ -228,30 +286,35 @@ def random_color_from_id(input_id: wp.uint32) -> wp.uint32: @wp.kernel -def generate_random_colors_from_ids_kernel( +def generate_random_colors_from_ids_kernel_legacy( input_ids: wp.array(dtype=wp.uint32, ndim=2), # type: ignore output_colors: wp.array(dtype=wp.uint32, ndim=2), # type: ignore ): """Generate random colors given IDs (e.g. semantic IDs). + Used with ovrtx older than 0.3.0. + Args: - input_ids: 2D uint32 array of semantic IDs per pixel + input_ids: 2D uint32 array of shape (H, W) for semantic IDs per pixel. output_data: 2D uint32 array; each word is `r | (g<<8) | (b<<16) | (a<<24)` """ i, j = wp.tid() + output_colors[i, j] = random_color_from_id(input_ids[i, j]) - input_id = input_ids[i, j] - if input_id == wp.uint32(0): - # BACKGROUND special case - output_color = wp.uint32(0) - elif input_id == wp.uint32(1): - # UNLABELLED special case - output_color = wp.uint32(0xFF000000) - else: - output_color = random_color_from_id(input_id) +@wp.kernel +def generate_random_colors_from_ids_kernel( + input_ids: wp.array(dtype=wp.uint32, ndim=3), # type: ignore + output_colors: wp.array(dtype=wp.uint32, ndim=3), # type: ignore +): + """Generate random colors given IDs (e.g. semantic IDs). - output_colors[i, j] = output_color + Args: + input_ids: 3D uint32 array for semantic IDs per pixel. + output_colors: 3D uint32 array for colors per pixel; each word is ``r | (g<<8) | (b<<16) | (a<<24)``. + """ + i, j, k = wp.tid() + output_colors[i, j, k] = random_color_from_id(input_ids[i, j, k]) @wp.kernel diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_usd.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_usd.py index 7dc48877a4ad..a222981ea2ed 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_usd.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_usd.py @@ -19,8 +19,8 @@ logger = logging.getLogger(__name__) -def get_render_var_config(data_types: list[str], simple_shading_mode: bool) -> tuple[str, str, str]: - """Return (render_var_path, render_var_name, source_name) from data_types and shading mode.""" +def get_render_var_config(data_types: list[str]) -> tuple[str, str, str]: + """Return (render_var_path, render_var_name, source_name) from data_types.""" use_depth = any(dt in ["depth", "distance_to_image_plane", "distance_to_camera"] for dt in data_types) use_albedo = "albedo" in data_types use_semantic = "semantic_segmentation" in data_types @@ -32,8 +32,6 @@ def get_render_var_config(data_types: list[str], simple_shading_mode: bool) -> t return "/Render/Vars/albedo", "albedo", "DiffuseAlbedoSD" if use_semantic and not (use_rgb or use_albedo): return "/Render/Vars/semantic", "semantic", "SemanticSegmentation" - if simple_shading_mode: - return "/Render/Vars/SimpleShading", "SimpleShading", "SimpleShadingSD" return "/Render/Vars/LdrColor", "LdrColor", "LdrColor" @@ -45,16 +43,35 @@ def build_render_scope_usd( source_name: str, tiled_width: int, tiled_height: int, - simple_shading_mode: bool = False, + minimal_mode: int | None = None, ) -> str: - """Build the Render scope USD string (def Scope Render, RenderProduct, Vars).""" - render_mode = "Minimal" if simple_shading_mode else "RealTimePathTracing" - logger.info("Rendering mode: %s (omni:rtx:rendermode=%s)", render_var_name, render_mode) - if simple_shading_mode: - logger.info("Simple shading mode: ENABLED") - else: - logger.info("Simple shading mode: DISABLED (using full RTX path tracing)") + """Build the Render scope USD string (def Scope Render, RenderProduct, Vars). + + Args: + camera_paths: List of camera prim paths. + render_product_name: Name of the render product. + render_var_path: Path of the render variable. + render_var_name: Name of the render variable. + source_name: Name of the source. + tiled_width: Width of the tiled image. + tiled_height: Height of the tiled image. + minimal_mode: RTX minimal mode. None if not requested. Valid values are 1, 2, 3. + + Returns: + The USD string for the render scope. + """ camera_rel_list = ", ".join([f"<{p}>" for p in camera_paths]) + + if minimal_mode is None: + render_mode_lines = ['token omni:rtx:rendermode = "RealTimePathTracing"'] + else: + render_mode_lines = [ + 'token omni:rtx:rendermode = "Minimal"', + f"int omni:rtx:minimal:mode = {minimal_mode}", + ] + + render_mode_block = "\n ".join(render_mode_lines) + return f''' def Scope "Render" {{ @@ -63,7 +80,8 @@ def RenderProduct "{render_product_name}" ( ) {{ rel camera = [{camera_rel_list}] token omni:rtx:background:source:type = "domeLight" - token omni:rtx:rendermode = "{render_mode}" + float omni:rtx:rt:ambientLight:intensity = 1.0 + {render_mode_block} token[] omni:rtx:waitForEvents = ["AllLoadingFinished", "OnlyOnFirstRequest"] rel orderedVars = <{render_var_path}> uniform int2 resolution = ({tiled_width}, {tiled_height}) @@ -94,6 +112,7 @@ def inject_cameras_into_usd( height: int, num_envs: int, data_types: list[str], + minimal_mode: int | None = None, camera_rel_path: str = "Camera", ) -> tuple[str, str]: """Inject camera and render product definitions into an existing USD file. @@ -108,8 +127,8 @@ def inject_cameras_into_usd( height: Tile height from sensor config. num_envs: Number of environments from scene. data_types: Data types from sensor config. - camera_rel_path: Camera prim path relative to the env root (e.g. ``"Camera"`` - or ``"Robot/head_cam"``). + minimal_mode: RTX minimal mode. None if not requested. Valid values are 1, 2, 3. + camera_rel_path: Camera prim path relative to the env root (e.g. ``"Camera"`` or ``"Robot/head_cam"``). """ with open(usd_scene_path) as f: original_usd = f.read() @@ -121,7 +140,7 @@ def inject_cameras_into_usd( render_product_name = "RenderProduct" render_product_path = f"/Render/{render_product_name}" - render_var_path, render_var_name, source_name = get_render_var_config(data_types, cfg.simple_shading_mode) + render_var_path, render_var_name, source_name = get_render_var_config(data_types) camera_content = build_render_scope_usd( camera_paths, @@ -131,7 +150,7 @@ def inject_cameras_into_usd( source_name, tiled_width, tiled_height, - simple_shading_mode=cfg.simple_shading_mode, + minimal_mode, ) combined_usd = original_usd.rstrip() + "\n\n" + camera_content diff --git a/source/isaaclab_ov/test/test_ovrtx_renderer_kernels.py b/source/isaaclab_ov/test/test_ovrtx_renderer_kernels.py index 5bbbb6139e2b..ed416d05a7e6 100644 --- a/source/isaaclab_ov/test/test_ovrtx_renderer_kernels.py +++ b/source/isaaclab_ov/test/test_ovrtx_renderer_kernels.py @@ -12,7 +12,11 @@ import warp as wp from isaaclab_ov.renderers.ovrtx_renderer_kernels import ( DEVICE, + extract_all_depth_tiles_kernel, + extract_all_depth_tiles_kernel_legacy, + extract_all_rgba_tiles_kernel, generate_random_colors_from_ids_kernel, + generate_random_colors_from_ids_kernel_legacy, ) @@ -68,44 +72,383 @@ def _reference_color(input_id: int) -> int: return r | (g << 8) | (b << 16) | (a << 24) -@pytest.mark.skip(reason="OVRTX is optional and experimental feature and temporarily is excluded from testing.") +def _reference_extract_all_depth_tiles_legacy( + tiled_2d: np.ndarray, + num_envs: int, + num_cols: int, + tile_width: int, + tile_height: int, +) -> np.ndarray: + """NumPy reference for ``extract_all_depth_tiles_kernel_legacy`` (2D tiled buffer).""" + 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_2d[src_y, src_x] + return out + + +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``.""" + return _reference_extract_all_depth_tiles_legacy(tiled_np[..., 0], num_envs, num_cols, tile_width, tile_height) + + +def _reference_extract_all_rgba_tiles( + tiled_np: np.ndarray, + num_envs: int, + num_cols: int, + tile_width: int, + tile_height: int, + num_channels: int, +) -> 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) + 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 + + +class TestExtractAllDepthTilesKernel: + """Tests for ``extract_all_depth_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_depth_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_depth_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"), + [ + (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(12345) + tiled_np = rng.random((tiled_h, tiled_w, 1), dtype=np.float32).astype(np.float32) + + 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_depth_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=1e-6, atol=1e-6) + + +class TestExtractAllDepthTilesKernelLegacy: + """Tests for ``extract_all_depth_tiles_kernel_legacy`` (ovrtx < 0.3.0, 2D tiled buffer).""" + + 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), dtype=np.float32) + for h in range(tiled_h): + for w in range(tiled_w): + tiled_np[h, w] = float(h * 1000 + w) + + tiled_wp = wp.array(tiled_np, dtype=wp.float32, ndim=2, device=DEVICE) + output_wp = wp.zeros(shape=(num_envs, tile_height, tile_width, 1), dtype=wp.float32, device=DEVICE) + + wp.launch( + kernel=extract_all_depth_tiles_kernel_legacy, + 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_legacy(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) + + tiled_wp = wp.array(tiled_np, dtype=wp.float32, ndim=2, device=DEVICE) + output_wp = wp.zeros(shape=(num_envs, tile_height, tile_width, 1), dtype=wp.float32, device=DEVICE) + + wp.launch( + kernel=extract_all_depth_tiles_kernel_legacy, + 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_legacy(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(12345) + tiled_np = rng.random((tiled_h, tiled_w), dtype=np.float32).astype(np.float32) + + tiled_wp = wp.array(tiled_np, dtype=wp.float32, ndim=2, device=DEVICE) + output_wp = wp.zeros(shape=(num_envs, tile_height, tile_width, 1), dtype=wp.float32, device=DEVICE) + + wp.launch( + kernel=extract_all_depth_tiles_kernel_legacy, + 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_legacy(tiled_np, num_envs, num_cols, tile_width, tile_height) + np.testing.assert_allclose(output_wp.numpy(), expected, rtol=1e-6, atol=1e-6) + + +class TestExtractAllRgbaTilesKernel: + """Tests for ``extract_all_rgba_tiles_kernel``.""" + + def test_two_by_two_tile_grid_rgba(self): + num_cols = 2 + num_envs = 4 + tile_width = 2 + tile_height = 3 + num_channels = 4 + tiled_h = (num_envs // num_cols) * tile_height + tiled_w = num_cols * tile_width + tiled_np = np.zeros((tiled_h, tiled_w, 4), dtype=np.uint8) + for h in range(tiled_h): + for w in range(tiled_w): + tiled_np[h, w, 0] = (h * 17 + w) % 256 + tiled_np[h, w, 1] = (h * 31 + w * 3) % 256 + tiled_np[h, w, 2] = (h + w * 11) % 256 + tiled_np[h, w, 3] = (h * 7 + w * 13) % 256 + + 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_rgba_tiles_kernel, + dim=(num_envs, tile_height, tile_width), + inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height, num_channels], + device=DEVICE, + ) + 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_rgba_tiles_kernel, + dim=(num_envs, tile_height, tile_width), + inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height, num_channels], + device=DEVICE, + ) + 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_num_channels_not_four_skips_alpha(self): + """Values other than 4 use the RGB-only path (same as RGB tiled input).""" + num_cols = 1 + num_envs = 1 + tile_width = 2 + tile_height = 2 + tiled_np = np.array( + [ + [[1, 2, 3, 99], [4, 5, 6, 88]], + [[7, 8, 9, 77], [10, 11, 12, 66]], + ], + dtype=np.uint8, + ) + + tiled_wp = wp.array(tiled_np, dtype=wp.uint8, ndim=3, device=DEVICE) + output_wp = wp.zeros(shape=(1, 2, 2, 3), dtype=wp.uint8, device=DEVICE) + + wp.launch( + kernel=extract_all_rgba_tiles_kernel, + dim=(1, tile_height, tile_width), + inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height, 2], + device=DEVICE, + ) + wp.synchronize() + + expected = _reference_extract_all_rgba_tiles(tiled_np, num_envs, num_cols, tile_width, tile_height, 2) + 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_rgba_tiles_kernel, + dim=(num_envs, tile_height, tile_width), + inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height, num_channels], + device=DEVICE, + ) + 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) + + class TestRandomColorsFromIdsKernel: """Tests for generate_random_colors_from_ids_kernel.""" def test_random_colors(self): - inputs_np = np.array([[0, 1], [2, 3]], dtype=np.uint32) - input_ids = wp.array(inputs_np, dtype=wp.uint32, ndim=2, device=DEVICE) - h, w = inputs_np.shape - output_colors = wp.zeros(shape=(h, w), dtype=wp.uint32, device=DEVICE) + inputs_np = np.array([[[0], [1]], [[2], [3]]], dtype=np.uint32) + input_ids = wp.array(inputs_np, dtype=wp.uint32, ndim=3, device=DEVICE) + output_colors = wp.zeros(shape=inputs_np.shape, dtype=wp.uint32, device=DEVICE) wp.launch( kernel=generate_random_colors_from_ids_kernel, - dim=(h, w), + dim=inputs_np.shape, inputs=[input_ids, output_colors], device=DEVICE, ) wp.synchronize() out_np = output_colors.numpy() - for i in range(h): - for j in range(w): - input_id = int(inputs_np[i, j]) - ref_color = _reference_color(input_id) - out_color = int(out_np[i, j]) - assert out_color == ref_color, ( - f"At ({i},{j}) id={input_id}: expected 0x{ref_color:08x}, got 0x{out_color:08x}" - ) + for (i, j, k), input_id in np.ndenumerate(inputs_np): + input_id = int(np.uint32(input_id)) + ref_color = _reference_color(input_id) + out_color = int(out_np[i, j, k]) + assert out_color == ref_color, ( + f"At ({i},{j},{k}) id={input_id}: expected 0x{ref_color:08x}, got 0x{out_color:08x}" + ) def test_deterministic_across_launches(self): - h, w = 4, 4 + shape = (4, 4, 1) rng = np.random.default_rng(42) - inputs_np = rng.integers(0, 2**31, size=(h, w), dtype=np.uint32) - input_ids = wp.array(inputs_np, dtype=wp.uint32, ndim=2, device=DEVICE) - output_colors = wp.zeros(shape=(h, w), dtype=wp.uint32, device=DEVICE) + inputs_np = rng.integers(0, 2**31, size=shape, dtype=np.uint32) + input_ids = wp.array(inputs_np, dtype=wp.uint32, ndim=3, device=DEVICE) + output_colors = wp.zeros(shape=shape, dtype=wp.uint32, device=DEVICE) wp.launch( kernel=generate_random_colors_from_ids_kernel, - dim=(h, w), + dim=shape, inputs=[input_ids, output_colors], device=DEVICE, ) @@ -114,7 +457,89 @@ def test_deterministic_across_launches(self): wp.launch( kernel=generate_random_colors_from_ids_kernel, - dim=(h, w), + dim=shape, + inputs=[input_ids, output_colors], + device=DEVICE, + ) + wp.synchronize() + second_run = output_colors.numpy() + + np.testing.assert_array_equal(first_run, second_run) + + @pytest.mark.parametrize( + "input_value", + [ + 0, + 1, + 2, + 3, + 100, + ], + ) + def test_single_value(self, input_value): + inputs_np = np.array([[[input_value]]], dtype=np.uint32) + input_ids = wp.array(inputs_np, dtype=wp.uint32, ndim=3, device=DEVICE) + output_colors = wp.zeros(shape=(1, 1, 1), dtype=wp.uint32, device=DEVICE) + + wp.launch( + kernel=generate_random_colors_from_ids_kernel, + dim=(1, 1, 1), + inputs=[input_ids, output_colors], + device=DEVICE, + ) + wp.synchronize() + + ref_color = _reference_color(int(np.uint32(input_value))) + out_color = int(output_colors.numpy()[0, 0, 0]) + assert out_color == ref_color, ( + f"id=0x{int(np.uint32(input_value)):08x}: expected 0x{ref_color:08x}, got 0x{out_color:08x}" + ) + + +class TestRandomColorsFromIdsKernelLegacy: + """Tests for ``generate_random_colors_from_ids_kernel_legacy`` (ovrtx < 0.3.0, 2D buffers).""" + + def test_random_colors(self): + inputs_np = np.array([[0, 1], [2, 3]], dtype=np.uint32) + input_ids = wp.array(inputs_np, dtype=wp.uint32, ndim=2, device=DEVICE) + output_colors = wp.zeros(shape=inputs_np.shape, dtype=wp.uint32, device=DEVICE) + + wp.launch( + kernel=generate_random_colors_from_ids_kernel_legacy, + dim=inputs_np.shape, + inputs=[input_ids, output_colors], + device=DEVICE, + ) + wp.synchronize() + + out_np = output_colors.numpy() + for (i, j), input_id in np.ndenumerate(inputs_np): + input_id = int(np.uint32(input_id)) + ref_color = _reference_color(input_id) + out_color = int(out_np[i, j]) + assert out_color == ref_color, ( + f"At ({i},{j}) id={input_id}: expected 0x{ref_color:08x}, got 0x{out_color:08x}" + ) + + def test_deterministic_across_launches(self): + shape = (4, 4) + rng = np.random.default_rng(42) + inputs_np = rng.integers(0, 2**31, size=shape, dtype=np.uint32) + input_ids = wp.array(inputs_np, dtype=wp.uint32, ndim=2, device=DEVICE) + output_colors = wp.zeros(shape=shape, dtype=wp.uint32, device=DEVICE) + + wp.launch( + kernel=generate_random_colors_from_ids_kernel_legacy, + dim=shape, + inputs=[input_ids, output_colors], + device=DEVICE, + ) + wp.synchronize() + first_run = output_colors.numpy().copy() + + wp.launch( + kernel=generate_random_colors_from_ids_kernel_legacy, + dim=shape, inputs=[input_ids, output_colors], device=DEVICE, ) @@ -139,7 +564,7 @@ def test_single_value(self, input_value): output_colors = wp.zeros(shape=(1, 1), dtype=wp.uint32, device=DEVICE) wp.launch( - kernel=generate_random_colors_from_ids_kernel, + kernel=generate_random_colors_from_ids_kernel_legacy, dim=(1, 1), inputs=[input_ids, output_colors], device=DEVICE, diff --git a/source/isaaclab_tasks/config/extension.toml b/source/isaaclab_tasks/config/extension.toml index 2e7ee00764d7..c6e5ea6bc181 100644 --- a/source/isaaclab_tasks/config/extension.toml +++ b/source/isaaclab_tasks/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "1.5.30" +version = "1.5.32" # Description title = "Isaac Lab Environments" diff --git a/source/isaaclab_tasks/docs/CHANGELOG.rst b/source/isaaclab_tasks/docs/CHANGELOG.rst index 2bb26e429744..1c8d2c65b979 100644 --- a/source/isaaclab_tasks/docs/CHANGELOG.rst +++ b/source/isaaclab_tasks/docs/CHANGELOG.rst @@ -1,6 +1,18 @@ Changelog --------- +1.5.32 (2026-04-30) +~~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Refactored rendering correctness tests under ``source/isaaclab_tasks/test/``: shared ``rendering_test_utils.py``, + split ``test_rendering_*`` modules (cartpole, Dexsuite Kuka Allegro lift, shadow hand) with ``*_kitless`` variants, + and Newton + OVRTX golden images. Newton + ``ovrtx_renderer`` test cases remain skipped on GitHub Actions temporarily + until they can run on GitHub Actions. + + 1.5.31 (2026-04-29) ~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-albedo.png b/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-albedo.png new file mode 100644 index 000000000000..d44cd9a9f6a8 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-albedo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:88da57e08497b308d47c3e606d40142291adde3c0fe2df46ab0a924d39b6847b +size 435 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-depth.png b/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-depth.png new file mode 100644 index 000000000000..0387686a7a78 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-depth.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ea4e6ba2251666e4df0c937fbd123489f2a06939bda9e674d5f84eb5c8831f7d +size 422 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-rgb.png b/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-rgb.png new file mode 100644 index 000000000000..e47c06e2ca7c --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-rgb.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4029eb71d2361c9fa12d255415bb9edcf1caaeb9d230ca2e6c4e67596c037dd1 +size 2580 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-rgba.png b/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-rgba.png new file mode 100644 index 000000000000..791497af827c --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-rgba.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4e1fed2c618875f9f9b4520c52308b0831cf637835e7a62e7a84e96f914c1e83 +size 2882 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-semantic_segmentation.png b/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-semantic_segmentation.png new file mode 100644 index 000000000000..21926715b7f6 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-semantic_segmentation.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1fbe8833cefa8b037cb80538ff73ec9c503253cfe410282f3b9acdea12e7a017 +size 427 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 new file mode 100644 index 000000000000..87104cb87161 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-simple_shading_constant_diffuse.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:47b5b15d79d0b61d00c0538caa0012172753a481ad6efb45df2888402be2f407 +size 391 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 new file mode 100644 index 000000000000..7d05e4a7adbd --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f2ba382c0804ea49b55fc5216c9f1e28c34d5cae95b33d9982fd55763df178cc +size 435 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 new file mode 100644 index 000000000000..6b4f8389da06 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/cartpole/newton-ovrtx_renderer-simple_shading_full_mdl.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d7af4ef2afca01d0bf4f9c069c5c3778fa07050bcd8091486541ab11f14e8227 +size 742 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-albedo.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-albedo.png new file mode 100644 index 000000000000..e7c78849a92a --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-albedo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4979bdc7bb0f3786f5bb08e9e2333a36f2f008f3f7bc303dffa88319eef28e20 +size 3463 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-depth.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-depth.png new file mode 100644 index 000000000000..bf4b0e0290dd --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-depth.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c003b10810539f538464992860c74ee3bf531b8b4e9b6e0ebe84041d42dba643 +size 532 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-rgb.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-rgb.png new file mode 100644 index 000000000000..c1d370c90eaa --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-rgb.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7f8f2305b02d61096172a83b3729c0fbc94f135d50e2dbfe2f7b3acb655855de +size 14781 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-rgba.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-rgba.png new file mode 100644 index 000000000000..0b13724ec7d3 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-rgba.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:86ee5f3822aae350436ae3fcd1edb7bdbdd3472c080059c573cab5d97cf4160e +size 17683 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-semantic_segmentation.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-semantic_segmentation.png new file mode 100644 index 000000000000..762a23184316 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-semantic_segmentation.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0b8729d722d1780272b24f0804e517ea024ec2b93f4a0fa3e26c82f222f5c9eb +size 700 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-simple_shading_constant_diffuse.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-simple_shading_constant_diffuse.png new file mode 100644 index 000000000000..46ce5933fb8b --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-simple_shading_constant_diffuse.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8e1d94f0c6ae2e40a1b0ff9cf27a0f2f9b756ebcebd9ddf27b0da31c89e3a57f +size 1485 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png new file mode 100644 index 000000000000..eeb46bec4489 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fbac25af4087687f2ae5434770a724ef1453ec988c39e46d4b53afa22421f5c7 +size 4044 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-simple_shading_full_mdl.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-simple_shading_full_mdl.png new file mode 100644 index 000000000000..16b6b73ec7f4 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-simple_shading_full_mdl.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3afc4f214e51a1ccc1b0341448f64b82773bcc4bba3d913ad4f3052dcf497032 +size 4513 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-albedo.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-albedo.png new file mode 100644 index 000000000000..b0a81304d1b4 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-albedo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d6ea478eb0b63ac6e9b19c66fabc9944a9cdd1d3131aa0d480ba645151765f41 +size 2150 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-depth.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-depth.png new file mode 100644 index 000000000000..5cad962b3ac0 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-depth.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cbc51bc91ac202857ec3648475f25e58e921aa927ec1d0814f63862462877e3f +size 3661 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-rgb.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-rgb.png new file mode 100644 index 000000000000..d6e87b16a116 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-rgb.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:87b927816b29714d92113b2fcab01569c60372f017119b37ced9a12f72b01cd7 +size 19717 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-rgba.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-rgba.png new file mode 100644 index 000000000000..ddffaebf0722 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-rgba.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7fb6696c895cb07a86897002e434be6c8c67a9d50f15615c2fd16f5038eee209 +size 21761 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-semantic_segmentation.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-semantic_segmentation.png new file mode 100644 index 000000000000..c1d11cc65f5c --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-semantic_segmentation.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8edbd9b2c8131d659d36ed3f6f07040eba5428d0e6e888bb8d97bb67d9001917 +size 1477 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 new file mode 100644 index 000000000000..a25340e96b0f --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-simple_shading_constant_diffuse.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3122340f40be0b24e9d7f2d262bc7285607536c9cf82151750d2691f05d8950d +size 6840 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 new file mode 100644 index 000000000000..572a1759a30c --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3f0ececab1b4b385c54352d02c9b07d6572f8a4f4069eea1afe2089343a164d6 +size 7429 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 new file mode 100644 index 000000000000..687917f13e3b --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-ovrtx_renderer-simple_shading_full_mdl.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:88ca19937bbf54a87c40f476c395f678a39d443df3899eb3c74b4e3e854866fc +size 9192 diff --git a/source/isaaclab_tasks/test/test_rendering_correctness.py b/source/isaaclab_tasks/test/rendering_test_utils.py similarity index 53% rename from source/isaaclab_tasks/test/test_rendering_correctness.py rename to source/isaaclab_tasks/test/rendering_test_utils.py index 3ecdf3461e52..c6c797fc937d 100644 --- a/source/isaaclab_tasks/test/test_rendering_correctness.py +++ b/source/isaaclab_tasks/test/rendering_test_utils.py @@ -3,45 +3,16 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Tests for rendering correctness. +"""Shared helpers for rendering correctness tests.""" -Each test builds an environment with a given (physics_backend, renderer, data_type), -resets, then checks if camera outputs are not blank (at least one non-zero -pixel) and consistent with golden images. Env-specific fixtures use parametrized -combinations; a separate test covers a list of registered task IDs that use -camera-based observations. -""" +import os +from datetime import datetime +from typing import Any -# Launch Isaac Sim Simulator first. -from isaaclab.app import AppLauncher - -app_launcher = AppLauncher(headless=True, enable_cameras=True) -simulation_app = app_launcher.app - -import os # noqa: E402 -from datetime import datetime # noqa: E402 -from typing import Any # noqa: E402 - -import gymnasium as gym # noqa: E402 -import numpy as np # noqa: E402 -import pytest # noqa: E402 -import torch # noqa: E402 -from PIL import Image, ImageChops # noqa: E402 - -from isaaclab.sim import SimulationContext # noqa: E402 - -from isaaclab_tasks.utils.hydra import ( # noqa: E402 - apply_overrides, - collect_presets, - parse_overrides, -) -from isaaclab_tasks.utils.parse_cfg import parse_env_cfg # noqa: E402 - -pytestmark = pytest.mark.isaacsim_ci - -# --------------------------------------------------------------------------- -# Constants -# --------------------------------------------------------------------------- +import numpy as np +import pytest +import torch +from PIL import Image, ImageChops # Directory containing golden images. _GOLDEN_IMAGES_DIRECTORY = os.path.join(os.path.dirname(os.path.abspath(__file__)), "golden_images") @@ -63,8 +34,9 @@ # ~3.28 % per-pixel diff from anti-aliasing noise along the many finger/cube edges. 7.0 gives # headroom above that without masking real regressions, which the SSIM gate still catches. "shadow_hand": 7.0, - "dexsuite_kuka": 10.0, # texture anti-aliasing on the ground + "dexsuite_kuka": 10.0, # texture aliasing artifacts on the ground (ticket has been filed for OVRTX) } +MAX_DIFFERENT_PIXELS_PERCENTAGE_BY_ENV_NAME = _MAX_DIFFERENT_PIXELS_PERCENTAGE_BY_ENV_NAME # Minimum SSIM score below which two images are considered structurally different. SSIM is a perceptual metric # robust to uniform per-pixel noise that penalises structural changes (geometry shifts, swapped colours, missing @@ -75,10 +47,8 @@ # Per-env SSIM overrides. Envs not listed fall back to ``_SSIM_THRESHOLD``. Loosened individually # (not globally) to keep the strict gate active everywhere it already passes. _SSIM_THRESHOLD_BY_ENV_NAME = { - # Dexsuite renders the observation point cloud markers whose sample positions depend on the - # global numpy/torch RNG, so a handful of pixels flip between runs. That translates to SSIM - # drops just under 0.982 on the worst variant without any structural regression. - "dexsuite_kuka": 0.98, + # Texture aliasing artifacts on the ground (ticket has been filed for OVRTX) + "dexsuite_kuka": 0.95, } # Data types for which the SSIM gate is not enforced. SSIM assumes natural-image statistics and is unreliable on @@ -87,200 +57,193 @@ # data types we still compute SSIM for reporting, but only the per-pixel L2 gate is used to decide pass/fail. _SSIM_DISABLED_DATA_TYPES: set[str] = {"depth", "distance_to_camera", "distance_to_image_plane"} -_OVRTX_DISABLED = pytest.mark.skip( - reason="OVRTX is optional and experimental feature and temporarily is excluded from testing." -) - # Directory for comparison images saved during the test session. # Located under the pytest output root so it gets copied alongside test reports. _COMPARISON_IMAGES_DIR = os.path.join(os.getcwd(), "tests", "comparison-images") - -# Collects comparison scores from all golden-image comparisons during the session. -# Each entry: {"test": str, "backend": str, "renderer": str, "aov": str, -# "ssim": float, "diff_pct": float, "passed": bool, -# "img_result_path": str | None, "img_golden_path": str | None} -_COMPARISON_SCORES: list[dict] = [] - - -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - - -@pytest.fixture(autouse=True) -def cleanup_simulation_context(): - """Fixture to clear SimulationContext after each test. - - SimulationContext is a singleton; tests that create envs leave it set. Without - cleanup, later tests can see stale context or fail when the instance is - reused. The fixture runs after every test and calls clear_instance() so each - test runs with a clean simulation context and tests stay isolated. - """ - yield - - SimulationContext.clear_instance() - - -@pytest.fixture(scope="session", autouse=True) -def _generate_comparison_html_report(): - """Generate an HTML comparison report after all tests in the session complete.""" - yield - try: - _generate_html_report() - except Exception as exc: # noqa: BLE001 - import warnings - - warnings.warn(f"Failed to generate HTML comparison report: {exc}", stacklevel=1) - - -@pytest.fixture(autouse=True) -def _attach_comparison_properties(request): - """Attach pixel-diff, SSIM scores, and failure images as JUnit XML properties.""" - initial_count = len(_COMPARISON_SCORES) - yield - for entry in _COMPARISON_SCORES[initial_count:]: - label = f"{entry['backend']}-{entry['renderer']}-{entry['aov']}" - request.node.user_properties.append((f"diff_pct:{label}", f"{entry['diff_pct']:.2f}")) - ssim_value = f"{entry['ssim']:.4f}" if entry.get("ssim_checked", True) else f"{entry['ssim']:.4f} (N/A)" - request.node.user_properties.append((f"ssim:{label}", ssim_value)) - request.node.user_properties.append((f"threshold:{label}", f"{entry['threshold']:.1f}")) - if entry.get("img_result_path"): - request.node.user_properties.append((f"img_result:{label}", entry["img_result_path"])) - request.node.user_properties.append((f"img_golden:{label}", entry["img_golden_path"])) - +_COMPARISON_IMAGE_SUBDIR = "images" # --------------------------------------------------------------------------- # Parametrization: (physics_backend, renderer, data_type) # --------------------------------------------------------------------------- -_PHYSICS_RENDERER_AOV_COMBINATIONS = [ +# OVRTX kitless paths can segfault on GitHub Actions runners; keep warp/Kit paths in CI. +_SKIP_ON_GITHUB_ACTIONS = os.environ.get("GITHUB_ACTIONS") == "true" +_SKIP_ON_GITHUB_ACTIONS_MARK = pytest.mark.skipif( + _SKIP_ON_GITHUB_ACTIONS, + reason="Skipped on GitHub Actions until the test can run on GitHub Actions.", +) + +PHYSICS_RENDERER_AOV_COMBINATIONS = [ # physx + isaacsim_rtx_renderer pytest.param( - ("physx", "isaacsim_rtx_renderer", "rgb"), + "physx", + "isaacsim_rtx_renderer", + "rgb", id="physx-isaacsim_rtx-rgb", ), pytest.param( - ("physx", "isaacsim_rtx_renderer", "albedo"), + "physx", + "isaacsim_rtx_renderer", + "albedo", id="physx-isaacsim_rtx-albedo", ), pytest.param( - ("physx", "isaacsim_rtx_renderer", "depth"), + "physx", + "isaacsim_rtx_renderer", + "depth", id="physx-isaacsim_rtx-depth", ), pytest.param( - ("physx", "isaacsim_rtx_renderer", "simple_shading_constant_diffuse"), + "physx", + "isaacsim_rtx_renderer", + "simple_shading_constant_diffuse", id="physx-isaacsim_rtx-simple_shading_constant_diffuse", ), pytest.param( - ("physx", "isaacsim_rtx_renderer", "simple_shading_diffuse_mdl"), + "physx", + "isaacsim_rtx_renderer", + "simple_shading_diffuse_mdl", id="physx-isaacsim_rtx-simple_shading_diffuse_mdl", ), pytest.param( - ("physx", "isaacsim_rtx_renderer", "simple_shading_full_mdl"), + "physx", + "isaacsim_rtx_renderer", + "simple_shading_full_mdl", id="physx-isaacsim_rtx-simple_shading_full_mdl", ), pytest.param( - ("physx", "isaacsim_rtx_renderer", "semantic_segmentation"), + "physx", + "isaacsim_rtx_renderer", + "semantic_segmentation", id="physx-isaacsim_rtx-semantic_segmentation", ), # physx + newton_renderer (warp) pytest.param( - ("physx", "newton_renderer", "rgb"), + "physx", + "newton_renderer", + "rgb", id="physx-newton_warp-rgb", ), pytest.param( - ("physx", "newton_renderer", "depth"), + "physx", + "newton_renderer", + "depth", id="physx-newton_warp-depth", ), # newton + isaacsim_rtx_renderer pytest.param( - ("newton", "isaacsim_rtx_renderer", "rgb"), + "newton", + "isaacsim_rtx_renderer", + "rgb", id="newton-isaacsim_rtx-rgb", ), pytest.param( - ("newton", "isaacsim_rtx_renderer", "albedo"), + "newton", + "isaacsim_rtx_renderer", + "albedo", id="newton-isaacsim_rtx-albedo", ), pytest.param( - ("newton", "isaacsim_rtx_renderer", "depth"), + "newton", + "isaacsim_rtx_renderer", + "depth", id="newton-isaacsim_rtx-depth", ), pytest.param( - ("newton", "isaacsim_rtx_renderer", "simple_shading_constant_diffuse"), + "newton", + "isaacsim_rtx_renderer", + "simple_shading_constant_diffuse", id="newton-isaacsim_rtx-simple_shading_constant_diffuse", ), pytest.param( - ("newton", "isaacsim_rtx_renderer", "simple_shading_diffuse_mdl"), + "newton", + "isaacsim_rtx_renderer", + "simple_shading_diffuse_mdl", id="newton-isaacsim_rtx-simple_shading_diffuse_mdl", ), pytest.param( - ("newton", "isaacsim_rtx_renderer", "simple_shading_full_mdl"), + "newton", + "isaacsim_rtx_renderer", + "simple_shading_full_mdl", id="newton-isaacsim_rtx-simple_shading_full_mdl", ), pytest.param( - ("newton", "isaacsim_rtx_renderer", "semantic_segmentation"), + "newton", + "isaacsim_rtx_renderer", + "semantic_segmentation", id="newton-isaacsim_rtx-semantic_segmentation", ), - # newton + newton_renderer (warp) - pytest.param( - ("newton", "newton_renderer", "rgb"), - id="newton-newton_warp-rgb", - ), - pytest.param( - ("newton", "newton_renderer", "depth"), - id="newton-newton_warp-depth", - ), +] + +KITLESS_PHYSICS_RENDERER_AOV_COMBINATIONS = [ # newton + ovrtx_renderer pytest.param( - ("newton", "ovrtx_renderer", "rgb"), + "newton", + "ovrtx_renderer", + "rgb", id="newton-ovrtx-rgb", - marks=_OVRTX_DISABLED, + marks=_SKIP_ON_GITHUB_ACTIONS_MARK, ), pytest.param( - ("newton", "ovrtx_renderer", "albedo"), + "newton", + "ovrtx_renderer", + "albedo", id="newton-ovrtx-albedo", - marks=_OVRTX_DISABLED, + marks=_SKIP_ON_GITHUB_ACTIONS_MARK, ), pytest.param( - ("newton", "ovrtx_renderer", "depth"), + "newton", + "ovrtx_renderer", + "depth", id="newton-ovrtx-depth", - marks=_OVRTX_DISABLED, + marks=_SKIP_ON_GITHUB_ACTIONS_MARK, ), pytest.param( - ("newton", "ovrtx_renderer", "simple_shading_constant_diffuse"), + "newton", + "ovrtx_renderer", + "simple_shading_constant_diffuse", id="newton-ovrtx-simple_shading_constant_diffuse", - marks=_OVRTX_DISABLED, + marks=_SKIP_ON_GITHUB_ACTIONS_MARK, ), pytest.param( - ("newton", "ovrtx_renderer", "simple_shading_diffuse_mdl"), + "newton", + "ovrtx_renderer", + "simple_shading_diffuse_mdl", id="newton-ovrtx-simple_shading_diffuse_mdl", - marks=_OVRTX_DISABLED, + marks=_SKIP_ON_GITHUB_ACTIONS_MARK, ), pytest.param( - ("newton", "ovrtx_renderer", "simple_shading_full_mdl"), + "newton", + "ovrtx_renderer", + "simple_shading_full_mdl", id="newton-ovrtx-simple_shading_full_mdl", - marks=_OVRTX_DISABLED, + marks=_SKIP_ON_GITHUB_ACTIONS_MARK, ), pytest.param( - ("newton", "ovrtx_renderer", "semantic_segmentation"), + "newton", + "ovrtx_renderer", + "semantic_segmentation", id="newton-ovrtx-semantic_segmentation", - marks=_OVRTX_DISABLED, + marks=_SKIP_ON_GITHUB_ACTIONS_MARK, + ), + # newton + newton_renderer (warp) + pytest.param( + "newton", + "newton_renderer", + "rgb", + id="newton-newton_warp-rgb", + ), + pytest.param( + "newton", + "newton_renderer", + "depth", + id="newton-newton_warp-depth", ), ] -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _maybe_save_stage(test_name: str, physics_backend: str, renderer: str, data_type: str) -> None: - """If ``ISAAC_LAB_SAVE_STAGES`` is set, dump the current USD stage to that directory. - - The file name is ``---.usda`` so each - parametrization gets its own stage. The export is a no-op when the environment variable - is unset, so this is safe to call unconditionally from fixtures. - """ +def maybe_save_stage(test_name: str, physics_backend: str, renderer: str, data_type: str) -> None: + """If ``ISAAC_LAB_SAVE_STAGES`` is set, dump the current USD stage to that directory.""" out_dir = os.environ.get("ISAAC_LAB_SAVE_STAGES") if not out_dir: return @@ -295,15 +258,9 @@ def _maybe_save_stage(test_name: str, physics_backend: str, renderer: str, data_ def _apply_overrides_to_env_cfg(env_cfg: Any, override_args: list[str]) -> Any: - """Apply override args to env_cfg using parse_overrides and apply_overrides. - - Args: - env_cfg: Environment config to mutate (supports :meth:`to_dict`). - override_args: List of override strings (e.g. ``["presets=physx,isaacsim_rtx_renderer,rgb"]``). + """Apply override args to env_cfg using parse_overrides and apply_overrides.""" + from isaaclab_tasks.utils.hydra import apply_overrides, collect_presets, parse_overrides - Returns: - The resolved env_cfg (possibly a different object if root preset was applied). - """ presets = {"env": collect_presets(env_cfg)} global_presets, preset_sel, preset_scalar, _ = parse_overrides(override_args, presets) hydra_cfg = {"env": env_cfg.to_dict()} @@ -312,15 +269,7 @@ def _apply_overrides_to_env_cfg(env_cfg: Any, override_args: list[str]) -> Any: def _normalize_tensor(tensor: torch.Tensor, data_type: str) -> torch.Tensor: - """Convert camera output tensor to [0, 1] float32 for conversion to image. - - Args: - tensor: Camera output tensor. - data_type: Data type of the camera output. - - Returns: - Normalized tensor. - """ + """Convert camera output tensor to [0, 1] float32 for conversion to image.""" normalized = tensor.float() if data_type in ["depth", "distance_to_camera", "distance_to_image_plane"]: @@ -336,35 +285,21 @@ def _normalize_tensor(tensor: torch.Tensor, data_type: str) -> torch.Tensor: def _save_comparison_image(img: Image.Image, filename: str) -> str: - """Save a PIL image to the comparison-images/images directory. - - Args: - img: PIL Image to save. - filename: File name (e.g. ``"test-backend-renderer-aov-result.png"``). - - Returns: - Absolute path to the saved file. - """ - path = os.path.join(_COMPARISON_IMAGES_DIR, "images", filename) + """Save a PIL image under the comparison images directory.""" + path = os.path.join(_COMPARISON_IMAGES_DIR, _COMPARISON_IMAGE_SUBDIR, filename) os.makedirs(os.path.dirname(path), exist_ok=True) img.save(path, format="PNG") return path -def _generate_html_report() -> None: - """Generate an HTML report of all comparison scores and save it alongside the comparison images. - - The report is written to ``<_COMPARISON_IMAGES_DIR>/_report_.html`` and includes a table of - all image comparison results sorted by PixelDiff % descending, with thumbnail links to actual - and golden images where available. - """ - if not _COMPARISON_SCORES: +def generate_html_report(comparison_scores: list[dict], report_filename: str) -> None: + """Generate and save an HTML report of comparison scores.""" + if not comparison_scores: return os.makedirs(_COMPARISON_IMAGES_DIR, exist_ok=True) - report_path = os.path.join(_COMPARISON_IMAGES_DIR, "_report_.html") - - sorted_scores = sorted(_COMPARISON_SCORES, key=lambda e: -e["diff_pct"]) + report_path = os.path.join(_COMPARISON_IMAGES_DIR, report_filename) + sorted_scores = sorted(comparison_scores, key=lambda e: -e["diff_pct"]) rows = [] for entry in sorted_scores: @@ -406,7 +341,7 @@ def _generate_html_report() -> None: "\n" "\n" '\n' - "Rendering Correctness — Image Comparison Report\n" + "Rendering Correctness - Image Comparison Report\n" "\n" "\n" "\n" - "

Rendering Correctness — Image Comparison Report

\n" - f"

Sorted by PixelDiff % (desc) — {len(sorted_scores)}  total.

\n" + "

Rendering Correctness - Image Comparison Report

\n" + f"

Sorted by PixelDiff % (desc) - {len(sorted_scores)}  total.

\n" "\n" "" "" @@ -445,40 +380,118 @@ def _generate_html_report() -> None: "\n" ) - with open(report_path, "w", encoding="utf-8") as f: - f.write(html) + with open(report_path, "w", encoding="utf-8") as file: + file.write(html) -def _make_grid(images: torch.Tensor) -> torch.Tensor: - """Make a grid of images from a tensor of shape (B, H, W, C). +def attach_comparison_properties( + request: pytest.FixtureRequest, comparison_scores: list[dict], initial_count: int +) -> None: + """Attach pixel-diff, SSIM scores, and failure images as JUnit XML properties.""" + for entry in comparison_scores[initial_count:]: + label = f"{entry['backend']}-{entry['renderer']}-{entry['aov']}" + request.node.user_properties.append((f"diff_pct:{label}", f"{entry['diff_pct']:.2f}")) + ssim_value = f"{entry['ssim']:.4f}" if entry.get("ssim_checked", True) else f"{entry['ssim']:.4f} (N/A)" + request.node.user_properties.append((f"ssim:{label}", ssim_value)) + request.node.user_properties.append((f"threshold:{label}", f"{entry['threshold']:.1f}")) + if entry.get("img_result_path"): + request.node.user_properties.append((f"img_result:{label}", entry["img_result_path"])) + request.node.user_properties.append((f"img_golden:{label}", entry["img_golden_path"])) - Args: - images: A tensor of shape (B, H, W, C) containing the images. - Returns: - A tensor of shape (H, W, C) containing the grid of images. - """ - from torchvision.utils import make_grid +def make_determinism_fixture(): + """Create an autouse fixture that enables determinism for each test.""" - return make_grid(torch.swapaxes(images.unsqueeze(1), 1, -1).squeeze(-1), nrow=round(images.shape[0] ** 0.5)) + @pytest.fixture(autouse=True) + def _determinism_fixture(): + """Enable determinism for each test.""" + from isaaclab.utils.seed import configure_seed + configure_seed(42, torch_deterministic=True) -def _ssim(img1: torch.Tensor, img2: torch.Tensor, window_size: int = 11) -> float: - """Compute mean SSIM between two (1, C, H, W) float tensors in [0, 1]. + yield - https://en.wikipedia.org/wiki/Structural_similarity_index_measure + from isaaclab.sim import SimulationContext - Uses a uniform averaging window and the standard SSIM constants (K1=0.01, K2=0.03, - data_range=1.0). + SimulationContext.clear_instance() + + return _determinism_fixture + + +def make_generate_html_report_fixture(comparison_scores: list[dict], report_filename: str): + """Create a session fixture that writes the HTML report for one module. Args: - img1: First image tensor of shape (1, C, H, W) in [0, 1]. - img2: Second image tensor of shape (1, C, H, W) in [0, 1]. - window_size: Side length of the square averaging window. + comparison_scores: Module-local comparison score storage. + report_filename: Output report filename. + """ + + @pytest.fixture(scope="session", autouse=True) + def _generate_html_report(): + """Generate an HTML comparison report after all tests in the session complete.""" + yield + generate_html_report(comparison_scores, report_filename) + + return _generate_html_report + - Returns: - Mean SSIM score (1.0 = identical). +def make_attach_comparison_properties_fixture(comparison_scores: list[dict]): + """Create an autouse fixture that attaches JUnit properties for one module. + + Args: + comparison_scores: Module-local comparison score storage. """ + + @pytest.fixture(autouse=True) + def _attach_comparison_properties(request): + """Attach pixel-diff, SSIM scores, and failure images as JUnit XML properties.""" + initial_count = len(comparison_scores) + yield + attach_comparison_properties(request, comparison_scores, initial_count) + + return _attach_comparison_properties + + +def make_require_ovrtx_install_fixture(): + """Create an autouse fixture that fails fast when OVRTX is required but not installed. + + Only parametrized cases with ``renderer == "ovrtx_renderer"`` are checked (Newton + Warp kitless cases do not need ``ov[ovrtx]``). Install with + ``./isaaclab.sh -i 'ov[ovrtx]'`` (or the equivalent in your environment). + """ + + @pytest.fixture(autouse=True) + def _require_ovrtx_install(request): + callspec = getattr(request.node, "callspec", None) + if callspec is None: + return + + if callspec.params.get("renderer") != "ovrtx_renderer": + return + + try: + import ovrtx + + print(f"ovrtx version: {ovrtx.__version__}") + except ImportError as exc: + pytest.fail( + "Kitless OVRTX rendering tests require the optional dependency ov[ovrtx]. " + "Install with: ./isaaclab.sh -i 'ov[ovrtx]'\n" + f"ImportError: {exc}" + ) + + return _require_ovrtx_install + + +def _make_grid(images: torch.Tensor) -> torch.Tensor: + """Make a grid of images from a tensor of shape (B, H, W, C).""" + from torchvision.utils import make_grid + + return make_grid(torch.swapaxes(images.unsqueeze(1), 1, -1).squeeze(-1), nrow=round(images.shape[0] ** 0.5)) + + +def _ssim(img1: torch.Tensor, img2: torch.Tensor, window_size: int = 11) -> float: + """Compute mean SSIM between two (1, C, H, W) float tensors in [0, 1].""" c1 = 0.01**2 c2 = 0.03**2 channels = img1.shape[1] @@ -508,16 +521,7 @@ def _pixel_diff_percentage( golden_image: Image.Image, pixel_diff_threshold: float = _PIXEL_L2_NORM_DIFFERENCE_THRESHOLD, ) -> float: - """Compute the percentage of pixels whose L2 norm difference exceeds a threshold. - - Args: - result_image: Result image as PIL Image. - golden_image: Golden image as PIL Image (must be same size/mode). - pixel_diff_threshold: Pixel L2 norm difference threshold. - - Returns: - Percentage of pixels that differ beyond the threshold. - """ + """Compute the percentage of pixels whose L2 norm difference exceeds a threshold.""" diff_array = np.array(ImageChops.difference(result_image, golden_image)) l2_norm_array = np.linalg.norm(diff_array, axis=2) num_different_pixels = np.sum(l2_norm_array > pixel_diff_threshold) @@ -531,26 +535,7 @@ def _compare_images( check_ssim: bool = True, ssim_threshold: float = _SSIM_THRESHOLD, ) -> tuple[bool, str | None, float, float]: - """Compare result and golden images. Fails if either the per-pixel L2 gate or the SSIM gate is breached. - - Two independent gates must pass: - - * Per-pixel L2 count: catches localised artefacts (e.g. a patch of broken shading, a few recoloured - pixels) that leave global SSIM nearly unchanged. - * SSIM: catches structural regressions (geometry shifts, large-area colour changes, missing materials) - that survive a loose per-pixel threshold. Disabled for data types where SSIM is unreliable - (see :data:`_SSIM_DISABLED_DATA_TYPES`); the score is still computed and returned for reporting. - - Args: - result_image: Result image as PIL Image to compare with golden image. - golden_image: Golden image as PIL Image to compare with result image. - max_different_pixels_percentage: Maximum percentage of pixels allowed to exceed pixel_diff_threshold. - check_ssim: If True, enforce the SSIM gate; if False, compute SSIM for reporting only. - - Returns: - (passed, error_message_or_None, diff_percentage, ssim_score). - Scores are 0.0 / 0.0 when comparison cannot be performed (size/mode mismatch). - """ + """Compare result and golden images against pixel and SSIM thresholds.""" if result_image.size != golden_image.size: return False, f"Size mismatch: expected {golden_image.size}, got {result_image.size}.", 0.0, 0.0 @@ -584,35 +569,24 @@ def _compare_images( return True, None, diff_pct, ssim_score -def _validate_camera_outputs( +def validate_camera_outputs( test_name: str, physics_backend: str, renderer: str, camera_outputs: dict[str, torch.Tensor], max_different_pixels_percentage: float, + comparison_scores: list[dict], ) -> None: - """Validate correctness and consistency of camera outputs. - - Args: - test_name: Test name. - physics_backend: Physics backend. - renderer: Renderer. - camera_outputs: {data_type -> tensor}. - max_different_pixels_percentage: Maximum percentage of pixels allowed to exceed pixel_diff_threshold. - """ + """Validate correctness and consistency of camera outputs.""" assert len(camera_outputs) > 0, f"[{test_name}] No camera outputs produced by {physics_backend} + {renderer}." golden_image_dir = os.path.join(_GOLDEN_IMAGES_DIRECTORY, test_name) os.makedirs(golden_image_dir, exist_ok=True) - # Per-env SSIM threshold (falls back to the global default). Kept in sync with the pixel-diff - # pattern so loosening a gate is always a localised, explicit decision. ssim_threshold = _SSIM_THRESHOLD_BY_ENV_NAME.get(test_name, _SSIM_THRESHOLD) - failed_data_types = {} for data_type, tensor in camera_outputs.items(): - # Replace inf/nan with zero so they do not break comparison; ensure the tensor has at least one non-zero value. condition = torch.logical_or(torch.isinf(tensor), torch.isnan(tensor)) corrected = torch.where(condition, torch.zeros_like(tensor), tensor) max_val = corrected.max() @@ -620,16 +594,11 @@ def _validate_camera_outputs( failed_data_types[data_type] = f"Camera output '{data_type}' has no non-zero pixels." continue - # convert tensors to a tiled image. normalized = _normalize_tensor(corrected, data_type) grid = _make_grid(normalized) - - # permute(1, 2, 0) is there to change the tensor layout from channel-first to channel-last so it matches what - # PIL expects. ndarr = grid.mul(255).add_(0.5).clamp_(0, 255).permute(1, 2, 0).to("cpu", torch.uint8).numpy() result_image = Image.fromarray(ndarr) - # first run creates baseline and fails; second run validates. golden_path = os.path.join(golden_image_dir, f"{physics_backend}-{renderer}-{data_type}.png") if not os.path.exists(golden_path): failed_data_types[data_type] = f"Golden image not found at {golden_path}." @@ -638,11 +607,10 @@ def _validate_camera_outputs( try: golden_image = Image.open(golden_path) - except Exception as e: - failed_data_types[data_type] = f"Error opening golden image: {e}" + except Exception as error: # noqa: BLE001 + failed_data_types[data_type] = f"Error opening golden image: {error}" continue - # validate the consistency of rendering outputs. check_ssim = data_type not in _SSIM_DISABLED_DATA_TYPES succeeded, error_message, diff_pct, ssim_score = _compare_images( result_image, @@ -672,7 +640,7 @@ def _validate_camera_outputs( entry["img_result_path"] = _save_comparison_image(result_image, f"{prefix}-actual.png") entry["img_golden_path"] = _save_comparison_image(golden_image, f"{prefix}-golden.png") - _COMPARISON_SCORES.append(entry) + comparison_scores.append(entry) if not succeeded: failed_data_types[data_type] = error_message @@ -682,53 +650,18 @@ def _validate_camera_outputs( for data_type, error_message in failed_data_types.items(): reason += f"- {data_type}: {error_message}\n" reason += f"Comparison images were written to {_COMPARISON_IMAGES_DIR}." - pytest.fail(reason) -def _collect_camera_outputs(env: object) -> dict[str, dict[str, torch.Tensor]]: - """Collect camera outputs from env.scene.sensors. - - Args: - env: Gymnasium env (or any object with optional unwrapped.scene.sensors). - - Returns: - Nested dict: sensor name -> {data_type -> tensor} for non-empty tensor outputs. - """ - base = getattr(env, "unwrapped", env) - out = {} - - scene = getattr(base, "scene", None) - if scene is not None: - sensors = getattr(scene, "sensors", None) - if sensors is not None: - for name, sensor in sensors.items(): - data = getattr(sensor, "data", None) - output = getattr(data, "output", None) if data is not None else None - if not isinstance(output, dict): - continue - - # Collect only tensor entries (ignore empty or lazy-unfilled) - tensor_output = {k: v for k, v in output.items() if isinstance(v, torch.Tensor) and v.numel() > 0} - if tensor_output: - out[name] = tensor_output - - return out - - -# --------------------------------------------------------------------------- -# Shadow Hand vision env -# --------------------------------------------------------------------------- - - -@pytest.fixture(params=_PHYSICS_RENDERER_AOV_COMBINATIONS) -def shadow_hand_env(request): - """Build Shadow Hand vision env for (physics_backend, renderer, data_type); reset, yield, close.""" +def rendering_test_shadow_hand( + physics_backend: str, + renderer: str, + data_type: str, + comparison_scores: list[dict], +) -> None: from isaaclab_tasks.direct.shadow_hand.shadow_hand_vision_env import ShadowHandVisionEnv from isaaclab_tasks.direct.shadow_hand.shadow_hand_vision_env_cfg import ShadowHandVisionEnvCfg - physics_backend, renderer, data_type = request.param - override_args = [f"presets={physics_backend},{renderer},{data_type}"] env_cfg = ShadowHandVisionEnvCfg() @@ -741,95 +674,76 @@ def shadow_hand_env(request): env_cfg.feature_extractor.enabled = False env = None + try: env = ShadowHandVisionEnv(env_cfg) - _maybe_save_stage("shadow_hand", physics_backend, renderer, data_type) - yield physics_backend, renderer, data_type, env + maybe_save_stage("shadow_hand", physics_backend, renderer, data_type) + + validate_camera_outputs( + "shadow_hand", + physics_backend, + renderer, + env._tiled_camera.data.output, + max_different_pixels_percentage=MAX_DIFFERENT_PIXELS_PERCENTAGE_BY_ENV_NAME["shadow_hand"], + comparison_scores=comparison_scores, + ) finally: if env is not None: env.close() + # This invokes camera sensor and renderer cleanup explicitly before pytest teardown, otherwise OV + # native code could probably complain about leaks and trigger segmentation fault. + env = None -def test_shadow_hand(shadow_hand_env): - """Camera output must contain at least one non-zero pixel (Shadow Hand vision env).""" - physics_backend, renderer, _, env = shadow_hand_env - test_name = "shadow_hand" - _validate_camera_outputs( - test_name, - physics_backend, - renderer, - env._tiled_camera.data.output, - max_different_pixels_percentage=_MAX_DIFFERENT_PIXELS_PERCENTAGE_BY_ENV_NAME[test_name], - ) - - -# --------------------------------------------------------------------------- -# Cartpole camera env -# --------------------------------------------------------------------------- - -@pytest.fixture(params=_PHYSICS_RENDERER_AOV_COMBINATIONS) -def cartpole_env(request): - """Build Cartpole camera env for (physics_backend, renderer, data_type); reset, yield, close.""" +def rendering_test_cartpole( + physics_backend: str, + renderer: str, + data_type: str, + comparison_scores: list[dict], +) -> None: from isaaclab_tasks.direct.cartpole.cartpole_camera_env import CartpoleCameraEnv from isaaclab_tasks.direct.cartpole.cartpole_camera_presets_env_cfg import CartpoleCameraPresetsEnvCfg - physics_backend, renderer, data_type = request.param - - override_args = [f"presets={physics_backend},{renderer},{data_type}"] - env_cfg = CartpoleCameraPresetsEnvCfg() - env_cfg = _apply_overrides_to_env_cfg(env_cfg, override_args) + env_cfg = _apply_overrides_to_env_cfg(env_cfg, [f"presets={physics_backend},{renderer},{data_type}"]) env_cfg.scene.num_envs = 4 env = None + try: env = CartpoleCameraEnv(env_cfg) - _maybe_save_stage("cartpole", physics_backend, renderer, data_type) - yield physics_backend, renderer, data_type, env + maybe_save_stage("cartpole", physics_backend, renderer, data_type) + validate_camera_outputs( + "cartpole", + physics_backend, + renderer, + env._tiled_camera.data.output, + max_different_pixels_percentage=MAX_DIFFERENT_PIXELS_PERCENTAGE_BY_ENV_NAME["cartpole"], + comparison_scores=comparison_scores, + ) finally: if env is not None: env.close() + # This invokes camera sensor and renderer cleanup explicitly before pytest teardown, otherwise OV + # native code could probably complain about leaks and trigger segmentation fault. + env = None -def test_cartpole(cartpole_env): - """Camera output must contain at least one non-zero pixel (Cartpole camera env).""" - physics_backend, renderer, _, env = cartpole_env - test_name = "cartpole" - _validate_camera_outputs( - test_name, - physics_backend, - renderer, - env._tiled_camera.data.output, - max_different_pixels_percentage=_MAX_DIFFERENT_PIXELS_PERCENTAGE_BY_ENV_NAME[test_name], - ) - - -# --------------------------------------------------------------------------- -# Dexsuite Kuka-Allegro Lift (single camera) -# --------------------------------------------------------------------------- - - -@pytest.mark.flaky(max_runs=3, min_passes=1) -@pytest.mark.parametrize("test_params", _PHYSICS_RENDERER_AOV_COMBINATIONS) -def test_dexsuite_kuka_allegro_lift(test_params): - """Camera output must contain at least one non-zero pixel (Dexsuite Kuka-Allegro Lift, single camera). - The env setup is intentionally inlined (not delegated to a yield fixture) so that - ``@pytest.mark.flaky`` reruns the full env-creation + render + validation cycle on - each attempt. With a yield fixture the fixture body runs only once, meaning every - retry would see the same cached image — making the flaky mark ineffective. - """ +def rendering_test_dexsuite_kuka( + physics_backend: str, + renderer: str, + data_type: str, + comparison_scores: list[dict], +) -> None: from isaaclab.envs import ManagerBasedRLEnv from isaaclab_tasks.manager_based.manipulation.dexsuite.config.kuka_allegro.dexsuite_kuka_allegro_env_cfg import ( DexsuiteKukaAllegroLiftEnvCfg, ) - physics_backend, renderer, data_type = test_params - - # Dexsuite data type has explicit resolution suffix (64, 128, 256). We only test 64x64. override_args = [f"presets={physics_backend},{renderer},{data_type}64,single_camera,cube"] env_cfg = DexsuiteKukaAllegroLiftEnvCfg() @@ -846,76 +760,23 @@ def test_dexsuite_kuka_allegro_lift(test_params): if point_cloud_term is not None: point_cloud_term.params["visualize"] = False - test_name = "dexsuite_kuka" env = None + try: env = ManagerBasedRLEnv(env_cfg) - _maybe_save_stage(test_name, physics_backend, renderer, data_type) - _validate_camera_outputs( - test_name, + maybe_save_stage("dexsuite_kuka", physics_backend, renderer, data_type) + validate_camera_outputs( + "dexsuite_kuka", physics_backend, renderer, env.scene.sensors["base_camera"].data.output, - max_different_pixels_percentage=_MAX_DIFFERENT_PIXELS_PERCENTAGE_BY_ENV_NAME[test_name], + max_different_pixels_percentage=MAX_DIFFERENT_PIXELS_PERCENTAGE_BY_ENV_NAME["dexsuite_kuka"], + comparison_scores=comparison_scores, ) finally: if env is not None: env.close() - -# --------------------------------------------------------------------------- -# Registered tasks (camera-based observations) -# --------------------------------------------------------------------------- - -# Task IDs that expose camera/tiled_camera image observations; each is validated for non-blank rendering. -# The max different pixels percentage is set based on the screen space taken up by the env. -_RENDER_CORRECTNESS_TASK_IDS = [ - ("Isaac-Cartpole-Albedo-Camera-Direct-v0", "cartpole"), - ("Isaac-Cartpole-Camera-Presets-Direct-v0", "cartpole"), - ("Isaac-Cartpole-Depth-Camera-Direct-v0", "cartpole"), - ("Isaac-Cartpole-RGB-Camera-Direct-v0", "cartpole"), - ("Isaac-Cartpole-SimpleShading-Constant-Camera-Direct-v0", "cartpole"), - ("Isaac-Cartpole-SimpleShading-Diffuse-Camera-Direct-v0", "cartpole"), - ("Isaac-Cartpole-SimpleShading-Full-Camera-Direct-v0", "cartpole"), - pytest.param( - "Isaac-Repose-Cube-Shadow-Vision-Direct-v0", - "shadow_hand", - # The Shadow-Vision render is right at the SSIM/diff-pixel tolerance and intermittently - # exceeds the 3% diff threshold by a fraction of a percent. Allow up to 3 attempts and - # require at least one pass while we tighten the validation tolerances for this scene. - marks=pytest.mark.flaky(max_runs=3, min_passes=1), - ), -] - - -@pytest.mark.parametrize("task_id, env_name", _RENDER_CORRECTNESS_TASK_IDS) -def test_registered_tasks(task_id, env_name): - """Camera output must be non-empty for each registered task with camera-based observations.""" - env = None - try: - env_cfg = parse_env_cfg(task_id, num_envs=4) - - env = gym.make(task_id, cfg=env_cfg) - unwrapped: Any = env.unwrapped - sim = getattr(unwrapped, "sim", None) - if sim is not None: - sim._app_control_on_stop_handle = None - - _maybe_save_stage(f"registered_tasks_{task_id}", "default_physics", "default_renderer", "stage") - - camera_outputs_nested_dict = _collect_camera_outputs(env) - num_camera_outputs = len(camera_outputs_nested_dict) - assert num_camera_outputs == 1, f"[{task_id}] Expected 1 camera output, got {num_camera_outputs}." - - camera_outputs = next(iter(camera_outputs_nested_dict.values())) - - _validate_camera_outputs( - f"registered_tasks/{task_id}", - "default_physics", - "default_renderer", - camera_outputs, - max_different_pixels_percentage=_MAX_DIFFERENT_PIXELS_PERCENTAGE_BY_ENV_NAME[env_name], - ) - finally: - if env is not None: - env.close() + # This invokes camera sensor and renderer cleanup explicitly before pytest teardown, otherwise OV + # native code could probably complain about leaks and trigger segmentation fault. + env = None diff --git a/source/isaaclab_tasks/test/test_rendering_cartpole.py b/source/isaaclab_tasks/test/test_rendering_cartpole.py new file mode 100644 index 000000000000..6f10a80a1d4b --- /dev/null +++ b/source/isaaclab_tasks/test/test_rendering_cartpole.py @@ -0,0 +1,37 @@ +# 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 Cartpole environment backend combinations.""" + +# 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_COMBINATIONS, + make_attach_comparison_properties_fixture, + make_determinism_fixture, + make_generate_html_report_fixture, + rendering_test_cartpole, +) + +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_type", PHYSICS_RENDERER_AOV_COMBINATIONS) +def test_rendering_cartpole(physics_backend, renderer, data_type): + """Test cartpole environment rendering correctness.""" + rendering_test_cartpole(physics_backend, renderer, data_type, _COMPARISON_SCORES) diff --git a/source/isaaclab_tasks/test/test_rendering_cartpole_kitless.py b/source/isaaclab_tasks/test/test_rendering_cartpole_kitless.py new file mode 100644 index 000000000000..802ecfd32cfc --- /dev/null +++ b/source/isaaclab_tasks/test/test_rendering_cartpole_kitless.py @@ -0,0 +1,33 @@ +# 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 rendering correctness tests for Cartpole environment backend combinations.""" + +from pathlib import Path + +import pytest +from rendering_test_utils import ( + KITLESS_PHYSICS_RENDERER_AOV_COMBINATIONS, + make_attach_comparison_properties_fixture, + make_determinism_fixture, + make_generate_html_report_fixture, + make_require_ovrtx_install_fixture, + rendering_test_cartpole, +) + +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) +_require_ovrtx_install_fixture = make_require_ovrtx_install_fixture() + + +@pytest.mark.parametrize("physics_backend,renderer,data_type", KITLESS_PHYSICS_RENDERER_AOV_COMBINATIONS) +def test_rendering_cartpole_kitless(physics_backend, renderer, data_type): + """Camera output must match golden images (Cartpole camera presets env).""" + rendering_test_cartpole(physics_backend, renderer, data_type, _COMPARISON_SCORES) diff --git a/source/isaaclab_tasks/test/test_rendering_dexsuite_kuka.py b/source/isaaclab_tasks/test/test_rendering_dexsuite_kuka.py new file mode 100644 index 000000000000..623c1e08c233 --- /dev/null +++ b/source/isaaclab_tasks/test/test_rendering_dexsuite_kuka.py @@ -0,0 +1,38 @@ +# 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 Dexsuite Kuka-Allegro Lift backend combinations.""" + +# 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_COMBINATIONS, + make_attach_comparison_properties_fixture, + make_determinism_fixture, + make_generate_html_report_fixture, + rendering_test_dexsuite_kuka, +) + +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.flaky(max_runs=3, min_passes=1) +@pytest.mark.parametrize("physics_backend,renderer,data_type", PHYSICS_RENDERER_AOV_COMBINATIONS) +def test_rendering_dexsuite_kuka(physics_backend, renderer, data_type): + """Test dexsuite kuka allegro lift environment rendering correctness.""" + rendering_test_dexsuite_kuka(physics_backend, renderer, data_type, _COMPARISON_SCORES) diff --git a/source/isaaclab_tasks/test/test_rendering_dexsuite_kuka_kitless.py b/source/isaaclab_tasks/test/test_rendering_dexsuite_kuka_kitless.py new file mode 100644 index 000000000000..f76d43364ab5 --- /dev/null +++ b/source/isaaclab_tasks/test/test_rendering_dexsuite_kuka_kitless.py @@ -0,0 +1,34 @@ +# 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 rendering correctness tests for Dexsuite Kuka-Allegro Lift backend combinations.""" + +from pathlib import Path + +import pytest +from rendering_test_utils import ( + KITLESS_PHYSICS_RENDERER_AOV_COMBINATIONS, + make_attach_comparison_properties_fixture, + make_determinism_fixture, + make_generate_html_report_fixture, + make_require_ovrtx_install_fixture, + rendering_test_dexsuite_kuka, +) + +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) +_require_ovrtx_install_fixture = make_require_ovrtx_install_fixture() + + +@pytest.mark.flaky(max_runs=3, min_passes=1) +@pytest.mark.parametrize("physics_backend,renderer,data_type", KITLESS_PHYSICS_RENDERER_AOV_COMBINATIONS) +def test_rendering_dexsuite_kuka_kitless(physics_backend, renderer, data_type): + """Camera output must match golden images (Dexsuite Kuka-Allegro Lift, single camera).""" + rendering_test_dexsuite_kuka(physics_backend, renderer, data_type, _COMPARISON_SCORES) diff --git a/source/isaaclab_tasks/test/test_rendering_registered_tasks.py b/source/isaaclab_tasks/test/test_rendering_registered_tasks.py new file mode 100644 index 000000000000..f1b208f4d727 --- /dev/null +++ b/source/isaaclab_tasks/test/test_rendering_registered_tasks.py @@ -0,0 +1,119 @@ +# 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 camera-based registered tasks.""" + +# 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 +from typing import Any # noqa: E402 + +import gymnasium as gym # noqa: E402 +import pytest # noqa: E402 +import torch # noqa: E402 +from rendering_test_utils import ( # noqa: E402 + MAX_DIFFERENT_PIXELS_PERCENTAGE_BY_ENV_NAME, + make_attach_comparison_properties_fixture, + make_determinism_fixture, + make_generate_html_report_fixture, + maybe_save_stage, + validate_camera_outputs, +) + +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) + + +def _collect_camera_outputs(env: object) -> dict[str, dict[str, torch.Tensor]]: + """Collect camera outputs from env.scene.sensors.""" + base = getattr(env, "unwrapped", env) + outputs = {} + + scene = getattr(base, "scene", None) + if scene is not None: + sensors = getattr(scene, "sensors", None) + if sensors is not None: + for name, sensor in sensors.items(): + data = getattr(sensor, "data", None) + output = getattr(data, "output", None) if data is not None else None + if not isinstance(output, dict): + continue + + tensor_output = {k: v for k, v in output.items() if isinstance(v, torch.Tensor) and v.numel() > 0} + if tensor_output: + outputs[name] = tensor_output + + return outputs + + +# Task IDs that expose camera/tiled_camera image observations; each is validated for non-blank rendering. +# The max different pixels percentage is set based on the screen space taken up by the env. +_RENDER_CORRECTNESS_TASK_IDS = [ + ("Isaac-Cartpole-Albedo-Camera-Direct-v0", "cartpole"), + ("Isaac-Cartpole-Camera-Presets-Direct-v0", "cartpole"), + ("Isaac-Cartpole-Depth-Camera-Direct-v0", "cartpole"), + ("Isaac-Cartpole-RGB-Camera-Direct-v0", "cartpole"), + ("Isaac-Cartpole-SimpleShading-Constant-Camera-Direct-v0", "cartpole"), + ("Isaac-Cartpole-SimpleShading-Diffuse-Camera-Direct-v0", "cartpole"), + ("Isaac-Cartpole-SimpleShading-Full-Camera-Direct-v0", "cartpole"), + pytest.param( + "Isaac-Repose-Cube-Shadow-Vision-Direct-v0", + "shadow_hand", + # The Shadow-Vision render is right at the SSIM/diff-pixel tolerance and intermittently + # exceeds the 3% diff threshold by a fraction of a percent. Allow up to 3 attempts and + # require at least one pass while we tighten the validation tolerances for this scene. + marks=pytest.mark.flaky(max_runs=3, min_passes=1), + ), +] + + +@pytest.mark.parametrize("task_id, env_name", _RENDER_CORRECTNESS_TASK_IDS) +def test_rendering_registered_tasks(task_id: str, env_name: str): + """Test registered tasks rendering correctness.""" + env = None + + try: + from isaaclab_tasks.utils.parse_cfg import parse_env_cfg + + env_cfg = parse_env_cfg(task_id, num_envs=4) + + env = gym.make(task_id, cfg=env_cfg) + unwrapped: Any = env.unwrapped + sim = getattr(unwrapped, "sim", None) + if sim is not None: + sim._app_control_on_stop_handle = None + + maybe_save_stage(f"registered_tasks_{task_id}", "default_physics", "default_renderer", "stage") + + camera_outputs_nested_dict = _collect_camera_outputs(env) + num_camera_outputs = len(camera_outputs_nested_dict) + assert num_camera_outputs == 1, f"[{task_id}] Expected 1 camera output, got {num_camera_outputs}." + + camera_outputs = next(iter(camera_outputs_nested_dict.values())) + + validate_camera_outputs( + f"registered_tasks/{task_id}", + "default_physics", + "default_renderer", + camera_outputs, + max_different_pixels_percentage=MAX_DIFFERENT_PIXELS_PERCENTAGE_BY_ENV_NAME[env_name], + comparison_scores=_COMPARISON_SCORES, + ) + finally: + if env is not None: + env.close() + + # This invokes camera sensor and renderer cleanup explicitly before pytest teardown, otherwise OV + # native code could probably complain about leaks and trigger segmentation fault. + env = None diff --git a/source/isaaclab_tasks/test/test_rendering_shadow_hand.py b/source/isaaclab_tasks/test/test_rendering_shadow_hand.py new file mode 100644 index 000000000000..b49bff692170 --- /dev/null +++ b/source/isaaclab_tasks/test/test_rendering_shadow_hand.py @@ -0,0 +1,37 @@ +# 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 Shadow Hand environment backend combinations.""" + +# 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_COMBINATIONS, + make_attach_comparison_properties_fixture, + make_determinism_fixture, + make_generate_html_report_fixture, + rendering_test_shadow_hand, +) + +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_type", PHYSICS_RENDERER_AOV_COMBINATIONS) +def test_rendering_shadow_hand(physics_backend, renderer, data_type): + """Test shadow hand environment rendering correctness.""" + rendering_test_shadow_hand(physics_backend, renderer, data_type, _COMPARISON_SCORES) diff --git a/source/isaaclab_tasks/test/test_rendering_shadow_hand_kitless.py b/source/isaaclab_tasks/test/test_rendering_shadow_hand_kitless.py new file mode 100644 index 000000000000..2244dcce5fab --- /dev/null +++ b/source/isaaclab_tasks/test/test_rendering_shadow_hand_kitless.py @@ -0,0 +1,33 @@ +# 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 rendering correctness tests for Shadow Hand environment backend combinations.""" + +from pathlib import Path + +import pytest +from rendering_test_utils import ( + KITLESS_PHYSICS_RENDERER_AOV_COMBINATIONS, + make_attach_comparison_properties_fixture, + make_determinism_fixture, + make_generate_html_report_fixture, + make_require_ovrtx_install_fixture, + rendering_test_shadow_hand, +) + +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) +_require_ovrtx_install_fixture = make_require_ovrtx_install_fixture() + + +@pytest.mark.parametrize("physics_backend,renderer,data_type", KITLESS_PHYSICS_RENDERER_AOV_COMBINATIONS) +def test_rendering_shadow_hand_kitless(physics_backend, renderer, data_type): + """Test shadow hand environment rendering correctness.""" + rendering_test_shadow_hand(physics_backend, renderer, data_type, _COMPARISON_SCORES) From 41908c06473472815b11fc1935179683d755caa8 Mon Sep 17 00:00:00 2001 From: ooctipus Date: Thu, 30 Apr 2026 13:29:01 -0700 Subject: [PATCH 10/40] Makes ovphysx an optional install of isaaclab_ovphysx (#5428) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description `source/isaaclab_ovphysx/setup.py` lists `"ovphysx"` in `install_requires`. The default `./isaaclab.sh -i` flow auto-discovers every package under `source/` and pip-installs each one, so the wrapper's hard dep on the `ovphysx` PyPI wheel is fetched unconditionally. When that wheel is unavailable (the common case for users who don't need the runtime), the whole install aborts and unrelated cfg classes such as `OvPhysxCfg` become unimportable. This change makes the runtime an opt-in extra: ```python INSTALL_REQUIRES: list[str] = [] EXTRAS_REQUIRE = {"ovphysx": ["ovphysx"]} ``` After the change: - `./isaaclab.sh -i` (default) — installs `isaaclab_ovphysx` wrapper-only, cfg modules import without needing the `ovphysx` wheel. - `pip install -e source/isaaclab_ovphysx[ovphysx]` — installs the wrapper with the `ovphysx` runtime alongside it for users who want it. No CLI machinery is touched. Users who want the runtime use pip's existing `[extra]` syntax directly; we don't need to surface it through `./isaaclab.sh` since `isaaclab_ovphysx` is already installed by the default all-scan. ## Type of change - Bug fix (non-breaking change which fixes an issue) ## 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` - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there Co-authored-by: Antoine RICHARD --- source/isaaclab_ovphysx/setup.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/source/isaaclab_ovphysx/setup.py b/source/isaaclab_ovphysx/setup.py index f590ce02140c..4898806285dd 100644 --- a/source/isaaclab_ovphysx/setup.py +++ b/source/isaaclab_ovphysx/setup.py @@ -13,9 +13,11 @@ EXTENSION_PATH = os.path.dirname(os.path.realpath(__file__)) EXTENSION_TOML_DATA = toml.load(os.path.join(EXTENSION_PATH, "config", "extension.toml")) -INSTALL_REQUIRES = [ - "ovphysx", -] +INSTALL_REQUIRES: list[str] = [] + +EXTRAS_REQUIRE = { + "ovphysx": ["ovphysx"], +} setup( name="isaaclab_ovphysx", @@ -30,6 +32,7 @@ package_data={"": ["*.pyi"]}, python_requires=">=3.11", install_requires=INSTALL_REQUIRES, + extras_require=EXTRAS_REQUIRE, packages=[ "isaaclab_ovphysx", "isaaclab_ovphysx.assets", From 5037857d7a12c370bc01d4ec1b2267085d3dc87b Mon Sep 17 00:00:00 2001 From: Kelly Guo Date: Thu, 30 Apr 2026 15:47:21 -0700 Subject: [PATCH 11/40] Avoids force push for doc deploy job (#5460) # Description Recent branch protection rules blocked force push from the doc deployment job to the gh-pages branch, breaking the documentation update pipeline of our built docs. This change removes the force push from the job, which shouldn't be necessary. ## Type of change - Bug fix (non-breaking change which fixes an issue) - Documentation update ## 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 - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [ ] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- .github/workflows/docs.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 32fdfb9378a7..b8c6037621ae 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -131,4 +131,3 @@ jobs: github_token: ${{ secrets.GITHUB_TOKEN }} publish_dir: ./docs/_build keep_files: false - force_orphan: true From 082e48854a9c15c90f0cf66304d82b8033745918 Mon Sep 17 00:00:00 2001 From: myurasov-nv <168484206+myurasov-nv@users.noreply.github.com> Date: Thu, 30 Apr 2026 16:19:57 -0700 Subject: [PATCH 12/40] Fixes more installation issues for ARM and Windows (#5431) # Description Fixes three NVBugs from the Apr 28 SQA wave against Isaac Lab 3.0 Beta2. NVBugs fixed: - nvbug#6123115 - pip wheel install does not pull skrl - nvbug#6122694 - nlopt wheel build fails on DGX Spark (ARM64) - nvbug#6110670 - Win11 isaaclab.bat --install rejects setuptools<82.0.0 spec Changes: - Document that the bundled training scripts need the [all] extras with the pip wheel. - On ARM Linux, isaaclab.sh -i now installs swig and pre-installs nlopt 2.6.2 with --no-build-isolation. - On Windows, extract_python_exe prefers kit/python/python.exe over python.bat, and the .bat fallback wrapper now caret-escapes cmd.exe metacharacters. ## Type of change - Bug fix (non-breaking change which fixes an issue) ## Checklist - [x] I have read and understood the contribution guidelines - [x] I have run the pre-commit checks with ./isaaclab.sh --format - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [x] I have updated the changelog and the corresponding version in the extension's config/extension.toml file - [x] I have added my name to the CONTRIBUTORS.md or my name already exists there --------- Signed-off-by: Kelly Guo Co-authored-by: Kelly Guo --- .../installation/include/pip_extras_note.rst | 8 +++++ .../isaaclab_pip_installation.rst | 4 +++ source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 31 +++++++++++++++++ .../isaaclab/isaaclab/cli/commands/install.py | 32 ++++++++++++++++++ source/isaaclab/isaaclab/cli/utils.py | 33 ++++++++++++++++--- 6 files changed, 104 insertions(+), 6 deletions(-) create mode 100644 docs/source/setup/installation/include/pip_extras_note.rst diff --git a/docs/source/setup/installation/include/pip_extras_note.rst b/docs/source/setup/installation/include/pip_extras_note.rst new file mode 100644 index 000000000000..67df28e15089 --- /dev/null +++ b/docs/source/setup/installation/include/pip_extras_note.rst @@ -0,0 +1,8 @@ +.. note:: + + The bare ``isaaclab`` install ships only the core extension. To run + the bundled training scripts under ``scripts/reinforcement_learning/`` + you must install with the ``[all]`` extras (or the per-framework + extras ``[skrl]`` / ``[sb3]`` / ``[rsl-rl]``); otherwise commands such + as ``python scripts/reinforcement_learning/skrl/train.py ...`` fail + at import time with ``ModuleNotFoundError: No module named 'skrl'``. diff --git a/docs/source/setup/installation/isaaclab_pip_installation.rst b/docs/source/setup/installation/isaaclab_pip_installation.rst index 1d98f1536971..9ac6b9bbf4f9 100644 --- a/docs/source/setup/installation/isaaclab_pip_installation.rst +++ b/docs/source/setup/installation/isaaclab_pip_installation.rst @@ -71,6 +71,8 @@ Isaac Lab sub-packages: # Isaac Lab + Isaac Sim + all sub-packages uv pip install "isaaclab[isaacsim,all]" --extra-index-url https://pypi.nvidia.com --index-strategy unsafe-best-match --prerelease=allow + .. include:: include/pip_extras_note.rst + .. tab-item:: pip .. code-block:: bash @@ -90,6 +92,8 @@ Isaac Lab sub-packages: # Isaac Lab + Isaac Sim + all Isaac Lab sub-packages pip install "isaaclab[isaacsim,all]" --extra-index-url https://pypi.nvidia.com --pre + .. include:: include/pip_extras_note.rst + Installing dependencies ~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index 322e44e742c9..729d39541f41 100644 --- a/source/isaaclab/config/extension.toml +++ b/source/isaaclab/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "4.6.23" +version = "4.6.25" # Description title = "Isaac Lab framework for Robot Learning" diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index 8d07df40fdea..c76cf6d8757d 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,37 @@ Changelog --------- +4.6.25 (2026-04-28) +~~~~~~~~~~~~~~~~~~~ + +Fixed +^^^^^ + +* Fixed ``isaaclab.bat --install`` on Windows 11 failing with + ``'"setuptools<82.0.0"': Expected package name at the start of dependency + specifier``. ``extract_python_exe`` now prefers the underlying + ``kit/python/python.exe`` over Isaac Sim's ``python.bat`` so child pip calls + bypass the cmd.exe quoting hop that was preserving the literal double + quotes through to pip's argv. The fallback ``cmd.exe /c`` wrapper for + ``.bat``/``.cmd`` invocations now also uses caret-escaping + (e.g. ``setuptools^<82.0.0``) for metacharacters instead of double-quoting, + so the bat-hop path no longer leaks quotes. + + +4.6.24 (2026-04-28) +~~~~~~~~~~~~~~~~~~~ + +Fixed +^^^^^ + +* Fixed ``./isaaclab.sh -i`` failing to build the ``nlopt`` wheel on ARM Linux + (e.g. DGX Spark) when the host image is missing SWIG. The bare-metal install + path now mirrors ``docker/Dockerfile.base``: on ARM Linux it installs + ``swig`` via apt and pre-installs ``nlopt==2.6.2`` with + ``--no-build-isolation`` so later submodule installs skip the source-build + fallback. + + 4.6.23 (2026-04-30) ~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab/isaaclab/cli/commands/install.py b/source/isaaclab/isaaclab/cli/commands/install.py index fb903d59573b..ba9735b92a6d 100644 --- a/source/isaaclab/isaaclab/cli/commands/install.py +++ b/source/isaaclab/isaaclab/cli/commands/install.py @@ -76,6 +76,16 @@ def _install_system_deps() -> None: ] run_command(["sudo"] + cmd if os.geteuid() != 0 else cmd) + # nlopt has no aarch64 manylinux wheel for the version pinned by + # isaacteleop[retargeters], so pip falls back to a CMake source build + # that needs SWIG. Mirrors the apt step in docker/Dockerfile.base. + if not shutil.which("swig"): + print_info("Installing swig (required for building nlopt on ARM)...") + cmd = ["apt-get", "update"] + run_command(["sudo"] + cmd if os.geteuid() != 0 else cmd) + cmd = ["apt-get", "install", "-y", "--no-install-recommends", "swig"] + run_command(["sudo"] + cmd if os.geteuid() != 0 else cmd) + def _torch_first_on_sys_path_is_prebundle(python_exe: str, *, env: dict[str, str]) -> bool: """Return True when the first ``torch`` on ``sys.path`` comes from a prebundle directory. @@ -108,6 +118,25 @@ def _torch_first_on_sys_path_is_prebundle(python_exe: str, *, env: dict[str, str return result.returncode == 1 +def _maybe_preinstall_arm_nlopt(pip_cmd: list[str]) -> None: + """Pre-install ``nlopt==2.6.2`` on ARM Linux to skip the source-build fallback. + + There is no aarch64 manylinux wheel for the ``nlopt 2.6.2`` version pinned + by ``isaacteleop[retargeters]``, so pip falls back to a CMake source build + that hides the host-Python ``numpy`` from its isolated build env. Mirror + the docker/Dockerfile.base arm64 step: install ``setuptools wheel numpy`` + in the host Python first, then ``--no-build-isolation`` install nlopt so + later submodule installs see it as already satisfied. + """ + if is_windows() or not is_arm(): + return + print_info("Pre-installing nlopt==2.6.2 on ARM (no-build-isolation)...") + print_info(" step 1/2: ensure setuptools/wheel/numpy are importable for the no-build-isolation backend") + run_command(pip_cmd + ["install", "setuptools", "wheel", "numpy"]) + print_info(" step 2/2: install nlopt==2.6.2 with --no-build-isolation") + run_command(pip_cmd + ["install", "--no-build-isolation", "nlopt==2.6.2"]) + + def _maybe_uninstall_prebundled_torch( python_exe: str, pip_cmd: list[str], @@ -682,6 +711,9 @@ def command_install(install_type: str = "all") -> None: # Pin setuptools to avoid issues with pkg_resources removal in 82.0.0. run_command(pip_cmd + ["install", "setuptools<82.0.0"]) + # On ARM Linux pre-install nlopt to dodge its from-source build fallback. + _maybe_preinstall_arm_nlopt(pip_cmd) + # Drop pip-installed torch if Isaac Sim's deprecated ML prebundle would shadow it. _maybe_uninstall_prebundled_torch(python_exe, pip_cmd, using_uv, probe_env=probe_env) diff --git a/source/isaaclab/isaaclab/cli/utils.py b/source/isaaclab/isaaclab/cli/utils.py index 5a3b60532870..611a1c6e8101 100644 --- a/source/isaaclab/isaaclab/cli/utils.py +++ b/source/isaaclab/isaaclab/cli/utils.py @@ -147,20 +147,34 @@ def _print_debug_env(prefix: str, env: dict[str, str] | None) -> None: def _escape_for_cmd_exe(cmd: list[str] | tuple[str, ...]) -> list[str]: - """Wrap .bat/.cmd calls in cmd.exe /c so args with < > | & ^ stay literal - (otherwise Windows treats e.g. setuptools<82.0.0 as a redirection). + """Wrap .bat/.cmd calls in cmd.exe /c so args with < > | & ^ stay literal. + + Uses cmd.exe caret-escaping (``^<``) for metacharacters in args without + whitespace; double-quotes args that contain whitespace. Avoids wrapping a + metacharacter-bearing arg in double quotes -- ``cmd.exe /c "...\"X str: if isaacsim_path is not None: if is_windows(): - python_exe = isaacsim_path / "python.bat" + # Prefer the underlying python.exe over python.bat so we avoid + # cmd.exe metacharacter-quoting hazards on pip args like + # ``setuptools<82.0.0``. isaaclab.bat already sources + # setup_conda_env.bat before reaching the CLI, so children + # inherit the right env without going back through python.bat. + kit_python_exe = isaacsim_path / "kit" / "python" / "python.exe" + if kit_python_exe.exists(): + python_exe = kit_python_exe + else: + python_exe = isaacsim_path / "python.bat" else: python_exe = isaacsim_path / "python.sh" From 8fdb1105218de6307cf30574eb2bffdf5a4ab4fc Mon Sep 17 00:00:00 2001 From: Mike Yan Michelis <46975745+mmichelis@users.noreply.github.com> Date: Fri, 1 May 2026 01:20:39 +0200 Subject: [PATCH 13/40] Remove colors of deformable cube tutorial docs (#5456) # Description Clear up docs on tutorials for deformable asset. No need to mention color of cubes (in case this changes in the future). ## Type of change - Documentation update ## 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 updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- docs/source/tutorials/01_assets/run_deformable_object.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/tutorials/01_assets/run_deformable_object.rst b/docs/source/tutorials/01_assets/run_deformable_object.rst index 7ae8092e6ba6..61092353cec8 100644 --- a/docs/source/tutorials/01_assets/run_deformable_object.rst +++ b/docs/source/tutorials/01_assets/run_deformable_object.rst @@ -163,7 +163,7 @@ Now that we have gone through the code, let's run the script and see the result: ./isaaclab.sh -p scripts/tutorials/01_assets/run_deformable_object.py --visualizer kit -This should open a stage with a ground plane, lights, and several green cubes. Two of the four cubes must be dropping +This should open a stage with a ground plane, lights, and several cubes. Two of the four cubes must be dropping from a height and settling on to the ground. Meanwhile the other two cubes must be moving along the z-axis. You should see a marker showing the kinematic target position for the nodes at the bottom-left corner of the cubes. To stop the simulation, you can either close the window, or press ``Ctrl+C`` in the terminal From 61c8a9457c461f5d7097c451f734b63fc02e7594 Mon Sep 17 00:00:00 2001 From: Kelly Guo Date: Thu, 30 Apr 2026 18:45:36 -0700 Subject: [PATCH 14/40] Fixes multi-GPU device resolution in AppLauncher (#5215) ## Description Centralizes multi-GPU device resolution in AppLauncher and launch_simulation() so that individual training scripts do not need per-script distributed device logic. ### Problem In distributed (multi-GPU) training, every RL script duplicates device resolution. This breaks when CUDA_VISIBLE_DEVICES restricts each process to a single GPU (e.g., SLURM), because local_rank=1 exceeds the visible device count. Additionally, physics backends like Newton/Warp that allocate on the current CUDA device during init may all default to cuda:0. ### Fix **app_launcher.py:** CUDA_VISIBLE_DEVICES-aware device ID resolution, early torch.cuda.set_device(), new self.device attribute. **sim_launcher.py:** New _resolve_distributed_device() helper centralizes device logic in launch_simulation(). After AppLauncher creation, propagates AppLauncher.device to env_cfg.sim.device. Handles kitless Newton path. Also treats PresetCfg renderers as non-Kit in _is_kit_camera(). **presets.py:** Adds newton and physx aliases to MultiBackendRendererCfg. ## Type of change - Bug fix (non-breaking change which fixes an issue) ## Checklist - [x] I have run the pre-commit checks - [x] My changes generate no new warnings - [x] I have read and understood the contribution guidelines --------- Co-authored-by: Kelly Guo Co-authored-by: Bob Co-authored-by: Mustafa H <34825877+StafaH@users.noreply.github.com> --- .github/workflows/test-multi-gpu.yaml | 153 ++++++ scripts/benchmarks/benchmark_non_rl.py | 9 +- scripts/benchmarks/benchmark_rlgames.py | 22 +- scripts/benchmarks/benchmark_rsl_rl.py | 11 +- .../reinforcement_learning/rl_games/train.py | 15 +- .../reinforcement_learning/rsl_rl/train.py | 13 +- scripts/reinforcement_learning/skrl/train.py | 11 +- source/isaaclab/isaaclab/app/app_launcher.py | 27 +- .../isaaclab_tasks/utils/sim_launcher.py | 77 +++ .../test_distributed_device_resolution.py | 479 ++++++++++++++++++ 10 files changed, 790 insertions(+), 27 deletions(-) create mode 100644 .github/workflows/test-multi-gpu.yaml create mode 100644 source/isaaclab_tasks/test/test_distributed_device_resolution.py diff --git a/.github/workflows/test-multi-gpu.yaml b/.github/workflows/test-multi-gpu.yaml new file mode 100644 index 000000000000..e9bee1c4ed2d --- /dev/null +++ b/.github/workflows/test-multi-gpu.yaml @@ -0,0 +1,153 @@ +# 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 + +# Multi-GPU distributed training validation +# +# This workflow validates that multi-GPU training works correctly across: +# - Physics backends: PhysX, Newton +# - Rendering backends: none (physics-only), Isaac RTX, Newton Warp +# +# Runs on a dedicated multi-GPU runner (separate from standard CI) to minimize costs. +# Only triggered on PRs that touch distributed training code paths. + +name: Multi-GPU Training Tests + +on: + pull_request: + paths: + - "source/isaaclab/isaaclab/app/app_launcher.py" + - "source/isaaclab_tasks/isaaclab_tasks/utils/sim_launcher.py" + - "scripts/reinforcement_learning/**/train.py" + - ".github/workflows/test-multi-gpu.yaml" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test-multi-gpu: + name: Multi-GPU (${{ matrix.physics }}, ${{ matrix.renderer }}) + # Use dedicated multi-GPU runner to avoid blocking standard CI resources + # Configure this label on a runner with 2+ GPUs (e.g., g5.12xlarge with 4x A10G) + runs-on: [self-hosted, linux, x64, gpu, multi-gpu] + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + # PhysX physics-only + - physics: physx + renderer: none + task: Isaac-Cartpole-Direct-v0 + extra_args: "" + + # PhysX + Isaac RTX renderer + - physics: physx + renderer: isaac-rtx + task: Isaac-Cartpole-Camera-Presets-Direct-v0 + extra_args: "" + trainer: skrl + + # PhysX + Newton Warp renderer (hybrid) + - physics: physx + renderer: newton-warp + task: Isaac-Cartpole-Camera-Presets-Direct-v0 + extra_args: "env.tiled_camera.renderer_cfg=newton_renderer" + trainer: skrl + + # Newton physics-only + - physics: newton + renderer: none + task: Isaac-Cartpole-Direct-v0 + extra_args: "+sim=newton" + + # Newton + Newton Warp renderer + - physics: newton + renderer: newton-warp + task: Isaac-Cartpole-Camera-Presets-Direct-v0 + extra_args: "+sim=newton env.tiled_camera.renderer_cfg=newton_renderer" + trainer: skrl + + # Newton + Isaac RTX renderer (hybrid) + - physics: newton + renderer: isaac-rtx + task: Isaac-Cartpole-Camera-Presets-Direct-v0 + extra_args: "+sim=newton" + trainer: skrl + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install Isaac Lab + run: | + ./isaaclab.sh --install + + - name: Verify multi-GPU availability + run: | + echo "=== GPU Info ===" + nvidia-smi --query-gpu=index,name,memory.total --format=csv + + GPU_COUNT=$(python -c "import torch; print(torch.cuda.device_count())") + echo "Detected $GPU_COUNT GPU(s)" + + if [ "$GPU_COUNT" -lt 2 ]; then + echo "::error::At least 2 GPUs required for multi-GPU tests, found $GPU_COUNT" + exit 1 + fi + + - name: Run multi-GPU training (${{ matrix.physics }}, ${{ matrix.renderer }}) + env: + NCCL_DEBUG: WARN + run: | + TRAINER="${{ matrix.trainer || 'rsl_rl' }}" + + echo "==========================================" + echo "Physics: ${{ matrix.physics }}" + echo "Renderer: ${{ matrix.renderer }}" + echo "Task: ${{ matrix.task }}" + echo "Trainer: $TRAINER" + echo "Extra args: ${{ matrix.extra_args }}" + echo "==========================================" + + # Run 2-GPU distributed training for 3 iterations + ./isaaclab.sh -p -m torch.distributed.run --nproc_per_node=2 \ + scripts/reinforcement_learning/${TRAINER}/train.py \ + --task=${{ matrix.task }} \ + --headless \ + --distributed \ + --max_iterations=3 \ + --num_envs=16 \ + ${{ matrix.extra_args }} + + - name: Verify training completed + run: | + # Find the most recent log directory + LATEST_LOG=$(ls -td logs/*/*/*/ 2>/dev/null | head -1) + + if [ -z "$LATEST_LOG" ]; then + echo "::error::No training log directory found" + exit 1 + fi + + echo "Log directory: $LATEST_LOG" + ls -la "$LATEST_LOG" + + # Check for model checkpoints + MODELS=$(find "$LATEST_LOG" -name "*.pt" | wc -l) + echo "Model checkpoints found: $MODELS" + + if [ "$MODELS" -lt 1 ]; then + echo "::error::No model checkpoints found - training may have failed" + exit 1 + fi + + echo "✅ Multi-GPU training completed successfully (${{ matrix.physics }}, ${{ matrix.renderer }})" diff --git a/scripts/benchmarks/benchmark_non_rl.py b/scripts/benchmarks/benchmark_non_rl.py index aee3be21a40e..4a4ffc700974 100644 --- a/scripts/benchmarks/benchmark_non_rl.py +++ b/scripts/benchmarks/benchmark_non_rl.py @@ -120,7 +120,12 @@ def main( # override configurations with non-hydra CLI arguments env_cfg.scene.num_envs = args_cli.num_envs if args_cli.num_envs is not None else env_cfg.scene.num_envs - env_cfg.sim.device = args_cli.device if args_cli.device is not None else env_cfg.sim.device + # For distributed training, launch_simulation() already resolved the + # correct per-rank device; only apply a CLI --device override for + # non-distributed runs (the default "cuda:0" would clobber the + # per-rank device otherwise). + if not args_cli.distributed: + env_cfg.sim.device = args_cli.device if args_cli.device is not None else env_cfg.sim.device env_cfg.seed = args_cli.seed # check for invalid combination of CPU device with distributed training @@ -131,10 +136,10 @@ def main( ) # process distributed + # env_cfg.sim.device is already resolved by launch_simulation(). world_size = 1 world_rank = 0 if args_cli.distributed: - env_cfg.sim.device = f"cuda:{int(os.getenv('LOCAL_RANK', '0'))}" world_size = int(os.getenv("WORLD_SIZE", 1)) world_rank = int(os.getenv("RANK", "0")) diff --git a/scripts/benchmarks/benchmark_rlgames.py b/scripts/benchmarks/benchmark_rlgames.py index 7ef482ad3a6f..b79c47a1ab69 100644 --- a/scripts/benchmarks/benchmark_rlgames.py +++ b/scripts/benchmarks/benchmark_rlgames.py @@ -147,7 +147,12 @@ def main( # override configurations with non-hydra CLI arguments env_cfg.scene.num_envs = args_cli.num_envs if args_cli.num_envs is not None else env_cfg.scene.num_envs - env_cfg.sim.device = args_cli.device if args_cli.device is not None else env_cfg.sim.device + # For distributed training, launch_simulation() already resolved the + # correct per-rank device; only apply a CLI --device override for + # non-distributed runs (the default "cuda:0" would clobber the + # per-rank device otherwise). + if not args_cli.distributed: + env_cfg.sim.device = args_cli.device if args_cli.device is not None else env_cfg.sim.device # check for invalid combination of CPU device with distributed training if args_cli.distributed and args_cli.device is not None and "cpu" in args_cli.device: raise ValueError( @@ -155,8 +160,9 @@ def main( "Please use GPU device (e.g., --device cuda) for distributed training." ) - # update agent device to match simulation device - if args_cli.device is not None: + # update agent device to match simulation device (skip for distributed — + # the per-rank device is resolved by launch_simulation) + if args_cli.device is not None and not args_cli.distributed: agent_cfg["params"]["config"]["device"] = args_cli.device agent_cfg["params"]["config"]["device_name"] = args_cli.device @@ -166,10 +172,10 @@ def main( agent_cfg["params"]["seed"] = args_cli.seed if args_cli.seed is not None else agent_cfg["params"]["seed"] # process distributed + # env_cfg.sim.device is already resolved by launch_simulation(). world_rank = 0 if args_cli.distributed: - env_cfg.sim.device = f"cuda:{int(os.getenv('LOCAL_RANK', '0'))}" - agent_cfg["params"]["config"]["device"] = f"cuda:{int(os.getenv('LOCAL_RANK', '0'))}" + agent_cfg["params"]["config"]["device"] = env_cfg.sim.device world_rank = int(os.getenv("RANK", "0")) # specify directory for logging experiments @@ -186,11 +192,9 @@ def main( # multi-gpu training config if args_cli.distributed: agent_cfg["params"]["seed"] += int(os.getenv("RANK", "0")) - agent_cfg["params"]["config"]["device"] = f"cuda:{int(os.getenv('LOCAL_RANK', '0'))}" - agent_cfg["params"]["config"]["device_name"] = f"cuda:{int(os.getenv('LOCAL_RANK', '0'))}" + agent_cfg["params"]["config"]["device"] = env_cfg.sim.device + agent_cfg["params"]["config"]["device_name"] = env_cfg.sim.device agent_cfg["params"]["config"]["multi_gpu"] = True - # update env config device - env_cfg.sim.device = f"cuda:{int(os.getenv('LOCAL_RANK', '0'))}" # max iterations if args_cli.max_iterations: diff --git a/scripts/benchmarks/benchmark_rsl_rl.py b/scripts/benchmarks/benchmark_rsl_rl.py index 753f81f0cb51..c6ec83e97ae5 100644 --- a/scripts/benchmarks/benchmark_rsl_rl.py +++ b/scripts/benchmarks/benchmark_rsl_rl.py @@ -156,7 +156,12 @@ def main( # set the environment seed # note: certain randomizations occur in the environment initialization so we set the seed here env_cfg.seed = agent_cfg.seed - env_cfg.sim.device = args_cli.device if args_cli.device is not None else env_cfg.sim.device + # For distributed training, launch_simulation() already resolved the + # correct per-rank device; only apply a CLI --device override for + # non-distributed runs (the default "cuda:0" would clobber the + # per-rank device otherwise). + if not args_cli.distributed: + env_cfg.sim.device = args_cli.device if args_cli.device is not None else env_cfg.sim.device # check for invalid combination of CPU device with distributed training if args_cli.distributed and args_cli.device is not None and "cpu" in args_cli.device: raise ValueError( @@ -165,11 +170,11 @@ def main( ) # multi-gpu training configuration + # env_cfg.sim.device is already resolved by launch_simulation(). world_rank = 0 world_size = 1 if args_cli.distributed: - env_cfg.sim.device = f"cuda:{int(os.getenv('LOCAL_RANK', '0'))}" - agent_cfg.device = f"cuda:{int(os.getenv('LOCAL_RANK', '0'))}" + agent_cfg.device = env_cfg.sim.device # use global rank for seed diversity across all nodes world_rank = int(os.getenv("RANK", "0")) diff --git a/scripts/reinforcement_learning/rl_games/train.py b/scripts/reinforcement_learning/rl_games/train.py index 4de23c19246a..697ca06660a3 100644 --- a/scripts/reinforcement_learning/rl_games/train.py +++ b/scripts/reinforcement_learning/rl_games/train.py @@ -84,7 +84,12 @@ def main(): with launch_simulation(env_cfg, args_cli): # override configurations with non-hydra CLI arguments env_cfg.scene.num_envs = args_cli.num_envs if args_cli.num_envs is not None else env_cfg.scene.num_envs - env_cfg.sim.device = args_cli.device if args_cli.device is not None else env_cfg.sim.device + # For distributed training, launch_simulation() already resolved the + # correct per-rank device; only apply a CLI --device override for + # non-distributed runs (the default "cuda:0" would clobber the + # per-rank device otherwise). + if not args_cli.distributed: + env_cfg.sim.device = args_cli.device if args_cli.device is not None else env_cfg.sim.device if args_cli.distributed and args_cli.device is not None and "cpu" in args_cli.device: raise ValueError( "Distributed training is not supported when using CPU device. " @@ -110,12 +115,12 @@ def main(): # multi-gpu training config if args_cli.distributed: - local_rank = int(os.getenv("LOCAL_RANK", "0")) agent_cfg["params"]["seed"] += int(os.getenv("RANK", "0")) - agent_cfg["params"]["config"]["device"] = f"cuda:{local_rank}" - agent_cfg["params"]["config"]["device_name"] = f"cuda:{local_rank}" + # env_cfg.sim.device is resolved by launch_simulation() which + # accounts for CUDA_VISIBLE_DEVICES restrictions. + agent_cfg["params"]["config"]["device"] = env_cfg.sim.device + agent_cfg["params"]["config"]["device_name"] = env_cfg.sim.device agent_cfg["params"]["config"]["multi_gpu"] = True - env_cfg.sim.device = f"cuda:{local_rank}" # set the environment seed (after multi-gpu config for updated rank from agent seed) env_cfg.seed = agent_cfg["params"]["seed"] diff --git a/scripts/reinforcement_learning/rsl_rl/train.py b/scripts/reinforcement_learning/rsl_rl/train.py index da865153b398..eefc13a8aa2c 100644 --- a/scripts/reinforcement_learning/rsl_rl/train.py +++ b/scripts/reinforcement_learning/rsl_rl/train.py @@ -120,7 +120,12 @@ def main(env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, agen # set the environment seed # note: certain randomizations occur in the environment initialization so we set the seed here env_cfg.seed = agent_cfg.seed - env_cfg.sim.device = args_cli.device if args_cli.device is not None else env_cfg.sim.device + # For distributed training, launch_simulation() already resolved the + # correct per-rank device; only apply a CLI --device override for + # non-distributed runs (the default "cuda:0" would clobber the + # per-rank device otherwise). + if not args_cli.distributed: + env_cfg.sim.device = args_cli.device if args_cli.device is not None else env_cfg.sim.device # check for invalid combination of CPU device with distributed training if args_cli.distributed and args_cli.device is not None and "cpu" in args_cli.device: raise ValueError( @@ -130,10 +135,10 @@ def main(env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, agen # multi-gpu training configuration if args_cli.distributed: - local_rank = int(os.getenv("LOCAL_RANK", "0")) global_rank = int(os.getenv("RANK", "0")) - env_cfg.sim.device = f"cuda:{local_rank}" - agent_cfg.device = f"cuda:{local_rank}" + # env_cfg.sim.device is resolved by launch_simulation() which + # accounts for CUDA_VISIBLE_DEVICES restrictions. + agent_cfg.device = env_cfg.sim.device # use global rank for seed diversity across all nodes seed = agent_cfg.seed + global_rank diff --git a/scripts/reinforcement_learning/skrl/train.py b/scripts/reinforcement_learning/skrl/train.py index 472e9faf57b3..535403e5a105 100644 --- a/scripts/reinforcement_learning/skrl/train.py +++ b/scripts/reinforcement_learning/skrl/train.py @@ -117,7 +117,12 @@ def main(): # override configurations with non-hydra CLI arguments env_cfg.scene.num_envs = args_cli.num_envs if args_cli.num_envs is not None else env_cfg.scene.num_envs - env_cfg.sim.device = args_cli.device if args_cli.device is not None else env_cfg.sim.device + # For distributed training, launch_simulation() already resolved the + # correct per-rank device; only apply a CLI --device override for + # non-distributed runs (the default "cuda:0" would clobber the + # per-rank device otherwise). + if not args_cli.distributed: + env_cfg.sim.device = args_cli.device if args_cli.device is not None else env_cfg.sim.device if args_cli.distributed and args_cli.device is not None and "cpu" in args_cli.device: raise ValueError( @@ -127,9 +132,9 @@ def main(): # multi-gpu training config if args_cli.distributed: - local_rank = int(os.getenv("LOCAL_RANK", "0")) global_rank = int(os.getenv("RANK", "0")) - env_cfg.sim.device = f"cuda:{local_rank}" + # env_cfg.sim.device is resolved by launch_simulation() which + # accounts for CUDA_VISIBLE_DEVICES restrictions. # max iterations for training if args_cli.max_iterations: agent_cfg["trainer"]["timesteps"] = args_cli.max_iterations * agent_cfg["agent"]["rollouts"] diff --git a/source/isaaclab/isaaclab/app/app_launcher.py b/source/isaaclab/isaaclab/app/app_launcher.py index 0322713799e4..513757e0808b 100644 --- a/source/isaaclab/isaaclab/app/app_launcher.py +++ b/source/isaaclab/isaaclab/app/app_launcher.py @@ -231,6 +231,7 @@ def __init__(self, launcher_args: argparse.Namespace | dict | None = None, **kwa # Exposed to train scripts self.device_id: int # device ID for GPU simulation (defaults to 0) + self.device: str # resolved device string (e.g. "cuda:0" or "cpu") self.local_rank: int # local rank of GPUs in the current node self.global_rank: int # global rank for multi-node training @@ -953,7 +954,20 @@ def _resolve_device_settings(self, launcher_args: dict): # global rank (GPU id) in multi-gpu multi-node mode self.global_rank = int(os.getenv("RANK", "0")) + int(os.getenv("JAX_RANK", "0")) - self.device_id = self.local_rank + # When CUDA_VISIBLE_DEVICES restricts each process to a single GPU, + # local_rank may exceed the visible device count. Fall back to cuda:0 + # so the process uses the one GPU it can see. + # We compare local_rank against device_count (not WORLD_SIZE) so that + # multi-node setups work correctly: WORLD_SIZE is global across all + # nodes, but device_count is local. + import torch + + num_visible_gpus = torch.cuda.device_count() + if self.local_rank < num_visible_gpus: + self.device_id = self.local_rank + else: + self.device_id = 0 + device = "cuda:" + str(self.device_id) launcher_args["multi_gpu"] = False # limit CPU threads to minimize thread context switching @@ -971,6 +985,17 @@ def _resolve_device_settings(self, launcher_args: dict): launcher_args["physics_gpu"] = self.device_id launcher_args["active_gpu"] = self.device_id + # Set the current CUDA device early so that physics backends (e.g. Newton/Warp) + # that allocate on the "current" device during initialization get the correct GPU. + # Without this, all ranks may default to cuda:0 for early allocations. + if "cuda" in device: + import torch + + torch.cuda.set_device(self.device_id) + + # Store the resolved device string for downstream consumers (e.g. sim_launcher) + self.device = device + logger.info("Using device: %s", device) def _resolve_experience_file(self, launcher_args: dict): diff --git a/source/isaaclab_tasks/isaaclab_tasks/utils/sim_launcher.py b/source/isaaclab_tasks/isaaclab_tasks/utils/sim_launcher.py index 0f0c6e5404de..658b5a1b873e 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/utils/sim_launcher.py +++ b/source/isaaclab_tasks/isaaclab_tasks/utils/sim_launcher.py @@ -122,6 +122,15 @@ def _is_kit_camera(node) -> bool: return True if isinstance(renderer_cfg, RendererCfg): return renderer_cfg.renderer_type in ("default", "isaac_rtx") + # PresetCfg renderers (e.g. MultiBackendRendererCfg) are resolved during + # environment construction when the physics backend is known (see + # resolve_task_config and preset resolution in presets.py). At this + # stage we assume they will match the physics backend, so not + # necessarily Kit. + from isaaclab_tasks.utils import PresetCfg + + if isinstance(renderer_cfg, PresetCfg): + return False return True @@ -149,6 +158,64 @@ def compute_kit_requirements( return needs_kit, has_kit_cameras, visualizer_types +def _resolve_distributed_device( + env_cfg, + launcher_args: argparse.Namespace | dict | None, +) -> None: + """Set ``env_cfg.sim.device`` for distributed training. + + When ``--distributed`` is active and CUDA_VISIBLE_DEVICES restricts each + process to a single GPU, ``local_rank`` may exceed the visible device count. + This helper applies the same fallback logic used by :class:`AppLauncher` so + that **training scripts do not need their own device-resolution code**. + + For the Kit path, :func:`launch_simulation` additionally propagates + ``AppLauncher.device`` after creation; this function handles the early + (pre-AppLauncher) and kitless cases. + """ + distributed = False + if isinstance(launcher_args, argparse.Namespace): + distributed = getattr(launcher_args, "distributed", False) + elif isinstance(launcher_args, dict): + distributed = launcher_args.get("distributed", False) + + if not distributed: + return + + import os + + import torch + + local_rank = int(os.getenv("LOCAL_RANK", "0")) + int(os.getenv("JAX_LOCAL_RANK", "0")) + num_visible_gpus = torch.cuda.device_count() + + # Compare local_rank against device_count (not WORLD_SIZE) so that + # multi-node setups work correctly: WORLD_SIZE is global across all + # nodes, but device_count is local. + if local_rank < num_visible_gpus: + device_str = f"cuda:{local_rank}" + else: + device_str = "cuda:0" + + sim_cfg = getattr(env_cfg, "sim", None) + if sim_cfg is not None: + sim_cfg.device = device_str + + # Set CUDA device early so physics backends that allocate on the + # "current" device during init get the correct GPU. For the Kit path, + # AppLauncher._resolve_device_settings will call set_device again with + # the same value, which is harmless. For the kitless Newton path, this + # is the only place it gets set. + torch.cuda.set_device(device_str) + + logger.info( + "Distributed device resolved to %s (local_rank=%d, visible_gpus=%d)", + device_str, + local_rank, + num_visible_gpus, + ) + + @contextmanager def launch_simulation( env_cfg, @@ -185,6 +252,9 @@ def launch_simulation( close_fn: Any = None + # Resolve distributed device early, before AppLauncher or physics init. + _resolve_distributed_device(env_cfg, launcher_args) + if needs_kit: # check if Isaac Sim is installed import importlib.util @@ -235,6 +305,13 @@ def launch_simulation( from isaaclab.app import AppLauncher app_launcher = AppLauncher(launcher_args) + # AppLauncher may refine the device choice (e.g. Kit-specific + # overrides), so propagate its final value to env_cfg. This + # intentionally overwrites the earlier value set by + # _resolve_distributed_device. + sim_cfg = getattr(env_cfg, "sim", None) + if sim_cfg is not None and hasattr(app_launcher, "device"): + sim_cfg.device = app_launcher.device close_fn = app_launcher.app.close elif visualizer_types: # Newton path without Kit: AppLauncher is skipped, so manually store the visualizer diff --git a/source/isaaclab_tasks/test/test_distributed_device_resolution.py b/source/isaaclab_tasks/test/test_distributed_device_resolution.py new file mode 100644 index 000000000000..ec592c2b1342 --- /dev/null +++ b/source/isaaclab_tasks/test/test_distributed_device_resolution.py @@ -0,0 +1,479 @@ +# 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 distributed multi-GPU device resolution logic. + +These tests verify that ``_resolve_distributed_device`` (in sim_launcher) +correctly handles: + +- Normal multi-GPU: each rank sees all GPUs (local_rank maps directly) +- CUDA_VISIBLE_DEVICES restricted: each rank sees 1 GPU (fallback to cuda:0) +- Multi-node: WORLD_SIZE > local GPU count (local_rank still maps correctly) +- JAX_LOCAL_RANK: added to local_rank for JAX distributed training +- Non-distributed: no device override applied +- launch_simulation device propagation from AppLauncher + +No actual GPUs required — ``torch.cuda.device_count`` and +``torch.cuda.set_device`` are mocked throughout. +""" + +from __future__ import annotations + +import argparse +import os +import sys +import types +from unittest.mock import patch + +import isaaclab_tasks.utils.sim_launcher as sim_launcher + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +class _DummySimCfg: + """Minimal sim config stub with a mutable ``device`` attribute.""" + + def __init__(self, device: str = "cuda:0"): + self.device = device + + +class _DummyEnvCfg: + """Minimal env config stub wrapping a sim config.""" + + def __init__(self, device: str = "cuda:0"): + self.sim = _DummySimCfg(device) + + +def _make_distributed_args(**overrides) -> argparse.Namespace: + """Create an argparse.Namespace with ``distributed=True`` plus any overrides.""" + defaults = {"distributed": True} + defaults.update(overrides) + return argparse.Namespace(**defaults) + + +def _make_env_vars( + local_rank: int = 0, + world_size: int = 2, + rank: int = 0, + jax_local_rank: int | None = None, + jax_rank: int | None = None, +) -> dict[str, str]: + """Build a dict of environment variables for distributed training.""" + env = { + "LOCAL_RANK": str(local_rank), + "WORLD_SIZE": str(world_size), + "RANK": str(rank), + } + if jax_local_rank is not None: + env["JAX_LOCAL_RANK"] = str(jax_local_rank) + if jax_rank is not None: + env["JAX_RANK"] = str(jax_rank) + return env + + +# --------------------------------------------------------------------------- +# _resolve_distributed_device — Namespace launcher_args +# --------------------------------------------------------------------------- + + +class TestResolveDistributedDeviceNamespace: + """Tests for _resolve_distributed_device with argparse.Namespace args.""" + + @patch("torch.cuda.set_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("torch.cuda.set_device") + @patch("torch.cuda.device_count", return_value=4) + def test_normal_multi_gpu_rank3(self, mock_count, mock_set_device): + """4 visible GPUs, world_size=4, rank 3 → cuda:3.""" + env_cfg = _DummyEnvCfg() + args = _make_distributed_args() + 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:3" + mock_set_device.assert_called_once_with("cuda:3") + + @patch("torch.cuda.set_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("torch.cuda.set_device") + @patch("torch.cuda.device_count", return_value=1) + def test_cuda_visible_devices_restricted_rank1(self, mock_count, mock_set_device): + """1 visible GPU, world_size=2, rank 1 → falls back to cuda:0 (not cuda:1).""" + 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:0" + mock_set_device.assert_called_once_with("cuda:0") + + @patch("torch.cuda.set_device") + @patch("torch.cuda.device_count", return_value=2) + def test_jax_local_rank_added(self, mock_count, mock_set_device): + """JAX_LOCAL_RANK is added to LOCAL_RANK for correct device mapping.""" + env_cfg = _DummyEnvCfg() + args = _make_distributed_args() + # LOCAL_RANK=0, JAX_LOCAL_RANK=1 → effective local_rank=1 + 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:1" + mock_set_device.assert_called_once_with("cuda:1") + + @patch("torch.cuda.set_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 +# --------------------------------------------------------------------------- + + +class TestResolveDistributedDeviceDict: + """Tests for _resolve_distributed_device with dict-style args.""" + + @patch("torch.cuda.set_device") + @patch("torch.cuda.device_count", return_value=4) + def test_dict_args_distributed(self, mock_count, mock_set_device): + """Dict launcher_args with distributed=True should work identically.""" + env_cfg = _DummyEnvCfg() + args = {"distributed": True} + env = _make_env_vars(local_rank=2, 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:2" + mock_set_device.assert_called_once_with("cuda:2") + + @patch("torch.cuda.set_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) +# --------------------------------------------------------------------------- + + +class TestResolveDistributedDeviceNoop: + """Tests that non-distributed runs skip device resolution.""" + + @patch("torch.cuda.set_device") + def test_not_distributed_namespace(self, mock_set_device): + """distributed=False → 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("torch.cuda.set_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) + + assert env_cfg.sim.device == "cuda:0" + mock_set_device.assert_not_called() + + @patch("torch.cuda.set_device") + def test_no_distributed_key(self, mock_set_device): + """Dict without 'distributed' key → no-op.""" + env_cfg = _DummyEnvCfg(device="cuda:0") + args = {} + + sim_launcher._resolve_distributed_device(env_cfg, args) + + assert env_cfg.sim.device == "cuda:0" + mock_set_device.assert_not_called() + + @patch("torch.cuda.set_device") + def test_none_launcher_args(self, mock_set_device): + """launcher_args=None → no-op.""" + env_cfg = _DummyEnvCfg(device="cuda:0") + + sim_launcher._resolve_distributed_device(env_cfg, None) + + assert env_cfg.sim.device == "cuda:0" + mock_set_device.assert_not_called() + + +# --------------------------------------------------------------------------- +# _resolve_distributed_device — edge cases +# --------------------------------------------------------------------------- + + +class TestResolveDistributedDeviceEdgeCases: + """Edge cases for device resolution.""" + + @patch("torch.cuda.set_device") + @patch("torch.cuda.device_count", return_value=2) + def test_env_cfg_without_sim(self, mock_count, mock_set_device): + """env_cfg with no 'sim' attribute → set_device still called, no crash.""" + + class _BareEnvCfg: + pass + + env_cfg = _BareEnvCfg() + 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) + + # Should still call set_device even without sim_cfg + mock_set_device.assert_called_once_with("cuda:1") + + @patch("torch.cuda.set_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("torch.cuda.set_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("torch.cuda.set_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("torch.cuda.set_device") + @patch("torch.cuda.device_count", return_value=4) + def test_missing_env_vars_default_to_zero(self, mock_count, mock_set_device): + """Missing LOCAL_RANK/WORLD_SIZE → defaults to 0/1.""" + env_cfg = _DummyEnvCfg() + args = _make_distributed_args() + + # Remove distributed env vars if they exist + clean_env = { + k: v + for k, v in os.environ.items() + if k not in ("LOCAL_RANK", "WORLD_SIZE", "RANK", "JAX_LOCAL_RANK", "JAX_RANK") + } + + with patch.dict(os.environ, clean_env, clear=True): + sim_launcher._resolve_distributed_device(env_cfg, args) + + # local_rank=0, 0 < 4 → cuda:0 + assert env_cfg.sim.device == "cuda:0" + + +# --------------------------------------------------------------------------- +# _resolve_distributed_device — multi-node scenarios +# --------------------------------------------------------------------------- + + +class TestResolveDistributedDeviceMultiNode: + """Tests for multi-node setups where WORLD_SIZE > local GPU count.""" + + @patch("torch.cuda.set_device") + @patch("torch.cuda.device_count", return_value=4) + def test_multi_node_rank3_sees_4_gpus(self, mock_count, mock_set_device): + """2 nodes × 4 GPUs, WORLD_SIZE=8, local_rank=3, 4 visible → cuda:3. + + Previously this would fail because 4 >= 8 is False, falling back to cuda:0. + With the fix (local_rank < num_visible_gpus), 3 < 4 → cuda:3 ✅ + """ + env_cfg = _DummyEnvCfg() + args = _make_distributed_args() + env = _make_env_vars(local_rank=3, world_size=8, rank=7) + + with patch.dict(os.environ, env, clear=False): + sim_launcher._resolve_distributed_device(env_cfg, args) + + assert env_cfg.sim.device == "cuda:3" + mock_set_device.assert_called_once_with("cuda:3") + + @patch("torch.cuda.set_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("torch.cuda.set_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 +# --------------------------------------------------------------------------- + + +class TestLaunchSimulationDevicePropagation: + """Verify that launch_simulation propagates AppLauncher.device to env_cfg.""" + + def test_kit_path_propagates_applauncher_device(self, monkeypatch): + """When Kit is needed, AppLauncher.device should be written to env_cfg.sim.device.""" + + class _FakeAppLauncher: + def __init__(self, launcher_args): + self.device = "cuda:3" # Simulate resolved device + self.app = types.SimpleNamespace(close=lambda: None) + + # Mock has_kit to return False so AppLauncher gets created + mock_isaaclab_utils = types.ModuleType("isaaclab.utils") + mock_isaaclab_utils.has_kit = lambda: False + monkeypatch.setitem(sys.modules, "isaaclab.utils", mock_isaaclab_utils) + + monkeypatch.setitem( + sys.modules, + "isaaclab.app", + types.SimpleNamespace(AppLauncher=_FakeAppLauncher), + ) + monkeypatch.setattr( + "importlib.util.find_spec", + lambda name: object() if name == "omni.kit" else None, + ) + # Force needs_kit=True, no cameras + monkeypatch.setattr( + sim_launcher, + "compute_kit_requirements", + lambda env_cfg, launcher_args: (True, False, set()), + ) + # Mock _resolve_distributed_device to avoid torch.cuda calls + monkeypatch.setattr( + sim_launcher, + "_resolve_distributed_device", + lambda env_cfg, launcher_args: None, + ) + + env_cfg = _DummyEnvCfg(device="cuda:0") + args = argparse.Namespace() + + with sim_launcher.launch_simulation(env_cfg, args): + pass + + assert env_cfg.sim.device == "cuda:3" + + def test_kitless_path_uses_resolve_distributed_device(self, 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") + + monkeypatch.setattr( + sim_launcher, + "compute_kit_requirements", + lambda env_cfg, launcher_args: (False, False, set()), + ) + 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 + + assert env_cfg.sim.device == "cuda:1" + assert len(resolved_devices) == 1 From 38279561e6d32de45f52bb568a58cfd27ca6319f Mon Sep 17 00:00:00 2001 From: hujc Date: Thu, 30 Apr 2026 20:51:05 -0700 Subject: [PATCH 15/40] [Rough Locomotion] Part 2: H1/Cassie bipeds on Newton (#5298) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 1. Summary Restores biped-specific reset overrides on H1, Cassie, Digit, G1 that were lost when the parent PR (#5248) consolidated startup events into the shared `EventsCfg`. Re-enables `add_base_mass` randomization on H1 and Cassie with the new log-uniform scale default. This PR contains only the biped-level deltas — Newton physics, shape margin, and quadruped enablement all live in #5248. ## 2. Dependencies 1. PR #5365 — `checked_apply` helper. 2. PR #5248 — quadruped Newton support, shared `RoughPhysicsCfg`, `NewtonShapeCfg(margin=0.01)`. ## 3. Changes ### 3.1 Restore biped reset overrides Bipeds have precise initial poses that should not be randomly scaled on reset. The shared `EventsCfg.reset_robot_joints` uses `position_range = (0.5, 1.5)`; bipeds override to `(1.0, 1.0)`: | Env | Override | |---|---| | H1 | `position_range = (1.0, 1.0)` | | Cassie | `position_range = (1.0, 1.0)` + leg `armature = 0.02` for stability on rough terrain | | Digit | `position_range = (1.0, 1.0)` | | G1 | `position_range = (1.0, 1.0)` | ### 3.2 Re-enable `add_base_mass` on H1 and Cassie Per-env `add_base_mass = None` overrides on H1 and Cassie (pre-existing biped convention from PR #444, reinforced by PR #4165's Newton NaN TODO) are removed. The parent PR's new log-uniform scale default `(1/1.25, 1.25)` is safer for bipeds than the old additive `(-5, 5)` kg (which was effectively ±25% on H1's torso vs ±100% on Cassie's pelvis). - **H1** inherits the shared default (symmetric ±25% scale, `body_names="torso_link"`). - **Cassie** overrides to `(1.0, 1.25)` asymmetric heavier-bias: lighter-than-nominal pelvis destabilizes Cassie's closed-loop Achilles rod coupling and hip PD response, while heavier-than-nominal dampens dynamics. | Variant | reward | ep len | vs disabled | |---|---:|---:|---:| | Disabled (`= None`) | +20.00 | 982 | ref | | Symmetric ±25% (`(1/1.25, 1.25)`) | +12.00 | 605 | -40% (regression) | | Asymmetric heavier `(1.0, 1.25)` | +18.00 | 935 | **+90%** (chosen) | H1 reward at iter 1499: `24.02` with mass rand on vs `23.58` with it disabled — essentially equal; re-enabling provides sim-to-real robustness at negligible training cost. ## 4. PhysX / Newton parity (1500 iter, 4096 envs, last30 avg) | Robot | PhysX 1500 | Newton 1500 | Newton/PhysX | |---|---:|---:|---:| | H1 | **+18.15** | **+24.05** | **132%** ✓ | | Cassie | **+19.57** | **+24.75** | **127%** ✓ | Both bipeds reach parity or better on Newton at full 1500-iter training. ## 5. Versions - `isaaclab_tasks` 1.5.25 → 1.5.26 ## Type of change - New feature (non-breaking). --------- Co-authored-by: ooctipus --- source/isaaclab_tasks/config/extension.toml | 2 +- source/isaaclab_tasks/docs/CHANGELOG.rst | 17 +++++++++++++++++ .../velocity/config/cassie/rough_env_cfg.py | 16 ++++++++++++++-- .../velocity/config/digit/rough_env_cfg.py | 2 ++ .../velocity/config/g1/rough_env_cfg.py | 2 ++ .../velocity/config/h1/rough_env_cfg.py | 7 +++++-- 6 files changed, 41 insertions(+), 5 deletions(-) diff --git a/source/isaaclab_tasks/config/extension.toml b/source/isaaclab_tasks/config/extension.toml index c6e5ea6bc181..4fa2321c276f 100644 --- a/source/isaaclab_tasks/config/extension.toml +++ b/source/isaaclab_tasks/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "1.5.32" +version = "1.5.33" # Description title = "Isaac Lab Environments" diff --git a/source/isaaclab_tasks/docs/CHANGELOG.rst b/source/isaaclab_tasks/docs/CHANGELOG.rst index 1c8d2c65b979..9a0f648f490e 100644 --- a/source/isaaclab_tasks/docs/CHANGELOG.rst +++ b/source/isaaclab_tasks/docs/CHANGELOG.rst @@ -1,6 +1,23 @@ Changelog --------- +1.5.33 (2026-04-30) +~~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Re-enabled ``add_base_mass`` randomization on H1 and Cassie in their + rough-terrain configs (previously ``= None`` per the pre-existing biped + convention). H1 uses the shared log-uniform scale default from + ``EventsCfg``; Cassie overrides to ``(1.0, 1.25)`` asymmetric heavier-bias + (never lighter than nominal). Symmetric ±25% regressed Cassie reward by + 40% vs disabled due to closed-loop Achilles coupling destabilizing on + lighter pelvis mass; ``(1.0, 1.25)`` recovers to 90% of the + mass-rand-disabled baseline while retaining the domain-randomization + benefit. + + 1.5.32 (2026-04-30) ~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/cassie/rough_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/cassie/rough_env_cfg.py index a6eb29c49132..1d9b00d725e1 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/cassie/rough_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/cassie/rough_env_cfg.py @@ -13,6 +13,7 @@ LocomotionVelocityRoughEnvCfg, RewardsCfg, ) +from isaaclab_tasks.utils import preset ## # Pre-defined configs @@ -64,12 +65,23 @@ def __post_init__(self): self.commands.base_velocity.vel_yaw_success_threshold = 0.8 # scene self.scene.robot = CASSIE_CFG.replace(prim_path="{ENV_REGEX_NS}/Robot") + # Cassie Newton-only armature for biped stability on rough terrain; PhysX unchanged + self.scene.robot.actuators["legs"].armature = preset(default=0.0, newton=0.02) + self.scene.height_scanner.prim_path = "{ENV_REGEX_NS}/Robot/pelvis" - # Cassie uses "pelvis" as base body — disable mass randomization for bipeds - self.events.add_base_mass = None + # Cassie uses "pelvis" as base body. Override the shared symmetric + # (1/1.25, 1.25) log-uniform scale with asymmetric (1.0, 1.25) — + # lighter-than-nominal pelvis destabilizes Cassie's closed-loop + # Achilles coupling + hip PD response, so only heavier perturbations + # are safe. Symmetric ±25% regressed reward 40% vs disabled; + # (1.0, 1.25) recovers to 90% of baseline. + self.events.add_base_mass.params["asset_cfg"].body_names = "pelvis" + self.events.add_base_mass.params["mass_distribution_params"] = (1.0, 1.25) self.events.base_com = None self.events.base_external_force_torque.params["asset_cfg"].body_names = ".*pelvis" + # Cassie has precise initial pose — don't scale joint defaults randomly on reset + self.events.reset_robot_joints.params["position_range"] = (1.0, 1.0) # actions self.actions.joint_pos.scale = 0.5 diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/digit/rough_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/digit/rough_env_cfg.py index 89f6647a24f6..aa0f433e4ecc 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/digit/rough_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/digit/rough_env_cfg.py @@ -232,6 +232,8 @@ def __post_init__(self): self.events.add_base_mass.params["asset_cfg"].body_names = "torso_base" self.events.base_external_force_torque.params["asset_cfg"].body_names = "torso_base" self.events.base_com.default.params["asset_cfg"].body_names = "torso_base" + # Digit has precise initial pose — don't scale joint defaults randomly on reset + self.events.reset_robot_joints.params["position_range"] = (1.0, 1.0) # Override actuator to target only actuated joints. Digit has ball joints (rod constraints) # that MuJoCo represents with 4 DoFs instead of 3, inflating joint_pos to 74 columns while diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/g1/rough_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/g1/rough_env_cfg.py index c791a86c25e5..65dbb157c177 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/g1/rough_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/g1/rough_env_cfg.py @@ -123,6 +123,8 @@ def __post_init__(self): self.events.add_base_mass = None self.events.base_com = None self.events.base_external_force_torque.params["asset_cfg"].body_names = "torso_link" + # G1 has precise initial pose — don't scale joint defaults randomly on reset + self.events.reset_robot_joints.params["position_range"] = (1.0, 1.0) # Rewards self.rewards.lin_vel_z_l2.weight = 0.0 diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/h1/rough_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/h1/rough_env_cfg.py index 90e95dc21eaa..167141c1747e 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/h1/rough_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/h1/rough_env_cfg.py @@ -87,8 +87,11 @@ def __post_init__(self): if self.scene.height_scanner: self.scene.height_scanner.prim_path = "{ENV_REGEX_NS}/Robot/torso_link" - # H1 uses "torso_link" as base body — disable mass randomization for bipeds - self.events.add_base_mass = None + # H1 uses "torso_link" as base body; inherits the shared log-uniform mass + # randomization scale from EventsCfg (no per-H1 override needed). + self.events.add_base_mass.params["asset_cfg"].body_names = "torso_link" + # H1 has precise initial pose — don't scale joint defaults randomly on reset + self.events.reset_robot_joints.params["position_range"] = (1.0, 1.0) self.events.base_com = None self.events.base_external_force_torque.params["asset_cfg"].body_names = ".*torso_link" From 11b8c64f373a834148f0354b901d5cfdaf11a2e1 Mon Sep 17 00:00:00 2001 From: ooctipus Date: Thu, 30 Apr 2026 21:57:31 -0700 Subject: [PATCH 16/40] Suspends Fabric USD notice listener during cloning for faster startup (#5432) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description Adds a re-entrant context manager `disabled_fabric_change_notifies` that suspends the `omni::fabric::IFabricUsd` USD notice listener for the duration of bulk cloning. During `Sdf.CopySpec` loops in `usd_replicate`, every per-prim mutation otherwise fires `IFabricUsd::UsdNoticeListener::Handle` to do an immediate Fabric↔USD sync — for moderately heavy scenes that single hot path can dominate scene-load time. Toggling the listener's soft flag (`IFabricUsd.cpp:739`) gates the work without unregistering the listener, then the natural `SimulationContext.reset` path performs the catch-up resync in one pass. The same handler is what `isaacsim.core.cloner.Cloner.disable_change_listener` toggles, but this implementation reaches it through the underlying `omni::fabric::IFabricUsd` Carbonite interface directly, so the cloner has **no `isaacsim.core.simulation_manager` dependency**. Since `omni.fabric` has no public Python binding for `setEnableChangeNotifies`, acquisition is via a small ctypes module modelled on the in-tree `isaaclab_newton.physics._cubric` pattern. The context manager is wired in at the cloner-session boundary — `clone_from_template` and the two cloning regions in `InteractiveScene` — not inside `usd_replicate` itself, keeping the leaf USD-authoring primitive pure. ## Motivation This PR supersedes the approach taken in #5070 (still open). #5070 reaches the same toggle via `isaacsim.core.simulation_manager.SimulationManager.enable_fabric_usd_notice_handler`, which: 1. Adds an `isaacsim.core.simulation_manager` dependency to the cloner (project policy: avoid `isaacsim.*` implementation deps). 2. Implements via a state-dict pattern + a private `_manage_notice_handlers` kwarg leaked through public `usd_replicate`. 3. Disables the handler from inside `usd_replicate` and never re-enables it on exit (global-state leak that survives exceptions). This PR keeps #5070's measured perf win while replacing the above with a single context manager wired in at the orchestrator boundary, exception-safe, re-entrant, and IsaacSim-implementation-free. ### Architecture | Concern | Where it lives | |---|---| | ABI-coupled ctypes binding | `source/isaaclab/isaaclab/cloner/_fabric_notices.py` (private, ~135 lines) | | Public context manager | `disabled_fabric_change_notifies(stage, *, restore=True)` in `cloner_utils.py` | | Application | `clone_from_template` body and `InteractiveScene.clone_environments` | The `restore=False` kwarg, used at the two scene-init sites, opts out of the on-exit re-enable. This avoids the redundant `forceMinimalPopulate` batch that fires when the flag flips back on; downstream `SimulationContext.reset` performs the same Fabric resync as part of normal startup. `restore=True` (the default) is exception-safe and is what tests and any future direct callers get. When the Carbonite interface can't be acquired (e.g. running outside a live Kit application), the context manager falls through to a no-op so callers never break — they just don't get the perf win. ## Benchmarks `scripts/benchmarks/benchmark_startup.py`, RTX 5090, headless, warm run (2nd of 2 invocations after kernel/extension caches populate). | Task | num_envs | Backend | Before | After | Saved | % | |---|---:|---|---:|---:|---:|---:| | `Isaac-Cartpole-Direct-v0` | 4096 | PhysX | 7.41 s | 6.47 s | 0.94 s | −12.7% | | `Isaac-Cartpole-Direct-v0` | 4096 | Newton | 22.06 s | 22.01 s | 0.05 s | ~0% | | `Isaac-Velocity-Flat-Anymal-C-v0` | 4096 | PhysX | 28.09 s | 14.49 s | 13.60 s | **−48.4%** | | `Isaac-Velocity-Flat-Anymal-C-v0` | 4096 | Newton | 36.72 s | 32.99 s | 3.73 s | −10.1% | | `Isaac-Dexsuite-Kuka-Allegro-Reorient-v0` | 8192 | PhysX | 104.84 s | 41.94 s | 62.90 s | **−60.0%** | | `Isaac-Dexsuite-Kuka-Allegro-Reorient-v0` | 8192 | Newton | 81.00 s | 74.17 s | 6.83 s | −8.4% | Savings come entirely from `env_creation` (Scene Creation + Simulation Start) and `first_step`. Non-cloning phases (`app_launch`, `python_imports`, `task_config`) are unchanged within run-to-run noise. Newton kitless runs see smaller wins because Fabric is only touched when `omni.fabric` is loaded for rendering; PhysX with Kit is the primary beneficiary. The `first_step` collapse on heavy scenes (e.g. Dexsuite −93%, 10.8 s → 0.74 s) is the deferred Fabric resync work folding into the existing `SimulationContext.reset` pass with much less overhead than an eager `forceMinimalPopulate`. ## Type of change - New feature (non-breaking change which adds functionality) - Performance improvement ## Test plan - `pytest source/isaaclab/test/sim/test_cloner.py` → 18 passed - `pytest source/isaaclab_physx/test/sim/test_cloner.py` → 22 passed (2 xfailed/2 xpassed are pre-existing) - `./isaaclab.sh -f` (pre-commit) clean - Smoke test confirms `restore=True` round-trips the flag, `restore=False` leaves it off, and nested context managers are re-entrant. ## Follow-ups - File a Kit-team request for an `omni.fabric` Python binding to `setEnableChangeNotifies`, after which `_fabric_notices.py` can be deleted. - Consider an ABI-drift smoke test under `test_cloner.py` to catch Kit-side vtable changes in CI. ## 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` - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- source/isaaclab/isaaclab/cloner/__init__.pyi | 2 + .../isaaclab/cloner/_fabric_notices.py | 152 +++++++++++++ .../isaaclab/isaaclab/cloner/cloner_utils.py | 212 +++++++++++++----- .../isaaclab/scene/interactive_scene.py | 59 +++-- .../test/scene/test_interactive_scene.py | 10 + source/isaaclab_physx/test/sim/test_cloner.py | 149 +++++++++++- 6 files changed, 503 insertions(+), 81 deletions(-) create mode 100644 source/isaaclab/isaaclab/cloner/_fabric_notices.py diff --git a/source/isaaclab/isaaclab/cloner/__init__.pyi b/source/isaaclab/isaaclab/cloner/__init__.pyi index 69adc7cd7378..a2457ac78e79 100644 --- a/source/isaaclab/isaaclab/cloner/__init__.pyi +++ b/source/isaaclab/isaaclab/cloner/__init__.pyi @@ -8,6 +8,7 @@ __all__ = [ "random", "sequential", "clone_from_template", + "disabled_fabric_change_notifies", "filter_collisions", "grid_transforms", "make_clone_plan", @@ -19,6 +20,7 @@ from .cloner_cfg import TemplateCloneCfg from .cloner_strategies import random, sequential from .cloner_utils import ( clone_from_template, + disabled_fabric_change_notifies, filter_collisions, grid_transforms, make_clone_plan, diff --git a/source/isaaclab/isaaclab/cloner/_fabric_notices.py b/source/isaaclab/isaaclab/cloner/_fabric_notices.py new file mode 100644 index 000000000000..0feb8eef014a --- /dev/null +++ b/source/isaaclab/isaaclab/cloner/_fabric_notices.py @@ -0,0 +1,152 @@ +# 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 + +"""Pure-Python ctypes binding for ``omni::fabric::IFabricUsd::setEnableChangeNotifies``. + +Acquires the ``omni::fabric::IFabricUsd`` carb interface directly from the Carbonite +framework so cloning can suspend Fabric's USD notice listener without depending on +``isaacsim.core.simulation_manager``. + +Mirrors the in-tree pattern in :mod:`isaaclab_newton.physics._cubric` for +``omni::cubric::IAdapter`` — same problem (base-Kit Carbonite interface with no +Python binding), same solution. When Kit exposes this from Python, replace this +module with a one-line import. +""" + +from __future__ import annotations + +import ctypes +import logging +import threading + +logger = logging.getLogger(__name__) + +# carb::Framework vtable (carb/Framework.h) +# 0: loadPluginsEx, 8: unloadAllPlugins, 16: acquireInterfaceWithClient, +# 24: tryAcquireInterfaceWithClient ← used here +_FW_OFF_TRY_ACQUIRE = 24 + +# omni::fabric::IFabricUsd vtable (omni/fabric/usd/interface/IFabricUsd.h) +# 0..88: prefetch / export / type-conversion entry points +# 96: setEnableChangeNotifies(FabricId, bool) +# 104: getEnableChangeNotifies(FabricId) -> bool +_IFU_OFF_SET_ENABLE = 96 +_IFU_OFF_GET_ENABLE = 104 + + +class _Version(ctypes.Structure): + _fields_ = [("major", ctypes.c_uint32), ("minor", ctypes.c_uint32)] + + +class _InterfaceDesc(ctypes.Structure): + _fields_ = [("name", ctypes.c_char_p), ("version", _Version)] + + +def _read_u64(addr: int) -> int: + return ctypes.c_uint64.from_address(addr).value + + +class FabricNoticeBindings: + """Typed wrappers around ``omni::fabric::IFabricUsd``'s notice toggle.""" + + def __init__(self) -> None: + self._iface_ptr: int = 0 + self._set_fn = None + self._get_fn = None + self._validated: bool = False + + def initialize(self) -> bool: + """Acquire the ``IFabricUsd`` interface. Returns False if unavailable.""" + try: + libcarb = ctypes.CDLL("libcarb.so") + except OSError: + logger.info("libcarb.so unavailable — Fabric notice suspension disabled (Linux x86_64 only)") + return False + + libcarb.acquireFramework.restype = ctypes.c_void_p + libcarb.acquireFramework.argtypes = [ctypes.c_char_p, _Version] + fw_ptr = libcarb.acquireFramework(b"isaaclab.cloner", _Version(0, 0)) + if not fw_ptr: + return False + + try_acquire_addr = _read_u64(fw_ptr + _FW_OFF_TRY_ACQUIRE) + if not try_acquire_addr: + return False + + try_acquire = ctypes.CFUNCTYPE( + ctypes.c_void_p, # IFabricUsd* + ctypes.c_char_p, # clientName + _InterfaceDesc, # desc (by value) + ctypes.c_char_p, # pluginName + )(try_acquire_addr) + + desc = _InterfaceDesc(name=b"omni::fabric::IFabricUsd", version=_Version(1, 0)) + + # clientName varies across Kit configurations — same fallback chain as _cubric.py + ptr = try_acquire(b"carb.scripting-python.plugin", desc, None) or try_acquire(None, desc, None) + if not ptr: + return False + self._iface_ptr = ptr + + set_addr = _read_u64(ptr + _IFU_OFF_SET_ENABLE) + get_addr = _read_u64(ptr + _IFU_OFF_GET_ENABLE) + if not (set_addr and get_addr): + return False + + # FabricId is uint64; CARB_ABI uses the platform's standard C calling convention + self._set_fn = ctypes.CFUNCTYPE(None, ctypes.c_uint64, ctypes.c_bool)(set_addr) + self._get_fn = ctypes.CFUNCTYPE(ctypes.c_bool, ctypes.c_uint64)(get_addr) + return True + + @property + def available(self) -> bool: + return self._iface_ptr != 0 + + def set_enable(self, fabric_id: int, enable: bool) -> None: + if self._set_fn is not None: + self._set_fn(ctypes.c_uint64(fabric_id), ctypes.c_bool(enable)) + + def is_enabled(self, fabric_id: int) -> bool: + if self._get_fn is None: + return False + return bool(self._get_fn(ctypes.c_uint64(fabric_id))) + + def validate_with(self, fabric_id: int) -> bool: + """One-time toggle round-trip — guards against ABI offset drift. + + If Kit's ``IFabricUsd`` vtable layout changes, our hardcoded offsets call the + wrong functions and ``set_enable`` no longer flips the flag ``is_enabled`` reads + from. This catches that case the first time we have a real fabric_id to work + with, and lets the caller fall back to a no-op. + """ + if self._validated: + return True + original = self.is_enabled(fabric_id) + self.set_enable(fabric_id, not original) + ok = self.is_enabled(fabric_id) != original + self.set_enable(fabric_id, original) + self._validated = ok + return ok + + +_BINDINGS: FabricNoticeBindings | None = None +_INIT_TRIED: bool = False +_LOCK = threading.Lock() + + +def get_bindings() -> FabricNoticeBindings | None: + """Return the lazily-initialised bindings, or ``None`` if Kit/Carbonite is unavailable.""" + global _BINDINGS, _INIT_TRIED + with _LOCK: + if _BINDINGS is not None: + return _BINDINGS + if _INIT_TRIED: + return None + _INIT_TRIED = True + b = FabricNoticeBindings() + if not b.initialize(): + return None + _BINDINGS = b + return _BINDINGS diff --git a/source/isaaclab/isaaclab/cloner/cloner_utils.py b/source/isaaclab/isaaclab/cloner/cloner_utils.py index ec55b9c2f7cb..d06b38b0f5a5 100644 --- a/source/isaaclab/isaaclab/cloner/cloner_utils.py +++ b/source/isaaclab/isaaclab/cloner/cloner_utils.py @@ -5,25 +5,105 @@ from __future__ import annotations +import contextlib import itertools import logging import math -from collections.abc import Callable +from collections.abc import Callable, Iterator from typing import TYPE_CHECKING import torch -from pxr import Gf, Sdf, Usd, UsdGeom, Vt +from pxr import Gf, Sdf, Usd, UsdGeom, UsdUtils, Vt import isaaclab.sim as sim_utils from isaaclab.physics.scene_data_requirements import SceneDataRequirement, VisualizerPrebuiltArtifacts +from . import _fabric_notices + if TYPE_CHECKING: from .cloner_cfg import TemplateCloneCfg logger = logging.getLogger(__name__) +@contextlib.contextmanager +def disabled_fabric_change_notifies(stage: Usd.Stage, *, restore: bool = True) -> Iterator[None]: + """Suspend the ``IFabricUsd`` USD notice listener for the body of the ``with`` block. + + Targets the same handler that :meth:`isaacsim.core.cloner.Cloner.disable_change_listener` + toggles, but goes through ``omni::fabric::IFabricUsd`` directly so we don't take an + ``isaacsim.core.simulation_manager`` dependency. + + The listener is a global ``TfNotice`` registered when ``omni.fabric`` loads; it + short-circuits via a soft flag (``IFabricUsd.cpp:739``). Toggling that flag is what + skips the per-``Sdf.CopySpec`` Fabric sync that dominates cloning time on large scenes. + + When this provides a measurable speedup + ---------------------------------------- + Bisection on the regression test (see ``test_cloner.py``) shows the listener cost is + only on the critical path when **all** of these hold: + + 1. The clone happens through the ``InteractiveScene`` path with ``replicate_physics=True``. + Calling :func:`usd_replicate` directly on a stage produces no measurable gap; with + ``replicate_physics=False`` the gap drops to ~1.19x. The PhysX replication path is + what amplifies per-spec listener work. + 2. The cloned prims carry PhysX rigid-body schemas (e.g. ``UsdPhysics.RigidBodyAPI``, + authored via ``rigid_props`` on a spawn cfg). Plain Xforms or geometry without + physics schemas produce ~1.0x — the listener has no Fabric-tracked state to sync. + ``mass_props`` and ``collision_props`` add nothing beyond ``rigid_props``. + 3. Total per-``Sdf.CopySpec`` firings reach ~32K — i.e. ``num_bodies × num_envs`` is + large enough to dominate scene-init cost. Below this the speedup sinks into noise. + + Conditions outside this envelope (no PhysX schemas, single-env scenes, raw + ``usd_replicate`` calls, ``replicate_physics=False``) won't see a perf win — the + suspension is correct but its effect is lost in the rest of the work. + + Re-entrant: if the flag is already off on entry, ``__exit__`` leaves it off. Falls + through to a no-op if the Carbonite interface can't be acquired (e.g. outside a live + Kit application) — the caller never breaks, it just doesn't get the perf win. + + Args: + stage: USD stage whose Fabric notice handler should be suspended. + restore: When ``True`` (default), re-enable the handler on exit. Set to ``False`` + inside a known clone-then-``sim.reset`` window where the downstream Fabric + resync happens anyway and re-enabling here would trigger a redundant + ``forceMinimalPopulate`` batch — see ``PluginInterface.cpp:337``. + + Yields: + None. + """ + bindings = _fabric_notices.get_bindings() + if bindings is None: + yield + return + + # usdrt only works with a live Kit app — defer import so module load stays cheap. + import usdrt + + # Avoid leaking a strong reference into the global ``StageCache`` for stages we did not + # author into the cache: ``Insert`` keeps the stage alive for the rest of the process. + cache = UsdUtils.StageCache.Get() + cached_id = cache.GetId(stage) + stage_id = cached_id.ToLongInt() if cached_id.IsValid() else cache.Insert(stage).ToLongInt() + # ``FabricId`` wraps a uint64; the C ABI needs the raw integer. + fabric_id = usdrt.Usd.Stage.Attach(stage_id).GetFabricId().id + # First-call ABI sanity check — if the toggle doesn't actually round-trip the flag + # (e.g. Kit's vtable shifted), fall through to a no-op rather than corrupting state. + if not bindings.validate_with(fabric_id): + logger.warning("Fabric notice toggle failed round-trip check — suspension disabled") + yield + return + was_enabled = bindings.is_enabled(fabric_id) + if was_enabled: + bindings.set_enable(fabric_id, False) + try: + yield + finally: + if restore and was_enabled: + bindings.set_enable(fabric_id, True) + + def clone_from_template(stage: Usd.Stage, num_clones: int, template_clone_cfg: TemplateCloneCfg) -> None: """Clone assets from a template root into per-environment destinations. @@ -37,64 +117,80 @@ def clone_from_template(stage: Usd.Stage, num_clones: int, template_clone_cfg: T num_clones: Number of environments to clone to (typically equals ``cfg.num_clones``). template_clone_cfg: Configuration describing template location, destination pattern, and replication/mapping behavior. + + Note: + This function suspends the Fabric USD notice listener for the duration of the call + and **leaves it disabled on return**. It is intended to be invoked from a scene-init + path that is followed by :meth:`isaaclab.sim.SimulationContext.reset`, whose Fabric + resync naturally recovers the listener state. Callers that bypass that reset + contract (ad-hoc tooling, unit tests on a bare stage) should re-enable Fabric + notices themselves or wrap the call in + :func:`disabled_fabric_change_notifies` with ``restore=True``. """ cfg: TemplateCloneCfg = template_clone_cfg - world_indices = torch.arange(num_clones, device=cfg.device) - clone_path_fmt = cfg.clone_regex.replace(".*", "{}") - prototype_id = cfg.template_prototype_identifier - prototypes = sim_utils.get_all_matching_child_prims( - cfg.template_root, - predicate=lambda prim: str(prim.GetPath()).split("/")[-1].startswith(prototype_id), - ) - if len(prototypes) > 0: - # Canonicalize prototype-root order. Some simulation/visualization backends might apply order-dependent - # processing, so varying USD traversal or set iteration order can change outputs noticeably. Sorting here - # removes that nondeterminism at the source (group order feeds ``make_clone_plan`` and downstream replication), - # which matters for run-to-run reproducibility across IsaacLab's multi-backend stack. - prototype_roots = sorted({"/".join(str(prototype.GetPath()).split("/")[:-1]) for prototype in prototypes}) - - # discover prototypes per root then make a clone plan - src: list[list[str]] = [] - dest: list[str] = [] - - for prototype_root in prototype_roots: - protos = sim_utils.find_matching_prim_paths(f"{prototype_root}/.*") - protos = [proto for proto in protos if proto.split("/")[-1].startswith(prototype_id)] - src.append(protos) - dest.append(prototype_root.replace(cfg.template_root, clone_path_fmt)) - - src_paths, dest_paths, clone_masking = make_clone_plan(src, dest, num_clones, cfg.clone_strategy, cfg.device) - - # Spawn the first instance of clones from prototypes, then deactivate the prototypes, those first instances - # will be served as sources for usd and physics replication. - proto_idx = clone_masking.to(torch.int32).argmax(dim=1) - proto_mask = torch.zeros_like(clone_masking) - proto_mask.scatter_(1, proto_idx.view(-1, 1).to(torch.long), clone_masking.any(dim=1, keepdim=True)) - usd_replicate(stage, src_paths, dest_paths, world_indices, proto_mask) - stage.GetPrimAtPath(cfg.template_root).SetActive(False) - get_pos = lambda path: stage.GetPrimAtPath(path).GetAttribute("xformOp:translate").Get() # noqa: E731 - positions = torch.tensor([get_pos(clone_path_fmt.format(i)) for i in world_indices]) - # If all prototypes map to env_0, clone whole env_0 to all envs; else clone per-object - if torch.all(proto_idx == 0): - mapping = clone_masking.new_ones(1, num_clones) - replicate_args = [clone_path_fmt.format(0)], [clone_path_fmt], world_indices, mapping - if cfg.clone_physics and cfg.physics_clone_fn is not None: - cfg.physics_clone_fn(stage, *replicate_args, positions=positions, device=cfg.device) - if cfg.visualizer_clone_fn is not None: - cfg.visualizer_clone_fn(stage, *replicate_args, positions=positions, device=cfg.device) - if cfg.clone_usd: - # parse env_origins directly from clone_path - usd_replicate(stage, *replicate_args, positions=positions) - - else: - selected_src = [tpl.format(int(idx)) for tpl, idx in zip(dest_paths, proto_idx.tolist())] - replicate_args = selected_src, dest_paths, world_indices, clone_masking - if cfg.clone_physics and cfg.physics_clone_fn is not None: - cfg.physics_clone_fn(stage, *replicate_args, positions=positions, device=cfg.device) - if cfg.visualizer_clone_fn is not None: - cfg.visualizer_clone_fn(stage, *replicate_args, positions=positions, device=cfg.device) - if cfg.clone_usd: - usd_replicate(stage, *replicate_args) + # Suspend Fabric's USD notice listener for the duration of bulk authoring. ``restore=False`` + # because clone_from_template is only called at scene-init time, which is followed by + # ``SimulationContext.reset`` — that reset path does the Fabric resync naturally, and + # re-enabling here would trigger a redundant ``forceMinimalPopulate`` batch. + with disabled_fabric_change_notifies(stage, restore=False): + world_indices = torch.arange(num_clones, device=cfg.device) + clone_path_fmt = cfg.clone_regex.replace(".*", "{}") + prototype_id = cfg.template_prototype_identifier + prototypes = sim_utils.get_all_matching_child_prims( + cfg.template_root, + predicate=lambda prim: str(prim.GetPath()).split("/")[-1].startswith(prototype_id), + ) + if len(prototypes) > 0: + # Canonicalize prototype-root order. Some simulation/visualization backends might apply order-dependent + # processing, so varying USD traversal or set iteration order can change outputs noticeably. Sorting here + # removes that nondeterminism at the source (group order feeds ``make_clone_plan`` and downstream + # replication), which matters for run-to-run reproducibility across IsaacLab's multi-backend stack. + prototype_roots = sorted({"/".join(str(prototype.GetPath()).split("/")[:-1]) for prototype in prototypes}) + + # discover prototypes per root then make a clone plan + src: list[list[str]] = [] + dest: list[str] = [] + + for prototype_root in prototype_roots: + protos = sim_utils.find_matching_prim_paths(f"{prototype_root}/.*") + protos = [proto for proto in protos if proto.split("/")[-1].startswith(prototype_id)] + src.append(protos) + dest.append(prototype_root.replace(cfg.template_root, clone_path_fmt)) + + src_paths, dest_paths, clone_masking = make_clone_plan( + src, dest, num_clones, cfg.clone_strategy, cfg.device + ) + + # Spawn the first instance of clones from prototypes, then deactivate the prototypes, those first + # instances will be served as sources for usd and physics replication. + proto_idx = clone_masking.to(torch.int32).argmax(dim=1) + proto_mask = torch.zeros_like(clone_masking) + proto_mask.scatter_(1, proto_idx.view(-1, 1).to(torch.long), clone_masking.any(dim=1, keepdim=True)) + usd_replicate(stage, src_paths, dest_paths, world_indices, proto_mask) + stage.GetPrimAtPath(cfg.template_root).SetActive(False) + get_pos = lambda path: stage.GetPrimAtPath(path).GetAttribute("xformOp:translate").Get() # noqa: E731 + positions = torch.tensor([get_pos(clone_path_fmt.format(i)) for i in world_indices]) + # If all prototypes map to env_0, clone whole env_0 to all envs; else clone per-object + if torch.all(proto_idx == 0): + mapping = clone_masking.new_ones(1, num_clones) + replicate_args = [clone_path_fmt.format(0)], [clone_path_fmt], world_indices, mapping + if cfg.clone_physics and cfg.physics_clone_fn is not None: + cfg.physics_clone_fn(stage, *replicate_args, positions=positions, device=cfg.device) + if cfg.visualizer_clone_fn is not None: + cfg.visualizer_clone_fn(stage, *replicate_args, positions=positions, device=cfg.device) + if cfg.clone_usd: + # parse env_origins directly from clone_path + usd_replicate(stage, *replicate_args, positions=positions) + + else: + selected_src = [tpl.format(int(idx)) for tpl, idx in zip(dest_paths, proto_idx.tolist())] + replicate_args = selected_src, dest_paths, world_indices, clone_masking + if cfg.clone_physics and cfg.physics_clone_fn is not None: + cfg.physics_clone_fn(stage, *replicate_args, positions=positions, device=cfg.device) + if cfg.visualizer_clone_fn is not None: + cfg.visualizer_clone_fn(stage, *replicate_args, positions=positions, device=cfg.device) + if cfg.clone_usd: + usd_replicate(stage, *replicate_args) def make_clone_plan( diff --git a/source/isaaclab/isaaclab/scene/interactive_scene.py b/source/isaaclab/isaaclab/scene/interactive_scene.py index 096572bee6e1..b564f80c886d 100644 --- a/source/isaaclab/isaaclab/scene/interactive_scene.py +++ b/source/isaaclab/isaaclab/scene/interactive_scene.py @@ -181,9 +181,19 @@ def __init__(self, cfg: InteractiveSceneCfg): self._ALL_INDICES = torch.arange(self.cfg.num_envs, dtype=torch.long, device=self.device) self._default_env_origins, _ = cloner.grid_transforms(self.num_envs, self.cfg.env_spacing, device=self.device) # copy empty prim of env_0 to env_1, env_2, ..., env_{num_envs-1} with correct location. - cloner.usd_replicate( - self.stage, [self.env_fmt.format(0)], [self.env_fmt], self._ALL_INDICES, positions=self._default_env_origins - ) + # Suspend Fabric's USD notice listener: scene-init is followed by ``SimulationContext.reset``, + # which does the Fabric resync naturally — re-enabling here would just trigger a redundant batch. + # Note: ``restore=False`` means the listener stays disabled past this ``with`` block — through + # ``_add_entities_from_cfg`` and ``clone_environments`` below — until ``SimulationContext.reset`` + # re-enables it. The nested suspension inside ``clone_environments`` becomes a no-op as a result. + with cloner.disabled_fabric_change_notifies(self.stage, restore=False): + cloner.usd_replicate( + self.stage, + [self.env_fmt.format(0)], + [self.env_fmt], + self._ALL_INDICES, + positions=self._default_env_origins, + ) self._global_prim_paths = list() has_scene_cfg_entities = self._is_scene_setup_from_cfg() @@ -232,25 +242,30 @@ def clone_environments(self, copy_from_source: bool = False): prim = self.stage.GetPrimAtPath("/physicsScene") prim.CreateAttribute("physxScene:envIdInBoundsBitCount", Sdf.ValueTypeNames.Int).Set(4) - if self._is_scene_setup_from_cfg(): - self.cloner_cfg.clone_physics = not copy_from_source - cloner.clone_from_template(self.stage, num_clones=self.num_envs, template_clone_cfg=self.cloner_cfg) - else: - mapping = torch.ones((1, self.num_envs), device=self.device, dtype=torch.bool) - replicate_args = ( - [self.env_fmt.format(0)], - [self.env_fmt], - self._ALL_INDICES, - mapping, - self._default_env_origins, - ) - - if not copy_from_source and self.cloner_cfg.physics_clone_fn is not None: - self.cloner_cfg.physics_clone_fn(self.stage, *replicate_args, device=self.cloner_cfg.device) - if self.cloner_cfg.visualizer_clone_fn is not None: - self.cloner_cfg.visualizer_clone_fn(self.stage, *replicate_args, device=self.cloner_cfg.device) - if self.cloner_cfg.clone_usd: - cloner.usd_replicate(self.stage, *replicate_args) + # Suspend Fabric's USD notice listener around bulk authoring (re-entrant with the inner + # call inside :func:`clone_from_template`). ``restore=False`` because the downstream + # ``SimulationContext.reset`` does the Fabric resync — re-enabling here would batch-resync + # everything we just authored, which is slower than the unsuppressed baseline. + with cloner.disabled_fabric_change_notifies(self.stage, restore=False): + if self._is_scene_setup_from_cfg(): + self.cloner_cfg.clone_physics = not copy_from_source + cloner.clone_from_template(self.stage, num_clones=self.num_envs, template_clone_cfg=self.cloner_cfg) + else: + mapping = torch.ones((1, self.num_envs), device=self.device, dtype=torch.bool) + replicate_args = ( + [self.env_fmt.format(0)], + [self.env_fmt], + self._ALL_INDICES, + mapping, + self._default_env_origins, + ) + + if not copy_from_source and self.cloner_cfg.physics_clone_fn is not None: + self.cloner_cfg.physics_clone_fn(self.stage, *replicate_args, device=self.cloner_cfg.device) + if self.cloner_cfg.visualizer_clone_fn is not None: + self.cloner_cfg.visualizer_clone_fn(self.stage, *replicate_args, device=self.cloner_cfg.device) + if self.cloner_cfg.clone_usd: + cloner.usd_replicate(self.stage, *replicate_args) def _sensor_renderer_types(self) -> list[str]: """Return renderer type names used by scene sensors.""" diff --git a/source/isaaclab/test/scene/test_interactive_scene.py b/source/isaaclab/test/scene/test_interactive_scene.py index 5c6954080149..5932e467e8d9 100644 --- a/source/isaaclab/test/scene/test_interactive_scene.py +++ b/source/isaaclab/test/scene/test_interactive_scene.py @@ -12,6 +12,7 @@ """Rest everything follows.""" +import contextlib from types import SimpleNamespace import pytest @@ -141,6 +142,15 @@ def test_clone_environments_non_cfg_invokes_visualizer_clone_fn(monkeypatch: pyt # Avoid binding this unit test to global SimulationContext singleton state. monkeypatch.setattr(InteractiveScene, "device", property(lambda self: "cpu")) + # ``disabled_fabric_change_notifies`` resolves the stage via UsdUtils.StageCache and would + # crash on the bare ``object()`` mocked above. This unit test exercises clone-dispatch + # logic only; the fabric notice path has its own coverage in ``test_cloner.py``. + @contextlib.contextmanager + def _noop_fabric_notices(stage, *, restore=True): + yield + + monkeypatch.setattr("isaaclab.scene.interactive_scene.cloner.disabled_fabric_change_notifies", _noop_fabric_notices) + physics_calls = [] visualizer_calls = [] usd_calls = [] diff --git a/source/isaaclab_physx/test/sim/test_cloner.py b/source/isaaclab_physx/test/sim/test_cloner.py index 826bfa635cc8..b0dfaf3e081c 100644 --- a/source/isaaclab_physx/test/sim/test_cloner.py +++ b/source/isaaclab_physx/test/sim/test_cloner.py @@ -20,7 +20,14 @@ from isaaclab_physx.cloner import physx_replicate import isaaclab.sim as sim_utils -from isaaclab.cloner import TemplateCloneCfg, clone_from_template, sequential, usd_replicate +from isaaclab.cloner import ( + TemplateCloneCfg, + _fabric_notices, + clone_from_template, + disabled_fabric_change_notifies, + sequential, + usd_replicate, +) from isaaclab.sim import build_simulation_context wp.init() @@ -491,3 +498,143 @@ 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}" + + +def test_disabled_fabric_change_notifies_toggles_ifabricusd_flag(sim): + """Regression: ``disabled_fabric_change_notifies`` actually toggles the IFabricUsd flag. + + The PR's perf win depends on ``setEnableChangeNotifies`` being driven correctly by the + ctypes binding in ``_fabric_notices.py``. That binding reads hardcoded vtable offsets + and could silently no-op if Kit's ABI shifts (offsets drift) or libcarb fails to load. + + A perf-delta assertion can't be done reliably in synthetic isolation — the listener's + cost only shows up under full Kit+PhysX integration paths that this test environment + doesn't reproduce; production-scene benchmarks are the PR's load-bearing perf evidence. + What this test guards is the mechanic itself: ``is_enabled`` flips on entry, restores + on exit when ``restore=True``, stays off when ``restore=False``, and re-entrant nested + blocks behave correctly. + """ + import usdrt + from pxr import UsdUtils + + bindings = _fabric_notices.get_bindings() + if bindings is None: + pytest.skip("omni::fabric::IFabricUsd unavailable — Fabric notice path inert here") + + stage = sim_utils.get_current_stage() + cache = UsdUtils.StageCache.Get() + cached_id = cache.GetId(stage) + stage_id = cached_id.ToLongInt() if cached_id.IsValid() else cache.Insert(stage).ToLongInt() + fabric_id = usdrt.Usd.Stage.Attach(stage_id).GetFabricId().id + + # 1. Listener starts enabled. + assert bindings.is_enabled(fabric_id), "Fabric notice listener should be enabled at test start" + + # 2. Default ``restore=True`` round-trips the flag. + with disabled_fabric_change_notifies(stage): + assert not bindings.is_enabled(fabric_id), "listener should be suspended inside the with block" + assert bindings.is_enabled(fabric_id), "listener should be restored on exit when restore=True" + + # 3. ``restore=False`` leaves the flag off. Manually re-enable to get back to a + # known state for subsequent assertions. + with disabled_fabric_change_notifies(stage, restore=False): + assert not bindings.is_enabled(fabric_id), "listener should be suspended inside the with block" + assert not bindings.is_enabled(fabric_id), "listener should remain suspended on exit when restore=False" + bindings.set_enable(fabric_id, True) + assert bindings.is_enabled(fabric_id) + + # 4. Re-entrant nesting: inner exits don't re-enable while outer still wants it suspended. + with disabled_fabric_change_notifies(stage): + assert not bindings.is_enabled(fabric_id) + with disabled_fabric_change_notifies(stage): + 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" + + +def test_disabled_fabric_change_notifies_speedup_regression(): + """Local-only perf regression: listener suspension speeds up clone+reset by >= 1.2x. + + Skipped under ``CI=true`` — 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 os + 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 os.getenv("CI", "").lower() in ("true", "1"): + pytest.skip("CI: covered by toggle test; perf is platform-sensitive — re-verify locally") + 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.RigidBodyPropertiesCfg()), + 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)" From a6e7577a59fc8668e75d02d04616d573dff8fe0a Mon Sep 17 00:00:00 2001 From: Antoine RICHARD Date: Fri, 1 May 2026 08:17:45 +0200 Subject: [PATCH 17/40] Document Kamino solver presets (#5457) # Description Document Kamino solver selection through the existing Hydra preset system. - Adds a "Backend and Solver Presets" section that explains how `newton` and `kamino` presets both use `NewtonCfg` but choose different solver configs. - Notes that Kamino is experimental and depends on assets being structured for Kamino. - Adds a brief mention from the Newton solver-transitioning page and updates the MuJoCo-Warp example to current public config APIs. Fixes: N/A ## Type of change - Documentation update ## Screenshots N/A. Documentation-only change. ## 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 - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension config file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- .../solver-transitioning.rst | 33 ++++++--- docs/source/features/hydra.rst | 68 +++++++++++++++++++ 2 files changed, 92 insertions(+), 9 deletions(-) diff --git a/docs/source/experimental-features/newton-physics-integration/solver-transitioning.rst b/docs/source/experimental-features/newton-physics-integration/solver-transitioning.rst index db85df0f991f..0c480bfec73d 100644 --- a/docs/source/experimental-features/newton-physics-integration/solver-transitioning.rst +++ b/docs/source/experimental-features/newton-physics-integration/solver-transitioning.rst @@ -2,7 +2,16 @@ Solver Transitioning ==================== Transitioning to the Newton physics engine introduces new physics solvers that handle simulation using different numerical approaches. -While Newton supports several different solvers, our initial focus for Isaac Lab is on using the MuJoCo-Warp solver from Google DeepMind. +While Newton supports several different solvers, our initial focus for Isaac Lab is on using the +MuJoCo-Warp solver from Google DeepMind. Isaac Lab also includes beta support for the Kamino +solver on selected classic tasks. Kamino is selected through a physics preset rather than as a +separate backend; see :ref:`hydra-backend-solver-presets`. + +.. note:: + + Kamino support is experimental and currently depends on assets being structured + in a way that Kamino can consume. Assets that work with MuJoCo-Warp or PhysX + may still require model-structure updates before they work with Kamino. The way the physics scene itself is defined does not change - we continue to use USD as the primary way to set basic parameters of objects and robots in the scene, and for current environments, the exact same USD files used for the PhysX-based Isaac Lab are used. @@ -12,15 +21,18 @@ What does require change is the way that some solver-specific settings are confi Tuning these parameters can have a significant impact on both simulation performance and behaviour. For now, we will show an example of setting these parameters to help provide a feel for these changes. -Note that the :class:`~isaaclab.sim.NewtonCfg` replaces the :class:`~isaaclab.sim.PhysxCfg` and is used to set everything related to the physical simulation parameters except for the ``dt``: +Note that the :class:`~isaaclab_newton.physics.NewtonCfg` replaces +:class:`~isaaclab_physx.physics.PhysxCfg` and is used to set everything related to the physical +simulation parameters except for the ``dt``: .. code-block:: python - from isaaclab.sim._impl.newton_manager_cfg import NewtonCfg - from isaaclab.sim._impl.solvers_cfg import MJWarpSolverCfg + from isaaclab.sim import SimulationCfg + from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg solver_cfg = MJWarpSolverCfg( - nefc_per_env=35, + njmax=35, + nconmax=20, ls_iterations=10, cone="pyramidal", ls_parallel=True, @@ -31,14 +43,17 @@ Note that the :class:`~isaaclab.sim.NewtonCfg` replaces the :class:`~isaaclab.si num_substeps=1, debug_mode=False, ) - sim: SimulationCfg = SimulationCfg(dt=1 / 120, render_interval=decimation, newton_cfg=newton_cfg) + sim: SimulationCfg = SimulationCfg(dt=1 / 120, render_interval=decimation, physics=newton_cfg) Here is a very brief explanation of some of the key parameters above: -* ``nefc_per_env``: This is the size of the buffer constraints we want MuJoCo warp to - pre-allocate for a given environment. A large value will slow down the simulation, - while a too small value may lead to some contacts being missed. +* ``njmax``: This is the number of constraint rows MuJoCo-Warp pre-allocates for a + given environment. A large value will slow down the simulation, while a too small + value may lead to missing constraints. + +* ``nconmax``: This is the maximum number of contact points MuJoCo-Warp pre-allocates + for a given environment. Set it high enough for the expected contact count. * ``ls_iterations``: The number of line searches performed by the MuJoCo Warp solver. Line searches are used to find an optimal step size, and for each solver step, diff --git a/docs/source/features/hydra.rst b/docs/source/features/hydra.rst index 0e3ddc341815..73b3337bc6df 100644 --- a/docs/source/features/hydra.rst +++ b/docs/source/features/hydra.rst @@ -242,6 +242,74 @@ disabled unless explicitly selected: python train.py --task=Isaac-Reach-Franka-v0 env.scene.camera=large +.. _hydra-backend-solver-presets: + +Backend and Solver Presets +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Physics backend selection uses the same preset system. A task can define a +``PresetCfg`` whose entries replace the complete physics config: + +.. code-block:: python + + from isaaclab.utils import configclass + from isaaclab_newton.physics import KaminoSolverCfg, MJWarpSolverCfg, NewtonCfg + from isaaclab_physx.physics import PhysxCfg + from isaaclab_tasks.utils import PresetCfg + + @configclass + class CartpolePhysicsCfg(PresetCfg): + default: PhysxCfg = PhysxCfg() + physx: PhysxCfg = PhysxCfg() + newton: NewtonCfg = NewtonCfg( + solver_cfg=MJWarpSolverCfg(njmax=5, nconmax=3), + num_substeps=1, + ) + kamino: NewtonCfg = NewtonCfg( + solver_cfg=KaminoSolverCfg( + integrator="moreau", + use_collision_detector=True, + sparse_jacobian=True, + padmm_max_iterations=100, + ), + num_substeps=1, + debug_mode=False, + use_cuda_graph=True, + ) + +The ``newton`` and ``kamino`` entries both select the Newton physics backend because +both entries are :class:`~isaaclab_newton.physics.NewtonCfg` objects. The difference +is the solver configuration: ``newton`` uses +:class:`~isaaclab_newton.physics.MJWarpSolverCfg`, while ``kamino`` uses +:class:`~isaaclab_newton.physics.KaminoSolverCfg`. + +Kamino is therefore a solver preset, not a separate Isaac Lab backend. The same +Newton assets, sensors, renderers, and visualizers are used after the preset is +resolved. It is a Proximal Alternating Direction Method of Multipliers (P-ADMM) +based solver for constrained rigid multi-body dynamics, and its Isaac Lab support +is currently beta. + +.. note:: + + Kamino support is experimental and currently depends on the asset being + structured in a way that Kamino can consume. Assets that work with the + MuJoCo-Warp or PhysX presets may still require model-structure updates before + they work with ``presets=kamino``. + +.. code-block:: bash + + # Select the Kamino solver preset everywhere it is defined + python train.py --task=Isaac-Cartpole-v0 presets=kamino + + # Select the Kamino solver preset for a specific physics config path + python train.py --task=Isaac-Cartpole-v0 env.sim.physics=kamino + +The ``kamino`` preset is currently defined for ``Isaac-Cartpole-Direct-v0``, +``Isaac-Ant-Direct-v0``, ``Isaac-Cartpole-v0``, and ``Isaac-Ant-v0``. Passing +``presets=kamino`` to a task without a ``kamino`` preset does not enable Kamino; +add and validate a task-specific preset first. + + Inline Presets with preset() ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ From 6dfa5fac2a984cabde1cb2cf93999cc0df30afec Mon Sep 17 00:00:00 2001 From: jmart-nv Date: Fri, 1 May 2026 12:12:27 -0500 Subject: [PATCH 18/40] OMPE-90534: Add json nsys trace definitions (#5397) # Description This migrates json nsys trace definitions from omniperf benchmark to isaaclab itself. Part 1/2 - Added new nsys_trace.json (moved from omniperf benchmark) to decorate functions of interest in isaac traces without requiring decorators in the codebase. - Added a unit test to ensure the json stays in sync. ## Type of change - 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 - [ ] My changes generate no new warnings *(Intentionally added new warnings in new test file)* - [x] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file *(N/A - no package source files edited)* - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- .../how-to/howto_profile_nsys_example.png | Bin 0 -> 92051 bytes docs/source/how-to/index.rst | 11 ++ docs/source/how-to/profile_with_nsys.rst | 114 ++++++++++++ scripts/benchmarks/nsys_trace.json | 166 ++++++++++++++++++ scripts/benchmarks/test/test_nsys_trace.py | 153 ++++++++++++++++ 5 files changed, 444 insertions(+) create mode 100644 docs/source/_static/how-to/howto_profile_nsys_example.png create mode 100644 docs/source/how-to/profile_with_nsys.rst create mode 100644 scripts/benchmarks/nsys_trace.json create mode 100644 scripts/benchmarks/test/test_nsys_trace.py diff --git a/docs/source/_static/how-to/howto_profile_nsys_example.png b/docs/source/_static/how-to/howto_profile_nsys_example.png new file mode 100644 index 0000000000000000000000000000000000000000..6590f967018cb6efa0eadfb481526965c73e5a53 GIT binary patch literal 92051 zcmbTeWn5I<*FOxRB1(v;fRvKb-6eu_NJ)1|$G}iCfP#dSbc2#2-Ccr�W?ZFm%n( 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

!av91FFm}<&Pevi1+Xyz1TEo;tGR73@r(2a9JL= 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

YPHP#R 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

- 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 literal 0 HcmV?d00001 diff --git a/docs/source/how-to/index.rst b/docs/source/how-to/index.rst index 85ef6055839e..25ae4fbee85d 100644 --- a/docs/source/how-to/index.rst +++ b/docs/source/how-to/index.rst @@ -207,3 +207,14 @@ This guide explains 2 features that can speed up stage initialization, **fabric :maxdepth: 1 optimize_stage_creation + + +Profiling Isaac Lab with Nsight Systems +--------------------------------------- + +This guide explains how to profile Isaac Lab tasks with NVIDIA Nsight Systems for runtime performance analysis. + +.. toctree:: + :maxdepth: 1 + + profile_with_nsys diff --git a/docs/source/how-to/profile_with_nsys.rst b/docs/source/how-to/profile_with_nsys.rst new file mode 100644 index 000000000000..6ef17f55c555 --- /dev/null +++ b/docs/source/how-to/profile_with_nsys.rst @@ -0,0 +1,114 @@ +Profiling Isaac Lab with Nsight Systems +======================================= + +.. currentmodule:: isaaclab + +Isaac Lab supports CPU and GPU profiling via **NVIDIA Nsight Systems (nsys)** for runtime performance analysis. This can help identify GPU/CPU bottlenecks and determine the best configuration for your environments and tasks. Profiling adds modest runtime overhead and produces large output files, so it's best suited to targeted investigations rather than long unattended training runs. + +Common Use Cases +---------------- + +- **My training iteration is slow** - capture 3-5 iterations to see whether time is mostly spent on physics, rendering, env reset, observation, etc. +- **Env init takes 40 seconds** - profile a single launch to see the import times for each module, kernel compilation, etc. +- **Which physics/renderer backend should I use?** - profile your task with different backend combinations to find the best fit. +- **Are my GPU kernels efficient?** - dive into the CUDA rows in nsys to identify when the GPU is idle (optimization opportunity). +- **Did my code changes make things slower?** - A/B profile comparison before and after your change. + + +Quick Start +----------- + +This section walks you through everything needed to capture your first nsys profile. + +Prerequisites +~~~~~~~~~~~~~ + +- `Nsight Systems `_ - install for your platform. +- ``nvtx`` Python package for source code instrumentation, installed into your Isaac Lab environment: + + .. code-block:: bash + + ./isaaclab.sh -p -m pip install nvtx + + +Running a Profile +~~~~~~~~~~~~~~~~~ + +The following command shows how to capture a profile for the ``Isaac-Cartpole-v0`` task via the ``rsl_rl`` training framework with 3 iterations: + +.. code-block:: bash + + nsys profile \ + -t nvtx,cuda \ + --python-functions-trace=scripts/benchmarks/nsys_trace.json \ + -o my_profile \ + ./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/train.py \ + --task=Isaac-Cartpole-v0 \ + --headless \ + --max_iterations=3 + +Flags: + +- ``-t nvtx,cuda`` - capture NVTX ranges (CPU swim-lanes) and CUDA activity (GPU row). +- ``--python-functions-trace=...`` - the function annotations file; ships with Isaac Lab. +- ``-o my_profile`` - output path; nsys appends ``.nsys-rep``. + +Reading the Resulting Profile +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Launch the Nsight Systems UI, go to **File > Open**, and select your ``.nsys-rep`` file that you generated in the previous step. Expand the **Threads** section and the domain swim-lanes will appear as separate rows. Clicking on a function will highlight related functions in other lanes. Expand the **CUDA HW** row to cross-reference with GPU kernels fired during that CPU range. + +.. image:: ../_static/how-to/howto_profile_nsys_example.png + :alt: Domain swim-lanes in nsys-ui + + +The Trace JSON +-------------- + +The trace definition file lives at ``scripts/benchmarks/nsys_trace.json`` and defines metadata for the Python functions of interest in Isaac Lab. nsys monkey-patches these functions to automatically emit NVTX ranges at runtime. + +Schema: + +.. code-block:: json + + [ + { + "domain": "MyDomain", + "color": "0x9C27B0", + "module": "isaaclab.envs.manager_based_env", + "functions": [ + "ManagerBasedEnv.step", + {"function": "ManagerBasedEnv.reset", "color": "0xAB47BC"} + ] + } + ] + + +After editing the JSON, run the sync test to confirm every entry resolves: + +.. code-block:: bash + + ./isaaclab.sh -p -m pytest scripts/benchmarks/test/test_nsys_trace.py + + +Troubleshooting +--------------- + +- **An expected domain doesn't appear in the timeline:** + + - Confirm ``nvtx`` is installed in your Isaac Lab environment (``./isaaclab.sh -p -m pip show nvtx``). + - Make sure the function is actually called during the profiled run. + - Verify the JSON entry by running the sync test (above). + +- **Sync test fails with an** ``AttributeError`` - a function listed in the JSON no longer exists at the given path. + + - Fix the path to point at the renamed function, or + - Remove the entry if the function was deleted. + + +See Also +-------- + +- :doc:`simulation_performance` - broader simulation performance tuning tips. +- `Nsight Systems User Guide `_ - official ``nsys`` documentation. +- `NVTX Python package `_ - the package nsys uses to emit NVTX ranges from Python. diff --git a/scripts/benchmarks/nsys_trace.json b/scripts/benchmarks/nsys_trace.json new file mode 100644 index 000000000000..0d04fd1b8546 --- /dev/null +++ b/scripts/benchmarks/nsys_trace.json @@ -0,0 +1,166 @@ +[ + { + "_comment": "=== PYTHON IMPORTS (tracks module loading) ===", + "domain": "Python-Imports", + "color": "0x9E9E9E", + "module": "importlib", + "functions": ["import_module"] + }, + { + "domain": "Python-Imports", + "color": "0x9E9E9E", + "module": "importlib._bootstrap", + "functions": ["_find_and_load", "_load_unlocked"] + }, + { + "_comment": "=== WARP (verified working) ===", + "domain": "Warp", + "color": "0xF44336", + "module": "warp", + "functions": ["launch", "synchronize", "copy", "zeros", "empty"] + }, + { + "_comment": "=== PYTORCH (verified working) ===", + "domain": "PyTorch", + "color": "0x3F51B5", + "module": "torch.autograd", + "functions": ["backward"] + }, + { + "_comment": "=== USD (verified working) ===", + "domain": "USD", + "color": "0x795548", + "module": "pxr.UsdGeom", + "functions": ["Xform", "Mesh", "Sphere", "Cube"] + }, + { + "_comment": "=== ISAACLAB ENVIRONMENTS ===", + "domain": "IsaacLab-Env", + "color": "0x9C27B0", + "module": "isaaclab.envs.manager_based_rl_env", + "functions": [ + {"function": "ManagerBasedRLEnv.__init__", "color": "0x9C27B0"}, + {"function": "ManagerBasedRLEnv.step", "color": "0xAB47BC"} + ] + }, + { + "domain": "IsaacLab-Env", + "color": "0x9C27B0", + "module": "isaaclab.envs.manager_based_env", + "functions": [ + {"function": "ManagerBasedEnv.__init__", "color": "0x9C27B0"}, + {"function": "ManagerBasedEnv.step", "color": "0xAB47BC"}, + {"function": "ManagerBasedEnv.reset", "color": "0xBA68C8"}, + {"function": "ManagerBasedEnv._reset_idx", "color": "0xCE93D8"} + ] + }, + { + "_comment": "=== ISAACLAB SIMULATION ===", + "domain": "IsaacLab-Sim", + "color": "0x4CAF50", + "module": "isaaclab.sim.simulation_context", + "functions": [ + {"function": "SimulationContext.__init__", "color": "0x4CAF50"}, + {"function": "SimulationContext.reset", "color": "0x66BB6A"}, + {"function": "SimulationContext.step", "color": "0x81C784"} + ] + }, + { + "_comment": "=== ISAACLAB SCENE ===", + "domain": "IsaacLab-Scene", + "color": "0x2196F3", + "module": "isaaclab.scene.interactive_scene", + "functions": [ + {"function": "InteractiveScene.__init__", "color": "0x2196F3"}, + {"function": "InteractiveScene.reset", "color": "0x42A5F5"}, + {"function": "InteractiveScene.write_data_to_sim", "color": "0x64B5F6"}, + {"function": "InteractiveScene.update", "color": "0x90CAF9"} + ] + }, + { + "_comment": "=== ISAACLAB MANAGERS ===", + "domain": "IsaacLab-Managers", + "color": "0xFF9800", + "module": "isaaclab.managers.observation_manager", + "functions": [ + {"function": "ObservationManager.__init__", "color": "0xFF9800"}, + {"function": "ObservationManager.reset", "color": "0xFFA726"}, + {"function": "ObservationManager.compute", "color": "0xFFB74D"} + ] + }, + { + "domain": "IsaacLab-Managers", + "color": "0xFF9800", + "module": "isaaclab.managers.action_manager", + "functions": [ + {"function": "ActionManager.__init__", "color": "0xFF9800"}, + {"function": "ActionManager.reset", "color": "0xFFA726"}, + {"function": "ActionManager.process_action", "color": "0xFFB74D"}, + {"function": "ActionManager.apply_action", "color": "0xFFCC80"} + ] + }, + { + "domain": "IsaacLab-Managers", + "color": "0xFF9800", + "module": "isaaclab.managers.reward_manager", + "functions": [ + {"function": "RewardManager.__init__", "color": "0xFF9800"}, + {"function": "RewardManager.reset", "color": "0xFFA726"}, + {"function": "RewardManager.compute", "color": "0xFFB74D"} + ] + }, + { + "_comment": "=== ISAACLAB ASSETS ===", + "domain": "IsaacLab-Assets", + "color": "0x607D8B", + "module": "isaaclab.assets.articulation.articulation", + "functions": [ + {"function": "Articulation.__init__", "color": "0x607D8B"}, + {"function": "Articulation.reset", "color": "0x78909C"}, + {"function": "Articulation.write_data_to_sim", "color": "0x90A4AE"}, + {"function": "Articulation.update", "color": "0xB0BEC5"} + ] + }, + { + "_comment": "=== ISAACLAB SENSORS ===", + "domain": "IsaacLab-Sensors", + "color": "0x00BCD4", + "module": "isaaclab.sensors.camera.camera", + "functions": [ + {"function": "Camera.__init__", "color": "0x00BCD4"}, + {"function": "Camera.reset", "color": "0x26C6DA"}, + {"function": "Camera.update", "color": "0x4DD0E1"} + ] + }, + { + "_comment": "=== RSL-RL ===", + "domain": "RSL-RL", + "color": "0x673AB7", + "module": "rsl_rl.runners.on_policy_runner", + "functions": [ + {"function": "OnPolicyRunner.__init__", "color": "0x673AB7"}, + {"function": "OnPolicyRunner.learn", "color": "0x7E57C2"} + ] + }, + { + "domain": "RSL-RL", + "color": "0x673AB7", + "module": "rsl_rl.algorithms.ppo", + "functions": [ + {"function": "PPO.__init__", "color": "0x673AB7"}, + {"function": "PPO.act", "color": "0x7E57C2"}, + {"function": "PPO.update", "color": "0x9575CD"} + ] + }, + { + "_comment": "=== NEWTON WARP RENDERER ===", + "domain": "NewtonWarpRenderer", + "color": "0xE91E63", + "module": "isaaclab_newton.renderers.newton_warp_renderer", + "functions": [ + {"function": "NewtonWarpRenderer.update_transforms", "color": "0x2196F3"}, + {"function": "NewtonWarpRenderer.render", "color": "0x4CAF50"}, + {"function": "NewtonWarpRenderer.read_output", "color": "0xFF9800"} + ] + } +] diff --git a/scripts/benchmarks/test/test_nsys_trace.py b/scripts/benchmarks/test/test_nsys_trace.py new file mode 100644 index 000000000000..e39f372da380 --- /dev/null +++ b/scripts/benchmarks/test/test_nsys_trace.py @@ -0,0 +1,153 @@ +# 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 + +"""Sync tests for ``scripts/benchmarks/nsys_trace.json``. + +The JSON lists Python functions that nsys annotates during profiling via the +``--python-functions-trace`` flag. Entries reference IsaacLab (and adjacent) +source by dotted paths. These tests catch two kinds of drift: + +* Referenced functions that no longer resolve (hard failure) +* Covered classes that have public methods not listed in the JSON (warning — + signals that new coverage may need to be added) +""" + +from isaaclab.app import AppLauncher + +simulation_app = AppLauncher(headless=True).app + +"""Rest everything follows.""" + +import importlib +import inspect +import json +import warnings +from collections import defaultdict +from pathlib import Path + +import pytest + +TRACE_JSON_PATH = Path(__file__).resolve().parents[1] / "nsys_trace.json" + + +def _load_trace_entries() -> list[dict]: + """Return the parsed JSON entries from the trace file.""" + if not TRACE_JSON_PATH.exists(): + raise RuntimeError(f"nsys trace JSON not found at {TRACE_JSON_PATH}") + try: + with TRACE_JSON_PATH.open() as f: + return json.load(f) + except json.JSONDecodeError as exc: + raise RuntimeError(f"nsys trace JSON at {TRACE_JSON_PATH} is malformed: {exc}") from exc + + +def _function_name_and_module(entry_module: str, func_spec) -> tuple[str, str]: + """Normalize a function spec to ``(module, dotted_name)``. + + ``func_spec`` may be a bare string or a dict that optionally overrides + ``module`` (per the nsys --python-functions-trace schema). + """ + if isinstance(func_spec, str): + return entry_module, func_spec + return func_spec.get("module", entry_module), func_spec["function"] + + +def _iter_function_pairs(entries: list[dict]) -> list[tuple[str, str]]: + """Yield ``(module, dotted_function_path)`` for every function in the JSON.""" + pairs: list[tuple[str, str]] = [] + for entry in entries: + entry_module = entry["module"] + for func_spec in entry["functions"]: + pairs.append(_function_name_and_module(entry_module, func_spec)) + return pairs + + +def _resolve(module_name: str, dotted_path: str): + """Import ``module_name`` and walk ``dotted_path`` via getattr.""" + obj = importlib.import_module(module_name) + for attr in dotted_path.split("."): + obj = getattr(obj, attr) + return obj + + +def _group_methods_by_class(pairs: list[tuple[str, str]]) -> dict[tuple[str, str], set[str]]: + """Group referenced method names by ``(module, class_name)``. + + Top-level functions (paths without a dot) are skipped — no class context. + """ + grouped: dict[tuple[str, str], set[str]] = defaultdict(set) + for module_name, dotted_path in pairs: + parts = dotted_path.split(".") + if len(parts) >= 2: + class_name, method_name = parts[0], parts[-1] + grouped[(module_name, class_name)].add(method_name) + return grouped + + +def _is_own_public_method(cls: type, name: str, member: object) -> bool: + """True if ``member`` is a public method defined directly on ``cls``.""" + if not inspect.isfunction(member): + return False + if name not in cls.__dict__: + return False + # Skip dunders and private helpers from the unreferenced-method check; the JSON curates inclusions explicitly. + if name.startswith("_"): + return False + return True + + +_FUNCTION_PAIRS = _iter_function_pairs(_load_trace_entries()) + + +@pytest.mark.parametrize( + "module_name, dotted_path", + _FUNCTION_PAIRS, + ids=[f"{m}:{p}" for m, p in _FUNCTION_PAIRS], +) +def test_function_resolves(module_name: str, dotted_path: str): + """Every function referenced in the trace JSON must resolve to a callable. + + A missing reference silently loses profiling coverage, so this fails loudly. + Modules that aren't importable in the current environment (e.g. optional + RL frameworks) are skipped rather than failed. + """ + try: + importlib.import_module(module_name) + except ImportError as exc: + pytest.skip(f"Module '{module_name}' not importable here: {exc}") + + try: + resolved = _resolve(module_name, dotted_path) + except AttributeError as exc: + pytest.fail(f"'{module_name}.{dotted_path}' not found: {exc}") + + assert callable(resolved), f"'{module_name}.{dotted_path}' resolved but is not callable" + + +def test_warn_unreferenced_methods_on_covered_classes(): + """Emit a warning for each public method that isn't listed in the JSON. + + Scope: classes that already have at least one method referenced. If the + class gained a new public method since the JSON was last updated, it shows + up here as a nudge to add (or intentionally omit) it. Inherited, dunder, + and private methods are excluded to keep the signal actionable. + """ + grouped = _group_methods_by_class(_FUNCTION_PAIRS) + + for (module_name, class_name), referenced in sorted(grouped.items()): + try: + cls = _resolve(module_name, class_name) + except (ImportError, AttributeError): + continue + if not inspect.isclass(cls): + continue + + own_public = {name for name, member in inspect.getmembers(cls) if _is_own_public_method(cls, name, member)} + unreferenced = own_public - referenced + if unreferenced: + warnings.warn( + f"{module_name}.{class_name} has public methods not listed in nsys_trace.json: {sorted(unreferenced)}", + stacklevel=2, + ) From 2b49dac5188e5ff65945110d4b63cdfea1219b7d Mon Sep 17 00:00:00 2001 From: myurasov-nv <168484206+myurasov-nv@users.noreply.github.com> Date: Fri, 1 May 2026 10:15:59 -0700 Subject: [PATCH 19/40] Updates wheel versioning (#5467) # Description Adds wheel versioning so QA can identify which build is newer and reproduce any wheel from its commit. The wheel follows PEP 440 local-version `+build.`. Downloading in QA env: ``` # Latest from develop RUN_ID=$(gh run list --workflow=wheel.yml --branch=develop -L 1 --json databaseId -q '.[0].databaseId') rm -rf ./out && gh run download "$RUN_ID" --pattern 'isaaclab-*' -D ./out # Latest from a specific PR branch RUN_ID=$(gh run list --workflow=wheel.yml --branch= -L 1 --json databaseId -q '.[0].databaseId') rm -rf ./out && gh run download "$RUN_ID" --pattern 'isaaclab-*' -D ./out # Specific run rm -rf ./out && gh run download --pattern 'isaaclab-*' -D ./out # Find newly downloaded wheel file WHEEL_FILE=$(ls -1t ./out/isaaclab-*/*.whl | head -1) # Install it pip install --force-reinstall $WHEEL_FILE ``` ## Type of change - New feature (non-breaking change which adds functionality) ## Checklist - [x] I have read and understood the contribution guidelines - [x] I have run the pre-commit checks with `./isaaclab.sh --format` - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- .github/workflows/wheel.yml | 17 ++++++++++++++++- tools/wheel_builder/build.sh | 17 ++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wheel.yml b/.github/workflows/wheel.yml index 41567dde43bc..0cad2933e949 100644 --- a/.github/workflows/wheel.yml +++ b/.github/workflows/wheel.yml @@ -46,13 +46,28 @@ jobs: python-version: "3.12" architecture: x64 + # Compose Docker-image-style metadata for the artifact. The artifact + # name is what QA sees in `gh run download`, so we make it scannable: + # isaaclab--build-. The wheel inside follows + # PEP 440 (VERSION+buildN.SHA7) since pip requires that format. + - name: Compute wheel metadata + id: meta + run: | + set -euo pipefail + version=$(cat VERSION) + sha_slug="${GITHUB_SHA:0:7}" + echo "artifact_name=isaaclab-${version}-build${{ github.run_number }}-${sha_slug}" >> "$GITHUB_OUTPUT" + - name: Build wheel + env: + WHEEL_BUILD_NUMBER: ${{ github.run_number }} + WHEEL_SHA: ${{ github.sha }} run: bash tools/wheel_builder/build.sh - name: Upload wheel artifact uses: actions/upload-artifact@v7 with: - name: ${{ github.event_name == 'pull_request' && format('isaaclab-wheel-pr-{0}-{1}', github.event.pull_request.number, github.sha) || format('isaaclab-wheel-{0}-{1}', github.ref_name, github.sha) }} + name: ${{ steps.meta.outputs.artifact_name }} path: tools/wheel_builder/build/dist/isaaclab-*.whl if-no-files-found: error retention-days: 30 diff --git a/tools/wheel_builder/build.sh b/tools/wheel_builder/build.sh index f8ddfe4fed73..12e6a00e3abc 100755 --- a/tools/wheel_builder/build.sh +++ b/tools/wheel_builder/build.sh @@ -8,6 +8,21 @@ VERSION=$(cat VERSION) BUILD_DIR=$SELF_DIR/build/stage DIST_DIR=$SELF_DIR/build/dist +# Compose a PEP 440 local version when CI metadata is provided so the wheel is +# traceable to a specific build and commit. With both env vars set the version +# becomes e.g. "3.0.0+build123.abc1234" (build number is monotonic, sha slug +# pins the source). If either is missing, fall back to the plain VERSION so +# local dev builds stay simple. +WHEEL_BUILD_NUMBER="${WHEEL_BUILD_NUMBER:-}" +WHEEL_SHA="${WHEEL_SHA:-}" +if [ -n "$WHEEL_BUILD_NUMBER" ] && [ -n "$WHEEL_SHA" ]; then + SHA_SLUG="${WHEEL_SHA:0:7}" + WHEEL_VERSION="${VERSION}+build${WHEEL_BUILD_NUMBER}.${SHA_SLUG}" +else + WHEEL_VERSION="${VERSION}" +fi +echo "[WHEEL VERSION] $WHEEL_VERSION" + # Platform tags matching the official IsaacLab wheel PYTHON_TAG="${PYTHON_TAG:-cp312}" ABI_TAG="${ABI_TAG:-cp312}" @@ -65,7 +80,7 @@ cp "$SELF_DIR/res/__init__.py" "$BUILD_DIR/src/isaaclab/" cp "$SELF_DIR/res/__main__.py" "$BUILD_DIR/src/isaaclab/" # 3. Generate pyproject.toml with dependencies from python_packages.toml -python3 "$SELF_DIR/gen_pyproject.py" "$SELF_DIR/res/python_packages.toml" "$BUILD_DIR/pyproject.toml" "$VERSION" +python3 "$SELF_DIR/gen_pyproject.py" "$SELF_DIR/res/python_packages.toml" "$BUILD_DIR/pyproject.toml" "$WHEEL_VERSION" # 4. Build the wheel cd "$BUILD_DIR" From 3303bef0c72ecf0a274a2afde291ac4f83390208 Mon Sep 17 00:00:00 2001 From: Kelly Guo Date: Fri, 1 May 2026 10:19:03 -0700 Subject: [PATCH 20/40] Adds compatibility note regarding Isaac Sim (#5465) # Description Recent changes in Isaac Lab requires a newer version of Isaac Sim than what is available in the GitHub repo for Isaac Sim. Adds notes in the docs and readme to mention this incompatibility issue and specifies working commits and tags of Isaac Lab that can be used in conjunction with Isaac Sim from GitHub. ## Type of change - Documentation update ## 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 - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [ ] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- README.md | 5 +++++ docs/source/setup/installation/index.rst | 7 +++++++ docs/source/setup/installation/source_installation.rst | 9 +++++++++ 3 files changed, 21 insertions(+) diff --git a/README.md b/README.md index a2f2cfd766ac..7c9b9f751e23 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,11 @@ This branch is a development branch for Isaac Sim 6.0, which is currently only a For installation, please refer to the Isaac Sim GitHub repo to build the latest Isaac Sim branch, and follow the binary installation method in the Isaac Lab documentation for Isaac Lab installation. +> [!WARNING] +> A recent breaking change on the Isaac Lab `develop` branch is not compatible with the `develop` branch of Isaac Sim on GitHub. +> To run Isaac Lab with Isaac Sim's GitHub `develop` branch, use Isaac Lab commit [`f0234a82e432e2a0b0f0a26ca3c5b59e527ddaaa`](https://github.com/isaac-sim/IsaacLab/commit/f0234a82e432e2a0b0f0a26ca3c5b59e527ddaaa) or an earlier commit. +> Alternatively, use the Isaac Lab [`v3.0.0-beta`](https://github.com/isaac-sim/IsaacLab/tree/v3.0.0-beta) tag. + Note that this branch is currently under active development and may experience breaking changes or error messages. Performance issues and regressions may also be observed in some use cases. diff --git a/docs/source/setup/installation/index.rst b/docs/source/setup/installation/index.rst index dd92d178be60..6d00776ee754 100644 --- a/docs/source/setup/installation/index.rst +++ b/docs/source/setup/installation/index.rst @@ -42,6 +42,13 @@ installation methods. .. caution:: + **Compatibility warning for Isaac Sim GitHub develop:** A recent breaking change on the Isaac Lab + ``develop`` branch is not compatible with the ``develop`` branch of Isaac Sim on GitHub. To run + Isaac Lab with Isaac Sim's GitHub ``develop`` branch, use Isaac Lab commit + `f0234a82e432e2a0b0f0a26ca3c5b59e527ddaaa `__ + or an earlier commit. Alternatively, use the Isaac Lab + `v3.0.0-beta `__ tag. + We have dropped support for Isaac Sim versions 5.1.0 and below. We recommend using the latest Isaac Sim 6.0.0 release to benefit from the latest features and improvements. diff --git a/docs/source/setup/installation/source_installation.rst b/docs/source/setup/installation/source_installation.rst index ce575e48bc7b..830cdf839c34 100644 --- a/docs/source/setup/installation/source_installation.rst +++ b/docs/source/setup/installation/source_installation.rst @@ -23,6 +23,15 @@ or want to test Isaac Lab with the nightly version of Isaac Sim. The following instructions are adapted from the `Isaac Sim documentation `_ for the convenience of users. +.. warning:: + + **Compatibility warning for Isaac Sim GitHub develop:** A recent breaking change on the Isaac Lab + ``develop`` branch is not compatible with the ``develop`` branch of Isaac Sim on GitHub. To run + Isaac Lab with Isaac Sim's GitHub ``develop`` branch, use Isaac Lab commit + `f0234a82e432e2a0b0f0a26ca3c5b59e527ddaaa `__ + or an earlier commit. Alternatively, use the Isaac Lab + `v3.0.0-beta `__ tag. + .. attention:: Building Isaac Sim from source requires Ubuntu 22.04 LTS or higher. From 8bb6eff2366b2ee02249a5becba9bf2b48a16cea Mon Sep 17 00:00:00 2001 From: Kelly Guo Date: Fri, 1 May 2026 11:02:43 -0700 Subject: [PATCH 21/40] Adds retry logic for timeout tests (#5448) # Description Some tests arbitrarily times out due to inconsistent CI runs. This change adds a logic to rerun tests that have timed out in an attempt to reduce flaky timeout issues. ## Type of change - Bug fix (non-breaking change which fixes an issue) ## 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 - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [ ] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- .../test_robot_load_performance.py | 4 +-- tools/conftest.py | 36 ++++++++++++++++--- tools/test_settings.py | 2 +- 3 files changed, 34 insertions(+), 8 deletions(-) diff --git a/source/isaaclab/test/performance/test_robot_load_performance.py b/source/isaaclab/test/performance/test_robot_load_performance.py index c86b8fd86109..a298c8b746e5 100644 --- a/source/isaaclab/test/performance/test_robot_load_performance.py +++ b/source/isaaclab/test/performance/test_robot_load_performance.py @@ -36,8 +36,8 @@ ({"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"), # TODO: regression - this used to be 40 - ({"name": "Anymal_D", "robot_cfg": ANYMAL_D_CFG, "expected_load_time": 55.0}, "cuda:0"), - ({"name": "Anymal_D", "robot_cfg": ANYMAL_D_CFG, "expected_load_time": 55.0}, "cpu"), + ({"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"), ], ) def test_robot_load_performance(test_config, device): diff --git a/tools/conftest.py b/tools/conftest.py index 2a123fe62d2a..bf92d62f6c46 100644 --- a/tools/conftest.py +++ b/tools/conftest.py @@ -48,6 +48,9 @@ def pytest_ignore_collect(collection_path, config): STARTUP_HANG_RETRIES = 2 """Number of times to retry a test that hangs during startup before giving up.""" +TIMEOUT_RETRIES = 2 +"""Number of times to retry a test that reaches its hard timeout before giving up.""" + SHUTDOWN_GRACE_PERIOD = 30 """Seconds to wait for clean exit after the JUnit XML report file appears. @@ -352,10 +355,12 @@ def run_individual_tests(test_files, workspace_root, isaacsim_ci): report_file = f"tests/test-reports-{str(file_name)}.xml" - # -- Run with retry on startup hang -------------------------------- + # -- Run with retry on startup hang or hard timeout ----------------- returncode, stdout_data, stderr_data, kill_reason = -1, b"", b"", "" wall_time, pre_kill_diag = 0.0, "" - for attempt in range(STARTUP_HANG_RETRIES + 1): + startup_hang_attempts = 0 + timeout_attempts = 0 + while True: with contextlib.suppress(FileNotFoundError): os.remove(report_file) @@ -365,11 +370,32 @@ def run_individual_tests(test_files, workspace_root, isaacsim_ci): ) ) - if kill_reason == "startup_hang" and attempt < STARTUP_HANG_RETRIES: + has_report = os.path.exists(report_file) + + if kill_reason == "startup_hang" and startup_hang_attempts < STARTUP_HANG_RETRIES: + startup_hang_attempts += 1 print( f"⚠️ {test_file}: startup hang detected after {startup_deadline}s" - f" (attempt {attempt + 1}/{STARTUP_HANG_RETRIES + 1}), retrying..." + f" (attempt {startup_hang_attempts}/{STARTUP_HANG_RETRIES + 1}), retrying..." + ) + if stderr_data: + print("=== STDERR (last 5000 chars) ===") + print(stderr_data.decode("utf-8", errors="replace")[-5000:]) + diag = pre_kill_diag or _capture_system_diagnostics() + if len(diag) > 10000: + diag = diag[:10000] + "\n... (truncated)" + print(diag) + continue + + if kill_reason == "timeout" and not has_report and timeout_attempts < TIMEOUT_RETRIES: + timeout_attempts += 1 + print( + f"⚠️ {test_file}: timeout detected after {timeout}s" + f" (attempt {timeout_attempts}/{TIMEOUT_RETRIES + 1}), retrying..." ) + if stdout_data: + print("=== STDOUT (last 5000 chars) ===") + print(stdout_data.decode("utf-8", errors="replace")[-5000:]) if stderr_data: print("=== STDERR (last 5000 chars) ===") print(stderr_data.decode("utf-8", errors="replace")[-5000:]) @@ -417,7 +443,7 @@ def run_individual_tests(test_files, workspace_root, isaacsim_ci): print(f"Test {test_file} timed out after {timeout} seconds...") print(diag) - msg = f"Timeout after {timeout} seconds" + msg = f"Timeout after {timeout} seconds (retried {timeout_attempts} time(s))" details = f"{msg}\n\n=== SYSTEM DIAGNOSTICS ===\n{diag}\n\n" if stdout_data: details += "=== STDOUT (last 5000 chars) ===\n" diff --git a/tools/test_settings.py b/tools/test_settings.py index ab450cb5db46..773af2dead5c 100644 --- a/tools/test_settings.py +++ b/tools/test_settings.py @@ -17,7 +17,7 @@ PER_TEST_TIMEOUTS = { - "test_articulation.py": 1000, + "test_articulation.py": 1500, "test_stage_in_memory.py": 1000, "test_imu.py": 1000, "test_environments.py": 10000, # This test runs through all the environments for 100 steps each From 71662294fee541762fb4d232ff10189f8e62c9f7 Mon Sep 17 00:00:00 2001 From: ooctipus Date: Fri, 1 May 2026 15:57:09 -0700 Subject: [PATCH 22/40] Skip cloner perf test and fix ray caster intrinsics camera pose (#5470) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description Two unrelated CI flakes in the same test infrastructure area, bundled because both are minimal targeted fixes. ### 1. `test_disabled_fabric_change_notifies_speedup_regression` (`source/isaaclab_physx/test/sim/test_cloner.py`) This is a wall-clock perf regression test that's intended to run **locally only** — it asserts a >= 1.2× speedup of clone+reset with listener suspension. The result is platform-sensitive (deferred Fabric resync in `sim.reset` can offset the scene-time savings on some hardware), so it was meant to be skipped in CI. The original guard was `if os.getenv(\"CI\", \"\").lower() in (\"true\", \"1\"): pytest.skip(...)` inside the test body. However, this project's CI doesn't set the `CI` env var — it selects tests via the `isaacsim_ci` pytest marker registered in `pyproject.toml` and applied module-wide via `pytestmark` at the top of `test_cloner.py`. Result: the env-var skip never fires, and the test runs (and occasionally flakes) on CI. Fix: replace the dead env-var branch with a top-level `@pytest.mark.skip(...)` decorator so the test is collected and skipped unconditionally regardless of how CI selects tests. The correctness of the suspension mechanism is still covered by `test_disabled_fabric_change_notifies_toggles_ifabricusd_flag`, which is unaffected. Re-enable the perf test manually when touching listener suspension. ### 2. `test_output_equal_to_usd_camera_when_intrinsics_set` (`source/isaaclab/test/sensors/test_ray_caster_camera.py`) Intermittent CI failure where the ray caster camera output mismatched the USD camera reference with inf-valued differences across all 518400 elements: ``` E Mismatched elements: 518400 / 518400 (100.0%) E Greatest absolute difference: inf at index (0, 0, 0, 0) (up to 0.0001 allowed) E Greatest relative difference: inf at index (0, 0, 0, 0) (up to 0.005 allowed) ``` Root cause: the test placed the camera at `eye=(0, 0, 5)` looking at `target=(0, 0, 0)`, which is colinear with the default up vector and produces a degenerate view transform. Fix: nudge `eye` to `(0.001, 0, 5)` for both the ray caster and USD camera, keeping them at identical poses while breaking the singularity. The underlying degeneracy is tracked in a separate internal ticket; this is the test-side mitigation. Fixes # (n/a) ## Type of change - Bug fix (non-breaking change which fixes an issue) ## Screenshots N/A — test infrastructure changes. ## 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 - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- .../isaaclab/test/sensors/test_ray_caster_camera.py | 4 ++-- source/isaaclab_physx/test/sim/test_cloner.py | 12 ++++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/source/isaaclab/test/sensors/test_ray_caster_camera.py b/source/isaaclab/test/sensors/test_ray_caster_camera.py index cc10b092a806..a913d38dd833 100644 --- a/source/isaaclab/test/sensors/test_ray_caster_camera.py +++ b/source/isaaclab/test/sensors/test_ray_caster_camera.py @@ -898,11 +898,11 @@ def test_output_equal_to_usd_camera_when_intrinsics_set(setup_sim, focal_length_ # set camera position camera_warp.set_world_poses_from_view( - eyes=torch.tensor([[0.0, 0.0, 5.0]], device=camera_warp.device), + eyes=torch.tensor([[0.001, 0.0, 5.0]], device=camera_warp.device), targets=torch.tensor([[0.0, 0.0, 0.0]], device=camera_warp.device), ) camera_usd.set_world_poses_from_view( - eyes=torch.tensor([[0.0, 0.0, 5.0]], device=camera_usd.device), + eyes=torch.tensor([[0.001, 0.0, 5.0]], device=camera_usd.device), targets=torch.tensor([[0.0, 0.0, 0.0]], device=camera_usd.device), ) diff --git a/source/isaaclab_physx/test/sim/test_cloner.py b/source/isaaclab_physx/test/sim/test_cloner.py index b0dfaf3e081c..4bfba07d99e8 100644 --- a/source/isaaclab_physx/test/sim/test_cloner.py +++ b/source/isaaclab_physx/test/sim/test_cloner.py @@ -552,10 +552,17 @@ def test_disabled_fabric_change_notifies_toggles_ifabricusd_flag(sim): 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 under ``CI=true`` — the suspension mechanism's correctness is covered by + 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. @@ -564,7 +571,6 @@ def test_disabled_fabric_change_notifies_speedup_regression(): ``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 os import time import isaaclab.cloner._fabric_notices as fabric_notices_mod @@ -573,8 +579,6 @@ def test_disabled_fabric_change_notifies_speedup_regression(): from isaaclab.scene import InteractiveScene, InteractiveSceneCfg from isaaclab.utils import configclass - if os.getenv("CI", "").lower() in ("true", "1"): - pytest.skip("CI: covered by toggle test; perf is platform-sensitive — re-verify locally") if fabric_notices_mod.get_bindings() is None: pytest.skip("omni::fabric::IFabricUsd unavailable") From 261d077ed7cc012336b8f7bcd7f986df24524eec Mon Sep 17 00:00:00 2001 From: matthewtrepte Date: Fri, 1 May 2026 16:06:10 -0700 Subject: [PATCH 23/40] Patch newton model reqs (#5398) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description Patch newton model reqs issue where in some caes env configs were constructed in a way where newton model reqs were not correctly determined, leading to downstream issues. Also, revert the revert to physx scene data provider, since the fix above no longer requires the revert, which is non ideal since building newton model from usd fallback is slow. ## Type of change - Bug fix (non-breaking change which fixes an issue) ## Screenshots Please attach before and after screenshots of the change if applicable. ## Checklist - [ ] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html) - [ ] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [ ] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Signed-off-by: matthewtrepte Co-authored-by: Kelly Guo Co-authored-by: HuiDong Chen Co-authored-by: myurasov-nv <168484206+myurasov-nv@users.noreply.github.com> Co-authored-by: hujc Co-authored-by: Antoine Richard Co-authored-by: peterd-NV --- .../isaaclab/scene/interactive_scene.py | 53 +++++---- .../isaaclab/sensors/camera/camera.py | 25 ++++ .../isaaclab/visualizers/visualizer_cfg.py | 2 +- .../test/scene/test_interactive_scene.py | 40 +++++++ source/isaaclab/test/sensors/test_camera.py | 20 ++++ ...scene_data_provider_visualizer_contract.py | 65 +---------- .../test_simulation_context_visualizers.py | 6 + .../physx_scene_data_provider.py | 109 ++++-------------- 8 files changed, 152 insertions(+), 168 deletions(-) diff --git a/source/isaaclab/isaaclab/scene/interactive_scene.py b/source/isaaclab/isaaclab/scene/interactive_scene.py index b564f80c886d..2aee730abb9a 100644 --- a/source/isaaclab/isaaclab/scene/interactive_scene.py +++ b/source/isaaclab/isaaclab/scene/interactive_scene.py @@ -28,7 +28,7 @@ RigidObjectCollection, RigidObjectCollectionCfg, ) -from isaaclab.physics.scene_data_requirements import resolve_scene_data_requirements +from isaaclab.physics.scene_data_requirements import aggregate_requirements, resolve_scene_data_requirements from isaaclab.sensors import ContactSensorCfg, FrameTransformerCfg, SensorBase, SensorBaseCfg from isaaclab.sim import SimulationContext from isaaclab.sim.utils.stage import get_current_stage, get_current_stage_id @@ -140,7 +140,6 @@ def __init__(self, cfg: InteractiveSceneCfg): self.stage_id = get_current_stage_id() self.sim.clear_scene_data_visualizer_prebuilt_artifact() self.physics_backend = self.sim.physics_manager.__name__.lower() - visualizer_clone_fn = None requested_viz_types = set(self.sim.resolve_visualizer_types()) if self.physics_backend.startswith("ovphysx"): from isaaclab_ovphysx.cloner import ovphysx_replicate @@ -200,26 +199,7 @@ def __init__(self, cfg: InteractiveSceneCfg): if has_scene_cfg_entities: self._add_entities_from_cfg() - requirements = resolve_scene_data_requirements( - visualizer_types=requested_viz_types, - renderer_types=self._sensor_renderer_types(), - ) - self.sim.update_scene_data_requirements(requirements) - visualizer_clone_fn = cloner.resolve_visualizer_clone_fn( - physics_backend=self.physics_backend, - requirements=requirements, - stage=self.stage, - set_visualizer_artifact=self.sim.set_scene_data_visualizer_prebuilt_artifact, - ) - if visualizer_clone_fn is not None: - logger.debug( - "Enabling visualizer artifact prebuild for clone path " - "(backend=%s, requires_newton_model=%s, requires_usd_stage=%s).", - self.physics_backend, - requirements.requires_newton_model, - requirements.requires_usd_stage, - ) - self.cloner_cfg.visualizer_clone_fn = visualizer_clone_fn + self._refresh_visualizer_clone_fn_from_requirements(requested_viz_types) if has_scene_cfg_entities: self.clone_environments(copy_from_source=(not self.cfg.replicate_physics)) @@ -236,6 +216,8 @@ def clone_environments(self, copy_from_source: bool = False): If True, clones are independent copies of the source prim and won't reflect its changes (start-up time may increase). Defaults to False. """ + self._refresh_visualizer_clone_fn_from_requirements() + # PhysX-only: set env id bit count for replicated physics. Newton handles env separation in its own API. # Intentionally matches both physx and ovphysx (both are PhysX-based) if self.cfg.replicate_physics and "physx" in self.physics_backend: @@ -267,6 +249,33 @@ def clone_environments(self, copy_from_source: bool = False): if self.cloner_cfg.clone_usd: cloner.usd_replicate(self.stage, *replicate_args) + def _refresh_visualizer_clone_fn_from_requirements(self, visualizer_types=()) -> None: + """Refresh clone-time visualizer prebuild hook from current scene-data requirements.""" + discovered_req = resolve_scene_data_requirements( + visualizer_types=visualizer_types, + renderer_types=self._sensor_renderer_types(), + ) + current_req = self.sim.get_scene_data_requirements() + requirements = aggregate_requirements((current_req, discovered_req)) + if requirements != current_req: + self.sim.update_scene_data_requirements(requirements) + + visualizer_clone_fn = cloner.resolve_visualizer_clone_fn( + physics_backend=self.physics_backend, + requirements=requirements, + stage=self.stage, + set_visualizer_artifact=self.sim.set_scene_data_visualizer_prebuilt_artifact, + ) + if visualizer_clone_fn is not None: + logger.debug( + "Enabling visualizer artifact prebuild for clone path " + "(backend=%s, requires_newton_model=%s, requires_usd_stage=%s).", + self.physics_backend, + requirements.requires_newton_model, + requirements.requires_usd_stage, + ) + self.cloner_cfg.visualizer_clone_fn = visualizer_clone_fn + def _sensor_renderer_types(self) -> list[str]: """Return renderer type names used by scene sensors.""" renderer_types: list[str] = [] diff --git a/source/isaaclab/isaaclab/sensors/camera/camera.py b/source/isaaclab/isaaclab/sensors/camera/camera.py index 2244d191db72..db0dd4c760ac 100644 --- a/source/isaaclab/isaaclab/sensors/camera/camera.py +++ b/source/isaaclab/isaaclab/sensors/camera/camera.py @@ -106,6 +106,7 @@ def __init__(self, cfg: CameraCfg): self._check_supported_data_types(cfg) # initialize base class super().__init__(cfg) + self._register_renderer_scene_data_requirements() # TODO(follow-up PR): move this flag flip out of Camera. The cleanest path is # an apply_pre_reset_settings() hook on RendererCfg (default no-op) that @@ -133,6 +134,30 @@ def __init__(self, cfg: CameraCfg): self._renderer: BaseRenderer | None = None self._render_data = None + def _register_renderer_scene_data_requirements(self) -> None: + """Register renderer requirements early enough for clone-time prebuilds.""" + renderer_type = getattr(getattr(self.cfg, "renderer_cfg", None), "renderer_type", None) + if renderer_type is None: + return + + from isaaclab.physics.scene_data_requirements import aggregate_requirements, requirement_for_renderer_type + from isaaclab.sim import SimulationContext + + sim = SimulationContext.instance() + if sim is None: + logger.debug("SimulationContext not available; deferring renderer requirements registration.") + return + + try: + renderer_req = requirement_for_renderer_type(renderer_type) + except ValueError: + return + + current_req = sim.get_scene_data_requirements() + merged_req = aggregate_requirements((current_req, renderer_req)) + if merged_req != current_req: + sim.update_scene_data_requirements(merged_req) + def __del__(self): """Unsubscribes from callbacks and cleans up renderer resources.""" # unsubscribe callbacks diff --git a/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py b/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py index 1ee4cde038b5..80a943d6c4ce 100644 --- a/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py +++ b/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py @@ -40,7 +40,7 @@ class VisualizerCfg: lookat: tuple[float, float, float] = (0.0, 0.0, 0.0) """Initial camera look-at point (x, y, z) in world coordinates.""" - cam_source: Literal["cfg", "prim_path"] = "cfg" + cam_source: Literal["cfg", "prim_path"] = "prim_path" """Camera source mode: 'cfg' uses eye/lookat, 'prim_path' follows a camera prim.""" cam_prim_path: str = "/World/envs/env_0/Camera" diff --git a/source/isaaclab/test/scene/test_interactive_scene.py b/source/isaaclab/test/scene/test_interactive_scene.py index 5932e467e8d9..ecb758346700 100644 --- a/source/isaaclab/test/scene/test_interactive_scene.py +++ b/source/isaaclab/test/scene/test_interactive_scene.py @@ -21,6 +21,7 @@ import isaaclab.sim as sim_utils from isaaclab.actuators import ImplicitActuatorCfg from isaaclab.assets import ArticulationCfg, RigidObjectCfg +from isaaclab.physics.scene_data_requirements import SceneDataRequirement from isaaclab.scene import InteractiveScene, InteractiveSceneCfg from isaaclab.sim import build_simulation_context from isaaclab.utils import configclass @@ -134,6 +135,13 @@ def test_clone_environments_non_cfg_invokes_visualizer_clone_fn(monkeypatch: pyt scene = object.__new__(InteractiveScene) scene.cfg = SimpleNamespace(replicate_physics=False, num_envs=3) scene.stage = object() + scene.physics_backend = "physx" + scene._sensors = {} + scene.sim = SimpleNamespace( + get_scene_data_requirements=lambda: SceneDataRequirement(), + update_scene_data_requirements=lambda requirements: None, + set_scene_data_visualizer_prebuilt_artifact=lambda artifact: None, + ) scene.env_fmt = "/World/envs/env_{}" scene._ALL_INDICES = torch.arange(3, dtype=torch.long) scene._default_env_origins = torch.zeros((3, 3), dtype=torch.float32) @@ -189,6 +197,38 @@ def _usd_replicate(stage, *args, **kwargs): assert len(usd_calls) == 1 +def test_refresh_visualizer_clone_fn_uses_registered_requirements(monkeypatch: pytest.MonkeyPatch): + """Clone-time prebuild hook should be installed from requirements registered after scene init.""" + scene = object.__new__(InteractiveScene) + scene.physics_backend = "physx" + scene.stage = object() + scene._sensors = {} + scene.cloner_cfg = SimpleNamespace(visualizer_clone_fn=None) + + requirements = SceneDataRequirement(requires_newton_model=True) + scene.sim = SimpleNamespace( + get_scene_data_requirements=lambda: requirements, + update_scene_data_requirements=lambda requirements: None, + set_scene_data_visualizer_prebuilt_artifact=lambda artifact: None, + ) + + captured = {} + + def _resolve_visualizer_clone_fn(**kwargs): + captured.update(kwargs) + return "visualizer-clone-fn" + + monkeypatch.setattr( + "isaaclab.scene.interactive_scene.cloner.resolve_visualizer_clone_fn", + _resolve_visualizer_clone_fn, + ) + + scene._refresh_visualizer_clone_fn_from_requirements() + + assert captured["requirements"].requires_newton_model + assert scene.cloner_cfg.visualizer_clone_fn == "visualizer-clone-fn" + + def assert_state_equal(s1: dict, s2: dict, path=""): """ Recursively assert that s1 and s2 have the same nested keys diff --git a/source/isaaclab/test/sensors/test_camera.py b/source/isaaclab/test/sensors/test_camera.py index daed8e95773d..e1178192ef63 100644 --- a/source/isaaclab/test/sensors/test_camera.py +++ b/source/isaaclab/test/sensors/test_camera.py @@ -17,6 +17,7 @@ import copy import random +from types import SimpleNamespace import numpy as np import pytest @@ -27,7 +28,9 @@ from pxr import Gf, Usd, UsdGeom import isaaclab.sim as sim_utils +from isaaclab.physics.scene_data_requirements import SceneDataRequirement from isaaclab.sensors.camera import Camera, CameraCfg +from isaaclab.sim import SimulationContext pytestmark = pytest.mark.isaacsim_ci @@ -45,6 +48,23 @@ WIDTH = 320 +def test_camera_registers_renderer_scene_data_requirements(monkeypatch: pytest.MonkeyPatch): + """Camera creation path should register renderer-driven scene-data requirements.""" + camera = object.__new__(Camera) + camera.cfg = SimpleNamespace(renderer_cfg=SimpleNamespace(renderer_type="newton_warp")) + updates = [] + sim = SimpleNamespace( + get_scene_data_requirements=lambda: SceneDataRequirement(), + update_scene_data_requirements=updates.append, + ) + + monkeypatch.setattr(SimulationContext, "instance", staticmethod(lambda: sim)) + + camera._register_renderer_scene_data_requirements() + + assert updates == [SceneDataRequirement(requires_newton_model=True)] + + def setup() -> tuple[sim_utils.SimulationContext, CameraCfg, float]: camera_cfg = CameraCfg( height=HEIGHT, diff --git a/source/isaaclab/test/sim/test_physx_scene_data_provider_visualizer_contract.py b/source/isaaclab/test/sim/test_physx_scene_data_provider_visualizer_contract.py index efd42b475eaa..927fe351d202 100644 --- a/source/isaaclab/test/sim/test_physx_scene_data_provider_visualizer_contract.py +++ b/source/isaaclab/test/sim/test_physx_scene_data_provider_visualizer_contract.py @@ -67,68 +67,11 @@ def test_load_prebuilt_artifact_populates_provider_state(): assert provider._xform_mask_buf is None -def test_load_prebuilt_artifact_missing_falls_back_to_usd_build(): - """When no artifact is registered, the USD-traversal fallback is invoked.""" +def test_load_prebuilt_artifact_missing_sets_error_state(): + """When no artifact is registered, model/state stay unset.""" provider = _make_provider() - fallback_artifact = VisualizerPrebuiltArtifacts( - model="usd-built-model", - state="usd-built-state", - rigid_body_paths=["/World/envs/env_0/A"], - articulation_paths=[], - num_envs=2, - ) - stored: list[VisualizerPrebuiltArtifacts] = [] - provider._simulation_context = SimpleNamespace( - get_scene_data_visualizer_prebuilt_artifact=lambda: None, - set_scene_data_visualizer_prebuilt_artifact=stored.append, - ) - provider._stage = None - provider._xform_views = {} - provider._view_body_index_map = {} - provider._view_order_tensors = {} - provider._pose_buf_num_bodies = 0 - provider._positions_buf = None - provider._orientations_buf = None - provider._covered_buf = None - provider._xform_mask_buf = None - - with ( - patch.object( - PhysxSceneDataProvider, - "_build_newton_artifact_from_usd_fallback", - autospec=True, - return_value=fallback_artifact, - ), - patch( - "isaaclab_physx.scene_data_providers.physx_scene_data_provider.replace_newton_shape_colors", - lambda m, s: None, - ), - ): - provider._load_newton_model_from_prebuilt_artifact() - - assert provider._last_newton_model_build_source == "usd_fallback" - assert provider._newton_model == "usd-built-model" - assert provider._newton_state == "usd-built-state" - assert provider._rigid_body_paths == ["/World/envs/env_0/A"] - assert provider._num_envs_at_last_newton_build == 2 - # The fallback artifact is cached on the simulation context so subsequent providers see it. - assert stored == [fallback_artifact] - - -def test_load_prebuilt_artifact_missing_and_fallback_failed_sets_missing_state(): - """When both the prebuilt artifact and the USD-traversal fallback fail, model/state stay unset.""" - provider = _make_provider() - provider._simulation_context = SimpleNamespace( - get_scene_data_visualizer_prebuilt_artifact=lambda: None, - set_scene_data_visualizer_prebuilt_artifact=lambda artifact: None, - ) - with patch.object( - PhysxSceneDataProvider, - "_build_newton_artifact_from_usd_fallback", - autospec=True, - return_value=None, - ): - provider._load_newton_model_from_prebuilt_artifact() + provider._simulation_context = SimpleNamespace(get_scene_data_visualizer_prebuilt_artifact=lambda: None) + provider._load_newton_model_from_prebuilt_artifact() assert provider._last_newton_model_build_source == "missing" assert provider._newton_model is None assert provider._newton_state is None diff --git a/source/isaaclab/test/sim/test_simulation_context_visualizers.py b/source/isaaclab/test/sim/test_simulation_context_visualizers.py index d5fa5ffbb2ce..4ac6faabba54 100644 --- a/source/isaaclab/test/sim/test_simulation_context_visualizers.py +++ b/source/isaaclab/test/sim/test_simulation_context_visualizers.py @@ -183,6 +183,9 @@ def get_newton_state(self): self.state_calls.append(None) return {"state_call": len(self.state_calls)} + def get_camera_transforms(self): + return {} + class _DummyViserViewer: def __init__(self): @@ -354,6 +357,9 @@ def get_newton_model(self): def get_newton_state(self): return {"ok": True} + def get_camera_transforms(self): + return {} + monkeypatch.setattr(rerun_visualizer, "NewtonViewerRerun", _FakeNewtonViewerRerun) monkeypatch.setattr( rerun_visualizer, "_ensure_rerun_server", lambda **kwargs: ("rerun+http://127.0.0.1:9876/proxy", False) diff --git a/source/isaaclab_physx/isaaclab_physx/scene_data_providers/physx_scene_data_provider.py b/source/isaaclab_physx/isaaclab_physx/scene_data_providers/physx_scene_data_provider.py index aa19784ff1b6..c501e8b32831 100644 --- a/source/isaaclab_physx/isaaclab_physx/scene_data_providers/physx_scene_data_provider.py +++ b/source/isaaclab_physx/isaaclab_physx/scene_data_providers/physx_scene_data_provider.py @@ -18,7 +18,6 @@ from pxr import UsdGeom, UsdPhysics from isaaclab.physics.base_scene_data_provider import BaseSceneDataProvider -from isaaclab.physics.scene_data_requirements import VisualizerPrebuiltArtifacts from isaaclab.sim.utils.newton_model_utils import replace_newton_shape_colors logger = logging.getLogger(__name__) @@ -106,8 +105,6 @@ def __init__(self, stage, simulation_context) -> None: "[PhysxSceneDataProvider] USD stage is None and not available from simulation_context. " "Ensure the simulation context has a valid stage when using OV/Newton/Rerun/Viser visualizers." ) - # Cached so the USD-traversal fallback can hand it to ``newton.ModelBuilder``. - self._up_axis = UsdGeom.GetStageUpAxis(self._stage) self._num_envs_at_last_newton_build: int | None = None # for _refresh_newton_model_if_needed self._device = getattr(self._simulation_context, "device", "cuda:0") @@ -125,7 +122,7 @@ def __init__(self, stage, simulation_context) -> None: self._xform_mask_buf = None # View index order as device tensors for vectorized scatter in _apply_view_poses. self._view_order_tensors: dict[str, Any] = {} - # Last load outcome (tests / debug): "prebuilt" | "usd_fallback" | "missing" | "error". + # Last load outcome (tests / debug): "prebuilt" | "missing" | "error". self._last_newton_model_build_source: str | None = None self._last_newton_model_build_elapsed_ms: float | None = None @@ -168,40 +165,35 @@ def _model_body_paths(self, model) -> list[str]: return list(getattr(model, "body_label", None) or getattr(model, "body_key", [])) def _load_newton_model_from_prebuilt_artifact(self) -> None: - """Load Newton model and state, preferring the prebuilt artifact and falling back to USD traversal. - - The fast path consumes the artifact stashed on - :class:`~isaaclab.sim.SimulationContext` by the cloner's visualizer prebuild - hook. When the artifact is missing — for example when a Direct env adds a - camera in :meth:`_setup_scene` after the scene's clone-time requirement - resolution has already run — fall back to building the model directly from - the USD stage and stash the result on the simulation context so subsequent - callers hit the fast path. - """ + """Load Newton model and state from the simulation context prebuilt artifact.""" start_t = time.perf_counter() try: artifact = self._simulation_context.get_scene_data_visualizer_prebuilt_artifact() - if not artifact or artifact.model is None or artifact.state is None: - artifact = self._build_newton_artifact_from_usd_fallback() - if artifact is None: - self._last_newton_model_build_source = "missing" - logger.error( - "[PhysxSceneDataProvider] No visualizer prebuilt artifact on the simulation context " - "and the USD-traversal fallback failed; cannot sync PhysX to Newton." - ) - self._clear_newton_model_state() - return - self._simulation_context.set_scene_data_visualizer_prebuilt_artifact(artifact) - self._last_newton_model_build_source = "usd_fallback" - else: - self._last_newton_model_build_source = "prebuilt" + if not artifact: + self._last_newton_model_build_source = "missing" + logger.error( + "[PhysxSceneDataProvider] No visualizer prebuilt artifact on the simulation context " + "(expected VisualizerPrebuiltArtifacts from scene setup)." + ) + self._clear_newton_model_state() + return + + model = artifact.model + state = artifact.state + if model is None or state is None: + self._last_newton_model_build_source = "missing" + logger.error( + "[PhysxSceneDataProvider] Prebuilt artifact is missing model or state; cannot sync PhysX to Newton." + ) + self._clear_newton_model_state() + return - self._newton_model = artifact.model - self._newton_state = artifact.state + self._newton_model = model + self._newton_state = state replace_newton_shape_colors(self._newton_model, self._stage) - body_paths = list(artifact.rigid_body_paths) or self._model_body_paths(artifact.model) + body_paths = list(artifact.rigid_body_paths) or self._model_body_paths(model) self._rigid_body_paths = body_paths view_paths = list(body_paths) if artifact.articulation_paths: @@ -220,9 +212,10 @@ def _load_newton_model_from_prebuilt_artifact(self) -> None: self._covered_buf = None self._xform_mask_buf = None self._num_envs_at_last_newton_build = int(artifact.num_envs) + self._last_newton_model_build_source = "prebuilt" except Exception as exc: self._last_newton_model_build_source = "error" - logger.error("[PhysxSceneDataProvider] Failed to load Newton model: %s", exc) + logger.error("[PhysxSceneDataProvider] Failed to load Newton model from prebuilt artifact: %s", exc) self._clear_newton_model_state() finally: elapsed_ms = (time.perf_counter() - start_t) * 1000.0 @@ -246,58 +239,6 @@ def _clear_newton_model_state(self) -> None: self._rigid_body_view_paths = [] self._num_envs_at_last_newton_build = None - def _build_newton_artifact_from_usd_fallback(self) -> VisualizerPrebuiltArtifacts | None: - """Build a Newton model from USD when no prebuilt artifact is available. - - Used by Direct envs that add their camera in :meth:`_setup_scene` after - :class:`~isaaclab.scene.InteractiveScene` has already resolved scene-data - requirements (with no sensors registered). Slower than the cloner-time - prebuild path because Newton has to traverse the full USD scene per - environment, but functionally equivalent and required for those envs. - - Returns: - A :class:`~isaaclab.physics.scene_data_requirements.VisualizerPrebuiltArtifacts` - wrapping the freshly built Newton model, or ``None`` when the build - could not be performed. - """ - try: - from newton import ModelBuilder - except ModuleNotFoundError as exc: - logger.error( - "[PhysxSceneDataProvider] Newton module not available; cannot build USD-fallback model. " - "Install the Newton backend to use newton/rerun/viser visualizers or the newton_warp renderer." - ) - logger.debug("[PhysxSceneDataProvider] Newton import error: %s", exc) - return None - - num_envs = self.get_num_envs() - if num_envs <= 0: - return None - - try: - builder = ModelBuilder(up_axis=self._up_axis) - builder.add_usd(self._stage, ignore_paths=[r"/World/envs/.*"]) - for env_id in range(num_envs): - builder.begin_world() - builder.add_usd(self._stage, root_path=f"/World/envs/env_{env_id}") - builder.end_world() - - model = builder.finalize(device=self._device) - state = model.state() - except Exception as exc: - logger.error("[PhysxSceneDataProvider] USD-traversal Newton build failed: %s", exc) - return None - - body_paths = self._model_body_paths(model) - articulation_paths = list(getattr(model, "articulation_label", None) or getattr(model, "articulation_key", [])) - return VisualizerPrebuiltArtifacts( - model=model, - state=state, - rigid_body_paths=body_paths, - articulation_paths=articulation_paths, - num_envs=num_envs, - ) - def _setup_rigid_body_view(self) -> None: """Create PhysX RigidBodyView from Newton's body paths. From 779a0fc5d1b98d6adf85ec3f7d2b9f1884ee6097 Mon Sep 17 00:00:00 2001 From: shauryadNv Date: Fri, 1 May 2026 19:06:55 -0700 Subject: [PATCH 24/40] Adds support for gear insertion with Flexiv Rizon 4s (#5175) # Description Added support to train a gear assembly/insertion policy for the Flexiv Rizon 4s robot. Defined new envs/tasks for this. Added required robot config for the Flexiv Rizon 4s with Grav gripper. Added documentation for a tutorial on training a gear assembly policy with Flexiv Rizon 4s. ## Type of change - New feature (non-breaking change which adds functionality) - Documentation update ## 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 - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Co-authored-by: Kelly Guo --- .../02_gear_assembly/gear_assembly_policy.rst | 618 +++++++++++++----- source/isaaclab_assets/config/extension.toml | 2 +- source/isaaclab_assets/docs/CHANGELOG.rst | 9 + .../isaaclab_assets/__init__.pyi | 2 + .../isaaclab_assets/robots/__init__.pyi | 3 +- .../isaaclab_assets/robots/flexiv.py | 90 ++- source/isaaclab_tasks/config/extension.toml | 2 +- source/isaaclab_tasks/docs/CHANGELOG.rst | 18 + .../gear_assembly/config/rizon_4s/__init__.py | 46 ++ .../config/rizon_4s/agents/__init__.py | 4 + .../config/rizon_4s/agents/rsl_rl_ppo_cfg.py | 49 ++ .../config/rizon_4s/joint_pos_env_cfg.py | 425 ++++++++++++ .../config/rizon_4s/ros_inference_env_cfg.py | 197 ++++++ .../manipulation/deploy/mdp/__init__.pyi | 4 + .../manipulation/deploy/mdp/events.py | 21 +- .../manipulation/deploy/mdp/noise_models.py | 84 ++- .../manipulation/deploy/mdp/observations.py | 4 +- .../manipulation/deploy/mdp/rewards.py | 360 +++++++--- 18 files changed, 1684 insertions(+), 254 deletions(-) create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/config/rizon_4s/__init__.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/config/rizon_4s/agents/__init__.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/config/rizon_4s/agents/rsl_rl_ppo_cfg.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/config/rizon_4s/joint_pos_env_cfg.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/config/rizon_4s/ros_inference_env_cfg.py diff --git a/docs/source/policy_deployment/02_gear_assembly/gear_assembly_policy.rst b/docs/source/policy_deployment/02_gear_assembly/gear_assembly_policy.rst index de8497f181b7..60ea4d0e3d7d 100644 --- a/docs/source/policy_deployment/02_gear_assembly/gear_assembly_policy.rst +++ b/docs/source/policy_deployment/02_gear_assembly/gear_assembly_policy.rst @@ -3,14 +3,17 @@ Training a Gear Insertion Policy and ROS Deployment ==================================================== -This tutorial walks you through how to train a gear insertion assembly reinforcement learning (RL) policy that transfers from simulation to a real robot. The workflow consists of two main stages: +This tutorial walks you through how to train a gear insertion reinforcement learning (RL) policy that transfers from simulation to a real robot. The workflow consists of two main stages: 1. **Simulation Training in Isaac Lab**: Train the policy in a high-fidelity physics simulation with domain randomization 2. **Real Robot Deployment with Isaac ROS**: Deploy the trained policy on real hardware using Isaac ROS and a custom ROS inference node -This walkthrough covers the key principles and best practices for sim-to-real transfer using Isaac Lab, illustrated with a real-world example: +This walkthrough covers the key principles and best practices for sim-to-real transfer using Isaac Lab. -- the Gear Assembly task for the UR10e robot with the Robotiq 2F-140 gripper or 2F-85 gripper +**Supported Robots:** + +- **Universal Robots UR10e**: 6-DOF industrial robot arm with Robotiq 2F-140 or 2F-85 gripper +- **Flexiv Rizon 4s**: 7-DOF collaborative robot arm with Grav parallel gripper **Task Details:** @@ -29,7 +32,7 @@ The gear assembly policy operates as follows: Sim-to-real transfer: Gear assembly policy trained in Isaac Lab (left) successfully deployed on real UR10e robot (right). -This environment has been successfully deployed on real UR10e robots without an IsaacLab dependency. +This environment has been successfully deployed on real UR10e and Flexiv Rizon 4s robots without an IsaacLab dependency. **Scope of This Tutorial:** @@ -62,41 +65,55 @@ Using Real-Robot-Available Observations Your simulation environment should only use observations that are available on the real robot and not use "privileged" information that wouldn't be available in deployment. -Observation Specification: Isaac-Deploy-GearAssembly-UR10e-2F140-v0 -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Observation Specification +^^^^^^^^^^^^^^^^^^^^^^^^^ The Gear Assembly environment uses both proprioceptive and exteroceptive (vision) observations: .. list-table:: Gear Assembly Environment Observations - :widths: 25 25 25 25 + :widths: 20 10 10 20 20 20 :header-rows: 1 * - Observation - - Dimension + - UR10e Dim + - Rizon 4s Dim - Real-World Source - - Noise in Training + - UR10e Noise + - Rizon 4s Noise * - ``joint_pos`` - - 6 (UR10e arm joints) - - UR10e controller - - None (proprioceptive) + - 6 + - 7 + - Robot controller + - None + - None * - ``joint_vel`` - - 6 (UR10e arm joints) - - UR10e controller - - None (proprioceptive) + - 6 + - 7 + - Robot controller + - None + - None * - ``gear_shaft_pos`` - - 3 (x, y, z position) + - 3 + - 3 - FoundationPose + RealSense depth - - ±0.005 m (5mm, estimated error from FoundationPose + RealSense depth pipeline) + - ±5mm + - ±10mm * - ``gear_shaft_quat`` - - 4 (quaternion orientation) + - 4 + - 4 - FoundationPose + RealSense depth - - ±0.01 per component (~5° angular error, estimated error from FoundationPose + RealSense depth pipeline) + - None + - ±2° -**Implementation:** +**Total observation dimension:** 19 (UR10e) or 21 (Rizon 4s) -.. code-block:: python +.. note:: + + The Rizon 4s uses higher observation noise than the UR10e. The position noise is doubled (±10mm vs ±5mm) and quaternion noise (±2° per axis via quaternion multiplication) is added to the gear shaft orientation. These noise levels are set in the Rizon4s-specific config (``joint_pos_env_cfg.py``) rather than the shared base class, so they do not affect UR10e environments. The higher noise trains a more robust policy for the Rizon 4s perception pipeline. - from isaaclab.utils.noise import UniformNoiseCfg as Unoise +**Implementation (base class, shared by all robots):** + +.. code-block:: python @configclass class PolicyCfg(ObsGroup): @@ -105,36 +122,41 @@ The Gear Assembly environment uses both proprioceptive and exteroceptive (vision # Robot joint states - NO noise for proprioceptive observations joint_pos = ObsTerm( func=mdp.joint_pos, - params={"asset_cfg": SceneEntityCfg("robot", joint_names=["shoulder_pan_joint", ...])}, + params={"asset_cfg": SceneEntityCfg("robot", joint_names=[".*"])}, ) - joint_vel = ObsTerm( func=mdp.joint_vel, - params={"asset_cfg": SceneEntityCfg("robot", joint_names=["shoulder_pan_joint", ...])}, + params={"asset_cfg": SceneEntityCfg("robot", joint_names=[".*"])}, ) - # Gear shaft pose from FoundationPose perception - # ADD noise for exteroceptive (vision-based) observations - # Calibrated to match FoundationPose + RealSense D435 error - # Typical error: 3-8mm position, 3-7° orientation + # Vision-based observations with noise calibrated to FoundationPose + RealSense D435 error gear_shaft_pos = ObsTerm( func=mdp.gear_shaft_pos_w, - params={"asset_cfg": SceneEntityCfg("factory_gear_base")}, - noise=Unoise(n_min=-0.005, n_max=0.005), # ±5mm - ) - - # Quaternion noise: small uniform noise on each component - # Results in ~5° orientation error - gear_shaft_quat = ObsTerm( - func=mdp.gear_shaft_quat_w, - params={"asset_cfg": SceneEntityCfg("factory_gear_base")}, - noise=Unoise(n_min=-0.01, n_max=0.01), + params={}, + noise=ResetSampledConstantNoiseModelCfg( + noise_cfg=UniformNoiseCfg(n_min=-0.005, n_max=0.005, operation="add") # ±5mm + ), ) + gear_shaft_quat = ObsTerm(func=mdp.gear_shaft_quat_w) def __post_init__(self): self.enable_corruption = True # Enable for perception observations only self.concatenate_terms = True +**Rizon 4s overrides** (in ``joint_pos_env_cfg.py``): + +.. code-block:: python + + # Higher noise for Rizon 4s perception pipeline + self.observations.policy.gear_shaft_pos.noise = ResetSampledConstantNoiseModelCfg( + noise_cfg=UniformNoiseCfg(n_min=-0.01, n_max=0.01, operation="add") # ±10mm + ) + self.observations.policy.gear_shaft_quat.noise = ResetSampledQuaternionNoiseModelCfg( + roll_range=(-0.03491, 0.03491), # ±2 degrees + pitch_range=(-0.03491, 0.03491), + yaw_range=(-0.03491, 0.03491), + ) + **Why No Noise for Proprioceptive Observations?** Empirically, we found that policies trained without noise on proprioceptive observations (joint positions and velocities) transfer well to real hardware. The UR10e controller provides sufficiently accurate joint state feedback that modeling sensor noise doesn't improve sim-to-real transfer for these tasks. @@ -163,41 +185,69 @@ Accurate physics simulation is critical for contact-rich tasks. Key parameters i The Gear Assembly task requires accurate contact modeling for insertion. Here's how friction is configured: -.. code-block:: python - - # From joint_pos_env_cfg.py in Isaac-Deploy-GearAssembly-UR10e-2F140-v0 - - @configclass - class EventCfg: - """Configuration for events including physics randomization.""" - - # Randomize friction for gear objects - small_gear_physics_material = EventTerm( - func=mdp.randomize_rigid_body_material, - mode="startup", - params={ - "asset_cfg": SceneEntityCfg("factory_gear_small", body_names=".*"), - "static_friction_range": (0.75, 0.75), # Calibrated to real gear material - "dynamic_friction_range": (0.75, 0.75), - "restitution_range": (0.0, 0.0), # No bounce - "num_buckets": 16, - }, - ) - - # Similar configuration for gripper fingers - robot_physics_material = EventTerm( - func=mdp.randomize_rigid_body_material, - mode="startup", - params={ - "asset_cfg": SceneEntityCfg("robot", body_names=".*finger"), - "static_friction_range": (0.75, 0.75), # Calibrated to real gripper - "dynamic_friction_range": (0.75, 0.75), - "restitution_range": (0.0, 0.0), - "num_buckets": 16, - }, - ) - -These friction values (0.75) were determined through iterative visual comparison: +.. tab-set:: + + .. tab-item:: UR10e + + .. code-block:: python + + # From config/ur_10e/joint_pos_env_cfg.py + + small_gear_physics_material = EventTerm( + func=mdp.randomize_rigid_body_material, + mode="startup", + params={ + "asset_cfg": SceneEntityCfg("factory_gear_small", body_names=".*"), + "static_friction_range": (0.75, 0.75), + "dynamic_friction_range": (0.75, 0.75), + "restitution_range": (0.0, 0.0), + "num_buckets": 16, + }, + ) + + robot_physics_material = EventTerm( + func=mdp.randomize_rigid_body_material, + mode="startup", + params={ + "asset_cfg": SceneEntityCfg("robot", body_names=".*finger"), + "static_friction_range": (0.75, 0.75), + "dynamic_friction_range": (0.75, 0.75), + "restitution_range": (0.0, 0.0), + "num_buckets": 16, + }, + ) + + .. tab-item:: Flexiv Rizon 4s + + .. code-block:: python + + # From config/rizon_4s/joint_pos_env_cfg.py + + small_gear_physics_material = EventTerm( + func=mdp.randomize_rigid_body_material, + mode="startup", + params={ + "asset_cfg": SceneEntityCfg("factory_gear_small", body_names=".*"), + "static_friction_range": (0.75, 0.75), + "dynamic_friction_range": (0.75, 0.75), + "restitution_range": (0.0, 0.0), + "num_buckets": 16, + }, + ) + + robot_physics_material = EventTerm( + func=mdp.randomize_rigid_body_material, + mode="startup", + params={ + "asset_cfg": SceneEntityCfg("robot", body_names=".*finger.*"), + "static_friction_range": (3.0, 3.0), + "dynamic_friction_range": (3.0, 3.0), + "restitution_range": (0.0, 0.0), + "num_buckets": 16, + }, + ) + +These friction values were determined through iterative visual comparison: 1. Record videos of the gear being grasped and manipulated on real hardware 2. Start training in simulation and observe the live simulation viewer @@ -207,12 +257,25 @@ These friction values (0.75) were determined through iterative visual comparison 6. Repeat adjustments until behavior matches (no need to wait for full policy training) 7. Once physics looks good, train in headless mode with video recording: - .. code-block:: bash + .. tab-set:: - python scripts/reinforcement_learning/rsl_rl/train.py \ - --task Isaac-Deploy-GearAssembly-UR10e-2F140-v0 \ - --headless \ - --video --video_length 800 --video_interval 5000 + .. tab-item:: UR10e + + .. code-block:: bash + + python scripts/reinforcement_learning/rsl_rl/train.py \ + --task Isaac-Deploy-GearAssembly-UR10e-2F140-v0 \ + --headless \ + --video --video_length 800 --video_interval 5000 + + .. tab-item:: Flexiv Rizon 4s + + .. code-block:: bash + + python scripts/reinforcement_learning/rsl_rl/train.py \ + --task Isaac-Deploy-GearAssembly-Rizon4s-Grav-ROS-Inference-v0 \ + --headless \ + --video --video_length 800 --video_interval 5000 8. Review the recorded videos and compare with real hardware videos to verify physics behavior @@ -264,36 +327,74 @@ Accurate actuator modeling ensures the simulated robot moves like the real one. **Controller Choice: Impedance Control** -For the UR10e deployment, we use an impedance controller interface. Using a simpler controller like impedance control reduces the chances of variation between simulation and reality compared to more complex controllers (e.g., operational space control, hybrid force-position control). Simpler controllers: +For the UR10e and Flexiv Rizon 4s deployments, we use an impedance controller interface. Using a simpler controller like impedance control reduces the chances of variation between simulation and reality compared to more complex controllers (e.g., operational space control, hybrid force-position control). Simpler controllers: - Have fewer parameters that can mismatch between sim and real - Are easier to model accurately in simulation - Have more predictable behavior that's easier to replicate - Reduce the controller complexity as a source of sim-real gap -**Example: UR10e Actuator Configuration** - -.. code-block:: python - - # Default UR10e actuator configuration - actuators = { - "arm": ImplicitActuatorCfg( - joint_names_expr=["shoulder_pan_joint", "shoulder_lift_joint", - "elbow_joint", "wrist_1_joint", "wrist_2_joint", "wrist_3_joint"], - effort_limit=87.0, # From UR10e specifications - velocity_limit=2.0, # From UR10e specifications - stiffness=800.0, # Calibrated to match real behavior - damping=40.0, # Calibrated to match real behavior - ), - } - -**Domain Randomization of Actuator Parameters** - -To account for variations in real robot behavior, randomize actuator gains during training: +**Actuator Configurations:** + +.. tab-set:: + + .. tab-item:: UR10e + + .. code-block:: python + + # Default UR10e actuator configuration + actuators = { + "arm": ImplicitActuatorCfg( + joint_names_expr=["shoulder_pan_joint", "shoulder_lift_joint", + "elbow_joint", "wrist_1_joint", "wrist_2_joint", "wrist_3_joint"], + effort_limit=87.0, # From UR10e specifications + velocity_limit=2.0, # From UR10e specifications + stiffness=800.0, # Calibrated to match real behavior + damping=40.0, # Calibrated to match real behavior + ), + } + + .. tab-item:: Flexiv Rizon 4s + Grav Gripper + + The Rizon 4s uses ``ImplicitActuatorCfg`` with per-joint-group tuning, plus actuators for the Grav parallel gripper: + + .. code-block:: python + + actuators = { + "shoulder": ImplicitActuatorCfg( + joint_names_expr=["joint[1-2]"], + effort_limit=123.0, velocity_limit=2.094, + stiffness=6000.0, damping=108.4, + ), + "elbow": ImplicitActuatorCfg( + joint_names_expr=["joint[3-4]"], + effort_limit=64.0, velocity_limit=2.443, + stiffness=4200.0, damping=90.7, + ), + "wrist": ImplicitActuatorCfg( + joint_names_expr=["joint[5-7]"], + effort_limit=39.0, velocity_limit=4.887, + stiffness=1500.0, damping=54.2, + ), + "gripper_drive": ImplicitActuatorCfg( + joint_names_expr=["finger_joint"], + effort_limit=2.0, velocity_limit=1.0, + stiffness=2e3, damping=1e1, + ), + "gripper_passive": ImplicitActuatorCfg( + joint_names_expr=[".*_knuckle_joint"], + effort_limit=1.0, velocity_limit=1.0, + stiffness=0.0, damping=0.0, + ), + } + +**Domain Randomization of Actuator Parameters (UR10e only)** + +To account for variations in real robot behavior, the UR10e configuration randomizes actuator gains during training: .. code-block:: python - # From EventCfg in the Gear Assembly environment + # From EventCfg in config/ur_10e/joint_pos_env_cfg.py robot_joint_stiffness_and_damping = EventTerm( func=mdp.randomize_actuator_gains, mode="reset", @@ -307,7 +408,7 @@ To account for variations in real robot behavior, randomize actuator gains durin ) -**Joint Friction Randomization** +**Joint Friction Randomization (UR10e only)** Real robots have friction in their joints that varies with position, velocity, and temperature. For the UR10e with impedance controller interface, we observed significant stiction (static friction) causing the controller to not reach target joint positions. @@ -317,6 +418,7 @@ To quantify this behavior, we plotted the step response of the impedance control .. code-block:: python + # From EventCfg in config/ur_10e/joint_pos_env_cfg.py joint_friction = EventTerm( func=mdp.randomize_joint_parameters, mode="reset", @@ -330,6 +432,10 @@ To quantify this behavior, we plotted the step response of the impedance control **Why Joint Friction Matters**: Without modeling joint friction in simulation, the policy learns to expect that commanded joint positions are always reached. On the real robot, stiction prevents small movements and causes steady-state errors. By adding friction during training, the policy learns to account for these effects and commands appropriately larger motions to overcome friction. +.. note:: + + **Flexiv Rizon 4s**: Domain randomization for actuator gains and joint friction is not included in the Rizon 4s ``EventCfg`` (``config/rizon_4s/joint_pos_env_cfg.py``). We found the Rizon 4s real-world controller is more stable and precise than the UR10e's, with negligible stiction and steady-state error. As a result, the simulation policy transfers well to the real robot without needing these additional randomizations. + **Compensating for Stiction with Action Scaling:** To help the policy overcome stiction on the real robot, we also increased the output action scaling. The Isaac ROS documentation notes that a higher action scale (0.0325 vs 0.025) is needed to overcome the higher static friction (stiction) compared to the 2F-85 gripper. This increased scaling ensures the policy commands are large enough to overcome the friction forces observed in the step response analysis. @@ -339,20 +445,41 @@ Action Space Design Your action space should match what the real robot controller can execute. For this task we found that **incremental joint position control** is the most reliable approach. -**Example: Gear Assembly Action Configuration** +**Action Configuration:** -.. code-block:: python +.. tab-set:: - # For contact-rich manipulation, smaller action scale for more precise control - self.joint_action_scale = 0.025 # ±2.5 degrees per step + .. tab-item:: UR10e - self.actions.arm_action = mdp.RelativeJointPositionActionCfg( - asset_name="robot", - joint_names=["shoulder_pan_joint", "shoulder_lift_joint", "elbow_joint", - "wrist_1_joint", "wrist_2_joint", "wrist_3_joint"], - scale=self.joint_action_scale, - use_zero_offset=True, - ) + .. code-block:: python + + self.joint_action_scale = 0.025 # ±1.4 degrees per step + + self.actions.arm_action = mdp.RelativeJointPositionActionCfg( + asset_name="robot", + joint_names=["shoulder_pan_joint", "shoulder_lift_joint", "elbow_joint", + "wrist_1_joint", "wrist_2_joint", "wrist_3_joint"], + scale=self.joint_action_scale, + use_zero_offset=True, + ) + + **Action dimension:** 6 + + .. tab-item:: Flexiv Rizon 4s + + .. code-block:: python + + self.joint_action_scale = 0.025 # ±1.4 degrees per step + + self.actions.arm_action = mdp.RelativeJointPositionActionCfg( + asset_name="robot", + joint_names=["joint1", "joint2", "joint3", "joint4", + "joint5", "joint6", "joint7"], + scale=self.joint_action_scale, + use_zero_offset=True, + ) + + **Action dimension:** 7 The action scale is a critical hyperparameter that should be tuned based on: @@ -366,11 +493,11 @@ Domain randomization should cover the range of conditions in which you want the **Pose Randomization** -For manipulation tasks, randomize object poses to ensure the policy works across the workspace: +For manipulation tasks, randomize object poses to ensure the policy works across the workspace. Both robots use the same gear and base pose randomization: .. code-block:: python - # From Gear Assembly environment + # Shared by both UR10e and Rizon 4s EventCfg randomize_gears_and_base_pose = EventTerm( func=gear_assembly_events.randomize_gears_and_base_pose, mode="reset", @@ -388,11 +515,7 @@ For manipulation tasks, randomize object poses to ensure the policy works across "y": [-0.02, 0.02], "z": [0.0575, 0.0775], # 5.75-7.75cm above base }, - "rot_randomization_range": { - "roll": [-math.pi/36, math.pi/36], # ±5 degrees - "pitch": [-math.pi/36, math.pi/36], - "yaw": [-math.pi/36, math.pi/36], - }, + "velocity_range": {}, }, ) @@ -400,23 +523,117 @@ For manipulation tasks, randomize object poses to ensure the policy works across Randomizing the robot's initial configuration helps the policy handle different starting conditions: +.. tab-set:: + + .. tab-item:: UR10e + + .. code-block:: python + + set_robot_to_grasp_pose = EventTerm( + func=gear_assembly_events.set_robot_to_grasp_pose, + mode="reset", + params={ + "robot_asset_cfg": SceneEntityCfg("robot"), + "pos_randomization_range": { + "x": [-0.0, 0.0], + "y": [-0.005, 0.005], # ±5mm variation + "z": [-0.003, 0.003], # ±3mm variation + }, + }, + ) + + .. tab-item:: Flexiv Rizon 4s + + .. code-block:: python + + set_robot_to_grasp_pose = EventTerm( + func=gear_assembly_events.set_robot_to_grasp_pose, + mode="reset", + params={ + "robot_asset_cfg": SceneEntityCfg("robot"), + "pos_randomization_range": { + "x": [-0.0, 0.0], + "y": [-0.0, 0.0], + "z": [-0.0, 0.0], + }, + }, + ) + +Reward Shaping +~~~~~~~~~~~~~~ + +The gear assembly environment uses keypoint-based rewards that measure the distance between keypoints on the gear and corresponding keypoints on the gear shaft. Both robots share a base set of reward terms defined in ``gear_assembly_env_cfg.py``: + +- **Keypoint tracking** (``keypoint_entity_error``): Penalizes the L2 distance between gear and shaft keypoints, encouraging the gear to approach the shaft. +- **Exponential keypoint tracking** (``keypoint_entity_error_exp``): Provides a dense exponential reward that grows sharply as keypoints align, helping the policy refine fine-grained insertion. +- **Action rate** (``action_rate_l2``): Penalizes large changes in actions between timesteps, promoting smooth motions. + +**Rizon 4s additional reward terms:** + +The Rizon 4s configuration adds two reward terms that measure the distance between the robot's end effector and the grasp-corrected pose computed from the active gear. For each gear, the code applies ``grasp_rot_offset`` and per-gear-size ``gear_offsets_grasp`` to compute where the EE should be if properly grasping that gear, then measures keypoint distance between the actual EE pose and that target. This acts as a grasp quality metric. These terms are defined only in the Rizon4s config (``joint_pos_env_cfg.py``) so they do not affect UR10e training: + .. code-block:: python - set_robot_to_grasp_pose = EventTerm( - func=gear_assembly_events.set_robot_to_grasp_pose, - mode="reset", + # Penalizes distance between EE and grasp-corrected pose + self.rewards.end_effector_grasp_keypoint_tracking = RewTerm( + func=mdp.keypoint_ee_grasp_error, + weight=-0.5, params={ "robot_asset_cfg": SceneEntityCfg("robot"), - "rot_offset": [0.0, math.sqrt(2)/2, math.sqrt(2)/2, 0.0], # Base gripper orientation - "pos_randomization_range": { - "x": [-0.0, 0.0], - "y": [-0.005, 0.005], # ±5mm variation - "z": [-0.003, 0.003], # ±3mm variation - }, - "gripper_type": "2f_140", + "keypoint_scale": 0.15, + "ee_grasp_threshold": 0.00, + "weight_ramp_start": 0.0, + "weight_ramp_steps": 250_000, + "end_effector_body_name": self.end_effector_body_name, + "grasp_rot_offset": self.grasp_rot_offset, + "gear_offsets_grasp": self.gear_offsets_grasp, + }, + ) + + # Exponential version for dense reward near alignment + self.rewards.end_effector_grasp_keypoint_tracking_exp = RewTerm( + func=mdp.keypoint_ee_grasp_error_exp, + weight=0.5, + params={ + "robot_asset_cfg": SceneEntityCfg("robot"), + "kp_exp_coeffs": [(50, 0.0001), (300, 0.0001)], + "kp_use_sum_of_exps": False, + "keypoint_scale": 0.15, + "ee_grasp_threshold": 0.00, + "weight_ramp_start": 0.0, + "weight_ramp_steps": 250_000, + "end_effector_body_name": self.end_effector_body_name, + "grasp_rot_offset": self.grasp_rot_offset, + "gear_offsets_grasp": self.gear_offsets_grasp, }, ) +These terms encourage the Rizon 4s policy to keep the gripper properly aligned with the gear during insertion. The distance is ~0 when the EE is correctly grasping the gear, and increases when the gripper drifts away. The ``weight_ramp_steps`` parameter linearly ramps the reward weight from zero over the first 512k environment steps, allowing the policy to first learn coarse approach/insertion behavior before the grasp quality reward becomes active. + +.. list-table:: Reward Terms Comparison + :widths: 40 15 15 + :header-rows: 1 + + * - Reward Term + - UR10e + - Rizon 4s + * - ``keypoint_entity_error`` (gear-shaft distance) + - Yes + - Yes + * - ``keypoint_entity_error_exp`` (exponential gear-shaft) + - Yes + - Yes + * - ``action_rate_l2`` (smooth actions) + - Yes + - Yes + * - ``keypoint_ee_grasp_error`` (EE vs grasp-corrected gear) + - No + - Yes + * - ``keypoint_ee_grasp_error_exp`` (exponential EE vs gear) + - No + - Yes + + Part 3: Training the Policy in Isaac Lab ----------------------------------------- @@ -427,16 +644,34 @@ Step 1: Visualize the Environment First, launch the training with a small number of environments and visualization enabled to verify that the environment is set up correctly: -.. code-block:: bash +.. tab-set:: - # Launch training with visualization - python scripts/reinforcement_learning/rsl_rl/train.py \ - --task Isaac-Deploy-GearAssembly-UR10e-2F140-v0 \ - --num_envs 4 + .. tab-item:: UR10e (2F-140) -.. note:: + .. code-block:: bash + + python scripts/reinforcement_learning/rsl_rl/train.py \ + --task Isaac-Deploy-GearAssembly-UR10e-2F140-ROS-Inference-v0 \ + --num_envs 4 \ + --visualizer kit + + .. tab-item:: UR10e (2F-85) + + .. code-block:: bash + + python scripts/reinforcement_learning/rsl_rl/train.py \ + --task Isaac-Deploy-GearAssembly-UR10e-2F85-ROS-Inference-v0 \ + --num_envs 4 \ + --visualizer kit + + .. tab-item:: Flexiv Rizon 4s + Grav - For the Robotiq 2F-85 gripper, use ``--task Isaac-Deploy-GearAssembly-UR10e-2F85-v0`` instead. + .. code-block:: bash + + python scripts/reinforcement_learning/rsl_rl/train.py \ + --task Isaac-Deploy-GearAssembly-Rizon4s-Grav-ROS-Inference-v0 \ + --num_envs 4 \ + --visualizer kit This will open the Isaac Sim viewer where you can observe the training process in real-time. @@ -456,21 +691,44 @@ Step 2: Full-Scale Training with Video Recording Now launch the full training run with more parallel environments in headless mode for faster training. We'll also enable video recording to monitor progress: -.. code-block:: bash +.. tab-set:: + + .. tab-item:: UR10e (2F-140) + + .. code-block:: bash + + python scripts/reinforcement_learning/rsl_rl/train.py \ + --task Isaac-Deploy-GearAssembly-UR10e-2F140-ROS-Inference-v0 \ + --headless \ + --num_envs 256 \ + --video --video_length 200 --video_interval 76800 + + .. tab-item:: UR10e (2F-85) + + .. code-block:: bash - # Full training with video recording - python scripts/reinforcement_learning/rsl_rl/train.py \ - --task Isaac-Deploy-GearAssembly-UR10e-2F140-v0 \ - --headless \ - --num_envs 256 \ - --video --video_length 800 --video_interval 5000 + python scripts/reinforcement_learning/rsl_rl/train.py \ + --task Isaac-Deploy-GearAssembly-UR10e-2F85-ROS-Inference-v0 \ + --headless \ + --num_envs 256 \ + --video --video_length 200 --video_interval 76800 -This command will: + .. tab-item:: Flexiv Rizon 4s + Grav -- Run 256 parallel environments for efficient training -- Run in headless mode (no visualization) for maximum performance -- Record videos every 5000 steps to monitor training progress -- Save videos with 800 frames each + .. code-block:: bash + + python scripts/reinforcement_learning/rsl_rl/train.py \ + --task Isaac-Deploy-GearAssembly-Rizon4s-Grav-ROS-Inference-v0 \ + --headless \ + --num_envs 256 \ + --video --video_length 200 --video_interval 76800 + +**Command breakdown:** + +- ``--headless``: Disables visualization for maximum training speed +- ``--num_envs 256``: Runs 256 parallel environments for efficient training +- ``--video_length 200``: Each video captures approximately one full episode (``episode_length_s / (sim.dt * decimation)`` = ``6.66 / (1/1000 * 33)`` ≈ 200 steps) +- ``--video_interval 76800``: Records a video every 76,800 environment steps (~every 150 iterations), producing ~10 videos over full training Training typically takes ~12-24 hours for a robust insertion policy. The videos will be saved in the ``logs`` directory and can be reviewed to assess policy performance during training. @@ -483,11 +741,21 @@ Training typically takes ~12-24 hours for a robust insertion policy. The videos You can monitor training metrics in real-time using TensorBoard. Open a new terminal and run: -.. code-block:: bash +.. tab-set:: + + .. tab-item:: UR10e + + .. code-block:: bash + + ./isaaclab.sh -p -m tensorboard.main --logdir logs/rsl_rl/gear_assembly_ur10e - ./isaaclab.sh -p -m tensorboard.main --logdir + .. tab-item:: Flexiv Rizon 4s -Replace ```` with the path to your training logs (e.g., ``logs/rsl_rl/gear_assembly_ur10e/2025-11-19_19-31-01``). TensorBoard will display plots showing rewards, episode lengths, and other metrics. Verify that the rewards are increasing over iterations to ensure the policy is learning successfully. + .. code-block:: bash + + ./isaaclab.sh -p -m tensorboard.main --logdir logs/rsl_rl/gear_assembly_rizon4s_grav + +Replace the log directory path with your actual training log location if different. TensorBoard will display plots showing rewards, episode lengths, and other metrics. Verify that the rewards are increasing over iterations to ensure the policy is learning successfully. Step 3: Deploy on Real Robot @@ -594,6 +862,54 @@ CUDA Out of Memory You can always evaluate the trained policy later with visualization. +Deterministic Debugging (Play Environment) +------------------------------------------- + +The ``Isaac-Deploy-GearAssembly-Rizon4s-Grav-Play-v0`` environment provides a fully +deterministic setup for debugging policy behavior against a specific real-world scenario. +All randomization is disabled and observation noise is turned off, so the simulation is +identical on every reset. + +To use it, run the standard ``play.py`` script: + +.. code-block:: bash + + python scripts/reinforcement_learning/rsl_rl/play.py \ + --task Isaac-Deploy-GearAssembly-Rizon4s-Grav-Play-v0 \ + --num_envs 1 \ + --checkpoint + +To match a specific real-world setup, edit the constants at the top of the +``Rizon4sGearAssemblyEnvCfg_PLAY`` class in +``isaaclab_tasks/.../gear_assembly/config/rizon_4s/ros_inference_env_cfg.py``: + +.. code-block:: python + + @configclass + class Rizon4sGearAssemblyEnvCfg_PLAY(Rizon4sGearAssemblyROSInferenceEnvCfg): + # ── Scene setup ── + GEAR_TYPE: str = "gear_large" # which gear to grasp + GEAR_BASE_POS: tuple = (0.481, -0.073, -0.005) # (x, y, z) meters + GEAR_BASE_ROT: tuple = (0.0, 0.0, 0.70711, -0.70711) # quaternion (x,y,z,w) + GEAR_Z_OFFSET: float = 0.0675 # gear height above base + + # ── Observation overrides (None = use simulated values) ── + OBS_SHAFT_POS: tuple | None = None # e.g. (0.481, -0.073, -0.005) + OBS_SHAFT_QUAT: tuple | None = None # e.g. (0.0, 0.0, 0.70711, -0.70711) + +When ``OBS_SHAFT_POS`` or ``OBS_SHAFT_QUAT`` are set (not ``None``), the +``play.py`` script automatically overwrites the corresponding portions of the +policy's observation tensor every step, regardless of simulation state. This +lets you test what the policy does when given a specific observation (e.g. a +pose captured from the real robot). + +This environment is particularly useful for: + +- Comparing simulated and real-world policy behavior at a known pose +- Injecting real-world observations to verify policy actions match expectations +- Debugging insertion failures at a specific gear base position/orientation + + Further Resources ----------------- diff --git a/source/isaaclab_assets/config/extension.toml b/source/isaaclab_assets/config/extension.toml index 17c76453c2e1..1bf36d627e3e 100644 --- a/source/isaaclab_assets/config/extension.toml +++ b/source/isaaclab_assets/config/extension.toml @@ -1,6 +1,6 @@ [package] # Semantic Versioning is used: https://semver.org/ -version = "0.3.2" +version = "0.3.3" # Description title = "Isaac Lab Assets" diff --git a/source/isaaclab_assets/docs/CHANGELOG.rst b/source/isaaclab_assets/docs/CHANGELOG.rst index 4e9caf33db6a..1d676f70a27e 100644 --- a/source/isaaclab_assets/docs/CHANGELOG.rst +++ b/source/isaaclab_assets/docs/CHANGELOG.rst @@ -1,6 +1,15 @@ Changelog --------- +0.3.3 (2026-04-29) +~~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added configuration for Flexiv Rizon 4s with Grav parallel gripper for manipulation tasks. + + 0.3.2 (2026-04-13) ~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_assets/isaaclab_assets/__init__.pyi b/source/isaaclab_assets/isaaclab_assets/__init__.pyi index cf607283682a..dc69e0b8c71c 100644 --- a/source/isaaclab_assets/isaaclab_assets/__init__.pyi +++ b/source/isaaclab_assets/isaaclab_assets/__init__.pyi @@ -55,6 +55,7 @@ __all__ = [ "UR10e_ROBOTIQ_GRIPPER_CFG", "UR10e_ROBOTIQ_2F_85_CFG", "FLEXIV_RIZON4S_CFG", + "FLEXIV_RIZON4S_GRAV_GRIPPER_CFG", "GELSIGHT_R15_CFG", "GELSIGHT_MINI_CFG", "VELODYNE_VLP_16_RAYCASTER_CFG", @@ -117,5 +118,6 @@ from .robots import ( UR10e_ROBOTIQ_GRIPPER_CFG, UR10e_ROBOTIQ_2F_85_CFG, FLEXIV_RIZON4S_CFG, + FLEXIV_RIZON4S_GRAV_GRIPPER_CFG, ) from .sensors import GELSIGHT_R15_CFG, GELSIGHT_MINI_CFG, VELODYNE_VLP_16_RAYCASTER_CFG diff --git a/source/isaaclab_assets/isaaclab_assets/robots/__init__.pyi b/source/isaaclab_assets/isaaclab_assets/robots/__init__.pyi index 9875e9244fa3..1a91afa213a7 100644 --- a/source/isaaclab_assets/isaaclab_assets/robots/__init__.pyi +++ b/source/isaaclab_assets/isaaclab_assets/robots/__init__.pyi @@ -55,6 +55,7 @@ __all__ = [ "UR10e_ROBOTIQ_GRIPPER_CFG", "UR10e_ROBOTIQ_2F_85_CFG", "FLEXIV_RIZON4S_CFG", + "FLEXIV_RIZON4S_GRAV_GRIPPER_CFG", ] from .agibot import AGIBOT_A2D_CFG @@ -105,4 +106,4 @@ from .universal_robots import ( UR10e_ROBOTIQ_GRIPPER_CFG, UR10e_ROBOTIQ_2F_85_CFG, ) -from .flexiv import FLEXIV_RIZON4S_CFG +from .flexiv import FLEXIV_RIZON4S_CFG, FLEXIV_RIZON4S_GRAV_GRIPPER_CFG diff --git a/source/isaaclab_assets/isaaclab_assets/robots/flexiv.py b/source/isaaclab_assets/isaaclab_assets/robots/flexiv.py index 2e4b14347da8..18adf4312022 100644 --- a/source/isaaclab_assets/isaaclab_assets/robots/flexiv.py +++ b/source/isaaclab_assets/isaaclab_assets/robots/flexiv.py @@ -9,6 +9,7 @@ The following configurations are available: * :obj:`FLEXIV_RIZON4S_CFG`: The Flexiv Rizon 4s arm without a gripper. +* :obj:`FLEXIV_RIZON4S_GRAV_GRIPPER_CFG`: The Flexiv Rizon 4s arm with Grav gripper. Reference: https://www.flexiv.com/product/rizon """ @@ -79,5 +80,92 @@ ), }, ) - """Configuration of Flexiv Rizon 4s arm using implicit actuator models.""" + + +FLEXIV_RIZON4S_GRAV_GRIPPER_CFG = ArticulationCfg( + spawn=sim_utils.UsdFileCfg( + usd_path=f"{ISAACLAB_NUCLEUS_DIR}/Robots/Flexiv/Rizon4s/rizon4s_with_grav.usd", + rigid_props=sim_utils.RigidBodyPropertiesCfg( + disable_gravity=False, + max_depenetration_velocity=5.0, + ), + articulation_props=sim_utils.ArticulationRootPropertiesCfg( + enabled_self_collisions=False, + solver_position_iteration_count=16, + solver_velocity_iteration_count=1, + ), + activate_contact_sensors=False, + ), + init_state=ArticulationCfg.InitialStateCfg( + joint_pos={ + "joint1": 0.0, + "joint2": -0.698, + "joint3": 0.0, + "joint4": 1.571, + "joint5": 0.0, + "joint6": 0.698, + "joint7": 0.0, + "finger_joint": 0.0, + "left_outer_finger_joint": 0.0, + "right_outer_finger_joint": 0.0, + }, + pos=(0.0, 0.0, 0.0), + rot=(0.0, 0.0, 0.0, 1.0), + ), + actuators={ + "shoulder": ImplicitActuatorCfg( + joint_names_expr=["joint[1-2]"], + effort_limit_sim=123.0, + velocity_limit_sim=2.094, + stiffness=1320.0, + damping=72.0, + friction=0.0, + armature=0.0, + ), + "elbow": ImplicitActuatorCfg( + joint_names_expr=["joint[3-4]"], + effort_limit_sim=64.0, + velocity_limit_sim=2.443, + stiffness=600.0, + damping=35.0, + friction=0.0, + armature=0.0, + ), + "wrist": ImplicitActuatorCfg( + joint_names_expr=["joint[5-7]"], + effort_limit_sim=39.0, + velocity_limit_sim=4.887, + stiffness=216.0, + damping=29.0, + friction=0.0, + armature=0.0, + ), + "gripper_drive": ImplicitActuatorCfg( + joint_names_expr=["finger_joint"], + effort_limit_sim=200.0, + velocity_limit_sim=0.6, + stiffness=2e3, + damping=1e1, + friction=0.0, + armature=0.0, + ), + "gripper_passive": ImplicitActuatorCfg( + joint_names_expr=[".*_knuckle_joint"], + effort_limit_sim=1.0, + velocity_limit_sim=1.0, + stiffness=0.0, + damping=0.0, + friction=0.0, + armature=0.0, + ), + }, +) +"""Configuration of Flexiv Rizon 4s arm with Grav gripper using implicit actuator models. + +The Grav gripper is a parallel gripper with the following joint configuration: +- finger_joint: Main actuation joint (opened: 45 deg, closed: -8.88 deg) +- *_knuckle_joint: Passive/mimic joints (not directly actuated) + +End effector body: right_finger_tip +""" diff --git a/source/isaaclab_tasks/config/extension.toml b/source/isaaclab_tasks/config/extension.toml index 4fa2321c276f..273ae57d2cb9 100644 --- a/source/isaaclab_tasks/config/extension.toml +++ b/source/isaaclab_tasks/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "1.5.33" +version = "1.5.34" # Description title = "Isaac Lab Environments" diff --git a/source/isaaclab_tasks/docs/CHANGELOG.rst b/source/isaaclab_tasks/docs/CHANGELOG.rst index 9a0f648f490e..2044f807afc5 100644 --- a/source/isaaclab_tasks/docs/CHANGELOG.rst +++ b/source/isaaclab_tasks/docs/CHANGELOG.rst @@ -1,6 +1,24 @@ Changelog --------- +1.5.34 (2026-04-30) +~~~~~~~~~~~~~~~~~~~ +Added +^^^^^ + +* Added Flexiv Rizon 4s gear assembly environment with Grav parallel gripper, including + training, ROS inference, and deterministic play/debug configurations. +* Added EE-grasp keypoint reward terms (``keypoint_ee_grasp_error``, ``keypoint_ee_grasp_error_exp``) + for tracking end-effector alignment with the grasp-corrected pose. +* Added quaternion noise model (``ResetSampledQuaternionNoiseModelCfg``) for Rizon 4s + gear shaft orientation observations. + +Fixed +^^^^^ + +* Fixed quaternion w-component indexing in gear assembly observation functions to match XYZW convention. + + 1.5.33 (2026-04-30) ~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/config/rizon_4s/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/config/rizon_4s/__init__.py new file mode 100644 index 000000000000..6940800ead36 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/config/rizon_4s/__init__.py @@ -0,0 +1,46 @@ +# Copyright (c) 2025-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 gymnasium as gym + +from . import agents + +## +# Register Gym environments. +## + + +# Flexiv Rizon 4s +gym.register( + id="Isaac-Deploy-GearAssembly-Rizon4s-Grav-v0", + entry_point="isaaclab.envs:ManagerBasedRLEnv", + disable_env_checker=True, + kwargs={ + "env_cfg_entry_point": f"{__name__}.joint_pos_env_cfg:Rizon4sGearAssemblyEnvCfg", + "rsl_rl_cfg_entry_point": f"{agents.__name__}.rsl_rl_ppo_cfg:Rizon4sGearAssemblyRNNPPORunnerCfg", + }, +) + +# Flexiv Rizon 4s - Play / Debug (deterministic, no randomization) +gym.register( + id="Isaac-Deploy-GearAssembly-Rizon4s-Grav-Play-v0", + entry_point="isaaclab.envs:ManagerBasedRLEnv", + disable_env_checker=True, + kwargs={ + "env_cfg_entry_point": f"{__name__}.ros_inference_env_cfg:Rizon4sGearAssemblyEnvCfg_PLAY", + "rsl_rl_cfg_entry_point": f"{agents.__name__}.rsl_rl_ppo_cfg:Rizon4sGearAssemblyRNNPPORunnerCfg", + }, +) + +# Flexiv Rizon 4s - ROS Inference +gym.register( + id="Isaac-Deploy-GearAssembly-Rizon4s-Grav-ROS-Inference-v0", + entry_point="isaaclab.envs:ManagerBasedRLEnv", + disable_env_checker=True, + kwargs={ + "env_cfg_entry_point": f"{__name__}.ros_inference_env_cfg:Rizon4sGearAssemblyROSInferenceEnvCfg", + "rsl_rl_cfg_entry_point": f"{agents.__name__}.rsl_rl_ppo_cfg:Rizon4sGearAssemblyRNNPPORunnerCfg", + }, +) diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/config/rizon_4s/agents/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/config/rizon_4s/agents/__init__.py new file mode 100644 index 000000000000..cf59b16a1e2e --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/config/rizon_4s/agents/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2025-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 diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/config/rizon_4s/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/config/rizon_4s/agents/rsl_rl_ppo_cfg.py new file mode 100644 index 000000000000..06f64e731afc --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/config/rizon_4s/agents/rsl_rl_ppo_cfg.py @@ -0,0 +1,49 @@ +# Copyright (c) 2025-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 isaaclab.utils import configclass + +from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg, RslRlPpoActorCriticRecurrentCfg, RslRlPpoAlgorithmCfg + + +@configclass +class Rizon4sGearAssemblyRNNPPORunnerCfg(RslRlOnPolicyRunnerCfg): + num_steps_per_env = 512 + max_iterations = 1500 + save_interval = 50 + experiment_name = "gear_assembly_rizon4s" + clip_actions = 1.0 + resume = False + obs_groups = { + "policy": ["policy"], + "critic": ["critic"], + } + policy = RslRlPpoActorCriticRecurrentCfg( + state_dependent_std=True, + init_noise_std=1.0, + actor_obs_normalization=True, + critic_obs_normalization=True, + actor_hidden_dims=[256, 128, 64], + critic_hidden_dims=[256, 128, 64], + noise_std_type="log", + activation="elu", + rnn_type="lstm", + rnn_hidden_dim=256, + rnn_num_layers=2, + ) + algorithm = RslRlPpoAlgorithmCfg( + value_loss_coef=1.0, + use_clipped_value_loss=True, + clip_param=0.2, + entropy_coef=0.0, + num_learning_epochs=8, + num_mini_batches=16, + learning_rate=5.0e-4, + schedule="adaptive", + gamma=0.99, + lam=0.95, + desired_kl=0.008, + max_grad_norm=1.0, + ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/config/rizon_4s/joint_pos_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/config/rizon_4s/joint_pos_env_cfg.py new file mode 100644 index 000000000000..bd8382b55212 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/config/rizon_4s/joint_pos_env_cfg.py @@ -0,0 +1,425 @@ +# Copyright (c) 2025-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 math + +import torch + +import isaaclab.sim as sim_utils +from isaaclab.actuators import ImplicitActuatorCfg +from isaaclab.assets import ArticulationCfg, RigidObjectCfg +from isaaclab.managers import EventTermCfg as EventTerm +from isaaclab.managers import RewardTermCfg as RewTerm +from isaaclab.managers import SceneEntityCfg +from isaaclab.utils import configclass +from isaaclab.utils.noise import UniformNoiseCfg + +import isaaclab_tasks.manager_based.manipulation.deploy.mdp as mdp +import isaaclab_tasks.manager_based.manipulation.deploy.mdp.events as gear_assembly_events +from isaaclab_tasks.manager_based.manipulation.deploy.gear_assembly.gear_assembly_env_cfg import GearAssemblyEnvCfg +from isaaclab_tasks.manager_based.manipulation.deploy.mdp.noise_models import ( + ResetSampledConstantNoiseModelCfg, + ResetSampledQuaternionNoiseModelCfg, +) + +## +# Pre-defined configs +## +from isaaclab_assets import FLEXIV_RIZON4S_GRAV_GRIPPER_CFG # isort: skip + + +## +# Gripper-specific helper functions +## + + +def set_finger_joint_pos_grav( + joint_pos: torch.Tensor, + reset_ind_joint_pos: list[int], + finger_joints: list[int], + finger_joint_position: float, +): + """Set finger joint positions for Grav gripper. + + Args: + joint_pos: Joint positions tensor + reset_ind_joint_pos: Row indices into the sliced joint_pos tensor + finger_joints: List of all gripper joint indices (6 joints total) + finger_joint_position: Target position for main finger joint (in radians) + + Note: + Grav gripper joint structure (indices from finger_joints list): + [0] finger_joint - main controllable joint + [1] left_inner_knuckle_joint - mimic with -1 gearing + [2] right_inner_knuckle_joint - mimic with -1 gearing + [3] right_outer_knuckle_joint - mimic with -1 gearing + [4] left_outer_finger_joint - mimic with +1 gearing + [5] right_outer_finger_joint - mimic with +1 gearing + """ + for idx in reset_ind_joint_pos: + if len(finger_joints) < 6: + raise ValueError(f"Grav gripper requires at least 6 finger joints, got {len(finger_joints)}") + + # Main controllable joint + joint_pos[idx, finger_joints[0]] = finger_joint_position + + # Mimic joints with -1 gearing + joint_pos[idx, finger_joints[1]] = finger_joint_position # left_inner_knuckle_joint + joint_pos[idx, finger_joints[2]] = finger_joint_position # right_inner_knuckle_joint + joint_pos[idx, finger_joints[3]] = finger_joint_position # right_outer_knuckle_joint + + # Mimic joints with +1 gearing + joint_pos[idx, finger_joints[4]] = -finger_joint_position # left_outer_finger_joint + joint_pos[idx, finger_joints[5]] = -finger_joint_position # right_outer_finger_joint + + +## +# Environment configuration +## + + +@configclass +class EventCfg: + """Configuration for events.""" + + small_gear_physics_material = EventTerm( + func=mdp.randomize_rigid_body_material, + mode="startup", + params={ + "asset_cfg": SceneEntityCfg("factory_gear_small", body_names=".*"), + "static_friction_range": (0.75, 0.75), + "dynamic_friction_range": (0.75, 0.75), + "restitution_range": (0.0, 0.0), + "num_buckets": 16, + }, + ) + + medium_gear_physics_material = EventTerm( + func=mdp.randomize_rigid_body_material, + mode="startup", + params={ + "asset_cfg": SceneEntityCfg("factory_gear_medium", body_names=".*"), + "static_friction_range": (0.75, 0.75), + "dynamic_friction_range": (0.75, 0.75), + "restitution_range": (0.0, 0.0), + "num_buckets": 16, + }, + ) + + large_gear_physics_material = EventTerm( + func=mdp.randomize_rigid_body_material, + mode="startup", + params={ + "asset_cfg": SceneEntityCfg("factory_gear_large", body_names=".*"), + "static_friction_range": (0.75, 0.75), + "dynamic_friction_range": (0.75, 0.75), + "restitution_range": (0.0, 0.0), + "num_buckets": 16, + }, + ) + + gear_base_physics_material = EventTerm( + func=mdp.randomize_rigid_body_material, + mode="startup", + params={ + "asset_cfg": SceneEntityCfg("factory_gear_base", body_names=".*"), + "static_friction_range": (0.0, 0.0), + "dynamic_friction_range": (0.0, 0.0), + "restitution_range": (0.0, 0.0), + "num_buckets": 16, + }, + ) + + robot_physics_material = EventTerm( + func=mdp.randomize_rigid_body_material, + mode="startup", + params={ + "asset_cfg": SceneEntityCfg("robot", body_names=".*finger.*"), + "static_friction_range": (3.0, 3.0), + "dynamic_friction_range": (3.0, 3.0), + "restitution_range": (0.0, 0.0), + "num_buckets": 16, + }, + ) + + randomize_gear_type = EventTerm( + func=gear_assembly_events.randomize_gear_type, + mode="reset", + params={"gear_types": ["gear_small", "gear_medium", "gear_large"]}, + ) + + reset_all = EventTerm(func=mdp.reset_scene_to_default, mode="reset") + + randomize_gears_and_base_pose = EventTerm( + func=gear_assembly_events.randomize_gears_and_base_pose, + mode="reset", + params={ + "pose_range": { + "x": [-0.1, 0.1], + "y": [-0.25, 0.25], + "z": [-0.1, 0.1], + "roll": [-math.pi / 90, math.pi / 90], # 2 degree + "pitch": [-math.pi / 90, math.pi / 90], # 2 degree + "yaw": [-math.pi / 6, math.pi / 6], # 30 degree + }, + "gear_pos_range": { + "x": [-0.02, 0.02], + "y": [-0.02, 0.02], + "z": [0.0575, 0.0775], + }, + "velocity_range": {}, + }, + ) + + set_robot_to_grasp_pose = EventTerm( + func=gear_assembly_events.set_robot_to_grasp_pose, + mode="reset", + params={ + "robot_asset_cfg": SceneEntityCfg("robot"), + "pos_randomization_range": {"x": [-0.0, 0.0], "y": [-0.0, 0.0], "z": [-0.0, 0.0]}, + }, + ) + + +@configclass +class Rizon4sGearAssemblyEnvCfg(GearAssemblyEnvCfg): + """Configuration for Flexiv Rizon 4s with Grav Gripper Gear Assembly Environment. + + The Flexiv Rizon 4s is a 7-DOF collaborative robot arm equipped with the + Flexiv Grav parallel gripper for gear manipulation tasks. + """ + + ee_grasp_weight_ramp_steps: int = 512_000 + + def __post_init__(self): + # post init of parent + super().__post_init__() + + # Flexiv-specific observation noise overrides + self.observations.policy.gear_shaft_pos.noise = ResetSampledConstantNoiseModelCfg( + noise_cfg=UniformNoiseCfg(n_min=-0.01, n_max=0.01, operation="add") + ) + self.observations.policy.gear_shaft_quat.noise = ResetSampledQuaternionNoiseModelCfg( + roll_range=(-0.03491, 0.03491), + pitch_range=(-0.03491, 0.03491), + yaw_range=(-0.03491, 0.03491), + ) + + # Robot-specific parameters for Flexiv Rizon 4s with Grav gripper + self.end_effector_body_name = "link7" # End effector body name for IK + self.num_arm_joints = 7 # Number of arm joints (Rizon 4s has 7 DOF) + # Rotation offset for grasp pose (quaternion [x, y, z, w]) + # Computed from IK convergence for downward-facing end effector + self.grasp_rot_offset = [ + -0.707, + 0.707, + 0.0, + 0.0, + ] + self.gripper_joint_setter_func = set_finger_joint_pos_grav # Grav gripper joint setter function + + # Gear orientation termination thresholds (in degrees) + self.gear_orientation_roll_threshold_deg = 15.0 # Maximum allowed roll deviation + self.gear_orientation_pitch_threshold_deg = 15.0 # Maximum allowed pitch deviation + self.gear_orientation_yaw_threshold_deg = 180.0 # Maximum allowed yaw deviation + + # Common observation configuration for Rizon 4s joints (arm only, not gripper) + self.observations.policy.joint_pos.params["asset_cfg"].joint_names = [ + "joint1", + "joint2", + "joint3", + "joint4", + "joint5", + "joint6", + "joint7", + ] + self.observations.policy.joint_vel.params["asset_cfg"].joint_names = [ + "joint1", + "joint2", + "joint3", + "joint4", + "joint5", + "joint6", + "joint7", + ] + + # override events + self.events = EventCfg() + + # Update termination thresholds from config + self.terminations.gear_orientation_exceeded.params["roll_threshold_deg"] = ( + self.gear_orientation_roll_threshold_deg + ) + self.terminations.gear_orientation_exceeded.params["pitch_threshold_deg"] = ( + self.gear_orientation_pitch_threshold_deg + ) + self.terminations.gear_orientation_exceeded.params["yaw_threshold_deg"] = ( + self.gear_orientation_yaw_threshold_deg + ) + + # Action configuration for Rizon 4s arm + # Using smaller action scale for stability + self.joint_action_scale = 0.025 + self.actions.arm_action = mdp.RelativeJointPositionActionCfg( + asset_name="robot", + joint_names=[ + "joint1", + "joint2", + "joint3", + "joint4", + "joint5", + "joint6", + "joint7", + ], + scale=self.joint_action_scale, + use_zero_offset=True, + ) + + # Switch robot to Flexiv Rizon 4s with Grav gripper + self.scene.robot = FLEXIV_RIZON4S_GRAV_GRIPPER_CFG.replace( + prim_path="{ENV_REGEX_NS}/Robot", + spawn=FLEXIV_RIZON4S_GRAV_GRIPPER_CFG.spawn.replace( + rigid_props=sim_utils.RigidBodyPropertiesCfg( + disable_gravity=True, + max_depenetration_velocity=5.0, + linear_damping=0.0, + angular_damping=0.0, + max_linear_velocity=1000.0, + max_angular_velocity=3666.0, + enable_gyroscopic_forces=True, + solver_position_iteration_count=4, + solver_velocity_iteration_count=1, + max_contact_impulse=1e32, + ), + articulation_props=sim_utils.ArticulationRootPropertiesCfg( + enabled_self_collisions=False, solver_position_iteration_count=4, solver_velocity_iteration_count=1 + ), + collision_props=sim_utils.CollisionPropertiesCfg(contact_offset=0.005, rest_offset=0.0), + ), + # Joint positions based on IK from center of distribution for randomized gear positions + init_state=ArticulationCfg.InitialStateCfg( + joint_pos={ + "joint1": 0.0, + "joint2": -0.698, + "joint3": 0.0, + "joint4": 1.571, + "joint5": 0.0, + "joint6": 0.698, + "joint7": 0.0, + }, + pos=(0.0, 0.0, 0.0), + rot=(0.0, 0.0, 0.0, 1.0), + ), + ) + + # Grav gripper actuator configuration for gear manipulation + self.scene.robot.actuators["gripper_drive"] = ImplicitActuatorCfg( + joint_names_expr=["finger_joint"], + effort_limit_sim=2.0, + velocity_limit_sim=1.0, + stiffness=2e3, + damping=1e1, + friction=0.0, + armature=0.0, + ) + + # Passive/mimic joints in the gripper - set to zero stiffness/damping + self.scene.robot.actuators["gripper_passive"] = ImplicitActuatorCfg( + joint_names_expr=[".*_knuckle_joint"], + effort_limit_sim=1.0, + velocity_limit_sim=1.0, + stiffness=0.0, + damping=0.0, + friction=0.0, + armature=0.0, + ) + + # Override gear initial states for Rizon (closer to robot, centered) + self.scene.factory_gear_base.init_state = RigidObjectCfg.InitialStateCfg( + pos=(0.481, -0.073, 0.071), + rot=(0.0, 0.0, 0.70711, -0.70711), + ) + self.scene.factory_gear_small.init_state = RigidObjectCfg.InitialStateCfg( + pos=(0.481, -0.073, 0.071), + rot=(0.0, 0.0, 0.70711, -0.70711), + ) + self.scene.factory_gear_medium.init_state = RigidObjectCfg.InitialStateCfg( + pos=(0.481, -0.073, 0.071), + rot=(0.0, 0.0, 0.70711, -0.70711), + ) + self.scene.factory_gear_large.init_state = RigidObjectCfg.InitialStateCfg( + pos=(0.481, -0.073, 0.071), + rot=(0.0, 0.0, 0.70711, -0.70711), + ) + + # Gear offsets and grasp positions for Rizon 4s with Grav gripper + # These offsets are relative to the end effector frame (link7) + # Z offset accounts for the gripper length from link7 to finger tip + self.gear_offsets_grasp = { + "gear_small": [0.0, -self.gear_offsets["gear_small"][0], -0.35], + "gear_medium": [0.0, -self.gear_offsets["gear_medium"][0], -0.35], + "gear_large": [0.0, -self.gear_offsets["gear_large"][0], -0.35], + } + + # Grasp widths for Grav gripper (raw radian values for finger_joint) + self.hand_grasp_width = { + "gear_small": 0.05, + "gear_medium": 0.2, + "gear_large": 0.28, + } + + # Close widths for Grav gripper (raw radian values for finger_joint) + self.hand_close_width = { + "gear_small": 0.0, + "gear_medium": 0.139626, + "gear_large": 0.139626, + } + + # Populate event term parameters + self.events.set_robot_to_grasp_pose.params["gear_offsets_grasp"] = self.gear_offsets_grasp + self.events.set_robot_to_grasp_pose.params["end_effector_body_name"] = self.end_effector_body_name + self.events.set_robot_to_grasp_pose.params["num_arm_joints"] = self.num_arm_joints + self.events.set_robot_to_grasp_pose.params["grasp_rot_offset"] = self.grasp_rot_offset + self.events.set_robot_to_grasp_pose.params["gripper_joint_setter_func"] = self.gripper_joint_setter_func + + # Flexiv-specific reward terms for EE-grasp keypoint tracking + self.rewards.end_effector_grasp_keypoint_tracking = RewTerm( + func=mdp.keypoint_ee_grasp_error, + weight=-0.5, + params={ + "robot_asset_cfg": SceneEntityCfg("robot"), + "keypoint_scale": 0.15, + "ee_grasp_threshold": 0.00, + "weight_ramp_start": 0.0, + "weight_ramp_steps": self.ee_grasp_weight_ramp_steps, + "end_effector_body_name": self.end_effector_body_name, + "grasp_rot_offset": self.grasp_rot_offset, + "gear_offsets_grasp": self.gear_offsets_grasp, + }, + ) + self.rewards.end_effector_grasp_keypoint_tracking_exp = RewTerm( + func=mdp.keypoint_ee_grasp_error_exp, + weight=0.5, + params={ + "robot_asset_cfg": SceneEntityCfg("robot"), + "kp_exp_coeffs": [(50, 0.0001), (300, 0.0001)], + "kp_use_sum_of_exps": False, + "keypoint_scale": 0.15, + "ee_grasp_threshold": 0.00, + "weight_ramp_start": 0.0, + "weight_ramp_steps": self.ee_grasp_weight_ramp_steps, + "end_effector_body_name": self.end_effector_body_name, + "grasp_rot_offset": self.grasp_rot_offset, + "gear_offsets_grasp": self.gear_offsets_grasp, + }, + ) + + # Populate termination term parameters + self.terminations.gear_dropped.params["gear_offsets_grasp"] = self.gear_offsets_grasp + self.terminations.gear_dropped.params["end_effector_body_name"] = self.end_effector_body_name + self.terminations.gear_dropped.params["grasp_rot_offset"] = self.grasp_rot_offset + + self.terminations.gear_orientation_exceeded.params["end_effector_body_name"] = self.end_effector_body_name + self.terminations.gear_orientation_exceeded.params["grasp_rot_offset"] = self.grasp_rot_offset diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/config/rizon_4s/ros_inference_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/config/rizon_4s/ros_inference_env_cfg.py new file mode 100644 index 000000000000..504a3ccda288 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/gear_assembly/config/rizon_4s/ros_inference_env_cfg.py @@ -0,0 +1,197 @@ +# Copyright (c) 2025-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 math + +import torch + +from isaaclab.assets import RigidObjectCfg +from isaaclab.managers import ObservationTermCfg as ObsTerm +from isaaclab.utils import configclass + +from .joint_pos_env_cfg import Rizon4sGearAssemblyEnvCfg + + +def constant_obs(env, value: tuple) -> torch.Tensor: + """Observation function that returns a fixed tensor every step.""" + return torch.tensor([value], device=env.device, dtype=torch.float32).expand(env.num_envs, -1) + + +@configclass +class Rizon4sGearAssemblyROSInferenceEnvCfg(Rizon4sGearAssemblyEnvCfg): + """Configuration for ROS inference with Flexiv Rizon 4s and Grav gripper. + + This configuration: + - Exposes variables needed for ROS inference + - Overrides robot and gear initial poses for fixed/deterministic setup + """ + + def __post_init__(self): + # post init of parent + super().__post_init__() + + # Variables used by Isaac Manipulator for on robot inference + # These parameters allow the ROS inference node to validate environment configuration, + # perform checks during inference, and correctly interpret observations and actions. + self.obs_order = ["arm_dof_pos", "arm_dof_vel", "shaft_pos", "shaft_quat"] + self.policy_action_space = "joint" + # Use inherited joint names from parent's observation configuration + self.arm_joint_names = self.observations.policy.joint_pos.params["asset_cfg"].joint_names + # Use inherited num_arm_joints from parent + self.action_space = self.num_arm_joints + # State space and observation space for Rizon 4s with Grav gripper (7 DOF arm + 1 gripper) + # State: 7 joint pos + 7 joint vel + 3 shaft pos + 4 shaft quat + 3 gear pos + 4 gear quat = 28 + # For critic: additional gear observations + self.state_space = 28 + # Observation: 7 joint pos + 7 joint vel + 3 shaft pos + 4 shaft quat = 21 + self.observation_space = 21 + + # Set joint_action_scale from the existing arm_action.scale + self.joint_action_scale = self.actions.arm_action.scale + + # Dynamically generate action_scale_joint_space based on action_space + self.action_scale_joint_space = [self.joint_action_scale] * self.action_space + + # Override robot initial pose for ROS inference (fixed pose, no randomization) + # Joint positions and pos are inherited from parent, only override rotation to be deterministic + self.scene.robot.init_state.rot = (0.0, 0.0, 0.0, 1.0) # Identity quaternion (x, y, z, w) + + # Override gear base initial pose (fixed pose for ROS inference) + # Position configured for Rizon 4s workspace + self.scene.factory_gear_base.init_state = RigidObjectCfg.InitialStateCfg( + pos=(0.481, -0.073, -0.005), + rot=(0.0, 0.0, 0.70711, -0.70711), + ) + + # Override gear initial poses (fixed poses for ROS inference) + # Small gear + self.scene.factory_gear_small.init_state = RigidObjectCfg.InitialStateCfg( + pos=(0.481, -0.073, -0.005), + rot=(0.0, 0.0, 0.70711, -0.70711), + ) + + # Medium gear + self.scene.factory_gear_medium.init_state = RigidObjectCfg.InitialStateCfg( + pos=(0.481, -0.073, -0.005), + rot=(0.0, 0.0, 0.70711, -0.70711), + ) + + # Large gear + self.scene.factory_gear_large.init_state = RigidObjectCfg.InitialStateCfg( + pos=(0.481, -0.073, -0.005), + rot=(0.0, 0.0, 0.70711, -0.70711), + ) + + # Fixed asset parameters for ROS inference - derived from configuration + # These parameters are used by the ROS inference node to validate the environment setup + # and apply appropriate noise models for robust real-world deployment. + # Derive position center from gear base init state + self.fixed_asset_init_pos_center = list(self.scene.factory_gear_base.init_state.pos) + # Derive position range from parent's randomize_gears_and_base_pose event pose_range + pose_range = self.events.randomize_gears_and_base_pose.params["pose_range"] + self.fixed_asset_init_pos_range = [ + pose_range["x"][1], # max value + pose_range["y"][1], # max value + pose_range["z"][1], # max value + ] + # Orientation in degrees (quaternion (0.0, 0.0, 0.70711, -0.70711) = -90° around Z) + self.fixed_asset_init_orn_deg = [0.0, 0.0, -90.0] + # Derive orientation range from parent's pose_range (radians to degrees) + self.fixed_asset_init_orn_deg_range = [ + math.degrees(pose_range["roll"][1]), # convert radians to degrees + math.degrees(pose_range["pitch"][1]), + math.degrees(pose_range["yaw"][1]), + ] + # Derive observation noise level from parent's gear_shaft_pos noise configuration + gear_shaft_pos_noise = self.observations.policy.gear_shaft_pos.noise.noise_cfg.n_max + self.fixed_asset_pos_obs_noise_level = [ + gear_shaft_pos_noise, + gear_shaft_pos_noise, + gear_shaft_pos_noise, + ] + + +@configclass +class Rizon4sGearAssemblyEnvCfg_PLAY(Rizon4sGearAssemblyROSInferenceEnvCfg): + """Deterministic play/debug configuration for Flexiv Rizon 4s gear assembly. + + Inherits the full ROS-inference configuration and then disables all + randomization so the simulation is identical on every reset. Useful for + comparing simulated and real-world policy behavior at a known pose. + + To debug a specific real-world scenario, edit the constants below to match + the physical setup, then run:: + + python scripts/reinforcement_learning/rsl_rl/play.py \\ + --task Isaac-Deploy-GearAssembly-Rizon4s-Grav-Play-v0 \\ + --num_envs 1 --checkpoint + + Observation overrides (``OBS_SHAFT_POS``, ``OBS_SHAFT_QUAT``) let you + inject fixed values into the policy's observation tensor regardless of + simulation state. Set to ``None`` to use the simulated values. + """ + + # ╔══════════════════════════════════════════════════════════════════════╗ + # ║ SCENE SETUP — edit to match your real-world setup ║ + # ╚══════════════════════════════════════════════════════════════════════╝ + + GEAR_TYPE: str = "gear_large" + GEAR_BASE_POS: tuple = (0.481, -0.073, -0.005) + GEAR_BASE_ROT: tuple = (0.0, 0.0, -0.70711, 0.70711) + GEAR_Z_OFFSET: float = 0.0675 + + # ╔══════════════════════════════════════════════════════════════════════╗ + # ║ OBSERVATION OVERRIDES — set to None to use simulated values ║ + # ║ ║ + # ║ Obs layout: [joint_pos(7) | joint_vel(7) | shaft_pos(3) | ║ + # ║ shaft_quat(4)] ║ + # ╚══════════════════════════════════════════════════════════════════════╝ + + OBS_SHAFT_POS: tuple | None = None # e.g. (0.481, -0.028, -0.005) + OBS_SHAFT_QUAT: tuple | None = None # e.g. (0.0, 0.0, -0.70711, 0.70711) + + def __post_init__(self): + super().__post_init__() + + self.scene.num_envs = 1 + self.scene.env_spacing = 2.5 + + # ── Fix gear type (no random selection) ─────────────────────────── + self.events.randomize_gear_type.params["gear_types"] = [self.GEAR_TYPE] + + # ── Override gear base pose ─────────────────────────────────────── + self.scene.factory_gear_base.init_state = RigidObjectCfg.InitialStateCfg( + pos=self.GEAR_BASE_POS, + rot=self.GEAR_BASE_ROT, + ) + for attr in ("factory_gear_small", "factory_gear_medium", "factory_gear_large"): + getattr(self.scene, attr).init_state = RigidObjectCfg.InitialStateCfg( + pos=self.GEAR_BASE_POS, + rot=self.GEAR_BASE_ROT, + ) + + # ── Zero out all pose randomization ─────────────────────────────── + self.events.randomize_gears_and_base_pose.params["pose_range"] = { + "x": [0.0, 0.0], + "y": [0.0, 0.0], + "z": [0.0, 0.0], + "roll": [0.0, 0.0], + "pitch": [0.0, 0.0], + "yaw": [0.0, 0.0], + } + self.events.randomize_gears_and_base_pose.params["gear_pos_range"] = { + "x": [0.0, 0.0], + "y": [0.0, 0.0], + "z": [self.GEAR_Z_OFFSET, self.GEAR_Z_OFFSET], + } + + # ── Disable observation noise ───────────────────────────────────── + self.observations.policy.enable_corruption = False + + # ── Observation overrides (replace terms with constant functions) ─ + if self.OBS_SHAFT_POS is not None: + self.observations.policy.gear_shaft_pos = ObsTerm(func=constant_obs, params={"value": self.OBS_SHAFT_POS}) + if self.OBS_SHAFT_QUAT is not None: + self.observations.policy.gear_shaft_quat = ObsTerm(func=constant_obs, params={"value": self.OBS_SHAFT_QUAT}) diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/mdp/__init__.pyi b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/mdp/__init__.pyi index 9f27fc7560c6..2a200c888bc0 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/mdp/__init__.pyi +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/mdp/__init__.pyi @@ -17,6 +17,8 @@ __all__ = [ "keypoint_command_error_exp", "keypoint_entity_error", "keypoint_entity_error_exp", + "keypoint_ee_grasp_error", + "keypoint_ee_grasp_error_exp", "reset_when_gear_dropped", "reset_when_gear_orientation_exceeds_threshold", ] @@ -29,6 +31,8 @@ from .rewards import ( keypoint_command_error_exp, keypoint_entity_error, keypoint_entity_error_exp, + keypoint_ee_grasp_error, + keypoint_ee_grasp_error_exp, ) from .terminations import reset_when_gear_dropped, reset_when_gear_orientation_exceeds_threshold from isaaclab.envs.mdp import * diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/mdp/events.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/mdp/events.py index 2933033fd8e0..b651a002966e 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/mdp/events.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/mdp/events.py @@ -216,7 +216,7 @@ def __call__( robot_asset_cfg: SceneEntityCfg = SceneEntityCfg("robot"), pos_threshold: float = 1e-6, rot_threshold: float = 1e-6, - max_iterations: int = 10, + max_iterations: int = 50, pos_randomization_range: dict | None = None, gear_offsets_grasp: dict | None = None, end_effector_body_name: str | None = None, @@ -340,6 +340,22 @@ def __call__( # Update joint positions joint_pos = joint_pos + delta_dof_pos + + # Wrap arm joint positions to fall within robot's actual joint limits + joint_pos_limits = self.robot_asset.data.joint_pos_limits.torch[env_ids, : self.num_arm_joints, :] + joint_min = joint_pos_limits[:, :, 0] + joint_max = joint_pos_limits[:, :, 1] + joint_range = joint_max - joint_min + + # Wrap only the arm joint positions (not gripper joints) + arm_joint_pos = joint_pos[:, : self.num_arm_joints] + arm_joint_pos = torch.where( + joint_range > 0, + joint_min + torch.remainder(arm_joint_pos - joint_min, joint_range), + arm_joint_pos, + ) + joint_pos[:, : self.num_arm_joints] = arm_joint_pos + joint_vel = torch.zeros_like(joint_pos) # Write to sim @@ -348,6 +364,9 @@ def __call__( self.robot_asset.write_joint_position_to_sim_index(position=joint_pos, env_ids=env_ids) self.robot_asset.write_joint_velocity_to_sim_index(velocity=joint_vel, env_ids=env_ids) + # Reset joint velocities to zero after IK convergence + joint_vel = torch.zeros_like(self.robot_asset.data.joint_vel.torch[env_ids]) + # Set gripper to grasp position joint_pos = self.robot_asset.data.joint_pos.torch[env_ids].clone() diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/mdp/noise_models.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/mdp/noise_models.py index 2d5411e96977..740099a169ee 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/mdp/noise_models.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/mdp/noise_models.py @@ -7,7 +7,12 @@ from __future__ import annotations -__all__ = ["ResetSampledConstantNoiseModel", "ResetSampledConstantNoiseModelCfg"] +__all__ = [ + "ResetSampledConstantNoiseModel", + "ResetSampledConstantNoiseModelCfg", + "ResetSampledQuaternionNoiseModel", + "ResetSampledQuaternionNoiseModelCfg", +] from collections.abc import Sequence from dataclasses import MISSING @@ -16,7 +21,8 @@ import torch from isaaclab.utils import configclass -from isaaclab.utils.noise import NoiseModel, NoiseModelCfg +from isaaclab.utils.math import quat_from_euler_xyz, quat_mul +from isaaclab.utils.noise import ConstantNoiseCfg, NoiseModel, NoiseModelCfg if TYPE_CHECKING: from isaaclab.utils.noise import NoiseCfg @@ -107,3 +113,77 @@ class ResetSampledConstantNoiseModelCfg(NoiseModelCfg): Based on this configuration, the noise is sampled at every reset of the noise model. """ + + +class ResetSampledQuaternionNoiseModel(NoiseModel): + """Noise model that applies a rotation perturbation to quaternion observations. + + At each episode reset, small Euler angle perturbations (roll, pitch, yaw) are sampled + uniformly from configurable ranges and converted to a perturbation quaternion. This + perturbation is then applied via quaternion multiplication at every step, producing a + geometrically valid rotated quaternion (unlike additive noise on raw components). + + The perturbation is held constant for the entire episode until the next reset. + """ + + def __init__(self, noise_model_cfg: NoiseModelCfg, num_envs: int, device: str): + super().__init__(noise_model_cfg, num_envs, device) + self._roll_range = noise_model_cfg.roll_range + self._pitch_range = noise_model_cfg.pitch_range + self._yaw_range = noise_model_cfg.yaw_range + # Identity quaternion in (x, y, z, w) format + self._perturbation_quat = torch.zeros((num_envs, 4), device=device) + self._perturbation_quat[:, 3] = 1.0 + + def reset(self, env_ids: Sequence[int] | None = None): + """Sample new rotation perturbations for the specified environments. + + Args: + env_ids: The environment ids to reset. Defaults to None (all environments). + """ + if env_ids is None: + env_ids = slice(None) + + num_resets = env_ids.stop - env_ids.start if isinstance(env_ids, slice) else len(env_ids) + + roll = torch.empty(num_resets, device=self._device).uniform_(*self._roll_range) + pitch = torch.empty(num_resets, device=self._device).uniform_(*self._pitch_range) + yaw = torch.empty(num_resets, device=self._device).uniform_(*self._yaw_range) + + self._perturbation_quat[env_ids] = quat_from_euler_xyz(roll, pitch, yaw) + + def __call__(self, data: torch.Tensor) -> torch.Tensor: + """Apply the pre-sampled rotation perturbation to the quaternion data. + + Args: + data: Quaternion observations in (x, y, z, w) format. Shape is (num_envs, 4). + + Returns: + Perturbed quaternions in (x, y, z, w) format. Shape is (num_envs, 4). + """ + return quat_mul(self._perturbation_quat, data) + + +@configclass +class ResetSampledQuaternionNoiseModelCfg(NoiseModelCfg): + """Configuration for a quaternion noise model that samples rotation perturbations at reset. + + The perturbation is specified as independent uniform ranges for roll, pitch, and yaw + (in radians). At each episode reset, Euler angles are sampled and converted to a + perturbation quaternion that is multiplied with the observed quaternion. + """ + + class_type: type = ResetSampledQuaternionNoiseModel + + noise_cfg: ConstantNoiseCfg = ConstantNoiseCfg(bias=0.0) + """Unused placeholder inherited from NoiseModelCfg. Quaternion perturbation is + controlled by roll_range, pitch_range, and yaw_range instead.""" + + roll_range: tuple[float, float] = (-0.01745, 0.01745) + """Uniform range for roll perturbation in radians. Default is ±1 degree.""" + + pitch_range: tuple[float, float] = (-0.01745, 0.01745) + """Uniform range for pitch perturbation in radians. Default is ±1 degree.""" + + yaw_range: tuple[float, float] = (-0.01745, 0.01745) + """Uniform range for yaw perturbation in radians. Default is ±1 degree.""" diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/mdp/observations.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/mdp/observations.py index 744182befd15..c32adbd7f616 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/mdp/observations.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/mdp/observations.py @@ -185,7 +185,7 @@ def __call__( # Ensure w component is positive (q and -q represent the same rotation) # Pick one canonical form to reduce observation variation seen by the policy - w_negative = base_quat[:, 0] < 0 + w_negative = base_quat[:, 3] < 0 positive_quat = base_quat.clone() positive_quat[w_negative] = -base_quat[w_negative] @@ -334,7 +334,7 @@ def __call__(self, env: ManagerBasedRLEnv) -> torch.Tensor: # Ensure w component is positive (q and -q represent the same rotation) # Pick one canonical form to reduce observation variation seen by the policy - w_negative = gear_quat[:, 0] < 0 + w_negative = gear_quat[:, 3] < 0 gear_positive_quat = gear_quat.clone() gear_positive_quat[w_negative] = -gear_quat[w_negative] diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/mdp/rewards.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/mdp/rewards.py index 2eff6a6e0bca..c776168e5b48 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/mdp/rewards.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/mdp/rewards.py @@ -12,9 +12,10 @@ import torch from isaaclab.managers import ManagerTermBase, RewardTermCfg, SceneEntityCfg -from isaaclab.utils.math import combine_frame_transforms +from isaaclab.utils.math import combine_frame_transforms, quat_apply, quat_mul if TYPE_CHECKING: + from isaaclab.assets import Articulation from isaaclab.envs import ManagerBasedRLEnv from isaaclab.sensors.frame_transformer.frame_transformer import FrameTransformer @@ -192,44 +193,29 @@ def __init__(self, cfg: RewardTermCfg, env: ManagerBasedRLEnv): self.asset_cfg_1: SceneEntityCfg = cfg.params.get("asset_cfg_1", SceneEntityCfg("factory_gear_base")) self.asset_1 = env.scene[self.asset_cfg_1.name] - # Pre-allocate gear type mapping and indices + self._init_gear_selection(env) + + # Create keypoint distance computer + self.keypoint_computer = _compute_keypoint_distance(cfg, env) + + def _init_gear_selection(self, env: ManagerBasedRLEnv) -> None: + """Pre-allocate gear type mapping, index tensors, and cache gear scene assets.""" self.gear_type_map = {"gear_small": 0, "gear_medium": 1, "gear_large": 2} self.gear_type_indices = torch.zeros(env.num_envs, device=env.device, dtype=torch.long) self.env_indices = torch.arange(env.num_envs, device=env.device) - # Cache gear assets self.gear_assets = { "gear_small": env.scene["factory_gear_small"], "gear_medium": env.scene["factory_gear_medium"], "gear_large": env.scene["factory_gear_large"], } - # Create keypoint distance computer - self.keypoint_computer = _compute_keypoint_distance(cfg, env) - - def __call__( - self, - env: ManagerBasedRLEnv, - asset_cfg_1: SceneEntityCfg, - keypoint_scale: float = 1.0, - add_cube_center_kp: bool = True, - ) -> torch.Tensor: - """Compute keypoint distance error. - - Args: - env: Environment instance - asset_cfg_1: Configuration of the first asset (RigidObject) - keypoint_scale: Scale factor for keypoint offsets - add_cube_center_kp: Whether to include center keypoint + def _get_selected_gear_poses(self, env: ManagerBasedRLEnv) -> tuple[torch.Tensor, torch.Tensor]: + """Retrieve world-frame position and quaternion of the active gear per environment. Returns: - Mean keypoint distance tensor of shape (num_envs,) + Tuple of (gear_pos, gear_quat) with shapes (num_envs, 3) and (num_envs, 4). """ - # Get current pose of asset_1 (RigidObject) - curr_pos_1 = self.asset_1.data.body_pos_w.torch[:, 0] - curr_quat_1 = self.asset_1.data.body_quat_w.torch[:, 0] - - # Check if gear type manager exists if not hasattr(env, "_gear_type_manager"): raise RuntimeError( "Gear type manager not initialized. Ensure randomize_gear_type event is configured " @@ -237,10 +223,8 @@ def __call__( ) gear_type_manager: randomize_gear_type = env._gear_type_manager - # Get gear type indices directly as tensor self.gear_type_indices = gear_type_manager.get_all_gear_type_indices() - # Stack all gear positions and quaternions all_gear_pos = torch.stack( [ self.gear_assets["gear_small"].data.body_pos_w.torch[:, 0], @@ -259,9 +243,35 @@ def __call__( dim=1, ) - # Select positions and quaternions using advanced indexing - curr_pos_2 = all_gear_pos[self.env_indices, self.gear_type_indices] - curr_quat_2 = all_gear_quat[self.env_indices, self.gear_type_indices] + gear_pos = all_gear_pos[self.env_indices, self.gear_type_indices] + gear_quat = all_gear_quat[self.env_indices, self.gear_type_indices] + + return gear_pos, gear_quat + + def __call__( + self, + env: ManagerBasedRLEnv, + asset_cfg_1: SceneEntityCfg, + keypoint_scale: float = 1.0, + add_cube_center_kp: bool = True, + ) -> torch.Tensor: + """Compute keypoint distance error. + + Args: + env: Environment instance + asset_cfg_1: Configuration of the first asset (RigidObject) + keypoint_scale: Scale factor for keypoint offsets + add_cube_center_kp: Whether to include center keypoint + + Returns: + Mean keypoint distance tensor of shape (num_envs,) + """ + # Get current pose of asset_1 (RigidObject) + curr_pos_1 = self.asset_1.data.body_pos_w.torch[:, 0] + curr_quat_1 = self.asset_1.data.body_quat_w.torch[:, 0] + + # Get selected gear pose + curr_pos_2, curr_quat_2 = self._get_selected_gear_poses(env) # Compute keypoint distance keypoint_dist_sep = self.keypoint_computer.compute( @@ -275,40 +285,13 @@ def __call__( return keypoint_dist_sep.mean(-1) -class keypoint_entity_error_exp(ManagerTermBase): +class keypoint_entity_error_exp(keypoint_entity_error): """Compute exponential keypoint reward between a RigidObject and the dynamically selected gear. - This class-based term pre-caches gear type mapping and asset references. + Inherits gear selection and initialization from :class:`keypoint_entity_error` + and applies an exponential reward transformation to the keypoint distances. """ - def __init__(self, cfg: RewardTermCfg, env: ManagerBasedRLEnv): - """Initialize the keypoint entity error exponential term. - - Args: - cfg: Reward term configuration - env: Environment instance - """ - super().__init__(cfg, env) - - # Cache asset configuration - self.asset_cfg_1: SceneEntityCfg = cfg.params.get("asset_cfg_1", SceneEntityCfg("factory_gear_base")) - self.asset_1 = env.scene[self.asset_cfg_1.name] - - # Pre-allocate gear type mapping and indices - self.gear_type_map = {"gear_small": 0, "gear_medium": 1, "gear_large": 2} - self.gear_type_indices = torch.zeros(env.num_envs, device=env.device, dtype=torch.long) - self.env_indices = torch.arange(env.num_envs, device=env.device) - - # Cache gear assets - self.gear_assets = { - "gear_small": env.scene["factory_gear_small"], - "gear_medium": env.scene["factory_gear_medium"], - "gear_large": env.scene["factory_gear_large"], - } - - # Create keypoint distance computer - self.keypoint_computer = _compute_keypoint_distance(cfg, env) - def __call__( self, env: ManagerBasedRLEnv, @@ -335,39 +318,8 @@ def __call__( curr_pos_1 = self.asset_1.data.body_pos_w.torch[:, 0] curr_quat_1 = self.asset_1.data.body_quat_w.torch[:, 0] - # Check if gear type manager exists - if not hasattr(env, "_gear_type_manager"): - raise RuntimeError( - "Gear type manager not initialized. Ensure randomize_gear_type event is configured " - "in your environment's event configuration before this reward term is used." - ) - - gear_type_manager: randomize_gear_type = env._gear_type_manager - # Get gear type indices directly as tensor - self.gear_type_indices = gear_type_manager.get_all_gear_type_indices() - - # Stack all gear positions and quaternions - all_gear_pos = torch.stack( - [ - self.gear_assets["gear_small"].data.body_pos_w.torch[:, 0], - self.gear_assets["gear_medium"].data.body_pos_w.torch[:, 0], - self.gear_assets["gear_large"].data.body_pos_w.torch[:, 0], - ], - dim=1, - ) - - all_gear_quat = torch.stack( - [ - self.gear_assets["gear_small"].data.body_quat_w.torch[:, 0], - self.gear_assets["gear_medium"].data.body_quat_w.torch[:, 0], - self.gear_assets["gear_large"].data.body_quat_w.torch[:, 0], - ], - dim=1, - ) - - # Select positions and quaternions using advanced indexing - curr_pos_2 = all_gear_pos[self.env_indices, self.gear_type_indices] - curr_quat_2 = all_gear_quat[self.env_indices, self.gear_type_indices] + # Get selected gear pose + curr_pos_2, curr_quat_2 = self._get_selected_gear_poses(env) # Compute keypoint distance keypoint_dist_sep = self.keypoint_computer.compute( @@ -396,6 +348,226 @@ def __call__( return keypoint_reward_exp +class keypoint_ee_grasp_error(keypoint_entity_error): + """Compute keypoint distance between the robot end effector and the gear's grasp-corrected pose. + + Transforms the gear's actual world pose into the expected EE position/orientation + using grasp offsets, so that the distance is ~0 when properly holding the gear + and increases when the gripper drifts away. + + The penalty is gated by ``ee_grasp_threshold``: It only activates when the mean + keypoint error exceeds the threshold, i.e., when the EE has drifted away from the + expected grasp pose. With threshold=0.0, the penalty is effectively always active. + + Supports linear weight ramp-up: The returned reward is scaled by a factor that + linearly increases from ``weight_ramp_start`` to 1.0 over ``weight_ramp_steps`` + env steps, allowing the reward to grow in importance as training progresses. + """ + + def __init__(self, cfg: RewardTermCfg, env: ManagerBasedRLEnv): + ManagerTermBase.__init__(self, cfg, env) + + self._init_gear_selection(env) + self.keypoint_computer = _compute_keypoint_distance(cfg, env) + + self.robot_asset_cfg: SceneEntityCfg = cfg.params.get("robot_asset_cfg", SceneEntityCfg("robot")) + self.robot_asset: Articulation = env.scene[self.robot_asset_cfg.name] + + self.end_effector_body_name: str = cfg.params["end_effector_body_name"] + grasp_rot_offset = cfg.params["grasp_rot_offset"] + self.grasp_rot_offset_tensor = ( + torch.tensor(grasp_rot_offset, device=env.device, dtype=torch.float32).unsqueeze(0).repeat(env.num_envs, 1) + ) + + gear_offsets_grasp = cfg.params["gear_offsets_grasp"] + self.gear_grasp_offsets_stacked = torch.stack( + [ + torch.tensor(gear_offsets_grasp["gear_small"], device=env.device, dtype=torch.float32), + torch.tensor(gear_offsets_grasp["gear_medium"], device=env.device, dtype=torch.float32), + torch.tensor(gear_offsets_grasp["gear_large"], device=env.device, dtype=torch.float32), + ], + dim=0, + ) + + self.weight_ramp_start: float = cfg.params.get("weight_ramp_start", 0.0) + self.weight_ramp_steps: int = cfg.params.get("weight_ramp_steps", 1) + self.ee_grasp_threshold: float = cfg.params.get("ee_grasp_threshold", 0.0) + + eef_indices, _ = self.robot_asset.find_bodies([self.end_effector_body_name]) + self.eef_idx = eef_indices[0] if len(eef_indices) > 0 else None + self._step_count = 0 + + def _get_weight_scale(self, env: ManagerBasedRLEnv) -> float: + progress = min(env.common_step_counter / max(self.weight_ramp_steps, 1), 1.0) + return self.weight_ramp_start + (1.0 - self.weight_ramp_start) * progress + + def _get_grasp_corrected_target( + self, env: ManagerBasedRLEnv + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: + """Compute EE pose and grasp-corrected target pose. + + Returns: + Tuple of (eef_pos, eef_quat, gear_grasp_pos, gear_quat_grasp). + """ + eef_pos = self.robot_asset.data.body_link_pos_w.torch[:, self.eef_idx] + eef_quat = self.robot_asset.data.body_link_quat_w.torch[:, self.eef_idx] + + gear_pos, gear_quat = self._get_selected_gear_poses(env) + + gear_quat_grasp = quat_mul(gear_quat, self.grasp_rot_offset_tensor) + grasp_offsets = self.gear_grasp_offsets_stacked[self.gear_type_indices] + gear_grasp_pos = gear_pos + quat_apply(gear_quat_grasp, grasp_offsets) + + return eef_pos, eef_quat, gear_grasp_pos, gear_quat_grasp + + def __call__( + self, + env: ManagerBasedRLEnv, + robot_asset_cfg: SceneEntityCfg = SceneEntityCfg("robot"), + end_effector_body_name: str = "", + grasp_rot_offset: list | None = None, + gear_offsets_grasp: dict | None = None, + keypoint_scale: float = 1.0, + add_cube_center_kp: bool = True, + weight_ramp_start: float = 0.0, + weight_ramp_steps: int = 1, + ee_grasp_threshold: float = 0.0, + ) -> torch.Tensor: + if self.eef_idx is None: + return torch.zeros(env.num_envs, device=env.device) + + eef_pos, eef_quat, gear_grasp_pos, gear_quat_grasp = self._get_grasp_corrected_target(env) + + keypoint_dist_sep = self.keypoint_computer.compute( + current_pos=eef_pos, + current_quat=eef_quat, + target_pos=gear_grasp_pos, + target_quat=gear_quat_grasp, + keypoint_scale=keypoint_scale, + ) + + mean_kp_error = keypoint_dist_sep.mean(-1) + + is_active = (mean_kp_error > self.ee_grasp_threshold).float() + + weight_scale = self._get_weight_scale(env) + scaled_reward = mean_kp_error * weight_scale * is_active + + mean_error_scalar = mean_kp_error.mean().item() + pct_active = is_active.mean().item() + + if not hasattr(env, "extras"): + env.extras = {} + if "log" not in env.extras: + env.extras["log"] = {} + env.extras["log"]["ee_grasp_kp_error/mean_keypoint_dist"] = mean_error_scalar + env.extras["log"]["ee_grasp_kp_error/pct_envs_active"] = pct_active + env.extras["log"]["ee_grasp_kp_error/weight_scale"] = weight_scale + + self._step_count += 1 + import carb + + carb.log_info( + f"[ee_grasp_kp_error] step={self._step_count}" + f" | mean_kp_error={mean_error_scalar:.5f}" + f" | pct_active={pct_active:.3f}" + f" | weight_scale={weight_scale:.4f}" + ) + + return scaled_reward + + +class keypoint_ee_grasp_error_exp(keypoint_ee_grasp_error): + """Compute exponential keypoint reward between the robot end effector and the gear's grasp-corrected pose. + + Transforms the gear's actual world pose into the expected EE position/orientation + using grasp offsets, so that the reward is high (~1) when properly holding the gear + and drops sharply when the gripper drifts away. + + The reward is gated by ``ee_grasp_threshold``: It only activates when the mean + keypoint error exceeds the threshold, i.e. when the EE has drifted away from the + expected grasp pose. With threshold=0.0 the reward is effectively always active. + + Supports linear weight ramp-up: The returned reward is scaled by a factor that + linearly increases from ``weight_ramp_start`` to 1.0 over ``weight_ramp_steps`` + env steps, allowing the reward to grow in importance as training progresses. + """ + + def __call__( + self, + env: ManagerBasedRLEnv, + robot_asset_cfg: SceneEntityCfg = SceneEntityCfg("robot"), + end_effector_body_name: str = "", + grasp_rot_offset: list | None = None, + gear_offsets_grasp: dict | None = None, + kp_exp_coeffs: list[tuple[float, float]] = [(1.0, 0.1)], + kp_use_sum_of_exps: bool = True, + keypoint_scale: float = 1.0, + add_cube_center_kp: bool = True, + weight_ramp_start: float = 0.0, + weight_ramp_steps: int = 1, + ee_grasp_threshold: float = 0.0, + ) -> torch.Tensor: + if self.eef_idx is None: + return torch.zeros(env.num_envs, device=env.device) + + eef_pos, eef_quat, gear_grasp_pos, gear_quat_grasp = self._get_grasp_corrected_target(env) + + keypoint_dist_sep = self.keypoint_computer.compute( + current_pos=eef_pos, + current_quat=eef_quat, + target_pos=gear_grasp_pos, + target_quat=gear_quat_grasp, + keypoint_scale=keypoint_scale, + ) + + mean_kp_error = keypoint_dist_sep.mean(-1) + + is_active = (mean_kp_error > self.ee_grasp_threshold).float() + + keypoint_reward_exp = torch.zeros_like(keypoint_dist_sep[:, 0]) + if kp_use_sum_of_exps: + for coeff in kp_exp_coeffs: + a, b = coeff + keypoint_reward_exp += ( + 1.0 / (torch.exp(a * keypoint_dist_sep) + b + torch.exp(-a * keypoint_dist_sep)) + ).mean(-1) + else: + kp_dist_mean = keypoint_dist_sep.mean(-1) + for coeff in kp_exp_coeffs: + a, b = coeff + keypoint_reward_exp += 1.0 / (torch.exp(a * kp_dist_mean) + b + torch.exp(-a * kp_dist_mean)) + + weight_scale = self._get_weight_scale(env) + scaled_reward = keypoint_reward_exp * weight_scale * is_active + + mean_error_scalar = mean_kp_error.mean().item() + mean_reward_scalar = keypoint_reward_exp.mean().item() + pct_active = is_active.mean().item() + + if not hasattr(env, "extras"): + env.extras = {} + if "log" not in env.extras: + env.extras["log"] = {} + env.extras["log"]["ee_grasp_kp_error_exp/mean_keypoint_dist"] = mean_error_scalar + env.extras["log"]["ee_grasp_kp_error_exp/mean_exp_reward"] = mean_reward_scalar + env.extras["log"]["ee_grasp_kp_error_exp/pct_envs_active"] = pct_active + env.extras["log"]["ee_grasp_kp_error_exp/weight_scale"] = weight_scale + + self._step_count += 1 + import carb + + carb.log_info( + f"[ee_grasp_kp_error_exp] step={self._step_count}" + f" | mean_kp_error={mean_error_scalar:.5f}" + f" | pct_active={pct_active:.3f}" + f" | weight_scale={weight_scale:.4f}" + f" | mean_exp_reward={mean_reward_scalar:.5f}" + ) + + return scaled_reward + + ## # Helper functions and classes ## From 9bf2920159bed5d1b8c2f79a7f68b0a72b6e9aaf Mon Sep 17 00:00:00 2001 From: hujc Date: Fri, 1 May 2026 23:20:00 -0700 Subject: [PATCH 25/40] Add fragment-based changelog system to eliminate per-PR merge conflicts (#5434) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Every PR that touches `source//` updates `source//docs/CHANGELOG.rst` and `source//config/extension.toml`. Both files are append-only at the top, so concurrent PRs collide on the same lines. Contributors burn time resolving merge conflicts that say nothing about the actual change. Maintainers occasionally land an entry under the wrong version heading because the resolution is mechanical and easy to mis-do. ## Solution Adopt the towncrier-style fragment-per-PR pattern (used by pip, urllib3, Twisted, Trio, attrs, Sphinx, …): - Each PR adds **one fragment file** per touched package under `source//changelog.d/..rst`. Different PRs touch different files — no conflict. - A **nightly CI workflow** rolls accumulated fragments into per-package `CHANGELOG.rst` entries, bumps each `extension.toml`, deletes consumed fragments, and pushes the result back to `develop`. - A **PR gate** rejects any PR that touches a managed package's source without adding a fragment, and rejects modifications to existing fragments (immutability). CHANGELOG.rst and extension.toml become append-only-by-CI files; humans never edit them. ## Contributor workflow (one fragment per touched package) ```bash # Branch: jdoe/fix-spawn-pose $EDITOR source/isaaclab/changelog.d/jdoe-fix-spawn-pose.rst ``` ```rst Fixed ^^^^^ * Fixed :meth:`~isaaclab.assets.Articulation.write_root_pose` ignoring the env-id mask. ``` Filename convention: | Filename | Effect | |---|---| | `.rst` | patch bump (`X.Y.Z → X.Y.Z+1`) | | `.minor.rst` | minor bump (`X.Y.Z → X.Y+1.0`) | | `.major.rst` | major bump (`X+1.0.0`) — breaking change | | `.skip` | no entry, no bump (CI / docs / test-only PRs) | The slug is any short, unique name. **Branch name with `/` replaced by `-` is the recommended default** — already in scope at commit time, naturally unique because of the `/` branch convention. Within a batch, the highest tier wins for the package (`major > minor > patch`). ## What the nightly does `tools/changelog/cli.py compile --all` walks every package's `changelog.d/`, sorts fragments by merge time, merges sections across fragments (canonical order: Added, Changed, Deprecated, Removed, Fixed), prepends a single `X.Y.Z (YYYY-MM-DD)` block to `CHANGELOG.rst`, bumps `extension.toml`, and deletes the consumed files. One commit per night, with a body listing every package that bumped: ``` [CI][Auto Version Bump] Compile changelog fragments (schedule) Bumped packages: - isaaclab: 4.6.26 → 4.7.0 - isaaclab_physx: 0.5.29 → 1.0.0 - isaaclab_tasks: 1.5.32 → 1.5.33 ``` ## What's tested **Unit + integration (82 tests, all passing):** filename regex acceptance/rejection, bump-tier aggregation, cross-fragment section merge, immutability, missing-fragment-per-touched-package, version-pinning guards, content validation. Worked-example fixtures under `tools/changelog/tests/fixtures/integration/{01_patch_bump, 02_minor_bump, 03_major_bump}/` double as living demos. **Live end-to-end on a fork** (real GitHub Actions runners, not local mocks): 1. Single fragment dry-run + real run: ✓ produces correct CHANGELOG block, bumps extension.toml, deletes fragment, commits with the expected subject/body. 2. Multi-package multi-tier batch (7 simulated PRs across 3 packages, mixed Fixed/Added/Deprecated/Removed/Changed/Breaking, mixed patch/minor/major/skip): ✓ each package bumps to the right tier (`isaaclab: 4.6.26→4.7.0` minor, `isaaclab_physx: 0.5.29→1.0.0` major, `isaaclab_tasks: 1.5.32→1.5.33` patch), single commit lists all three. 3. Cross-fragment section merge (3 fragments → one Added section with 3 bullets, 2 fragments → one Fixed section with 2 bullets, one fragment contributing to both): ✓ bullets correctly merged in merge-time order. Fork test branch: . ## Setup checklist for upstream after merge The new workflow files are pinned by SHA per the existing CI conventions; no additional repo settings are required for the workflows to run. Two optional follow-ups maintainers may want: 1. **Add `CHANGELOG_PAT` secret** (fine-grained PAT with `contents:write` on the repo). Optional. Without it, the nightly's auto-commit uses `GITHUB_TOKEN` — sufficient to push, but pushes signed with `GITHUB_TOKEN` do not trigger downstream workflow runs (this is GitHub's by-design loop guard). Adding the PAT lets the auto-commit re-trigger the docs / Docker rebuilds. 2. **Branch protection on `develop`** may need an exception for `github-actions[bot]` so the auto-commit can push directly. If `develop` already accepts non-PR pushes from CI service accounts, no change. ## Files changed - `tools/changelog/cli.py` — single entry with `compile` and `check` subcommands. - `tools/changelog/tests/` — 82 tests + integration fixtures. - `.github/workflows/changelog-check.yml` — PR gate (per-PR). - `.github/workflows/nightly-changelog.yml` — nightly auto-compile (cron + workflow_dispatch). - `AGENTS.md` — contributor docs (+10 / -5 lines in the existing "Changelog" section). - `source//changelog.d/.gitkeep` — empty directory placeholders for every managed package. --- .github/workflows/changelog-check.yml | 48 + .github/workflows/nightly-changelog.yml | 119 ++ AGENTS.md | 24 +- source/isaaclab/changelog.d/.gitkeep | 0 source/isaaclab_assets/changelog.d/.gitkeep | 0 source/isaaclab_contrib/changelog.d/.gitkeep | 0 .../changelog.d/.gitkeep | 0 source/isaaclab_mimic/changelog.d/.gitkeep | 0 source/isaaclab_newton/changelog.d/.gitkeep | 0 source/isaaclab_ov/changelog.d/.gitkeep | 0 source/isaaclab_physx/changelog.d/.gitkeep | 0 source/isaaclab_rl/changelog.d/.gitkeep | 0 source/isaaclab_tasks/changelog.d/.gitkeep | 0 source/isaaclab_teleop/changelog.d/.gitkeep | 0 tools/changelog/cli.py | 1009 +++++++++++++++++ tools/changelog/pyproject.toml | 13 + .../01_patch_bump/changelog_after.rst | 25 + .../01_patch_bump/changelog_before.rst | 10 + .../fragments/asmith-fix-collision-margin.rst | 9 + .../fragments/jdoe-fix-mass-units.rst | 4 + .../02_minor_bump/changelog_after.rst | 30 + .../02_minor_bump/changelog_before.rst | 10 + .../asmith-add-multi-asset-spawner.minor.rst | 4 + .../blee-add-camera-output-contract.minor.rst | 9 + .../fragments/jdoe-fix-rotation-frame.rst | 4 + .../03_major_bump/changelog_after.rst | 34 + .../03_major_bump/changelog_before.rst | 10 + .../asmith-add-warp-contact-stream.minor.rst | 4 + .../blee-rename-articulation-api.major.rst | 9 + .../fragments/jdoe-fix-articulation-state.rst | 4 + tools/changelog/test/integration/README.md | 35 + tools/changelog/test/invalid_content/3001.rst | 0 tools/changelog/test/invalid_content/3002.rst | 1 + tools/changelog/test/invalid_content/3003.rst | 2 + .../test/invalid_filenames/1234.notabump.rst | 4 + .../test/invalid_filenames/multi.dot.slug.rst | 4 + tools/changelog/test/test_bump_suffix.py | 135 +++ tools/changelog/test/test_format.py | 94 ++ tools/changelog/test/test_integration.py | 69 ++ tools/changelog/test/test_parse.py | 143 +++ tools/changelog/test/test_validate.py | 323 ++++++ 41 files changed, 2179 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/changelog-check.yml create mode 100644 .github/workflows/nightly-changelog.yml create mode 100644 source/isaaclab/changelog.d/.gitkeep create mode 100644 source/isaaclab_assets/changelog.d/.gitkeep create mode 100644 source/isaaclab_contrib/changelog.d/.gitkeep create mode 100644 source/isaaclab_experimental/changelog.d/.gitkeep create mode 100644 source/isaaclab_mimic/changelog.d/.gitkeep create mode 100644 source/isaaclab_newton/changelog.d/.gitkeep create mode 100644 source/isaaclab_ov/changelog.d/.gitkeep create mode 100644 source/isaaclab_physx/changelog.d/.gitkeep create mode 100644 source/isaaclab_rl/changelog.d/.gitkeep create mode 100644 source/isaaclab_tasks/changelog.d/.gitkeep create mode 100644 source/isaaclab_teleop/changelog.d/.gitkeep create mode 100644 tools/changelog/cli.py create mode 100644 tools/changelog/pyproject.toml create mode 100644 tools/changelog/test/integration/01_patch_bump/changelog_after.rst create mode 100644 tools/changelog/test/integration/01_patch_bump/changelog_before.rst create mode 100644 tools/changelog/test/integration/01_patch_bump/fragments/asmith-fix-collision-margin.rst create mode 100644 tools/changelog/test/integration/01_patch_bump/fragments/jdoe-fix-mass-units.rst create mode 100644 tools/changelog/test/integration/02_minor_bump/changelog_after.rst create mode 100644 tools/changelog/test/integration/02_minor_bump/changelog_before.rst create mode 100644 tools/changelog/test/integration/02_minor_bump/fragments/asmith-add-multi-asset-spawner.minor.rst create mode 100644 tools/changelog/test/integration/02_minor_bump/fragments/blee-add-camera-output-contract.minor.rst create mode 100644 tools/changelog/test/integration/02_minor_bump/fragments/jdoe-fix-rotation-frame.rst create mode 100644 tools/changelog/test/integration/03_major_bump/changelog_after.rst create mode 100644 tools/changelog/test/integration/03_major_bump/changelog_before.rst create mode 100644 tools/changelog/test/integration/03_major_bump/fragments/asmith-add-warp-contact-stream.minor.rst create mode 100644 tools/changelog/test/integration/03_major_bump/fragments/blee-rename-articulation-api.major.rst create mode 100644 tools/changelog/test/integration/03_major_bump/fragments/jdoe-fix-articulation-state.rst create mode 100644 tools/changelog/test/integration/README.md create mode 100644 tools/changelog/test/invalid_content/3001.rst create mode 100644 tools/changelog/test/invalid_content/3002.rst create mode 100644 tools/changelog/test/invalid_content/3003.rst create mode 100644 tools/changelog/test/invalid_filenames/1234.notabump.rst create mode 100644 tools/changelog/test/invalid_filenames/multi.dot.slug.rst create mode 100644 tools/changelog/test/test_bump_suffix.py create mode 100644 tools/changelog/test/test_format.py create mode 100644 tools/changelog/test/test_integration.py create mode 100644 tools/changelog/test/test_parse.py create mode 100644 tools/changelog/test/test_validate.py diff --git a/.github/workflows/changelog-check.yml b/.github/workflows/changelog-check.yml new file mode 100644 index 000000000000..945d6139f43b --- /dev/null +++ b/.github/workflows/changelog-check.yml @@ -0,0 +1,48 @@ +# 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 + +name: Changelog Fragment Check + +on: + pull_request: + types: [opened, synchronize, reopened] + workflow_dispatch: + inputs: + base_ref: + description: 'Base branch to diff against' + required: true + default: 'develop' + +concurrency: + group: changelog-check-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + check-fragments: + name: Check changelog fragments + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + # Full history needed to diff against the base branch + fetch-depth: 0 + + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + + - name: Resolve base ref + id: base + run: echo "ref=${{ github.event.inputs.base_ref || github.base_ref }}" >> "$GITHUB_OUTPUT" + + - name: Fetch base branch + run: git fetch origin ${{ steps.base.outputs.ref }} + + - name: Verify changelog fragments + run: python3 tools/changelog/cli.py check ${{ steps.base.outputs.ref }} diff --git a/.github/workflows/nightly-changelog.yml b/.github/workflows/nightly-changelog.yml new file mode 100644 index 000000000000..7e55ad7450c6 --- /dev/null +++ b/.github/workflows/nightly-changelog.yml @@ -0,0 +1,119 @@ +# 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 + +# Nightly auto-compile: rolls accumulated fragments under +# ``source//changelog.d/`` into per-package ``CHANGELOG.rst`` entries, +# bumps each ``extension.toml``, deletes consumed fragments, and pushes the +# result back to ``develop``. Keeps the develop branch's changelog current +# without requiring a maintainer to run ``compile`` by hand. +# +# The push uses ``CHANGELOG_PAT`` (a personal access token / fine-grained +# GitHub App token with ``contents:write`` on this repo) when it's +# available so downstream CI runs on the auto-commit. Falls back to +# ``GITHUB_TOKEN`` — sufficient for the push itself, but pushes signed +# with ``GITHUB_TOKEN`` do not trigger workflow runs on the resulting +# commit, which is by design (avoids infinite loops) but means the +# Docker / docs rebuild won't re-trigger off the nightly's auto-commit. + +name: Nightly Changelog Compilation + +on: + schedule: + # Run nightly at 5 AM UTC (one hour after daily-compatibility, so we + # don't compete for runner capacity). + - cron: '0 5 * * *' + workflow_dispatch: + inputs: + dry_run: + description: 'Preview only — do not commit / push' + required: false + type: boolean + default: false + +concurrency: + # Only one nightly compile may be in flight at a time. ``cancel-in-progress`` + # is intentionally false: if a previous run is still finishing its push, we + # queue rather than abort it mid-commit. + group: nightly-changelog + cancel-in-progress: false + +permissions: + contents: write + +jobs: + compile-changelog: + name: Compile changelog fragments + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + # Operate on develop, not the repo's default branch. Scheduled + # workflows fire from the default branch's workflow file by + # default, but we want the *checkout* to be develop so the + # compile sees develop's accumulated fragments and the push + # writes back to develop. + ref: develop + # Use a PAT so the auto-commit triggers downstream CI; falls back + # to GITHUB_TOKEN which is sufficient for the push itself. + token: ${{ secrets.CHANGELOG_PAT || secrets.GITHUB_TOKEN }} + # Full history so the compiler can resolve each fragment's merge + # time via ``git log --diff-filter=A --first-parent``. + fetch-depth: 0 + + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + + - name: Compile fragments + run: | + ARGS="--all" + if [ "${{ inputs.dry_run }}" = "true" ]; then + ARGS="$ARGS --dry-run" + fi + echo "Running: python3 tools/changelog/cli.py compile $ARGS" + python3 tools/changelog/cli.py compile $ARGS + + - name: Commit and push if fragments were compiled + if: inputs.dry_run != 'true' + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add source/*/changelog.d/ \ + source/*/docs/CHANGELOG.rst \ + source/*/config/extension.toml + if git diff --staged --quiet; then + echo "No changelog fragments found — nothing to commit." + else + # Convention for CI-driven auto-commits on this repo: + # [CI][] + # The leading ``[CI]`` tag groups every machine-driven commit + # (so future automations — auto image rebuilds, auto publish, + # etc. — all share the prefix and are easy to find/filter in + # ``git log --grep``). The second tag names the specific + # action. The trigger event suffix (``schedule`` vs + # ``workflow_dispatch``) is preserved for traceability. + # + # The body lists every package that bumped, derived from the + # staged ``extension.toml`` diff so the entries are accurate + # regardless of which packages happen to have pending + # fragments this run. + MSG_FILE=$(mktemp) + { + echo "[CI][Auto Version Bump] Compile changelog fragments (${{ github.event_name }})" + echo + echo "Bumped packages:" + for tom in $(git diff --staged --name-only -- 'source/*/config/extension.toml'); do + pkg=$(echo "$tom" | sed -E 's|source/([^/]+)/config/extension.toml|\1|') + old=$(git diff --staged "$tom" | awk -F'"' '/^-version/{print $2; exit}') + new=$(git diff --staged "$tom" | awk -F'"' '/^\+version/{print $2; exit}') + echo "- $pkg: $old → $new" + done + } > "$MSG_FILE" + git commit -F "$MSG_FILE" + # Push explicitly to develop so we don't accidentally write + # to the source ref of a workflow_dispatch run. + git push origin HEAD:develop + fi diff --git a/AGENTS.md b/AGENTS.md index 91f858751f50..f331c3c10988 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,23 +89,27 @@ Proper workflow: ## Changelog -- **Update `CHANGELOG.rst` for every change** targeting the source directory. Each extension has its own changelog at `source//docs/CHANGELOG.rst` (e.g. `source/isaaclab/docs/CHANGELOG.rst`, `source/isaaclab_physx/docs/CHANGELOG.rst`). -- **Always create a new version heading.** Never add entries to an existing version — they are released and immutable. Bump the patch version (e.g. `1.5.0` → `1.5.1`) and use today's date. -- **Bump `config/extension.toml` to match.** When creating a new changelog version, update the `version` field in `source//config/extension.toml` to the same version string. -- **Determine which changelog(s) to update** by looking at which `source//` directories your changes touch. A single PR may require entries in multiple changelogs. +- **Do not edit `CHANGELOG.rst` or `config/extension.toml` directly.** Each PR adds a fragment file under `source//changelog.d/`; the changelog and version are compiled by the nightly CI workflow. +- **Add one fragment per touched package.** Pick any short, unique slug for the filename — your branch name (with `/` replaced by `-`) is a good default. The filename suffix declares the bump tier; within a batch the highest tier wins for the package. + + | Filename | Effect | + |---|---| + | `source//changelog.d/.rst` | patch bump | + | `source//changelog.d/.minor.rst` | minor bump | + | `source//changelog.d/.major.rst` | major bump | + | `source//changelog.d/.skip` | no entry, no bump (CI / docs / test-only) | + - Use **past tense** matching the section header: "Added X", "Fixed Y", "Changed Z". - Place entries under the correct category: `Added`, `Changed`, `Deprecated`, `Removed`, or `Fixed`. - Avoid internal implementation details users wouldn't understand. - **For `Deprecated`, `Changed`, and `Removed` entries, include migration guidance.** - Example: "Deprecated `Articulation.A` in favor of `Articulation.B`." +- **Breaking changes** belong in `Changed`, prefixed with `**Breaking:**`. - Use Sphinx cross-reference roles for class/method/module names. ### RST formatting reference ``` -X.Y.Z (YYYY-MM-DD) -~~~~~~~~~~~~~~~~~~ - Added ^^^^^ @@ -119,10 +123,10 @@ Fixed ``` Key formatting rules: -- Version heading: underline with `~` (tildes), must be at least as long as the heading text. -- Category heading: underline with `^` (carets). +- Category heading: underline with `^` (carets), at least as long as the heading text. - Entries: `* ` prefix, continuation lines indented by 2 spaces. -- Blank line between the last entry and the next version heading. + +See `tools/changelog/test/integration/` for worked examples that double as integration-test fixtures. ## Commit and Pull Request Guidelines diff --git a/source/isaaclab/changelog.d/.gitkeep b/source/isaaclab/changelog.d/.gitkeep new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_assets/changelog.d/.gitkeep b/source/isaaclab_assets/changelog.d/.gitkeep new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_contrib/changelog.d/.gitkeep b/source/isaaclab_contrib/changelog.d/.gitkeep new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_experimental/changelog.d/.gitkeep b/source/isaaclab_experimental/changelog.d/.gitkeep new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_mimic/changelog.d/.gitkeep b/source/isaaclab_mimic/changelog.d/.gitkeep new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_newton/changelog.d/.gitkeep b/source/isaaclab_newton/changelog.d/.gitkeep new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_ov/changelog.d/.gitkeep b/source/isaaclab_ov/changelog.d/.gitkeep new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_physx/changelog.d/.gitkeep b/source/isaaclab_physx/changelog.d/.gitkeep new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_rl/changelog.d/.gitkeep b/source/isaaclab_rl/changelog.d/.gitkeep new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_tasks/changelog.d/.gitkeep b/source/isaaclab_tasks/changelog.d/.gitkeep new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_teleop/changelog.d/.gitkeep b/source/isaaclab_teleop/changelog.d/.gitkeep new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tools/changelog/cli.py b/tools/changelog/cli.py new file mode 100644 index 000000000000..16ad551b4ca3 --- /dev/null +++ b/tools/changelog/cli.py @@ -0,0 +1,1009 @@ +# 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 + +"""Manage changelog fragments — single entry point with two subcommands. + +Each PR drops a fragment under ``source//changelog.d/.rst``. +The slug is any short, unique name — the contributor's branch name (with +``/`` replaced by ``-``) is the recommended default. The file mirrors +the RST that will appear in the changelog — one or more section headings +(``Added``, ``Changed``, ``Deprecated``, ``Removed``, ``Fixed``) each +underlined with ``^``. The **filename suffix** declares the bump tier: + +- ``.rst`` — patch bump. +- ``.minor.rst`` — minor bump. +- ``.major.rst`` — major bump. +- ``.skip`` — no entry, no bump. + +When a batch compiles together, the highest declared bump wins for the +package (one ``.major.rst`` anywhere → major). + +Subcommands: + + check PR gate. Verifies every modified package has a valid fragment. + compile Roll accumulated fragments into ``CHANGELOG.rst`` and bump + ``extension.toml``. Run by the nightly workflow + (``.github/workflows/nightly-changelog.yml``) on a cron and + by maintainers manually when cutting a release. + +Usage: + + # ── check ───────────────────────────────────────────────────── + # CI invocation on every pull_request: + cli.py check + + # ── compile ─────────────────────────────────────────────────── + # Normal release-time invocation — bump every managed package + # from accumulated fragments, write entries, delete fragments: + cli.py compile --all + + # Preview only (no writes, no deletes): + cli.py compile --all --dry-run + + # Pin one package to a specific version (single-package only — + # each managed package has its own version trajectory): + cli.py compile --package isaaclab --version 4.7.0 + + # Preview against a worked example without touching real packages: + cli.py compile --package isaaclab --dry-run \\ + --fragments-dir tools/changelog/test/integration/02_minor_bump/fragments + +For big version jumps (e.g. ``2.1`` → ``4.7``) edit +``source//config/extension.toml`` and prepend a manual entry to +``source//docs/CHANGELOG.rst``. The compiler is for fragment-driven +incremental bumps, not for jumps. +""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from dataclasses import dataclass, field +from datetime import date +from functools import cached_property +from pathlib import Path +from typing import ClassVar + +# Walk three levels up: tools/changelog/cli.py -> tools/changelog/ -> tools/ -> repo root. +REPO_ROOT = Path(__file__).parent.parent.parent +PACKAGES_ROOT = REPO_ROOT / "source" + +# Recognised fragment filename patterns. ```` is any short identifier +# the contributor chose — typically their branch name with ``/`` replaced by +# ``-``. The slug must not contain ``.`` (reserved for the tier suffix) or +# ``/`` (path separator), but otherwise mirrors what git allows in a ref name. +# These regexes live at module level because Fragment, FragmentBatch, and +# PRDiff all match against them — they are the wire-format contract between +# contributors and the gate. +FRAGMENT_RE = re.compile(r"^(?P[^./][^./]*)(?:\.(?Pminor|major))?\.rst$") +SKIP_RE = re.compile(r"^(?P[^./][^./]*)\.skip$") + + +def _display_path(p: Path) -> str: + """Pretty-print a Path. Strips ``REPO_ROOT`` if ``p`` is inside the repo, + falls back to the absolute path otherwise (``--fragments-dir`` may + legitimately point at an external directory like ``/tmp/...``). + + Lives at module level because both :class:`Package` (writing on-disk + paths) and :class:`FragmentBatch` (warning about external fragment + paths) use it. + """ + try: + return str(p.relative_to(REPO_ROOT)) + except ValueError: + return str(p) + + +# --------------------------------------------------------------------------- +# Domain objects +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class Version: + """A semver-style version string ``X.Y.Z`` (optionally suffixed with ``.devN``). + + Models a version as a value object: immutable, comparable by its text, + knows how to produce a bumped successor. PEP 440 ``.devN`` suffixes + are tolerated on the way *in* (stripped before bumping) but never + written back out — :meth:`bumped` always returns a clean ``X.Y.Z``. + + Construction validates the format up front so that an invalid + ``--version`` flag from the CLI fails fast instead of silently writing + a malformed entry to ``CHANGELOG.rst``. + """ + + # ``X.Y.Z`` with an optional PEP 440 ``.devN`` suffix. The suffix is + # tolerated on the way *in* (e.g. when reading a stale dev version out + # of an existing ``extension.toml``) but :meth:`bumped` always strips + # it before producing a successor. + _SEMVER_RE: ClassVar[re.Pattern[str]] = re.compile(r"^\d+\.\d+\.\d+(\.dev\d+)?$") + + text: str + + def __post_init__(self) -> None: + if not self._SEMVER_RE.match(self.text): + raise ValueError(f"Invalid version {self.text!r}; expected X.Y.Z (optionally suffixed with .devN)") + + def bumped(self, tier: str) -> Version: + """Return a new Version one tier ahead of this one. + + ``tier`` is ``'major'``, ``'minor'``, or ``'patch'``. Major zeros + the minor and patch components; minor zeros patch. Any ``.devN`` + suffix on the current version is stripped before bumping. + """ + # __post_init__ guarantees the format, so this split is safe. + parts = self.text.split(".dev")[0].split(".") + if tier == "major": + return Version(f"{int(parts[0]) + 1}.0.0") + if tier == "minor": + return Version(f"{parts[0]}.{int(parts[1]) + 1}.0") + return Version(f"{parts[0]}.{parts[1]}.{int(parts[2]) + 1}") + + def __str__(self) -> str: + return self.text + + +@dataclass(frozen=True) +class Fragment: + """One fragment file in a package's ``changelog.d/`` (or an examples dir). + + A :class:`Fragment` instance is just a path plus methods that interpret + it as a changelog fragment. ``.gitkeep`` and ``*.skip`` files should + not be wrapped — only files matching :data:`FRAGMENT_RE`. + """ + + path: Path + + @property + def name(self) -> str: + return self.path.name + + @cached_property + def _match(self) -> re.Match[str] | None: + return FRAGMENT_RE.match(self.name) + + @property + def is_valid_filename(self) -> bool: + return self._match is not None + + @property + def bump(self) -> str: + """Bump tier declared by the filename suffix (defaults to ``'patch'``).""" + if self._match and self._match.group("bump"): + return self._match.group("bump") + return "patch" + + def parse(self) -> dict[str, list[str]]: + """Return ``{section: [lines]}`` from this fragment's content. + + Lines are kept as-is (including trailing newlines) so the compiled + output is byte-for-byte identical to what the contributor wrote. A + section heading is a non-empty line followed by ``^`` underline of + equal-or-greater length. + """ + text = self.path.read_text(encoding="utf-8") + lines = text.splitlines(keepends=True) + sections: dict[str, list[str]] = {} + current: str | None = None + buf: list[str] = [] + + i = 0 + while i < len(lines): + raw = lines[i] + stripped = raw.rstrip("\n") + if ( + i + 1 < len(lines) + and stripped + and re.fullmatch(r"\^+", lines[i + 1].rstrip("\n")) + and len(lines[i + 1].rstrip("\n")) >= len(stripped) + ): + if current is not None: + sections[current] = self._strip_trailing_blank(buf) + current = stripped + buf = [] + i += 2 # skip heading + underline + if i < len(lines) and not lines[i].strip(): + i += 1 + continue + if current is not None: + buf.append(raw) + i += 1 + + if current is not None: + sections[current] = self._strip_trailing_blank(buf) + + return sections + + @staticmethod + def _strip_trailing_blank(lines: list[str]) -> list[str]: + """Drop trailing blank lines from a section's raw line buffer.""" + while lines and not lines[-1].strip(): + lines.pop() + return lines + + @staticmethod + def parse_slug(filename: str) -> str | None: + """Return the slug declared by a fragment / skip filename, or ``None``. + + Used by :class:`PRDiff` to detect collisions between an added + fragment's slug and an existing fragment in the same directory, + without needing to materialise a :class:`Fragment` (the diff entry + may not exist on disk yet during a gate run). + """ + m = FRAGMENT_RE.match(filename) or SKIP_RE.match(filename) + return m.group("slug") if m else None + + def merge_time(self) -> int: + """Unix timestamp of the merge commit that introduced this fragment. + + Uses ``git log --diff-filter=A --first-parent`` to follow develop's + first-parent history, so the timestamp reflects when the PR's merge + commit landed (not the feature-branch commit that originally added + the file). Falls back to the file's most recent commit time when + not yet in first-parent history (e.g. local dry-runs on a feature + branch), and ultimately to ``0`` if git is unavailable. + """ + for cmd in ( + ["git", "log", "--diff-filter=A", "--first-parent", "-1", "--format=%ct", "--", str(self.path)], + ["git", "log", "-1", "--format=%ct", "--", str(self.path)], + ): + try: + result = subprocess.run(cmd, capture_output=True, text=True, check=True, cwd=REPO_ROOT) + ts = result.stdout.strip() + if ts: + return int(ts) + except (subprocess.CalledProcessError, ValueError): + continue + return 0 + + def validate(self) -> str | None: + """Return a human-readable error string if malformed, else ``None``. + + Filename rules: must match :data:`FRAGMENT_RE` (``.gitkeep`` and + ``*.skip`` files are filtered out at :meth:`FragmentBatch.from_dir` + level and never reach this method). Content rules (for ``*.rst`` + fragments only): non-empty file with at least one valid section + heading and at least one bullet point. + """ + if not self.is_valid_filename: + return ( + "invalid filename — must be .rst, .minor.rst, " + ".major.rst, or .skip (slug = your branch name " + "with `/` replaced by `-`, no dots)" + ) + if not self.path.exists(): + # Deleted fragments don't need validating (consumed by a previous compile). + return None + text = self.path.read_text(encoding="utf-8") + if not text.strip(): + return "fragment is empty" + sections = self.parse() + if not sections: + return ( + "no recognised section headings (expected one or more of " + "Added / Changed / Deprecated / Removed / Fixed, each underlined " + "with carets ``^`` of equal-or-greater length)" + ) + # Every declared section must carry at least one bullet — otherwise + # the compiled output emits a heading with no body, which is both + # ugly and almost certainly a contributor authoring mistake (typed + # the heading, forgot the bullet). + empty = [s for s, lines in sections.items() if not any(line.lstrip().startswith("*") for line in lines)] + if empty: + return ( + f"section(s) {', '.join(repr(s) for s in empty)} have no bullet entries — " + "use ``* `` to start each entry, or remove the heading" + ) + return None + + +@dataclass(frozen=True) +class FragmentBatch: + """A collection of fragments collected from a directory. + + ``valid`` are :class:`Fragment` instances sorted by merge time + (oldest first). ``invalid`` are paths that don't match any recognised + filename pattern — surfaced so the caller can warn or fail. ``.skip`` + and ``.gitkeep`` files are tolerated but excluded from both lists. + + Holds the pure-data class methods that turn a batch (or a synthetic + list of bumps / sections) into a compiled changelog entry. The + instance methods (:meth:`aggregate_bump`, :meth:`merged_sections`, + :meth:`compile_to_entry`) read the batch's own state; the + underscore-prefixed static methods (:meth:`_aggregate`, etc.) are + the underlying pure transformations and are used directly by tests + that exercise edge cases without a real fragments directory. + """ + + # Canonical ordering of section headings in compiled output. Anything + # not listed here keeps insertion order *after* these. + _SECTION_ORDER: ClassVar[list[str]] = ["Added", "Changed", "Deprecated", "Removed", "Fixed"] + + # Strict ordering of bump tiers (``major`` strictly outranks ``minor`` + # outranks ``patch``). Unrecognised tiers sort below ``patch``. + _BUMP_RANK: ClassVar[dict[str, int]] = {"patch": 0, "minor": 1, "major": 2} + + valid: list[Fragment] + invalid: list[Path] + skip_paths: list[Path] = field(default_factory=list) + + # ---- Construction -------------------------------------------------- + + @classmethod + def from_dir(cls, fragment_dir: Path) -> FragmentBatch: + if not fragment_dir.is_dir(): + return cls([], []) + valid: list[Fragment] = [] + invalid: list[Path] = [] + skips: list[Path] = [] + for p in fragment_dir.iterdir(): + if p.is_dir() or p.name == ".gitkeep": + continue + if SKIP_RE.match(p.name): + skips.append(p) + continue + f = Fragment(p) + if f.is_valid_filename: + valid.append(f) + else: + invalid.append(p) + # Sort by merge time, breaking ties on filename so the compiled output + # is deterministic when fragments share a merge commit (or when none + # are in git history yet — e.g. a local dry-run against test fixtures). + valid.sort(key=lambda f: (f.merge_time(), f.name)) + return cls(valid, invalid, skips) + + # ---- Queries against this batch's state --------------------------- + + def aggregate_bump(self) -> str: + """Highest bump tier declared by fragments that parsed to content. + + Empty fragments (which the compiler warns about and skips) are + excluded so they don't influence the version. Defaults to + ``patch`` if nothing parsed. + """ + return self._aggregate([f.bump for f, _ in self.parsed()]) + + def parsed(self) -> list[tuple[Fragment, dict[str, list[str]]]]: + """Return ``(fragment, sections)`` pairs, dropping fragments that parse empty.""" + return [(f, s) for f, s in ((f, f.parse()) for f in self.valid) if s] + + def merged_sections(self) -> dict[str, list[str]]: + """Cross-fragment merged section map for this batch.""" + return self._merge_sections([s for _, s in self.parsed()]) + + def compile_to_entry( + self, + current_version: Version, + *, + explicit_version: Version | None = None, + ) -> tuple[Version, str, str]: + """Return ``(new_version, bump_label, entry_text)`` for this batch. + + ``new_version`` is either ``explicit_version`` verbatim or the + result of bumping ``current_version`` by the aggregated tier. + ``bump_label`` is a human-readable suffix like ``" (bump: minor)"`` + for log lines (empty when ``explicit_version`` is used). + ``entry_text`` is the rendered RST block ready to prepend to a + ``CHANGELOG.rst``. Pure computation — no I/O. + """ + if explicit_version is not None: + new_version = explicit_version + bump_label = "" + else: + chosen_bump = self.aggregate_bump() + new_version = current_version.bumped(chosen_bump) + bump_label = f" (bump: {chosen_bump})" + entry = self._format_entry(new_version.text, self.merged_sections()) + return new_version, bump_label, entry + + # ---- Cleanup ------------------------------------------------------- + + def delete_all(self) -> tuple[int, int]: + """Delete every consumed fragment + skip file. Returns ``(n_frag, n_skip)``.""" + n_frag = self.delete_valid() + n_skip = self.delete_skips() + return n_frag, n_skip + + def delete_valid(self) -> int: + for f in self.valid: + f.path.unlink() + return len(self.valid) + + def delete_skips(self) -> int: + for p in self.skip_paths: + p.unlink() + return len(self.skip_paths) + + # ---- Pure transformations (the data class methods) ---------------- + # Static so callers and tests can exercise them with synthetic + # primitives — no FragmentBatch instance needed when the question + # is "given these tiers, which wins?" or "how do these dicts merge?" + + @classmethod + def _aggregate(cls, bumps: list[str]) -> str: + """Highest-ranked bump from ``bumps`` (``major > minor > patch``). + + An empty list defaults to ``'patch'``. + """ + if not bumps: + return "patch" + return max(bumps, key=lambda b: cls._BUMP_RANK.get(b, -1)) + + @staticmethod + def _merge_sections(fragments: list[dict[str, list[str]]]) -> dict[str, list[str]]: + """Merge multiple parsed fragments into a single section map. + + Bullets from different fragments that share a section heading are + concatenated directly (no blank line between them) to match the + dominant style in IsaacLab's existing ``CHANGELOG.rst`` files. + """ + merged: dict[str, list[str]] = {} + for frag in fragments: + for section, lines in frag.items(): + if section not in merged: + merged[section] = list(lines) + else: + merged[section].extend(lines) + return merged + + @classmethod + def _format_entry(cls, version: str, sections: dict[str, list[str]]) -> str: + """Return a complete RST version entry, ready to prepend to ``CHANGELOG.rst``. + + Sections appear in :attr:`_SECTION_ORDER` (Added, Changed, + Deprecated, Removed, Fixed). Anything else keeps insertion order + *after* the canonical ones. + """ + today = date.today().strftime("%Y-%m-%d") + heading = f"{version} ({today})" + out = [heading, "~" * len(heading), ""] + + ordered = [s for s in cls._SECTION_ORDER if s in sections] + extras = [s for s in sections if s not in cls._SECTION_ORDER] + + for section in ordered + extras: + out.append(section) + out.append("^" * len(section)) + out.append("") + for line in sections[section]: + out.append(line.rstrip("\n")) + out.append("") + + return "\n".join(out) + "\n" + + +@dataclass(frozen=True) +class Package: + """A source// directory the changelog tool can manage. + + A package is "managed" if it has both a ``config/extension.toml`` (the + version file the compiler bumps) and a ``docs/CHANGELOG.rst`` (the + file the compiler updates). :meth:`discover` returns only managed + packages; instances created directly may not be managed (use + :attr:`is_managed`). + """ + + root: Path + + @property + def name(self) -> str: + return self.root.name + + @property + def changelog_path(self) -> Path: + return self.root / "docs" / "CHANGELOG.rst" + + @property + def toml_path(self) -> Path: + return self.root / "config" / "extension.toml" + + @property + def default_fragment_dir(self) -> Path: + return self.root / "changelog.d" + + @property + def is_managed(self) -> bool: + return self.toml_path.is_file() and self.changelog_path.is_file() + + def current_version(self) -> Version: + for line in self.toml_path.read_text(encoding="utf-8").splitlines(): + m = re.match(r'^version\s*=\s*"([^"]+)"', line) + if m: + return Version(m.group(1)) + raise ValueError(f"No version field found in {self.toml_path}") + + def write_changelog_entry(self, entry: str, *, dry_run: bool) -> None: + text = self.changelog_path.read_text(encoding="utf-8") + m = re.search(r"^Changelog\n-+\s*\n\s*\n", text, re.MULTILINE) + if not m: + raise ValueError(f"Could not locate changelog header in {self.changelog_path}") + updated = text[: m.end()] + entry + "\n" + text[m.end() :] + if dry_run: + print(f"\n{'=' * 60}") + print(f"DRY RUN — would write to {_display_path(self.changelog_path)}") + print(f"{'=' * 60}") + print(entry) + else: + self.changelog_path.write_text(updated, encoding="utf-8") + + def write_version(self, new_version: Version, *, dry_run: bool) -> None: + text = self.toml_path.read_text(encoding="utf-8") + updated = re.sub(r'^version\s*=\s*"[^"]+"', f'version = "{new_version}"', text, flags=re.MULTILINE) + if dry_run: + print(f'DRY RUN — would set version = "{new_version}" in {_display_path(self.toml_path)}') + else: + self.toml_path.write_text(updated, encoding="utf-8") + + @classmethod + def from_name(cls, name: str, packages_root: Path = PACKAGES_ROOT) -> Package: + return cls(packages_root / name) + + @classmethod + def discover(cls, packages_root: Path = PACKAGES_ROOT) -> list[Package]: + """Return all managed packages under ``packages_root``, sorted by name.""" + if not packages_root.is_dir(): + return [] + return sorted( + (cls(child) for child in packages_root.iterdir() if child.is_dir() and cls(child).is_managed), + key=lambda p: p.name, + ) + + def compile( + self, + *, + fragments_dir: Path | None = None, + explicit_version: Version | None = None, + dry_run: bool = False, + ) -> bool: + """Compile fragments for this package. Returns True if any were compiled. + + There are exactly two modes: ``dry_run=True`` previews and writes + nothing; ``dry_run=False`` writes the new entry, bumps the version, + **and** deletes the consumed fragments. There is deliberately no + third "write but keep fragments" mode — leaving fragments in place + after a real compile is a footgun (the next compile would re-emit + them as a duplicate version block). + + Args: + fragments_dir: Read fragments from here instead of + :attr:`default_fragment_dir`. Useful for previewing against + example fixtures. + explicit_version: Pin the new version to this string (skips the + per-fragment bump inference). + dry_run: Preview only — no files are written or deleted. + """ + batch = FragmentBatch.from_dir(self._resolve_fragments_dir(fragments_dir)) + + for p in batch.invalid: + print( + f" WARNING: {_display_path(p)} does not match any recognised fragment " + "pattern (.rst, .minor.rst, .major.rst, .skip) — skipping.", + file=sys.stderr, + ) + + if not batch.valid: + if batch.skip_paths: + n = len(batch.skip_paths) + if dry_run: + print(f" {self.name}: would clean {n} stale skip file(s).") + else: + batch.delete_skips() + print(f" {self.name}: cleaned {n} stale skip file(s).") + else: + print(f" {self.name}: no fragments, skipping.") + return False + + # Apply the same content-validation rules the PR gate uses, so a + # malformed fragment that somehow reached this package (e.g. a + # stale fragment that predates a content-rule tightening, or a + # locally-edited file) doesn't silently produce a half-empty + # version block. Runs every fragment that survived filename + # validation in ``from_dir``. + validation_errors = [(f, err) for f in batch.valid if (err := f.validate()) is not None] + if validation_errors: + for f, err in validation_errors: + print(f" ERROR: {_display_path(f.path)}: {err}", file=sys.stderr) + raise ValueError( + f"{self.name}: {len(validation_errors)} fragment(s) failed content validation; " + "fix or remove them before compiling." + ) + + parsed_pairs = batch.parsed() + if not parsed_pairs: + print(f" {self.name}: all fragments empty after parsing, skipping.") + return False + + new_version, bump_label, entry = batch.compile_to_entry( + self.current_version(), explicit_version=explicit_version + ) + print(f" {self.name}: {len(parsed_pairs)} fragment(s) → version {new_version}{bump_label}") + + if not self.changelog_path.exists(): + # Should never happen with managed packages discovered via + # ``Package.discover()`` — defensive check for callers that + # construct a ``Package`` directly with an unmanaged root. + raise ValueError( + f"{_display_path(self.changelog_path)} does not exist; " + f"package {self.name!r} is not managed (missing CHANGELOG.rst)." + ) + self.write_changelog_entry(entry, dry_run=dry_run) + self.write_version(new_version, dry_run=dry_run) + + if not dry_run: + n_frag, n_skip = batch.delete_all() + msg = f" {self.name}: deleted {n_frag} fragment(s)" + if n_skip: + msg += f" and {n_skip} skip file(s)" + print(msg + ".") + + return True + + def _resolve_fragments_dir(self, override: Path | None) -> Path: + """Pick the directory ``compile`` should read fragments from. + + ``None`` means "use this package's own ``changelog.d/``"; an + absolute path is used as-is; a relative path is resolved against + ``REPO_ROOT`` so callers can pass things like + ``tools/changelog/test/integration/01_patch_bump/fragments`` without + worrying about the cwd. + """ + if override is None: + return self.default_fragment_dir + return override if override.is_absolute() else (REPO_ROOT / override).resolve() + + +@dataclass(frozen=True) +class PRDiff: + """A snapshot of "what this PR changed against its base branch." + + Wraps two views from the same git diff: ``changed`` (any file modified + or added) and ``added`` (the strict subset that's new on this branch). + Tests construct ``PRDiff`` directly with synthetic sets; + :meth:`from_git` runs the real ``git diff`` for production use. + """ + + changed: set[str] + added: set[str] + + @classmethod + def from_git(cls, base_ref: str) -> PRDiff: + """Run ``git diff`` against ``origin/...HEAD`` to populate the diff.""" + + def _diff(extra_args: list[str]) -> set[str]: + result = subprocess.run( + ["git", "diff", "--name-only", *extra_args, f"origin/{base_ref}...HEAD"], + capture_output=True, + text=True, + check=True, + cwd=REPO_ROOT, + ) + return {f for f in result.stdout.splitlines() if f} + + return cls(changed=_diff([]), added=_diff(["--diff-filter=A"])) + + def evaluate( + self, + packages: list[Package], + ) -> tuple[list[str], list[tuple[str, str]]]: + """Apply the PR-gate rules and return ``(missing_packages, invalid_fragments)``. + + Rules: + + 1. **Immutability** — every fragment file in the diff must be in + ``added`` (added on this branch). Modifying or renaming an existing + fragment is rejected with a hint to add a new one instead. + + 2. **Content validity** — every added ``*.rst`` fragment must parse + (recognised section headings + at least one bullet). ``.skip`` and + ``.gitkeep`` are exempt. + + 3. **Slug uniqueness** — within a package's ``changelog.d/``, no two + fragments may share the same slug. If an added fragment's slug + collides with an existing or co-added fragment, fail with a hint + to rename (e.g. append ``-2``). + + 4. **Required fragment per touched package** — for each managed + package the PR touches in ``source/`` (outside ``changelog.d/``), + the PR must *add* at least one valid fragment to that package's + ``changelog.d/``. Chained PRs (parent PR's fragment shows up in + the child's diff) naturally satisfy this — slug uniqueness is + the only constraint that matters. + """ + missing: list[str] = [] + invalid_fragments: list[tuple[str, str]] = [] + + for pkg in packages: + pkg_prefix = f"source/{pkg.name}/" + changelog_dir = f"source/{pkg.name}/changelog.d/" + + source_changed = [f for f in self.changed if f.startswith(pkg_prefix) and not f.startswith(changelog_dir)] + fragment_changes = [f for f in self.changed if f.startswith(changelog_dir)] + + # Map *pre-existing* fragments in the package's changelog.d/ by slug, + # for the uniqueness check below. The CI checkout contains both + # base-branch fragments and the PR's additions side by side, so we + # must explicitly exclude added files — otherwise an added file can + # overwrite the entry for a colliding pre-existing fragment with + # the same slug, hiding the very collision we're trying to detect. + # Skip ``.gitkeep`` and unrecognised filenames — they can't collide. + added_basenames = {Path(f).name for f in self.added if f.startswith(changelog_dir)} + existing_slugs: dict[str, str] = {} + existing_dir = pkg.default_fragment_dir + if existing_dir.is_dir(): + for p in existing_dir.iterdir(): + if p.is_dir() or p.name == ".gitkeep" or p.name in added_basenames: + continue + slug = Fragment.parse_slug(p.name) + if slug is not None: + existing_slugs[slug] = p.name + + added_slugs: dict[str, str] = {} + for f in fragment_changes: + path = Path(f) + if path.name == ".gitkeep": + continue + + # Rule 1: immutability — modifying an existing fragment is forbidden. + if f not in self.added: + invalid_fragments.append( + ( + f, + "fragments are immutable — add a new fragment with a different slug " + "instead of editing an existing one", + ) + ) + continue + + # Rule 2: content validity (only for *.rst, not *.skip). + if not SKIP_RE.match(path.name): + err = Fragment(REPO_ROOT / f).validate() + if err: + invalid_fragments.append((f, err)) + continue + + # Rule 3: slug uniqueness within the package's changelog.d/. + slug = Fragment.parse_slug(path.name) + if slug is None: + # Filename validation already flagged this above for *.rst, + # but a malformed *.skip would slip through. Surface it. + invalid_fragments.append( + (f, "invalid filename — must be .rst, .minor.rst, .major.rst, or .skip") + ) + continue + if slug in existing_slugs and existing_slugs[slug] != path.name: + invalid_fragments.append( + ( + f, + f"slug {slug!r} collides with existing fragment " + f"{existing_slugs[slug]!r} — rename to {slug}-2 (or any unused slug)", + ) + ) + continue + if slug in added_slugs and added_slugs[slug] != path.name: + invalid_fragments.append( + ( + f, + f"slug {slug!r} collides with another added fragment " + f"{added_slugs[slug]!r} — rename one to {slug}-2 (or any unused slug)", + ) + ) + continue + added_slugs[slug] = path.name + + if not source_changed: + continue + + # Rule 4: this PR must add at least one valid fragment for the package. + owned = [ + f + for f in fragment_changes + if f in self.added and (FRAGMENT_RE.match(Path(f).name) or SKIP_RE.match(Path(f).name)) + ] + if not owned: + missing.append(pkg.name) + + return missing, invalid_fragments + + +# --------------------------------------------------------------------------- +# Subcommand handlers +# --------------------------------------------------------------------------- + + +def cmd_compile(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int: + if args.fragments_dir is not None and args.all: + parser.error("--fragments-dir requires --package (it cannot apply to all packages at once)") + if args.version is not None and args.all: + parser.error( + "--version requires --package (each managed package has its own version trajectory; " + "pin one with --package )" + ) + # Validate ``--version`` shape up front so a typo like ``--version 4.7`` + # fails at argument parsing instead of silently writing ``4.7`` into + # ``CHANGELOG.rst`` and ``extension.toml``. + explicit_version: Version | None = None + if args.version is not None: + try: + explicit_version = Version(args.version) + except ValueError as e: + parser.error(f"--version: {e}") + + if args.package: + pkg = Package.from_name(args.package) + if not pkg.root.is_dir(): + parser.error(f"--package {args.package!r}: directory not found at {pkg.root}") + if not pkg.is_managed: + parser.error( + f"--package {args.package!r} is not managed: missing config/extension.toml or " + f"docs/CHANGELOG.rst at {pkg.root}. Run with --all to see the discovered list." + ) + packages = [pkg] + else: + packages = Package.discover() + + any_compiled = False + for pkg in packages: + try: + compiled = pkg.compile( + fragments_dir=args.fragments_dir, + explicit_version=explicit_version, + dry_run=args.dry_run, + ) + except (FileNotFoundError, ValueError) as e: + print(f" ERROR: {e}", file=sys.stderr) + return 1 + any_compiled = any_compiled or compiled + + if not any_compiled: + print("No fragments found in any package.") + return 0 + + +def cmd_check(args: argparse.Namespace, _parser: argparse.ArgumentParser) -> int: + try: + diff = PRDiff.from_git(args.base_ref) + except subprocess.CalledProcessError as e: + print(f"ERROR: git diff failed: {e.stderr}", file=sys.stderr) + return 1 + + missing, invalid_fragments = diff.evaluate(Package.discover()) + + if invalid_fragments: + print("::error::Invalid changelog fragment(s) in this PR:") + for path, reason in invalid_fragments: + print(f" • {path}") + print(f" → {reason}") + print() + + if missing: + print("::error::Missing changelog fragments for the following packages:") + for pkg_name in missing: + print(f" • {pkg_name}") + print(f" → add source/{pkg_name}/changelog.d/.rst (patch bump)") + print(f" → or source/{pkg_name}/changelog.d/.minor.rst (minor bump)") + print(f" → or source/{pkg_name}/changelog.d/.major.rst (major bump)") + print(f" → or source/{pkg_name}/changelog.d/.skip (no entry, no bump)") + print() + print("Slug = your branch name with `/` replaced by `-` (or any short, unique name).") + print() + print("Fragment format (source//changelog.d/[.minor|.major].rst):") + print() + print(" Added") + print(" ^^^^^") + print() + print(" * Added :class:`~pkg.Foo` for feature X.") + print() + print(" Fixed") + print(" ^^^^^") + print() + print(" * Fixed edge case in :meth:`~pkg.Foo.bar`.") + print() + print("See AGENTS.md ## Changelog for full guidance.") + + if invalid_fragments or missing: + return 1 + + print("✓ All modified packages have valid changelog fragments.") + return 0 + + +# --------------------------------------------------------------------------- +# CLI entry point +# --------------------------------------------------------------------------- + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + # The module docstring carries the full usage walkthrough — surfacing + # it as the parser description means ``cli.py --help`` shows the same + # guidance someone reading the source would see. + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + sub = parser.add_subparsers(dest="cmd", required=True, metavar="{compile,check}") + + p_compile = sub.add_parser( + "compile", + help="Compile fragments into CHANGELOG.rst (maintainer release-time tool).", + description="Compile accumulated fragments into per-package CHANGELOG.rst entries and bump extension.toml.", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + p_compile.set_defaults(func=cmd_compile) + + # ── Target: which packages to compile (required, mutually exclusive) ── + target = p_compile.add_argument_group("target", "Which package(s) to compile (required, mutually exclusive)") + target_group = target.add_mutually_exclusive_group(required=True) + target_group.add_argument("--package", metavar="NAME", help="Compile a single package.") + target_group.add_argument("--all", action="store_true", help="Compile all managed packages.") + + # ── Version source: by default inferred from filename suffixes ──────── + version_group = p_compile.add_argument_group( + "version (optional)", + "By default the new version is inferred from the filename suffixes of the consumed fragments.", + ) + version_group.add_argument( + "--version", + metavar="X.Y.Z", + help=( + "Pin the package to an explicit version, skipping the per-fragment bump inference. " + "Requires --package — each managed package has its own version trajectory and " + "applying a single version to all of them would corrupt their independent histories." + ), + ) + + # ── Execution mode: preview vs apply, where to read fragments from ──── + exec_group = p_compile.add_argument_group("execution") + exec_group.add_argument( + "--dry-run", + action="store_true", + help=( + "Preview only — no files are written or deleted. Without this flag, " + "the compile writes the new entry, bumps the version, and deletes " + "the consumed fragments." + ), + ) + exec_group.add_argument( + "--fragments-dir", + type=Path, + default=None, + metavar="PATH", + help=( + "Override the directory to read fragments from " + "(default: source//changelog.d/). " + "Useful for previewing against example fragments without touching real ones. " + "Only valid with --package." + ), + ) + + p_check = sub.add_parser( + "check", + help="Verify each modified package has a valid fragment (PR gate).", + description="Verify each modified package has a valid changelog fragment.", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + p_check.set_defaults(func=cmd_check) + p_check.add_argument( + "base_ref", + help=( + "Base branch to diff against (e.g. 'main' or 'develop'). " + "The diff is taken against ``origin/...HEAD``." + ), + ) + + return parser + + +def main() -> None: + parser = _build_parser() + args = parser.parse_args() + sys.exit(args.func(args, parser)) + + +if __name__ == "__main__": + main() diff --git a/tools/changelog/pyproject.toml b/tools/changelog/pyproject.toml new file mode 100644 index 000000000000..6033f35f178c --- /dev/null +++ b/tools/changelog/pyproject.toml @@ -0,0 +1,13 @@ +# Scopes pytest to this self-contained tool: the ``[tool.pytest.ini_options]`` +# section makes pytest treat ``tools/changelog/`` as its rootdir, so: +# +# 1. ``pythonpath = ["."]`` adds ``tools/changelog/`` to ``sys.path``, +# making ``import cli`` work from the test files without any shim. +# 2. ``tools/conftest.py`` (a session-takeover hook for the IsaacLab +# source/ test suite) sits *above* rootdir and is therefore not +# loaded — no ``--noconftest`` flag required. +# +# Run with: ``./isaaclab.sh -p -m pytest tools/changelog/`` +[tool.pytest.ini_options] +pythonpath = ["."] +testpaths = ["test"] diff --git a/tools/changelog/test/integration/01_patch_bump/changelog_after.rst b/tools/changelog/test/integration/01_patch_bump/changelog_after.rst new file mode 100644 index 000000000000..199771149d62 --- /dev/null +++ b/tools/changelog/test/integration/01_patch_bump/changelog_after.rst @@ -0,0 +1,25 @@ +Changelog +--------- + +1.2.4 (2026-04-30) +~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Tightened error message in :class:`~example.Foo` when a required argument is missing. + +Fixed +^^^^^ + +* Fixed missing GPU sync in :func:`~example.refresh_buffers` that occasionally returned stale data. +* Fixed off-by-one in :meth:`~example.Foo.bar` when the input list was empty. + + +1.2.3 (2026-01-15) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :class:`~example.OldThing` for an earlier feature. diff --git a/tools/changelog/test/integration/01_patch_bump/changelog_before.rst b/tools/changelog/test/integration/01_patch_bump/changelog_before.rst new file mode 100644 index 000000000000..81dc30f240c3 --- /dev/null +++ b/tools/changelog/test/integration/01_patch_bump/changelog_before.rst @@ -0,0 +1,10 @@ +Changelog +--------- + +1.2.3 (2026-01-15) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :class:`~example.OldThing` for an earlier feature. diff --git a/tools/changelog/test/integration/01_patch_bump/fragments/asmith-fix-collision-margin.rst b/tools/changelog/test/integration/01_patch_bump/fragments/asmith-fix-collision-margin.rst new file mode 100644 index 000000000000..2f4c9e39a432 --- /dev/null +++ b/tools/changelog/test/integration/01_patch_bump/fragments/asmith-fix-collision-margin.rst @@ -0,0 +1,9 @@ +Fixed +^^^^^ + +* Fixed missing GPU sync in :func:`~example.refresh_buffers` that occasionally returned stale data. + +Changed +^^^^^^^ + +* Tightened error message in :class:`~example.Foo` when a required argument is missing. diff --git a/tools/changelog/test/integration/01_patch_bump/fragments/jdoe-fix-mass-units.rst b/tools/changelog/test/integration/01_patch_bump/fragments/jdoe-fix-mass-units.rst new file mode 100644 index 000000000000..f3a7a66a215a --- /dev/null +++ b/tools/changelog/test/integration/01_patch_bump/fragments/jdoe-fix-mass-units.rst @@ -0,0 +1,4 @@ +Fixed +^^^^^ + +* Fixed off-by-one in :meth:`~example.Foo.bar` when the input list was empty. diff --git a/tools/changelog/test/integration/02_minor_bump/changelog_after.rst b/tools/changelog/test/integration/02_minor_bump/changelog_after.rst new file mode 100644 index 000000000000..bc5a3fee0e35 --- /dev/null +++ b/tools/changelog/test/integration/02_minor_bump/changelog_after.rst @@ -0,0 +1,30 @@ +Changelog +--------- + +1.3.0 (2026-04-30) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :class:`~example.NewSensor` for IMU-based proprioception. +* Added :func:`~example.helper` utility for batched coordinate transforms. + +Changed +^^^^^^^ + +* Documented thread-safety guarantees for :class:`~example.Worker`. + +Fixed +^^^^^ + +* Fixed a NaN propagation in :meth:`~example.Sensor.update`. + + +1.2.3 (2026-01-15) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :class:`~example.OldThing` for an earlier feature. diff --git a/tools/changelog/test/integration/02_minor_bump/changelog_before.rst b/tools/changelog/test/integration/02_minor_bump/changelog_before.rst new file mode 100644 index 000000000000..81dc30f240c3 --- /dev/null +++ b/tools/changelog/test/integration/02_minor_bump/changelog_before.rst @@ -0,0 +1,10 @@ +Changelog +--------- + +1.2.3 (2026-01-15) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :class:`~example.OldThing` for an earlier feature. diff --git a/tools/changelog/test/integration/02_minor_bump/fragments/asmith-add-multi-asset-spawner.minor.rst b/tools/changelog/test/integration/02_minor_bump/fragments/asmith-add-multi-asset-spawner.minor.rst new file mode 100644 index 000000000000..5f623cf02ad5 --- /dev/null +++ b/tools/changelog/test/integration/02_minor_bump/fragments/asmith-add-multi-asset-spawner.minor.rst @@ -0,0 +1,4 @@ +Added +^^^^^ + +* Added :class:`~example.NewSensor` for IMU-based proprioception. diff --git a/tools/changelog/test/integration/02_minor_bump/fragments/blee-add-camera-output-contract.minor.rst b/tools/changelog/test/integration/02_minor_bump/fragments/blee-add-camera-output-contract.minor.rst new file mode 100644 index 000000000000..a3feda328623 --- /dev/null +++ b/tools/changelog/test/integration/02_minor_bump/fragments/blee-add-camera-output-contract.minor.rst @@ -0,0 +1,9 @@ +Added +^^^^^ + +* Added :func:`~example.helper` utility for batched coordinate transforms. + +Changed +^^^^^^^ + +* Documented thread-safety guarantees for :class:`~example.Worker`. diff --git a/tools/changelog/test/integration/02_minor_bump/fragments/jdoe-fix-rotation-frame.rst b/tools/changelog/test/integration/02_minor_bump/fragments/jdoe-fix-rotation-frame.rst new file mode 100644 index 000000000000..e103861d0d98 --- /dev/null +++ b/tools/changelog/test/integration/02_minor_bump/fragments/jdoe-fix-rotation-frame.rst @@ -0,0 +1,4 @@ +Fixed +^^^^^ + +* Fixed a NaN propagation in :meth:`~example.Sensor.update`. diff --git a/tools/changelog/test/integration/03_major_bump/changelog_after.rst b/tools/changelog/test/integration/03_major_bump/changelog_after.rst new file mode 100644 index 000000000000..cec9e3221263 --- /dev/null +++ b/tools/changelog/test/integration/03_major_bump/changelog_after.rst @@ -0,0 +1,34 @@ +Changelog +--------- + +2.0.0 (2026-04-30) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :class:`~example.AnotherSensor` for proximity detection. + +Changed +^^^^^^^ + +* **Breaking:** :meth:`~example.Foo.bar` now returns a tuple ``(value, error)`` instead of raising. + +Removed +^^^^^^^ + +* Removed deprecated module ``example.old_api`` (use :mod:`~example.api` instead). + +Fixed +^^^^^ + +* Fixed a deadlock in :class:`~example.Pool` under high concurrency. + + +1.2.3 (2026-01-15) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :class:`~example.OldThing` for an earlier feature. diff --git a/tools/changelog/test/integration/03_major_bump/changelog_before.rst b/tools/changelog/test/integration/03_major_bump/changelog_before.rst new file mode 100644 index 000000000000..81dc30f240c3 --- /dev/null +++ b/tools/changelog/test/integration/03_major_bump/changelog_before.rst @@ -0,0 +1,10 @@ +Changelog +--------- + +1.2.3 (2026-01-15) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :class:`~example.OldThing` for an earlier feature. diff --git a/tools/changelog/test/integration/03_major_bump/fragments/asmith-add-warp-contact-stream.minor.rst b/tools/changelog/test/integration/03_major_bump/fragments/asmith-add-warp-contact-stream.minor.rst new file mode 100644 index 000000000000..864d48ce0cef --- /dev/null +++ b/tools/changelog/test/integration/03_major_bump/fragments/asmith-add-warp-contact-stream.minor.rst @@ -0,0 +1,4 @@ +Added +^^^^^ + +* Added :class:`~example.AnotherSensor` for proximity detection. diff --git a/tools/changelog/test/integration/03_major_bump/fragments/blee-rename-articulation-api.major.rst b/tools/changelog/test/integration/03_major_bump/fragments/blee-rename-articulation-api.major.rst new file mode 100644 index 000000000000..d392bcd339c2 --- /dev/null +++ b/tools/changelog/test/integration/03_major_bump/fragments/blee-rename-articulation-api.major.rst @@ -0,0 +1,9 @@ +Removed +^^^^^^^ + +* Removed deprecated module ``example.old_api`` (use :mod:`~example.api` instead). + +Changed +^^^^^^^ + +* **Breaking:** :meth:`~example.Foo.bar` now returns a tuple ``(value, error)`` instead of raising. diff --git a/tools/changelog/test/integration/03_major_bump/fragments/jdoe-fix-articulation-state.rst b/tools/changelog/test/integration/03_major_bump/fragments/jdoe-fix-articulation-state.rst new file mode 100644 index 000000000000..b184ab45c6e2 --- /dev/null +++ b/tools/changelog/test/integration/03_major_bump/fragments/jdoe-fix-articulation-state.rst @@ -0,0 +1,4 @@ +Fixed +^^^^^ + +* Fixed a deadlock in :class:`~example.Pool` under high concurrency. diff --git a/tools/changelog/test/integration/README.md b/tools/changelog/test/integration/README.md new file mode 100644 index 000000000000..1f55d0438705 --- /dev/null +++ b/tools/changelog/test/integration/README.md @@ -0,0 +1,35 @@ +# Changelog integration fixtures + +End-to-end test fixtures for `tools/changelog/cli.py compile`. Each +subdirectory holds a worked example: input fragments, the starting +`CHANGELOG.rst`, and the expected compiled output. + +`tools/changelog/test/test_integration.py` runs the compiler +against each one and asserts the output matches `changelog_after.rst`. +The fixtures double as human-readable demos — read alongside the PR +description to see how the system handles patch / minor / major bumps +and cross-fragment section merges. + +## Layout + +| Demo | Fragments | Bump | Resulting version | +|---|---|---|---| +| `01_patch_bump/` | 2 × `.rst` | patch | `1.2.3 → 1.2.4` | +| `02_minor_bump/` | 1 × `.rst` + 2 × `.minor.rst` | minor | `1.2.3 → 1.3.0` | +| `03_major_bump/` | 1 × `.rst` + 1 × `.minor.rst` + 1 × `.major.rst` | major | `1.2.3 → 2.0.0` | + +Each demo includes a `changelog_before.rst` (initial state) and a +`changelog_after.rst` (expected post-compile state). The bump tier is the +**max** of every fragment's filename suffix in the batch. + +## Run the compiler against a demo + +```bash +./isaaclab.sh -p tools/changelog/cli.py compile --package isaaclab \ + --fragments-dir tools/changelog/test/integration/02_minor_bump/fragments \ + --dry-run +``` + +`--dry-run` prevents the compile from consuming (deleting) the fixture +fragments. The output should match `02_minor_bump/changelog_after.rst` +modulo today's date. diff --git a/tools/changelog/test/invalid_content/3001.rst b/tools/changelog/test/invalid_content/3001.rst new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tools/changelog/test/invalid_content/3002.rst b/tools/changelog/test/invalid_content/3002.rst new file mode 100644 index 000000000000..190621a3aaf3 --- /dev/null +++ b/tools/changelog/test/invalid_content/3002.rst @@ -0,0 +1 @@ +Just a free-form note with no section headings. diff --git a/tools/changelog/test/invalid_content/3003.rst b/tools/changelog/test/invalid_content/3003.rst new file mode 100644 index 000000000000..4470915bae8e --- /dev/null +++ b/tools/changelog/test/invalid_content/3003.rst @@ -0,0 +1,2 @@ +Added +^^^^^ diff --git a/tools/changelog/test/invalid_filenames/1234.notabump.rst b/tools/changelog/test/invalid_filenames/1234.notabump.rst new file mode 100644 index 000000000000..f70a86344dc6 --- /dev/null +++ b/tools/changelog/test/invalid_filenames/1234.notabump.rst @@ -0,0 +1,4 @@ +Added +^^^^^ + +* This file has an unrecognised bump tier and should be rejected. diff --git a/tools/changelog/test/invalid_filenames/multi.dot.slug.rst b/tools/changelog/test/invalid_filenames/multi.dot.slug.rst new file mode 100644 index 000000000000..c9ea00434158 --- /dev/null +++ b/tools/changelog/test/invalid_filenames/multi.dot.slug.rst @@ -0,0 +1,4 @@ +Added +^^^^^ + +* This file's slug contains dots (reserved for the tier suffix) and should be rejected. diff --git a/tools/changelog/test/test_bump_suffix.py b/tools/changelog/test/test_bump_suffix.py new file mode 100644 index 000000000000..1c191c6b33d9 --- /dev/null +++ b/tools/changelog/test/test_bump_suffix.py @@ -0,0 +1,135 @@ +# 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 + +"""Bump-tier inference: filename suffix → bump, and aggregating across a batch. + +These tests use the worked examples under :file:`tools/changelog/examples/` +as fixtures so the same files double as human-readable demos and as +inputs the test suite verifies. +""" + +from __future__ import annotations + +from pathlib import Path + +import cli +import pytest + +EXAMPLES = Path(__file__).parent / "integration" + + +# --------------------------------------------------------------------------- +# Filename → bump tier (one demo per tier, tested separately) +# --------------------------------------------------------------------------- + + +def test_patch_bump_demo_aggregates_to_patch(): + """``examples/01_patch_bump/`` has two ``.rst`` files (no suffix) → patch.""" + batch = cli.FragmentBatch.from_dir(EXAMPLES / "01_patch_bump" / "fragments") + assert batch.invalid == [] + assert {f.name for f in batch.valid} == { + "jdoe-fix-mass-units.rst", + "asmith-fix-collision-margin.rst", + } + assert all(f.bump == "patch" for f in batch.valid) + assert batch.aggregate_bump() == "patch" + + +def test_minor_bump_demo_aggregates_to_minor(): + """``examples/02_minor_bump/`` mixes patch + minor fragments → minor wins.""" + batch = cli.FragmentBatch.from_dir(EXAMPLES / "02_minor_bump" / "fragments") + assert batch.invalid == [] + assert {f.name for f in batch.valid} == { + "jdoe-fix-rotation-frame.rst", + "asmith-add-multi-asset-spawner.minor.rst", + "blee-add-camera-output-contract.minor.rst", + } + bumps = sorted(f.bump for f in batch.valid) + assert bumps == ["minor", "minor", "patch"] + assert batch.aggregate_bump() == "minor" + + +def test_major_bump_demo_aggregates_to_major(): + """``examples/03_major_bump/`` mixes patch + minor + major → major wins.""" + batch = cli.FragmentBatch.from_dir(EXAMPLES / "03_major_bump" / "fragments") + assert batch.invalid == [] + assert {f.name for f in batch.valid} == { + "jdoe-fix-articulation-state.rst", + "asmith-add-warp-contact-stream.minor.rst", + "blee-rename-articulation-api.major.rst", + } + bumps = sorted(f.bump for f in batch.valid) + assert bumps == ["major", "minor", "patch"] + assert batch.aggregate_bump() == "major" + + +# --------------------------------------------------------------------------- +# Pure aggregation logic (no filesystem) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "bumps,expected", + [ + ([], "patch"), + (["patch"], "patch"), + (["patch", "patch"], "patch"), + (["patch", "minor"], "minor"), + (["minor", "patch", "minor"], "minor"), + (["patch", "minor", "major"], "major"), + (["major", "patch"], "major"), + ], +) +def test_aggregate_bump_logic(bumps, expected): + assert cli.FragmentBatch._aggregate(bumps) == expected + + +# --------------------------------------------------------------------------- +# Filename regex — what the gate and compiler agree to accept +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "name,is_fragment,is_skip", + [ + ("1234.rst", True, False), + ("1234.minor.rst", True, False), + ("1234.major.rst", True, False), + ("1234.skip", False, True), + ("jdoe-fix-bug.rst", True, False), + ("jdoe-add-feature.minor.rst", True, False), + ("jdoe-rename-api.major.rst", True, False), + ("jdoe-ci-only.skip", False, True), + (".gitkeep", False, False), + ("README.md", False, False), + ("1234.patch.rst", False, False), # only minor/major are recognised tiers + ("foo.bar.rst", False, False), # extra dots in slug are reserved for tier suffix + ("1234.minor", False, False), # missing .rst extension + ("1234.rst.bak", False, False), + ], +) +def test_fragment_filename_regexes(name, is_fragment, is_skip): + assert bool(cli.FRAGMENT_RE.match(name)) is is_fragment + assert bool(cli.SKIP_RE.match(name)) is is_skip + + +# --------------------------------------------------------------------------- +# Fragment.parse_slug — derived from filename for collision detection +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "name,expected_slug", + [ + ("1234.rst", "1234"), + ("jdoe-add-feature.minor.rst", "jdoe-add-feature"), + ("blee-rename-api.major.rst", "blee-rename-api"), + ("ci-only.skip", "ci-only"), + ("README.md", None), + (".gitkeep", None), + ], +) +def test_parse_slug_for_filenames(name, expected_slug): + assert cli.Fragment.parse_slug(name) == expected_slug diff --git a/tools/changelog/test/test_format.py b/tools/changelog/test/test_format.py new file mode 100644 index 000000000000..24a249992f7b --- /dev/null +++ b/tools/changelog/test/test_format.py @@ -0,0 +1,94 @@ +# 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 + +"""FragmentBatch._merge_sections + ._format_entry + Version.bumped — the rendering pipeline.""" + +from __future__ import annotations + +import cli +import pytest + +# --------------------------------------------------------------------------- +# merge_fragments — collapses bullets across fragments under the same section +# --------------------------------------------------------------------------- + + +def test_merge_fragments_collapses_same_section_across_fragments(): + f1 = {"Added": ["* a1\n"]} + f2 = {"Added": ["* a2\n"], "Fixed": ["* f1\n"]} + merged = cli.FragmentBatch._merge_sections([f1, f2]) + # Bullets from separate fragments concatenate with no blank line in between + # (matching IsaacLab's repo convention, where successive bullets are run-on). + assert merged["Added"] == ["* a1\n", "* a2\n"] + assert merged["Fixed"] == ["* f1\n"] + + +# --------------------------------------------------------------------------- +# format_entry — section ordering + version heading +# --------------------------------------------------------------------------- + + +def test_format_entry_orders_canonical_sections(): + sections = { + "Fixed": ["* f1\n"], + "Added": ["* a1\n"], + "Removed": ["* r1\n"], + } + out = cli.FragmentBatch._format_entry("1.2.4", sections) + # Canonical order is Added, Changed, Deprecated, Removed, Fixed. + a_pos = out.index("Added") + r_pos = out.index("Removed") + f_pos = out.index("Fixed") + assert a_pos < r_pos < f_pos + + +def test_format_entry_includes_version_heading(): + out = cli.FragmentBatch._format_entry("9.9.9", {"Added": ["* x\n"]}) + assert "9.9.9 (" in out + assert "~~~~~~" in out # tilde underline + + +def test_format_entry_unknown_sections_appear_after_canonical(): + sections = {"Performance": ["* p1\n"], "Added": ["* a1\n"]} + out = cli.FragmentBatch._format_entry("1.0.0", sections) + assert out.index("Added") < out.index("Performance") + + +# --------------------------------------------------------------------------- +# bump_version — semver maths +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "current,part,expected", + [ + ("1.2.3", "patch", "1.2.4"), + ("1.2.3", "minor", "1.3.0"), # minor bump zeros patch + ("1.2.3", "major", "2.0.0"), # major bump zeros minor and patch + ("4.6.21", "patch", "4.6.22"), + ("4.6.21.dev20260301", "patch", "4.6.22"), # dev suffix stripped + ], +) +def test_version_bumped(current, part, expected): + assert cli.Version(current).bumped(part).text == expected + assert str(cli.Version(current).bumped(part)) == expected + + +def test_version_bumped_rejects_non_semver(): + # Construction itself rejects malformed input — fail-fast for bad ``--version``. + with pytest.raises(ValueError): + cli.Version("1.2") + with pytest.raises(ValueError): + cli.Version("not-semver") + with pytest.raises(ValueError): + cli.Version("1.2.3.4.5") + + +def test_version_accepts_dev_suffix(): + """PEP 440 ``.devN`` suffixes are tolerated on construction (they appear in + real ``extension.toml`` files between releases) and stripped on bump.""" + v = cli.Version("4.6.21.dev20260301") + assert v.text == "4.6.21.dev20260301" + assert v.bumped("patch").text == "4.6.22" diff --git a/tools/changelog/test/test_integration.py b/tools/changelog/test/test_integration.py new file mode 100644 index 000000000000..0e48e4a874aa --- /dev/null +++ b/tools/changelog/test/test_integration.py @@ -0,0 +1,69 @@ +# 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 + +"""End-to-end checks: run the compiler against each worked example and verify +the resulting changelog matches the checked-in :file:`changelog_after.rst`. + +This is what makes the examples *living docs* — if anything in the compile +pipeline drifts, an example's ``changelog_after.rst`` stops matching and +the corresponding test fails immediately. +""" + +from __future__ import annotations + +import re +import shutil +from pathlib import Path + +import cli +import pytest + +EXAMPLES = Path(__file__).parent / "integration" + +# Strip the ``(YYYY-MM-DD)`` suffix from version headings so the fixed example +# files don't drift when the compiler stamps today's date. +_DATE_RE = re.compile(r"\(\d{4}-\d{2}-\d{2}\)") + + +def _normalize(text: str) -> str: + return _DATE_RE.sub("(YYYY-MM-DD)", text) + + +@pytest.mark.parametrize( + "demo,expected_version", + [ + ("01_patch_bump", "1.2.4"), + ("02_minor_bump", "1.3.0"), + ("03_major_bump", "2.0.0"), + ], +) +def test_demo_compile_matches_changelog_after(tmp_path, demo, expected_version): + """Stage a fake package whose CHANGELOG.rst matches the demo's ``before``, + run the compiler against the demo's fragments, and verify the file ends + up byte-equal to the demo's ``after`` (modulo today's date).""" + demo_dir = EXAMPLES / demo + + # Build a minimal package layout the compiler will accept. + pkg_root = tmp_path / "demo_pkg" + (pkg_root / "config").mkdir(parents=True) + (pkg_root / "docs").mkdir(parents=True) + (pkg_root / "config" / "extension.toml").write_text('version = "1.2.3"\n', encoding="utf-8") + shutil.copy(demo_dir / "changelog_before.rst", pkg_root / "docs" / "CHANGELOG.rst") + + # Copy fragments into tmp_path so the compile's auto-clean doesn't + # delete the live checked-in examples directory. + fragments_tmp = tmp_path / "fragments" + shutil.copytree(demo_dir / "fragments", fragments_tmp) + + # Run the compiler against the (copied) fragments. + pkg = cli.Package(pkg_root) + pkg.compile(fragments_dir=fragments_tmp) + + actual = (pkg_root / "docs" / "CHANGELOG.rst").read_text(encoding="utf-8") + expected = (demo_dir / "changelog_after.rst").read_text(encoding="utf-8") + assert _normalize(actual) == _normalize(expected) + + # Version should have bumped exactly as the demo name suggests. + assert str(pkg.current_version()) == expected_version diff --git a/tools/changelog/test/test_parse.py b/tools/changelog/test/test_parse.py new file mode 100644 index 000000000000..fe8123c2f14c --- /dev/null +++ b/tools/changelog/test/test_parse.py @@ -0,0 +1,143 @@ +# 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 + +"""Fragment.parse + FragmentBatch.from_dir + Package.discover — directory scanning.""" + +from __future__ import annotations + +from pathlib import Path + +import cli + +FIXTURES = Path(__file__).parent + + +def _write(path: Path, body: str) -> Path: + path.write_text(body, encoding="utf-8") + return path + + +# --------------------------------------------------------------------------- +# parse_fragment — section header detection (pure function) +# --------------------------------------------------------------------------- + + +def test_parse_fragment_single_section(tmp_path): + p = _write(tmp_path / "1.rst", "Added\n^^^^^\n\n* Added :class:`~pkg.Foo`.\n") + sections = cli.Fragment(p).parse() + assert list(sections.keys()) == ["Added"] + assert sections["Added"] == ["* Added :class:`~pkg.Foo`.\n"] + + +def test_parse_fragment_multiple_sections_preserves_dict_order(tmp_path): + p = _write(tmp_path / "1.rst", "Added\n^^^^^\n\n* a1\n\nFixed\n^^^^^\n\n* f1\n* f2\n") + sections = cli.Fragment(p).parse() + assert list(sections.keys()) == ["Added", "Fixed"] + assert sections["Added"] == ["* a1\n"] + assert sections["Fixed"] == ["* f1\n", "* f2\n"] + + +def test_parse_fragment_underline_must_be_at_least_heading_length(tmp_path): + """Heading 'Added' (5 chars) needs >=5 carets; '^^' must not match.""" + p = _write(tmp_path / "1.rst", "Added\n^^\n\n* a1\n") + assert cli.Fragment(p).parse() == {} + + +def test_parse_fragment_empty_file(tmp_path): + p = _write(tmp_path / "1.rst", "") + assert cli.Fragment(p).parse() == {} + + +def test_parse_fragment_no_section_headings(tmp_path): + p = _write(tmp_path / "1.rst", "Just a free-form note with no headings.\n") + assert cli.Fragment(p).parse() == {} + + +# --------------------------------------------------------------------------- +# Fragment.parse — same logic, exposed as a method on the wrapper +# --------------------------------------------------------------------------- + + +# --------------------------------------------------------------------------- +# FragmentBatch.from_dir — separates valid filenames from the rest +# --------------------------------------------------------------------------- + + +def test_fragment_batch_flags_invalid_filenames_from_fixture(): + """Files with dotted slugs or unknown bump tiers go in ``invalid``.""" + batch = cli.FragmentBatch.from_dir(FIXTURES / "invalid_filenames") + assert batch.valid == [] + assert {p.name for p in batch.invalid} == {"multi.dot.slug.rst", "1234.notabump.rst"} + + +def test_fragment_batch_missing_directory(tmp_path): + """A non-existent directory is treated as empty, not an error.""" + batch = cli.FragmentBatch.from_dir(tmp_path / "does-not-exist") + assert batch.valid == [] + assert batch.invalid == [] + assert batch.skip_paths == [] + + +def test_fragment_batch_collects_skip_files_separately(tmp_path): + """``.skip`` files are tolerated — exposed via ``skip_paths``, not ``valid``.""" + (tmp_path / "1234.skip").write_text("", encoding="utf-8") + (tmp_path / "1235.rst").write_text("Added\n^^^^^\n\n* x\n", encoding="utf-8") + batch = cli.FragmentBatch.from_dir(tmp_path) + assert {f.name for f in batch.valid} == {"1235.rst"} + assert {p.name for p in batch.skip_paths} == {"1234.skip"} + + +# --------------------------------------------------------------------------- +# Package.discover — a package is "managed" iff it has both +# config/extension.toml and docs/CHANGELOG.rst +# --------------------------------------------------------------------------- + + +def _make_pkg(root: Path, name: str, *, has_ext: bool = True, has_changelog: bool = True) -> None: + pkg = root / name + if has_ext: + (pkg / "config").mkdir(parents=True, exist_ok=True) + (pkg / "config" / "extension.toml").write_text('version = "0.0.0"\n', encoding="utf-8") + if has_changelog: + (pkg / "docs").mkdir(parents=True, exist_ok=True) + (pkg / "docs" / "CHANGELOG.rst").write_text("Changelog\n---------\n\n", encoding="utf-8") + + +def test_package_discover_includes_complete_packages(tmp_path): + _make_pkg(tmp_path, "complete_a") + _make_pkg(tmp_path, "complete_b") + pkgs = cli.Package.discover(tmp_path) + assert [p.name for p in pkgs] == ["complete_a", "complete_b"] + assert all(p.is_managed for p in pkgs) + + +def test_package_discover_excludes_packages_missing_changelog(tmp_path): + _make_pkg(tmp_path, "complete") + _make_pkg(tmp_path, "no_changelog", has_changelog=False) + assert [p.name for p in cli.Package.discover(tmp_path)] == ["complete"] + + +def test_package_discover_excludes_packages_missing_extension_toml(tmp_path): + _make_pkg(tmp_path, "complete") + _make_pkg(tmp_path, "no_extension", has_ext=False) + assert [p.name for p in cli.Package.discover(tmp_path)] == ["complete"] + + +def test_package_discover_returns_sorted_alphabetically(tmp_path): + _make_pkg(tmp_path, "zebra") + _make_pkg(tmp_path, "alpha") + _make_pkg(tmp_path, "mango") + assert [p.name for p in cli.Package.discover(tmp_path)] == ["alpha", "mango", "zebra"] + + +def test_package_discover_missing_root_returns_empty(tmp_path): + assert cli.Package.discover(tmp_path / "does-not-exist") == [] + + +def test_package_is_managed_property(tmp_path): + _make_pkg(tmp_path, "complete") + _make_pkg(tmp_path, "no_changelog", has_changelog=False) + assert cli.Package(tmp_path / "complete").is_managed is True + assert cli.Package(tmp_path / "no_changelog").is_managed is False diff --git a/tools/changelog/test/test_validate.py b/tools/changelog/test/test_validate.py new file mode 100644 index 000000000000..e02e1b95e990 --- /dev/null +++ b/tools/changelog/test/test_validate.py @@ -0,0 +1,323 @@ +# 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 + +"""Fragment.validate — PR-gate filename + content rules.""" + +from __future__ import annotations + +from pathlib import Path + +import cli +import pytest + +FIXTURES = Path(__file__).parent + + +def _write(path: Path, body: str) -> Path: + path.write_text(body, encoding="utf-8") + return path + + +# --------------------------------------------------------------------------- +# Acceptance — well-formed fragments +# --------------------------------------------------------------------------- + + +def test_validate_accepts_well_formed(tmp_path): + p = _write(tmp_path / "1234.rst", "Added\n^^^^^\n\n* Added X.\n") + assert cli.Fragment(p).validate() is None + + +def test_validate_accepts_minor_suffix(tmp_path): + p = _write(tmp_path / "1234.minor.rst", "Added\n^^^^^\n\n* Added X.\n") + assert cli.Fragment(p).validate() is None + + +def test_validate_accepts_major_suffix(tmp_path): + p = _write(tmp_path / "1234.major.rst", "Removed\n^^^^^^^\n\n* Removed X.\n") + assert cli.Fragment(p).validate() is None + + +# --------------------------------------------------------------------------- +# Rejection — uses checked-in fixtures so the malformed inputs are reviewable +# --------------------------------------------------------------------------- + + +def test_validate_rejects_unknown_filename_from_fixture(): + err = cli.Fragment(FIXTURES / "invalid_filenames" / "multi.dot.slug.rst").validate() + assert err is not None and "invalid filename" in err + + +def test_validate_rejects_unknown_bump_tier_from_fixture(): + err = cli.Fragment(FIXTURES / "invalid_filenames" / "1234.notabump.rst").validate() + assert err is not None and "invalid filename" in err + + +def test_validate_rejects_empty_file_from_fixture(): + err = cli.Fragment(FIXTURES / "invalid_content" / "3001.rst").validate() + assert err is not None and "empty" in err + + +def test_validate_rejects_missing_section_heading_from_fixture(): + err = cli.Fragment(FIXTURES / "invalid_content" / "3002.rst").validate() + assert err is not None and "section" in err.lower() + + +def test_validate_rejects_section_without_bullets_from_fixture(): + err = cli.Fragment(FIXTURES / "invalid_content" / "3003.rst").validate() + assert err is not None and "bullet" in err.lower() + + +# --------------------------------------------------------------------------- +# check_fragments — gate orchestration: immutability, slug uniqueness, and +# the "PR must add at least one fragment per touched package" rule +# --------------------------------------------------------------------------- + + +def _pkg_under(tmp_path: Path, name: str) -> cli.Package: + """Build a managed-looking Package rooted at ``tmp_path/source/``.""" + root = tmp_path / "source" / name + (root / "config").mkdir(parents=True) + (root / "docs").mkdir(parents=True) + (root / "config" / "extension.toml").write_text('version = "0.0.0"\n', encoding="utf-8") + (root / "docs" / "CHANGELOG.rst").write_text("Changelog\n---------\n\n", encoding="utf-8") + return cli.Package(root) + + +def test_check_fragments_immutability_rejects_modified_fragment(tmp_path): + """Modifying an existing fragment is forbidden — must add a new one instead.""" + pkg = _pkg_under(tmp_path, "isaaclab") + changed = {"source/isaaclab/code.py", "source/isaaclab/changelog.d/jdoe-fix-bug.rst"} + added = {"source/isaaclab/code.py"} # fragment exists already; the PR only modified it + missing, invalid = cli.PRDiff(changed=changed, added=added).evaluate([pkg]) + assert missing == ["isaaclab"] + invalid_map = dict(invalid) + assert "source/isaaclab/changelog.d/jdoe-fix-bug.rst" in invalid_map + assert "immutable" in invalid_map["source/isaaclab/changelog.d/jdoe-fix-bug.rst"] + + +def test_check_fragments_chain_allows_other_pr_fragment(tmp_path): + """A chained PR (B based on A's branch, A still open) sees A's fragment in + its diff. That should pass — both fragments have distinct slugs and B + contributes its own fragment for the touched package.""" + pkg = _pkg_under(tmp_path, "isaaclab") + (pkg.root / "changelog.d").mkdir() + (pkg.root / "changelog.d" / "alice-feature-a.rst").write_text("Fixed\n^^^^^\n\n* x\n", encoding="utf-8") + (pkg.root / "changelog.d" / "bob-feature-b.rst").write_text("Added\n^^^^^\n\n* y\n", encoding="utf-8") + changed = { + "source/isaaclab/code.py", + "source/isaaclab/changelog.d/alice-feature-a.rst", # parent PR's fragment + "source/isaaclab/changelog.d/bob-feature-b.rst", # this PR's own fragment + } + added = changed + missing, invalid = cli.PRDiff(changed=changed, added=added).evaluate([pkg]) + assert missing == [] + assert invalid == [] + + +def test_check_fragments_slug_collision_with_existing(tmp_path): + """Adding a fragment whose slug collides with one already in changelog.d/ fails.""" + pkg = _pkg_under(tmp_path, "isaaclab") + (pkg.root / "changelog.d").mkdir() + # Pre-existing fragment on develop with the same slug as the one this PR adds. + (pkg.root / "changelog.d" / "jdoe-fix-bug.rst").write_text("Fixed\n^^^^^\n\n* x\n", encoding="utf-8") + # PR adds a fresh fragment whose slug collides — different tier, same slug. + (pkg.root / "changelog.d" / "jdoe-fix-bug.minor.rst").write_text("Added\n^^^^^\n\n* y\n", encoding="utf-8") + changed = {"source/isaaclab/code.py", "source/isaaclab/changelog.d/jdoe-fix-bug.minor.rst"} + added = changed + missing, invalid = cli.PRDiff(changed=changed, added=added).evaluate([pkg]) + invalid_map = dict(invalid) + assert "source/isaaclab/changelog.d/jdoe-fix-bug.minor.rst" in invalid_map + assert "collides" in invalid_map["source/isaaclab/changelog.d/jdoe-fix-bug.minor.rst"] + + +def test_check_fragments_collision_independent_of_iterdir_order(tmp_path, monkeypatch): + """Regression: an added file must not be allowed to *replace* a colliding + pre-existing fragment in the existing-slug map. The CI checkout contains + both, and depending on filesystem iteration order the added file could + end up as the "existing" entry, hiding the collision.""" + pkg = _pkg_under(tmp_path, "isaaclab") + (pkg.root / "changelog.d").mkdir() + (pkg.root / "changelog.d" / "jdoe-foo.rst").write_text("Fixed\n^^^^^\n\n* x\n", encoding="utf-8") + (pkg.root / "changelog.d" / "jdoe-foo.minor.rst").write_text("Added\n^^^^^\n\n* y\n", encoding="utf-8") + changed = {"source/isaaclab/code.py", "source/isaaclab/changelog.d/jdoe-foo.minor.rst"} + added = changed + + # Force iterdir() to return the added file *last* so it would overwrite + # the pre-existing entry in a buggy implementation. Sort with the added + # file ranked highest, so it lands at the tail regardless of natural + # alphabetical order. + real_iterdir = Path.iterdir + added_name = "jdoe-foo.minor.rst" + + def ordered_iterdir(self): + if self == pkg.root / "changelog.d": + return iter(sorted(real_iterdir(self), key=lambda p: (p.name == added_name, p.name))) + return real_iterdir(self) + + monkeypatch.setattr(Path, "iterdir", ordered_iterdir) + + missing, invalid = cli.PRDiff(changed=changed, added=added).evaluate([pkg]) + invalid_map = dict(invalid) + assert "source/isaaclab/changelog.d/jdoe-foo.minor.rst" in invalid_map + assert "collides" in invalid_map["source/isaaclab/changelog.d/jdoe-foo.minor.rst"] + + +def test_check_fragments_slug_collision_within_pr(tmp_path): + """Two added fragments in the same PR that share a slug (e.g. across tiers) fail.""" + pkg = _pkg_under(tmp_path, "isaaclab") + (pkg.root / "changelog.d").mkdir() + (pkg.root / "changelog.d" / "jdoe-fix.rst").write_text("Fixed\n^^^^^\n\n* x\n", encoding="utf-8") + (pkg.root / "changelog.d" / "jdoe-fix.minor.rst").write_text("Added\n^^^^^\n\n* y\n", encoding="utf-8") + changed = { + "source/isaaclab/code.py", + "source/isaaclab/changelog.d/jdoe-fix.rst", + "source/isaaclab/changelog.d/jdoe-fix.minor.rst", + } + added = changed + missing, invalid = cli.PRDiff(changed=changed, added=added).evaluate([pkg]) + # One of the two is the offender; the other is the first-seen "winner". + invalid_paths = [p for p, _ in invalid] + assert any("jdoe-fix" in p for p in invalid_paths) + assert any("collides" in r for _, r in invalid) + + +def test_check_fragments_skip_file_satisfies_requirement(tmp_path): + """A ``.skip`` opt-out is a valid form of "PR owns a fragment for this pkg".""" + pkg = _pkg_under(tmp_path, "isaaclab") + (pkg.root / "changelog.d").mkdir() + (pkg.root / "changelog.d" / "ci-only.skip").write_text("", encoding="utf-8") + changed = {"source/isaaclab/code.py", "source/isaaclab/changelog.d/ci-only.skip"} + added = changed + missing, invalid = cli.PRDiff(changed=changed, added=added).evaluate([pkg]) + assert missing == [] + assert invalid == [] + + +def test_check_fragments_no_source_changes_means_no_required_fragment(tmp_path): + """Pure docs / CI / changelog-tooling PRs don't trigger the requirement.""" + pkg = _pkg_under(tmp_path, "isaaclab") + changed = {"docs/something.rst"} # not under source/isaaclab/ + added = changed + missing, invalid = cli.PRDiff(changed=changed, added=added).evaluate([pkg]) + assert missing == [] + assert invalid == [] + + +def test_check_fragments_missing_when_source_touched_without_fragment(tmp_path): + """If the PR touches a package's source but adds no fragment, the package is missing.""" + pkg = _pkg_under(tmp_path, "isaaclab") + changed = {"source/isaaclab/code.py"} + added = changed + missing, invalid = cli.PRDiff(changed=changed, added=added).evaluate([pkg]) + assert missing == ["isaaclab"] + assert invalid == [] + + +# --------------------------------------------------------------------------- +# _display_path — handles paths inside *and* outside REPO_ROOT +# --------------------------------------------------------------------------- + + +def test_display_path_strips_repo_root_for_internal_paths(): + """Inside-repo paths are shown relative for terse log lines.""" + p = cli.REPO_ROOT / "tools" / "changelog" / "cli.py" + assert cli._display_path(p) == "tools/changelog/cli.py" + + +def test_display_path_falls_back_to_absolute_for_external(tmp_path): + """External paths (e.g. ``--fragments-dir /tmp/foo`` outside the repo) + used to crash on ``relative_to(REPO_ROOT)``; the helper now returns the + absolute path in that case.""" + external = tmp_path / "external_fragments" / "1234.rst" + external.parent.mkdir(parents=True) + external.write_text("", encoding="utf-8") + assert cli._display_path(external) == str(external) + + +# --------------------------------------------------------------------------- +# Package.compile bails on unmanaged packages instead of silently warning +# --------------------------------------------------------------------------- + + +def test_compile_raises_on_package_missing_changelog(tmp_path): + """Constructing a Package directly at an unmanaged root and calling + ``compile()`` must raise (not silently warn-and-write a stale toml).""" + pkg_root = tmp_path / "pkg" + (pkg_root / "config").mkdir(parents=True) + (pkg_root / "config" / "extension.toml").write_text('version = "1.2.3"\n', encoding="utf-8") + # No docs/CHANGELOG.rst — package is not managed. + pkg = cli.Package(pkg_root) + assert pkg.is_managed is False + + fragments = tmp_path / "fragments" + fragments.mkdir() + (fragments / "1234.rst").write_text("Fixed\n^^^^^\n\n* x\n", encoding="utf-8") + + with pytest.raises(ValueError, match="not managed"): + pkg.compile(fragments_dir=fragments, dry_run=True) + + +# --------------------------------------------------------------------------- +# cmd_compile parser guards — argparse-level errors fire as SystemExit +# --------------------------------------------------------------------------- + + +def _parse_compile(argv: list[str]): + """Build the parser and parse a compile invocation. Returns (parser, args).""" + parser = cli._build_parser() + return parser, parser.parse_args(argv) + + +def test_compile_guard_version_with_all_errors(): + """``--version`` with ``--all`` is meaningless — each package has its own version.""" + parser, args = _parse_compile(["compile", "--all", "--version", "1.2.3"]) + with pytest.raises(SystemExit): + cli.cmd_compile(args, parser) + + +def test_compile_guard_fragments_dir_with_all_errors(): + """``--fragments-dir`` with ``--all`` is meaningless — different dirs per package.""" + parser, args = _parse_compile(["compile", "--all", "--fragments-dir", "/tmp/x"]) + with pytest.raises(SystemExit): + cli.cmd_compile(args, parser) + + +def test_compile_guard_malformed_version_errors(): + """A garbage ``--version`` value fails before any file is touched.""" + parser, args = _parse_compile(["compile", "--package", "isaaclab", "--version", "not-semver"]) + with pytest.raises(SystemExit): + cli.cmd_compile(args, parser) + + +def test_compile_guard_nonexistent_package_errors(): + """A ``--package`` that doesn't exist on disk fails fast.""" + parser, args = _parse_compile(["compile", "--package", "definitely_not_a_real_package_xyz"]) + with pytest.raises(SystemExit): + cli.cmd_compile(args, parser) + + +def test_compile_rejects_fragments_that_check_would_reject(tmp_path): + """``compile`` must enforce the same content rules as ``check``. + + Regression: a fragment with a section heading but no bullet body + used to slip past compile (parsed to ``{"Added": []}``, emitted an + empty Added section), while check correctly rejected it. The two + paths must agree on what a valid fragment looks like. + """ + pkg_root = tmp_path / "pkg" + (pkg_root / "config").mkdir(parents=True) + (pkg_root / "docs").mkdir(parents=True) + (pkg_root / "config" / "extension.toml").write_text('version = "1.2.3"\n', encoding="utf-8") + (pkg_root / "docs" / "CHANGELOG.rst").write_text("Changelog\n---------\n\n", encoding="utf-8") + pkg = cli.Package(pkg_root) + + fragments = tmp_path / "fragments" + fragments.mkdir() + # Header but no bullets — same shape as fixtures/invalid_content/3003.rst. + (fragments / "1234.rst").write_text("Added\n^^^^^\n\n", encoding="utf-8") + + with pytest.raises(ValueError, match="failed content validation"): + pkg.compile(fragments_dir=fragments, dry_run=True) From 26c253ce3b97ec2cb9c3691d11c50072df072d2d Mon Sep 17 00:00:00 2001 From: jmart-nv Date: Sat, 2 May 2026 23:12:12 -0500 Subject: [PATCH 26/40] CI: trigger build.yaml workflow on scripts/ changes (#5442) # Description Adds the `scripts/**` path to the build.yaml filter to allow the CI workflow to run on changes to scripts. This is a followup to the new tests added in [PR 5397](https://github.com/isaac-sim/IsaacLab/pull/5397) and the existing tests in the scripts/tools folder. ## Type of change - 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` - [ ] I have made corresponding changes to the documentation *(N/A)* - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works *(N/A)* - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file *(N/A)* - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there Co-authored-by: Kelly Guo Co-authored-by: Piotr Barejko --- .github/workflows/build.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index de8f4898267f..bb77da0be5d0 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -92,7 +92,7 @@ jobs: printf '%s\n' "$changed_files" # config.yaml controls the base image names and tags consumed by the Docker build jobs. - if printf '%s\n' "$changed_files" | grep -qE '^(source/|docker/|tools/|apps/|\.github/workflows/build\.yaml$|\.github/workflows/config\.yaml$|\.github/actions/)'; then + if printf '%s\n' "$changed_files" | grep -qE '^(source/|docker/|tools/|apps/|scripts/|\.github/workflows/build\.yaml$|\.github/workflows/config\.yaml$|\.github/actions/)'; then echo "run_docker_tests=true" >> "$GITHUB_OUTPUT" else echo "run_docker_tests=false" >> "$GITHUB_OUTPUT" From d3ce0b3db007f671fe1ade111810e247bd4d378e Mon Sep 17 00:00:00 2001 From: jmart-nv Date: Sat, 2 May 2026 23:12:47 -0500 Subject: [PATCH 27/40] OMPE-85851: Added success metric tracking to benchmark scripts. (#5335) # Description The previous benchmark convergence checking was dependent on analyzing reward curves, which required maintaining expected reward thresholds for each environment, which is not sustainable or reliable. This change adds a new success metric `Metrics/success_rate` that can be instrumented by each environment to signal whether training succeeded or not, without requiring any special knowledge of that environment by the benchmark scripts. The results are logged to the json artifact. Threshold, window, and tag can be overridden via new CLI args. A new `--check_success` arg can also be used to optionally enable early-exit when success stabilizes. ## Type of change - 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` - [ ] I have made corresponding changes to the documentation *(N/A)* - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works *(unit tests + manual e2e tests)* - [x] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- scripts/benchmarks/benchmark_rlgames.py | 17 +- scripts/benchmarks/benchmark_rsl_rl.py | 19 +- scripts/benchmarks/early_stop.py | 321 ++++++++++ scripts/benchmarks/test/test_early_stop.py | 706 +++++++++++++++++++++ scripts/benchmarks/utils.py | 39 ++ source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 10 + 7 files changed, 1110 insertions(+), 4 deletions(-) create mode 100644 scripts/benchmarks/early_stop.py create mode 100644 scripts/benchmarks/test/test_early_stop.py diff --git a/scripts/benchmarks/benchmark_rlgames.py b/scripts/benchmarks/benchmark_rlgames.py index b79c47a1ab69..ab1d625d5aaf 100644 --- a/scripts/benchmarks/benchmark_rlgames.py +++ b/scripts/benchmarks/benchmark_rlgames.py @@ -14,6 +14,13 @@ from isaaclab.app import AppLauncher +from scripts.benchmarks.early_stop import ( + RlGamesEarlyStopObserver, + add_success_cli_args, + build_success_kwargs, + get_success_tracker, +) + # add argparse arguments parser = argparse.ArgumentParser(description="Train an RL agent with RL-Games.") parser.add_argument("--video", action="store_true", default=False, help="Record videos during training.") @@ -52,6 +59,7 @@ parser.add_argument( "--convergence_config", type=str, default="full", help="Config mode for convergence thresholds (default: full)." ) +add_success_cli_args(parser) # append AppLauncher cli args AppLauncher.add_app_launcher_args(parser) @@ -105,6 +113,7 @@ log_runtime_step_times, log_scene_creation_time, log_simulation_start_time, + log_success, log_task_start_time, log_total_start_time, parse_tf_logs, @@ -239,8 +248,9 @@ def main( # set number of actors into agent config agent_cfg["params"]["config"]["num_actors"] = env.unwrapped.num_envs - # create runner from rl-games - runner = Runner(IsaacAlgoObserver()) + # always track the success metric; early-stop only if --check_success + observer = RlGamesEarlyStopObserver(IsaacAlgoObserver(), **build_success_kwargs(args_cli)) + runner = Runner(observer) runner.load(agent_cfg) # set seed of the env @@ -293,6 +303,9 @@ def main( convergence_config=args_cli.convergence_config, ) + tracker = get_success_tracker(args_cli, observer.tracker, log_data) + log_success(benchmark, tracker, framework_iteration_count=observer.framework_iteration_count) + benchmark._finalize_impl() # close the simulator diff --git a/scripts/benchmarks/benchmark_rsl_rl.py b/scripts/benchmarks/benchmark_rsl_rl.py index c6ec83e97ae5..0eef6063fba7 100644 --- a/scripts/benchmarks/benchmark_rsl_rl.py +++ b/scripts/benchmarks/benchmark_rsl_rl.py @@ -14,6 +14,13 @@ from isaaclab.app import AppLauncher +from scripts.benchmarks.early_stop import ( + RslRlEarlyStopWrapper, + add_success_cli_args, + build_success_kwargs, + get_success_tracker, +) + sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), "../..")) import scripts.reinforcement_learning.rsl_rl.cli_args as cli_args # isort: skip @@ -55,6 +62,7 @@ parser.add_argument( "--convergence_config", type=str, default="full", help="Config mode for convergence thresholds (default: full)." ) +add_success_cli_args(parser) # append RSL-RL cli arguments cli_args.add_rsl_rl_args(parser) @@ -107,6 +115,7 @@ log_runtime_step_times, log_scene_creation_time, log_simulation_start_time, + log_success, log_task_start_time, log_total_start_time, parse_tf_logs, @@ -239,8 +248,13 @@ def main( dump_yaml(os.path.join(log_dir, "params", "env.yaml"), env_cfg) dump_yaml(os.path.join(log_dir, "params", "agent.yaml"), agent_cfg) + # always track the success metric; early-stop only if --check_success + early_stop_ctx = RslRlEarlyStopWrapper( + env, runner, num_steps_per_env=agent_cfg.num_steps_per_env, **build_success_kwargs(args_cli) + ) + # run training with continuous benchmark monitoring - with BenchmarkMonitor(benchmark, interval=1.0): + with early_stop_ctx, BenchmarkMonitor(benchmark, interval=1.0): runner.learn(num_learning_iterations=agent_cfg.max_iterations, init_at_random_ep_len=True) if world_rank == 0: @@ -289,6 +303,9 @@ def main( convergence_config=args_cli.convergence_config, ) + tracker = get_success_tracker(args_cli, early_stop_ctx.tracker, log_data) + log_success(benchmark, tracker, framework_iteration_count=early_stop_ctx.framework_iteration_count) + benchmark._finalize_impl() # close the simulator diff --git a/scripts/benchmarks/early_stop.py b/scripts/benchmarks/early_stop.py new file mode 100644 index 000000000000..38dabe3b67cd --- /dev/null +++ b/scripts/benchmarks/early_stop.py @@ -0,0 +1,321 @@ +# 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 + +"""Early stopping for benchmark training based on a success metric. + +Framework-specific implementations that monitor a metric from ``extras["log"]`` +and stop training when it stabilizes above a threshold: + +- **rsl_rl**: ``env.step`` wrapper + exception (no callback API in rsl_rl). +- **rl_games**: ``AlgoObserver`` subclass, sets ``max_epochs`` for clean exit. +""" + +from __future__ import annotations + +import argparse +import os +import statistics +from typing import TYPE_CHECKING + +from scripts.benchmarks.utils import get_success_rate_log + +if TYPE_CHECKING: + from rl_games.common.algo_observer import AlgoObserver + from rsl_rl.runners import OnPolicyRunner + + from isaaclab_rl.rsl_rl import RslRlVecEnvWrapper + +DEFAULT_SUCCESS_THRESHOLD = 0.3 +DEFAULT_SUCCESS_WINDOW = 20 + + +class EarlyStopConverged(Exception): + """Raised by :class:`RslRlEarlyStopWrapper` when the metric has converged.""" + + +class SuccessRateTracker: + """Accumulates a per-iteration success-rate metric and checks trailing-window convergence. + + Args: + threshold: Minimum value to consider a pass. + window: Consecutive iterations above *threshold* to trigger convergence. + num_steps_per_env: Steps per RL iteration (for boundary detection). + """ + + def __init__(self, threshold: float, window: int, num_steps_per_env: int): + self.threshold = threshold + self.window = window + self.num_steps_per_env = num_steps_per_env + + self.history: list[float] = [] + self._step_count = 0 + self._iter_sum = 0.0 + self._iter_count = 0 + + def record_step(self, extras: dict) -> None: + """Record one env step.""" + val = get_success_rate_log(extras.get("log", {})) + if val is not None: + self._iter_sum += val.item() if hasattr(val, "item") else float(val) + self._iter_count += 1 + self._step_count += 1 + + def end_iteration(self) -> float | None: + """Finalize the current iteration. Returns mean metric, or ``None`` if no data.""" + if self._iter_count == 0: + return None + mean = self._iter_sum / self._iter_count + self.history.append(mean) + self._iter_sum = 0.0 + self._iter_count = 0 + return mean + + @property + def at_iteration_boundary(self) -> bool: + """Whether the tracker has seen exactly a full iteration's worth of steps. + + Assumes :meth:`record_step` is called exactly once per env step. This holds for + all current framework integrations (rsl_rl's patched ``env.step`` and rl_games' + ``AlgoObserver.process_infos``) — both pair a single step with a single record. + Integrations that call :meth:`record_step` more or fewer times per env step will + break iteration accounting. + """ + return self.num_steps_per_env > 0 and self._step_count % self.num_steps_per_env == 0 + + @property + def converged(self) -> bool: + if len(self.history) < self.window: + return False + return all(v >= self.threshold for v in self.history[-self.window :]) + + @property + def current_iteration(self) -> int: + return len(self.history) + + @property + def tail_mean(self) -> float: + if not self.history: + return 0.0 + tail = self.history[-self.window :] if len(self.history) >= self.window else self.history + return statistics.mean(tail) + + +class RslRlEarlyStopWrapper: + """Context manager that wraps ``env.step`` to track a success metric during rsl_rl training. + + Always records the metric into :attr:`tracker` so the caller can log the tail mean / converged-at + iteration regardless of whether early stopping is enabled. When ``stop_on_convergence=True``, the + wrapper also raises :class:`EarlyStopConverged` on the first iteration where the trailing window + is above threshold, performs runner cleanup (checkpoint save + logger flush), and suppresses the + exception so the caller sees a normal return from :meth:`rsl_rl.runners.OnPolicyRunner.learn`. + + Args: + env: ``RslRlVecEnvWrapper`` instance. + runner: ``OnPolicyRunner`` instance. + threshold: Minimum metric value to pass. + window: Consecutive iterations above threshold to trigger stop. + num_steps_per_env: Steps per RL iteration. + stop_on_convergence: If ``True``, raise :class:`EarlyStopConverged` when the metric converges. + If ``False``, only track the metric without interrupting training. + """ + + def __init__( + self, + env: RslRlVecEnvWrapper, + runner: OnPolicyRunner, + threshold: float, + window: int, + num_steps_per_env: int, + stop_on_convergence: bool = True, + ): + self.env = env + self.runner = runner + self.tracker = SuccessRateTracker(threshold, window, num_steps_per_env) + self.stop_on_convergence = stop_on_convergence + self._orig_step = env.step + + def __enter__(self): + self.env.step = self._step + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.env.step = self._orig_step + if exc_type is EarlyStopConverged: + self._runner_cleanup() + print( + f"[INFO] Early stop: success rate converged at iteration " + f"{self.tracker.current_iteration} (tail mean {self.tracker.tail_mean:.4f})" + ) + return True + return False + + def _step(self, actions): + result = self._orig_step(actions) + self.tracker.record_step(result[3]) # rsl_rl: (obs, rew, dones, extras) + if self.tracker.at_iteration_boundary: + self.tracker.end_iteration() + if self.stop_on_convergence and self.tracker.converged: + # relies on rsl_rl's rollout loop not catching arbitrary exceptions; if upstream + # ever wraps env.step in a broad except, this exception will be swallowed + raise EarlyStopConverged() + return result + + def _runner_cleanup(self): + """Save final checkpoint and flush the TensorBoard writer.""" + if self.runner.logger.writer is not None: + it = self.runner.current_learning_iteration + self.runner.save(os.path.join(self.runner.logger.log_dir, f"model_{it}.pt")) + self.runner.logger.stop_logging_writer() + + @property + def framework_iteration_count(self) -> int: + """Number of training iterations the rsl_rl runner has recorded as completed. + + Note: ``current_learning_iteration`` is set AFTER rollout + policy update, so mid-rollout + (including the instant our early-stop exception fires) this counter lags :attr:`tracker` + by 1 iteration. + """ + return self.runner.current_learning_iteration + 1 + + +class RlGamesEarlyStopObserver: + """``AlgoObserver`` that tracks a success metric during rl_games training. + + Always records the metric into :attr:`tracker` so the caller can log the tail mean / converged-at + iteration regardless of whether early stopping is enabled. When ``stop_on_convergence=True``, the + observer also sets ``algo.max_epochs`` on the first iteration where the trailing window is above + threshold, which forces a clean exit from :meth:`rl_games.torch_runner.Runner.run`. All other + observer calls are delegated to *base_observer*. + + Args: + base_observer: Original ``AlgoObserver`` to delegate to. + threshold: Minimum metric value to pass. + window: Consecutive iterations above threshold to trigger stop. + stop_on_convergence: If ``True``, set ``algo.max_epochs`` when the metric converges. + If ``False``, only track the metric without interrupting training. + """ + + def __init__( + self, + base_observer: AlgoObserver, + threshold: float, + window: int, + stop_on_convergence: bool = True, + ): + self._base = base_observer + self.threshold = threshold + self.window = window + self.stop_on_convergence = stop_on_convergence + self.algo = None + self.tracker: SuccessRateTracker | None = None + + def before_init(self, base_name, config, experiment_name): + self._base.before_init(base_name, config, experiment_name) + + def after_init(self, algo): + self._base.after_init(algo) + self.algo = algo + num_steps = getattr(algo, "horizon_length", algo.config.get("horizon_length", 16)) + self.tracker = SuccessRateTracker(self.threshold, self.window, num_steps) + + def process_infos(self, infos, done_indices): + self._base.process_infos(infos, done_indices) + if self.tracker is not None and isinstance(infos, dict) and "episode" in infos: + # rl_games remaps extras["log"] → extras["episode"] + self.tracker.record_step({"log": infos["episode"]}) + + def after_steps(self): + self._base.after_steps() + if self.tracker is None: + return + self.tracker.end_iteration() + if self.stop_on_convergence and self.tracker.converged and self.algo is not None: + print( + f"[INFO] Early stop: success rate converged at iteration " + f"{self.tracker.current_iteration} (tail mean {self.tracker.tail_mean:.4f})" + ) + self.algo.max_epochs = self.tracker.current_iteration + + def after_clear_stats(self): + self._base.after_clear_stats() + + def after_print_stats(self, frame, epoch_num, total_time): + self._base.after_print_stats(frame, epoch_num, total_time) + + @property + def framework_iteration_count(self) -> int | None: + """Number of training iterations the rl_games algo has recorded. + + rl_games increments ``algo.epoch_num`` at the start of each iteration, so after iter N + completes this value equals N (matching :attr:`tracker`'s count exactly). Returns + ``None`` before :meth:`after_init` has attached to an algo. + """ + return None if self.algo is None else self.algo.epoch_num + + +def add_success_cli_args(parser: argparse.ArgumentParser) -> None: + """Register the success-metric CLI args on *parser*. + + Adds ``--check_success``, ``--success_threshold``, and ``--success_window``. Use + :func:`build_success_kwargs` to resolve the parsed values into a kwargs dict for + the wrapper constructors. + """ + parser.add_argument( + "--check_success", action="store_true", help="Early-stop when the normalized success metric converges." + ) + parser.add_argument( + "--success_threshold", + type=float, + default=None, + help=f"Override the success threshold (default: {DEFAULT_SUCCESS_THRESHOLD}).", + ) + parser.add_argument( + "--success_window", + type=int, + default=None, + help=f"Override the convergence window (default: {DEFAULT_SUCCESS_WINDOW}).", + ) + + +def build_success_kwargs(args_cli: argparse.Namespace) -> dict: + """Resolve success-metric CLI args into kwargs for the wrapper constructors. + + Returns a dict with ``threshold``, ``window``, and ``stop_on_convergence``, suitable + to splat into :class:`RslRlEarlyStopWrapper` or :class:`RlGamesEarlyStopObserver`. + """ + return { + "threshold": ( + args_cli.success_threshold if args_cli.success_threshold is not None else DEFAULT_SUCCESS_THRESHOLD + ), + "window": args_cli.success_window if args_cli.success_window is not None else DEFAULT_SUCCESS_WINDOW, + "stop_on_convergence": args_cli.check_success, + } + + +def get_success_tracker( + args_cli: argparse.Namespace, + live_tracker: SuccessRateTracker | None, + log_data: dict[str, list[float]], +) -> SuccessRateTracker | None: + """Return a tracker with recorded history, or ``None`` if neither source has data. + + Prefers *live_tracker* (from the training wrapper/observer). If it never ran or recorded + no iterations, falls back to building a post-hoc tracker by replaying the success metric + series out of TensorBoard *log_data* (from :func:`scripts.benchmarks.utils.parse_tf_logs`). + + Args: + args_cli: Parsed arg namespace with the ``--success_*`` flags. + live_tracker: Tracker attached to the early-stop wrapper/observer (or ``None``). + log_data: Mapping of TB tag -> list of scalars for the current run. + """ + if live_tracker is not None and live_tracker.history: + return live_tracker + history = get_success_rate_log(log_data) + if not history: + return None + kwargs = build_success_kwargs(args_cli) + tracker = SuccessRateTracker(kwargs["threshold"], kwargs["window"], num_steps_per_env=0) + tracker.history = list(history) + return tracker diff --git a/scripts/benchmarks/test/test_early_stop.py b/scripts/benchmarks/test/test_early_stop.py new file mode 100644 index 000000000000..b11231481da3 --- /dev/null +++ b/scripts/benchmarks/test/test_early_stop.py @@ -0,0 +1,706 @@ +# 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 the benchmark success-metric early-stopping helpers.""" + +from __future__ import annotations + +import argparse + +import pytest + +from scripts.benchmarks.early_stop import ( + DEFAULT_SUCCESS_THRESHOLD, + DEFAULT_SUCCESS_WINDOW, + RlGamesEarlyStopObserver, + RslRlEarlyStopWrapper, + SuccessRateTracker, + add_success_cli_args, + build_success_kwargs, + get_success_tracker, +) +from scripts.benchmarks.utils import SUCCESS_RATE_LOG_TAGS, log_success + +DEFAULT_SUCCESS_TAG = SUCCESS_RATE_LOG_TAGS[0] + +# -- fakes ------------------------------------------------------------------ + + +class _FakeTensor: + """Stand-in for ``torch.Tensor`` with only the ``.item()`` path exercised.""" + + def __init__(self, value: float): + self._value = value + + def item(self) -> float: + return self._value + + +class _FakeBenchmark: + def __init__(self): + self.measurements: list[tuple[str, str, object, str]] = [] + + def add_measurement(self, phase, measurement): + self.measurements.append((phase, measurement.name, measurement.value, measurement.unit)) + + def by_name(self, name: str): + return next(m for m in self.measurements if m[1] == name) + + +class _FakeLogger: + def __init__(self, has_writer: bool = True): + self.writer = object() if has_writer else None + self.log_dir = "/tmp/fake_log_dir" + self.stopped = False + + def stop_logging_writer(self): + self.stopped = True + + +class _FakeRunner: + def __init__(self, has_writer: bool = True): + self.logger = _FakeLogger(has_writer=has_writer) + self.current_learning_iteration = 7 + self.saved: list[str] = [] + + def save(self, path: str): + self.saved.append(path) + + +class _FakeEnv: + def __init__(self, extras_sequence): + self._seq = list(extras_sequence) + self.step_calls = 0 + + def step(self, actions): + extras = self._seq[self.step_calls] if self.step_calls < len(self._seq) else self._seq[-1] + self.step_calls += 1 + return (None, None, None, extras) + + +class _FakeBaseObserver: + def __init__(self): + self.calls: list[str] = [] + + def before_init(self, base_name, config, experiment_name): + self.calls.append("before_init") + + def after_init(self, algo): + self.calls.append("after_init") + + def process_infos(self, infos, done_indices): + self.calls.append("process_infos") + + def after_steps(self): + self.calls.append("after_steps") + + def after_clear_stats(self): + self.calls.append("after_clear_stats") + + def after_print_stats(self, frame, epoch_num, total_time): + self.calls.append("after_print_stats") + + +class _FakeAlgo: + def __init__(self, horizon_length: int | None = None, config_horizon: int | None = 16, epoch_num: int = 0): + self.max_epochs = 999 + self.epoch_num = epoch_num + if horizon_length is not None: + self.horizon_length = horizon_length + self.config = {"horizon_length": config_horizon} if config_horizon is not None else {} + + +def _parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser() + add_success_cli_args(p) + return p + + +# -- SuccessRateTracker ----------------------------------------------------- + + +class TestSuccessRateTracker: + """Test cases for the per-iteration metric accumulator and convergence check.""" + + def test_records_metric_from_extras_log(self): + """Test that a present metric is accumulated into the iteration sum.""" + t = SuccessRateTracker(0.5, 3, num_steps_per_env=4) + t.record_step({"log": {DEFAULT_SUCCESS_TAG: 0.9}}) + assert t._iter_sum == pytest.approx(0.9) + assert t._iter_count == 1 + + def test_ignores_missing_metric_key(self): + """Test that a foreign key in extras["log"] is ignored.""" + t = SuccessRateTracker(0.5, 3, num_steps_per_env=4) + t.record_step({"log": {"other": 1.0}}) + assert t._iter_count == 0 + + def test_missing_log_subdict_does_not_raise(self): + """Test that an extras dict without a "log" sub-dict is handled gracefully.""" + t = SuccessRateTracker(0.5, 3, num_steps_per_env=4) + t.record_step({}) + assert t._iter_count == 0 + assert t._step_count == 1 + + def test_tensor_value_uses_item_method(self): + """Test that tensor-like values are extracted via ``.item()``.""" + t = SuccessRateTracker(0.5, 3, num_steps_per_env=4) + t.record_step({"log": {DEFAULT_SUCCESS_TAG: _FakeTensor(0.7)}}) + assert t._iter_sum == pytest.approx(0.7) + + def test_step_count_increments_even_without_metric(self): + """Test that ``_step_count`` tracks every call regardless of metric presence.""" + t = SuccessRateTracker(0.5, 3, num_steps_per_env=4) + t.record_step({}) + t.record_step({"log": {"other": 1.0}}) + assert t._step_count == 2 + assert t._iter_count == 0 + + def test_end_iteration_averages_and_resets(self): + """Test that ``end_iteration`` averages recorded values and resets counters.""" + t = SuccessRateTracker(0.5, 3, num_steps_per_env=4) + t.record_step({"log": {DEFAULT_SUCCESS_TAG: 0.4}}) + t.record_step({"log": {DEFAULT_SUCCESS_TAG: 0.6}}) + assert t.end_iteration() == pytest.approx(0.5) + assert t.history == [pytest.approx(0.5)] + assert t._iter_sum == 0.0 + assert t._iter_count == 0 + + def test_end_iteration_no_data_returns_none_without_recording(self): + """Test that ``end_iteration`` returns None and skips history append when no data was seen.""" + t = SuccessRateTracker(0.5, 3, num_steps_per_env=4) + assert t.end_iteration() is None + assert t.history == [] + + def test_at_iteration_boundary_respects_num_steps_per_env(self): + """Test that the boundary flag fires only after exactly ``num_steps_per_env`` calls.""" + t = SuccessRateTracker(0.5, 3, num_steps_per_env=4) + for _ in range(3): + t.record_step({"log": {DEFAULT_SUCCESS_TAG: 0.1}}) + assert t.at_iteration_boundary is False + t.record_step({"log": {DEFAULT_SUCCESS_TAG: 0.1}}) + assert t.at_iteration_boundary is True + + def test_at_iteration_boundary_false_when_num_steps_zero(self): + """Test that a post-hoc tracker (``num_steps_per_env=0``) never reports a boundary.""" + t = SuccessRateTracker(0.5, 3, num_steps_per_env=0) + t.record_step({"log": {DEFAULT_SUCCESS_TAG: 0.1}}) + assert t.at_iteration_boundary is False + + def test_not_converged_when_history_shorter_than_window(self): + """Test that convergence is False when there aren't yet enough history entries.""" + t = SuccessRateTracker(0.5, 3, num_steps_per_env=4) + t.history = [0.9, 0.9] + assert t.converged is False + + def test_not_converged_when_history_empty(self): + """Test that convergence is False on a freshly-created tracker.""" + t = SuccessRateTracker(0.5, 3, num_steps_per_env=4) + assert t.history == [] + assert t.converged is False + + def test_converged_when_window_all_above_threshold(self): + """Test that convergence is True when the trailing window is all above threshold.""" + t = SuccessRateTracker(0.5, 3, num_steps_per_env=4) + t.history = [0.1, 0.9, 0.9, 0.9] + assert t.converged is True + + def test_converged_when_history_length_equals_window(self): + """Test the window boundary: history length == window (minimum qualifying case).""" + t = SuccessRateTracker(0.5, 3, num_steps_per_env=4) + t.history = [0.9, 0.9, 0.9] + assert t.converged is True + + def test_converged_at_exact_threshold(self): + """Test the threshold boundary: values equal to the threshold satisfy ``>= threshold``.""" + t = SuccessRateTracker(0.5, 3, num_steps_per_env=4) + t.history = [0.5, 0.5, 0.5] + assert t.converged is True + + def test_not_converged_when_any_window_value_below(self): + """Test that a single sub-threshold value in the trailing window blocks convergence.""" + t = SuccessRateTracker(0.5, 3, num_steps_per_env=4) + t.history = [0.9, 0.9, 0.4] + assert t.converged is False + + def test_converged_with_window_of_one(self): + """Test the degenerate ``window=1`` case: only the last value matters.""" + t = SuccessRateTracker(0.5, 1, num_steps_per_env=4) + t.history = [0.1, 0.2, 0.9] + assert t.converged is True + t.history = [0.9, 0.9, 0.1] + assert t.converged is False + + def test_tail_mean_empty_history_is_zero(self): + """Test that ``tail_mean`` returns 0.0 for an empty history.""" + t = SuccessRateTracker(0.5, 3, num_steps_per_env=4) + assert t.tail_mean == 0.0 + + def test_tail_mean_shorter_than_window_uses_all_values(self): + """Test that ``tail_mean`` averages the full history when it's shorter than the window.""" + t = SuccessRateTracker(0.5, 3, num_steps_per_env=4) + t.history = [0.2, 0.4] + assert t.tail_mean == pytest.approx(0.3) + + def test_tail_mean_longer_than_window_uses_tail(self): + """Test that ``tail_mean`` averages only the last ``window`` entries.""" + t = SuccessRateTracker(0.5, 3, num_steps_per_env=4) + t.history = [0.9, 0.9, 0.1, 0.2, 0.3] + assert t.tail_mean == pytest.approx(0.2) + + def test_current_iteration_equals_history_length(self): + """Test that ``current_iteration`` reports the history length.""" + t = SuccessRateTracker(0.5, 3, num_steps_per_env=4) + t.history = [0.1, 0.2, 0.3] + assert t.current_iteration == 3 + + +# -- CLI helpers ------------------------------------------------------------ + + +class TestCliHelpers: + """Test cases for the ``--success_*`` CLI registration and kwargs resolution.""" + + def test_defaults_parse_to_none_and_false(self): + """Test that unset args resolve to None / False.""" + args = _parser().parse_args([]) + assert args.check_success is False + assert args.success_threshold is None + assert args.success_window is None + + def test_overrides_parse(self): + """Test that explicit ``--success_*`` values round-trip through argparse.""" + args = _parser().parse_args( + [ + "--check_success", + "--success_threshold", + "0.75", + "--success_window", + "50", + ] + ) + assert args.check_success is True + assert args.success_threshold == 0.75 + assert args.success_window == 50 + + def test_build_success_kwargs_uses_defaults_when_unset(self): + """Test that ``build_success_kwargs`` substitutes library defaults for unset args.""" + kwargs = build_success_kwargs(_parser().parse_args([])) + assert kwargs == { + "threshold": DEFAULT_SUCCESS_THRESHOLD, + "window": DEFAULT_SUCCESS_WINDOW, + "stop_on_convergence": False, + } + + def test_build_success_kwargs_applies_overrides(self): + """Test that CLI overrides flow through into the kwargs dict.""" + args = _parser().parse_args( + [ + "--check_success", + "--success_threshold", + "0.1", + "--success_window", + "5", + ] + ) + kwargs = build_success_kwargs(args) + assert kwargs["threshold"] == pytest.approx(0.1) + assert kwargs["window"] == 5 + assert kwargs["stop_on_convergence"] is True + + def test_zero_threshold_is_respected_not_treated_as_unset(self): + """Test that ``--success_threshold 0`` is preserved (``is not None`` check, not truthy).""" + args = _parser().parse_args(["--success_threshold", "0"]) + assert build_success_kwargs(args)["threshold"] == 0.0 + + +# -- get_success_tracker ---------------------------------------------------- + + +class TestGetSuccessTracker: + """Test cases for the live-vs-post-hoc tracker resolution helper.""" + + def test_prefers_live_tracker_with_history(self): + """Test that a non-empty live tracker is returned as-is.""" + live = SuccessRateTracker(0.5, 3, num_steps_per_env=4) + live.history = [0.9, 0.9] + assert get_success_tracker(_parser().parse_args([]), live, {}) is live + + def test_falls_back_to_post_hoc_when_live_tracker_empty(self): + """Test that an empty live tracker falls back to TensorBoard replay.""" + live = SuccessRateTracker(0.5, 3, num_steps_per_env=4) + log_data = {DEFAULT_SUCCESS_TAG: [0.1, 0.2, 0.3]} + result = get_success_tracker(_parser().parse_args([]), live, log_data) + assert result is not live + assert result.history == [pytest.approx(0.1), pytest.approx(0.2), pytest.approx(0.3)] + + def test_falls_back_to_post_hoc_when_live_tracker_none(self): + """Test that a missing live tracker falls back to TensorBoard replay.""" + log_data = {DEFAULT_SUCCESS_TAG: [0.5, 0.6, 0.7]} + result = get_success_tracker(_parser().parse_args([]), None, log_data) + assert result is not None + assert result.history == [pytest.approx(0.5), pytest.approx(0.6), pytest.approx(0.7)] + + def test_returns_none_when_no_data_anywhere(self): + """Test that both sources missing resolves to ``None``.""" + assert get_success_tracker(_parser().parse_args([]), None, {}) is None + + def test_returns_none_when_tag_absent_from_log_data(self): + """Test that unrelated TensorBoard tags don't satisfy the fallback.""" + assert get_success_tracker(_parser().parse_args([]), None, {"Metrics/other": [1.0]}) is None + + def test_post_hoc_honors_override_threshold_and_window(self): + """Test that CLI threshold/window overrides are applied to the post-hoc tracker.""" + args = _parser().parse_args(["--success_threshold", "0.2", "--success_window", "2"]) + log_data = {DEFAULT_SUCCESS_TAG: [0.3, 0.3]} + result = get_success_tracker(args, None, log_data) + assert result.threshold == pytest.approx(0.2) + assert result.window == 2 + assert result.converged is True + + def test_post_hoc_tracker_has_no_iteration_boundary(self): + """Test that post-hoc trackers use ``num_steps_per_env=0`` so ``at_iteration_boundary`` never fires.""" + result = get_success_tracker(_parser().parse_args([]), None, {DEFAULT_SUCCESS_TAG: [0.9]}) + assert result.num_steps_per_env == 0 + assert result.at_iteration_boundary is False + + +# -- RslRlEarlyStopWrapper -------------------------------------------------- + + +class TestRslRlEarlyStopWrapper: + """Test cases for the rsl_rl env.step monkey-patch context manager.""" + + def test_records_every_step_and_restores_on_exit(self): + """Test that wrapped env.step records, and original step is restored on normal exit.""" + env = _FakeEnv([{"log": {DEFAULT_SUCCESS_TAG: 0.9}}] * 5) + runner = _FakeRunner() + with RslRlEarlyStopWrapper(env, runner, 0.5, 3, num_steps_per_env=2) as ctx: + env.step(None) + assert ctx.tracker._iter_sum == pytest.approx(0.9) + # after exit, env.step no longer routes through the tracker + env.step(None) + assert ctx.tracker._iter_sum == pytest.approx(0.9) + assert env.step_calls == 2 + + def test_raises_and_cleans_up_on_convergence_by_default(self): + """Test that convergence triggers cleanup (checkpoint + flush) and suppresses the exception.""" + env = _FakeEnv([{"log": {DEFAULT_SUCCESS_TAG: 0.9}}] * 100) + runner = _FakeRunner() + # num_steps_per_env=2, window=2 -> converges on step 4 (iter 2) + with RslRlEarlyStopWrapper(env, runner, 0.5, 2, num_steps_per_env=2) as ctx: + for _ in range(10): + env.step(None) + assert ctx.tracker.converged is True + assert env.step_calls == 4 + assert len(runner.saved) == 1 + assert runner.logger.stopped is True + + def test_does_not_raise_when_stop_on_convergence_false(self): + """Test that ``stop_on_convergence=False`` lets training run past convergence.""" + env = _FakeEnv([{"log": {DEFAULT_SUCCESS_TAG: 0.9}}] * 100) + runner = _FakeRunner() + with RslRlEarlyStopWrapper( + env, + runner, + 0.5, + 2, + num_steps_per_env=2, + stop_on_convergence=False, + ) as ctx: + for _ in range(10): + env.step(None) + assert env.step_calls == 10 + assert ctx.tracker.converged is True + assert runner.saved == [] + assert runner.logger.stopped is False + + def test_does_not_suppress_other_exceptions(self): + """Test that non-EarlyStopConverged exceptions propagate out of the ``with`` block.""" + env = _FakeEnv([{"log": {}}]) + runner = _FakeRunner() + with pytest.raises(ValueError): + with RslRlEarlyStopWrapper(env, runner, 0.5, 2, num_steps_per_env=2): + raise ValueError("not an early stop") + + def test_env_step_restored_after_early_stop_exception(self): + """Test that env.step is unwrapped after an early-stop exception suppressed by __exit__.""" + env = _FakeEnv([{"log": {DEFAULT_SUCCESS_TAG: 0.9}}] * 100) + runner = _FakeRunner() + with RslRlEarlyStopWrapper(env, runner, 0.5, 2, num_steps_per_env=2) as ctx: + for _ in range(10): + env.step(None) # converges & raises at step 4, suppressed + sum_at_exit = ctx.tracker._iter_sum + env.step(None) + assert ctx.tracker._iter_sum == sum_at_exit # post-exit step bypassed the tracker + + def test_env_step_restored_after_unrelated_exception(self): + """Test that env.step is unwrapped even when a non-EarlyStopConverged exception propagates.""" + env = _FakeEnv([{"log": {DEFAULT_SUCCESS_TAG: 0.9}}] * 10) + runner = _FakeRunner() + try: + with RslRlEarlyStopWrapper(env, runner, 0.5, 2, num_steps_per_env=2) as ctx: + env.step(None) + raise ValueError("boom") + except ValueError: + pass + sum_at_exit = ctx.tracker._iter_sum + env.step(None) + assert ctx.tracker._iter_sum == sum_at_exit + + def test_cleanup_not_called_on_unrelated_exceptions(self): + """Test that only EarlyStopConverged triggers checkpoint save + logger flush.""" + env = _FakeEnv([{"log": {}}]) + runner = _FakeRunner() + try: + with RslRlEarlyStopWrapper(env, runner, 0.5, 2, num_steps_per_env=2): + raise ValueError("boom") + except ValueError: + pass + assert runner.saved == [] + assert runner.logger.stopped is False + + def test_cleanup_skipped_when_runner_has_no_writer(self): + """Test that cleanup skips both save and flush when ``runner.logger.writer`` is ``None``.""" + env = _FakeEnv([{"log": {DEFAULT_SUCCESS_TAG: 0.9}}] * 100) + runner = _FakeRunner(has_writer=False) + with RslRlEarlyStopWrapper(env, runner, 0.5, 2, num_steps_per_env=2): + for _ in range(10): + env.step(None) + assert runner.saved == [] + assert runner.logger.stopped is False + + def test_framework_iteration_count_reflects_runner(self): + """Test that the framework-counter property reports ``current_learning_iteration + 1``.""" + env = _FakeEnv([{"log": {DEFAULT_SUCCESS_TAG: 0.0}}]) + runner = _FakeRunner() + runner.current_learning_iteration = 42 + wrapper = RslRlEarlyStopWrapper(env, runner, 0.5, 3, num_steps_per_env=2) + assert wrapper.framework_iteration_count == 43 + + +# -- RlGamesEarlyStopObserver ----------------------------------------------- + + +class TestRlGamesEarlyStopObserver: + """Test cases for the rl_games AlgoObserver that tracks success and forces max_epochs.""" + + def test_delegates_every_call_to_base(self): + """Test that all observer lifecycle calls are forwarded to the wrapped base observer.""" + base = _FakeBaseObserver() + obs = RlGamesEarlyStopObserver(base, 0.5, 3) + obs.before_init("name", {}, "exp") + obs.after_init(_FakeAlgo(horizon_length=8)) + obs.process_infos({"episode": {}}, []) + obs.after_steps() + obs.after_clear_stats() + obs.after_print_stats(0, 0, 0) + assert base.calls == [ + "before_init", + "after_init", + "process_infos", + "after_steps", + "after_clear_stats", + "after_print_stats", + ] + + def test_tracker_uses_horizon_length_attribute(self): + """Test that the tracker pulls ``num_steps_per_env`` from ``algo.horizon_length`` when present.""" + obs = RlGamesEarlyStopObserver(_FakeBaseObserver(), 0.5, 3) + obs.after_init(_FakeAlgo(horizon_length=24)) + assert obs.tracker.num_steps_per_env == 24 + + def test_tracker_falls_back_to_config_horizon_length(self): + """Test that the tracker falls back to ``algo.config['horizon_length']`` when the attr is missing.""" + obs = RlGamesEarlyStopObserver(_FakeBaseObserver(), 0.5, 3) + obs.after_init(_FakeAlgo(horizon_length=None, config_horizon=32)) + assert obs.tracker.num_steps_per_env == 32 + + def test_process_infos_records_from_episode_key(self): + """Test that ``infos["episode"]`` is remapped to the tracker's extras["log"] shape.""" + obs = RlGamesEarlyStopObserver(_FakeBaseObserver(), 0.5, 3) + obs.after_init(_FakeAlgo(horizon_length=2)) + obs.process_infos({"episode": {DEFAULT_SUCCESS_TAG: 0.8}}, []) + assert obs.tracker._iter_sum == pytest.approx(0.8) + + def test_process_infos_is_noop_before_after_init(self): + """Test that ``process_infos`` before ``after_init`` does not raise (tracker is None).""" + obs = RlGamesEarlyStopObserver(_FakeBaseObserver(), 0.5, 3) + obs.process_infos({"episode": {DEFAULT_SUCCESS_TAG: 0.8}}, []) + assert obs.tracker is None + + def test_process_infos_ignores_non_dict_infos(self): + """Test that non-dict ``infos`` are skipped gracefully without mutating the tracker.""" + obs = RlGamesEarlyStopObserver(_FakeBaseObserver(), 0.5, 3) + obs.after_init(_FakeAlgo(horizon_length=2)) + obs.process_infos([], []) + assert obs.tracker._iter_sum == 0.0 + + def test_after_steps_sets_max_epochs_on_convergence(self): + """Test that convergence on iteration N sets ``algo.max_epochs = N`` for clean exit.""" + obs = RlGamesEarlyStopObserver(_FakeBaseObserver(), 0.5, 2) + algo = _FakeAlgo(horizon_length=1) + obs.after_init(algo) + obs.process_infos({"episode": {DEFAULT_SUCCESS_TAG: 0.9}}, []) + obs.after_steps() + obs.process_infos({"episode": {DEFAULT_SUCCESS_TAG: 0.9}}, []) + obs.after_steps() + assert algo.max_epochs == 2 + + def test_after_steps_leaves_max_epochs_alone_when_stop_disabled(self): + """Test that ``stop_on_convergence=False`` preserves the caller's ``algo.max_epochs``.""" + obs = RlGamesEarlyStopObserver( + _FakeBaseObserver(), + 0.5, + 2, + stop_on_convergence=False, + ) + algo = _FakeAlgo(horizon_length=1) + original_max_epochs = algo.max_epochs + obs.after_init(algo) + obs.process_infos({"episode": {DEFAULT_SUCCESS_TAG: 0.9}}, []) + obs.after_steps() + obs.process_infos({"episode": {DEFAULT_SUCCESS_TAG: 0.9}}, []) + obs.after_steps() + assert algo.max_epochs == original_max_epochs + + def test_after_steps_noop_before_after_init(self): + """Test that ``after_steps`` before ``after_init`` does not raise (tracker is None).""" + obs = RlGamesEarlyStopObserver(_FakeBaseObserver(), 0.5, 2) + obs.after_steps() + assert obs.tracker is None + + def test_each_after_steps_appends_one_iteration(self): + """Test that each ``after_steps`` call finalizes exactly one iteration in the tracker.""" + obs = RlGamesEarlyStopObserver(_FakeBaseObserver(), 0.5, 5) + obs.after_init(_FakeAlgo(horizon_length=1)) + for i in range(4): + obs.process_infos({"episode": {DEFAULT_SUCCESS_TAG: 0.9}}, []) + obs.after_steps() + assert obs.tracker.current_iteration == i + 1 + + def test_after_steps_does_not_converge_with_insufficient_history(self): + """Test that a trailing window shorter than ``window`` does not trigger early stop.""" + obs = RlGamesEarlyStopObserver(_FakeBaseObserver(), 0.5, 5) + algo = _FakeAlgo(horizon_length=1) + obs.after_init(algo) + for _ in range(4): + obs.process_infos({"episode": {DEFAULT_SUCCESS_TAG: 0.9}}, []) + obs.after_steps() + assert algo.max_epochs == 999 # unchanged: tracker.converged is still False + + def test_framework_iteration_count_returns_none_before_after_init(self): + """Test that the framework-counter property returns ``None`` before an algo is attached.""" + obs = RlGamesEarlyStopObserver(_FakeBaseObserver(), 0.5, 2) + assert obs.framework_iteration_count is None + + def test_framework_iteration_count_reflects_algo_epoch_num(self): + """Test that the framework-counter property mirrors ``algo.epoch_num``.""" + obs = RlGamesEarlyStopObserver(_FakeBaseObserver(), 0.5, 2) + obs.after_init(_FakeAlgo(horizon_length=1, epoch_num=7)) + assert obs.framework_iteration_count == 7 + + +# -- log_success (scripts.benchmarks.utils) --------------------------------- + + +class TestLogSuccess: + """Test cases for the benchmark-side success-metric logging helper.""" + + def _tracker_with(self, history: list[float]) -> SuccessRateTracker: + """Build a tracker with a pre-populated history for testing.""" + t = SuccessRateTracker(0.5, 3, num_steps_per_env=4) + t.history = history + return t + + def test_noop_when_tracker_is_none(self): + """Test that ``log_success`` emits nothing when no tracker is supplied.""" + bench = _FakeBenchmark() + log_success(bench, None) + assert bench.measurements == [] + + def test_noop_when_history_empty(self): + """Test that an empty tracker history is a silent no-op.""" + bench = _FakeBenchmark() + log_success(bench, self._tracker_with([])) + assert bench.measurements == [] + + def test_logs_full_measurement_set(self): + """Test that a populated tracker produces the full measurement set.""" + bench = _FakeBenchmark() + log_success(bench, self._tracker_with([0.9, 0.9, 0.9])) + names = {m[1] for m in bench.measurements} + assert names == {"Success Rate (tail mean)", "Success Converged At Iter", "Success Passed"} + + def test_converged_path(self): + """Test that a converged run reports ``Passed=1`` with the true converged iter + tail mean.""" + bench = _FakeBenchmark() + log_success(bench, self._tracker_with([0.9, 0.9, 0.9])) + assert bench.by_name("Success Passed")[2] == 1 + assert bench.by_name("Success Converged At Iter")[2] == 3 + assert bench.by_name("Success Rate (tail mean)")[2] == pytest.approx(0.9) + + def test_failed_path(self): + """Test that a non-converged run reports ``Passed=0`` and ``Converged At Iter=-1``.""" + bench = _FakeBenchmark() + log_success(bench, self._tracker_with([0.1, 0.2, 0.3])) + assert bench.by_name("Success Passed")[2] == 0 + assert bench.by_name("Success Converged At Iter")[2] == -1 + + def test_cadence_warning_fires_on_cadence_violation(self, capsys): + """Test that a 2x tracker/framework ratio triggers the cadence warning.""" + bench = _FakeBenchmark() + log_success(bench, self._tracker_with([0.5] * 100), framework_iteration_count=50) + captured = capsys.readouterr().out + assert "[WARN]" in captured + assert "check record_step cadence" in captured + + def test_no_cadence_warning_on_exact_agreement(self, capsys): + """Test that an exact tracker-vs-framework match (rl_games case) is silent.""" + bench = _FakeBenchmark() + log_success(bench, self._tracker_with([0.5] * 50), framework_iteration_count=50) + assert "[WARN]" not in capsys.readouterr().out + + def test_no_cadence_warning_on_rsl_rl_early_stop_offset(self, capsys): + """Test that the rsl_rl early-stop +1 offset (tracker=51, framework=50) is within slack.""" + bench = _FakeBenchmark() + log_success(bench, self._tracker_with([0.5] * 51), framework_iteration_count=50) + assert "[WARN]" not in capsys.readouterr().out + + def test_no_cadence_warning_when_framework_count_not_provided(self, capsys): + """Test that the cadence check is skipped entirely when no framework count is supplied.""" + bench = _FakeBenchmark() + log_success(bench, self._tracker_with([0.5] * 999)) + assert "[WARN]" not in capsys.readouterr().out + + def test_cadence_violation_end_to_end_via_wrapper(self, capsys): + """Test that a simulated 2x env.step bug manifests as an overcounted tracker and is caught. + + The wrapper can't distinguish "2 env.step calls that should have been 1" from normal + traffic — but the tracker overcounts iterations by 2x, and comparing against the + runner's independent counter catches the discrepancy. + """ + env = _FakeEnv([{"log": {DEFAULT_SUCCESS_TAG: 0.5}}] * 100) + runner = _FakeRunner() + runner.current_learning_iteration = 9 # rsl_rl thinks 10 iterations completed + with RslRlEarlyStopWrapper( + env, + runner, + 0.5, + 3, + num_steps_per_env=2, + stop_on_convergence=False, + ) as ctx: + # simulate the bug: upstream calls env.step 2x per real rollout step + for _ in range(10 * 2 * 2): # 10 iters * 2 steps/iter * 2x-bug + env.step(None) + # 40 calls with num_steps_per_env=2 => tracker.current_iteration = 20 + assert ctx.tracker.current_iteration == 20 + # framework's counter is independent: reports 10 iterations actually ran + assert ctx.framework_iteration_count == 10 + bench = _FakeBenchmark() + log_success(bench, ctx.tracker, framework_iteration_count=ctx.framework_iteration_count) + captured = capsys.readouterr().out + assert "[WARN]" in captured diff --git a/scripts/benchmarks/utils.py b/scripts/benchmarks/utils.py index 564f9cd93a1b..e157765adb0e 100644 --- a/scripts/benchmarks/utils.py +++ b/scripts/benchmarks/utils.py @@ -256,6 +256,45 @@ def log_convergence( ) +def log_success(benchmark, tracker, framework_iteration_count: int | None = None): + """Log success-metric results to the benchmark backend. + + Always logs the tag, tail mean, converged-at-iter, and pass/fail whenever the tracker holds + data (useful for historical comparison across runs). No-op when the tracker is ``None`` or + never recorded anything. + + Args: + benchmark: Benchmark instance. + tracker: :class:`SuccessRateTracker` from early_stop (or ``None`` if no tracker ran). + framework_iteration_count: Iterations the RL framework actually ran. When provided, emits a warning + if the tracker's count exceeds the framework's by more than 1. + """ + if tracker is None or not tracker.history: + return + + converged = tracker.converged + benchmark.add_measurement( + "train", SingleMeasurement(name="Success Rate (tail mean)", value=round(tracker.tail_mean, 4), unit="float") + ) + benchmark.add_measurement( + "train", + SingleMeasurement( + name="Success Converged At Iter", + value=tracker.current_iteration if converged else -1, + unit="int", + ), + ) + benchmark.add_measurement("train", SingleMeasurement(name="Success Passed", value=int(converged), unit="bool")) + + # +1 slack handles counters that lag behind during early-stop. + # Anything larger signals a broken record_step cadence (see SuccessRateTracker.at_iteration_boundary). + if framework_iteration_count is not None and tracker.current_iteration > framework_iteration_count + 1: + print( + f"[WARN] Success tracker logged {tracker.current_iteration} iterations vs framework's " + f"{framework_iteration_count}; check record_step cadence assumption." + ) + + def parse_cprofile_stats( profile: cProfile.Profile, isaaclab_prefixes: list[str], diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index 729d39541f41..f28030cdc980 100644 --- a/source/isaaclab/config/extension.toml +++ b/source/isaaclab/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "4.6.25" +version = "4.6.26" # Description title = "Isaac Lab framework for Robot Learning" diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index c76cf6d8757d..4c46a9c9d4fb 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,16 @@ Changelog --------- +4.6.26 (2026-05-01) +~~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added ``Metrics/success_rate`` tracking to benchmark scripts. The result is always logged + to the benchmark artifact; ``--check_success`` additionally early-stops training on convergence. + + 4.6.25 (2026-04-28) ~~~~~~~~~~~~~~~~~~~ From 83948078e5362a2848fb86185b94691a1b76c1fa Mon Sep 17 00:00:00 2001 From: camevor Date: Sun, 3 May 2026 08:14:41 +0200 Subject: [PATCH 28/40] [Newton] Adds joint wrench sensor (#5412) # Description Adds a Newton and Base implementation for a `JointWrenchSensor` ## Type of change - New feature (non-breaking change which adds functionality) ## Checklist - [ ] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html) - [ ] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [ ] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Signed-off-by: camevor Signed-off-by: Kelly Guo Co-authored-by: Antoine RICHARD Co-authored-by: Kelly Guo --- docs/source/api/lab/isaaclab.sensors.rst | 16 + source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 9 + source/isaaclab/isaaclab/sensors/__init__.pyi | 12 + .../isaaclab/sensors/joint_wrench/__init__.py | 10 + .../sensors/joint_wrench/__init__.pyi | 18 + .../joint_wrench/base_joint_wrench_sensor.py | 76 +++ .../base_joint_wrench_sensor_data.py | 42 ++ .../joint_wrench/joint_wrench_sensor.py | 27 + .../joint_wrench/joint_wrench_sensor_cfg.py | 30 ++ .../joint_wrench/joint_wrench_sensor_data.py | 25 + source/isaaclab_newton/config/extension.toml | 2 +- source/isaaclab_newton/docs/CHANGELOG.rst | 9 + .../isaaclab_newton/sensors/__init__.pyi | 3 + .../sensors/joint_wrench/__init__.py | 10 + .../sensors/joint_wrench/__init__.pyi | 9 + .../joint_wrench/joint_wrench_sensor.py | 216 ++++++++ .../joint_wrench/joint_wrench_sensor_data.py | 67 +++ .../sensors/joint_wrench/kernels.py | 80 +++ .../test/sensors/test_joint_wrench_sensor.py | 460 ++++++++++++++++++ 20 files changed, 1121 insertions(+), 2 deletions(-) create mode 100644 source/isaaclab/isaaclab/sensors/joint_wrench/__init__.py create mode 100644 source/isaaclab/isaaclab/sensors/joint_wrench/__init__.pyi create mode 100644 source/isaaclab/isaaclab/sensors/joint_wrench/base_joint_wrench_sensor.py create mode 100644 source/isaaclab/isaaclab/sensors/joint_wrench/base_joint_wrench_sensor_data.py create mode 100644 source/isaaclab/isaaclab/sensors/joint_wrench/joint_wrench_sensor.py create mode 100644 source/isaaclab/isaaclab/sensors/joint_wrench/joint_wrench_sensor_cfg.py create mode 100644 source/isaaclab/isaaclab/sensors/joint_wrench/joint_wrench_sensor_data.py create mode 100644 source/isaaclab_newton/isaaclab_newton/sensors/joint_wrench/__init__.py create mode 100644 source/isaaclab_newton/isaaclab_newton/sensors/joint_wrench/__init__.pyi create mode 100644 source/isaaclab_newton/isaaclab_newton/sensors/joint_wrench/joint_wrench_sensor.py create mode 100644 source/isaaclab_newton/isaaclab_newton/sensors/joint_wrench/joint_wrench_sensor_data.py create mode 100644 source/isaaclab_newton/isaaclab_newton/sensors/joint_wrench/kernels.py create mode 100644 source/isaaclab_newton/test/sensors/test_joint_wrench_sensor.py diff --git a/docs/source/api/lab/isaaclab.sensors.rst b/docs/source/api/lab/isaaclab.sensors.rst index 0d7fb3a9d1fe..15fa68e71349 100644 --- a/docs/source/api/lab/isaaclab.sensors.rst +++ b/docs/source/api/lab/isaaclab.sensors.rst @@ -36,6 +36,8 @@ MultiMeshRayCasterCameraCfg Imu ImuCfg + JointWrenchSensor + JointWrenchSensorCfg Sensor Base ----------- @@ -189,3 +191,17 @@ Inertia Measurement Unit :inherited-members: :show-inheritance: :exclude-members: __init__, class_type + +Joint Wrench Sensor +------------------- + +.. autoclass:: JointWrenchSensor + :members: + :inherited-members: + :show-inheritance: + +.. autoclass:: JointWrenchSensorCfg + :members: + :inherited-members: + :show-inheritance: + :exclude-members: __init__, class_type diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index f28030cdc980..fff3815f9228 100644 --- a/source/isaaclab/config/extension.toml +++ b/source/isaaclab/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "4.6.26" +version = "4.6.27" # Description title = "Isaac Lab framework for Robot Learning" diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index 4c46a9c9d4fb..6f50ca262268 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,15 @@ Changelog --------- +4.6.27 (2026-05-01) +~~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :class:`~isaaclab.sensors.JointWrenchSensor`. + + 4.6.26 (2026-05-01) ~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab/isaaclab/sensors/__init__.pyi b/source/isaaclab/isaaclab/sensors/__init__.pyi index 2a9d735f70ea..1bd092de46e6 100644 --- a/source/isaaclab/isaaclab/sensors/__init__.pyi +++ b/source/isaaclab/isaaclab/sensors/__init__.pyi @@ -33,6 +33,11 @@ __all__ = [ "Imu", "ImuCfg", "ImuData", + "BaseJointWrenchSensor", + "BaseJointWrenchSensorData", + "JointWrenchSensor", + "JointWrenchSensorCfg", + "JointWrenchSensorData", "BasePva", "BasePvaData", "Pva", @@ -83,6 +88,13 @@ from .frame_transformer import ( FrameTransformerData, ) from .imu import BaseImu, BaseImuData, Imu, ImuCfg, ImuData +from .joint_wrench import ( + BaseJointWrenchSensor, + BaseJointWrenchSensorData, + JointWrenchSensor, + JointWrenchSensorCfg, + JointWrenchSensorData, +) from .pva import BasePva, BasePvaData, Pva, PvaCfg, PvaData from .ray_caster import ( MultiMeshRayCaster, diff --git a/source/isaaclab/isaaclab/sensors/joint_wrench/__init__.py b/source/isaaclab/isaaclab/sensors/joint_wrench/__init__.py new file mode 100644 index 000000000000..aae021e8e98d --- /dev/null +++ b/source/isaaclab/isaaclab/sensors/joint_wrench/__init__.py @@ -0,0 +1,10 @@ +# 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 + +"""Joint Wrench Sensor.""" + +from isaaclab.utils.module import lazy_export + +lazy_export() diff --git a/source/isaaclab/isaaclab/sensors/joint_wrench/__init__.pyi b/source/isaaclab/isaaclab/sensors/joint_wrench/__init__.pyi new file mode 100644 index 000000000000..97980db01c57 --- /dev/null +++ b/source/isaaclab/isaaclab/sensors/joint_wrench/__init__.pyi @@ -0,0 +1,18 @@ +# 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 + +__all__ = [ + "BaseJointWrenchSensor", + "BaseJointWrenchSensorData", + "JointWrenchSensor", + "JointWrenchSensorCfg", + "JointWrenchSensorData", +] + +from .base_joint_wrench_sensor import BaseJointWrenchSensor +from .base_joint_wrench_sensor_data import BaseJointWrenchSensorData +from .joint_wrench_sensor import JointWrenchSensor +from .joint_wrench_sensor_cfg import JointWrenchSensorCfg +from .joint_wrench_sensor_data import JointWrenchSensorData diff --git a/source/isaaclab/isaaclab/sensors/joint_wrench/base_joint_wrench_sensor.py b/source/isaaclab/isaaclab/sensors/joint_wrench/base_joint_wrench_sensor.py new file mode 100644 index 000000000000..e11811b451bb --- /dev/null +++ b/source/isaaclab/isaaclab/sensors/joint_wrench/base_joint_wrench_sensor.py @@ -0,0 +1,76 @@ +# 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 abc import abstractmethod +from typing import TYPE_CHECKING + +import warp as wp + +from ..sensor_base import SensorBase +from .base_joint_wrench_sensor_data import BaseJointWrenchSensorData + +if TYPE_CHECKING: + from .joint_wrench_sensor_cfg import JointWrenchSensorCfg + + +class BaseJointWrenchSensor(SensorBase): + """The joint reaction wrench sensor. + + Reports the incoming joint wrench on each joint of an articulation as a + split force [N] / torque [N·m] pair expressed in the + ``INCOMING_JOINT_FRAME`` convention (child-side joint frame, child-side + joint anchor reference point). Backends convert from their native + representation to this convention internally. + """ + + cfg: JointWrenchSensorCfg + """The configuration parameters.""" + + __backend_name__: str = "base" + """The name of the backend for the joint wrench sensor.""" + + def __init__(self, cfg: JointWrenchSensorCfg): + """Initialize the joint wrench sensor. + + Args: + cfg: The configuration parameters. + """ + super().__init__(cfg) + + """ + Properties + """ + + @property + @abstractmethod + def data(self) -> BaseJointWrenchSensorData: + """The sensor data container, populated after simulation initialization.""" + raise NotImplementedError + + @property + @abstractmethod + def body_names(self) -> list[str]: + """Ordered names of the bodies whose incoming joint wrench is reported.""" + raise NotImplementedError + + """ + Implementation - Abstract methods to be implemented by backend-specific subclasses. + """ + + @abstractmethod + def _initialize_impl(self) -> None: + """Initialize the sensor handles and internal buffers. + + Subclasses should call ``super()._initialize_impl()`` first to + initialize the common sensor infrastructure from + :class:`~isaaclab.sensors.SensorBase`. + """ + super()._initialize_impl() + + @abstractmethod + def _update_buffers_impl(self, env_mask: wp.array) -> None: + raise NotImplementedError diff --git a/source/isaaclab/isaaclab/sensors/joint_wrench/base_joint_wrench_sensor_data.py b/source/isaaclab/isaaclab/sensors/joint_wrench/base_joint_wrench_sensor_data.py new file mode 100644 index 000000000000..fd1380212990 --- /dev/null +++ b/source/isaaclab/isaaclab/sensors/joint_wrench/base_joint_wrench_sensor_data.py @@ -0,0 +1,42 @@ +# 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 + +"""Base class for joint-wrench sensor data containers.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + +from isaaclab.utils.warp import ProxyArray + + +class BaseJointWrenchSensorData(ABC): + """Data container for the joint reaction wrench sensor.""" + + @property + @abstractmethod + def force(self) -> ProxyArray | None: + """Linear component of the joint reaction wrench [N]. + + Expressed in the frame selected by + :attr:`~isaaclab.sensors.JointWrenchSensorCfg.convention`. Shape is + ``(num_envs, num_joints)``, dtype ``wp.vec3f``. In torch this resolves + to ``(num_envs, num_joints, 3)``. ``None`` before the simulation is + initialized. + """ + raise NotImplementedError + + @property + @abstractmethod + def torque(self) -> ProxyArray | None: + """Angular component of the joint reaction wrench [N·m]. + + Expressed in the frame selected by + :attr:`~isaaclab.sensors.JointWrenchSensorCfg.convention`. Shape is + ``(num_envs, num_joints)``, dtype ``wp.vec3f``. In torch this resolves + to ``(num_envs, num_joints, 3)``. ``None`` before the simulation is + initialized. + """ + raise NotImplementedError diff --git a/source/isaaclab/isaaclab/sensors/joint_wrench/joint_wrench_sensor.py b/source/isaaclab/isaaclab/sensors/joint_wrench/joint_wrench_sensor.py new file mode 100644 index 000000000000..f4a88d94b978 --- /dev/null +++ b/source/isaaclab/isaaclab/sensors/joint_wrench/joint_wrench_sensor.py @@ -0,0 +1,27 @@ +# 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 typing import TYPE_CHECKING + +from isaaclab.utils.backend_utils import FactoryBase + +from .base_joint_wrench_sensor import BaseJointWrenchSensor +from .base_joint_wrench_sensor_data import BaseJointWrenchSensorData + +if TYPE_CHECKING: + from isaaclab_newton.sensors.joint_wrench import JointWrenchSensor as NewtonJointWrenchSensor + from isaaclab_newton.sensors.joint_wrench import JointWrenchSensorData as NewtonJointWrenchSensorData + + +class JointWrenchSensor(FactoryBase, BaseJointWrenchSensor): + """Factory for creating joint-wrench sensor instances.""" + + data: BaseJointWrenchSensorData | NewtonJointWrenchSensorData + + def __new__(cls, *args, **kwargs) -> BaseJointWrenchSensor | NewtonJointWrenchSensor: + """Create a new instance of a joint-wrench sensor based on the backend.""" + return super().__new__(cls, *args, **kwargs) diff --git a/source/isaaclab/isaaclab/sensors/joint_wrench/joint_wrench_sensor_cfg.py b/source/isaaclab/isaaclab/sensors/joint_wrench/joint_wrench_sensor_cfg.py new file mode 100644 index 000000000000..aae63076156b --- /dev/null +++ b/source/isaaclab/isaaclab/sensors/joint_wrench/joint_wrench_sensor_cfg.py @@ -0,0 +1,30 @@ +# 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 typing import TYPE_CHECKING, Literal + +from isaaclab.utils import configclass + +from ..sensor_base_cfg import SensorBaseCfg + +if TYPE_CHECKING: + from .joint_wrench_sensor import JointWrenchSensor + + +@configclass +class JointWrenchSensorCfg(SensorBaseCfg): + """Configuration for a joint reaction wrench sensor.""" + + class_type: type[JointWrenchSensor] | str = "{DIR}.joint_wrench_sensor:JointWrenchSensor" + + convention: Literal["incoming_joint_frame"] = "incoming_joint_frame" + """Coordinate convention for the reported wrench. Defaults to ``"incoming_joint_frame"``. + + - ``"incoming_joint_frame"`` — child-side joint frame, child-side joint anchor as reference point. + Matches what a real 6-axis F/T sensor mounted at the joint would measure. This is the same + as PhysX convention in IsaacLab2.3 + """ diff --git a/source/isaaclab/isaaclab/sensors/joint_wrench/joint_wrench_sensor_data.py b/source/isaaclab/isaaclab/sensors/joint_wrench/joint_wrench_sensor_data.py new file mode 100644 index 000000000000..66bc9d1214bf --- /dev/null +++ b/source/isaaclab/isaaclab/sensors/joint_wrench/joint_wrench_sensor_data.py @@ -0,0 +1,25 @@ +# 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 + +"""Factory class for joint-wrench sensor data.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from isaaclab.utils.backend_utils import FactoryBase + +from .base_joint_wrench_sensor_data import BaseJointWrenchSensorData + +if TYPE_CHECKING: + from isaaclab_newton.sensors.joint_wrench import JointWrenchSensorData as NewtonJointWrenchSensorData + + +class JointWrenchSensorData(FactoryBase, BaseJointWrenchSensorData): + """Factory for creating joint-wrench sensor data instances.""" + + def __new__(cls, *args, **kwargs) -> BaseJointWrenchSensorData | NewtonJointWrenchSensorData: + """Create a new instance of joint-wrench sensor data based on the backend.""" + return super().__new__(cls, *args, **kwargs) diff --git a/source/isaaclab_newton/config/extension.toml b/source/isaaclab_newton/config/extension.toml index 03b74794b012..0a8eed8000c2 100644 --- a/source/isaaclab_newton/config/extension.toml +++ b/source/isaaclab_newton/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "0.5.25" +version = "0.5.26" # Description title = "Newton simulation interfaces for IsaacLab core package" diff --git a/source/isaaclab_newton/docs/CHANGELOG.rst b/source/isaaclab_newton/docs/CHANGELOG.rst index e883be590630..d9626a890476 100644 --- a/source/isaaclab_newton/docs/CHANGELOG.rst +++ b/source/isaaclab_newton/docs/CHANGELOG.rst @@ -1,6 +1,15 @@ Changelog --------- +0.5.26 (2026-04-30) +~~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :class:`~isaaclab_newton.sensors.JointWrenchSensor`. + + 0.5.25 (2026-04-28) ~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_newton/isaaclab_newton/sensors/__init__.pyi b/source/isaaclab_newton/isaaclab_newton/sensors/__init__.pyi index 0eba5ef7bdcf..e536b281952b 100644 --- a/source/isaaclab_newton/isaaclab_newton/sensors/__init__.pyi +++ b/source/isaaclab_newton/isaaclab_newton/sensors/__init__.pyi @@ -11,6 +11,8 @@ __all__ = [ "FrameTransformerData", "Imu", "ImuData", + "JointWrenchSensor", + "JointWrenchSensorData", "Pva", "PvaData", ] @@ -18,4 +20,5 @@ __all__ = [ from .contact_sensor import ContactSensor, ContactSensorData, ContactSensorCfg from .frame_transformer import FrameTransformer, FrameTransformerData from .imu import Imu, ImuData +from .joint_wrench import JointWrenchSensor, JointWrenchSensorData from .pva import Pva, PvaData diff --git a/source/isaaclab_newton/isaaclab_newton/sensors/joint_wrench/__init__.py b/source/isaaclab_newton/isaaclab_newton/sensors/joint_wrench/__init__.py new file mode 100644 index 000000000000..fb10acc86182 --- /dev/null +++ b/source/isaaclab_newton/isaaclab_newton/sensors/joint_wrench/__init__.py @@ -0,0 +1,10 @@ +# 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 + +"""Sub-module for the Newton joint-wrench sensor.""" + +from isaaclab.utils.module import lazy_export + +lazy_export() diff --git a/source/isaaclab_newton/isaaclab_newton/sensors/joint_wrench/__init__.pyi b/source/isaaclab_newton/isaaclab_newton/sensors/joint_wrench/__init__.pyi new file mode 100644 index 000000000000..b2bcd3582d44 --- /dev/null +++ b/source/isaaclab_newton/isaaclab_newton/sensors/joint_wrench/__init__.pyi @@ -0,0 +1,9 @@ +# 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 + +__all__ = ["JointWrenchSensor", "JointWrenchSensorData"] + +from .joint_wrench_sensor import JointWrenchSensor +from .joint_wrench_sensor_data import JointWrenchSensorData diff --git a/source/isaaclab_newton/isaaclab_newton/sensors/joint_wrench/joint_wrench_sensor.py b/source/isaaclab_newton/isaaclab_newton/sensors/joint_wrench/joint_wrench_sensor.py new file mode 100644 index 000000000000..4f59487e882d --- /dev/null +++ b/source/isaaclab_newton/isaaclab_newton/sensors/joint_wrench/joint_wrench_sensor.py @@ -0,0 +1,216 @@ +# 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 + +import logging +from collections.abc import Sequence +from typing import TYPE_CHECKING + +import warp as wp +from newton import JointType +from newton.selection import ArticulationView + +from isaaclab.sensors.joint_wrench import BaseJointWrenchSensor + +from isaaclab_newton.physics import NewtonManager + +from .joint_wrench_sensor_data import JointWrenchSensorData +from .kernels import joint_wrench_reset_kernel, joint_wrench_to_incoming_joint_frame_kernel + +if TYPE_CHECKING: + from isaaclab.sensors.joint_wrench import JointWrenchSensorCfg + +logger = logging.getLogger(__name__) + + +class JointWrenchSensor(BaseJointWrenchSensor): + """Newton joint reaction wrench sensor. + + Reads Newton's ``body_parent_f`` (world-frame wrench at child COM) and + converts each entry to the ``INCOMING_JOINT_FRAME`` convention + (child-side joint frame, child-side joint anchor as reference point) + before storing it in per-joint force / torque buffers. + + :attr:`~isaaclab.sensors.SensorBaseCfg.prim_path` must point at the + articulation root prim (the one carrying ``ArticulationRootAPI``) in + every environment; the sensor uses it as the + :class:`~newton.selection.ArticulationView` pattern directly. ``FREE`` + and ``FIXED`` joints are excluded — neither has a meaningful joint + anchor. + """ + + cfg: JointWrenchSensorCfg + """The configuration parameters.""" + + __backend_name__: str = "newton" + """The name of the backend for the joint wrench sensor.""" + + def __init__(self, cfg: JointWrenchSensorCfg): + """Initialize the Newton joint-wrench sensor. + + Requests the ``body_parent_f`` extended state attribute from :class:`NewtonManager` so the + model builder allocates it during simulation startup. + + Args: + cfg: The configuration parameters. + """ + super().__init__(cfg) + + self._data = JointWrenchSensorData() + self._root_view: ArticulationView | None = None + self._sim_bind_body_parent_f: wp.array | None = None + self._sim_bind_body_q: wp.array | None = None + self._sim_bind_body_com: wp.array | None = None + self._sim_bind_joint_X_c: wp.array | None = None + self._joint_child: wp.array | None = None + self._num_joints: int = 0 + + NewtonManager.request_extended_state_attribute("body_parent_f") + + def __str__(self) -> str: + """String representation of the sensor instance.""" + return ( + f"Joint wrench sensor @ '{self.cfg.prim_path}': \n" + f"\tbackend : newton\n" + f"\tupdate period (s) : {self.cfg.update_period}\n" + f"\tnumber of joints : {self._num_joints}\n" + ) + + """ + Properties + """ + + @property + def body_names(self) -> list[str]: + """Ordered names of the bodies whose incoming joint wrench is reported.""" + return self._data._body_names + + @property + def data(self) -> JointWrenchSensorData: + """The joint-wrench sensor data.""" + self._update_outdated_buffers() + return self._data + + """ + Operations + """ + + def reset(self, env_ids: Sequence[int] | None = None, env_mask: wp.array | None = None) -> None: + """Reset the sensor buffers for the given environments. + + Args: + env_ids: the environment ids to reset. + env_mask: the mask used to reset the environments. Shape is (num_envs).""" + if self._data._force is None or self._data._torque is None: + return + env_mask = self._resolve_indices_and_mask(env_ids, env_mask) + super().reset(None, env_mask) + wp.launch( + joint_wrench_reset_kernel, + dim=(self._num_envs, self._num_joints), + inputs=[env_mask, self._data._force, self._data._torque], + device=self._device, + ) + + """ + Implementation + """ + + def _initialize_impl(self) -> None: + """PHYSICS_READY callback: builds the articulation view and binds model / state arrays.""" + super()._initialize_impl() + + model = NewtonManager.get_model() + state_0 = NewtonManager.get_state_0() + + self._root_view = ArticulationView( + model, + self.cfg.prim_path.replace(".*", "*"), + verbose=False, + exclude_joint_types=[JointType.FREE, JointType.FIXED], + ) + self._num_joints = self._root_view.joint_count + if self._num_joints == 0: + raise RuntimeError( + "Joint wrench sensor matched zero reportable joints (all joints are FREE or FIXED)." + f" Check the articulation at '{self.cfg.prim_path}'." + ) + + try: + body_parent_f = self._root_view.get_attribute("body_parent_f", state_0) + except AttributeError as err: + raise RuntimeError( + f"Joint wrench sensor '{self.cfg.prim_path}': Newton state does not expose" + " 'body_parent_f'. Construct the sensor before sim startup so the extended-state" + " request is forwarded to the model builder." + ) from err + + self._sim_bind_body_parent_f = body_parent_f[:, 0] + self._sim_bind_body_q = self._root_view.get_link_transforms(state_0)[:, 0] + self._sim_bind_body_com = self._root_view.get_attribute("body_com", model)[:, 0] + self._sim_bind_joint_X_c = self._root_view.get_attribute("joint_X_c", model)[:, 0] + + # joint_child is per-articulation; topology is identical across envs, + # so we take the first-env mapping as the 1-D kernel input. + joint_child_full = self._root_view.get_attribute("joint_child", model)[:, 0] + joint_child_np = joint_child_full.numpy()[0] + if not all(0 <= b < self._sim_bind_body_parent_f.shape[1] for b in joint_child_np): + raise RuntimeError(f"joint_child contains out-of-range body indices for '{self.cfg.prim_path}'") + self._joint_child = wp.array(joint_child_np, dtype=wp.int32, device=self._device) + + link_names = list(self._root_view.link_names) + self._data._body_names = [link_names[int(b)] for b in joint_child_np] + + self._data.create_buffers(num_envs=self._num_envs, num_joints=self._num_joints, device=self._device) + + logger.info(f"Joint wrench sensor initialized: {self._num_envs} envs, {self._num_joints} joints") + + def _update_buffers_impl(self, env_mask: wp.array) -> None: + """Convert Newton's body_parent_f into INCOMING_JOINT_FRAME force and torque buffers. + + Args: + env_mask: A mask containing which environments need to be updated. Shape is (num_envs) + """ + if self._sim_bind_body_parent_f is None: + raise RuntimeError( + f"Joint wrench sensor '{self.cfg.prim_path}': not initialized." + " Access sensor data only after sim.reset() has been called." + ) + wp.launch( + joint_wrench_to_incoming_joint_frame_kernel, + dim=(self._num_envs, self._num_joints), + inputs=[ + env_mask, + self._sim_bind_body_parent_f, + self._sim_bind_body_q, + self._sim_bind_body_com, + self._sim_bind_joint_X_c, + self._joint_child, + ], + outputs=[self._data._force, self._data._torque], + device=self._device, + ) + + def _invalidate_initialize_callback(self, event) -> None: + """Drop view, cached sizes, and buffers; re-register the extended-state request. + + Args: + event: An invalidate event. + """ + super()._invalidate_initialize_callback(event) + self._root_view = None + self._sim_bind_body_parent_f = None + self._sim_bind_body_q = None + self._sim_bind_body_com = None + self._sim_bind_joint_X_c = None + self._joint_child = None + self._num_joints = 0 + self._data._force = None + self._data._torque = None + self._data._body_names = [] + self._data._force_ta = None + self._data._torque_ta = None + NewtonManager.request_extended_state_attribute("body_parent_f") diff --git a/source/isaaclab_newton/isaaclab_newton/sensors/joint_wrench/joint_wrench_sensor_data.py b/source/isaaclab_newton/isaaclab_newton/sensors/joint_wrench/joint_wrench_sensor_data.py new file mode 100644 index 000000000000..640fd784978c --- /dev/null +++ b/source/isaaclab_newton/isaaclab_newton/sensors/joint_wrench/joint_wrench_sensor_data.py @@ -0,0 +1,67 @@ +# 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 + +import warp as wp + +from isaaclab.sensors.joint_wrench import BaseJointWrenchSensorData +from isaaclab.utils.warp import ProxyArray + + +class JointWrenchSensorData(BaseJointWrenchSensorData): + """Data container for the Newton joint-wrench sensor.""" + + def __init__(self): + self._force: wp.array | None = None + self._torque: wp.array | None = None + self._body_names: list[str] = [] + self._force_ta: ProxyArray | None = None + self._torque_ta: ProxyArray | None = None + + @property + def force(self) -> ProxyArray | None: + """Linear component of the joint reaction wrench [N]. + + Expressed in the frame selected by + :attr:`~isaaclab.sensors.JointWrenchSensorCfg.convention`. Shape is + ``(num_envs, num_joints)``, dtype ``wp.vec3f``. In torch this resolves + to ``(num_envs, num_joints, 3)``. ``None`` before the simulation is + initialized. + """ + if self._force is None: + return None + if self._force_ta is None: + self._force_ta = ProxyArray(self._force) + return self._force_ta + + @property + def torque(self) -> ProxyArray | None: + """Angular component of the joint reaction wrench [N·m]. + + Expressed in the frame selected by + :attr:`~isaaclab.sensors.JointWrenchSensorCfg.convention`. Shape is + ``(num_envs, num_joints)``, dtype ``wp.vec3f``. In torch this resolves + to ``(num_envs, num_joints, 3)``. ``None`` before the simulation is + initialized. + """ + if self._torque is None: + return None + if self._torque_ta is None: + self._torque_ta = ProxyArray(self._torque) + return self._torque_ta + + def create_buffers(self, num_envs: int, num_joints: int, device: str) -> None: + """Allocate internal buffers. + + Args: + num_envs: Number of environments. + num_joints: Number of reported joints (excludes FREE and FIXED joint types). + device: Device for array storage. + """ + self._force = wp.zeros((num_envs, num_joints), dtype=wp.vec3f, device=device) + self._torque = wp.zeros((num_envs, num_joints), dtype=wp.vec3f, device=device) + self._force_ta = None + self._torque_ta = None diff --git a/source/isaaclab_newton/isaaclab_newton/sensors/joint_wrench/kernels.py b/source/isaaclab_newton/isaaclab_newton/sensors/joint_wrench/kernels.py new file mode 100644 index 000000000000..64a0b4f0dda2 --- /dev/null +++ b/source/isaaclab_newton/isaaclab_newton/sensors/joint_wrench/kernels.py @@ -0,0 +1,80 @@ +# 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 warp as wp + + +@wp.kernel +def joint_wrench_to_incoming_joint_frame_kernel( + env_mask: wp.array(dtype=wp.bool), + body_parent_f: wp.array(dtype=wp.spatial_vectorf, ndim=2), + body_q: wp.array(dtype=wp.transformf, ndim=2), + body_com: wp.array(dtype=wp.vec3f, ndim=2), + joint_X_c: wp.array(dtype=wp.transformf, ndim=2), + joint_child: wp.array(dtype=wp.int32), + out_force: wp.array(dtype=wp.vec3f, ndim=2), + out_torque: wp.array(dtype=wp.vec3f, ndim=2), +): + """Convert Newton's ``body_parent_f`` to the INCOMING_JOINT_FRAME convention. + + Newton reports ``body_parent_f[env, body]`` as a spatial wrench in world frame, referenced at + the child body's centre of mass. The output is that same wrench re-expressed in the child-side + joint frame and with the child-side joint anchor as the reference point — matching what a 6-axis + force/torque sensor mounted at the joint would measure. + + Args: + env_mask: Boolean mask selecting which environments to update. + body_parent_f: Newton state — world-frame spatial wrench at child COM ``(num_envs, num_bodies)``. + body_q: Newton state — child link transforms in world frame ``(num_envs, num_bodies)``. + body_com: Newton model — COM offset in link-local frame ``(num_envs, num_bodies)``. + joint_X_c: Newton model — child-side joint frame relative to child link ``(num_envs, num_joints)``. + joint_child: Newton model — body index of each joint's child link ``(num_joints,)``. + out_force: Output force in joint frame [N] ``(num_envs, num_joints)``. + out_torque: Output torque in joint frame [N·m] ``(num_envs, num_joints)``. + """ + env, j = wp.tid() + if not env_mask[env]: + return + + body_idx = joint_child[j] + + # Source wrench in world frame. Newton's body_parent_f stores (force, torque-about-COM). + src = body_parent_f[env, body_idx] + f_world = wp.spatial_top(src) + tau_world_com = wp.spatial_bottom(src) + + # Child link transform in world and COM offset in link frame. + link_xform = body_q[env, body_idx] + link_quat = wp.transform_get_rotation(link_xform) + link_pos = wp.transform_get_translation(link_xform) + com_world = link_pos + wp.quat_rotate(link_quat, body_com[env, body_idx]) + + # Child-side joint frame in world = body link pose composed with joint_X_c. + joint_xform_world = link_xform * joint_X_c[env, j] + anchor_world = wp.transform_get_translation(joint_xform_world) + joint_quat_world = wp.transform_get_rotation(joint_xform_world) + + # Shift torque reference from COM to joint anchor: + # tau_anchor = tau_com + (com - anchor) x f = tau_com + r_anchor_to_com x f. + r_anchor_to_com = com_world - anchor_world + tau_world_anchor = tau_world_com + wp.cross(r_anchor_to_com, f_world) + + # Rotate both components into the child-side joint frame. + out_force[env, j] = wp.quat_rotate_inv(joint_quat_world, f_world) + out_torque[env, j] = wp.quat_rotate_inv(joint_quat_world, tau_world_anchor) + + +@wp.kernel +def joint_wrench_reset_kernel( + env_mask: wp.array(dtype=wp.bool), + out_force: wp.array(dtype=wp.vec3f, ndim=2), + out_torque: wp.array(dtype=wp.vec3f, ndim=2), +): + """Zero force / torque entries for the environments selected by ``env_mask``.""" + env, joint = wp.tid() + if not env_mask[env]: + return + out_force[env, joint] = wp.vec3f(0.0, 0.0, 0.0) + out_torque[env, joint] = wp.vec3f(0.0, 0.0, 0.0) diff --git a/source/isaaclab_newton/test/sensors/test_joint_wrench_sensor.py b/source/isaaclab_newton/test/sensors/test_joint_wrench_sensor.py new file mode 100644 index 000000000000..7e0ac0ea6960 --- /dev/null +++ b/source/isaaclab_newton/test/sensors/test_joint_wrench_sensor.py @@ -0,0 +1,460 @@ +# 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 Newton JointWrenchSensor.""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import pytest +import torch +import warp as wp +from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg + +import isaaclab.sim as sim_utils +from isaaclab.actuators import ImplicitActuatorCfg +from isaaclab.assets import Articulation, ArticulationCfg +from isaaclab.scene import InteractiveScene, InteractiveSceneCfg +from isaaclab.sensors.joint_wrench import JointWrenchSensor, JointWrenchSensorCfg +from isaaclab.sim import SimulationCfg +from isaaclab.terrains import TerrainImporterCfg +from isaaclab.utils import configclass +from isaaclab.utils import math as math_utils +from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR, ISAACLAB_NUCLEUS_DIR + + +def _make_single_joint_articulation_cfg() -> ArticulationCfg: + """Single-joint revolute test articulation (root ``CenterPivot`` + arm ``Arm``).""" + return ArticulationCfg( + prim_path="{ENV_REGEX_NS}/Robot", + spawn=sim_utils.UsdFileCfg( + usd_path=f"{ISAAC_NUCLEUS_DIR}/Robots/IsaacSim/SimpleArticulation/revolute_articulation.usd", + joint_drive_props=sim_utils.JointDrivePropertiesCfg(max_effort=80.0, max_velocity=5.0), + ), + actuators={ + "joint": ImplicitActuatorCfg( + joint_names_expr=[".*"], + stiffness=2000.0, + damping=100.0, + ), + }, + init_state=ArticulationCfg.InitialStateCfg(pos=(0.0, 0.0, 1.0)), + ) + + +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"], effort_limit_sim=400.0, stiffness=0.0, damping=10.0 + ), + "pole_actuator": ImplicitActuatorCfg( + joint_names_expr=["cart_to_pole"], effort_limit_sim=400.0, stiffness=0.0, damping=pole_damping + ), + }, + ) + + +@configclass +class _SingleJointSceneCfg(InteractiveSceneCfg): + """Scene with a single-joint articulation and the joint-wrench sensor.""" + + env_spacing = 2.0 + terrain = TerrainImporterCfg(prim_path="/World/ground", terrain_type="plane") + robot = _make_single_joint_articulation_cfg() + 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") + + +@pytest.fixture +def sim(): + """Simulation context using the Newton backend.""" + sim_cfg = SimulationCfg( + dt=1.0 / 200.0, + physics=NewtonCfg( + solver_cfg=MJWarpSolverCfg(), + num_substeps=1, + ), + ) + with sim_utils.build_simulation_context(sim_cfg=sim_cfg) as sim_ctx: + sim_ctx._app_control_on_stop_handle = None + yield sim_ctx + + +# --------------------------------------------------------------------------- +# Sensor data — pre-init contract +# --------------------------------------------------------------------------- + + +def test_data_before_init_is_none(): + """``force``/``torque`` return ``None`` before :meth:`create_buffers` runs.""" + from isaaclab_newton.sensors.joint_wrench import JointWrenchSensorData + + data = JointWrenchSensorData() + assert data.force is None + assert data.torque is None + + +# --------------------------------------------------------------------------- +# Initialization and shapes +# --------------------------------------------------------------------------- + + +def test_initialization_and_shapes(sim): + """Sensor initializes on sim reset and exposes correctly-shaped buffers.""" + scene = InteractiveScene(_SingleJointSceneCfg(num_envs=2)) + sim.reset() + + sensor: JointWrenchSensor = scene["wrench"] + sim.step() + scene.update(sim.get_physics_dt()) + + # revolute_articulation has one joint whose child is "Arm". + num_envs = 2 + num_joints = 1 + assert sensor.data.force.torch.shape == (num_envs, num_joints, 3) + assert sensor.data.torque.torch.shape == (num_envs, num_joints, 3) + assert sensor.body_names == ["Arm"] + + +def test_multi_body_articulation(sim): + """Cartpole (2 joints) exposes a wrench for each joint labelled by its child body.""" + scene = InteractiveScene(_CartpoleSceneCfg(num_envs=2)) + sim.reset() + + sensor: JointWrenchSensor = scene["wrench"] + sim.step() + scene.update(sim.get_physics_dt()) + + num_envs = 2 + num_joints = 2 + assert sensor.data.force.torch.shape == (num_envs, num_joints, 3) + assert sensor.data.torque.torch.shape == (num_envs, num_joints, 3) + assert len(sensor.body_names) == 2 + assert "rail" not in [n.lower() for n in sensor.body_names] + + +# --------------------------------------------------------------------------- +# Physical correctness +# --------------------------------------------------------------------------- + + +def _compute_expected_wrench_in_joint_frame( + sensor, + robot, + env: int, + joint: int, + gravity: torch.Tensor, + ext_force_b: torch.Tensor | None = None, + ext_torque_b: torch.Tensor | None = None, + descendant_body_names: list[str] | None = None, +): + """Compute the analytical joint-frame wrench for a single joint. + + Uses the same geometric data (body_com, joint_X_c, body_q) and frame + transformations as the kernel, but computes the wrench analytically from + known loads rather than reading body_parent_f. Computes the moment of + forces about the joint anchor and rotates the result into the child-side + joint frame. + + For terminal links, the wrench is due to the child body alone. For + interior joints, pass all bodies in the subtree below the joint via + ``descendant_body_names`` so the helper sums their gravitational + contributions. + + Args: + sensor: The JointWrenchSensor instance (used to read Newton model bindings). + robot: The Articulation asset (used for body mass lookup). + env: Environment index. + joint: Joint index within the sensor. + gravity: Gravity vector in world frame, shape (3,). + ext_force_b: External force on the child body in body frame [N], shape (3,). + ext_torque_b: External torque on the child body in body frame [N·m], shape (3,). + descendant_body_names: Bodies whose gravitational load acts through this + joint. Defaults to the child body only (correct for terminal links). + For an interior joint, pass all bodies in the subtree below the joint. + + Returns: + A tuple of (force, torque) tensors, each shape (3,), in the child-side + joint frame. + """ + body_idx = wp.to_torch(sensor._joint_child)[joint].item() + + # Link transform in world (of the child body — defines the joint frame). + link_xform = wp.to_torch(sensor._sim_bind_body_q)[env, body_idx] # (7,) = pos(3) + quat(4) + link_pos = link_xform[:3] + link_quat = link_xform[3:] # wp.quatf = (x, y, z, w) + + # Joint anchor and orientation in world = link_xform * joint_X_c. + joint_X_c = wp.to_torch(sensor._sim_bind_joint_X_c)[env, joint] # (7,) + jxc_pos = joint_X_c[:3] + jxc_quat = joint_X_c[3:] + anchor_world = link_pos + math_utils.quat_apply(link_quat.unsqueeze(0), jxc_pos.unsqueeze(0)).squeeze(0) + joint_quat_world = math_utils.quat_mul(link_quat.unsqueeze(0), jxc_quat.unsqueeze(0)).squeeze(0) + + # Bodies whose weight contributes to the wrench at this joint. + if descendant_body_names is None: + descendant_body_names = [sensor.body_names[joint]] + + link_names = list(sensor._root_view.link_names) + + total_force_w = torch.zeros(3, device=gravity.device) + total_torque_w = torch.zeros(3, device=gravity.device) + + for body_name in descendant_body_names: + b_idx = link_names.index(body_name) + b_xform = wp.to_torch(sensor._sim_bind_body_q)[env, b_idx] + b_pos = b_xform[:3] + b_quat = b_xform[3:] + b_com_local = wp.to_torch(sensor._sim_bind_body_com)[env, b_idx] + b_com_world = b_pos + math_utils.quat_apply(b_quat.unsqueeze(0), b_com_local.unsqueeze(0)).squeeze(0) + + art_b_idx = robot.body_names.index(body_name) + mass = robot.data.body_mass.torch[env, art_b_idx].item() + weight_w = mass * gravity + + total_force_w = total_force_w + weight_w + r = b_com_world - anchor_world + total_torque_w = total_torque_w + torch.cross(r, weight_w, dim=-1) + + # External force/torque on the child body only (if provided). Actuator + # torque is intentionally omitted; see tolerance comment in calling tests. + if ext_force_b is not None: + ext_force_w = math_utils.quat_apply(link_quat.unsqueeze(0), ext_force_b.unsqueeze(0)).squeeze(0) + total_force_w = total_force_w + ext_force_w + # Moment of the external force about the joint anchor (applied at child COM). + child_com_local = wp.to_torch(sensor._sim_bind_body_com)[env, body_idx] + child_com_world = link_pos + math_utils.quat_apply( + link_quat.unsqueeze(0), child_com_local.unsqueeze(0) + ).squeeze(0) + total_torque_w = total_torque_w + torch.cross(child_com_world - anchor_world, ext_force_w, dim=-1) + if ext_torque_b is not None: + total_torque_w = total_torque_w + math_utils.quat_apply( + link_quat.unsqueeze(0), ext_torque_b.unsqueeze(0) + ).squeeze(0) + + # Reaction wrench = negation of total wrench (joint supports against all loads). + reaction_force_w = -total_force_w + reaction_torque_w = -total_torque_w + + # Rotate into joint frame. + expected_force = math_utils.quat_apply_inverse( + joint_quat_world.unsqueeze(0), reaction_force_w.unsqueeze(0) + ).squeeze(0) + expected_torque = math_utils.quat_apply_inverse( + joint_quat_world.unsqueeze(0), reaction_torque_w.unsqueeze(0) + ).squeeze(0) + + return expected_force, expected_torque + + +def test_force_and_torque_components_at_rest(sim): + """Component-level validation of force and torque against analytical expectations (gravity only).""" + 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()) + + gravity = torch.tensor(sim.cfg.gravity, device=sim.device) + expected_force, expected_torque = _compute_expected_wrench_in_joint_frame( + sensor, + robot, + env=0, + joint=0, + gravity=gravity, + ) + + force = sensor.data.force.torch[0, 0] + torque = sensor.data.torque.torch[0, 0] + + torch.testing.assert_close(force, expected_force, atol=1e-2, rtol=1e-3) + torch.testing.assert_close(torque, expected_torque, atol=1e-2, rtol=1e-3) + + +def test_wrench_with_external_force_and_torque(sim): + """Full analytical wrench validation with external force and torque applied. + + Mirrors the PhysX ``test_body_incoming_joint_wrench_b_single_joint`` pattern: + apply a known wrench, settle, compute the expected reaction wrench analytically, + and compare component-by-component. + """ + 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 PhysX 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()) + + gravity = torch.tensor(sim.cfg.gravity, device=sim.device) + expected_force, expected_torque = _compute_expected_wrench_in_joint_frame( + sensor, + robot, + env=0, + joint=0, + gravity=gravity, + ext_force_b=ext_force_b[0, arm_idx], + ext_torque_b=ext_torque_b[0, arm_idx], + ) + + force = sensor.data.force.torch[0, 0] + torque = sensor.data.torque.torch[0, 0] + + # The PD actuator contributes a small torque (~0.1 N·m) to body_parent_f that is + # not modelled in the analytical helper. Force is unaffected (actuator is pure torque). + torch.testing.assert_close(force, expected_force, atol=1e-2, rtol=1e-3) + torch.testing.assert_close(torque, expected_torque, atol=0.15, rtol=1e-2) + + +def test_interior_joint_wrench_at_rest(sim): + """Interior joint wrench accounts for the weight of all descendant bodies. + + The cartpole has two joints: ``slider_to_cart`` (interior, supports cart + and pole) and ``cart_to_pole`` (terminal, supports pole only). At steady + state with gravity as the only load, the reaction wrench at the interior + joint must equal the combined weight of cart and pole, with torque + computed from each body's moment about the joint anchor. + """ + 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()) + + gravity = torch.tensor(sim.cfg.gravity, device=sim.device) + + # Interior joint (index 0, slider_to_cart): reaction wrench supports + # all bodies in the subtree — both cart and pole. + expected_force, expected_torque = _compute_expected_wrench_in_joint_frame( + sensor, + robot, + env=0, + joint=0, + gravity=gravity, + descendant_body_names=list(sensor.body_names), + ) + + force = sensor.data.force.torch[0, 0] + torque = sensor.data.torque.torch[0, 0] + + torch.testing.assert_close(force, expected_force, atol=1e-2, rtol=1e-3) + torch.testing.assert_close(torque, expected_torque, atol=1e-2, rtol=1e-3) + + +# --------------------------------------------------------------------------- +# 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 "newton" 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 Newton 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.""" + scene = InteractiveScene(_SingleJointSceneCfg(num_envs=4)) + sim.reset() + + sensor: JointWrenchSensor = scene["wrench"] + for _ in range(100): + sim.step() + scene.update(sim.get_physics_dt()) + + force_before = sensor.data.force.torch.clone() + assert torch.any(force_before != 0), "Expected non-zero data before reset" + + sensor.reset(env_ids=[0, 2]) + + force_after = wp.to_torch(sensor._data._force) + torch.testing.assert_close(force_after[0], torch.zeros_like(force_after[0])) + torch.testing.assert_close(force_after[2], torch.zeros_like(force_after[2])) + torch.testing.assert_close(force_after[1], force_before[1]) + torch.testing.assert_close(force_after[3], force_before[3]) From d48075aacf40d579e9637d24c47640714c13dc94 Mon Sep 17 00:00:00 2001 From: frlai Date: Sun, 3 May 2026 02:07:43 -0700 Subject: [PATCH 29/40] Adds LEAPP export integration (#5105) # Description FEATURE: adds new LEAPP export functionality that targets manged environments that using rsl_rl. Policies can be exported end to end. Also adds a new direct deployment environment to deploy exported policies, bypassing all the manager classes. ## Type of change - 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 updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Signed-off-by: frlai --- .github/actions/run-package-tests/action.yml | 5 + .github/actions/run-tests/action.yml | 19 +- .github/workflows/build.yaml | 1 + CONTRIBUTORS.md | 1 + .../02_gear_assembly/gear_assembly_policy.rst | 4 +- .../exporting_policies_with_leapp.rst | 251 +++++++ docs/source/policy_deployment/index.rst | 1 + ...ng_direct_workflow_policies_with_leapp.rst | 183 +++++ docs/source/tutorials/index.rst | 11 + .../reinforcement_learning/leapp/deploy.py | 82 ++ .../leapp/rsl_rl/export.py | 282 +++++++ scripts/tutorials/06_deploy/anymal_c_env.py | 208 +++++ .../changelog.d/leapp_export_integration.rst | 15 + .../assets/articulation/base_articulation.py | 7 + .../articulation/base_articulation_data.py | 147 +++- .../rigid_object/base_rigid_object_data.py | 81 +- .../base_rigid_object_collection_data.py | 94 ++- .../isaaclab/envs/leapp_deployment_env.py | 449 +++++++++++ .../envs/mdp/commands/pose_2d_command.py | 5 + .../envs/mdp/commands/pose_command.py | 8 +- .../envs/mdp/commands/velocity_command.py | 5 + .../isaaclab/managers/manager_term_cfg.py | 5 + .../base_contact_sensor_data.py | 20 + .../base_frame_transformer_data.py | 19 + .../isaaclab/sensors/imu/base_imu_data.py | 7 + .../isaaclab/sensors/pva/base_pva_data.py | 15 + .../sensors/ray_caster/ray_caster_data.py | 8 + .../isaaclab/utils/buffers/circular_buffer.py | 69 +- .../isaaclab/isaaclab/utils/leapp/__init__.py | 10 + .../isaaclab/utils/leapp/__init__.pyi | 61 ++ .../isaaclab/utils/leapp/export_annotator.py | 708 ++++++++++++++++++ .../isaaclab/utils/leapp/leapp_semantics.py | 145 ++++ source/isaaclab/isaaclab/utils/leapp/proxy.py | 521 +++++++++++++ source/isaaclab/isaaclab/utils/leapp/utils.py | 87 +++ .../changelog.d/leapp_export_integration.rst | 5 + .../export/test_rsl_rl_direct_export_flow.py | 194 +++++ .../test/export/test_rsl_rl_export_flow.py | 151 ++++ .../changelog.d/leapp_export_integration.rst | 5 + .../classic/humanoid/mdp/observations.py | 2 +- .../manipulation/deploy/mdp/observations.py | 3 +- .../dexsuite/mdp/commands/pose_commands.py | 14 +- .../mdp/commands/orientation_command.py | 6 + tools/test_settings.py | 1 + 43 files changed, 3861 insertions(+), 54 deletions(-) create mode 100644 docs/source/policy_deployment/05_leapp/exporting_policies_with_leapp.rst create mode 100644 docs/source/tutorials/06_exporting/exporting_direct_workflow_policies_with_leapp.rst create mode 100644 scripts/reinforcement_learning/leapp/deploy.py create mode 100644 scripts/reinforcement_learning/leapp/rsl_rl/export.py create mode 100644 scripts/tutorials/06_deploy/anymal_c_env.py create mode 100644 source/isaaclab/changelog.d/leapp_export_integration.rst create mode 100644 source/isaaclab/isaaclab/envs/leapp_deployment_env.py create mode 100644 source/isaaclab/isaaclab/utils/leapp/__init__.py create mode 100644 source/isaaclab/isaaclab/utils/leapp/__init__.pyi create mode 100644 source/isaaclab/isaaclab/utils/leapp/export_annotator.py create mode 100644 source/isaaclab/isaaclab/utils/leapp/leapp_semantics.py create mode 100644 source/isaaclab/isaaclab/utils/leapp/proxy.py create mode 100644 source/isaaclab/isaaclab/utils/leapp/utils.py create mode 100644 source/isaaclab_rl/changelog.d/leapp_export_integration.rst create mode 100644 source/isaaclab_rl/test/export/test_rsl_rl_direct_export_flow.py create mode 100644 source/isaaclab_rl/test/export/test_rsl_rl_export_flow.py create mode 100644 source/isaaclab_tasks/changelog.d/leapp_export_integration.rst diff --git a/.github/actions/run-package-tests/action.yml b/.github/actions/run-package-tests/action.yml index 30937e04bfd0..9d7e7200e875 100644 --- a/.github/actions/run-package-tests/action.yml +++ b/.github/actions/run-package-tests/action.yml @@ -55,6 +55,10 @@ inputs: description: 'Additional pytest options' default: '' required: false + extra-pip-packages: + description: 'Space-separated pip packages to install inside the Docker container before pytest starts' + default: '' + required: false container-name: description: 'Docker container name prefix (run-id is appended automatically)' required: true @@ -134,6 +138,7 @@ runs: quarantined-only: ${{ inputs.quarantined-only }} include-files: ${{ inputs.include-files }} volume-mount-source: ${{ github.workspace }} + extra-pip-packages: ${{ inputs.extra-pip-packages }} - name: Check Test Results if: always() diff --git a/.github/actions/run-tests/action.yml b/.github/actions/run-tests/action.yml index a005a4d9ed36..ab8a6c5e1caa 100644 --- a/.github/actions/run-tests/action.yml +++ b/.github/actions/run-tests/action.yml @@ -55,6 +55,10 @@ inputs: description: 'Host path to bind-mount at /workspace/isaaclab (for deps-cache-hit mode)' default: '' required: false + extra-pip-packages: + description: 'Space-separated pip packages to install inside the Docker container before pytest starts' + default: '' + required: false runs: using: composite @@ -77,6 +81,7 @@ runs: local shard_index="${11}" local shard_count="${12}" local volume_mount_source="${13}" + local extra_pip_packages="${14}" local logs_pid="" local wait_pid="" local docker_wait_file="/tmp/.docker_exit_${container_name}" @@ -97,6 +102,9 @@ runs: if [ -n "$pytest_options" ]; then echo "With pytest options: $pytest_options" fi + if [ -n "$extra_pip_packages" ]; then + echo "With extra pip packages: $extra_pip_packages" + fi if [ -n "$filter_pattern" ]; then echo "With filter pattern: $filter_pattern" fi @@ -167,6 +175,11 @@ runs: echo "No filter pattern provided" fi + if [ -n "$extra_pip_packages" ]; then + export TEST_EXTRA_PIP_PACKAGES="$extra_pip_packages" + docker_env_vars="$docker_env_vars -e TEST_EXTRA_PIP_PACKAGES" + fi + echo "Docker environment variables: '$docker_env_vars'" # Volume mount for deps-cache-hit mode: bind-mount the checked-out @@ -201,6 +214,10 @@ runs: mkdir -p tests rm _isaac_sim || true ln -s /isaac-sim _isaac_sim + if [ -n \"\${TEST_EXTRA_PIP_PACKAGES:-}\" ]; then + echo \"Installing extra pip packages: \${TEST_EXTRA_PIP_PACKAGES}\" + ./isaaclab.sh -p -m pip install \${TEST_EXTRA_PIP_PACKAGES} + fi echo 'Starting pytest with path: $test_path' ./isaaclab.sh -p -m pytest --ignore=tools/conftest.py --ignore=source/isaaclab/test/install_ci $test_path $pytest_options -v --junitxml=tests/$result_file " @@ -296,7 +313,7 @@ runs: } # Call the function with provided parameters - run_tests "${{ inputs.test-path }}" "${{ inputs.result-file }}" "${{ inputs.container-name }}" "${{ inputs.image-tag }}" "${{ inputs.reports-dir }}" "${{ inputs.pytest-options }}" "${{ inputs.filter-pattern }}" "${{ inputs.curobo-only }}" "${{ inputs.include-files }}" "${{ inputs.quarantined-only }}" "${{ inputs.shard-index }}" "${{ inputs.shard-count }}" "${{ inputs.volume-mount-source }}" + run_tests "${{ inputs.test-path }}" "${{ inputs.result-file }}" "${{ inputs.container-name }}" "${{ inputs.image-tag }}" "${{ inputs.reports-dir }}" "${{ inputs.pytest-options }}" "${{ inputs.filter-pattern }}" "${{ inputs.curobo-only }}" "${{ inputs.include-files }}" "${{ inputs.quarantined-only }}" "${{ inputs.shard-index }}" "${{ inputs.shard-count }}" "${{ inputs.volume-mount-source }}" "${{ inputs.extra-pip-packages }}" - name: Kill container on cancellation if: cancelled() diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index bb77da0be5d0..b2da2f9d1709 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -310,6 +310,7 @@ jobs: isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} filter-pattern: "isaaclab_rl" + extra-pip-packages: "leapp" container-name: isaac-lab-rl-test test-isaaclab-mimic: diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index b03f6b8cf231..1e0a9ab35fc2 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -82,6 +82,7 @@ Guidelines for modifications: * Fabian Jenelten * Felipe Mohr * Felix Yu +* Frank Lai * Gary Lvov * Giulio Romualdi * Grzegorz Malczyk diff --git a/docs/source/policy_deployment/02_gear_assembly/gear_assembly_policy.rst b/docs/source/policy_deployment/02_gear_assembly/gear_assembly_policy.rst index 60ea4d0e3d7d..0021c2da8b09 100644 --- a/docs/source/policy_deployment/02_gear_assembly/gear_assembly_policy.rst +++ b/docs/source/policy_deployment/02_gear_assembly/gear_assembly_policy.rst @@ -36,7 +36,7 @@ This environment has been successfully deployed on real UR10e and Flexiv Rizon 4 **Scope of This Tutorial:** -This tutorial focuses exclusively on the **training part** of the sim-to-real transfer workflow in Isaac Lab. For the complete deployment workflow on the real robot, including the exact steps to set up the vision pipeline, robot interface and the ROS inference node to run your trained policy on real hardware, please refer to the `Isaac ROS Documentation `_. +This tutorial focuses exclusively on the **training part** of the sim-to-real transfer workflow in Isaac Lab. For the complete deployment workflow on the real robot, including the exact steps to set up the vision pipeline, robot interface and the ROS inference node to run your trained policy on real hardware, please refer to the `Isaac ROS Documentation `_. Overview -------- @@ -761,7 +761,7 @@ Replace the log directory path with your actual training log location if differe Step 3: Deploy on Real Robot ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Once training is complete, follow the `Isaac ROS inference documentation `_ to deploy your policy. +Once training is complete, follow the `Isaac ROS inference documentation `_ to deploy your policy. The Isaac ROS deployment pipeline directly uses the trained model checkpoint (``.pt`` file) along with the ``agent.yaml`` and ``env.yaml`` configuration files generated during training. No additional export step is required. diff --git a/docs/source/policy_deployment/05_leapp/exporting_policies_with_leapp.rst b/docs/source/policy_deployment/05_leapp/exporting_policies_with_leapp.rst new file mode 100644 index 000000000000..dc07a42fc141 --- /dev/null +++ b/docs/source/policy_deployment/05_leapp/exporting_policies_with_leapp.rst @@ -0,0 +1,251 @@ +Exporting Policies with LEAPP +============================= + +.. currentmodule:: isaaclab + +This guide covers how to export trained reinforcement learning policies from Isaac Lab using +`LEAPP `_ (Lightweight Export Annotations for Policy Pipelines). +The main goal of the LEAPP export path is to package the policy together with the input and +output semantics needed for deployment, so downstream users do not need to reimplement Isaac Lab +observation preprocessing, action postprocessing, or recurrent-state handling by hand. + +In practice, this makes the exported policy a much better fit for Isaac deployment libraries. +Isaac Lab can already consume these exports through :class:`~envs.LeappDeploymentEnv`, and Isaac +ROS will add direct support for running LEAPP-exported policies in a future release. + +.. note:: + + This export path currently supports **manager-based RL environments** (``ManagerBasedRLEnv``) + trained with **RSL-RL** only. Other environments are not yet supported. + + +Prerequisites +------------- + +LEAPP requires Python >= 3.8 and PyTorch >= 2.6. Install it with: + +.. code-block:: bash + + pip install leapp + +Ensure you have a trained RSL-RL checkpoint before proceeding. The standard Isaac Lab +training workflow produces checkpoints under ``logs/rsl_rl//``. + + +Why Export with LEAPP +--------------------- + +Running the export script generates a self-contained export directory alongside your +checkpoint (or at a custom path). The directory contains: + +- **Exported model files** — ``.onnx`` (default) or ``.pt`` depending on the chosen backend. +- **Export metadata** — LEAPP records the semantic information and wiring needed by downstream + deployment runtimes. +- **Initial values** — a ``.safetensors`` file for any feedback state, such as recurrent hidden + state or last action. +- **A graph visualization** — a ``.png`` diagram of the pipeline (can be disabled). + +The important outcome for Isaac deployment workflows is that the exported artifact preserves the +same dataflow that was used during training and inference inside Isaac Lab. That means downstream +consumers can run the policy without reconstructing observation ordering, command wiring, actuator +targets, or policy feedback loops themselves. + +For a detailed description of LEAPP's generated artifacts and APIs, refer to the +`LEAPP documentation `_. + + +Exporting a Policy +------------------ + +Use the RSL-RL export script to export a trained checkpoint: + +.. code-block:: bash + + ./isaaclab.sh -p scripts/reinforcement_learning/leapp/rsl_rl/export.py \ + --task \ + --checkpoint + +For example, to export a UR10 reach policy: + +.. code-block:: bash + + ./isaaclab.sh -p scripts/reinforcement_learning/leapp/rsl_rl/export.py \ + --task Isaac-Reach-UR10-v0 \ + --checkpoint logs/rsl_rl/ur10_reach/< date timestamp >/model_4999.pt + +By default, the export artifacts are saved in the same directory as the checkpoint. The +exported graph is named after the task. + + +CLI Options +^^^^^^^^^^^ + +The export script accepts the following LEAPP-specific arguments in addition to the standard +RSL-RL and AppLauncher arguments: + +.. list-table:: + :widths: 30 15 55 + :header-rows: 1 + + * - Argument + - Default + - Description + * - ``--export_task_name`` + - Task name + - Name for the exported graph and output directory. + * - ``--export_method`` + - ``onnx-dynamo`` + - Export backend. Choices: ``onnx-dynamo``, ``onnx-torchscript``, ``jit-script``, + ``jit-trace``. + * - ``--export_save_path`` + - Checkpoint dir + - Base directory for export output. + * - ``--validation_steps`` + - ``5`` + - Number of environment steps to run during the traced rollout. Set to ``0`` to skip + validation. + * - ``--disable_graph_visualization`` + - ``False`` + - Skip generating the pipeline graph PNG. + +The script also accepts the standard ``--checkpoint``, ``--load_run``, ``--load_checkpoint``, +and ``--use_pretrained_checkpoint`` arguments for locating the trained model. + + +How It Works (High Level) +^^^^^^^^^^^^^^^^^^^^^^^^^ + +The export script performs the following steps: + +1. **Creates the environment** with ``num_envs=1`` and loads the trained checkpoint. +2. **Patches the environment** for export. This step injects annotations into the environment + so that tensor i/o to the pipeline are identified by LEAPP during execution. +3. **Runs a short rollout** (controlled by ``--validation_steps``) with LEAPP tracing + active. During this rollout, LEAPP traces all tensor operations in the pipeline and automatically + builds an onnx file. +4. **Compiles the graph** so the exported model and deployment metadata can be consumed by + downstream runtimes, and optionally validates that the exported model reproduces the traced + outputs. + +The patching is transparent to the policy — no changes to your training code or environment +configuration are needed. + +.. warning:: + + LEAPP is designed to support a broad range of model architectures, but the current + implementation has a few important limitations: + + - **Dynamic control flow** is not supported when the condition depends on runtime tensor + values, such as tensor-dependent ``if``, ``for``, or ``while`` logic. + - **Complex slicing** is not fully supported. Examples include dynamic masked indexing + using multiple traced tensors such as ``tensor[traced1, traced2]``. Slicing with constant values + or with a single traced tensor is supported such as ``tensor[mask]`` or ``tensor[1:5]``. + - **Critical traced operations must be written in PyTorch.** For this release, Warp and + NumPy operations cannot be traced by LEAPP. + + +Verifying an Export +------------------- + +After export, we recommend validating the result in three ways. + +1. **Use LEAPP's automatic verification on seen traced data.** +2. **Inspect the generated graph visualization.** +3. **Read the LEAPP log carefully, especially when the export fails or emits warnings.** + +Automatic Verification on Seen Data +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +By default, Isaac Lab asks LEAPP to validate the exported model after compilation. LEAPP does +this by replaying the data it already saw during the traced rollout and checking that the +exported artifact reproduces the same outputs. + +This is a strong first-line check because it is good at catching export-time issues such as: + +- backend conversion problems +- unsupported or incorrectly lowered operators +- output shape or dtype mismatches +- numerical discrepancies between the original policy and the exported artifact +- recurrent or feedback-state handling mistakes that show up during replay + +This validation is controlled by ``--validation_steps``. Setting it to a positive value gives +LEAPP rollout data to validate against. Setting it to ``0`` skips this automatic check, which +is useful for debugging but not recommended for normal export workflows. + +Inspect the Graph Visualization +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +LEAPP can generate a diagram of the exported pipeline as part of ``compile_graph()``. Even when +automatic verification passes, it is still worth opening the diagram and doing a quick visual +inspection. + +This is especially useful for catching structural issues such as: + +- missing inputs or outputs +- unexpected extra nodes +- incorrect feedback edges +- naming mistakes that make deployment harder to reason about + +You can disable the diagram with ``--disable_graph_visualization``, but we recommend keeping it +enabled while developing and validating a new export path. + +Inspect the LEAPP Log +^^^^^^^^^^^^^^^^^^^^^ + +If something breaks, the LEAPP-generated log is usually the best place to determine exactly what +happened. Read it closely and pay attention to both hard errors and warnings. + +The log is useful for diagnosing issues such as: + +- export backend failures +- warnings about graph construction or validation +- missing metadata +- unsupported model patterns +- file generation problems + +In practice, this should be your first stop when the export does not complete or when the output +artifacts do not look correct. + + +Export Backends +^^^^^^^^^^^^^^^ + +The ``--export_method`` argument controls how the policy network is serialized: + +- **onnx-dynamo** (default) — Uses ``torch.onnx.dynamo_export``. Best compatibility with + modern PyTorch features. +- **onnx-torchscript** — Uses the legacy ``torch.onnx.export`` path. May be needed for + certain model architectures. +- **jit-script** / **jit-trace** — Produces TorchScript ``.pt`` files instead of ONNX. + + +Recurrent Policies +^^^^^^^^^^^^^^^^^^ + +Recurrent policies (e.g., using GRU or LSTM memory) are supported automatically. The export +script detects recurrent hidden state in the RSL-RL policy, registers it as LEAPP feedback +state, and ensures it appears in the ``feedback_flow`` section of the output YAML. The +initial hidden state values are saved in the ``.safetensors`` file. + + +Running the Exported Policy in Simulation +----------------------------------------- + +Isaac Lab provides :class:`~envs.LeappDeploymentEnv` for running exported policies back in +simulation without the training infrastructure. This is the Isaac Lab deployment path for +LEAPP-exported policies and is useful for validating that the packaged policy still behaves +correctly when driven through the deployment stack instead of the training stack. + +For Direct workflow policies, see the +:doc:`Direct workflow LEAPP export tutorial `. +That guide shows how to add LEAPP annotations to a direct RL environment so it can be +exported with ``scripts/reinforcement_learning/leapp/rsl_rl/export.py``. Direct +workflow policies are not currently supported by ``scripts/reinforcement_learning/leapp/deploy.py``. + + +Further Reading +--------------- + +- `LEAPP documentation `_ +- `LEAPP API reference `_ +- :class:`~envs.LeappDeploymentEnv` API reference diff --git a/docs/source/policy_deployment/index.rst b/docs/source/policy_deployment/index.rst index 750ca970df65..70cb7244078e 100644 --- a/docs/source/policy_deployment/index.rst +++ b/docs/source/policy_deployment/index.rst @@ -13,3 +13,4 @@ Below, you'll find detailed examples of various policies for training and deploy 02_gear_assembly/gear_assembly_policy 03_compass_with_NuRec/compass_navigation_policy_with_NuRec 04_reach/reach_policy + 05_leapp/exporting_policies_with_leapp diff --git a/docs/source/tutorials/06_exporting/exporting_direct_workflow_policies_with_leapp.rst b/docs/source/tutorials/06_exporting/exporting_direct_workflow_policies_with_leapp.rst new file mode 100644 index 000000000000..1ce0aa3a82e6 --- /dev/null +++ b/docs/source/tutorials/06_exporting/exporting_direct_workflow_policies_with_leapp.rst @@ -0,0 +1,183 @@ +Exporting Direct Workflow Policies with LEAPP +============================================= + +.. currentmodule:: isaaclab + +This tutorial shows how to prepare a Direct workflow policy for export with +LEAPP. If your policy is manager-based, use the +:doc:`manager-based LEAPP export guide ` +instead. + + +Overview +~~~~~~~~ + +To export a Direct workflow policy with LEAPP, you add LEAPP annotations to the +environment code. During export, LEAPP traces the annotated tensors and builds an +intermediate representation of the full policy pipeline. These annotations remain +dormant during normal environment execution and only add a small amount of +overhead until export time. They are activated by +``scripts/reinforcement_learning/leapp/rsl_rl/export.py`` when you run the export flow. + +This tutorial uses ``scripts/tutorials/06_deploy/anymal_c_env.py`` as the example. +The script is based on the existing ANYmal-C direct environment at +``source/isaaclab_tasks/isaaclab_tasks/direct/anymal_c/anymal_c_env.py`` and adds +the annotations needed to make it compatible with the export script. Once you have added +the annotations to your direct RL environment, you can export a trained policy +with: + +.. code-block:: bash + + ./isaaclab.sh -p scripts/reinforcement_learning/leapp/rsl_rl/export.py \ + --task \ + --checkpoint \ + --export_save_path + +The ``--task`` argument is the registered task name, such as +``Isaac-Velocity-Rough-Anymal-C-Direct-v0``. The ``--checkpoint`` argument +points to the trained RSL-RL checkpoint to export. The optional +``--export_save_path`` argument selects the output directory for the exported +artifacts. If you omit it, the export is written next to the checkpoint. + +.. warning:: + + This tutorial covers exporting Direct workflow policies only. Direct workflow + policies are not currently supported by + ``scripts/reinforcement_learning/leapp/deploy.py``. + +For more information on the export arguments, see the +:doc:`manager-based LEAPP export guide `. + + +.. dropdown:: Full example script + :icon: code + + .. literalinclude:: ../../../../scripts/tutorials/06_deploy/anymal_c_env.py + :language: python + :emphasize-lines: 20, 100-118, 85-88 + :linenos: + + +How the Annotations Work +~~~~~~~~~~~~~~~~~~~~~~~~ + +The main task is to identify the inputs, outputs, and persistent state in the +environment and register them with LEAPP. In this example, the script uses four +annotation helpers: + +- :func:`annotate.input_tensors` marks tensors that enter the policy pipeline. +- :func:`annotate.output_tensors` marks tensors that leave the environment-side + part of the pipeline. +- :func:`annotate.state_tensors` marks tensors that behave like persistent state. +- :func:`annotate.update_state` updates that persistent state after each step. + + +Input Annotations +~~~~~~~~~~~~~~~~~ + +Input annotations usually belong in ``_get_observations()``, because that method +collects the tensors that are passed to the policy. + + +.. literalinclude:: ../../../../scripts/tutorials/06_deploy/anymal_c_env.py + :language: python + :start-at: # start LEAPP annotations for inputs + :end-at: # end LEAPP annotations for inputs + :dedent: 8 + +``annotate.input_tensors()`` wraps a tensor so LEAPP can trace all downstream +operations that depend on it. The function takes two important arguments: + +- ``self.spec.id`` identifies the node that owns the tensor. When you use + ``export.py``, this ID matches the exported policy node. +- The second argument is a dictionary that maps a unique tensor name to the + tensor itself. LEAPP uses these names in the exported metadata and for + debugging. + +In this example, the observation tensors are registered one by one for +readability, but ``annotate.input_tensors()`` can also register multiple tensors +in a single call. + +.. note:: + Any inputs not explicitly annotated will be automatically inlined as a constant. + This may be desired for certain values such as constant transforms or default values. + + +Output Annotations +~~~~~~~~~~~~~~~~~~ + +Output annotations should be placed where the environment has finished preparing +the command that will be applied to the robot. In this example, that happens in +``_pre_physics_step()``. + +.. literalinclude:: ../../../../scripts/tutorials/06_deploy/anymal_c_env.py + :language: python + :start-at: # start LEAPP annotations for outputs + :end-at: # end LEAPP annotations for outputs + :dedent: 8 + +``annotate.output_tensors()`` marks the tensors that leave the environment-side +part of the pipeline. As with input annotations, the call uses ``self.spec.id`` +together with a dictionary that maps tensor names to tensors. + +The ``export_with`` argument restricts an output annotation to specific +export backends. The supported backend names are ``onnx-dynamo``, ``onnx-torchscript``, +``jit-script``, and ``jit-trace``. This argument is needed to actually generate the IR +based on the tracing. + +Unlike ``annotate.input_tensors()``, output annotation should happen once for the +final outputs of the pipeline stage. In this example, ``processed_actions`` is +the tensor that should be exported. After calling +``annotate.output_tensors()``, you do not need to use a return value. + +.. note:: + All tensors passed to ``annotate.output_tensors()`` must be traced tensors. + These tensors are created from inputs or tensors derived from inputs. + +.. warning:: + + Do not place output annotations in ``_apply_action()``. That method may be + called multiple times per environment step, depending on the decimation + setting, which would make the traced pipeline incorrect. + + +State Annotations +~~~~~~~~~~~~~~~~~ + +If your policy depends on internal state or feedback loops, register that data +explicitly with ``annotate.state_tensors()`` and update it with +``annotate.update_state()``. + +In this example, the environment uses the previous action as part of the +observation. That makes ``previous_actions`` a feedback state: + +- ``annotate.state_tensors()`` is called in ``_get_observations()`` so the state + can participate in the traced observation pipeline. +- ``annotate.update_state()`` is called in ``_pre_physics_step()`` so the stored + value is updated for the next step. + +The state name must match across both calls. Here, both functions use the name +``previous_actions``, which lets LEAPP route the feedback tensor correctly. + + +Semantic Annotations +~~~~~~~~~~~~~~~~~~~~ + +This example covers the minimum annotations needed to trace the pipeline. In +more advanced export workflows, you may also want to attach semantic metadata +so downstream runtimes know what each tensor represents. + +For direct environments, semantic annotations are optional and should be +authored explicitly by the user. Unlike the manager-based export path, Isaac Lab +does not infer tensor semantics automatically for direct environments, instead it +is up to the user to provide this data. LEAPP provides this through +``TensorSemantics``. You can use it to describe the meaning of tensors more +precisely and make the exported pipeline easier to inspect, validate, and integrate +into deployment systems. + +.. note:: + + Refer to the `LEAPP semantic annotation guide + `_ + and `LEAPP API reference `_ + for details on authoring semantic annotations. diff --git a/docs/source/tutorials/index.rst b/docs/source/tutorials/index.rst index f1096e6c05b0..41b606e1bf76 100644 --- a/docs/source/tutorials/index.rst +++ b/docs/source/tutorials/index.rst @@ -108,3 +108,14 @@ tutorials show you how to use motion generators to control the robots at the tas 05_controllers/run_diff_ik 05_controllers/run_osc + +Exporting Policies +------------------ + +The following tutorial shows how to prepare a Direct workflow policy for export with LEAPP. + +.. toctree:: + :maxdepth: 1 + :titlesonly: + + 06_exporting/exporting_direct_workflow_policies_with_leapp diff --git a/scripts/reinforcement_learning/leapp/deploy.py b/scripts/reinforcement_learning/leapp/deploy.py new file mode 100644 index 000000000000..5b032daa9dda --- /dev/null +++ b/scripts/reinforcement_learning/leapp/deploy.py @@ -0,0 +1,82 @@ +# 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 + +"""Deploy a LEAPP-exported policy in an Isaac Lab simulation.""" + +"""Launch Isaac Sim Simulator first.""" + +import argparse +import sys + +from isaaclab.app import AppLauncher + +parser = argparse.ArgumentParser(description="Deploy a LEAPP-exported policy in simulation.") +parser.add_argument("--task", type=str, default=None, help="Name of the registered Isaac Lab task.") +parser.add_argument("--leapp_model", type=str, default=None, help="Path to the LEAPP .yaml pipeline description.") +parser.add_argument("--seed", type=int, default=None, help="Seed for the environment.") +AppLauncher.add_app_launcher_args(parser) +args_cli, hydra_args = parser.parse_known_args() + +if args_cli.task is None or args_cli.leapp_model is None: + missing_args = [] + if args_cli.task is None: + missing_args.append("--task") + if args_cli.leapp_model is None: + missing_args.append("--leapp_model") + parser.error(f"the following arguments are required: {', '.join(missing_args)}") + +sys.argv = [sys.argv[0]] + hydra_args + +app_launcher = AppLauncher(args_cli) +simulation_app = app_launcher.app + +"""Rest everything follows.""" + +import torch + +from isaaclab.envs.leapp_deployment_env import LeappDeploymentEnv + +import isaaclab_tasks # noqa: F401 +from isaaclab_tasks.utils.parse_cfg import load_cfg_from_registry + + +def main(): + # ── Load env config from gym registry ───────────────────────── + task_name = args_cli.task.split(":")[-1] + env_cfg = load_cfg_from_registry(task_name, "env_cfg_entry_point") + + if args_cli.seed is not None: + env_cfg.seed = args_cli.seed + if args_cli.device is not None: + env_cfg.sim.device = args_cli.device + + # ── Create deploy env ───────────────────────────────────────── + env = LeappDeploymentEnv(env_cfg, args_cli.leapp_model) + + if getattr(args_cli, "headless", False): + print( + "[WARN]: Running deploy without a viewport. This happens when headless mode is active, " + "including the default case where no visualizer was selected. The policy may be " + "stepping normally, but no viewport will appear unless you specify the " + "`--visualizer` field." + ) + + print(f"[INFO]: Deploying task '{task_name}' with LEAPP model: {args_cli.leapp_model}") + print(f"[INFO]: Num envs: {env.num_envs}, decimation: {env.cfg.decimation}, step_dt: {env.step_dt:.4f}s") + + # ── Run loop ────────────────────────────────────────────────── + env.reset() + try: + with torch.inference_mode(): + while simulation_app.is_running(): + env.step() + env.close() + except KeyboardInterrupt: + pass + + +if __name__ == "__main__": + main() + simulation_app.close() diff --git a/scripts/reinforcement_learning/leapp/rsl_rl/export.py b/scripts/reinforcement_learning/leapp/rsl_rl/export.py new file mode 100644 index 000000000000..65bab2f9c221 --- /dev/null +++ b/scripts/reinforcement_learning/leapp/rsl_rl/export.py @@ -0,0 +1,282 @@ +# 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 + +# ruff: noqa: E402 + +"""Script to export a checkpoint if an RL agent from RSL-RL.""" + +"""Launch Isaac Sim Simulator first.""" + +import argparse +import importlib.metadata as metadata +import sys +import time +from collections.abc import Mapping +from pathlib import Path + +import torch + +try: + import leapp + from leapp import annotate +except ImportError as e: + raise ImportError("LEAPP package is required for policy export. Install with: pip install leapp") from e + +# Disable TorchScript before importing task/environment modules so any +# @torch.jit.script helpers resolve to plain Python functions during export. +torch.jit._state.disable() + +from isaaclab.app import AppLauncher + +_RSL_RL_SCRIPTS_DIR = Path(__file__).resolve().parents[2] / "rsl_rl" +if str(_RSL_RL_SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(_RSL_RL_SCRIPTS_DIR)) +import cli_args # isort: skip + + +parser = argparse.ArgumentParser(description="Train an RL agent with RSL-RL.") +parser.add_argument( + "--disable_fabric", action="store_true", default=False, help="Disable fabric and use USD I/O operations." +) +parser.add_argument("--task", type=str, default=None, help="Name of the task.") +parser.add_argument( + "--agent", type=str, default="rsl_rl_cfg_entry_point", help="Name of the RL agent configuration entry point." +) +parser.add_argument("--seed", type=int, default=None, help="Seed used for the environment") +parser.add_argument( + "--use_pretrained_checkpoint", + action="store_true", + help="Use the pre-trained checkpoint from Nucleus.", +) + +# LEAPP arguments +parser.add_argument( + "--export_task_name", + type=str, + default=None, + help="Name of the exported graph. Defaults to the task name.", +) +parser.add_argument( + "--export_method", + type=str, + default="onnx-dynamo", + choices=["onnx-dynamo", "onnx-torchscript", "jit-script", "jit-trace"], + help="Method to export the policy", +) +parser.add_argument( + "--export_save_path", + type=str, + default=None, + help="Path to save the exported model", +) +parser.add_argument( + "--validation_steps", + type=int, + default=5, + help="Number of steps to validate the exported model", +) +parser.add_argument( + "--disable_graph_visualization", + action="store_true", + default=False, + help="Disable LEAPP graph visualization during compile_graph().", +) + +cli_args.add_rsl_rl_args(parser) +AppLauncher.add_app_launcher_args(parser) +args_cli, hydra_args = parser.parse_known_args() +args_cli.headless = True + +# clear out sys.argv for Hydra +sys.argv = [sys.argv[0]] + hydra_args + +installed_version = metadata.version("rsl-rl-lib") + +app_launcher = AppLauncher(args_cli) +simulation_app = app_launcher.app + +"""Rest everything follows.""" + +import os + +import gymnasium as gym +from rsl_rl.runners import DistillationRunner, OnPolicyRunner + +from isaaclab.envs import ManagerBasedRLEnv, ManagerBasedRLEnvCfg +from isaaclab.utils.assets import retrieve_file_path +from isaaclab.utils.leapp import patch_env_for_export +from isaaclab.utils.leapp.utils import ensure_env_spec_id + +from isaaclab_rl.rsl_rl import RslRlBaseRunnerCfg, RslRlVecEnvWrapper, handle_deprecated_rsl_rl_cfg +from isaaclab_rl.utils.pretrained_checkpoint import get_published_pretrained_checkpoint + +import isaaclab_tasks # noqa: F401 +from isaaclab_tasks.utils import get_checkpoint_path +from isaaclab_tasks.utils.hydra import hydra_task_config + + +def get_actor_memory_module(policy_nn): + """Return the actor-side recurrent memory module when the policy exposes one.""" + if hasattr(policy_nn, "memory_a"): + return policy_nn.memory_a + if hasattr(policy_nn, "memory_s"): + return policy_nn.memory_s + return None + + +def ensure_actor_hidden_state_initialized(policy_nn, batch_size: int, device: torch.device, dtype: torch.dtype): + """Initialize and return the actor hidden state when a recurrent policy has not created it yet.""" + actor_state, _ = policy_nn.get_hidden_states() + if actor_state is not None: + return actor_state + + memory = get_actor_memory_module(policy_nn) + if memory is None or not hasattr(memory, "rnn"): + return None + + num_layers = memory.rnn.num_layers + hidden_size = memory.rnn.hidden_size + zeros = torch.zeros(num_layers, batch_size, hidden_size, device=device, dtype=dtype) + if isinstance(memory.rnn, torch.nn.LSTM): + actor_state = (zeros.clone(), zeros.clone()) + else: + actor_state = zeros + memory.hidden_state = actor_state + return actor_state + + +def state_dict_from_actor_hidden(actor_hidden): + """Convert the actor hidden state into the named tensor mapping expected by LEAPP state APIs.""" + if actor_hidden is None: + return {} + if isinstance(actor_hidden, tuple): + return {f"actor_state_{idx}": tensor for idx, tensor in enumerate(actor_hidden)} + return {"actor_state": actor_hidden} + + +def actor_hidden_from_registered(registered_state, original_hidden): + """Restore the registered LEAPP state to the hidden-state structure expected by the actor memory module.""" + if isinstance(original_hidden, tuple): + if isinstance(registered_state, tuple): + return registered_state + return (registered_state,) + return registered_state + + +@hydra_task_config(args_cli.task, args_cli.agent) +def main(env_cfg: ManagerBasedRLEnvCfg, agent_cfg: RslRlBaseRunnerCfg): + """Export a RSL-RL agent.""" + task_name = args_cli.task.split(":")[-1] + train_task_name = task_name.replace("-Play", "") + + agent_cfg: RslRlBaseRunnerCfg = cli_args.update_rsl_rl_cfg(agent_cfg, args_cli) + env_cfg.scene.num_envs = 1 + + agent_cfg = handle_deprecated_rsl_rl_cfg(agent_cfg, installed_version) + + # note: certain randomizations occur in the environment initialization so we set the seed here + env_cfg.seed = agent_cfg.seed + env_cfg.sim.device = args_cli.device if args_cli.device is not None else env_cfg.sim.device + + log_root_path = os.path.join("logs", "rsl_rl", agent_cfg.experiment_name) + log_root_path = os.path.abspath(log_root_path) + print(f"[INFO] Loading experiment from directory: {log_root_path}") + if args_cli.use_pretrained_checkpoint: + resume_path = get_published_pretrained_checkpoint("rsl_rl", train_task_name) + if not resume_path: + print("[INFO] Unfortunately a pre-trained checkpoint is currently unavailable for this task.") + return + elif args_cli.checkpoint: + resume_path = retrieve_file_path(args_cli.checkpoint) + else: + resume_path = get_checkpoint_path(log_root_path, agent_cfg.load_run, agent_cfg.load_checkpoint) + + log_dir = os.path.dirname(resume_path) + + env_cfg.log_dir = log_dir + + env = gym.make(args_cli.task, cfg=env_cfg, render_mode=None) + policy_node_name = ensure_env_spec_id(env) + + graph_name = args_cli.export_task_name if args_cli.export_task_name is not None else task_name + + if isinstance(env.unwrapped, ManagerBasedRLEnv): + # Patch only the observation groups consumed by the actor policy. + # This filters out the critic and teacher observation groups. + obs_groups_cfg = getattr(agent_cfg, "obs_groups", None) + if isinstance(obs_groups_cfg, Mapping): + required_obs_groups = set(obs_groups_cfg.get("actor", ["policy"])) + else: + required_obs_groups = {"policy"} + patch_env_for_export( + env, + export_method=args_cli.export_method, + required_obs_groups=required_obs_groups, + ) + + env = RslRlVecEnvWrapper(env, clip_actions=agent_cfg.clip_actions) + + print(f"[INFO]: Loading model checkpoint from: {resume_path}") + if agent_cfg.class_name == "OnPolicyRunner": + runner = OnPolicyRunner(env, agent_cfg.to_dict(), log_dir=None, device=agent_cfg.device) + elif agent_cfg.class_name == "DistillationRunner": + runner = DistillationRunner(env, agent_cfg.to_dict(), log_dir=None, device=agent_cfg.device) + else: + raise ValueError(f"Unsupported runner class: {agent_cfg.class_name}") + runner.load(resume_path) + + policy = runner.get_inference_policy(device=env.unwrapped.device) + policy_nn = getattr(policy, "__self__", None) + + if args_cli.export_save_path is not None: + save_path = args_cli.export_save_path + elif args_cli.use_pretrained_checkpoint: + # Use a predictable path independent of the Nucleus mirror directory structure. + save_path = os.path.join(".pretrained_checkpoints", "rsl_rl", train_task_name) + else: + save_path = log_dir + leapp.start(graph_name, save_path=save_path, max_cached_io=max(args_cli.validation_steps, 2)) + obs = env.reset()[0] + while not simulation_app.is_running(): + time.sleep(0.5) + + for _ in range(max(args_cli.validation_steps, 2)): + with torch.inference_mode(): + if policy_nn is not None and getattr(policy_nn, "is_recurrent", False): + actor_hidden = ensure_actor_hidden_state_initialized( + policy_nn, + batch_size=env.num_envs, + device=env.unwrapped.device, + dtype=next(policy_nn.parameters()).dtype, + ) + registered_state = annotate.state_tensors( + policy_node_name, + state_dict_from_actor_hidden(actor_hidden), + ) + actor_memory = get_actor_memory_module(policy_nn) + if actor_memory is not None: + actor_memory.hidden_state = actor_hidden_from_registered(registered_state, actor_hidden) + + actions = policy(obs) + + if policy_nn is not None and getattr(policy_nn, "is_recurrent", False): + actor_hidden_after = policy_nn.get_hidden_states()[0] + annotate.update_state( + policy_node_name, + state_dict_from_actor_hidden(actor_hidden_after), + ) + + obs, _, _, _ = env.step(actions) + + leapp.stop() + validate = args_cli.validation_steps > 0 + leapp.compile_graph(visualize=not args_cli.disable_graph_visualization, validate=validate) + + env.close() + + +if __name__ == "__main__": + main() + simulation_app.close() diff --git a/scripts/tutorials/06_deploy/anymal_c_env.py b/scripts/tutorials/06_deploy/anymal_c_env.py new file mode 100644 index 000000000000..94022e3a0956 --- /dev/null +++ b/scripts/tutorials/06_deploy/anymal_c_env.py @@ -0,0 +1,208 @@ +# 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 + +# ruff: noqa: I001 + +from __future__ import annotations + +import gymnasium as gym +import torch +import warp as wp + +import isaaclab.sim as sim_utils +from isaaclab.assets import Articulation +from isaaclab.envs import DirectRLEnv +from isaaclab.sensors import ContactSensor, RayCaster + +from .anymal_c_env_cfg import AnymalCFlatEnvCfg, AnymalCRoughEnvCfg +from leapp import annotate # isort: skip + + +class AnymalCEnv(DirectRLEnv): + cfg: AnymalCFlatEnvCfg | AnymalCRoughEnvCfg + + def __init__(self, cfg: AnymalCFlatEnvCfg | AnymalCRoughEnvCfg, render_mode: str | None = None, **kwargs): + super().__init__(cfg, render_mode, **kwargs) + + self._actions = torch.zeros(self.num_envs, gym.spaces.flatdim(self.single_action_space), device=self.device) + self._previous_actions = torch.zeros( + self.num_envs, gym.spaces.flatdim(self.single_action_space), device=self.device + ) + + self._commands = torch.zeros(self.num_envs, 3, device=self.device) + + self._episode_sums = { + key: torch.zeros(self.num_envs, dtype=torch.float, device=self.device) + for key in [ + "track_lin_vel_xy_exp", + "track_ang_vel_z_exp", + "lin_vel_z_l2", + "ang_vel_xy_l2", + "dof_torques_l2", + "dof_acc_l2", + "action_rate_l2", + "feet_air_time", + "undesired_contacts", + "flat_orientation_l2", + ] + } + self._base_id, _ = self._contact_sensor.find_sensors("base") + self._feet_ids, _ = self._contact_sensor.find_sensors(".*FOOT") + self._undesired_contact_body_ids, _ = self._contact_sensor.find_sensors(".*THIGH") + + def _setup_scene(self): + self._robot = Articulation(self.cfg.robot) + self.scene.articulations["robot"] = self._robot + self._contact_sensor = ContactSensor(self.cfg.contact_sensor) + self.scene.sensors["contact_sensor"] = self._contact_sensor + if isinstance(self.cfg, AnymalCRoughEnvCfg): + self._height_scanner = RayCaster(self.cfg.height_scanner) + self.scene.sensors["height_scanner"] = self._height_scanner + self.cfg.terrain.num_envs = self.scene.cfg.num_envs + self.cfg.terrain.env_spacing = self.scene.cfg.env_spacing + self._terrain = self.cfg.terrain.class_type(self.cfg.terrain) + self.scene.clone_environments(copy_from_source=False) + if self.device == "cpu": + self.scene.filter_collisions(global_prim_paths=[self.cfg.terrain.prim_path]) + light_cfg = sim_utils.DomeLightCfg(intensity=2000.0, color=(0.75, 0.75, 0.75)) + light_cfg.func("/World/Light", light_cfg) + + def _pre_physics_step(self, actions: torch.Tensor): + self._actions = actions.clone() + self._processed_actions = self.cfg.action_scale * self._actions + self._robot.data.default_joint_pos.torch + # start LEAPP annotations for outputs + annotate.update_state(self.spec.id, {"previous_actions": actions}) + annotate.output_tensors(self.spec.id, {"processed_actions": self._processed_actions}, export_with="onnx-dynamo") + # end LEAPP annotations for outputs + + def _apply_action(self): + self._robot.set_joint_position_target_index(target=self._processed_actions) + + def _get_observations(self) -> dict: + self._previous_actions = self._actions.clone() + height_data = None + if isinstance(self.cfg, AnymalCRoughEnvCfg): + height_data = ( + self._height_scanner.data.pos_w.torch[:, 2].unsqueeze(1) + - self._height_scanner.data.ray_hits_w.torch[..., 2] + - 0.5 + ).clip(-1.0, 1.0) + # start LEAPP annotations for inputs + # NOTE: height data is not used by the flat policy. not needed for this example + root_lin_vel_b = annotate.input_tensors(self.spec.id, {"root_lin_vel_b": self._robot.data.root_lin_vel_b.torch}) + root_ang_vel_b = annotate.input_tensors(self.spec.id, {"root_ang_vel_b": self._robot.data.root_ang_vel_b.torch}) + projected_gravity_b = annotate.input_tensors( + self.spec.id, {"projected_gravity_b": self._robot.data.projected_gravity_b.torch} + ) + commands = annotate.input_tensors(self.spec.id, {"commands": self._commands}) + joint_pos = annotate.input_tensors(self.spec.id, {"joint_pos": self._robot.data.joint_pos.torch}) + default_joint_pos = annotate.input_tensors( + self.spec.id, {"default_joint_pos": self._robot.data.default_joint_pos.torch} + ) + joint_vel = annotate.input_tensors(self.spec.id, {"joint_vel": self._robot.data.joint_vel.torch}) + previous_actions = annotate.state_tensors(self.spec.id, {"previous_actions": self._actions}) + # end LEAPP annotations for inputs + + obs = torch.cat( + [ + tensor + for tensor in ( + root_lin_vel_b, + root_ang_vel_b, + projected_gravity_b, + commands, + joint_pos - default_joint_pos, + joint_vel, + height_data, + previous_actions, + ) + if tensor is not None + ], + dim=-1, + ) + observations = {"policy": obs} + return observations + + def _get_rewards(self) -> torch.Tensor: + lin_vel_error = torch.sum( + torch.square(self._commands[:, :2] - self._robot.data.root_lin_vel_b.torch[:, :2]), dim=1 + ) + lin_vel_error_mapped = torch.exp(-lin_vel_error / 0.25) + yaw_rate_error = torch.square(self._commands[:, 2] - self._robot.data.root_ang_vel_b.torch[:, 2]) + yaw_rate_error_mapped = torch.exp(-yaw_rate_error / 0.25) + z_vel_error = torch.square(self._robot.data.root_lin_vel_b.torch[:, 2]) + ang_vel_error = torch.sum(torch.square(self._robot.data.root_ang_vel_b.torch[:, :2]), dim=1) + joint_torques = torch.sum(torch.square(self._robot.data.applied_torque.torch), dim=1) + joint_accel = torch.sum(torch.square(self._robot.data.joint_acc.torch), dim=1) + action_rate = torch.sum(torch.square(self._actions - self._previous_actions), dim=1) + first_contact = self._contact_sensor.compute_first_contact(self.step_dt).torch[:, self._feet_ids] + last_air_time = self._contact_sensor.data.last_air_time.torch[:, self._feet_ids] + air_time = torch.sum((last_air_time - 0.5) * first_contact, dim=1) * ( + torch.linalg.norm(self._commands[:, :2], dim=1) > 0.1 + ) + net_contact_forces = self._contact_sensor.data.net_forces_w_history.torch + is_contact = ( + torch.max(torch.linalg.norm(net_contact_forces[:, :, self._undesired_contact_body_ids], dim=-1), dim=1)[0] + > 1.0 + ) + contacts = torch.sum(is_contact, dim=1) + flat_orientation = torch.sum(torch.square(self._robot.data.projected_gravity_b.torch[:, :2]), dim=1) + + rewards = { + "track_lin_vel_xy_exp": lin_vel_error_mapped * self.cfg.lin_vel_reward_scale * self.step_dt, + "track_ang_vel_z_exp": yaw_rate_error_mapped * self.cfg.yaw_rate_reward_scale * self.step_dt, + "lin_vel_z_l2": z_vel_error * self.cfg.z_vel_reward_scale * self.step_dt, + "ang_vel_xy_l2": ang_vel_error * self.cfg.ang_vel_reward_scale * self.step_dt, + "dof_torques_l2": joint_torques * self.cfg.joint_torque_reward_scale * self.step_dt, + "dof_acc_l2": joint_accel * self.cfg.joint_accel_reward_scale * self.step_dt, + "action_rate_l2": action_rate * self.cfg.action_rate_reward_scale * self.step_dt, + "feet_air_time": air_time * self.cfg.feet_air_time_reward_scale * self.step_dt, + "undesired_contacts": contacts * self.cfg.undesired_contact_reward_scale * self.step_dt, + "flat_orientation_l2": flat_orientation * self.cfg.flat_orientation_reward_scale * self.step_dt, + } + reward = torch.sum(torch.stack(list(rewards.values())), dim=0) + for key, value in rewards.items(): + self._episode_sums[key] += value + return reward + + def _get_dones(self) -> tuple[torch.Tensor, torch.Tensor]: + time_out = self.episode_length_buf >= self.max_episode_length - 1 + net_contact_forces = self._contact_sensor.data.net_forces_w_history.torch + died = torch.any( + torch.max(torch.linalg.norm(net_contact_forces[:, :, self._base_id], dim=-1), dim=1)[0] > 1.0, dim=1 + ) + return died, time_out + + def _reset_idx(self, env_ids: torch.Tensor | None): + if env_ids is None or len(env_ids) == self.num_envs: + env_ids = wp.to_torch(self._robot._ALL_INDICES) + assert env_ids is not None + self._robot.reset(env_ids) + super()._reset_idx(env_ids) + if len(env_ids) == self.num_envs: + self.episode_length_buf[:] = torch.randint_like(self.episode_length_buf, high=int(self.max_episode_length)) + self._actions[env_ids] = 0.0 + self._previous_actions[env_ids] = 0.0 + self._commands[env_ids] = torch.zeros_like(self._commands[env_ids]).uniform_(-1.0, 1.0) + joint_pos = self._robot.data.default_joint_pos.torch[env_ids] + joint_vel = self._robot.data.default_joint_vel.torch[env_ids] + default_root_pose = self._robot.data.default_root_pose.torch[env_ids] + default_root_vel = self._robot.data.default_root_vel.torch[env_ids] + default_root_pose[:, :3] += self._terrain.env_origins[env_ids] + self._robot.write_root_pose_to_sim_index(root_pose=default_root_pose, env_ids=env_ids) + self._robot.write_root_velocity_to_sim_index(root_velocity=default_root_vel, env_ids=env_ids) + self._robot.write_joint_position_to_sim_index(position=joint_pos, env_ids=env_ids) + self._robot.write_joint_velocity_to_sim_index(velocity=joint_vel, env_ids=env_ids) + extras = dict() + for key in self._episode_sums.keys(): + episodic_sum_avg = torch.mean(self._episode_sums[key][env_ids]) + extras["Episode_Reward/" + key] = episodic_sum_avg / self.max_episode_length_s + self._episode_sums[key][env_ids] = 0.0 + self.extras["log"] = dict() + self.extras["log"].update(extras) + extras = dict() + extras["Episode_Termination/base_contact"] = torch.count_nonzero(self.reset_terminated[env_ids]).item() + extras["Episode_Termination/time_out"] = torch.count_nonzero(self.reset_time_outs[env_ids]).item() + self.extras["log"].update(extras) diff --git a/source/isaaclab/changelog.d/leapp_export_integration.rst b/source/isaaclab/changelog.d/leapp_export_integration.rst new file mode 100644 index 000000000000..ea2de5e5d029 --- /dev/null +++ b/source/isaaclab/changelog.d/leapp_export_integration.rst @@ -0,0 +1,15 @@ +Added +^^^^^ + +* Added LEAPP export support for manager-based RSL-RL policies, including + export-time observation/action annotation, recurrent actor-state handling, and + deployment through :mod:`scripts.reinforcement_learning.leapp.deploy`. +* Added a Direct workflow LEAPP export tutorial and annotated ANYmal-C example + script showing how to mark policy inputs, outputs, and persistent state with + LEAPP annotations. Direct workflow policies can be exported with + :mod:`scripts.reinforcement_learning.leapp.rsl_rl.export`, but are not yet + supported by :mod:`scripts.reinforcement_learning.leapp.deploy`. +* Added LEAPP deployment documentation describing the exported-policy validation + flow and linking the manager-based and Direct workflow export paths. +* Added LEAPP export annotations, proxy utilities, and deployment environment + support for Isaac Lab assets, sensors, commands, and manager-based environments. diff --git a/source/isaaclab/isaaclab/assets/articulation/base_articulation.py b/source/isaaclab/isaaclab/assets/articulation/base_articulation.py index 14aa592ad103..3cd9e33376db 100644 --- a/source/isaaclab/isaaclab/assets/articulation/base_articulation.py +++ b/source/isaaclab/isaaclab/assets/articulation/base_articulation.py @@ -16,6 +16,7 @@ import torch import warp as wp +from ...utils.leapp.leapp_semantics import OutputKindEnum, joint_names_resolver, leapp_tensor_semantics from ..asset_base import AssetBase if TYPE_CHECKING: @@ -1266,6 +1267,7 @@ def set_inertias_mask( raise NotImplementedError() @abstractmethod + @leapp_tensor_semantics(kind=OutputKindEnum.JOINT_POSITION, element_names_resolver=joint_names_resolver) def set_joint_position_target_index( self, *, @@ -1293,6 +1295,7 @@ def set_joint_position_target_index( raise NotImplementedError() @abstractmethod + @leapp_tensor_semantics(kind=OutputKindEnum.JOINT_POSITION, element_names_resolver=joint_names_resolver) def set_joint_position_target_mask( self, *, @@ -1320,6 +1323,7 @@ def set_joint_position_target_mask( raise NotImplementedError() @abstractmethod + @leapp_tensor_semantics(kind=OutputKindEnum.JOINT_VELOCITY, element_names_resolver=joint_names_resolver) def set_joint_velocity_target_index( self, *, @@ -1347,6 +1351,7 @@ def set_joint_velocity_target_index( raise NotImplementedError() @abstractmethod + @leapp_tensor_semantics(kind=OutputKindEnum.JOINT_VELOCITY, element_names_resolver=joint_names_resolver) def set_joint_velocity_target_mask( self, *, @@ -1374,6 +1379,7 @@ def set_joint_velocity_target_mask( raise NotImplementedError() @abstractmethod + @leapp_tensor_semantics(kind=OutputKindEnum.JOINT_EFFORT, element_names_resolver=joint_names_resolver) def set_joint_effort_target_index( self, *, @@ -1401,6 +1407,7 @@ def set_joint_effort_target_index( raise NotImplementedError() @abstractmethod + @leapp_tensor_semantics(kind=OutputKindEnum.JOINT_EFFORT, element_names_resolver=joint_names_resolver) def set_joint_effort_target_mask( self, *, diff --git a/source/isaaclab/isaaclab/assets/articulation/base_articulation_data.py b/source/isaaclab/isaaclab/assets/articulation/base_articulation_data.py index fdd79ce6d474..7587ca9e120a 100644 --- a/source/isaaclab/isaaclab/assets/articulation/base_articulation_data.py +++ b/source/isaaclab/isaaclab/assets/articulation/base_articulation_data.py @@ -8,6 +8,19 @@ import warp as wp +from isaaclab.utils.leapp import ( + POSE6_ELEMENT_NAMES, + POSE7_ELEMENT_NAMES, + QUAT_XYZW_ELEMENT_NAMES, + XYZ_ELEMENT_NAMES, + InputKindEnum, + body_pose6_resolver, + body_pose_resolver, + body_quat_resolver, + body_xyz_resolver, + joint_names_resolver, + leapp_tensor_semantics, +) from isaaclab.utils.warp import ProxyArray @@ -47,16 +60,16 @@ def update(self, dt: float) -> None: # Names. ## - body_names: list[str] = None + body_names: list[str] | None = None """Body names in the order parsed by the simulation view.""" - joint_names: list[str] = None + joint_names: list[str] | None = None """Joint names in the order parsed by the simulation view.""" - fixed_tendon_names: list[str] = None + fixed_tendon_names: list[str] | None = None """Fixed tendon names in the order parsed by the simulation view.""" - spatial_tendon_names: list[str] = None + spatial_tendon_names: list[str] | None = None """Spatial tendon names in the order parsed by the simulation view.""" ## @@ -65,6 +78,7 @@ def update(self, dt: float) -> None: @property @abstractmethod + @leapp_tensor_semantics(const=True) def default_root_pose(self) -> ProxyArray: """Default root pose ``[pos, quat]`` in the local environment frame. @@ -75,6 +89,7 @@ def default_root_pose(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(const=True) def default_root_vel(self) -> ProxyArray: """Default root velocity ``[lin_vel, ang_vel]`` in the local environment frame. @@ -85,12 +100,14 @@ def default_root_vel(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(const=True) def default_root_state(self) -> ProxyArray: """Deprecated, same as :attr:`default_root_pose` and :attr:`default_root_vel`.""" raise NotImplementedError @property @abstractmethod + @leapp_tensor_semantics(const=True) def default_joint_pos(self) -> ProxyArray: """Default joint positions of all joints. @@ -102,6 +119,7 @@ def default_joint_pos(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(const=True) def default_joint_vel(self) -> ProxyArray: """Default joint velocities of all joints. @@ -117,6 +135,7 @@ def default_joint_vel(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.COMMAND_JOINT_POSITION) def joint_pos_target(self) -> ProxyArray: """Joint position targets commanded by the user. @@ -130,6 +149,7 @@ def joint_pos_target(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.COMMAND_JOINT_VELOCITY) def joint_vel_target(self) -> ProxyArray: """Joint velocity targets commanded by the user. @@ -143,6 +163,7 @@ def joint_vel_target(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.COMMAND_JOINT_TORQUES) def joint_effort_target(self) -> ProxyArray: """Joint effort targets commanded by the user. @@ -160,6 +181,7 @@ def joint_effort_target(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind="state/joint/computed_torque") def computed_torque(self) -> ProxyArray: """Joint torques computed from the actuator model (before clipping). @@ -173,6 +195,7 @@ def computed_torque(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind="state/joint/applied_torque") def applied_torque(self) -> ProxyArray: """Joint torques applied from the actuator model (after clipping). @@ -189,6 +212,7 @@ def applied_torque(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(const=True) def joint_stiffness(self) -> ProxyArray: """Joint stiffness provided to the simulation. @@ -200,6 +224,7 @@ def joint_stiffness(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(const=True) def joint_damping(self) -> ProxyArray: """Joint damping provided to the simulation. @@ -211,6 +236,7 @@ def joint_damping(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(const=True) def joint_armature(self) -> ProxyArray: """Joint armature provided to the simulation. @@ -220,6 +246,7 @@ def joint_armature(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(const=True) def joint_friction_coeff(self) -> ProxyArray: """Joint static friction coefficient provided to the simulation. @@ -229,6 +256,7 @@ def joint_friction_coeff(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(const=True) def joint_pos_limits(self) -> ProxyArray: """Joint position limits provided to the simulation. @@ -241,6 +269,7 @@ def joint_pos_limits(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(const=True) def joint_vel_limits(self) -> ProxyArray: """Joint maximum velocity provided to the simulation. @@ -250,6 +279,7 @@ def joint_vel_limits(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(const=True) def joint_effort_limits(self) -> ProxyArray: """Joint maximum effort provided to the simulation. @@ -263,6 +293,7 @@ def joint_effort_limits(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(const=True) def soft_joint_pos_limits(self) -> ProxyArray: r"""Soft joint positions limits for all joints. @@ -288,6 +319,7 @@ def soft_joint_pos_limits(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(const=True) def soft_joint_vel_limits(self) -> ProxyArray: """Soft joint velocity limits for all joints. @@ -300,6 +332,7 @@ def soft_joint_vel_limits(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(const=True) def gear_ratio(self) -> ProxyArray: """Gear ratio for relating motor torques to applied Joint torques. @@ -313,6 +346,7 @@ def gear_ratio(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(const=True) def fixed_tendon_stiffness(self) -> ProxyArray: """Fixed tendon stiffness provided to the simulation. @@ -323,6 +357,7 @@ def fixed_tendon_stiffness(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(const=True) def fixed_tendon_damping(self) -> ProxyArray: """Fixed tendon damping provided to the simulation. @@ -333,6 +368,7 @@ def fixed_tendon_damping(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(const=True) def fixed_tendon_limit_stiffness(self) -> ProxyArray: """Fixed tendon limit stiffness provided to the simulation. @@ -343,6 +379,7 @@ def fixed_tendon_limit_stiffness(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(const=True) def fixed_tendon_rest_length(self) -> ProxyArray: """Fixed tendon rest length provided to the simulation. @@ -353,6 +390,7 @@ def fixed_tendon_rest_length(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(const=True) def fixed_tendon_offset(self) -> ProxyArray: """Fixed tendon offset provided to the simulation. @@ -363,6 +401,7 @@ def fixed_tendon_offset(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(const=True) def fixed_tendon_pos_limits(self) -> ProxyArray: """Fixed tendon position limits provided to the simulation. @@ -377,6 +416,7 @@ def fixed_tendon_pos_limits(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(const=True) def spatial_tendon_stiffness(self) -> ProxyArray: """Spatial tendon stiffness provided to the simulation. @@ -387,6 +427,7 @@ def spatial_tendon_stiffness(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(const=True) def spatial_tendon_damping(self) -> ProxyArray: """Spatial tendon damping provided to the simulation. @@ -397,6 +438,7 @@ def spatial_tendon_damping(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(const=True) def spatial_tendon_limit_stiffness(self) -> ProxyArray: """Spatial tendon limit stiffness provided to the simulation. @@ -407,6 +449,7 @@ def spatial_tendon_limit_stiffness(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(const=True) def spatial_tendon_offset(self) -> ProxyArray: """Spatial tendon offset provided to the simulation. @@ -421,6 +464,7 @@ def spatial_tendon_offset(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSE, element_names=POSE7_ELEMENT_NAMES) def root_link_pose_w(self) -> ProxyArray: """Root link pose ``[pos, quat]`` in simulation world frame. @@ -433,6 +477,7 @@ def root_link_pose_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_VEL, element_names=POSE6_ELEMENT_NAMES) def root_link_vel_w(self) -> ProxyArray: """Root link velocity ``[lin_vel, ang_vel]`` in simulation world frame. @@ -445,6 +490,7 @@ def root_link_vel_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSE, element_names=POSE7_ELEMENT_NAMES) def root_com_pose_w(self) -> ProxyArray: """Root center of mass pose ``[pos, quat]`` in simulation world frame. @@ -457,6 +503,7 @@ def root_com_pose_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_VEL, element_names=POSE6_ELEMENT_NAMES) def root_com_vel_w(self) -> ProxyArray: """Root center of mass velocity ``[lin_vel, ang_vel]`` in simulation world frame. @@ -469,18 +516,21 @@ def root_com_vel_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind="state/body/state") def root_state_w(self) -> ProxyArray: """Deprecated, same as :attr:`root_link_pose_w` and :attr:`root_com_vel_w`.""" raise NotImplementedError @property @abstractmethod + @leapp_tensor_semantics(kind="state/body/link_state") def root_link_state_w(self) -> ProxyArray: """Deprecated, same as :attr:`root_link_pose_w` and :attr:`root_link_vel_w`.""" raise NotImplementedError @property @abstractmethod + @leapp_tensor_semantics(kind="state/body/com_state") def root_com_state_w(self) -> ProxyArray: """Deprecated, same as :attr:`root_com_pose_w` and :attr:`root_com_vel_w`.""" raise NotImplementedError @@ -491,6 +541,7 @@ def root_com_state_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(const=True) def body_mass(self) -> ProxyArray: """Body mass ``wp.float32`` in the world frame. @@ -500,6 +551,7 @@ def body_mass(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(const=True) def body_inertia(self) -> ProxyArray: """Flattened body inertia in the world frame. @@ -510,6 +562,7 @@ def body_inertia(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSE, element_names_resolver=body_pose_resolver) def body_link_pose_w(self) -> ProxyArray: """Body link pose ``[pos, quat]`` in simulation world frame. @@ -523,6 +576,7 @@ def body_link_pose_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_VEL, element_names_resolver=body_pose6_resolver) def body_link_vel_w(self) -> ProxyArray: """Body link velocity ``[lin_vel, ang_vel]`` in simulation world frame. @@ -536,6 +590,7 @@ def body_link_vel_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSE, element_names_resolver=body_pose_resolver) def body_com_pose_w(self) -> ProxyArray: """Body center of mass pose ``[pos, quat]`` in simulation world frame. @@ -549,6 +604,7 @@ def body_com_pose_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_VEL, element_names_resolver=body_pose6_resolver) def body_com_vel_w(self) -> ProxyArray: """Body center of mass velocity ``[lin_vel, ang_vel]`` in simulation world frame. @@ -562,24 +618,28 @@ def body_com_vel_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind="state/body/state") def body_state_w(self) -> ProxyArray: """Deprecated, same as :attr:`body_link_pose_w` and :attr:`body_com_vel_w`.""" raise NotImplementedError @property @abstractmethod + @leapp_tensor_semantics(kind="state/body/link_state") def body_link_state_w(self) -> ProxyArray: """Deprecated, same as :attr:`body_link_pose_w` and :attr:`body_link_vel_w`.""" raise NotImplementedError @property @abstractmethod + @leapp_tensor_semantics(kind="state/body/com_state") def body_com_state_w(self) -> ProxyArray: """Deprecated, same as :attr:`body_com_pose_w` and :attr:`body_com_vel_w`.""" raise NotImplementedError @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ACC, element_names_resolver=body_pose6_resolver) def body_com_acc_w(self) -> ProxyArray: """Acceleration of all bodies center of mass ``[lin_acc, ang_acc]``. @@ -592,6 +652,7 @@ def body_com_acc_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSE, element_names_resolver=body_pose_resolver) def body_com_pose_b(self) -> ProxyArray: """Center of mass pose ``[pos, quat]`` of all bodies in their respective body's link frames. @@ -605,6 +666,7 @@ def body_com_pose_b(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.WRENCH) def body_incoming_joint_wrench_b(self) -> ProxyArray: """Joint reaction wrench applied from body parent to child body in parent body frame. @@ -626,6 +688,7 @@ def body_incoming_joint_wrench_b(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.JOINT_POSITION, element_names_resolver=joint_names_resolver) def joint_pos(self) -> ProxyArray: """Joint positions of all joints. @@ -636,6 +699,7 @@ def joint_pos(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.JOINT_VELOCITY, element_names_resolver=joint_names_resolver) def joint_vel(self) -> ProxyArray: """Joint velocities of all joints. @@ -646,6 +710,7 @@ def joint_vel(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind="state/joint/acceleration", element_names_resolver=joint_names_resolver) def joint_acc(self) -> ProxyArray: """Joint acceleration of all joints. @@ -660,6 +725,7 @@ def joint_acc(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.VECTOR3D, element_names=XYZ_ELEMENT_NAMES) def projected_gravity_b(self) -> ProxyArray: """Projection of the gravity direction on base frame. @@ -669,6 +735,7 @@ def projected_gravity_b(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind="state/body/heading") def heading_w(self) -> ProxyArray: """Yaw heading of the base frame (in radians). @@ -682,6 +749,7 @@ def heading_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_LINEAR_VELOCITY, element_names=XYZ_ELEMENT_NAMES) def root_link_lin_vel_b(self) -> ProxyArray: """Root link linear velocity in base frame. @@ -694,6 +762,7 @@ def root_link_lin_vel_b(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ANGULAR_VELOCITY, element_names=XYZ_ELEMENT_NAMES) def root_link_ang_vel_b(self) -> ProxyArray: """Root link angular velocity in base frame. @@ -706,6 +775,7 @@ def root_link_ang_vel_b(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_LINEAR_VELOCITY, element_names=XYZ_ELEMENT_NAMES) def root_com_lin_vel_b(self) -> ProxyArray: """Root center of mass linear velocity in base frame. @@ -718,6 +788,7 @@ def root_com_lin_vel_b(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ANGULAR_VELOCITY, element_names=XYZ_ELEMENT_NAMES) def root_com_ang_vel_b(self) -> ProxyArray: """Root center of mass angular velocity in base frame. @@ -734,6 +805,7 @@ def root_com_ang_vel_b(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSITION, element_names=XYZ_ELEMENT_NAMES) def root_link_pos_w(self) -> ProxyArray: """Root link position in simulation world frame. @@ -745,6 +817,7 @@ def root_link_pos_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ROTATION, element_names=QUAT_XYZW_ELEMENT_NAMES) def root_link_quat_w(self) -> ProxyArray: """Root link orientation (x, y, z, w) in simulation world frame. @@ -756,6 +829,7 @@ def root_link_quat_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_LINEAR_VELOCITY, element_names=XYZ_ELEMENT_NAMES) def root_link_lin_vel_w(self) -> ProxyArray: """Root linear velocity in simulation world frame. @@ -767,6 +841,7 @@ def root_link_lin_vel_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ANGULAR_VELOCITY, element_names=XYZ_ELEMENT_NAMES) def root_link_ang_vel_w(self) -> ProxyArray: """Root link angular velocity in simulation world frame. @@ -778,6 +853,7 @@ def root_link_ang_vel_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSITION, element_names=XYZ_ELEMENT_NAMES) def root_com_pos_w(self) -> ProxyArray: """Root center of mass position in simulation world frame. @@ -789,6 +865,7 @@ def root_com_pos_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ROTATION, element_names=QUAT_XYZW_ELEMENT_NAMES) def root_com_quat_w(self) -> ProxyArray: """Root center of mass orientation (x, y, z, w) in simulation world frame. @@ -800,6 +877,7 @@ def root_com_quat_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_LINEAR_VELOCITY, element_names=XYZ_ELEMENT_NAMES) def root_com_lin_vel_w(self) -> ProxyArray: """Root center of mass linear velocity in simulation world frame. @@ -811,6 +889,7 @@ def root_com_lin_vel_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ANGULAR_VELOCITY, element_names=XYZ_ELEMENT_NAMES) def root_com_ang_vel_w(self) -> ProxyArray: """Root center of mass angular velocity in simulation world frame. @@ -822,6 +901,7 @@ def root_com_ang_vel_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSITION, element_names_resolver=body_xyz_resolver) def body_link_pos_w(self) -> ProxyArray: """Positions of all bodies in simulation world frame. @@ -834,6 +914,7 @@ def body_link_pos_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ROTATION, element_names_resolver=body_quat_resolver) def body_link_quat_w(self) -> ProxyArray: """Orientation (x, y, z, w) of all bodies in simulation world frame. @@ -846,6 +927,7 @@ def body_link_quat_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_LINEAR_VELOCITY, element_names_resolver=body_xyz_resolver) def body_link_lin_vel_w(self) -> ProxyArray: """Linear velocity of all bodies in simulation world frame. @@ -858,6 +940,7 @@ def body_link_lin_vel_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ANGULAR_VELOCITY, element_names_resolver=body_xyz_resolver) def body_link_ang_vel_w(self) -> ProxyArray: """Angular velocity of all bodies in simulation world frame. @@ -870,6 +953,7 @@ def body_link_ang_vel_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSITION, element_names_resolver=body_xyz_resolver) def body_com_pos_w(self) -> ProxyArray: """Positions of all bodies in simulation world frame. @@ -882,6 +966,7 @@ def body_com_pos_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ROTATION, element_names_resolver=body_quat_resolver) def body_com_quat_w(self) -> ProxyArray: """Orientation (x, y, z, w) of the principal axes of inertia of all bodies in simulation world frame. @@ -894,6 +979,7 @@ def body_com_quat_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_LINEAR_VELOCITY, element_names_resolver=body_xyz_resolver) def body_com_lin_vel_w(self) -> ProxyArray: """Linear velocity of all bodies in simulation world frame. @@ -906,6 +992,7 @@ def body_com_lin_vel_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ANGULAR_VELOCITY, element_names_resolver=body_xyz_resolver) def body_com_ang_vel_w(self) -> ProxyArray: """Angular velocity of all bodies in simulation world frame. @@ -918,6 +1005,7 @@ def body_com_ang_vel_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_LINEAR_ACCELERATION, element_names_resolver=body_xyz_resolver) def body_com_lin_acc_w(self) -> ProxyArray: """Linear acceleration of all bodies in simulation world frame. @@ -930,6 +1018,7 @@ def body_com_lin_acc_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ANGULAR_ACCELERATION, element_names_resolver=body_xyz_resolver) def body_com_ang_acc_w(self) -> ProxyArray: """Angular acceleration of all bodies in simulation world frame. @@ -942,6 +1031,7 @@ def body_com_ang_acc_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSITION, element_names_resolver=body_xyz_resolver) def body_com_pos_b(self) -> ProxyArray: """Center of mass position of all of the bodies in their respective link frames. @@ -954,6 +1044,7 @@ def body_com_pos_b(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ROTATION, element_names_resolver=body_quat_resolver) def body_com_quat_b(self) -> ProxyArray: """Orientation (x, y, z, w) of the principal axes of inertia of all of the bodies in their respective link frames. @@ -991,121 +1082,145 @@ def _create_buffers(self) -> None: """ @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSE, element_names=POSE7_ELEMENT_NAMES) def root_pose_w(self) -> ProxyArray: """Shorthand for :attr:`root_link_pose_w`.""" return self.root_link_pose_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSITION, element_names=XYZ_ELEMENT_NAMES) def root_pos_w(self) -> ProxyArray: """Shorthand for :attr:`root_link_pos_w`.""" return self.root_link_pos_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ROTATION, element_names=QUAT_XYZW_ELEMENT_NAMES) def root_quat_w(self) -> ProxyArray: """Shorthand for :attr:`root_link_quat_w`.""" return self.root_link_quat_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_VEL, element_names=POSE6_ELEMENT_NAMES) def root_vel_w(self) -> ProxyArray: """Shorthand for :attr:`root_com_vel_w`.""" return self.root_com_vel_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_LINEAR_VELOCITY, element_names=XYZ_ELEMENT_NAMES) def root_lin_vel_w(self) -> ProxyArray: """Shorthand for :attr:`root_com_lin_vel_w`.""" return self.root_com_lin_vel_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ANGULAR_VELOCITY, element_names=XYZ_ELEMENT_NAMES) def root_ang_vel_w(self) -> ProxyArray: """Shorthand for :attr:`root_com_ang_vel_w`.""" return self.root_com_ang_vel_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_LINEAR_VELOCITY, element_names=XYZ_ELEMENT_NAMES) def root_lin_vel_b(self) -> ProxyArray: """Shorthand for :attr:`root_com_lin_vel_b`.""" return self.root_com_lin_vel_b @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ANGULAR_VELOCITY, element_names=XYZ_ELEMENT_NAMES) def root_ang_vel_b(self) -> ProxyArray: """Shorthand for :attr:`root_com_ang_vel_b`.""" return self.root_com_ang_vel_b @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSE, element_names_resolver=body_pose_resolver) def body_pose_w(self) -> ProxyArray: """Shorthand for :attr:`body_link_pose_w`.""" return self.body_link_pose_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSITION, element_names_resolver=body_xyz_resolver) def body_pos_w(self) -> ProxyArray: """Shorthand for :attr:`body_link_pos_w`.""" return self.body_link_pos_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ROTATION, element_names_resolver=body_quat_resolver) def body_quat_w(self) -> ProxyArray: """Shorthand for :attr:`body_link_quat_w`.""" return self.body_link_quat_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_VEL, element_names_resolver=body_pose6_resolver) def body_vel_w(self) -> ProxyArray: """Shorthand for :attr:`body_com_vel_w`.""" return self.body_com_vel_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_LINEAR_VELOCITY, element_names_resolver=body_xyz_resolver) def body_lin_vel_w(self) -> ProxyArray: """Shorthand for :attr:`body_com_lin_vel_w`.""" return self.body_com_lin_vel_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ANGULAR_VELOCITY, element_names_resolver=body_xyz_resolver) def body_ang_vel_w(self) -> ProxyArray: """Shorthand for :attr:`body_com_ang_vel_w`.""" return self.body_com_ang_vel_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ACC, element_names_resolver=body_pose6_resolver) def body_acc_w(self) -> ProxyArray: """Shorthand for :attr:`body_com_acc_w`.""" return self.body_com_acc_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_LINEAR_ACCELERATION, element_names_resolver=body_xyz_resolver) def body_lin_acc_w(self) -> ProxyArray: """Shorthand for :attr:`body_com_lin_acc_w`.""" return self.body_com_lin_acc_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ANGULAR_ACCELERATION, element_names_resolver=body_xyz_resolver) def body_ang_acc_w(self) -> ProxyArray: """Shorthand for :attr:`body_com_ang_acc_w`.""" return self.body_com_ang_acc_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSITION, element_names_resolver=body_xyz_resolver) def com_pos_b(self) -> ProxyArray: """Shorthand for :attr:`body_com_pos_b`.""" return self.body_com_pos_b @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ROTATION, element_names_resolver=body_quat_resolver) def com_quat_b(self) -> ProxyArray: """Shorthand for :attr:`body_com_quat_b`.""" return self.body_com_quat_b @property + @leapp_tensor_semantics(const=True) def joint_limits(self) -> ProxyArray: """Shorthand for :attr:`joint_pos_limits`.""" return self.joint_pos_limits @property + @leapp_tensor_semantics(const=True) def default_joint_limits(self) -> ProxyArray: """Shorthand for :attr:`default_joint_pos_limits`.""" return self.default_joint_pos_limits @property + @leapp_tensor_semantics(const=True) def joint_velocity_limits(self) -> ProxyArray: """Shorthand for :attr:`joint_vel_limits`.""" return self.joint_vel_limits @property + @leapp_tensor_semantics(const=True) def joint_friction(self) -> ProxyArray: """Shorthand for :attr:`joint_friction_coeff`.""" return self.joint_friction_coeff @property + @leapp_tensor_semantics(const=True) def fixed_tendon_limit(self) -> ProxyArray: """Shorthand for :attr:`fixed_tendon_pos_limits`.""" return self.fixed_tendon_pos_limits @@ -1115,6 +1230,7 @@ def fixed_tendon_limit(self) -> ProxyArray: """ @property + @leapp_tensor_semantics(const=True) def default_mass(self) -> ProxyArray: """Deprecated property. Please use :attr:`body_mass` instead and manage the default mass manually.""" warnings.warn( @@ -1128,6 +1244,7 @@ def default_mass(self) -> ProxyArray: return ProxyArray(self._default_mass) @property + @leapp_tensor_semantics(const=True) def default_inertia(self) -> ProxyArray: """Deprecated property. Please use :attr:`body_inertia` instead and manage the default inertia manually.""" warnings.warn( @@ -1141,6 +1258,7 @@ def default_inertia(self) -> ProxyArray: return ProxyArray(self._default_inertia) @property + @leapp_tensor_semantics(const=True) def default_joint_stiffness(self) -> ProxyArray: """Deprecated property. Please use :attr:`joint_stiffness` instead and manage the default joint stiffness manually.""" @@ -1155,6 +1273,7 @@ def default_joint_stiffness(self) -> ProxyArray: return ProxyArray(self._default_joint_stiffness) @property + @leapp_tensor_semantics(const=True) def default_joint_damping(self) -> ProxyArray: """Deprecated property. Please use :attr:`joint_damping` instead and manage the default joint damping manually.""" @@ -1169,6 +1288,7 @@ def default_joint_damping(self) -> ProxyArray: return ProxyArray(self._default_joint_damping) @property + @leapp_tensor_semantics(const=True) def default_joint_armature(self) -> ProxyArray: """Deprecated property. Please use :attr:`joint_armature` instead and manage the default joint armature manually.""" @@ -1183,6 +1303,7 @@ def default_joint_armature(self) -> ProxyArray: return ProxyArray(self._default_joint_armature) @property + @leapp_tensor_semantics(const=True) def default_joint_friction_coeff(self) -> ProxyArray: """Deprecated property. Please use :attr:`joint_friction_coeff` instead and manage the default joint friction coefficient manually.""" @@ -1197,6 +1318,7 @@ def default_joint_friction_coeff(self) -> ProxyArray: return ProxyArray(self._default_joint_friction_coeff) @property + @leapp_tensor_semantics(const=True) def default_joint_viscous_friction_coeff(self) -> ProxyArray: """Deprecated property. Please use :attr:`joint_viscous_friction_coeff` instead and manage the default joint viscous friction coefficient manually.""" @@ -1207,10 +1329,13 @@ def default_joint_viscous_friction_coeff(self) -> ProxyArray: stacklevel=2, ) if self._default_joint_viscous_friction_coeff is None: - self._default_joint_viscous_friction_coeff = wp.clone(self.joint_viscous_friction_coeff.warp, self.device) + self._default_joint_viscous_friction_coeff = wp.clone( + getattr(self, "joint_viscous_friction_coeff").warp, self.device + ) return ProxyArray(self._default_joint_viscous_friction_coeff) @property + @leapp_tensor_semantics(const=True) def default_joint_pos_limits(self) -> ProxyArray: """Deprecated property. Please use :attr:`joint_pos_limits` instead and manage the default joint position limits manually.""" @@ -1225,6 +1350,7 @@ def default_joint_pos_limits(self) -> ProxyArray: return ProxyArray(self._default_joint_pos_limits) @property + @leapp_tensor_semantics(const=True) def default_fixed_tendon_stiffness(self) -> ProxyArray: """Deprecated property. Please use :attr:`fixed_tendon_stiffness` instead and manage the default fixed tendon stiffness manually.""" @@ -1239,6 +1365,7 @@ def default_fixed_tendon_stiffness(self) -> ProxyArray: return ProxyArray(self._default_fixed_tendon_stiffness) @property + @leapp_tensor_semantics(const=True) def default_fixed_tendon_damping(self) -> ProxyArray: """Deprecated property. Please use :attr:`fixed_tendon_damping` instead and manage the default fixed tendon damping manually.""" @@ -1253,6 +1380,7 @@ def default_fixed_tendon_damping(self) -> ProxyArray: return ProxyArray(self._default_fixed_tendon_damping) @property + @leapp_tensor_semantics(const=True) def default_fixed_tendon_limit_stiffness(self) -> ProxyArray: """Deprecated property. Please use :attr:`fixed_tendon_limit_stiffness` instead and manage the default fixed tendon limit stiffness manually.""" @@ -1267,6 +1395,7 @@ def default_fixed_tendon_limit_stiffness(self) -> ProxyArray: return ProxyArray(self._default_fixed_tendon_limit_stiffness) @property + @leapp_tensor_semantics(const=True) def default_fixed_tendon_rest_length(self) -> ProxyArray: """Deprecated property. Please use :attr:`fixed_tendon_rest_length` instead and manage the default fixed tendon rest length manually.""" @@ -1281,6 +1410,7 @@ def default_fixed_tendon_rest_length(self) -> ProxyArray: return ProxyArray(self._default_fixed_tendon_rest_length) @property + @leapp_tensor_semantics(const=True) def default_fixed_tendon_offset(self) -> ProxyArray: """Deprecated property. Please use :attr:`fixed_tendon_offset` instead and manage the default fixed tendon offset manually.""" @@ -1295,6 +1425,7 @@ def default_fixed_tendon_offset(self) -> ProxyArray: return ProxyArray(self._default_fixed_tendon_offset) @property + @leapp_tensor_semantics(const=True) def default_fixed_tendon_pos_limits(self) -> ProxyArray: """Deprecated property. Please use :attr:`fixed_tendon_pos_limits` instead and manage the default fixed tendon position limits manually.""" @@ -1309,6 +1440,7 @@ def default_fixed_tendon_pos_limits(self) -> ProxyArray: return ProxyArray(self._default_fixed_tendon_pos_limits) @property + @leapp_tensor_semantics(const=True) def default_spatial_tendon_stiffness(self) -> ProxyArray: """Deprecated property. Please use :attr:`spatial_tendon_stiffness` instead and manage the default spatial tendon stiffness manually.""" @@ -1323,6 +1455,7 @@ def default_spatial_tendon_stiffness(self) -> ProxyArray: return ProxyArray(self._default_spatial_tendon_stiffness) @property + @leapp_tensor_semantics(const=True) def default_spatial_tendon_damping(self) -> ProxyArray: """Deprecated property. Please use :attr:`spatial_tendon_damping` instead and manage the default spatial tendon damping manually.""" @@ -1337,6 +1470,7 @@ def default_spatial_tendon_damping(self) -> ProxyArray: return ProxyArray(self._default_spatial_tendon_damping) @property + @leapp_tensor_semantics(const=True) def default_spatial_tendon_limit_stiffness(self) -> ProxyArray: """Deprecated property. Please use :attr:`spatial_tendon_limit_stiffness` instead and manage the default spatial tendon limit stiffness manually.""" @@ -1353,6 +1487,7 @@ def default_spatial_tendon_limit_stiffness(self) -> ProxyArray: return ProxyArray(self._default_spatial_tendon_limit_stiffness) @property + @leapp_tensor_semantics(const=True) def default_spatial_tendon_offset(self) -> ProxyArray: """Deprecated property. Please use :attr:`spatial_tendon_offset` instead and manage the default spatial tendon offset manually.""" @@ -1367,6 +1502,7 @@ def default_spatial_tendon_offset(self) -> ProxyArray: return ProxyArray(self._default_spatial_tendon_offset) @property + @leapp_tensor_semantics(const=True) def default_fixed_tendon_limit(self) -> ProxyArray: """Deprecated property. Please use :attr:`default_fixed_tendon_pos_limits` instead.""" warnings.warn( @@ -1378,6 +1514,7 @@ def default_fixed_tendon_limit(self) -> ProxyArray: return self.default_fixed_tendon_pos_limits @property + @leapp_tensor_semantics(const=True) def default_joint_friction(self) -> ProxyArray: """Deprecated property. Please use :attr:`default_joint_friction_coeff` instead.""" warnings.warn( diff --git a/source/isaaclab/isaaclab/assets/rigid_object/base_rigid_object_data.py b/source/isaaclab/isaaclab/assets/rigid_object/base_rigid_object_data.py index e73302db6436..b9134cf9ec93 100644 --- a/source/isaaclab/isaaclab/assets/rigid_object/base_rigid_object_data.py +++ b/source/isaaclab/isaaclab/assets/rigid_object/base_rigid_object_data.py @@ -8,6 +8,18 @@ import warp as wp +from isaaclab.utils.leapp import ( + POSE6_ELEMENT_NAMES, + POSE7_ELEMENT_NAMES, + QUAT_XYZW_ELEMENT_NAMES, + XYZ_ELEMENT_NAMES, + InputKindEnum, + body_pose6_resolver, + body_pose_resolver, + body_quat_resolver, + body_xyz_resolver, + leapp_tensor_semantics, +) from isaaclab.utils.warp import ProxyArray @@ -54,7 +66,7 @@ def update(self, dt: float) -> None: # Names. ## - body_names: list[str] = None + body_names: list[str] | None = None """Body names in the order parsed by the simulation view.""" ## @@ -63,6 +75,7 @@ def update(self, dt: float) -> None: @property @abstractmethod + @leapp_tensor_semantics(const=True) def default_root_pose(self) -> ProxyArray: """Default root pose ``[pos, quat]`` in local environment frame. @@ -73,6 +86,7 @@ def default_root_pose(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(const=True) def default_root_vel(self) -> ProxyArray: """Default root velocity ``[lin_vel, ang_vel]`` in local environment frame. @@ -93,6 +107,7 @@ def default_root_state(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSE, element_names=POSE7_ELEMENT_NAMES) def root_link_pose_w(self) -> ProxyArray: """Root link pose ``[pos, quat]`` in simulation world frame. @@ -105,6 +120,7 @@ def root_link_pose_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_VEL, element_names=POSE6_ELEMENT_NAMES) def root_link_vel_w(self) -> ProxyArray: """Root link velocity ``[lin_vel, ang_vel]`` in simulation world frame. @@ -117,6 +133,7 @@ def root_link_vel_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSE, element_names=POSE7_ELEMENT_NAMES) def root_com_pose_w(self) -> ProxyArray: """Root center of mass pose ``[pos, quat]`` in simulation world frame. @@ -129,6 +146,7 @@ def root_com_pose_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_VEL, element_names=POSE6_ELEMENT_NAMES) def root_com_vel_w(self) -> ProxyArray: """Root center of mass velocity ``[lin_vel, ang_vel]`` in simulation world frame. @@ -141,18 +159,21 @@ def root_com_vel_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind="state/body/state") def root_state_w(self) -> ProxyArray: """Deprecated, same as :attr:`root_link_pose_w` and :attr:`root_com_vel_w`.""" raise NotImplementedError() @property @abstractmethod + @leapp_tensor_semantics(kind="state/body/link_state") def root_link_state_w(self) -> ProxyArray: """Deprecated, same as :attr:`root_link_pose_w` and :attr:`root_link_vel_w`.""" raise NotImplementedError() @property @abstractmethod + @leapp_tensor_semantics(kind="state/body/com_state") def root_com_state_w(self) -> ProxyArray: """Deprecated, same as :attr:`root_com_pose_w` and :attr:`root_com_vel_w`.""" raise NotImplementedError() @@ -163,6 +184,7 @@ def root_com_state_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSE, element_names_resolver=body_pose_resolver) def body_link_pose_w(self) -> ProxyArray: """Body link pose ``[pos, quat]`` in simulation world frame. @@ -176,6 +198,7 @@ def body_link_pose_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_VEL, element_names_resolver=body_pose6_resolver) def body_link_vel_w(self) -> ProxyArray: """Body link velocity ``[lin_vel, ang_vel]`` in simulation world frame. @@ -189,6 +212,7 @@ def body_link_vel_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSE, element_names_resolver=body_pose_resolver) def body_com_pose_w(self) -> ProxyArray: """Body center of mass pose ``[pos, quat]`` in simulation world frame. @@ -202,6 +226,7 @@ def body_com_pose_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_VEL, element_names_resolver=body_pose6_resolver) def body_com_vel_w(self) -> ProxyArray: """Body center of mass velocity ``[lin_vel, ang_vel]`` in simulation world frame. @@ -215,24 +240,28 @@ def body_com_vel_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind="state/body/state") def body_state_w(self) -> ProxyArray: """Deprecated, same as :attr:`body_link_pose_w` and :attr:`body_com_vel_w`.""" raise NotImplementedError() @property @abstractmethod + @leapp_tensor_semantics(kind="state/body/link_state") def body_link_state_w(self) -> ProxyArray: """Deprecated, same as :attr:`body_link_pose_w` and :attr:`body_link_vel_w`.""" raise NotImplementedError() @property @abstractmethod + @leapp_tensor_semantics(kind="state/body/com_state") def body_com_state_w(self) -> ProxyArray: """Deprecated, same as :attr:`body_com_pose_w` and :attr:`body_com_vel_w`.""" raise NotImplementedError() @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ACC, element_names_resolver=body_pose6_resolver) def body_com_acc_w(self) -> ProxyArray: """Acceleration of all bodies ``[lin_acc, ang_acc]`` in the simulation world frame. @@ -245,6 +274,7 @@ def body_com_acc_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSE, element_names_resolver=body_pose_resolver) def body_com_pose_b(self) -> ProxyArray: """Center of mass pose ``[pos, quat]`` of all bodies in their respective body's link frames. @@ -258,6 +288,7 @@ def body_com_pose_b(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(const=True) def body_mass(self) -> ProxyArray: """Mass of all bodies in the simulation world frame. @@ -268,6 +299,7 @@ def body_mass(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(const=True) def body_inertia(self) -> ProxyArray: """Inertia of all bodies in the simulation world frame. @@ -282,6 +314,7 @@ def body_inertia(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.VECTOR3D, element_names=XYZ_ELEMENT_NAMES) def projected_gravity_b(self) -> ProxyArray: """Projection of the gravity direction on base frame. @@ -291,6 +324,7 @@ def projected_gravity_b(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind="state/body/heading") def heading_w(self) -> ProxyArray: """Yaw heading of the base frame (in radians). @@ -304,6 +338,7 @@ def heading_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_LINEAR_VELOCITY, element_names=XYZ_ELEMENT_NAMES) def root_link_lin_vel_b(self) -> ProxyArray: """Root link linear velocity in base frame. @@ -316,6 +351,7 @@ def root_link_lin_vel_b(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ANGULAR_VELOCITY, element_names=XYZ_ELEMENT_NAMES) def root_link_ang_vel_b(self) -> ProxyArray: """Root link angular velocity in base frame. @@ -328,6 +364,7 @@ def root_link_ang_vel_b(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_LINEAR_VELOCITY, element_names=XYZ_ELEMENT_NAMES) def root_com_lin_vel_b(self) -> ProxyArray: """Root center of mass linear velocity in base frame. @@ -340,6 +377,7 @@ def root_com_lin_vel_b(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ANGULAR_VELOCITY, element_names=XYZ_ELEMENT_NAMES) def root_com_ang_vel_b(self) -> ProxyArray: """Root center of mass angular velocity in base frame. @@ -356,6 +394,7 @@ def root_com_ang_vel_b(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSITION, element_names=XYZ_ELEMENT_NAMES) def root_link_pos_w(self) -> ProxyArray: """Root link position in simulation world frame. @@ -367,6 +406,7 @@ def root_link_pos_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ROTATION, element_names=QUAT_XYZW_ELEMENT_NAMES) def root_link_quat_w(self) -> ProxyArray: """Root link orientation (x, y, z, w) in simulation world frame. @@ -378,6 +418,7 @@ def root_link_quat_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_LINEAR_VELOCITY, element_names=XYZ_ELEMENT_NAMES) def root_link_lin_vel_w(self) -> ProxyArray: """Root linear velocity in simulation world frame. @@ -389,6 +430,7 @@ def root_link_lin_vel_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ANGULAR_VELOCITY, element_names=XYZ_ELEMENT_NAMES) def root_link_ang_vel_w(self) -> ProxyArray: """Root link angular velocity in simulation world frame. @@ -400,6 +442,7 @@ def root_link_ang_vel_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSITION, element_names=XYZ_ELEMENT_NAMES) def root_com_pos_w(self) -> ProxyArray: """Root center of mass position in simulation world frame. @@ -411,6 +454,7 @@ def root_com_pos_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ROTATION, element_names=QUAT_XYZW_ELEMENT_NAMES) def root_com_quat_w(self) -> ProxyArray: """Root center of mass orientation (x, y, z, w) in simulation world frame. @@ -422,6 +466,7 @@ def root_com_quat_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_LINEAR_VELOCITY, element_names=XYZ_ELEMENT_NAMES) def root_com_lin_vel_w(self) -> ProxyArray: """Root center of mass linear velocity in simulation world frame. @@ -433,6 +478,7 @@ def root_com_lin_vel_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ANGULAR_VELOCITY, element_names=XYZ_ELEMENT_NAMES) def root_com_ang_vel_w(self) -> ProxyArray: """Root center of mass angular velocity in simulation world frame. @@ -444,6 +490,7 @@ def root_com_ang_vel_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSITION, element_names_resolver=body_xyz_resolver) def body_link_pos_w(self) -> ProxyArray: """Positions of all bodies in simulation world frame. @@ -455,6 +502,7 @@ def body_link_pos_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ROTATION, element_names_resolver=body_quat_resolver) def body_link_quat_w(self) -> ProxyArray: """Orientation (x, y, z, w) of all bodies in simulation world frame. @@ -466,6 +514,7 @@ def body_link_quat_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_LINEAR_VELOCITY, element_names_resolver=body_xyz_resolver) def body_link_lin_vel_w(self) -> ProxyArray: """Linear velocity of all bodies in simulation world frame. @@ -477,6 +526,7 @@ def body_link_lin_vel_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ANGULAR_VELOCITY, element_names_resolver=body_xyz_resolver) def body_link_ang_vel_w(self) -> ProxyArray: """Angular velocity of all bodies in simulation world frame. @@ -488,6 +538,7 @@ def body_link_ang_vel_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSITION, element_names_resolver=body_xyz_resolver) def body_com_pos_w(self) -> ProxyArray: """Positions of all bodies' center of mass in simulation world frame. @@ -499,6 +550,7 @@ def body_com_pos_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ROTATION, element_names_resolver=body_quat_resolver) def body_com_quat_w(self) -> ProxyArray: """Orientation (x, y, z, w) of the principal axes of inertia of all bodies in simulation world frame. @@ -510,6 +562,7 @@ def body_com_quat_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_LINEAR_VELOCITY, element_names_resolver=body_xyz_resolver) def body_com_lin_vel_w(self) -> ProxyArray: """Linear velocity of all bodies in simulation world frame. @@ -521,6 +574,7 @@ def body_com_lin_vel_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ANGULAR_VELOCITY, element_names_resolver=body_xyz_resolver) def body_com_ang_vel_w(self) -> ProxyArray: """Angular velocity of all bodies in simulation world frame. @@ -532,6 +586,7 @@ def body_com_ang_vel_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_LINEAR_ACCELERATION, element_names_resolver=body_xyz_resolver) def body_com_lin_acc_w(self) -> ProxyArray: """Linear acceleration of all bodies in simulation world frame. @@ -543,6 +598,7 @@ def body_com_lin_acc_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ANGULAR_ACCELERATION, element_names_resolver=body_xyz_resolver) def body_com_ang_acc_w(self) -> ProxyArray: """Angular acceleration of all bodies in simulation world frame. @@ -554,6 +610,7 @@ def body_com_ang_acc_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSITION, element_names_resolver=body_xyz_resolver) def body_com_pos_b(self) -> ProxyArray: """Center of mass position of all of the bodies in their respective link frames. @@ -565,6 +622,7 @@ def body_com_pos_b(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ROTATION, element_names_resolver=body_quat_resolver) def body_com_quat_b(self) -> ProxyArray: """Orientation (x, y, z, w) of the principal axes of inertia of all of the bodies in their respective link frames. @@ -585,96 +643,115 @@ def _create_buffers(self) -> None: """ @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSE, element_names=POSE7_ELEMENT_NAMES) def root_pose_w(self) -> ProxyArray: """Shorthand for :attr:`root_link_pose_w`.""" return self.root_link_pose_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSITION, element_names=XYZ_ELEMENT_NAMES) def root_pos_w(self) -> ProxyArray: """Shorthand for :attr:`root_link_pos_w`.""" return self.root_link_pos_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ROTATION, element_names=QUAT_XYZW_ELEMENT_NAMES) def root_quat_w(self) -> ProxyArray: """Shorthand for :attr:`root_link_quat_w`.""" return self.root_link_quat_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_VEL, element_names=POSE6_ELEMENT_NAMES) def root_vel_w(self) -> ProxyArray: """Shorthand for :attr:`root_com_vel_w`.""" return self.root_com_vel_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_LINEAR_VELOCITY, element_names=XYZ_ELEMENT_NAMES) def root_lin_vel_w(self) -> ProxyArray: """Shorthand for :attr:`root_com_lin_vel_w`.""" return self.root_com_lin_vel_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ANGULAR_VELOCITY, element_names=XYZ_ELEMENT_NAMES) def root_ang_vel_w(self) -> ProxyArray: """Shorthand for :attr:`root_com_ang_vel_w`.""" return self.root_com_ang_vel_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_LINEAR_VELOCITY, element_names=XYZ_ELEMENT_NAMES) def root_lin_vel_b(self) -> ProxyArray: """Shorthand for :attr:`root_com_lin_vel_b`.""" return self.root_com_lin_vel_b @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ANGULAR_VELOCITY, element_names=XYZ_ELEMENT_NAMES) def root_ang_vel_b(self) -> ProxyArray: """Shorthand for :attr:`root_com_ang_vel_b`.""" return self.root_com_ang_vel_b @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSE, element_names_resolver=body_pose_resolver) def body_pose_w(self) -> ProxyArray: """Shorthand for :attr:`body_link_pose_w`.""" return self.body_link_pose_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSITION, element_names_resolver=body_xyz_resolver) def body_pos_w(self) -> ProxyArray: """Shorthand for :attr:`body_link_pos_w`.""" return self.body_link_pos_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ROTATION, element_names_resolver=body_quat_resolver) def body_quat_w(self) -> ProxyArray: """Shorthand for :attr:`body_link_quat_w`.""" return self.body_link_quat_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_VEL, element_names_resolver=body_pose6_resolver) def body_vel_w(self) -> ProxyArray: """Shorthand for :attr:`body_com_vel_w`.""" return self.body_com_vel_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_LINEAR_VELOCITY, element_names_resolver=body_xyz_resolver) def body_lin_vel_w(self) -> ProxyArray: """Shorthand for :attr:`body_com_lin_vel_w`.""" return self.body_com_lin_vel_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ANGULAR_VELOCITY, element_names_resolver=body_xyz_resolver) def body_ang_vel_w(self) -> ProxyArray: """Shorthand for :attr:`body_com_ang_vel_w`.""" return self.body_com_ang_vel_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ACC, element_names_resolver=body_pose6_resolver) def body_acc_w(self) -> ProxyArray: """Shorthand for :attr:`body_com_acc_w`.""" return self.body_com_acc_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_LINEAR_ACCELERATION, element_names_resolver=body_xyz_resolver) def body_lin_acc_w(self) -> ProxyArray: """Shorthand for :attr:`body_com_lin_acc_w`.""" return self.body_com_lin_acc_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ANGULAR_ACCELERATION, element_names_resolver=body_xyz_resolver) def body_ang_acc_w(self) -> ProxyArray: """Shorthand for :attr:`body_com_ang_acc_w`.""" return self.body_com_ang_acc_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSITION, element_names_resolver=body_xyz_resolver) def com_pos_b(self) -> ProxyArray: """Shorthand for :attr:`body_com_pos_b`.""" return self.body_com_pos_b @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ROTATION, element_names_resolver=body_quat_resolver) def com_quat_b(self) -> ProxyArray: """Shorthand for :attr:`body_com_quat_b`.""" return self.body_com_quat_b @@ -684,6 +761,7 @@ def com_quat_b(self) -> ProxyArray: """ @property + @leapp_tensor_semantics(const=True) def default_mass(self) -> ProxyArray: """Deprecated property. Please use :attr:`body_mass` instead and manage the default mass manually.""" warnings.warn( @@ -697,6 +775,7 @@ def default_mass(self) -> ProxyArray: return ProxyArray(self._default_mass) @property + @leapp_tensor_semantics(const=True) def default_inertia(self) -> ProxyArray: """Deprecated property. Please use :attr:`body_inertia` instead and manage the default inertia manually.""" warnings.warn( diff --git a/source/isaaclab/isaaclab/assets/rigid_object_collection/base_rigid_object_collection_data.py b/source/isaaclab/isaaclab/assets/rigid_object_collection/base_rigid_object_collection_data.py index 9ab4e718c47e..37842c23264a 100644 --- a/source/isaaclab/isaaclab/assets/rigid_object_collection/base_rigid_object_collection_data.py +++ b/source/isaaclab/isaaclab/assets/rigid_object_collection/base_rigid_object_collection_data.py @@ -8,6 +8,14 @@ import warp as wp +from isaaclab.utils.leapp import ( + InputKindEnum, + body_pose6_resolver, + body_pose_resolver, + body_quat_resolver, + body_xyz_resolver, + leapp_tensor_semantics, +) from isaaclab.utils.warp import ProxyArray @@ -56,7 +64,7 @@ def update(self, dt: float) -> None: # Names. ## - body_names: list[str] = None + body_names: list[str] | None = None """Body names in the order parsed by the simulation view.""" ## @@ -65,6 +73,7 @@ def update(self, dt: float) -> None: @property @abstractmethod + @leapp_tensor_semantics(const=True) def default_body_pose(self) -> ProxyArray: """Default body pose ``[pos, quat]`` in local environment frame. @@ -76,6 +85,7 @@ def default_body_pose(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(const=True) def default_body_vel(self) -> ProxyArray: """Default body velocity ``[lin_vel, ang_vel]`` in local environment frame. @@ -87,6 +97,7 @@ def default_body_vel(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(const=True) def default_body_state(self) -> ProxyArray: """Deprecated, same as :attr:`default_body_pose` and :attr:`default_body_vel`.""" raise NotImplementedError() @@ -97,6 +108,7 @@ def default_body_state(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSE, element_names_resolver=body_pose_resolver) def body_link_pose_w(self) -> ProxyArray: """Body link pose ``[pos, quat]`` in simulation world frame. @@ -110,6 +122,7 @@ def body_link_pose_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_VEL, element_names_resolver=body_pose6_resolver) def body_link_vel_w(self) -> ProxyArray: """Body link velocity ``[lin_vel, ang_vel]`` in simulation world frame. @@ -123,6 +136,7 @@ def body_link_vel_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSE, element_names_resolver=body_pose_resolver) def body_com_pose_w(self) -> ProxyArray: """Body center of mass pose ``[pos, quat]`` in simulation world frame. @@ -136,6 +150,7 @@ def body_com_pose_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_VEL, element_names_resolver=body_pose6_resolver) def body_com_vel_w(self) -> ProxyArray: """Body center of mass velocity ``[lin_vel, ang_vel]`` in simulation world frame. @@ -149,24 +164,28 @@ def body_com_vel_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind="state/body/state") def body_state_w(self) -> ProxyArray: """Deprecated, same as :attr:`body_link_pose_w` and :attr:`body_com_vel_w`.""" raise NotImplementedError() @property @abstractmethod + @leapp_tensor_semantics(kind="state/body/link_state") def body_link_state_w(self) -> ProxyArray: """Deprecated, same as :attr:`body_link_pose_w` and :attr:`body_link_vel_w`.""" raise NotImplementedError() @property @abstractmethod + @leapp_tensor_semantics(kind="state/body/com_state") def body_com_state_w(self) -> ProxyArray: """Deprecated, same as :attr:`body_com_pose_w` and :attr:`body_com_vel_w`.""" raise NotImplementedError() @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ACC, element_names_resolver=body_pose6_resolver) def body_com_acc_w(self) -> ProxyArray: """Acceleration of all bodies ``[lin_acc, ang_acc]`` in the simulation world frame. @@ -179,6 +198,7 @@ def body_com_acc_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSE, element_names_resolver=body_pose_resolver) def body_com_pose_b(self) -> ProxyArray: """Center of mass pose ``[pos, quat]`` of all bodies in their respective body's link frames. @@ -192,6 +212,7 @@ def body_com_pose_b(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(const=True) def body_mass(self) -> ProxyArray: """Mass of all bodies in the simulation world frame. @@ -201,6 +222,7 @@ def body_mass(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(const=True) def body_inertia(self) -> ProxyArray: """Inertia of all bodies in the simulation world frame. @@ -215,6 +237,7 @@ def body_inertia(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.VECTOR3D, element_names_resolver=body_xyz_resolver) def projected_gravity_b(self) -> ProxyArray: """Projection of the gravity direction on base frame. @@ -225,6 +248,7 @@ def projected_gravity_b(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind="state/body/heading") def heading_w(self) -> ProxyArray: """Yaw heading of the base frame (in radians). @@ -239,6 +263,7 @@ def heading_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_LINEAR_VELOCITY, element_names_resolver=body_xyz_resolver) def body_link_lin_vel_b(self) -> ProxyArray: """Root link linear velocity in base frame. @@ -252,6 +277,7 @@ def body_link_lin_vel_b(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ANGULAR_VELOCITY, element_names_resolver=body_xyz_resolver) def body_link_ang_vel_b(self) -> ProxyArray: """Root link angular velocity in base frame. @@ -265,6 +291,7 @@ def body_link_ang_vel_b(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_LINEAR_VELOCITY, element_names_resolver=body_xyz_resolver) def body_com_lin_vel_b(self) -> ProxyArray: """Root center of mass linear velocity in base frame. @@ -278,6 +305,7 @@ def body_com_lin_vel_b(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ANGULAR_VELOCITY, element_names_resolver=body_xyz_resolver) def body_com_ang_vel_b(self) -> ProxyArray: """Root center of mass angular velocity in base frame. @@ -295,6 +323,7 @@ def body_com_ang_vel_b(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSITION, element_names_resolver=body_xyz_resolver) def body_link_pos_w(self) -> ProxyArray: """Positions of all bodies in simulation world frame. @@ -307,6 +336,7 @@ def body_link_pos_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ROTATION, element_names_resolver=body_quat_resolver) def body_link_quat_w(self) -> ProxyArray: """Orientation (x, y, z, w) of all bodies in simulation world frame. @@ -319,6 +349,7 @@ def body_link_quat_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_LINEAR_VELOCITY, element_names_resolver=body_xyz_resolver) def body_link_lin_vel_w(self) -> ProxyArray: """Linear velocity of all bodies in simulation world frame. @@ -331,6 +362,7 @@ def body_link_lin_vel_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ANGULAR_VELOCITY, element_names_resolver=body_xyz_resolver) def body_link_ang_vel_w(self) -> ProxyArray: """Angular velocity of all bodies in simulation world frame. @@ -343,6 +375,7 @@ def body_link_ang_vel_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSITION, element_names_resolver=body_xyz_resolver) def body_com_pos_w(self) -> ProxyArray: """Positions of all bodies' center of mass in simulation world frame. @@ -355,6 +388,7 @@ def body_com_pos_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ROTATION, element_names_resolver=body_quat_resolver) def body_com_quat_w(self) -> ProxyArray: """Orientation (x, y, z, w) of the principal axes of inertia of all bodies in simulation world frame. @@ -367,6 +401,7 @@ def body_com_quat_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_LINEAR_VELOCITY, element_names_resolver=body_xyz_resolver) def body_com_lin_vel_w(self) -> ProxyArray: """Linear velocity of all bodies in simulation world frame. @@ -379,6 +414,7 @@ def body_com_lin_vel_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ANGULAR_VELOCITY, element_names_resolver=body_xyz_resolver) def body_com_ang_vel_w(self) -> ProxyArray: """Angular velocity of all bodies in simulation world frame. @@ -391,6 +427,7 @@ def body_com_ang_vel_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_LINEAR_ACCELERATION, element_names_resolver=body_xyz_resolver) def body_com_lin_acc_w(self) -> ProxyArray: """Linear acceleration of all bodies in simulation world frame. @@ -403,6 +440,7 @@ def body_com_lin_acc_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ANGULAR_ACCELERATION, element_names_resolver=body_xyz_resolver) def body_com_ang_acc_w(self) -> ProxyArray: """Angular acceleration of all bodies in simulation world frame. @@ -415,6 +453,7 @@ def body_com_ang_acc_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSITION, element_names_resolver=body_xyz_resolver) def body_com_pos_b(self) -> ProxyArray: """Center of mass position of all of the bodies in their respective link frames. @@ -427,6 +466,7 @@ def body_com_pos_b(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ROTATION, element_names_resolver=body_quat_resolver) def body_com_quat_b(self) -> ProxyArray: """Orientation (x, y, z, w) of the principal axes of inertia of all of the bodies in their respective link frames. @@ -443,56 +483,67 @@ def body_com_quat_b(self) -> ProxyArray: """ @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSE, element_names_resolver=body_pose_resolver) def body_pose_w(self) -> ProxyArray: """Shorthand for :attr:`body_link_pose_w`.""" return self.body_link_pose_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSITION, element_names_resolver=body_xyz_resolver) def body_pos_w(self) -> ProxyArray: """Shorthand for :attr:`body_link_pos_w`.""" return self.body_link_pos_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ROTATION, element_names_resolver=body_quat_resolver) def body_quat_w(self) -> ProxyArray: """Shorthand for :attr:`body_link_quat_w`.""" return self.body_link_quat_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_VEL, element_names_resolver=body_pose6_resolver) def body_vel_w(self) -> ProxyArray: """Shorthand for :attr:`body_com_vel_w`.""" return self.body_com_vel_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_LINEAR_VELOCITY, element_names_resolver=body_xyz_resolver) def body_lin_vel_w(self) -> ProxyArray: """Shorthand for :attr:`body_com_lin_vel_w`.""" return self.body_com_lin_vel_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ANGULAR_VELOCITY, element_names_resolver=body_xyz_resolver) def body_ang_vel_w(self) -> ProxyArray: """Shorthand for :attr:`body_com_ang_vel_w`.""" return self.body_com_ang_vel_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ACC, element_names_resolver=body_pose6_resolver) def body_acc_w(self) -> ProxyArray: """Shorthand for :attr:`body_com_acc_w`.""" return self.body_com_acc_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_LINEAR_ACCELERATION, element_names_resolver=body_xyz_resolver) def body_lin_acc_w(self) -> ProxyArray: """Shorthand for :attr:`body_com_lin_acc_w`.""" return self.body_com_lin_acc_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ANGULAR_ACCELERATION, element_names_resolver=body_xyz_resolver) def body_ang_acc_w(self) -> ProxyArray: """Shorthand for :attr:`body_com_ang_acc_w`.""" return self.body_com_ang_acc_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSITION, element_names_resolver=body_xyz_resolver) def com_pos_b(self) -> ProxyArray: """Shorthand for :attr:`body_com_pos_b`.""" return self.body_com_pos_b @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ROTATION, element_names_resolver=body_quat_resolver) def com_quat_b(self) -> ProxyArray: """Shorthand for :attr:`body_com_quat_b`.""" return self.body_com_quat_b @@ -507,6 +558,7 @@ def _create_buffers(self): """ @property + @leapp_tensor_semantics(const=True) def default_object_pose(self) -> ProxyArray: """Deprecated property. Please use :attr:`default_body_pose` instead.""" warnings.warn( @@ -518,6 +570,7 @@ def default_object_pose(self) -> ProxyArray: return self.default_body_pose @property + @leapp_tensor_semantics(const=True) def default_object_vel(self) -> ProxyArray: """Deprecated property. Please use :attr:`default_body_vel` instead.""" warnings.warn( @@ -529,6 +582,7 @@ def default_object_vel(self) -> ProxyArray: return self.default_body_vel @property + @leapp_tensor_semantics(const=True) def default_object_state(self) -> ProxyArray: """Deprecated property. Please use :attr:`default_body_state` instead.""" warnings.warn( @@ -540,6 +594,7 @@ def default_object_state(self) -> ProxyArray: return self.default_body_state @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSE, element_names_resolver=body_pose_resolver) def object_link_pose_w(self): """Deprecated property. Please use :attr:`body_link_pose_w` instead.""" warnings.warn( @@ -551,6 +606,7 @@ def object_link_pose_w(self): return self.body_link_pose_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_VEL, element_names_resolver=body_pose6_resolver) def object_link_vel_w(self): """Deprecated property. Please use :attr:`body_link_vel_w` instead.""" warnings.warn( @@ -562,6 +618,7 @@ def object_link_vel_w(self): return self.body_link_vel_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSE, element_names_resolver=body_pose_resolver) def object_com_pose_w(self): """Deprecated property. Please use :attr:`body_com_pose_w` instead.""" warnings.warn( @@ -573,6 +630,7 @@ def object_com_pose_w(self): return self.body_com_pose_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_VEL, element_names_resolver=body_pose6_resolver) def object_com_vel_w(self): """Deprecated property. Please use :attr:`body_com_vel_w` instead.""" warnings.warn( @@ -584,6 +642,7 @@ def object_com_vel_w(self): return self.body_com_vel_w @property + @leapp_tensor_semantics(kind="state/body/state") def object_state_w(self): """Deprecated property. Please use :attr:`body_state_w` instead.""" warnings.warn( @@ -594,6 +653,7 @@ def object_state_w(self): return self.body_state_w @property + @leapp_tensor_semantics(kind="state/body/link_state") def object_link_state_w(self): """Deprecated property. Please use :attr:`body_link_state_w` instead.""" warnings.warn( @@ -605,6 +665,7 @@ def object_link_state_w(self): return self.body_link_state_w @property + @leapp_tensor_semantics(kind="state/body/com_state") def object_com_state_w(self): """Deprecated property. Please use :attr:`body_com_state_w` instead.""" warnings.warn( @@ -616,6 +677,7 @@ def object_com_state_w(self): return self.body_com_state_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ACC, element_names_resolver=body_pose6_resolver) def object_com_acc_w(self): """Deprecated property. Please use :attr:`body_com_acc_w` instead.""" warnings.warn( @@ -627,6 +689,7 @@ def object_com_acc_w(self): return self.body_com_acc_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSE, element_names_resolver=body_pose_resolver) def object_com_pose_b(self): """Deprecated property. Please use :attr:`body_com_pose_b` instead.""" warnings.warn( @@ -638,6 +701,7 @@ def object_com_pose_b(self): return self.body_com_pose_b @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSITION, element_names_resolver=body_xyz_resolver) def object_link_pos_w(self) -> ProxyArray: """Deprecated property. Please use :attr:`body_link_pos_w` instead.""" warnings.warn( @@ -649,6 +713,7 @@ def object_link_pos_w(self) -> ProxyArray: return self.body_link_pos_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ROTATION, element_names_resolver=body_quat_resolver) def object_link_quat_w(self) -> ProxyArray: """Deprecated property. Please use :attr:`body_link_quat_w` instead.""" warnings.warn( @@ -660,6 +725,7 @@ def object_link_quat_w(self) -> ProxyArray: return self.body_link_quat_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_LINEAR_VELOCITY, element_names_resolver=body_xyz_resolver) def object_link_lin_vel_w(self) -> ProxyArray: """Deprecated property. Please use :attr:`body_link_lin_vel_w` instead.""" warnings.warn( @@ -671,6 +737,7 @@ def object_link_lin_vel_w(self) -> ProxyArray: return self.body_link_lin_vel_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ANGULAR_VELOCITY, element_names_resolver=body_xyz_resolver) def object_link_ang_vel_w(self) -> ProxyArray: """Deprecated property. Please use :attr:`body_link_ang_vel_w` instead.""" warnings.warn( @@ -682,6 +749,7 @@ def object_link_ang_vel_w(self) -> ProxyArray: return self.body_link_ang_vel_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSITION, element_names_resolver=body_xyz_resolver) def object_com_pos_w(self) -> ProxyArray: """Deprecated property. Please use :attr:`body_com_pos_w` instead.""" warnings.warn( @@ -693,6 +761,7 @@ def object_com_pos_w(self) -> ProxyArray: return self.body_com_pos_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ROTATION, element_names_resolver=body_quat_resolver) def object_com_quat_w(self) -> ProxyArray: """Deprecated property. Please use :attr:`body_com_quat_w` instead.""" warnings.warn( @@ -704,6 +773,7 @@ def object_com_quat_w(self) -> ProxyArray: return self.body_com_quat_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_LINEAR_VELOCITY, element_names_resolver=body_xyz_resolver) def object_com_lin_vel_w(self) -> ProxyArray: """Deprecated property. Please use :attr:`body_com_lin_vel_w` instead.""" warnings.warn( @@ -715,6 +785,7 @@ def object_com_lin_vel_w(self) -> ProxyArray: return self.body_com_lin_vel_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ANGULAR_VELOCITY, element_names_resolver=body_xyz_resolver) def object_com_ang_vel_w(self) -> ProxyArray: """Deprecated property. Please use :attr:`body_com_ang_vel_w` instead.""" warnings.warn( @@ -726,6 +797,7 @@ def object_com_ang_vel_w(self) -> ProxyArray: return self.body_com_ang_vel_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_LINEAR_ACCELERATION, element_names_resolver=body_xyz_resolver) def object_com_lin_acc_w(self) -> ProxyArray: """Deprecated property. Please use :attr:`body_com_lin_acc_w` instead.""" warnings.warn( @@ -737,6 +809,7 @@ def object_com_lin_acc_w(self) -> ProxyArray: return self.body_com_lin_acc_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ANGULAR_ACCELERATION, element_names_resolver=body_xyz_resolver) def object_com_ang_acc_w(self) -> ProxyArray: """Deprecated property. Please use :attr:`body_com_ang_acc_w` instead.""" warnings.warn( @@ -748,6 +821,7 @@ def object_com_ang_acc_w(self) -> ProxyArray: return self.body_com_ang_acc_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSITION, element_names_resolver=body_xyz_resolver) def object_com_pos_b(self) -> ProxyArray: """Deprecated property. Please use :attr:`body_com_pos_b` instead.""" warnings.warn( @@ -759,6 +833,7 @@ def object_com_pos_b(self) -> ProxyArray: return self.body_com_pos_b @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ROTATION, element_names_resolver=body_quat_resolver) def object_com_quat_b(self) -> ProxyArray: """Deprecated property. Please use :attr:`body_com_quat_b` instead.""" warnings.warn( @@ -770,6 +845,7 @@ def object_com_quat_b(self) -> ProxyArray: return self.body_com_quat_b @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_LINEAR_VELOCITY, element_names_resolver=body_xyz_resolver) def object_link_lin_vel_b(self) -> ProxyArray: """Deprecated property. Please use :attr:`body_link_lin_vel_b` instead.""" warnings.warn( @@ -781,6 +857,7 @@ def object_link_lin_vel_b(self) -> ProxyArray: return self.body_link_lin_vel_b @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ANGULAR_VELOCITY, element_names_resolver=body_xyz_resolver) def object_link_ang_vel_b(self) -> ProxyArray: """Deprecated property. Please use :attr:`body_link_ang_vel_b` instead.""" warnings.warn( @@ -792,6 +869,7 @@ def object_link_ang_vel_b(self) -> ProxyArray: return self.body_link_ang_vel_b @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_LINEAR_VELOCITY, element_names_resolver=body_xyz_resolver) def object_com_lin_vel_b(self) -> ProxyArray: """Deprecated property. Please use :attr:`body_com_lin_vel_b` instead.""" warnings.warn( @@ -803,6 +881,7 @@ def object_com_lin_vel_b(self) -> ProxyArray: return self.body_com_lin_vel_b @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ANGULAR_VELOCITY, element_names_resolver=body_xyz_resolver) def object_com_ang_vel_b(self) -> ProxyArray: """Deprecated property. Please use :attr:`body_com_ang_vel_b` instead.""" warnings.warn( @@ -814,6 +893,7 @@ def object_com_ang_vel_b(self) -> ProxyArray: return self.body_com_ang_vel_b @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSE, element_names_resolver=body_pose_resolver) def object_pose_w(self) -> ProxyArray: """Deprecated property. Please use :attr:`body_link_pose_w` instead.""" warnings.warn( @@ -824,6 +904,7 @@ def object_pose_w(self) -> ProxyArray: return self.body_link_pose_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSITION, element_names_resolver=body_xyz_resolver) def object_pos_w(self) -> ProxyArray: """Deprecated property. Please use :attr:`body_link_pos_w` instead.""" warnings.warn( @@ -834,6 +915,7 @@ def object_pos_w(self) -> ProxyArray: return self.body_link_pos_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ROTATION, element_names_resolver=body_quat_resolver) def object_quat_w(self) -> ProxyArray: """Deprecated property. Please use :attr:`body_link_quat_w` instead.""" warnings.warn( @@ -844,6 +926,7 @@ def object_quat_w(self) -> ProxyArray: return self.body_link_quat_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_VEL, element_names_resolver=body_pose6_resolver) def object_vel_w(self) -> ProxyArray: """Deprecated property. Please use :attr:`body_com_vel_w` instead.""" warnings.warn( @@ -854,6 +937,7 @@ def object_vel_w(self) -> ProxyArray: return self.body_com_vel_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_LINEAR_VELOCITY, element_names_resolver=body_xyz_resolver) def object_lin_vel_w(self) -> ProxyArray: """Deprecated property. Please use :attr:`body_com_lin_vel_w` instead.""" warnings.warn( @@ -865,6 +949,7 @@ def object_lin_vel_w(self) -> ProxyArray: return self.body_com_lin_vel_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ANGULAR_VELOCITY, element_names_resolver=body_xyz_resolver) def object_ang_vel_w(self) -> ProxyArray: """Deprecated property. Please use :attr:`body_com_ang_vel_w` instead.""" warnings.warn( @@ -876,6 +961,7 @@ def object_ang_vel_w(self) -> ProxyArray: return self.body_com_ang_vel_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_LINEAR_VELOCITY, element_names_resolver=body_xyz_resolver) def object_lin_vel_b(self) -> ProxyArray: """Deprecated property. Please use :attr:`body_com_lin_vel_b` instead.""" warnings.warn( @@ -887,6 +973,7 @@ def object_lin_vel_b(self) -> ProxyArray: return self.body_com_lin_vel_b @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ANGULAR_VELOCITY, element_names_resolver=body_xyz_resolver) def object_ang_vel_b(self) -> ProxyArray: """Deprecated property. Please use :attr:`body_com_ang_vel_b` instead.""" warnings.warn( @@ -898,6 +985,7 @@ def object_ang_vel_b(self) -> ProxyArray: return self.body_com_ang_vel_b @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ACC, element_names_resolver=body_pose6_resolver) def object_acc_w(self) -> ProxyArray: """Deprecated property. Please use :attr:`body_com_acc_w` instead.""" warnings.warn( @@ -908,6 +996,7 @@ def object_acc_w(self) -> ProxyArray: return self.body_com_acc_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_LINEAR_ACCELERATION, element_names_resolver=body_xyz_resolver) def object_lin_acc_w(self) -> ProxyArray: """Deprecated property. Please use :attr:`body_com_lin_acc_w` instead.""" warnings.warn( @@ -919,6 +1008,7 @@ def object_lin_acc_w(self) -> ProxyArray: return self.body_com_lin_acc_w @property + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ANGULAR_ACCELERATION, element_names_resolver=body_xyz_resolver) def object_ang_acc_w(self) -> ProxyArray: """Deprecated property. Please use :attr:`body_com_ang_acc_w` instead.""" warnings.warn( @@ -934,6 +1024,7 @@ def object_ang_acc_w(self) -> ProxyArray: """ @property + @leapp_tensor_semantics(const=True) def default_mass(self) -> ProxyArray: """Deprecated property. Please use :attr:`body_mass` instead and manage the default mass manually.""" warnings.warn( @@ -947,6 +1038,7 @@ def default_mass(self) -> ProxyArray: return ProxyArray(self._default_mass) @property + @leapp_tensor_semantics(const=True) def default_inertia(self) -> ProxyArray: """Deprecated property. Please use :attr:`body_inertia` instead and manage the default inertia manually.""" warnings.warn( diff --git a/source/isaaclab/isaaclab/envs/leapp_deployment_env.py b/source/isaaclab/isaaclab/envs/leapp_deployment_env.py new file mode 100644 index 000000000000..fe81e8f82e5f --- /dev/null +++ b/source/isaaclab/isaaclab/envs/leapp_deployment_env.py @@ -0,0 +1,449 @@ +# 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 + +"""Deployment environment that runs LEAPP-exported policies in simulation. + +This environment bypasses all Isaac Lab managers (observation, action, reward, etc.) +and instead wires scene entity data properties and ``CommandManager`` outputs directly +to a LEAPP ``InferenceManager``, then writes the model outputs back to the +corresponding scene entities. All I/O resolution is driven by the +``isaaclab_connection`` field in the LEAPP YAML. +""" + +from __future__ import annotations + +import inspect +import logging +from dataclasses import dataclass +from typing import Any, cast + +import torch +import yaml + +try: + from leapp import InferenceManager +except ImportError as e: + raise ImportError("LEAPP package is required for policy deployment testing. Install with: pip install leapp") from e + +from isaaclab.managers import CommandManager, EventManager +from isaaclab.scene import InteractiveScene +from isaaclab.sim import SimulationContext +from isaaclab.sim.utils.stage import use_stage +from isaaclab.utils.configclass import resolve_cfg_presets + +from .ui import ViewportCameraController + +logger = logging.getLogger(__name__) + + +# ══════════════════════════════════════════════════════════════════ +# I/O spec dataclasses +# ══════════════════════════════════════════════════════════════════ + + +@dataclass +class StateInputSpec: + """Read a property from a scene entity's data object.""" + + entity_name: str + property_name: str + joint_ids: list[int] | None = None + + +@dataclass +class CommandInputSpec: + """Read a command tensor from ``CommandManager``.""" + + command_term_name: str + + +@dataclass +class WriteOutputSpec: + """Write a tensor to a scene entity method, optionally indexed by joint.""" + + entity_name: str + method_name: str + value_param: str + joint_ids: list[int] | None = None + + +# ══════════════════════════════════════════════════════════════════ +# Connection-string helpers +# ══════════════════════════════════════════════════════════════════ + + +def _resolve_joint_ids(element_names: list | None, entity: Any) -> list[int] | None: + """Convert ``element_names[0]`` joint names to integer joint indices. + + Args: + element_names: LEAPP element-name metadata for the tensor, or ``None`` + when the tensor does not define named elements. + entity: Scene entity that may provide ``joint_names`` and + ``find_joints()`` for name-to-index resolution. + + Returns: + Joint indices matching ``element_names[0]``, or ``None`` when no + slicing is needed because all joints are selected, the tensor is not + joint-indexed, or the entity does not support joint lookup. + """ + if element_names is None or not hasattr(entity, "find_joints"): + return None + + # leapp tensor semantics will always store the array in a nested list of lists. + # NOTE: this is added in explicitly to handle partial joint application. currently + # this environment does not handle element reordering yet. Thus, this function + # is specialized to handle joints, hence reading index 0. + joint_names = element_names[0] + if not isinstance(joint_names, list) or not joint_names: + return None + entity_joint_names = list(entity.joint_names) + # Only resolve indices when the leading element-name axis actually refers + # to a subset of this articulation's joints. Other tensors can use axis + # labels like ["x", "y", "z"] or body names in the first axis. + matching_joint_names = [name for name in joint_names if name in entity_joint_names] + if not matching_joint_names: + return None + if len(matching_joint_names) != len(joint_names): + raise ValueError( + f"LEAPP element names mix joint and non-joint labels for an articulation-backed tensor: {joint_names}" + ) + if joint_names == entity_joint_names: + return None + joint_ids, _ = entity.find_joints(joint_names, preserve_order=True) + return joint_ids + + +def _first_param_name(method: Any) -> str: + """Return the name of the first non-self parameter of *method*. + + Expects a bound method — ``inspect.signature`` on a bound method + already excludes ``self``, so ``params[0]`` is the first real parameter. + + Args: + method: Bound method whose first callable parameter should be + inspected. + + Returns: + The name of the first non-``self`` parameter. + """ + params = list(inspect.signature(method).parameters.values()) + if not params: + raise TypeError(f"{method} has no parameters") + return params[0].name + + +# ══════════════════════════════════════════════════════════════════ +# LeappDeploymentEnv +# ══════════════════════════════════════════════════════════════════ + + +class LeappDeploymentEnv: + """Runs a LEAPP-exported policy in an Isaac Lab scene. + + The environment sets up the simulation scene and physics from a standard + Isaac Lab config, then wires raw sensor/command data to a LEAPP + ``InferenceManager`` and writes the model outputs back to the corresponding + scene entities. + + I/O wiring is driven entirely by the ``isaaclab_connection`` metadata field + in the LEAPP YAML. Each connection string encodes the type of access, the + scene entity name, and the property or method to call: + + - ``state:{entity}:{property}`` -- read ``scene[entity].data.{property}`` + - ``command:{name}`` -- read ``command_manager.get_command(name)`` + - ``write:{entity}:{method}`` -- call ``scene[entity].{method}(tensor, ...)`` + + No observation, action, reward, termination, or curriculum managers are used. + The LEAPP model already contains all pre/post-processing. + """ + + def __init__(self, cfg: Any, leapp_yaml_path: str): + """Initialize the deployment environment. + + Args: + cfg: A ``ManagerBasedRLEnvCfg`` (or compatible) task config. + leapp_yaml_path: Path to the LEAPP ``.yaml`` pipeline description. + """ + + cfg.scene.num_envs = 1 + cfg.validate() + resolve_cfg_presets(cfg) + self.cfg = cfg + self._is_closed = False + self._leapp_yaml_path = leapp_yaml_path + self._step_count = 0 + self._sim_step_counter = 0 + + # ── Simulation + scene ──────────────────────────────────── + self.sim = SimulationContext(cfg.sim) + if "cuda" in self.sim.device: + torch.cuda.set_device(self.sim.device) + + with use_stage(self.sim.stage): + self.scene = InteractiveScene(cfg.scene) + with use_stage(self.sim.stage): + self.sim.reset() + self.scene.update(dt=self.physics_dt) + self.has_rtx_sensors = bool(self.sim.get_setting("/isaaclab/render/rtx_sensors")) + + # Match the standard env initialization path for viewport camera setup. + has_visualizers = bool(self.sim.get_setting("/isaaclab/visualizer")) + if self.sim.has_gui or has_visualizers: + self.viewport_camera_controller = ViewportCameraController(cast(Any, self), self.cfg.viewer) + else: + self.viewport_camera_controller = None + + # ── EventManager (optional, for resets) ─────────────────── + self.event_manager: EventManager | None = None + if hasattr(cfg, "events") and cfg.events is not None: + self.event_manager = EventManager(cfg.events, cast(Any, self)) + + # ── CommandManager (optional, for command/* inputs) ─────── + self.command_manager: CommandManager | None = None + if hasattr(cfg, "commands") and cfg.commands is not None: + self.command_manager = CommandManager(cfg.commands, cast(Any, self)) + + # ── LEAPP InferenceManager ──────────────────────────────── + self.inference = InferenceManager(leapp_yaml_path) + + # ── Parse YAML and resolve I/O mappings ─────────────────── + with open(leapp_yaml_path) as f: + self._leapp_desc = yaml.safe_load(f) + self._input_mapping: dict[str, StateInputSpec | CommandInputSpec] = {} + self._output_mapping: dict[str, WriteOutputSpec] = {} + self._resolve_io() + + logger.info( + "LeappDeploymentEnv ready — %d inputs, %d outputs mapped", + len(self._input_mapping), + len(self._output_mapping), + ) + + if self.sim.has_gui and getattr(self.cfg, "ui_window_class_type", None) is not None: + self._window = self.cfg.ui_window_class_type(self, window_name="IsaacLab") + else: + self._window = None + + # ── Properties ──────────────────────────────────────────────── + + @property + def num_envs(self) -> int: + return 1 + + @property + def physics_dt(self) -> float: + return self.cfg.sim.dt + + @property + def step_dt(self) -> float: + return self.cfg.sim.dt * self.cfg.decimation + + @property + def device(self) -> str: + return self.sim.device + + # ── I/O Resolution ──────────────────────────────────────────── + + def _resolve_io(self): + """Build ``_input_mapping`` and ``_output_mapping`` from LEAPP metadata. + + Parses the ``isaaclab_connection`` field in the loaded LEAPP YAML and + resolves each declared input/output to the corresponding scene entity, + command term, and optional joint index selection. + """ + pipeline = self._leapp_desc["pipeline"] + + for node_name, input_names in pipeline["inputs"].items(): + node = self.inference.nodes[node_name] + desc_by_name = {d["name"]: d for d in node.input_descriptions} + for input_name in input_names: + desc = desc_by_name[input_name] + connection = desc.get("isaaclab_connection") + if connection is None: + continue + key = f"{node_name}/{input_name}" + parts = connection.split(":") + conn_type = parts[0] + + if conn_type == "state": + entity_name, prop_name = parts[1], parts[2] + entity = self.scene[entity_name] + jids = _resolve_joint_ids(desc.get("element_names"), entity) + self._input_mapping[key] = StateInputSpec( + entity_name=entity_name, + property_name=prop_name, + joint_ids=jids, + ) + elif conn_type == "command": + command_name = parts[1] + if self.command_manager is None: + raise RuntimeError( + f"LEAPP input '{key}' requires command '{command_name}' but no " + "CommandManager is available (cfg.commands is None)." + ) + self._input_mapping[key] = CommandInputSpec(command_term_name=command_name) + else: + logger.warning("Unknown connection type '%s' for input '%s'", conn_type, key) + + for node_name, output_names in pipeline["outputs"].items(): + node = self.inference.nodes[node_name] + desc_by_name = {d["name"]: d for d in node.output_descriptions} + for output_name in output_names: + desc = desc_by_name[output_name] + connection = desc.get("isaaclab_connection") + if connection is None: + continue + key = f"{node_name}/{output_name}" + parts = connection.split(":") + conn_type = parts[0] + + if conn_type == "write": + entity_name, method_name = parts[1], parts[2] + entity = self.scene[entity_name] + jids = _resolve_joint_ids(desc.get("element_names"), entity) + value_param = _first_param_name(getattr(entity, method_name)) + self._output_mapping[key] = WriteOutputSpec( + entity_name=entity_name, + method_name=method_name, + value_param=value_param, + joint_ids=jids, + ) + else: + logger.warning("Unknown connection type '%s' for output '%s'", conn_type, key) + + # ── Read / Write ────────────────────────────────────────────── + + def _read_inputs(self) -> dict[str, torch.Tensor]: + """Read all mapped inputs from scene entities and command manager. + + Returns: + A mapping from ``"node_name/tensor_name"`` to the tensor value that + should be passed to the LEAPP inference pipeline. + """ + inputs: dict[str, torch.Tensor] = {} + for key, spec in self._input_mapping.items(): + if isinstance(spec, StateInputSpec): + entity = self.scene[spec.entity_name] + value = getattr(entity.data, spec.property_name).torch + if spec.joint_ids is not None: + value = value[:, spec.joint_ids] + inputs[key] = value + elif isinstance(spec, CommandInputSpec): + command_manager = self.command_manager + assert command_manager is not None + inputs[key] = command_manager.get_command(spec.command_term_name) + return inputs + + def _write_outputs(self, outputs: dict[str, torch.Tensor]): + """Write model outputs to scene entities. + + Args: + outputs: Model outputs keyed by ``"node_name/tensor_name"`` as + returned by :meth:`step` and ``InferenceManager.run_policy()``. + """ + for key, tensor in outputs.items(): + spec = self._output_mapping.get(key) + if spec is None: + continue + entity = self.scene[spec.entity_name] + method = getattr(entity, spec.method_name) + if spec.joint_ids is not None: + method(**{spec.value_param: tensor, "joint_ids": spec.joint_ids}) + else: + method(**{spec.value_param: tensor}) + + # ── Public API ──────────────────────────────────────────────── + + def reset(self) -> dict[str, torch.Tensor]: + """Reset the scene and inference state. + + Returns: + The initial input tensors (for logging / debugging). + """ + env_ids = [0] + + self.scene.reset(env_ids) + + if self.event_manager is not None and "reset" in self.event_manager.available_modes: + self.event_manager.apply(mode="reset", env_ids=env_ids, global_env_step_count=self._step_count) + if self.command_manager is not None: + self.command_manager.reset(env_ids) + + self.scene.write_data_to_sim() + self.sim.forward() + self.scene.update(dt=self.physics_dt) + + # If RTX sensors are present, rerender after reset to refresh their outputs. + if self.has_rtx_sensors and getattr(self.cfg, "num_rerenders_on_reset", 0) > 0: + for _ in range(self.cfg.num_rerenders_on_reset): + self.sim.render() + + if getattr(self.cfg, "wait_for_textures", False) and self.has_rtx_sensors: + assets_loading = getattr(self.sim.physics_manager, "assets_loading", None) + if callable(assets_loading): + while assets_loading(): + self.sim.render() + + self.inference.reset() + + return self._read_inputs() + + def step(self, external_inputs: dict[str, torch.Tensor] | None = None) -> dict[str, torch.Tensor]: + """Run one environment step: read -> infer -> write -> physics. + + Args: + external_inputs: Optional overrides keyed by ``"ModelName/input_name"``. + Takes precedence over auto-resolved state/command values. + + Returns: + The dict of pipeline outputs from ``InferenceManager.run_policy()``. + """ + self._step_count += 1 + + # 1. Update commands + if self.command_manager is not None: + self.command_manager.compute(dt=self.step_dt) + + # 2. Read inputs + inputs = self._read_inputs() + + # 3. Merge external overrides + if external_inputs is not None: + inputs.update(external_inputs) + + # 4. Infer + with torch.inference_mode(): + outputs = self.inference.run_policy(inputs) + + # 5. Write outputs to scene entities + self._write_outputs(outputs) + + # 6. Decimation loop + is_rendering = self.sim.is_rendering + for _ in range(self.cfg.decimation): + self._sim_step_counter += 1 + self.scene.write_data_to_sim() + self.sim.step(render=False) + if self._sim_step_counter % self.cfg.sim.render_interval == 0 and is_rendering: + self.sim.render() + self.scene.update(dt=self.physics_dt) + + return outputs + + def close(self): + """Clean up the environment and release simulator-owned resources.""" + if not self._is_closed: + self.sim.stop() + if self.command_manager is not None: + del self.command_manager + if self.event_manager is not None: + del self.event_manager + del self.scene + if self.viewport_camera_controller is not None: + del self.viewport_camera_controller + self.sim.clear_instance() + if self._window is not None: + self._window = None + self._is_closed = True diff --git a/source/isaaclab/isaaclab/envs/mdp/commands/pose_2d_command.py b/source/isaaclab/isaaclab/envs/mdp/commands/pose_2d_command.py index 05a5e3afdda1..ffd7bdad5e49 100644 --- a/source/isaaclab/isaaclab/envs/mdp/commands/pose_2d_command.py +++ b/source/isaaclab/isaaclab/envs/mdp/commands/pose_2d_command.py @@ -65,6 +65,11 @@ def __init__(self, cfg: UniformPose2dCommandCfg, env: ManagerBasedEnv): if self._track_success: self._succeeded = torch.zeros(self.num_envs, dtype=torch.bool, device=self.device) + # adds (optional) cmd kind and element names for leapp export + # during export, semantic data about this command will be used to annotate the command input + self.cfg.cmd_kind = self.cfg.cmd_kind or "command/body/pose" + self.cfg.element_names = self.cfg.element_names or ["x", "y", "z", "heading"] + def __str__(self) -> str: msg = "PositionCommand:\n" msg += f"\tCommand dimension: {tuple(self.command.shape[1:])}\n" diff --git a/source/isaaclab/isaaclab/envs/mdp/commands/pose_command.py b/source/isaaclab/isaaclab/envs/mdp/commands/pose_command.py index 83cb27828df1..d98a6b5e7f81 100644 --- a/source/isaaclab/isaaclab/envs/mdp/commands/pose_command.py +++ b/source/isaaclab/isaaclab/envs/mdp/commands/pose_command.py @@ -15,6 +15,7 @@ from isaaclab.assets import Articulation from isaaclab.managers import CommandTerm from isaaclab.markers import VisualizationMarkers +from isaaclab.utils.leapp import POSE7_ELEMENT_NAMES from isaaclab.utils.math import combine_frame_transforms, compute_pose_error, quat_from_euler_xyz, quat_unique if TYPE_CHECKING: @@ -60,7 +61,7 @@ def __init__(self, cfg: UniformPoseCommandCfg, env: ManagerBasedEnv): self.body_idx = self.robot.find_bodies(cfg.body_name)[0][0] # create buffers - # -- commands: (x, y, z, qw, qx, qy, qz) in root frame + # -- commands: (x, y, z, qx, qy, qz, qw) in root frame self.pose_command_b = torch.zeros(self.num_envs, 7, device=self.device) self.pose_command_b[:, 3] = 1.0 self.pose_command_w = torch.zeros_like(self.pose_command_b) @@ -72,6 +73,11 @@ def __init__(self, cfg: UniformPoseCommandCfg, env: ManagerBasedEnv): if self._track_success: self._succeeded = torch.zeros(self.num_envs, dtype=torch.bool, device=self.device) + # adds (optional) cmd kind and element names for leapp export + # during export, semantic data about this command will be used to annotate the command input + self.cfg.cmd_kind = self.cfg.cmd_kind or "command/body/pose" + self.cfg.element_names = self.cfg.element_names or POSE7_ELEMENT_NAMES + def __str__(self) -> str: msg = "UniformPoseCommand:\n" msg += f"\tCommand dimension: {tuple(self.command.shape[1:])}\n" diff --git a/source/isaaclab/isaaclab/envs/mdp/commands/velocity_command.py b/source/isaaclab/isaaclab/envs/mdp/commands/velocity_command.py index 843682814ab2..930f663f65d3 100644 --- a/source/isaaclab/isaaclab/envs/mdp/commands/velocity_command.py +++ b/source/isaaclab/isaaclab/envs/mdp/commands/velocity_command.py @@ -92,6 +92,11 @@ def __init__(self, cfg: UniformVelocityCommandCfg, env: ManagerBasedEnv): self._error_yaw_sum = torch.zeros(self.num_envs, device=self.device) self._step_count = torch.zeros(self.num_envs, device=self.device) + # adds (optional) cmd kind and element names for leapp export + # during export, semantic data about this command will be used to annotate the command input + self.cfg.cmd_kind = self.cfg.cmd_kind or "command/body/velocity" + self.cfg.element_names = self.cfg.element_names or ["lin_vel_x", "lin_vel_y", "ang_vel_z"] + def __str__(self) -> str: """Return a string representation of the command generator.""" msg = "UniformVelocityCommand:\n" diff --git a/source/isaaclab/isaaclab/managers/manager_term_cfg.py b/source/isaaclab/isaaclab/managers/manager_term_cfg.py index de7c23aa220b..06f2516324b5 100644 --- a/source/isaaclab/isaaclab/managers/manager_term_cfg.py +++ b/source/isaaclab/isaaclab/managers/manager_term_cfg.py @@ -118,6 +118,11 @@ class CommandTermCfg: debug_vis: bool = False """Whether to visualize debug information. Defaults to False.""" + cmd_kind: str | None = None + """Type hint for the command for deployment.""" + element_names: list[str] | list[list[str]] | None = None + """Element names for the command for deployment.""" + ## # Curriculum manager. diff --git a/source/isaaclab/isaaclab/sensors/contact_sensor/base_contact_sensor_data.py b/source/isaaclab/isaaclab/sensors/contact_sensor/base_contact_sensor_data.py index 38702be28470..2f5f69ef55db 100644 --- a/source/isaaclab/isaaclab/sensors/contact_sensor/base_contact_sensor_data.py +++ b/source/isaaclab/isaaclab/sensors/contact_sensor/base_contact_sensor_data.py @@ -9,6 +9,13 @@ from abc import ABC, abstractmethod +from isaaclab.utils.leapp import ( + POSE7_ELEMENT_NAMES, + QUAT_XYZW_ELEMENT_NAMES, + XYZ_ELEMENT_NAMES, + InputKindEnum, + leapp_tensor_semantics, +) from isaaclab.utils.warp import ProxyArray @@ -21,6 +28,7 @@ class BaseContactSensorData(ABC): @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSE, element_names=POSE7_ELEMENT_NAMES) def pose_w(self) -> ProxyArray | None: """Pose of the sensor origin in world frame. @@ -30,6 +38,7 @@ def pose_w(self) -> ProxyArray | None: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSITION, element_names=XYZ_ELEMENT_NAMES) def pos_w(self) -> ProxyArray | None: """Position of the sensor origin in world frame. @@ -42,6 +51,7 @@ def pos_w(self) -> ProxyArray | None: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ROTATION, element_names=QUAT_XYZW_ELEMENT_NAMES) def quat_w(self) -> ProxyArray | None: """Orientation of the sensor origin in world frame. @@ -54,6 +64,7 @@ def quat_w(self) -> ProxyArray | None: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.VECTOR3D, element_names=XYZ_ELEMENT_NAMES) def net_forces_w(self) -> ProxyArray | None: """The net normal contact forces in world frame. @@ -64,6 +75,7 @@ def net_forces_w(self) -> ProxyArray | None: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.VECTOR3D, element_names=XYZ_ELEMENT_NAMES) def net_forces_w_history(self) -> ProxyArray | None: """History of net normal contact forces. @@ -74,6 +86,7 @@ def net_forces_w_history(self) -> ProxyArray | None: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.VECTOR3D, element_names=XYZ_ELEMENT_NAMES) def force_matrix_w(self) -> ProxyArray | None: """Normal contact forces filtered between sensor and filtered bodies. @@ -86,6 +99,7 @@ def force_matrix_w(self) -> ProxyArray | None: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.VECTOR3D, element_names=XYZ_ELEMENT_NAMES) def force_matrix_w_history(self) -> ProxyArray | None: """History of filtered contact forces. @@ -98,6 +112,7 @@ def force_matrix_w_history(self) -> ProxyArray | None: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSITION, element_names=XYZ_ELEMENT_NAMES) def contact_pos_w(self) -> ProxyArray | None: """Average position of contact points. @@ -110,6 +125,7 @@ def contact_pos_w(self) -> ProxyArray | None: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.VECTOR3D, element_names=XYZ_ELEMENT_NAMES) def friction_forces_w(self) -> ProxyArray | None: """Sum of friction forces. @@ -122,6 +138,7 @@ def friction_forces_w(self) -> ProxyArray | None: @property @abstractmethod + @leapp_tensor_semantics() def last_air_time(self) -> ProxyArray | None: """Time spent in air before last contact. @@ -133,6 +150,7 @@ def last_air_time(self) -> ProxyArray | None: @property @abstractmethod + @leapp_tensor_semantics() def current_air_time(self) -> ProxyArray | None: """Time spent in air since last detach. @@ -144,6 +162,7 @@ def current_air_time(self) -> ProxyArray | None: @property @abstractmethod + @leapp_tensor_semantics() def last_contact_time(self) -> ProxyArray | None: """Time spent in contact before last detach. @@ -155,6 +174,7 @@ def last_contact_time(self) -> ProxyArray | None: @property @abstractmethod + @leapp_tensor_semantics() def current_contact_time(self) -> ProxyArray | None: """Time spent in contact since last contact. diff --git a/source/isaaclab/isaaclab/sensors/frame_transformer/base_frame_transformer_data.py b/source/isaaclab/isaaclab/sensors/frame_transformer/base_frame_transformer_data.py index 85386abfc37b..286af6e84ea3 100644 --- a/source/isaaclab/isaaclab/sensors/frame_transformer/base_frame_transformer_data.py +++ b/source/isaaclab/isaaclab/sensors/frame_transformer/base_frame_transformer_data.py @@ -9,6 +9,16 @@ from abc import ABC, abstractmethod +from isaaclab.utils.leapp import ( + POSE7_ELEMENT_NAMES, + QUAT_XYZW_ELEMENT_NAMES, + XYZ_ELEMENT_NAMES, + InputKindEnum, + leapp_tensor_semantics, + target_frame_pose_resolver, + target_frame_quat_resolver, + target_frame_xyz_resolver, +) from isaaclab.utils.warp import ProxyArray @@ -30,6 +40,7 @@ def target_frame_names(self) -> list[str]: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSE, element_names_resolver=target_frame_pose_resolver) def target_pose_source(self) -> ProxyArray | None: """Pose of the target frame(s) relative to source frame. @@ -40,6 +51,7 @@ def target_pose_source(self) -> ProxyArray | None: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSITION, element_names_resolver=target_frame_xyz_resolver) def target_pos_source(self) -> ProxyArray: """Position of the target frame(s) relative to source frame. @@ -50,6 +62,7 @@ def target_pos_source(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ROTATION, element_names_resolver=target_frame_quat_resolver) def target_quat_source(self) -> ProxyArray: """Orientation of the target frame(s) relative to source frame. @@ -60,6 +73,7 @@ def target_quat_source(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSE, element_names_resolver=target_frame_pose_resolver) def target_pose_w(self) -> ProxyArray | None: """Pose of the target frame(s) after offset in world frame. @@ -70,6 +84,7 @@ def target_pose_w(self) -> ProxyArray | None: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSITION, element_names_resolver=target_frame_xyz_resolver) def target_pos_w(self) -> ProxyArray: """Position of the target frame(s) after offset in world frame. @@ -80,6 +95,7 @@ def target_pos_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ROTATION, element_names_resolver=target_frame_quat_resolver) def target_quat_w(self) -> ProxyArray: """Orientation of the target frame(s) after offset in world frame. @@ -90,6 +106,7 @@ def target_quat_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSE, element_names=POSE7_ELEMENT_NAMES) def source_pose_w(self) -> ProxyArray | None: """Pose of the source frame after offset in world frame. @@ -100,6 +117,7 @@ def source_pose_w(self) -> ProxyArray | None: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSITION, element_names=XYZ_ELEMENT_NAMES) def source_pos_w(self) -> ProxyArray: """Position of the source frame after offset in world frame. @@ -109,6 +127,7 @@ def source_pos_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ROTATION, element_names=QUAT_XYZW_ELEMENT_NAMES) def source_quat_w(self) -> ProxyArray: """Orientation of the source frame after offset in world frame. diff --git a/source/isaaclab/isaaclab/sensors/imu/base_imu_data.py b/source/isaaclab/isaaclab/sensors/imu/base_imu_data.py index bf8bf7bf2fc6..039d5dd60f64 100644 --- a/source/isaaclab/isaaclab/sensors/imu/base_imu_data.py +++ b/source/isaaclab/isaaclab/sensors/imu/base_imu_data.py @@ -9,6 +9,11 @@ from abc import ABC, abstractmethod +from isaaclab.utils.leapp import ( + XYZ_ELEMENT_NAMES, + InputKindEnum, + leapp_tensor_semantics, +) from isaaclab.utils.warp import ProxyArray @@ -24,6 +29,7 @@ class BaseImuData(ABC): @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ANGULAR_VELOCITY, element_names=XYZ_ELEMENT_NAMES) def ang_vel_b(self) -> ProxyArray: """IMU frame angular velocity relative to the world expressed in IMU frame [rad/s]. @@ -33,6 +39,7 @@ def ang_vel_b(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_LINEAR_ACCELERATION, element_names=XYZ_ELEMENT_NAMES) def lin_acc_b(self) -> ProxyArray: """Linear acceleration (proper) in the IMU frame [m/s^2]. diff --git a/source/isaaclab/isaaclab/sensors/pva/base_pva_data.py b/source/isaaclab/isaaclab/sensors/pva/base_pva_data.py index 41dde4b11065..07fcab62b0b6 100644 --- a/source/isaaclab/isaaclab/sensors/pva/base_pva_data.py +++ b/source/isaaclab/isaaclab/sensors/pva/base_pva_data.py @@ -9,6 +9,13 @@ from abc import ABC, abstractmethod +from isaaclab.utils.leapp import ( + POSE7_ELEMENT_NAMES, + QUAT_XYZW_ELEMENT_NAMES, + XYZ_ELEMENT_NAMES, + InputKindEnum, + leapp_tensor_semantics, +) from isaaclab.utils.warp import ProxyArray @@ -21,6 +28,7 @@ class BasePvaData(ABC): @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSE, element_names=POSE7_ELEMENT_NAMES) def pose_w(self) -> ProxyArray | None: """Pose of the sensor origin in world frame [m, unitless]. @@ -31,6 +39,7 @@ def pose_w(self) -> ProxyArray | None: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_POSITION, element_names=XYZ_ELEMENT_NAMES) def pos_w(self) -> ProxyArray: """Position of the sensor origin in world frame [m]. @@ -40,6 +49,7 @@ def pos_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ROTATION, element_names=QUAT_XYZW_ELEMENT_NAMES) def quat_w(self) -> ProxyArray: """Orientation of the sensor origin in world frame. @@ -50,6 +60,7 @@ def quat_w(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.VECTOR3D, element_names=XYZ_ELEMENT_NAMES) def projected_gravity_b(self) -> ProxyArray: """Gravity direction unit vector projected on the PVA frame. @@ -59,6 +70,7 @@ def projected_gravity_b(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_LINEAR_VELOCITY, element_names=XYZ_ELEMENT_NAMES) def lin_vel_b(self) -> ProxyArray: """PVA frame linear velocity relative to the world expressed in PVA frame [m/s]. @@ -68,6 +80,7 @@ def lin_vel_b(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ANGULAR_VELOCITY, element_names=XYZ_ELEMENT_NAMES) def ang_vel_b(self) -> ProxyArray: """PVA frame angular velocity relative to the world expressed in PVA frame [rad/s]. @@ -77,6 +90,7 @@ def ang_vel_b(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_LINEAR_ACCELERATION, element_names=XYZ_ELEMENT_NAMES) def lin_acc_b(self) -> ProxyArray: """Linear acceleration (coordinate) in the PVA frame [m/s^2]. @@ -88,6 +102,7 @@ def lin_acc_b(self) -> ProxyArray: @property @abstractmethod + @leapp_tensor_semantics(kind=InputKindEnum.BODY_ANGULAR_ACCELERATION, element_names=XYZ_ELEMENT_NAMES) def ang_acc_b(self) -> ProxyArray: """PVA frame angular acceleration relative to the world expressed in PVA frame [rad/s^2]. diff --git a/source/isaaclab/isaaclab/sensors/ray_caster/ray_caster_data.py b/source/isaaclab/isaaclab/sensors/ray_caster/ray_caster_data.py index 6ccd1f5729f1..1265e1df0fc3 100644 --- a/source/isaaclab/isaaclab/sensors/ray_caster/ray_caster_data.py +++ b/source/isaaclab/isaaclab/sensors/ray_caster/ray_caster_data.py @@ -7,6 +7,11 @@ import warp as wp +from isaaclab.utils.leapp import ( + QUAT_XYZW_ELEMENT_NAMES, + XYZ_ELEMENT_NAMES, + leapp_tensor_semantics, +) from isaaclab.utils.warp import ProxyArray @@ -27,6 +32,7 @@ def __init__(self): # Accessing the public properties before create_buffers() raises AttributeError. @property + @leapp_tensor_semantics(kind="state/sensor/position", element_names=XYZ_ELEMENT_NAMES) def pos_w(self) -> ProxyArray: """Position of the sensor origin in world frame [m]. @@ -37,6 +43,7 @@ def pos_w(self) -> ProxyArray: return self._pos_w_ta @property + @leapp_tensor_semantics(kind="state/sensor/rotation", element_names=QUAT_XYZW_ELEMENT_NAMES) def quat_w(self) -> ProxyArray: """Orientation of the sensor origin in quaternion (x, y, z, w) in world frame. @@ -47,6 +54,7 @@ def quat_w(self) -> ProxyArray: return self._quat_w_ta @property + @leapp_tensor_semantics(kind="state/sensor/ray_hit_position") def ray_hits_w(self) -> ProxyArray: """The ray hit positions in the world frame [m]. diff --git a/source/isaaclab/isaaclab/utils/buffers/circular_buffer.py b/source/isaaclab/isaaclab/utils/buffers/circular_buffer.py index c5c9fe9ff6ad..c72907b176b3 100644 --- a/source/isaaclab/isaaclab/utils/buffers/circular_buffer.py +++ b/source/isaaclab/isaaclab/utils/buffers/circular_buffer.py @@ -11,10 +11,11 @@ class CircularBuffer: """Circular buffer for storing a history of batched tensor data. - This class implements a circular buffer for storing a history of batched tensor data. The buffer is - initialized with a maximum length and a batch size. The data is stored in a circular fashion, and the - data can be retrieved in a LIFO (Last-In-First-Out) fashion. The buffer is designed to be used in - multi-environment settings, where each environment has its own data. + This class stores a history of batched tensor data with the oldest entry at + index 0 and the most recent entry at index ``max_len - 1`` of the internal + buffer. The public indexing API remains LIFO (last-in-first-out), while the + ordered internal layout keeps ``buffer`` retrieval cheap and makes the + implementation compatible with tracing-based export flows. The shape of the appended data is expected to be (batch_size, ...), where the first dimension is the batch dimension. Correspondingly, the shape of the ring buffer is (max_len, batch_size, ...). @@ -42,8 +43,6 @@ def __init__(self, max_len: int, batch_size: int, device: str): self._max_len = torch.full((batch_size,), max_len, dtype=torch.int, device=device) # number of data pushes passed since the last call to :meth:`reset` self._num_pushes = torch.zeros(batch_size, dtype=torch.long, device=device) - # the pointer to the current head of the circular buffer (-1 means not initialized) - self._pointer: int = -1 # the actual buffer for data storage # note: this is initialized on the first call to :meth:`append` self._buffer: torch.Tensor = None # type: ignore @@ -80,14 +79,11 @@ def current_length(self) -> torch.Tensor: def buffer(self) -> torch.Tensor: """Complete circular buffer with most recent entry at the end and oldest entry at the beginning. - The shape of the buffer is (batch_size, max_length, ...). - - Note: - The oldest entry is at the beginning of dimension 1. + Returns: + Complete circular buffer with most recent entry at the end and oldest entry at the beginning of + dimension 1. The shape is [batch_size, max_length, data.shape[1:]]. """ - buf = self._buffer.clone() - buf = torch.roll(buf, shifts=self.max_length - self._pointer - 1, dims=0) - return torch.transpose(buf, dim0=0, dim1=1) + return torch.transpose(self._buffer, dim0=0, dim1=1) """ Operations. @@ -99,15 +95,17 @@ def reset(self, batch_ids: Sequence[int] | None = None): Args: batch_ids: Elements to reset in the batch dimension. Default is None, which resets all the batch indices. """ - # resolve all indices + batch_ids_resolved: Sequence[int] | slice if batch_ids is None: - batch_ids = slice(None) + batch_ids_resolved = slice(None) + else: + batch_ids_resolved = batch_ids # reset the number of pushes for the specified batch indices - self._num_pushes[batch_ids] = 0 + self._num_pushes[batch_ids_resolved] = 0 if self._buffer is not None: - # set buffer at batch_id reset indices to 0.0 so that the buffer() - # getter returns the cleared circular buffer after reset. - self._buffer[:, batch_ids, :] = 0.0 + # set buffer at batch_id reset indices to 0.0 so that the buffer() getter returns + # the cleared circular buffer after reset. + self._buffer[:, batch_ids_resolved] = 0.0 def append(self, data: torch.Tensor): """Append the data to the circular buffer. @@ -125,21 +123,19 @@ def append(self, data: torch.Tensor): # move the data to the device data = data.to(self._device) - # at the first call, initialize the buffer size - if self._buffer is None: - self._pointer = -1 - self._buffer = torch.empty((self.max_length, *data.shape), dtype=data.dtype, device=self._device) - # move the head to the next slot - self._pointer = (self._pointer + 1) % self.max_length - # add the new data to the last layer - self._buffer[self._pointer] = data - # Check for batches with zero pushes and initialize all values in batch to first append is_first_push = self._num_pushes == 0 + if self._buffer is None: + self._buffer = data.unsqueeze(0).expand(self.max_length, *data.shape).clone() if torch.any(is_first_push): self._buffer[:, is_first_push] = data[is_first_push] # increment number of number of pushes for all batches + self._append(data) self._num_pushes += 1 + def _append(self, data: torch.Tensor): + self._buffer = torch.roll(self._buffer, shifts=-1, dims=0) + self._buffer[-1] = data + def __getitem__(self, key: torch.Tensor) -> torch.Tensor: """Retrieve the data from the circular buffer in last-in-first-out (LIFO) fashion. @@ -160,13 +156,14 @@ def __getitem__(self, key: torch.Tensor) -> torch.Tensor: # check the batch size if len(key) != self.batch_size: raise ValueError(f"The argument 'key' has length {key.shape[0]}, while expecting {self.batch_size}") - # check if the buffer is empty - if torch.any(self._num_pushes == 0) or self._buffer is None: - raise RuntimeError("Attempting to retrieve data on an empty circular buffer. Please append data first.") - - # admissible lag - valid_keys = torch.minimum(key, self._num_pushes - 1) - # the index in the circular buffer (pointer points to the last+1 index) - index_in_buffer = torch.remainder(self._pointer - valid_keys, self.max_length) + if self._buffer is None: + raise RuntimeError("The buffer is empty. Please append data before retrieving.") + + # admissible lag — clamp to [0, ..] so batches with _num_pushes == 0 + # return the zeroed-out slot instead of indexing out of bounds. + valid_keys = torch.clamp(torch.minimum(key, self._num_pushes - 1), min=0) + # The buffer is stored oldest->newest along dimension 0, so the most + # recent item lives at the last index. + index_in_buffer = (self.max_length - 1 - valid_keys).to(dtype=torch.long) # return output return self._buffer[index_in_buffer, self._ALL_INDICES] diff --git a/source/isaaclab/isaaclab/utils/leapp/__init__.py b/source/isaaclab/isaaclab/utils/leapp/__init__.py new file mode 100644 index 000000000000..f39b0e4d7eea --- /dev/null +++ b/source/isaaclab/isaaclab/utils/leapp/__init__.py @@ -0,0 +1,10 @@ +# 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 + +"""Sub-module for LEAPP export annotation and proxy-based policy tracing.""" + +from isaaclab.utils.module import lazy_export + +lazy_export() diff --git a/source/isaaclab/isaaclab/utils/leapp/__init__.pyi b/source/isaaclab/isaaclab/utils/leapp/__init__.pyi new file mode 100644 index 000000000000..e2b8f497b5f3 --- /dev/null +++ b/source/isaaclab/isaaclab/utils/leapp/__init__.pyi @@ -0,0 +1,61 @@ +# 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 + +__all__ = [ + "ExportPatcher", + "InputKindEnum", + "LeappTensorSemantics", + "OutputKindEnum", + "POSE6_ELEMENT_NAMES", + "POSE7_ELEMENT_NAMES", + "QUAT_XYZW_ELEMENT_NAMES", + "WRENCH6_ELEMENT_NAMES", + "XYZ_ELEMENT_NAMES", + "body_names_resolver", + "body_pose6_resolver", + "body_pose_resolver", + "body_quat_resolver", + "body_wrench_resolver", + "body_xyz_resolver", + "build_command_connection", + "build_state_connection", + "build_write_connection", + "joint_names_resolver", + "leapp_tensor_semantics", + "patch_env_for_export", + "resolve_leapp_element_names", + "target_frame_pose_resolver", + "target_frame_quat_resolver", + "target_frame_xyz_resolver", +] + +from .export_annotator import ExportPatcher, patch_env_for_export +from .leapp_semantics import ( + InputKindEnum, + OutputKindEnum, + POSE6_ELEMENT_NAMES, + POSE7_ELEMENT_NAMES, + QUAT_XYZW_ELEMENT_NAMES, + WRENCH6_ELEMENT_NAMES, + XYZ_ELEMENT_NAMES, + LeappTensorSemantics, + body_names_resolver, + body_pose6_resolver, + body_pose_resolver, + body_quat_resolver, + body_wrench_resolver, + body_xyz_resolver, + joint_names_resolver, + leapp_tensor_semantics, + resolve_leapp_element_names, + target_frame_pose_resolver, + target_frame_quat_resolver, + target_frame_xyz_resolver, +) +from .utils import ( + build_command_connection, + build_state_connection, + build_write_connection, +) diff --git a/source/isaaclab/isaaclab/utils/leapp/export_annotator.py b/source/isaaclab/isaaclab/utils/leapp/export_annotator.py new file mode 100644 index 000000000000..fb335811535a --- /dev/null +++ b/source/isaaclab/isaaclab/utils/leapp/export_annotator.py @@ -0,0 +1,708 @@ +# 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 + +"""Export annotations for Isaac Lab policies using proxy-based patching. + +Observation and action annotation share a unified dedup cache so that a +state property (e.g. ``joint_pos``) read by both an observation term and +an action term resolves to one LEAPP input edge. + +- Observation term functions see an ``_EnvProxy`` whose scene returns + ``_EntityProxy`` objects with annotating data proxies. + +- Action terms have their ``_asset`` attribute replaced with an + _ArticulationWriteProxy that intercepts ``_leapp_semantics``-decorated + write methods **and** routes ``.data`` reads through the same annotating + data proxy used by observations. + +Cache lifecycle (assuming single-env play-mode export): + + compute() clear cache → obs terms populate cache + policy inference TracedTensors propagate through NN + process_action() register_buffer for raw_actions + apply_action() [tracing] reuse cached TracedTensors for state reads, + capture write outputs, call output_tensors(), + then clear cache + apply_action() [decim.] clear cache → fresh reads for simulation + ... + compute() clear cache → fresh reads for next obs +""" + +from __future__ import annotations + +import inspect +import logging +from collections.abc import Callable +from contextlib import suppress +from typing import TYPE_CHECKING, Any + +import torch +from leapp import annotate +from leapp.utils.tensor_description import TensorSemantics + +from isaaclab.assets.articulation.base_articulation import BaseArticulation +from isaaclab.managers import ManagerTermBase + +from .leapp_semantics import select_element_names +from .proxy import _ArticulationWriteProxy, _DataProxy, _EnvProxy, _ManagerTermProxy +from .utils import ( + TracedProxyArray, + build_command_connection, + build_write_connection, +) + +if TYPE_CHECKING: + from isaaclab.envs import ManagerBasedEnv + + +VARIABLE_IMPEDANCE_MODES = frozenset({"variable", "variable_kp"}) + + +# ══════════════════════════════════════════════════════════════════ +# ExportPatcher +# ══════════════════════════════════════════════════════════════════ + + +class ExportPatcher: + """Unified patcher that annotates observation inputs and action outputs for LEAPP export. + + Observation-side property semantics are resolved lazily inside + ``_DataProxy`` by combining: + + - the concrete runtime getter from the backend data class + - the nearest ``_leapp_semantics`` metadata found while walking the MRO + + This lets backends override property implementations without duplicating + decorators from the abstract API. + + - The observation proxy chain (``_EnvProxy`` → ``_SceneProxy`` → + ``_EntityProxy`` → ``_DataProxy``) for state reads + by observation term functions. + - The ``_ArticulationWriteProxy`` on each action term, which intercepts + target writes **and** routes ``.data`` reads through the same + ``_DataProxy`` / cache. + + """ + + def __init__(self, export_method: str, required_obs_groups: set[str] | None = None): + """Initialize the export patcher. + + Args: + export_method: LEAPP export backend passed to + :func:`annotate.output_tensors`. + required_obs_groups: Observation groups that should be patched, or + ``None`` to patch all groups. + """ + self.task_name: str | None = None + self.export_method = export_method + self.required_obs_groups = required_obs_groups + self._annotated_tensor_cache: dict[tuple[int, str], TracedProxyArray] = {} + self._data_property_resolution_cache: dict[tuple[type, str], tuple[Callable, object] | None] = {} + self._write_method_resolution_cache: dict[ + tuple[type, str], tuple[Callable, object, inspect.Signature] | None + ] = {} + self._action_output_cache: list[TensorSemantics] = [] + self._captured_write_term_names: set[str] = set() + self._fallback_term_names: set[str] = set() + self._pending_action_output_export: bool = False + self._uses_last_action_state: bool = False + self._action_term_scene_keys: dict[str, str] = {} + + def setup(self, env): + """Patch the environment in place for LEAPP-aware export. + + Args: + env: Wrapped manager-based environment whose unwrapped instance + should be patched. + """ + unwrapped = env.env.unwrapped + task_name = str(unwrapped.spec.id) + self.task_name = task_name + + proxy_env = _EnvProxy( + unwrapped, + task_name, + self._data_property_resolution_cache, + self._annotated_tensor_cache, + ) + + self._disable_training_managers(unwrapped) + self._patch_observation_manager(unwrapped.observation_manager, proxy_env) + self._patch_history_buffers(unwrapped.observation_manager) + self._patch_action_manager( + unwrapped.action_manager, + self._annotated_tensor_cache, + ) + + # ── Disable training-only managers ───────────────────────────── + + @staticmethod + def _disable_training_managers(unwrapped): + """Replace training-only manager methods with no-ops. + + During export the curriculum, reward, termination, and recorder + managers serve no purpose. Disabling them avoids side-effect + crashes (e.g. ADR curriculum terms accessing nullified noise + configs) and removes unnecessary computation. + + Args: + unwrapped: Unwrapped environment whose training-only managers + should be disabled. + """ + num_envs = unwrapped.num_envs + device = unwrapped.device + _zero_reward = torch.zeros(num_envs, device=device) + _no_termination = torch.zeros(num_envs, dtype=torch.bool, device=device) + + def _noop_curriculum(env_ids=None): + return None + + def _zero_reward_compute(dt): + return _zero_reward + + def _no_termination_compute(): + return _no_termination + + def _noop(*args, **kwargs): + return None + + if hasattr(unwrapped, "curriculum_manager"): + unwrapped.curriculum_manager.compute = _noop_curriculum + + if hasattr(unwrapped, "reward_manager"): + unwrapped.reward_manager.compute = _zero_reward_compute + + if hasattr(unwrapped, "termination_manager"): + unwrapped.termination_manager.compute = _no_termination_compute + + if hasattr(unwrapped, "recorder_manager"): + rm = unwrapped.recorder_manager + + rm.record_pre_step = _noop + rm.record_post_step = _noop + rm.record_pre_reset = _noop + rm.record_post_reset = _noop + rm.record_post_physics_decimation_step = _noop + + @staticmethod + def _resolve_scene_entity_key(scene, entity: Any) -> str | None: + """Return the scene dictionary key for an entity. + + Args: + scene: Scene object that stores entity dictionaries. + entity: Entity instance to locate. + + Returns: + The scene key for ``entity`` if found, otherwise ``None``. + """ + for attr_value in vars(scene).values(): + if not isinstance(attr_value, dict): + continue + for key, candidate in attr_value.items(): + if candidate is entity: + return key + return None + + # ── Observation manager patches ─────────────────────────────── + + def _patch_history_buffers(self, obs_manager): + """Patch history-enabled observation buffers to export as LEAPP state. + + Args: + obs_manager: Observation manager whose history buffers should be + wrapped. + """ + history_buffers = getattr(obs_manager, "_group_obs_term_history_buffer", {}) + term_names_by_group = getattr(obs_manager, "_group_obs_term_names", {}) + + for group_name, term_cfgs in obs_manager._group_obs_term_cfgs.items(): + if self.required_obs_groups is not None and group_name not in self.required_obs_groups: + continue + group_buffers = history_buffers.get(group_name, {}) + group_term_names = term_names_by_group.get(group_name, []) + + for index, term_cfg in enumerate(term_cfgs): + history_length = getattr(term_cfg, "history_length", 0) or 0 + if history_length <= 0: + continue + + if index >= len(group_term_names): + continue + + term_name = group_term_names[index] + circular_buffer = group_buffers.get(term_name) + if circular_buffer is None: + continue + + state_name = f"h_{group_name}_{term_name}" + self._patch_history_buffer_append(circular_buffer, state_name) + + def _patch_history_buffer_append(self, circular_buffer, state_name: str): + """Wrap ``_append`` so history buffers become explicit LEAPP state. + + Args: + circular_buffer: Circular buffer instance to patch. + state_name: LEAPP state tensor name for the buffer contents. + """ + if hasattr(circular_buffer, "_leapp_original_append"): + return + + task_name = self.task_name + original_append = circular_buffer._append + + def patched_append(data: torch.Tensor): + """Annotate history buffer updates as LEAPP state transitions. + + Args: + data: New observation slice appended to the buffer. + + Returns: + ``None``. + """ + if circular_buffer._buffer is not None: + circular_buffer._buffer = annotate.state_tensors(task_name, {state_name: circular_buffer._buffer}) + + original_append(data) + + if circular_buffer._buffer is not None: + circular_buffer._buffer = annotate.update_state(task_name, {state_name: circular_buffer._buffer}) + + circular_buffer._leapp_original_append = original_append + circular_buffer._append = patched_append + + def _patch_observation_manager(self, obs_manager, proxy_env): + """Patch observation terms to use annotating proxies and disable noise. + + Args: + obs_manager: Observation manager instance to patch. + proxy_env: Proxy environment routed into observation terms. + """ + for group_name, term_cfgs in obs_manager._group_obs_term_cfgs.items(): + if self.required_obs_groups is not None and group_name not in self.required_obs_groups: + continue + for term_cfg in term_cfgs: + original_func = term_cfg.func + func_name = getattr(original_func, "__name__", None) + + if func_name == "last_action": + self._uses_last_action_state = True + term_cfg.func = self._wrap_last_action(original_func) + elif func_name == "generated_commands": + term_cfg.func = self._wrap_generated_commands(original_func, term_cfg) + else: + term_cfg.func = self._wrap_with_proxy(original_func, proxy_env) + + term_cfg.noise = None + + original_compute = obs_manager.compute + cache = self._annotated_tensor_cache + + def patched_compute(*args, **kwargs): + """Clear the tensor dedup cache once per full observation pass.""" + cache.clear() + return original_compute(*args, **kwargs) + + obs_manager.compute = patched_compute + + # ── Action manager patches ──────────────────────────────────── + + def _patch_action_manager(self, action_manager, cache): + """Patch action terms with write/read proxies and manager hooks. + + Args: + action_manager: Action manager instance to patch. + cache: Shared tensor dedup cache for annotated state reads. + """ + assert self.task_name is not None + scene = action_manager._env.scene + for term_name, term in action_manager._terms.items(): + asset = getattr(term, "_asset", None) + if isinstance(asset, BaseArticulation): + real_asset: BaseArticulation = asset + scene_key = self._resolve_scene_entity_key(scene, real_asset) or "ego" + data_proxy = _DataProxy( + real_asset.data, + scene_key, + self.task_name, + self._data_property_resolution_cache, + cache, + input_name_resolver=lambda prop_name, k=scene_key: f"{k}_{prop_name}", + ) + term._asset = _ArticulationWriteProxy( + real_asset=real_asset, + entity_name=scene_key, + term_name=term_name, + output_cache=self._action_output_cache, + method_resolution_cache=self._write_method_resolution_cache, + captured_write_term_names=self._captured_write_term_names, + data_proxy=data_proxy, + ) + self._action_term_scene_keys[term_name] = scene_key + + self._patch_action_manager_methods(action_manager) + + def _patch_action_manager_methods(self, action_manager): + """Patch ``process_action`` and ``apply_action`` on the action manager instance. + + ``process_action`` registers raw_action buffers for LEAPP tracing and + preserves the action tensor clone. + + ``apply_action`` coordinates the cache and output lifecycle: + + - **Tracing pass** (first ``apply_action`` after ``process_action``): + The cache still holds TracedTensors populated by ``compute_group``. + Action terms that read state (e.g. ``RelativeJointPositionAction`` + reading ``joint_pos``) get those TracedTensors from the cache, + keeping the LEAPP graph connected. After ``output_tensors()`` the + cache is cleared so subsequent decimation sub-steps read fresh values. + + - **Non-tracing passes** (remaining decimation sub-steps and all + subsequent iterations): The cache is cleared **before** running + action terms so every ``.data`` read returns the current simulator + value, preserving simulation correctness. + + Args: + action_manager: Action manager whose instance methods should be + wrapped. + """ + original_process = action_manager.process_action + original_apply = action_manager.apply_action + task_name = self.task_name + cache = self._annotated_tensor_cache + + def patched_process_action(action: torch.Tensor): + """Register raw_action buffers, call real process_action, preserve action clone.""" + original_process(action) + action_manager._action = action.clone() + self._pending_action_output_export = True + + def patched_apply_action(): + """Coordinate cache lifecycle and LEAPP output annotation.""" + if not self._pending_action_output_export: + cache.clear() + return original_apply() + + # Tracing pass: cache still holds TracedTensors from compute_group. + self._action_output_cache.clear() + self._captured_write_term_names.clear() + original_apply() + + self._action_output_cache.extend(self._collect_action_outputs(action_manager)) + self._action_output_cache.extend(self._collect_processed_action_fallbacks(action_manager)) + if self._uses_last_action_state: + annotate.update_state(task_name, {"last_action": action_manager._action}) + fallback_terms = self._fallback_term_names + static_values = self._collect_action_static_outputs(action_manager, fallback_terms) + annotate.output_tensors( + task_name, + self._action_output_cache, + static_outputs=static_values or None, + export_with=self.export_method, + ) + self._pending_action_output_export = False + self._action_output_cache.clear() + cache.clear() + return None + + action_manager.process_action = patched_process_action + action_manager.apply_action = patched_apply_action + + # ── Observation term wrappers ───────────────────────────────── + + @staticmethod + def _wrap_with_proxy(original_func, proxy_env): + """Wrap a term function so it receives the proxy env. + + Args: + original_func: Original observation term function or manager term. + proxy_env: Proxy environment routed into the wrapped callable. + + Returns: + Wrapped callable that substitutes ``proxy_env`` for the real env. + """ + + if isinstance(original_func, ManagerTermBase): + return _ManagerTermProxy(original_func, proxy_env) + + def wrapped(*args, **kwargs): + """Invoke the original function with the proxy environment. + + Args: + *args: Original positional arguments. + **kwargs: Original keyword arguments. + + Returns: + Result of the wrapped observation term. + """ + if args: + args = (proxy_env, *args[1:]) + else: + args = (proxy_env,) + return original_func(*args, **kwargs) + + wrapped.__name__ = getattr(original_func, "__name__", "unknown") + return wrapped + + def _wrap_last_action(self, original_func): + """Wrap ``last_action`` as a LEAPP state tensor. + + ``last_action`` is feedback state, not a regular dangling input. We + therefore register it through ``annotate.state_tensors(...)`` on the + observation side and update it through ``annotate.update_state(...)`` + after the traced action pass. + + Args: + original_func: Original ``last_action`` observation term. + + Returns: + Wrapped callable that exports ``last_action`` as LEAPP state. + """ + task_name = self.task_name + + def wrapped(env, action_name=None, **kwargs): + """Run the wrapped ``last_action`` term and annotate its output. + + Args: + env: Environment passed by the observation manager. + action_name: Optional action term name. + **kwargs: Additional keyword arguments for the term. + + Returns: + Annotated last-action tensor. + """ + result = original_func(env, action_name, **kwargs) + return annotate.state_tensors(task_name, {"last_action": result}) + + wrapped.__name__ = original_func.__name__ + return wrapped + + def _wrap_generated_commands(self, original_func, term_cfg): + """Wrap the ``generated_commands`` observation term to annotate its output as a LEAPP input. + + Resolves command semantics (kind, element_names) from the command manager + configuration when available. + + Args: + original_func: Original ``generated_commands`` observation term. + term_cfg: Observation term config used to resolve the command name. + + Returns: + Wrapped callable that exports generated commands as LEAPP inputs. + """ + task_name = self.task_name + command_name_from_cfg = term_cfg.params.get("command_name") + + def wrapped(env, command_name=None, **kwargs): + """Run the wrapped command term and annotate its output. + + Args: + env: Environment passed by the observation manager. + command_name: Optional command term name override. + **kwargs: Additional keyword arguments for the term. + + Returns: + Annotated command tensor. + """ + result = original_func(env, command_name, **kwargs) + leapp_input_name = command_name or command_name_from_cfg or "commands" + command_cfg = None + with suppress(AttributeError, KeyError): + command_cfg = env.command_manager.get_term(leapp_input_name).cfg + sem = TensorSemantics( + name=leapp_input_name, + ref=result, + kind=getattr(command_cfg, "cmd_kind", None), + element_names=getattr(command_cfg, "element_names", None), + extra=build_command_connection(leapp_input_name), + ) + return annotate.input_tensors(task_name, sem) + + wrapped.__name__ = original_func.__name__ + return wrapped + + # ── Output collection ───────────────────────────────────────── + + def _collect_action_outputs(self, action_manager) -> list[TensorSemantics]: + """Collect non-writer action tensors that should be exported. + + Args: + action_manager: Action manager whose terms should be inspected. + + Returns: + Exportable tensor semantics for dynamic action outputs such as OSC + gains. + """ + tensors: list[TensorSemantics] = [] + for term_name, term in action_manager._terms.items(): + osc = getattr(term, "_osc", None) + if osc and hasattr(osc, "cfg") and osc.cfg.impedance_mode in VARIABLE_IMPEDANCE_MODES: + asset = getattr(term, "_asset", None) + real_asset = getattr(asset, "_real_asset", asset) + joint_ids = getattr(term, "_joint_ids", None) + joint_names = getattr(real_asset, "joint_names", None) if real_asset else None + scene_key = self._action_term_scene_keys.get(term_name, "ego") + tensors.append( + TensorSemantics( + name=f"{term_name}_kp_gains", + ref=torch.diagonal(osc._motion_p_gains_task, dim1=-2, dim2=-1), + kind="kp", + element_names=select_element_names(joint_names, joint_ids), + extra=build_write_connection(scene_key, "write_joint_stiffness_to_sim_index"), + ) + ) + tensors.append( + TensorSemantics( + name=f"{term_name}_kd_gains", + ref=torch.diagonal(osc._motion_d_gains_task, dim1=-2, dim2=-1), + kind="kd", + element_names=select_element_names(joint_names, joint_ids), + extra=build_write_connection(scene_key, "write_joint_damping_to_sim_index"), + ) + ) + return tensors + + def _collect_processed_action_fallbacks(self, action_manager) -> list[TensorSemantics]: + """Fallback: use ``term.processed_actions`` for terms that produced no write outputs. + + When an action term does not call any ``_leapp_semantics``-decorated write method + (e.g. ``PreTrainedPolicyAction`` which delegates writes to a nested sub-policy), + we fall back to capturing ``term.processed_actions`` as the output tensor. + + Args: + action_manager: Action manager whose terms should be inspected. + + Returns: + Fallback tensor semantics built from ``processed_actions``. + """ + logger = logging.getLogger(__name__) + fallback_terms: set[str] = set() + tensors: list[TensorSemantics] = [] + for term_name, term in action_manager._terms.items(): + if term_name in self._captured_write_term_names: + continue + processed = getattr(term, "processed_actions", None) + if processed is None: + continue + if isinstance(processed, torch.Tensor): + logger.warning( + "Action term '%s' did not write to any asset directly. Falling back to processed_actions as the" + " export output.\nIf you wish to add semantic data to this policy, you need to manually annotate it" + " with output_tensors.", + term_name, + ) + tensors.append( + TensorSemantics( + name=term_name, + ref=processed.clone(), + kind=None, + element_names=None, + ) + ) + fallback_terms.add(term_name) + self._fallback_term_names = fallback_terms + return tensors + + def _collect_action_static_outputs( + self, action_manager, skip_terms: set[str] | None = None + ) -> list[TensorSemantics]: + """Collect static kp/kd gain values from action terms for export metadata. + + Terms in ``skip_terms`` are excluded — these are terms that fell back + to ``processed_actions`` and whose static gains (kp/kd) belong to a + lower abstraction level that is not part of the exported policy. + + Args: + action_manager: Action manager whose terms should be inspected. + skip_terms: Action term names whose static outputs should be + skipped. + + Returns: + Static tensor semantics for action gains exported as metadata. + """ + static_values: list[TensorSemantics] = [] + for term_name, term in action_manager._terms.items(): + if skip_terms and term_name in skip_terms: + continue + osc = getattr(term, "_osc", None) + if osc and hasattr(osc, "cfg") and osc.cfg.impedance_mode in VARIABLE_IMPEDANCE_MODES: + continue + asset = getattr(term, "_asset", None) + real_asset = getattr(asset, "_real_asset", asset) + if real_asset and hasattr(real_asset, "data"): + data = real_asset.data + joint_ids = getattr(term, "_joint_ids", None) + joint_names = getattr(real_asset, "joint_names", None) + scene_key = self._action_term_scene_keys.get(term_name, "ego") + if hasattr(data, "default_joint_stiffness") and data.default_joint_stiffness is not None: + gains = data.default_joint_stiffness.torch + static_values.append( + TensorSemantics( + name=f"{term_name}_kp_gains", + ref=gains[:, joint_ids] if joint_ids else gains, + kind="kp", + element_names=select_element_names(joint_names, joint_ids), + extra=build_write_connection(scene_key, "write_joint_stiffness_to_sim_index"), + ) + ) + if hasattr(data, "default_joint_damping") and data.default_joint_damping is not None: + gains = data.default_joint_damping.torch + static_values.append( + TensorSemantics( + name=f"{term_name}_kd_gains", + ref=gains[:, joint_ids] if joint_ids else gains, + kind="kd", + element_names=select_element_names(joint_names, joint_ids), + extra=build_write_connection(scene_key, "write_joint_damping_to_sim_index"), + ) + ) + return static_values + + +# ══════════════════════════════════════════════════════════════════ +# Public entry point +# ══════════════════════════════════════════════════════════════════ + + +def patch_env_for_export( + env: ManagerBasedEnv, + export_method: str, + required_obs_groups: set[str] | None = None, +) -> None: + """Patch the env's observation and action managers for LEAPP export. + + This is a thin public entry point around ``ExportPatcher``. It mutates + the provided env instance in-place so that: + + - Observation terms route through proxy objects that annotate tensor + reads from **any** scene entity data class (articulations, rigid + objects, sensors, etc.). + - Action terms route through proxy objects that annotate both data + reads **and** ``Articulation`` write methods. + + Data properties are resolved lazily through proxies — no hardcoded + class list is required. To produce LEAPP input annotations, the + accessed data property getter must carry ``_leapp_semantics``. + Likewise, action-side write methods must be annotated to produce + semantic LEAPP outputs. Undecorated reads and writes are forwarded + as normal runtime access, but they do not gain semantic annotation + metadata through this patching path. + + State reads are deduplicated across observation and action paths via a + shared cache, so a property like ``joint_pos`` that is read by both an + observation term and a relative-position action term appears as a single + LEAPP input edge. + + The underlying env, scene, assets, and tensors remain shared with the rest + of the pipeline; only the manager call paths are redirected. + + Args: + env: Manager-based environment to patch in place. + export_method: LEAPP export backend passed to + :func:`annotate.output_tensors`. + required_obs_groups: Observation groups that should be patched, or + ``None`` to patch all groups. + """ + patcher = ExportPatcher(export_method, required_obs_groups=required_obs_groups) + patcher.setup(env) diff --git a/source/isaaclab/isaaclab/utils/leapp/leapp_semantics.py b/source/isaaclab/isaaclab/utils/leapp/leapp_semantics.py new file mode 100644 index 000000000000..340291de16ad --- /dev/null +++ b/source/isaaclab/isaaclab/utils/leapp/leapp_semantics.py @@ -0,0 +1,145 @@ +# 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 + +"""LEAPP semantic metadata helpers for raw tensor-producing functions.""" + +from __future__ import annotations + +from collections.abc import Callable +from contextlib import suppress +from dataclasses import dataclass +from typing import Any + +try: + from leapp import InputKindEnum, OutputKindEnum +except ImportError: + + class _LeappEnumSentinel: + """Stand-in when leapp is not installed. + + Any attribute access returns ``None`` so that + ``@leapp_tensor_semantics(kind=InputKindEnum.BODY_POSE)`` + silently stores ``kind=None`` instead of crashing at import time. + The real enum values are only needed at export time, when leapp + *is* guaranteed to be available. + """ + + def __getattr__(self, name: str): + return None + + InputKindEnum = _LeappEnumSentinel() # type: ignore[assignment,misc] + OutputKindEnum = _LeappEnumSentinel() # type: ignore[assignment,misc] + + +@dataclass(frozen=True) +class LeappTensorSemantics: + """Semantic metadata attached directly to a raw tensor-producing function.""" + + kind: Any = None + element_names: list[str] | list[list[str]] | None = None + element_names_resolver: Callable | None = None + const: bool = False + + +XYZ_ELEMENT_NAMES: list[str] = ["x", "y", "z"] +QUAT_XYZW_ELEMENT_NAMES: list[str] = ["qx", "qy", "qz", "qw"] +POSE7_ELEMENT_NAMES: list[str] = ["x", "y", "z", "qx", "qy", "qz", "qw"] +POSE6_ELEMENT_NAMES: list[str] = ["x", "y", "z", "angular_x", "angular_y", "angular_z"] +WRENCH6_ELEMENT_NAMES: list[str] = ["fx", "fy", "fz", "tx", "ty", "tz"] + + +def select_element_names(names: list[str] | None, indices: Any = None) -> list[str] | None: + """Select element names using optional runtime indices.""" + if names is None: + return None + if indices is None or indices == slice(None): + return list(names) + if isinstance(indices, slice): + return list(names[indices]) + with suppress(AttributeError): + indices = indices.tolist() + if isinstance(indices, (list, tuple)): + return [names[int(index)] for index in indices] + if isinstance(indices, int): + return [names[indices]] + return None + + +def leapp_tensor_semantics( + *, + kind: Any = None, + element_names: list[str] | list[list[str]] | None = None, + element_names_resolver: Callable | None = None, + const: bool = False, +) -> Callable: + """Attach LEAPP semantic metadata to a raw tensor-producing function.""" + + semantics = LeappTensorSemantics( + kind=kind, + element_names=element_names, + element_names_resolver=element_names_resolver, + const=const, + ) + + def _apply(func: Callable) -> Callable: + func._leapp_semantics = semantics + return func + + return _apply + + +def resolve_leapp_element_names(semantics: LeappTensorSemantics | None, data_self) -> list | None: + """Resolve element names from attached semantics and a tensor-producing object.""" + if semantics is None: + return None + if semantics.element_names is not None: + return semantics.element_names + if semantics.element_names_resolver is not None: + return semantics.element_names_resolver(data_self) + return None + + +# ── Predefined element-name resolvers ───────────────────────────── + + +def joint_names_resolver(data_self) -> list[str] | None: + """Resolve joint element names from the data object at trace time.""" + return select_element_names( + getattr(data_self, "joint_names", getattr(data_self, "_joint_names", None)), + getattr(data_self, "_joint_ids", None), + ) + + +def body_names_resolver(data_self) -> list[str] | None: + """Resolve body element names from the data object at trace time.""" + return select_element_names( + getattr(data_self, "body_names", getattr(data_self, "_body_names", None)), + getattr(data_self, "_body_ids", None), + ) + + +def _compound_resolver(outer_fn: Callable, inner_names: list[str]) -> Callable: + """Build a 2D resolver: ``[outer_names, inner_constant_names]``.""" + + def resolver(data_self) -> list | None: + outer = outer_fn(data_self) + return [outer, inner_names] if outer else None + + return resolver + + +def _target_frame_names(data_self) -> list[str] | None: + names = getattr(data_self, "target_frame_names", None) + return list(names) if names is not None else None + + +body_xyz_resolver = _compound_resolver(body_names_resolver, XYZ_ELEMENT_NAMES) +body_pose_resolver = _compound_resolver(body_names_resolver, POSE7_ELEMENT_NAMES) +body_pose6_resolver = _compound_resolver(body_names_resolver, POSE6_ELEMENT_NAMES) +body_quat_resolver = _compound_resolver(body_names_resolver, QUAT_XYZW_ELEMENT_NAMES) +body_wrench_resolver = _compound_resolver(body_names_resolver, WRENCH6_ELEMENT_NAMES) +target_frame_xyz_resolver = _compound_resolver(_target_frame_names, XYZ_ELEMENT_NAMES) +target_frame_quat_resolver = _compound_resolver(_target_frame_names, QUAT_XYZW_ELEMENT_NAMES) +target_frame_pose_resolver = _compound_resolver(_target_frame_names, POSE7_ELEMENT_NAMES) diff --git a/source/isaaclab/isaaclab/utils/leapp/proxy.py b/source/isaaclab/isaaclab/utils/leapp/proxy.py new file mode 100644 index 000000000000..49ef86e7265f --- /dev/null +++ b/source/isaaclab/isaaclab/utils/leapp/proxy.py @@ -0,0 +1,521 @@ +# 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 + +import inspect +from collections.abc import Callable +from typing import Any, cast + +import torch +from leapp.utils.tensor_description import TensorSemantics + +from isaaclab.managers import ManagerTermBase +from isaaclab.utils.warp.proxy_array import ProxyArray + +from .leapp_semantics import resolve_leapp_element_names +from .utils import TracedProxyArray, build_write_connection + + +def _resolve_annotated_property( + property_resolution_cache: dict[tuple[type, str], tuple[Callable, Any] | None], + real_data: Any, + name: str, +) -> tuple[Callable, Any] | None: + """Resolve a concrete property getter and inherited semantics metadata. + + The execution getter always comes from the concrete runtime class. Semantic + metadata is resolved independently by walking the MRO until a property + definition with ``_leapp_semantics`` is found. This mirrors the output-side + export path, where semantics are authored on the base API while concrete + backends provide the runtime implementation. + """ + cache_key = (type(real_data), name) + if cache_key in property_resolution_cache: + return property_resolution_cache[cache_key] + + execution_prop = getattr(type(real_data), name, None) + if not isinstance(execution_prop, property) or execution_prop.fget is None: + property_resolution_cache[cache_key] = None + return None + + semantics_meta = None + for data_cls in type(real_data).__mro__: + prop = data_cls.__dict__.get(name) + if not isinstance(prop, property) or prop.fget is None: + continue + candidate = getattr(prop.fget, "_leapp_semantics", None) + if candidate is None: + continue + if getattr(candidate, "const", False): + property_resolution_cache[cache_key] = None + return None + semantics_meta = candidate + break + + if semantics_meta is None: + property_resolution_cache[cache_key] = None + return None + + resolution = (execution_prop.fget, semantics_meta) + property_resolution_cache[cache_key] = resolution + return resolution + + +def _resolve_annotated_method( + method_resolution_cache: dict[tuple[type, str], tuple[Callable, Any, inspect.Signature] | None], + real_asset: Any, + name: str, +) -> tuple[Callable, Any, inspect.Signature] | None: + """Resolve a concrete bound method and inherited semantics metadata.""" + cache_key = (type(real_asset), name) + if cache_key in method_resolution_cache: + return method_resolution_cache[cache_key] + + original_method = getattr(real_asset, name, None) + if not callable(original_method): + method_resolution_cache[cache_key] = None + return None + + for asset_cls in type(real_asset).__mro__: + candidate = asset_cls.__dict__.get(name) + if not callable(candidate): + continue + semantics_meta = getattr(candidate, "_leapp_semantics", None) + if semantics_meta is None: + continue + resolution = (original_method, semantics_meta, inspect.signature(candidate)) + method_resolution_cache[cache_key] = resolution + return resolution + + method_resolution_cache[cache_key] = None + return None + + +class _WriteJointNameContext: + """Resolve runtime joint-name subsets for lazy write interception.""" + + __slots__ = ("joint_names", "_joint_ids") + + def __init__(self, joint_names: list[str], joint_ids): + self.joint_names = joint_names + self._joint_ids = joint_ids + + +def _unique_output_name(term_name: str, method_name: str, output_cache: list[TensorSemantics]) -> str: + """Return a stable, unique output name for an action write entry.""" + existing = {t.name for t in output_cache} + candidate = term_name + if candidate in existing: + candidate = f"{term_name}_{method_name}" + suffix = 2 + while candidate in existing: + candidate = f"{term_name}_{method_name}_{suffix}" + suffix += 1 + return candidate + + +class _DataProxy: + """Proxy around a real data object that intercepts tensor-returning property reads. + + The real data object may be any scene entity data class (``ArticulationData``, + ``RigidObjectData``, sensor data classes, etc.). The proxy resolves property + semantics lazily on first access by walking the runtime class MRO. This lets + concrete backend overrides reuse semantic metadata authored on abstract base + properties without copying decorators onto every implementation. + + When a semantic property returns a :class:`~isaaclab.utils.warp.ProxyArray`, + the result is wrapped in a ``TracedProxyArray`` and cached for + deduplication. Non-proxy results and ordinary attributes are forwarded + transparently. + + All other attribute access is forwarded transparently to the real object. + """ + + def __init__( + self, + real_data: Any, + entity_name: str, + task_name: str, + property_resolution_cache: dict[tuple[type, str], tuple[Callable, Any] | None], + cache: dict, + input_name_resolver: Callable, + ): + object.__setattr__(self, "_real_data", real_data) + object.__setattr__(self, "_entity_name", entity_name) + object.__setattr__(self, "_task_name", task_name) + object.__setattr__(self, "_property_resolution_cache", property_resolution_cache) + object.__setattr__(self, "_cache", cache) + object.__setattr__(self, "_input_name_resolver", input_name_resolver) + + def __getattr__(self, name): + """Intercept semantic property reads; forward everything else.""" + real_data = object.__getattribute__(self, "_real_data") + resolution = _resolve_annotated_property( + object.__getattribute__(self, "_property_resolution_cache"), real_data, name + ) + if resolution is None: + return getattr(real_data, name) + + cache = object.__getattribute__(self, "_cache") + cache_key = (id(real_data), name) + if cache_key in cache: + return cache[cache_key] + + execution_fget, semantics_meta = resolution + result = execution_fget(real_data) + if not isinstance(result, ProxyArray): + return result + + input_name = object.__getattribute__(self, "_input_name_resolver")(name) + traced = TracedProxyArray( + result, + input_name=input_name, + semantics_meta=semantics_meta, + real_data=real_data, + entity_name=object.__getattribute__(self, "_entity_name"), + property_name=name, + task_name=object.__getattribute__(self, "_task_name"), + ) + cache[cache_key] = traced + return traced + + +class _EntityProxy: + """Proxy around a real scene entity that returns a ``_DataProxy`` for ``.data``. + + All other attribute access is forwarded transparently to the real asset. + """ + + def __init__(self, real_entity: Any, data_proxy: _DataProxy): + object.__setattr__(self, "_real_entity", real_entity) + object.__setattr__(self, "_data_proxy", data_proxy) + + @property + def data(self): + """Return the annotating data proxy instead of the real data object.""" + return object.__getattribute__(self, "_data_proxy") + + def __getattr__(self, name): + """Forward all non-data attribute access to the real scene entity.""" + return getattr(object.__getattribute__(self, "_real_entity"), name) + + +class _EntityMappingProxy: + """Proxy around a mapping of scene entities that lazily wraps data-producing entries.""" + + def __init__( + self, + real_mapping, + task_name: str, + property_resolution_cache: dict[tuple[type, str], tuple[Callable, Any] | None], + cache: dict, + ): + object.__setattr__(self, "_real_mapping", real_mapping) + object.__setattr__(self, "_task_name", task_name) + object.__setattr__(self, "_property_resolution_cache", property_resolution_cache) + object.__setattr__(self, "_cache", cache) + object.__setattr__(self, "_proxied", {}) + + def __getitem__(self, key): + """Return a proxied entity when it has a ``.data`` attribute.""" + proxied = object.__getattribute__(self, "_proxied") + if key in proxied: + return proxied[key] + real_mapping = object.__getattribute__(self, "_real_mapping") + entity = real_mapping[key] + data = getattr(entity, "data", None) + if data is None: + return entity + data_proxy = _DataProxy( + data, + key, + object.__getattribute__(self, "_task_name"), + object.__getattribute__(self, "_property_resolution_cache"), + object.__getattribute__(self, "_cache"), + input_name_resolver=lambda prop_name: f"{key}_{prop_name}", + ) + proxy = _EntityProxy(entity, data_proxy) + proxied[key] = proxy + return proxy + + def get(self, key, default=None): + """Return a proxied entity when present, default otherwise.""" + real_mapping = object.__getattribute__(self, "_real_mapping") + if key not in real_mapping: + return default + return self[key] + + def __iter__(self): + return iter(object.__getattribute__(self, "_real_mapping")) + + def __len__(self): + return len(object.__getattribute__(self, "_real_mapping")) + + def __getattr__(self, name): + """Forward all other mapping access to the real mapping.""" + return getattr(object.__getattribute__(self, "_real_mapping"), name) + + +class _SceneProxy: + """Proxy around the real InteractiveScene. + + When an observation term looks up a scene entity by name, this proxy lazily + wraps any entity that has a ``.data`` attribute. All tensor-returning + properties on the data object are intercepted for LEAPP annotation. This + covers articulations, rigid objects, and sensors through both + ``scene["name"]`` and ``scene.sensors["name"]`` access paths. + """ + + def __init__( + self, + real_scene, + task_name: str, + property_resolution_cache: dict[tuple[type, str], tuple[Callable, Any] | None], + cache: dict, + ): + object.__setattr__(self, "_real_scene", real_scene) + object.__setattr__(self, "_task_name", task_name) + object.__setattr__(self, "_property_resolution_cache", property_resolution_cache) + object.__setattr__(self, "_cache", cache) + object.__setattr__(self, "_proxied", {}) + object.__setattr__(self, "_sensor_mapping_proxy", None) + + def _maybe_proxy_entity(self, key: str, entity: Any): + """Return a proxy for any entity that has a ``.data`` attribute.""" + proxied = object.__getattribute__(self, "_proxied") + if key in proxied: + return proxied[key] + + data = getattr(entity, "data", None) + if data is None: + return entity + + cache = object.__getattribute__(self, "_cache") + data_proxy = _DataProxy( + data, + key, + object.__getattribute__(self, "_task_name"), + object.__getattribute__(self, "_property_resolution_cache"), + cache, + input_name_resolver=lambda prop_name, k=key: f"{k}_{prop_name}", + ) + proxy = _EntityProxy(entity, data_proxy) + proxied[key] = proxy + return proxy + + def __getitem__(self, key): + """Return a proxied entity when it exposes annotated data getters.""" + real_scene = object.__getattribute__(self, "_real_scene") + entity = real_scene[key] + return self._maybe_proxy_entity(key, entity) + + @property + def sensors(self): + """Return a mapping proxy for scene sensors.""" + sensor_mapping_proxy = object.__getattribute__(self, "_sensor_mapping_proxy") + if sensor_mapping_proxy is None: + real_scene = object.__getattribute__(self, "_real_scene") + sensor_mapping_proxy = _EntityMappingProxy( + real_scene.sensors, + object.__getattribute__(self, "_task_name"), + object.__getattribute__(self, "_property_resolution_cache"), + object.__getattribute__(self, "_cache"), + ) + object.__setattr__(self, "_sensor_mapping_proxy", sensor_mapping_proxy) + return sensor_mapping_proxy + + def __getattr__(self, name): + """Forward all other scene access to the real scene.""" + return getattr(object.__getattribute__(self, "_real_scene"), name) + + +class _EnvProxy: + """Proxy around the real env that returns a _SceneProxy for ``.scene``. + + All other attribute access (``num_envs``, ``command_manager``, etc.) + is forwarded transparently to the real env. + """ + + def __init__( + self, + real_env, + task_name: str, + property_resolution_cache: dict[tuple[type, str], tuple[Callable, Any] | None], + cache: dict, + ): + object.__setattr__(self, "_real_env", real_env) + object.__setattr__( + self, + "_scene_proxy", + _SceneProxy(real_env.scene, task_name, property_resolution_cache, cache), + ) + + @property + def scene(self): + """Return the scene proxy instead of the real scene.""" + return object.__getattribute__(self, "_scene_proxy") + + def __getattr__(self, name): + """Forward all non-scene attribute access to the real env.""" + return getattr(object.__getattribute__(self, "_real_env"), name) + + +def _build_scene_entity_lookup(real_scene) -> dict[int, tuple[str, str]]: + """Map real scene entity object ids to their lookup path.""" + lookup: dict[int, tuple[str, str]] = {} + for attr_name, attr_value in vars(real_scene).items(): + if not isinstance(attr_value, dict): + continue + container_kind = "sensors" if attr_name == "sensors" else "scene" + for key, entity in attr_value.items(): + lookup[id(entity)] = (container_kind, key) + return lookup + + +class _ManagerTermProxy(ManagerTermBase): + """Proxy a class-based manager term while preserving its lifecycle methods. + + Observation manager terms can be stateful ``ManagerTermBase`` instances that + expose ``reset()`` and ``serialize()`` in addition to being callable. This + proxy preserves that interface while swapping the env argument passed into + ``__call__`` for the observation-side proxy env. + """ + + def __init__(self, target: ManagerTermBase, proxy_env: _EnvProxy): + super().__init__(target.cfg, target._env) + self._target = target + self._proxy_env = proxy_env + self._entity_lookup = _build_scene_entity_lookup(target._env.scene) + + @property + def __name__(self) -> str: + """Expose the wrapped term name for compatibility and debugging.""" + return getattr(self._target, "__name__", self._target.__class__.__name__) + + def reset(self, env_ids=None) -> None: + """Forward resets to the wrapped term instance.""" + self._target.reset(env_ids=env_ids) + + def serialize(self) -> dict: + """Forward serialization to the wrapped term instance.""" + return self._target.serialize() + + def __call__(self, *args, **kwargs): + """Call the wrapped term with the proxy env in place of the real env.""" + if args: + args = (self._proxy_env, *args[1:]) + else: + args = (self._proxy_env,) + swapped_attrs: list[tuple[str, Any]] = [] + for attr_name, attr_value in vars(self._target).items(): + lookup = self._entity_lookup.get(id(attr_value)) + if lookup is None: + continue + + container_kind, key = lookup + proxy_entity = ( + self._proxy_env.scene.sensors[key] if container_kind == "sensors" else self._proxy_env.scene[key] + ) + swapped_attrs.append((attr_name, attr_value)) + setattr(self._target, attr_name, proxy_entity) + + try: + return self._target(*args, **kwargs) + finally: + for attr_name, attr_value in swapped_attrs: + setattr(self._target, attr_name, attr_value) + + def __getattr__(self, name): + """Forward all other attribute access to the wrapped term instance.""" + return getattr(self._target, name) + + +# ══════════════════════════════════════════════════════════════════ +# Action-side proxy +# ══════════════════════════════════════════════════════════════════ + + +class _ArticulationWriteProxy: + """Proxy around a real articulation implementation for action terms. + + Intercepts ``_leapp_semantics``-decorated write methods **and** routes + ``.data`` reads through a shared ``_DataProxy`` so that + action-side state reads (e.g. ``self._asset.data.joint_pos`` inside + ``RelativeJointPositionAction``) participate in LEAPP annotation and + share the dedup cache with observation-side reads. + + All other attribute access is forwarded transparently to the real asset. + """ + + def __init__( + self, + real_asset: Any, + entity_name: str, + term_name: str, + output_cache: list[TensorSemantics], + method_resolution_cache: dict[tuple[type, str], tuple[Callable, Any, inspect.Signature] | None], + captured_write_term_names: set[str], + data_proxy: _DataProxy, + ): + object.__setattr__(self, "_real_asset", real_asset) + object.__setattr__(self, "_entity_name", entity_name) + object.__setattr__(self, "_term_name", term_name) + object.__setattr__(self, "_output_cache", output_cache) + object.__setattr__(self, "_method_resolution_cache", method_resolution_cache) + object.__setattr__(self, "_captured_write_term_names", captured_write_term_names) + object.__setattr__(self, "_data_proxy", data_proxy) + + @property + def data(self): + """Return the shared annotating data proxy.""" + return object.__getattribute__(self, "_data_proxy") + + def __getattr__(self, name): + """Return an annotating wrapper for semantic write methods; forward everything else.""" + real_asset = object.__getattribute__(self, "_real_asset") + resolution = _resolve_annotated_method( + object.__getattribute__(self, "_method_resolution_cache"), + real_asset, + name, + ) + if resolution is None: + return getattr(real_asset, name) + + original_method, semantics_meta, signature = resolution + term_name = object.__getattribute__(self, "_term_name") + output_cache = object.__getattribute__(self, "_output_cache") + captured_write_term_names = object.__getattribute__(self, "_captured_write_term_names") + + def interceptor(*args, **kwargs): + result = original_method(*args, **kwargs) + bound_args = signature.bind_partial(real_asset, *args, **kwargs) + target = bound_args.arguments.get("target") + + if not isinstance(target, torch.Tensor): + return result + + target_tensor = cast(torch.Tensor, target) + joint_ids = bound_args.arguments.get("joint_ids") + output_cache.append( + TensorSemantics( + name=_unique_output_name(term_name, name, output_cache), + ref=target_tensor.clone(), + kind=semantics_meta.kind, + element_names=resolve_leapp_element_names( + semantics_meta, + _WriteJointNameContext(real_asset.joint_names, joint_ids), + ), + extra=build_write_connection( + object.__getattribute__(self, "_entity_name"), + name, + ), + ) + ) + captured_write_term_names.add(term_name) + + return result + + return interceptor diff --git a/source/isaaclab/isaaclab/utils/leapp/utils.py b/source/isaaclab/isaaclab/utils/leapp/utils.py new file mode 100644 index 000000000000..2308f662ab80 --- /dev/null +++ b/source/isaaclab/isaaclab/utils/leapp/utils.py @@ -0,0 +1,87 @@ +# 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 types import SimpleNamespace +from typing import Any + +import torch +from leapp import annotate +from leapp.utils.tensor_description import TensorSemantics + +from isaaclab.utils.warp.proxy_array import ProxyArray + +from .leapp_semantics import LeappTensorSemantics, resolve_leapp_element_names + + +class TracedProxyArray(ProxyArray): + _traced_array: torch.Tensor + + def __init__( + self, + proxy_array: ProxyArray, + *, + input_name: str, + semantics_meta: LeappTensorSemantics, + real_data: Any, + entity_name: str, + property_name: str, + task_name: str, + ) -> None: + super().__init__(proxy_array.warp) + astorch = super().torch + sem = TensorSemantics( + name=input_name, + ref=astorch, + kind=semantics_meta.kind, + element_names=resolve_leapp_element_names(semantics_meta, real_data), + extra=build_state_connection(entity_name, property_name), + ) + annotated = annotate.input_tensors(task_name, sem) + object.__setattr__(self, "_traced_array", annotated) + + @property + def torch(self) -> torch.Tensor: + return self._traced_array + + @property + def warp(self) -> Any: + raise AttributeError("warp arrays are not supported for leapp export") + + +def ensure_env_spec_id(env, fallback_task_name: str = "policy") -> str: + """Return ``env.unwrapped.spec.id``, creating a fallback spec when needed.""" + spec = getattr(env.unwrapped, "spec", None) + if spec is None: + env.unwrapped.spec = SimpleNamespace(id=fallback_task_name) + return fallback_task_name + + task_name = getattr(spec, "id", None) + if task_name is None: + spec.id = fallback_task_name + return fallback_task_name + + return task_name + + +# ══════════════════════════════════════════════════════════════════ +# Connection Builders +# ══════════════════════════════════════════════════════════════════ + + +def build_state_connection(entity_name: str, property_name: str) -> dict[str, str]: + """Return a compact deployment connection string for a state property.""" + return {"isaaclab_connection": f"state:{entity_name}:{property_name}"} + + +def build_command_connection(command_name: str) -> dict[str, str]: + """Return a compact deployment connection string for a command term.""" + return {"isaaclab_connection": f"command:{command_name}"} + + +def build_write_connection(entity_name: str, method_name: str) -> dict[str, str]: + """Return a compact deployment connection string for an articulation write target.""" + return {"isaaclab_connection": f"write:{entity_name}:{method_name}"} diff --git a/source/isaaclab_rl/changelog.d/leapp_export_integration.rst b/source/isaaclab_rl/changelog.d/leapp_export_integration.rst new file mode 100644 index 000000000000..8a9a65b18d7f --- /dev/null +++ b/source/isaaclab_rl/changelog.d/leapp_export_integration.rst @@ -0,0 +1,5 @@ +Added +^^^^^ + +* Added RSL-RL LEAPP export scripts and integration tests for exporting trained + policies with semantic input, output, and state annotations. diff --git a/source/isaaclab_rl/test/export/test_rsl_rl_direct_export_flow.py b/source/isaaclab_rl/test/export/test_rsl_rl_direct_export_flow.py new file mode 100644 index 000000000000..ecc155c666e5 --- /dev/null +++ b/source/isaaclab_rl/test/export/test_rsl_rl_direct_export_flow.py @@ -0,0 +1,194 @@ +# 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 + +"""Direct-env export integration test with subprocess-side gym re-registration.""" + +from __future__ import annotations + +import argparse +import importlib +import importlib.util +import os +import runpy +import shutil +import subprocess +import sys +import tempfile +import types +from pathlib import Path + +import gymnasium as gym +import pytest + +_THIS_FILE = Path(__file__).resolve() +_REPO_ROOT = str(_THIS_FILE.parents[4]) +_EXPORT_SCRIPT = os.path.join(_REPO_ROOT, "scripts", "reinforcement_learning", "leapp", "rsl_rl", "export.py") +_THIS_SCRIPT = str(_THIS_FILE) +_TASK_NAME = "Isaac-Velocity-Flat-Anymal-C-Direct-v0" +_PACKAGE_NAME = "_isaaclab_test_tutorial_anymal_c" +_MODULE_NAME = f"{_PACKAGE_NAME}.anymal_c_env" +_CFG_MODULE_NAME = f"{_PACKAGE_NAME}.anymal_c_env_cfg" +_RUNTIME_MODULE_NAME = "_isaaclab_test_tutorial_anymal_c_runtime" +_TUTORIAL_ENV_PATH = Path(_REPO_ROOT) / "scripts" / "tutorials" / "06_deploy" / "anymal_c_env.py" + + +def _export_command(task_name: str, export_dir: str) -> list[str]: + """Build a subprocess command that runs this file in helper mode.""" + return [ + sys.executable, + _THIS_SCRIPT, + "--task", + task_name, + "--use_pretrained_checkpoint", + "--export_save_path", + export_dir, + "--disable_graph_visualization", + "--headless", + ] + + +def _artifact_dir(export_dir: str, task_name: str) -> str: + """Return the LEAPP artifact directory for the exported task.""" + return os.path.join(export_dir, task_name) + + +def _load_tutorial_env_class(): + """Load the tutorial env through a synthetic package for relative imports.""" + module = sys.modules.get(_MODULE_NAME) + if module is not None: + return module.AnymalCEnv + + package = types.ModuleType(_PACKAGE_NAME) + package.__path__ = [] # type: ignore[attr-defined] + sys.modules.setdefault(_PACKAGE_NAME, package) + + cfg_module = importlib.import_module("isaaclab_tasks.direct.anymal_c.anymal_c_env_cfg") + sys.modules[_CFG_MODULE_NAME] = cfg_module + + spec = importlib.util.spec_from_file_location(_MODULE_NAME, _TUTORIAL_ENV_PATH) + if spec is None or spec.loader is None: + raise ImportError(f"Could not create module spec for tutorial env: {_TUTORIAL_ENV_PATH}") + + module = importlib.util.module_from_spec(spec) + sys.modules[_MODULE_NAME] = module + spec.loader.exec_module(module) + return module.AnymalCEnv + + +class _LazyTutorialEnvModule(types.ModuleType): + """Resolve the tutorial env class only when gym imports the entrypoint.""" + + def __getattr__(self, name: str): + if name != "AnymalCEnv": + raise AttributeError(name) + env_class = _load_tutorial_env_class() + setattr(self, name, env_class) + return env_class + + +def _install_lazy_runtime_module() -> str: + """Install a lazy module so gym can defer tutorial env imports.""" + module = sys.modules.get(_RUNTIME_MODULE_NAME) + if module is None: + sys.modules[_RUNTIME_MODULE_NAME] = _LazyTutorialEnvModule(_RUNTIME_MODULE_NAME) + return _RUNTIME_MODULE_NAME + + +def _reregister_task(task_name: str) -> None: + """Override the direct task registration to point at the tutorial env.""" + import isaaclab_tasks.direct.anymal_c # noqa: F401 + + original_spec = gym.spec(task_name) + original_kwargs = dict(original_spec.kwargs) + runtime_module_name = _install_lazy_runtime_module() + + gym.registry.pop(task_name, None) + gym.register( + id=task_name, + entry_point=f"{runtime_module_name}:AnymalCEnv", + disable_env_checker=original_spec.disable_env_checker, + kwargs=original_kwargs, + ) + + +def _run_export_subprocess_entrypoint() -> None: + """Run export.py after re-registering the direct task in-process.""" + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("--task", required=True) + args, remaining_args = parser.parse_known_args() + + _reregister_task(args.task) + export_script_dir = os.path.dirname(_EXPORT_SCRIPT) + sys.argv = [_EXPORT_SCRIPT, "--task", args.task, *remaining_args] + if export_script_dir not in sys.path: + sys.path.insert(0, export_script_dir) + runpy.run_path(_EXPORT_SCRIPT, run_name="__main__") + + +def _build_failure_context(result: subprocess.CompletedProcess[str], artifact_dir: str) -> str: + """Return debug context for subprocess and export artifacts.""" + export_dir = os.path.dirname(artifact_dir) + log_txt_path = os.path.join(artifact_dir, "log.txt") + leapp_tail = "" + if os.path.isfile(log_txt_path): + with open(log_txt_path) as file: + last_lines = file.readlines()[-50:] + leapp_tail = f"\n--- leapp log.txt (last 50 lines) ---\n{''.join(last_lines)}" + + try: + export_dir_contents = sorted(os.listdir(export_dir)) + except FileNotFoundError: + export_dir_contents = [""] + + try: + artifact_dir_contents = sorted(os.listdir(artifact_dir)) + except FileNotFoundError: + artifact_dir_contents = [""] + + return ( + f"--- export_dir ---\n{export_dir}\n" + f"--- export_dir contents ---\n{export_dir_contents}\n" + f"--- artifact_dir ---\n{artifact_dir}\n" + f"--- artifact_dir contents ---\n{artifact_dir_contents}\n" + f"--- stdout ---\n{result.stdout[-3000:]}\n" + f"--- stderr ---\n{result.stderr[-3000:]}" + f"{leapp_tail}" + ) + + +def test_direct_env_export_flow(): + """Run export.py against the tutorial direct env and assert artifacts are created.""" + export_dir = tempfile.mkdtemp(prefix="isaaclab-direct-export-") + artifact_dir = _artifact_dir(export_dir, _TASK_NAME) + shutil.rmtree(artifact_dir, ignore_errors=True) + + result = subprocess.run( + _export_command(_TASK_NAME, export_dir), + cwd=_REPO_ROOT, + capture_output=True, + text=True, + timeout=6000, + ) + + if "pre-trained checkpoint is currently unavailable" in result.stdout: + pytest.skip(f"No pretrained checkpoint available for {_TASK_NAME}") + + if result.returncode != 0: + pytest.fail(f"export.py exited with code {result.returncode}.\n{_build_failure_context(result, artifact_dir)}") + + onnx_path = os.path.join(artifact_dir, f"{_TASK_NAME}.onnx") + yaml_path = os.path.join(artifact_dir, f"{_TASK_NAME}.yaml") + log_path = os.path.join(artifact_dir, "log.txt") + + if not os.path.isfile(onnx_path): + pytest.fail(f"Missing .onnx export at {onnx_path}.\n{_build_failure_context(result, artifact_dir)}") + if not os.path.isfile(yaml_path): + pytest.fail(f"Missing .yaml export at {yaml_path}.\n{_build_failure_context(result, artifact_dir)}") + if not os.path.isfile(log_path): + pytest.fail(f"Missing log.txt at {log_path}.\n{_build_failure_context(result, artifact_dir)}") + + +if __name__ == "__main__": + _run_export_subprocess_entrypoint() diff --git a/source/isaaclab_rl/test/export/test_rsl_rl_export_flow.py b/source/isaaclab_rl/test/export/test_rsl_rl_export_flow.py new file mode 100644 index 000000000000..5606be484286 --- /dev/null +++ b/source/isaaclab_rl/test/export/test_rsl_rl_export_flow.py @@ -0,0 +1,151 @@ +# 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 + +"""Export pipeline integration tests. + +Each test calls ``export.py`` as a subprocess so that Isaac Sim's AppLauncher +is fully isolated per task and the export logic is not duplicated here. +The export artifacts land in the default checkpoint directory; only the +per-task export subdirectory is removed after each test. +""" + +import os +import shutil +import subprocess + +import pytest + +# Root of the repository (three levels up from this file). +_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", "..")) +_EXPORT_SCRIPT = os.path.join("scripts", "reinforcement_learning", "leapp", "rsl_rl", "export.py") + + +# Tasks with confirmed pretrained checkpoints (Direct and no-checkpoint tasks excluded). +TASKS = [ + # Classic + "Isaac-Ant-v0", + "Isaac-Cartpole-v0", + # Navigation + "Isaac-Navigation-Flat-Anymal-C-v0", + "Isaac-Navigation-Flat-Anymal-C-Play-v0", + # Locomotion Velocity + "Isaac-Velocity-Flat-Anymal-B-v0", + "Isaac-Velocity-Flat-Anymal-B-Play-v0", + "Isaac-Velocity-Rough-Anymal-B-v0", + "Isaac-Velocity-Rough-Anymal-B-Play-v0", + "Isaac-Velocity-Flat-Anymal-C-v0", + "Isaac-Velocity-Flat-Anymal-C-Play-v0", + "Isaac-Velocity-Rough-Anymal-C-v0", + "Isaac-Velocity-Rough-Anymal-C-Play-v0", + "Isaac-Velocity-Flat-Anymal-D-v0", + "Isaac-Velocity-Flat-Anymal-D-Play-v0", + "Isaac-Velocity-Rough-Anymal-D-v0", + "Isaac-Velocity-Rough-Anymal-D-Play-v0", + "Isaac-Velocity-Flat-Cassie-v0", + "Isaac-Velocity-Flat-Cassie-Play-v0", + "Isaac-Velocity-Rough-Cassie-v0", + "Isaac-Velocity-Rough-Cassie-Play-v0", + "Isaac-Velocity-Flat-G1-v0", + "Isaac-Velocity-Flat-G1-Play-v0", + "Isaac-Velocity-Rough-G1-v0", + "Isaac-Velocity-Rough-G1-Play-v0", + "Isaac-Velocity-Flat-H1-v0", + "Isaac-Velocity-Flat-H1-Play-v0", + "Isaac-Velocity-Rough-H1-v0", + "Isaac-Velocity-Rough-H1-Play-v0", + "Isaac-Velocity-Flat-Spot-v0", + "Isaac-Velocity-Flat-Spot-Play-v0", + "Isaac-Velocity-Flat-Unitree-A1-v0", + "Isaac-Velocity-Flat-Unitree-A1-Play-v0", + "Isaac-Velocity-Rough-Unitree-A1-v0", + "Isaac-Velocity-Rough-Unitree-A1-Play-v0", + "Isaac-Velocity-Flat-Unitree-Go1-v0", + "Isaac-Velocity-Flat-Unitree-Go1-Play-v0", + "Isaac-Velocity-Rough-Unitree-Go1-v0", + "Isaac-Velocity-Rough-Unitree-Go1-Play-v0", + "Isaac-Velocity-Flat-Unitree-Go2-v0", + "Isaac-Velocity-Flat-Unitree-Go2-Play-v0", + "Isaac-Velocity-Rough-Unitree-Go2-v0", + "Isaac-Velocity-Rough-Unitree-Go2-Play-v0", + # Manipulation Reach + "Isaac-Reach-Franka-v0", + "Isaac-Reach-Franka-Play-v0", + "Isaac-Reach-UR10-v0", + "Isaac-Reach-UR10-Play-v0", + # Manipulation Lift + "Isaac-Lift-Cube-Franka-v0", + "Isaac-Lift-Cube-Franka-Play-v0", + # Manipulation Cabinet + "Isaac-Open-Drawer-Franka-v0", + "Isaac-Open-Drawer-Franka-Play-v0", + # Dexsuite + "Isaac-Dexsuite-Kuka-Allegro-Reorient-v0", + "Isaac-Dexsuite-Kuka-Allegro-Reorient-Play-v0", + "Isaac-Dexsuite-Kuka-Allegro-Lift-v0", + "Isaac-Dexsuite-Kuka-Allegro-Lift-Play-v0", +] + + +def _export_dir(task_name: str) -> str: + """Return the directory where export.py writes artifacts for *task_name*.""" + train_task = task_name.replace("-Play", "") + return os.path.join(_REPO_ROOT, ".pretrained_checkpoints", "rsl_rl", train_task, task_name) + + +@pytest.mark.parametrize("task_name", TASKS) +def test_export_flow(task_name): + """Run export.py for *task_name* and assert the expected artifacts are created.""" + export_dir = _export_dir(task_name) + + try: + result = subprocess.run( + [ + "./isaaclab.sh", + "-p", + _EXPORT_SCRIPT, + "--task", + task_name, + "--use_pretrained_checkpoint", + "--disable_graph_visualization", + "--headless", + ], + cwd=_REPO_ROOT, + capture_output=True, + text=True, + timeout=600, + ) + + # Gracefully skip tasks whose checkpoint isn't published yet + if "pre-trained checkpoint is currently unavailable" in result.stdout: + pytest.skip(f"No pretrained checkpoint available for {task_name.replace('-Play', '')}") + + # Skip tasks whose checkpoint was saved with an older rsl_rl architecture + # that does not use the 'actor_state_dict' key expected by the current runner + if "actor_state_dict" in result.stderr: + pytest.skip( + f"{task_name} checkpoint uses an older network architecture incompatible with the current rsl_rl runner" + ) + + # Surface stdout/stderr on failure for easier debugging + if result.returncode != 0: + log_txt_path = os.path.join(export_dir, "log.txt") + leapp_tail = "" + if os.path.isfile(log_txt_path): + with open(log_txt_path) as f: + last_lines = f.readlines()[-50:] + leapp_tail = f"\n--- leapp log.txt (last 50 lines) ---\n{''.join(last_lines)}" + pytest.fail( + f"export.py exited with code {result.returncode}.\n" + f"--- stdout ---\n{result.stdout[-3000:]}\n" + f"--- stderr ---\n{result.stderr[-3000:]}" + f"{leapp_tail}" + ) + + assert os.path.isfile(os.path.join(export_dir, f"{task_name}.onnx")), "Missing .onnx export" + assert os.path.isfile(os.path.join(export_dir, f"{task_name}.yaml")), "Missing .yaml export" + assert os.path.isfile(os.path.join(export_dir, "log.txt")), "Missing log.txt" + + finally: + shutil.rmtree(export_dir, ignore_errors=True) diff --git a/source/isaaclab_tasks/changelog.d/leapp_export_integration.rst b/source/isaaclab_tasks/changelog.d/leapp_export_integration.rst new file mode 100644 index 000000000000..94a128b6416f --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/leapp_export_integration.rst @@ -0,0 +1,5 @@ +Added +^^^^^ + +* Added LEAPP-compatible policy deployment tutorials and tracing-compatible task + observation helpers for exported policy workflows. diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/humanoid/mdp/observations.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/humanoid/mdp/observations.py index 0c696ef62f50..d7195b0f380e 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/humanoid/mdp/observations.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/humanoid/mdp/observations.py @@ -48,7 +48,7 @@ def base_heading_proj( asset: Articulation = env.scene[asset_cfg.name] # compute desired heading direction to_target_pos = torch.tensor(target_pos, device=env.device) - asset.data.root_pos_w.torch[:, :3] - to_target_pos[:, 2] = 0.0 + to_target_pos = torch.cat((to_target_pos[:, :2], torch.zeros_like(to_target_pos[:, 2:3])), dim=-1) to_target_dir = math_utils.normalize(to_target_pos) # compute base forward vector heading_vec = math_utils.quat_apply(asset.data.root_quat_w.torch, asset.data.FORWARD_VEC_B.torch) diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/mdp/observations.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/mdp/observations.py index c32adbd7f616..ac12d8b22f7f 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/mdp/observations.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/mdp/observations.py @@ -186,8 +186,7 @@ def __call__( # Ensure w component is positive (q and -q represent the same rotation) # Pick one canonical form to reduce observation variation seen by the policy w_negative = base_quat[:, 3] < 0 - positive_quat = base_quat.clone() - positive_quat[w_negative] = -base_quat[w_negative] + positive_quat = torch.where(w_negative.unsqueeze(-1), -base_quat, base_quat) return positive_quat diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/mdp/commands/pose_commands.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/mdp/commands/pose_commands.py index d3a43dc933ac..710fef931ee4 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/mdp/commands/pose_commands.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/mdp/commands/pose_commands.py @@ -14,6 +14,7 @@ import torch from isaaclab.managers import CommandTerm +from isaaclab.utils.leapp import POSE7_ELEMENT_NAMES from isaaclab.utils.math import combine_frame_transforms, compute_pose_error, quat_from_euler_xyz, quat_unique if TYPE_CHECKING: @@ -29,7 +30,7 @@ class ObjectUniformPoseCommand(CommandTerm): This command term samples target object poses by: • Drawing (x, y, z) uniformly within configured Cartesian bounds, and • Drawing roll-pitch-yaw uniformly within configured ranges, then converting - to a quaternion (w, x, y, z). Optionally makes quaternions unique by enforcing + to a quaternion (x, y, z, w). Optionally makes quaternions unique by enforcing a positive real part. Frames: @@ -37,7 +38,7 @@ class ObjectUniformPoseCommand(CommandTerm): targets are transformed into the *world frame* using the robot root pose. Outputs: - The command buffer has shape (num_envs, 7): `(x, y, z, qw, qx, qy, qz)`. + The command buffer has shape (num_envs, 7): ``(x, y, z, qx, qy, qz, qw)``. Metrics: `position_error` and `orientation_error` are computed between the commanded @@ -70,7 +71,7 @@ def __init__(self, cfg: dex_cmd_cfgs.ObjectUniformPoseCommandCfg, env: ManagerBa self.success_vis_asset = None # create buffers - # -- commands: (x, y, z, qw, qx, qy, qz) in root frame + # -- commands: (x, y, z, qx, qy, qz, qw) in root frame self.pose_command_b = torch.zeros(self.num_envs, 7, device=self.device) self.pose_command_b[:, 3] = 1.0 self.pose_command_w = torch.zeros_like(self.pose_command_b) @@ -82,6 +83,11 @@ def __init__(self, cfg: dex_cmd_cfgs.ObjectUniformPoseCommandCfg, env: ManagerBa self.success_visualizer = VisualizationMarkers(self.cfg.success_visualizer_cfg) self.success_visualizer.set_visibility(True) + # adds (optional) cmd kind and element names for leapp export + # during export, semantic data about this command will be used to annotate the command input + self.cfg.cmd_kind = self.cfg.cmd_kind or "command/body/pose" + self.cfg.element_names = self.cfg.element_names or POSE7_ELEMENT_NAMES + def __str__(self) -> str: msg = "UniformPoseCommand:\n" msg += f"\tCommand dimension: {tuple(self.command.shape[1:])}\n" @@ -96,7 +102,7 @@ def __str__(self) -> str: def command(self) -> torch.Tensor: """The desired pose command. Shape is (num_envs, 7). - The first three elements correspond to the position, followed by the quaternion orientation in (w, x, y, z). + The first three elements correspond to the position, followed by the quaternion orientation in (x, y, z, w). """ return self.pose_command_b diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/inhand/mdp/commands/orientation_command.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/inhand/mdp/commands/orientation_command.py index 763648608a3b..7c6d250f2147 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/inhand/mdp/commands/orientation_command.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/inhand/mdp/commands/orientation_command.py @@ -15,6 +15,7 @@ import isaaclab.utils.math as math_utils from isaaclab.managers import CommandTerm from isaaclab.markers import VisualizationMarkers +from isaaclab.utils.leapp import POSE7_ELEMENT_NAMES if TYPE_CHECKING: from isaaclab.assets import RigidObject @@ -78,6 +79,11 @@ def __init__(self, cfg: InHandReOrientationCommandCfg, env: ManagerBasedRLEnv): # the trailing attempt at episode end counts as one unsuccessful attempt. self._completed_attempts = torch.zeros(self.num_envs, device=self.device) + # adds (optional) cmd kind and element names for leapp export + # during export, semantic data about this command will be used to annotate the command input + self.cfg.cmd_kind = self.cfg.cmd_kind or "command/body/pose" + self.cfg.element_names = self.cfg.element_names or POSE7_ELEMENT_NAMES + def __str__(self) -> str: msg = "InHandManipulationCommandGenerator:\n" msg += f"\tCommand dimension: {tuple(self.command.shape[1:])}\n" diff --git a/tools/test_settings.py b/tools/test_settings.py index 773af2dead5c..66832541e5cc 100644 --- a/tools/test_settings.py +++ b/tools/test_settings.py @@ -47,6 +47,7 @@ "test_operational_space.py": 1000, "test_non_headless_launch.py": 1000, # This test launches the app in non-headless mode and starts simulation "test_rl_games_wrapper.py": 1000, + "test_rsl_rl_export_flow.py": 4000, "test_rsl_rl_wrapper.py": 1000, "test_sb3_wrapper.py": 1000, "test_skrl_wrapper.py": 1000, From 26cfddacfcf73d8f27b8898a22dac264bdc4ba56 Mon Sep 17 00:00:00 2001 From: r-schmitt <139814266+r-schmitt@users.noreply.github.com> Date: Sun, 3 May 2026 21:48:51 -0400 Subject: [PATCH 30/40] Decouple Renderer from Camera (#5297) # Description The renderer was previously held by the camera. this PR refactors this relationship, creating a RenderContext as a property of the SimulationContext to manage the renderer's lifetime independent of the camera(s) ## Type of change - refactor ## 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 updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Signed-off-by: r-schmitt <139814266+r-schmitt@users.noreply.github.com> Co-authored-by: nvsekkin <72572910+nvsekkin@users.noreply.github.com> --- CONTRIBUTORS.md | 1 + .../rschmitt_decouple_renderer_camera.rst | 33 +++ source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 5 +- .../isaaclab/isaaclab/renderers/__init__.pyi | 4 + .../isaaclab/renderers/base_renderer.py | 19 +- .../isaaclab/renderers/camera_render_spec.py | 37 ++++ .../isaaclab/renderers/render_context.py | 129 +++++++++++ .../isaaclab/scene/interactive_scene.py | 5 + .../isaaclab/sensors/camera/camera.py | 51 ++++- .../isaaclab/sim/simulation_context.py | 13 ++ .../test/renderers/test_renderer_factory.py | 2 +- .../test_simulation_render_context.py | 207 ++++++++++++++++++ .../rschmitt_decouple_rednerer_camera.rst | 4 + .../renderers/newton_warp_renderer.py | 21 +- .../rschmitt_decouple_renderer_camera.rst | 4 + .../isaaclab_ov/renderers/ovrtx_renderer.py | 54 ++--- .../renderers/ovrtx_renderer_cfg.py | 6 +- .../rschmitt_decouple_renderer_camera.rst | 4 + .../renderers/isaac_rtx_renderer.py | 79 ++++--- 20 files changed, 562 insertions(+), 118 deletions(-) create mode 100644 source/isaaclab/changelog.d/rschmitt_decouple_renderer_camera.rst create mode 100644 source/isaaclab/isaaclab/renderers/camera_render_spec.py create mode 100644 source/isaaclab/isaaclab/renderers/render_context.py create mode 100644 source/isaaclab/test/renderers/test_simulation_render_context.py create mode 100644 source/isaaclab_newton/changelog.d/rschmitt_decouple_rednerer_camera.rst create mode 100644 source/isaaclab_ov/changelog.d/rschmitt_decouple_renderer_camera.rst create mode 100644 source/isaaclab_physx/changelog.d/rschmitt_decouple_renderer_camera.rst diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 1e0a9ab35fc2..82c5eb49ba92 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -153,6 +153,7 @@ Guidelines for modifications: * Rafael Wiltz * Renaud Poncelet * René Zurbrügg +* Richard Schmitt * RinZ27 * Ritvik Singh * Rosario Scalise diff --git a/source/isaaclab/changelog.d/rschmitt_decouple_renderer_camera.rst b/source/isaaclab/changelog.d/rschmitt_decouple_renderer_camera.rst new file mode 100644 index 000000000000..337b7f55b6a3 --- /dev/null +++ b/source/isaaclab/changelog.d/rschmitt_decouple_renderer_camera.rst @@ -0,0 +1,33 @@ +Added +^^^^^ + +* Added :class:`~isaaclab.renderers.camera_render_spec.CameraRenderSpec` so render backends + take explicit camera inputs (USD paths, :class:`~isaaclab.sensors.camera.CameraCfg`, device, + counts) instead of the :class:`~isaaclab.sensors.camera.Camera` instance. +* Added :class:`~isaaclab.renderers.render_context.RenderContext` (accessed as + :attr:`~isaaclab.sim.simulation_context.SimulationContext.render_context`) to own one or + more :class:`~isaaclab.renderers.base_renderer.BaseRenderer` instances: configurations that + compare equal under ``==`` and share the same concrete + :class:`~isaaclab.renderers.renderer_cfg.RendererCfg` class reuse a backend; distinct + types (e.g. Isaac RTX and Newton) register separate backends, each with + :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.prepare_stage` the first time a camera + with that configuration initializes. +* Added :meth:`~isaaclab.renderers.render_context.RenderContext.render_into_camera` to run + :meth:`~isaaclab.renderers.render_context.RenderContext.update_transforms` (at most once + per physics step), then :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.render` and + :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.read_output`. +* Added :meth:`~isaaclab.sim.simulation_context.SimulationContext.get_physics_step_count`. + +Changed +^^^^^^^ + +* :class:`~isaaclab.sensors.camera.Camera` obtains a backend via + :meth:`~isaaclab.renderers.render_context.RenderContext.get_renderer` and calls + :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.create_render_data` with + a :class:`~isaaclab.renderers.camera_render_spec.CameraRenderSpec` (no + :class:`~isaaclab.sensors.sensor_base.SensorBase` reference on the public API). +* :class:`~isaaclab.scene.interactive_scene.InteractiveScene` calls + :meth:`~isaaclab.renderers.render_context.RenderContext.update_transforms` once at the start + of :meth:`~isaaclab.scene.interactive_scene.InteractiveScene.update` when + ``lazy_sensor_update`` is false; fetches that render still dedupe the same way via + ``physics_step_count`` in :class:`~isaaclab.renderers.render_context.RenderContext`. diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index fff3815f9228..95993d71590f 100644 --- a/source/isaaclab/config/extension.toml +++ b/source/isaaclab/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "4.6.27" +version = "4.6.28" # Description title = "Isaac Lab framework for Robot Learning" diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index 6f50ca262268..dbc2e07aa8f9 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,12 +1,10 @@ Changelog --------- + 4.6.27 (2026-05-01) ~~~~~~~~~~~~~~~~~~~ -Added -^^^^^ - * Added :class:`~isaaclab.sensors.JointWrenchSensor`. @@ -559,6 +557,7 @@ Changed Added ^^^^^ + * Added :class:`~isaaclab.sim.spawners.meshes.MeshSquareCfg` and :func:`~isaaclab.sim.spawners.meshes.spawn_mesh_square` for spawning 2D triangle mesh grids, used as surface deformable bodies (cloth). diff --git a/source/isaaclab/isaaclab/renderers/__init__.pyi b/source/isaaclab/isaaclab/renderers/__init__.pyi index 838cdb6e6bef..ae408c03ae7d 100644 --- a/source/isaaclab/isaaclab/renderers/__init__.pyi +++ b/source/isaaclab/isaaclab/renderers/__init__.pyi @@ -5,13 +5,17 @@ __all__ = [ "BaseRenderer", + "CameraRenderSpec", "RenderBufferKind", "RenderBufferSpec", "Renderer", "RendererCfg", + "RenderContext", ] from .base_renderer import BaseRenderer +from .camera_render_spec import CameraRenderSpec from .output_contract import RenderBufferKind, RenderBufferSpec from .renderer import Renderer from .renderer_cfg import RendererCfg +from .render_context import RenderContext diff --git a/source/isaaclab/isaaclab/renderers/base_renderer.py b/source/isaaclab/isaaclab/renderers/base_renderer.py index a8cba01ca6c6..2fc498eae8e3 100644 --- a/source/isaaclab/isaaclab/renderers/base_renderer.py +++ b/source/isaaclab/isaaclab/renderers/base_renderer.py @@ -10,12 +10,12 @@ from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Any +from .camera_render_spec import CameraRenderSpec from .output_contract import RenderBufferKind, RenderBufferSpec if TYPE_CHECKING: import torch - from isaaclab.sensors import SensorBase from isaaclab.sensors.camera.camera_data import CameraData @@ -35,11 +35,11 @@ def supported_output_types(self) -> dict[RenderBufferKind, RenderBufferSpec]: @abstractmethod def prepare_stage(self, stage: Any, num_envs: int) -> None: - """Prepare the stage for rendering before create_render_data is called. + """Prepare the stage for rendering before :meth:`create_render_data` is called. Some renderers need to export or preprocess the USD stage before creating render data. This method is called after the renderer is - instantiated and before create_render_data. + instantiated and before :meth:`create_render_data`. Args: stage: USD stage to prepare, or None if not applicable. @@ -48,19 +48,14 @@ def prepare_stage(self, stage: Any, num_envs: int) -> None: pass @abstractmethod - def create_render_data(self, sensor: SensorBase) -> Any: - """Create render data for the given sensor. - - The returned object is opaque to the interface: callers pass it to other - renderer methods without inspecting its contents. Its structure is - implementation-specific (each renderer defines its own type). + def create_render_data(self, spec: CameraRenderSpec) -> Any: + """Create render data for the given camera :class:`CameraRenderSpec`. Args: - sensor: The camera sensor to create render data for. + spec: Immutable description of the tiled camera (paths, config, device). Returns: - Renderer-specific data object holding resources needed for rendering. - Passed to subsequent render calls. + Renderer-specific data for subsequent :meth:`render` / :meth:`read_output` calls. """ pass diff --git a/source/isaaclab/isaaclab/renderers/camera_render_spec.py b/source/isaaclab/isaaclab/renderers/camera_render_spec.py new file mode 100644 index 000000000000..526b90121713 --- /dev/null +++ b/source/isaaclab/isaaclab/renderers/camera_render_spec.py @@ -0,0 +1,37 @@ +# 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 + +"""Immutable description of a tiled camera passed to render backends.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from isaaclab.sensors.camera.camera_cfg import CameraCfg + + +@dataclass(frozen=True) +class CameraRenderSpec: + """Stable inputs for :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.create_render_data`. + + Backends use this instead of holding a reference to the :class:`~isaaclab.sensors.camera.Camera` + sensor instance, avoiding circular dependencies between sensors and render data. + + Args: + cfg: Camera configuration (data types, resolution, filters, etc.). + device: Torch device string (e.g. ``"cuda:0"``) used by GPU annotators and Warp. + num_instances: Number of tiled camera instances (environments). + camera_prim_paths: Absolute USD paths for each environment's camera prim. + view_count: Number of camera prims (must match ``len(camera_prim_paths)``). + camera_path_relative_to_env_0: Camera prim path with ``/World/envs/env_0/`` prefix + stripped; required by OVRTX. Empty string if the first camera is not under env 0. + """ + + cfg: CameraCfg + device: str + num_instances: int + camera_prim_paths: tuple[str, ...] + view_count: int + camera_path_relative_to_env_0: str diff --git a/source/isaaclab/isaaclab/renderers/render_context.py b/source/isaaclab/isaaclab/renderers/render_context.py new file mode 100644 index 000000000000..1c1a45a19454 --- /dev/null +++ b/source/isaaclab/isaaclab/renderers/render_context.py @@ -0,0 +1,129 @@ +# 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 + +"""Simulation-scoped renderers for camera sensors.""" + +from __future__ import annotations + +import logging +from typing import Any, cast + +from isaaclab.sensors.camera.camera_data import CameraData + +from .base_renderer import BaseRenderer +from .renderer import Renderer +from .renderer_cfg import RendererCfg + +logger = logging.getLogger(__name__) + + +class RenderContext: + """Holds :class:`BaseRenderer` instances for all :class:`Camera` sensors in a simulation. + + A camera reuses a backend when a prior camera registered a config equal under ``==`` (value + equality) and the same concrete ``RendererCfg`` subclass. A distinct ``RendererCfg`` that + maps to a different implementation (e.g. Isaac RTX vs Newton) produces another backend; each + has :meth:`BaseRenderer.prepare_stage` run before use. + + :meth:`update_transforms` is invoked at most once per :meth:`get_physics_step_count` for the + context; + """ + + __slots__ = ( + "_renderer_entries", + "_prepared_renderer_ids", + "_prepared_num_envs", + "_last_transforms_step", + ) + + def __init__(self) -> None: + self._renderer_entries: list[tuple[RendererCfg, BaseRenderer]] = [] + self._prepared_renderer_ids: set[int] = set() + self._prepared_num_envs: int | None = None + self._last_transforms_step: int | None = None + + def get_renderer(self, cfg: RendererCfg) -> BaseRenderer: + """Return a backend for this configuration, reusing a matching instance if present. + + Lookups use ``==`` and concrete ``RendererCfg`` type, so :func:`hash` is not used (configs + are typically not hashable). + + Args: + cfg: Renderer configuration from the initializing camera. + + Returns: + A shared or newly created renderer backend. + """ + for stored_cfg, r in self._renderer_entries: + if type(stored_cfg) is type(cfg) and stored_cfg == cfg: + return r + new_renderer = cast(BaseRenderer, Renderer(cfg)) # type: ignore[misc] + self._renderer_entries.append((cfg, new_renderer)) + logger.info( + "Created new renderer for simulation: %s", + type(new_renderer).__name__, + ) + return new_renderer + + def ensure_prepare_stage(self, stage: Any, num_envs: int) -> None: + """Call :meth:`BaseRenderer.prepare_stage` for each registered backend (once per backend). + + If a new backend is added after the first :meth:`prepare_stage` call, this method ensures + that new backend is prepared for the same ``stage`` and ``num_envs`` when the camera + that owns it is initialized. + + Args: + stage: USD stage passed to each backend. + num_envs: Environment count. + + Raises: + RuntimeError: If :meth:`get_renderer` was never called, or ``num_envs`` disagrees with + a value already used for a prepared backend in this context. + """ + if not self._renderer_entries: + raise RuntimeError("get_renderer must be called at least once before ensure_prepare_stage.") + if self._prepared_num_envs is not None and self._prepared_num_envs != num_envs: + raise RuntimeError( + "RenderContext prepare_stage was used with a different num_envs " + f"({self._prepared_num_envs} vs {num_envs})." + ) + for _cfg, renderer in self._renderer_entries: + rid = id(renderer) + if rid not in self._prepared_renderer_ids: + renderer.prepare_stage(stage, num_envs) + self._prepared_renderer_ids.add(rid) + if self._prepared_num_envs is None: + self._prepared_num_envs = num_envs + + def update_transforms(self, physics_step_count: int) -> None: + """Call :meth:`BaseRenderer.update_transforms` on all backends (at most once per step).""" + if not self._renderer_entries: + return + if self._last_transforms_step == physics_step_count: + return + for _cfg, renderer in self._renderer_entries: + renderer.update_transforms() + self._last_transforms_step = physics_step_count + + def render_into_camera( + self, + renderer: BaseRenderer, + render_data: Any, + camera_data: CameraData, + physics_step_count: int, + ) -> None: + """Sync scene transforms, render, and read outputs into ``camera_data``.""" + self.update_transforms(physics_step_count) + renderer.render(render_data) + renderer.read_output(render_data, camera_data) + + def reset_stage_prepare_flag(self) -> None: + """Allow :meth:`ensure_prepare_stage` to run ``prepare_stage`` again (e.g. a new USD stage).""" + self._prepared_renderer_ids.clear() + self._prepared_num_envs = None + + def reset_transform_cadence(self) -> None: + """Clear per-step transform dedupe (e.g. a long pause with no physics).""" + self._last_transforms_step = None diff --git a/source/isaaclab/isaaclab/scene/interactive_scene.py b/source/isaaclab/isaaclab/scene/interactive_scene.py index 2aee730abb9a..95bd5f76027f 100644 --- a/source/isaaclab/isaaclab/scene/interactive_scene.py +++ b/source/isaaclab/isaaclab/scene/interactive_scene.py @@ -497,6 +497,11 @@ def update(self, dt: float) -> None: Args: dt: The amount of time passed from last :meth:`update` call. """ + # Scene-wide renderer transform sync once per step when all sensors update, + # so per-camera fetches do not own this concern (deduped inside RenderContext). + if not self.cfg.lazy_sensor_update: + self.sim.render_context.update_transforms(self.sim.get_physics_step_count()) + # -- assets for articulation in self._articulations.values(): articulation.update(dt) diff --git a/source/isaaclab/isaaclab/sensors/camera/camera.py b/source/isaaclab/isaaclab/sensors/camera/camera.py index db0dd4c760ac..6362cea8ce15 100644 --- a/source/isaaclab/isaaclab/sensors/camera/camera.py +++ b/source/isaaclab/isaaclab/sensors/camera/camera.py @@ -15,9 +15,11 @@ from pxr import UsdGeom +import isaaclab.sim as sim_utils import isaaclab.utils.sensors as sensor_utils from isaaclab.app.settings_manager import get_settings_manager -from isaaclab.renderers import BaseRenderer, Renderer +from isaaclab.renderers import BaseRenderer +from isaaclab.renderers.camera_render_spec import CameraRenderSpec from isaaclab.sim.views import FrameView from isaaclab.utils import to_camel_case from isaaclab.utils.math import ( @@ -379,7 +381,8 @@ def reset(self, env_ids: Sequence[int] | None = None, env_mask: wp.array | None def _initialize_impl(self): """Initializes the sensor handles and internal buffers. - This function creates a :class:`~isaaclab.renderers.Renderer` from the configured + This function obtains the simulation-scoped :class:`~isaaclab.renderers.base_renderer.BaseRenderer` + from :attr:`~isaaclab.sim.simulation_context.SimulationContext.render_context` using the configured :attr:`~isaaclab.sensors.camera.CameraCfg.renderer_cfg` and delegates all render-product and annotator management to it. It also initializes the internal buffers to store the data. @@ -392,12 +395,15 @@ def _initialize_impl(self): # Initialize parent class super()._initialize_impl() - self._renderer = Renderer(self.cfg.renderer_cfg) + sim_ctx = sim_utils.SimulationContext.instance() + if sim_ctx is None: + raise RuntimeError("SimulationContext is not initialized.") + self._renderer = sim_ctx.render_context.get_renderer(self.cfg.renderer_cfg) logger.info("Using renderer: %s", type(self._renderer).__name__) # Stage preprocessing must happen before creating the view because the view keeps # references to prims located in the stage. - self._renderer.prepare_stage(self.stage, self._num_envs) + sim_ctx.render_context.ensure_prepare_stage(self.stage, self._num_envs) # Create a view for the sensor with Fabric enabled for fast pose queries. # TODO: remove sync_usd_on_fabric_write=True once the GPU Fabric sync bug is fixed. @@ -425,7 +431,21 @@ def _initialize_impl(self): self._sensor_prims.append(UsdGeom.Camera(cam_prim)) # View needs to exist before creating render data - self._render_data = self._renderer.create_render_data(self) + cam_paths = tuple(cam_prim.GetPath().pathString for cam_prim in self._view.prims) + env_0_prefix = "/World/envs/env_0/" + rel_under_env0 = ( + cam_paths[0].removeprefix(env_0_prefix) if cam_paths and cam_paths[0].startswith(env_0_prefix) else "" + ) + device_str = self._device if isinstance(self._device, str) else str(self._device) + render_spec = CameraRenderSpec( + cfg=self.cfg, + device=device_str, + num_instances=self.num_instances, + camera_prim_paths=cam_paths, + view_count=self._view.count, + camera_path_relative_to_env_0=rel_under_env0, + ) + self._render_data = self._renderer.create_render_data(render_spec) # Create internal buffers (includes intrinsic matrix and pose init) self._create_buffers() @@ -440,10 +460,19 @@ def _update_buffers_impl(self, env_mask: wp.array): if self.cfg.update_latest_camera_pose: self._update_poses(env_ids) - self._renderer.update_transforms() - self._renderer.render(self._render_data) - - self._renderer.read_output(self._render_data, self._data) + sim_ctx = sim_utils.SimulationContext.instance() + renderer = self._renderer + assert renderer is not None + if sim_ctx is not None: + sim_ctx.render_context.render_into_camera( + renderer, + self._render_data, + self._data, + sim_ctx.get_physics_step_count(), + ) + else: + renderer.render(self._render_data) + renderer.read_output(self._render_data, self._data) """ Private Helpers @@ -572,6 +601,10 @@ def _update_poses(self, env_ids: Sequence[int]): def _invalidate_initialize_callback(self, event): """Invalidates the scene elements.""" + if self._renderer is not None and self._render_data is not None: + self._renderer.cleanup(self._render_data) + self._render_data = None + self._renderer = None # call parent super()._invalidate_initialize_callback(event) # set all existing views to None to invalidate them diff --git a/source/isaaclab/isaaclab/sim/simulation_context.py b/source/isaaclab/isaaclab/sim/simulation_context.py index 84f8b24f5990..4fe97648a063 100644 --- a/source/isaaclab/isaaclab/sim/simulation_context.py +++ b/source/isaaclab/isaaclab/sim/simulation_context.py @@ -28,6 +28,7 @@ VisualizerPrebuiltArtifacts, resolve_scene_data_requirements, ) +from isaaclab.renderers.render_context import RenderContext from isaaclab.sim.utils import create_new_stage from isaaclab.utils.string import clear_resolve_matching_names_cache from isaaclab.utils.version import has_kit @@ -196,6 +197,9 @@ def __init__(self, cfg: SimulationCfg | None = None): # render/reset transitions that occur without advancing physics steps. self._render_generation: int = 0 + # Shared renderers for all Camera sensors (compatible renderer_cfg only). + self._render_context = RenderContext() + type(self)._instance = self # Mark as valid singleton only after successful init def _apply_render_cfg_settings(self) -> None: @@ -373,6 +377,15 @@ def get_physics_dt(self) -> float: """Returns the physics time step.""" return self.physics_manager.get_physics_dt() + def get_physics_step_count(self) -> int: + """Return the monotonic physics step counter (incremented each :meth:`step`).""" + return self._physics_step_count + + @property + def render_context(self) -> RenderContext: + """Shared :class:`~isaaclab.renderers.render_context.RenderContext` for camera renderers.""" + return self._render_context + @property def render_generation(self) -> int: """Returns a monotonic counter for render() executions.""" diff --git a/source/isaaclab/test/renderers/test_renderer_factory.py b/source/isaaclab/test/renderers/test_renderer_factory.py index 51dc7f0a075a..66bd76fba598 100644 --- a/source/isaaclab/test/renderers/test_renderer_factory.py +++ b/source/isaaclab/test/renderers/test_renderer_factory.py @@ -34,7 +34,7 @@ def supported_output_types(self): def prepare_stage(self, stage, num_envs): pass - def create_render_data(self, sensor): + def create_render_data(self, spec): return None def set_outputs(self, render_data, output_data): diff --git a/source/isaaclab/test/renderers/test_simulation_render_context.py b/source/isaaclab/test/renderers/test_simulation_render_context.py new file mode 100644 index 000000000000..905643eefed1 --- /dev/null +++ b/source/isaaclab/test/renderers/test_simulation_render_context.py @@ -0,0 +1,207 @@ +# 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 :class:`~isaaclab.renderers.render_context.RenderContext`.""" + +from __future__ import annotations + +from collections.abc import Generator +from typing import Any, cast +from unittest.mock import patch + +import pytest +import torch + +from isaaclab.renderers.base_renderer import BaseRenderer +from isaaclab.renderers.output_contract import RenderBufferKind, RenderBufferSpec +from isaaclab.renderers.render_context import RenderContext +from isaaclab.renderers.renderer_cfg import RendererCfg +from isaaclab.sensors.camera.camera_data import CameraData + +pytest.importorskip("isaaclab_physx") +pytest.importorskip("isaaclab_newton") +pytest.importorskip("isaaclab_ov") + +from isaaclab_newton.renderers import NewtonWarpRendererCfg +from isaaclab_physx.renderers import IsaacRtxRendererCfg + + +class _FakeBackend(BaseRenderer): + """Test double for :class:`BaseRenderer`; does not load PhysX/Newton/OV renderer classes.""" + + __slots__ = ("_prepare_hits", "_update_transforms_hits", "_event_log") + + def __init__( + self, + *, + prepare_hits: list[int] | None = None, + update_transforms_hits: list[int] | None = None, + event_log: list[str] | None = None, + ) -> None: + super().__init__() + self._prepare_hits = prepare_hits + self._update_transforms_hits = update_transforms_hits + self._event_log = event_log + + def supported_output_types(self) -> dict[RenderBufferKind, RenderBufferSpec]: + return {} + + def prepare_stage(self, stage: Any, num_envs: int) -> None: + if self._prepare_hits is not None: + self._prepare_hits.append(1) + + def create_render_data(self, spec: Any) -> Any: + return object() + + def set_outputs(self, render_data: Any, output_data: dict[str, torch.Tensor]) -> None: + pass + + def update_transforms(self) -> None: + if self._update_transforms_hits is not None: + self._update_transforms_hits.append(1) + if self._event_log is not None: + self._event_log.append("ut") + + def update_camera( + self, + render_data: Any, + positions: torch.Tensor, + orientations: torch.Tensor, + intrinsics: torch.Tensor, + ) -> None: + pass + + def render(self, render_data: Any) -> None: + if self._event_log is not None: + self._event_log.append("render") + + def read_output(self, render_data: Any, camera_data: CameraData) -> None: + if self._event_log is not None: + self._event_log.append("read") + + def cleanup(self, render_data: Any) -> None: + pass + + +def _set_entries(ctx: RenderContext, *cfg_backend_pairs: tuple[RendererCfg, BaseRenderer]) -> None: + ctx._renderer_entries = list(cfg_backend_pairs) # type: ignore[assignment] # noqa: SLF001 + + +@pytest.fixture(autouse=True) +def _patch_renderer_factory() -> Generator[None, None, None]: + """Never construct :class:`~isaaclab.renderers.renderer.Renderer` (real backends) in this module.""" + + with patch( + "isaaclab.renderers.render_context.Renderer", + side_effect=lambda *_args, **_kwargs: _FakeBackend(), + ): + yield + + +def test_get_renderer_returns_equal_cfg_singleton(): + ctx = RenderContext() + cfg = IsaacRtxRendererCfg() + r1 = ctx.get_renderer(cfg) + r2 = ctx.get_renderer(cfg) + assert r1 is r2 + + +def test_get_renderer_two_different_concrete_types_coexist(): + """Different renderer_cfg concrete classes register distinct backends (no error).""" + + ctx = RenderContext() + rtx = ctx.get_renderer(IsaacRtxRendererCfg()) + nw = ctx.get_renderer(NewtonWarpRendererCfg()) + assert rtx is not nw + + +def test_ensure_prepare_stage_idempotent(): + """Second ``ensure_prepare_stage`` with same args does not call ``prepare_stage`` again.""" + + ctx = RenderContext() + prepares: list[int] = [] + cfg = IsaacRtxRendererCfg() + _set_entries(ctx, (cfg, _FakeBackend(prepare_hits=prepares))) + + ctx.ensure_prepare_stage(None, 4) + ctx.ensure_prepare_stage(None, 4) + assert len(prepares) == 1 + + +def test_ensure_prepare_stage_num_envs_mismatch(): + ctx = RenderContext() + cfg = IsaacRtxRendererCfg() + _set_entries(ctx, (cfg, _FakeBackend())) + + ctx.ensure_prepare_stage(None, 4) + with pytest.raises(RuntimeError, match="different num_envs"): + ctx.ensure_prepare_stage(None, 8) + + +def test_update_transforms_dedupes_per_physics_step(): + """All backends' update_transforms run once per physics step index.""" + + ctx = RenderContext() + hits: list[int] = [] + cfg = NewtonWarpRendererCfg() + _set_entries(ctx, (cfg, _FakeBackend(update_transforms_hits=hits))) + + ctx.update_transforms(1) + ctx.update_transforms(1) + assert len(hits) == 1 + + ctx.update_transforms(2) + assert len(hits) == 2 + + +def test_render_into_camera_calls_update_render_read_order(): + """render_into_camera runs update_transforms then render then read_output; dedupes UT per step.""" + ctx = RenderContext() + events: list[str] = [] + cfg = IsaacRtxRendererCfg() + fake = _FakeBackend(event_log=events) + _set_entries(ctx, (cfg, fake)) + + rd = object() + cam_data = CameraData() + ctx.render_into_camera(cast(BaseRenderer, fake), rd, cam_data, physics_step_count=1) + assert events == ["ut", "render", "read"] + + ctx.render_into_camera(cast(BaseRenderer, fake), rd, cam_data, physics_step_count=1) + assert events == ["ut", "render", "read", "render", "read"] + + +def test_reset_stage_prepare_flag_allows_second_prepare_stage(): + """After reset_stage_prepare_flag, ensure_prepare_stage invokes prepare_stage again.""" + ctx = RenderContext() + prepares: list[int] = [] + cfg = IsaacRtxRendererCfg() + _set_entries(ctx, (cfg, _FakeBackend(prepare_hits=prepares))) + + ctx.ensure_prepare_stage(None, 4) + assert len(prepares) == 1 + ctx.ensure_prepare_stage(None, 4) + assert len(prepares) == 1 + + ctx.reset_stage_prepare_flag() + ctx.ensure_prepare_stage(None, 4) + assert len(prepares) == 2 + + +def test_reset_transform_cadence_allows_repeat_update_transforms_same_step(): + """reset_transform_cadence clears step dedupe so the same physics_step_count can sync again.""" + ctx = RenderContext() + hits: list[int] = [] + cfg = IsaacRtxRendererCfg() + _set_entries(ctx, (cfg, _FakeBackend(update_transforms_hits=hits))) + + ctx.update_transforms(1) + assert len(hits) == 1 + ctx.update_transforms(1) + assert len(hits) == 1 + + ctx.reset_transform_cadence() + ctx.update_transforms(1) + assert len(hits) == 2 diff --git a/source/isaaclab_newton/changelog.d/rschmitt_decouple_rednerer_camera.rst b/source/isaaclab_newton/changelog.d/rschmitt_decouple_rednerer_camera.rst new file mode 100644 index 000000000000..1c3efcb5c33e --- /dev/null +++ b/source/isaaclab_newton/changelog.d/rschmitt_decouple_rednerer_camera.rst @@ -0,0 +1,4 @@ +Changed +^^^^^^^^ + +* Modified the newton renderer to use the new patterns from renderer/camera decoupling. diff --git a/source/isaaclab_newton/isaaclab_newton/renderers/newton_warp_renderer.py b/source/isaaclab_newton/isaaclab_newton/renderers/newton_warp_renderer.py index 3fb198faf53d..a02d820f2951 100644 --- a/source/isaaclab_newton/isaaclab_newton/renderers/newton_warp_renderer.py +++ b/source/isaaclab_newton/isaaclab_newton/renderers/newton_warp_renderer.py @@ -8,7 +8,6 @@ from __future__ import annotations import logging -import weakref from dataclasses import dataclass from typing import TYPE_CHECKING, Any @@ -17,6 +16,7 @@ import warp as wp from isaaclab.renderers import BaseRenderer, RenderBufferKind, RenderBufferSpec +from isaaclab.renderers.camera_render_spec import CameraRenderSpec from isaaclab.sim import SimulationContext from isaaclab.utils.math import convert_camera_frame_orientation_convention @@ -24,7 +24,6 @@ if TYPE_CHECKING: from isaaclab.physics import BaseSceneDataProvider - from isaaclab.sensors import SensorBase from isaaclab.sensors.camera.camera_data import CameraData logger = logging.getLogger(__name__) @@ -42,21 +41,16 @@ class CameraOutputs: normals_image: wp.array(dtype=wp.vec3f, ndim=4) = None instance_segmentation_image: wp.array(dtype=wp.uint32, ndim=4) = None - def __init__(self, newton_sensor: newton.sensors.SensorTiledCamera, sensor: SensorBase): + def __init__(self, newton_sensor: newton.sensors.SensorTiledCamera, spec: CameraRenderSpec): self.newton_sensor = newton_sensor - # Currently camera owns the renderer and render data. By holding full - # reference of the sensor, we create a circular reference between the - # sensor and the render data. Weak reference ensures proper garbage - # collection. - self.sensor = weakref.ref(sensor) self.num_cameras = 1 self.camera_rays: wp.array(dtype=wp.vec3f, ndim=4) = None self.camera_transforms: wp.array(dtype=wp.transformf, ndim=2) = None self.outputs = RenderData.CameraOutputs() - self.width = getattr(sensor.cfg, "width", 100) - self.height = getattr(sensor.cfg, "height", 100) + self.width = getattr(spec.cfg, "width", 100) + self.height = getattr(spec.cfg, "height", 100) def set_outputs(self, output_data: dict[str, torch.Tensor]): for output_name, tensor_data in output_data.items(): @@ -203,10 +197,10 @@ def prepare_stage(self, stage: Any, num_envs: int) -> None: See :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.prepare_stage`.""" pass - def create_render_data(self, sensor: SensorBase) -> RenderData: + def create_render_data(self, spec: CameraRenderSpec) -> RenderData: """Create render data for the Newton tiled camera. See :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.create_render_data`.""" - return RenderData(self.newton_sensor, sensor) + return RenderData(self.newton_sensor, spec) def set_outputs(self, render_data: RenderData, output_data: dict[str, torch.Tensor]): """Store output buffers. See :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.set_outputs`.""" @@ -254,8 +248,7 @@ def read_output(self, render_data: RenderData, camera_data: CameraData) -> None: def cleanup(self, render_data: RenderData | None): """Release resources. No-op for Newton Warp. See :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.cleanup`.""" - if render_data: - render_data.sensor = None + pass def get_scene_data_provider(self) -> BaseSceneDataProvider: return SimulationContext.instance().initialize_scene_data_provider() diff --git a/source/isaaclab_ov/changelog.d/rschmitt_decouple_renderer_camera.rst b/source/isaaclab_ov/changelog.d/rschmitt_decouple_renderer_camera.rst new file mode 100644 index 000000000000..1de2259dc2c3 --- /dev/null +++ b/source/isaaclab_ov/changelog.d/rschmitt_decouple_renderer_camera.rst @@ -0,0 +1,4 @@ +Changed +^^^^^^^^ + +* Modified the OVRTX renderer to use the new patterns from renderer/camera decoupling. diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py index 4111ddea47fc..422ecec2f1e7 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py @@ -22,7 +22,6 @@ import logging import math import os -import weakref from typing import TYPE_CHECKING, Any logger = logging.getLogger(__name__) @@ -62,9 +61,9 @@ ) if TYPE_CHECKING: - from isaaclab.sensors import SensorBase from isaaclab.sensors.camera.camera_data import CameraData +from isaaclab.renderers.camera_render_spec import CameraRenderSpec # Shared integration floor for this module; reuse for ovrtx features that share one support floor. _OVRTX_VERSION = Version(ovrtx.__version__) @@ -108,19 +107,14 @@ def _resolve_rtx_minimal_mode(data_types: list[str]) -> int | None: class OVRTXRenderData: - """OVRTX-specific RenderData. Holds warp output buffers and a weakref to the sensor. - - The sensor is stored as a weakref to avoid a Sensor ↔ RenderData reference cycle - (the sensor already owns this object). - """ - - def __init__(self, sensor: SensorBase, device): - """Create render data from sensor. Holds weak ref to avoid circular reference.""" - self.sensor: weakref.ref[object] | None = weakref.ref(sensor) - self.width = sensor.cfg.width - self.height = sensor.cfg.height - self.num_envs = sensor.num_instances - self.data_types = sensor.cfg.data_types if sensor.cfg.data_types else ["rgb"] + """OVRTX-specific RenderData. Holds warp output buffers sized from :class:`CameraRenderSpec`.""" + + def __init__(self, spec: CameraRenderSpec, device): + """Create render data from a camera render specification.""" + self.width = spec.cfg.width + self.height = spec.cfg.height + self.num_envs = spec.num_instances + self.data_types = spec.cfg.data_types if spec.cfg.data_types else ["rgb"] self.num_cols = math.ceil(math.sqrt(self.num_envs)) self.num_rows = math.ceil(self.num_envs / self.num_cols) self.warp_buffers: dict[str, wp.array] = {} @@ -159,7 +153,6 @@ def __init__(self, cfg: OVRTXRendererCfg): self._object_binding = None self._object_newton_indices: wp.array | None = None self._initialized_scene = False - self._sensor_ref: weakref.ref[object] | None = None self._exported_usd_path: str | None = None self._camera_rel_path: str | None = None self._output_semantic_color_buffer: wp.array | None = None @@ -183,25 +176,22 @@ def prepare_stage(self, stage: Any, num_envs: int) -> None: self._exported_usd_path = export_path logger.info("Exported to %s", export_path) - def initialize(self, sensor: SensorBase): + def initialize(self, spec: CameraRenderSpec): """Initialize the OVRTX renderer with internal environment cloning. - Args: - sensor: The Camera sensor. width, height, num_envs, data_types are - obtained from sensor when needed. Weak ref stored to avoid circular ref. + spec: Tiled camera description (resolution, paths, data types). """ - self._sensor_ref = weakref.ref(sensor) - width = sensor.cfg.width - height = sensor.cfg.height - num_envs = sensor.num_instances - data_types = sensor.cfg.data_types if sensor.cfg.data_types else ["rgb"] + width = spec.cfg.width + height = spec.cfg.height + num_envs = spec.num_instances + data_types = spec.cfg.data_types if spec.cfg.data_types else ["rgb"] env_0_prefix = "/World/envs/env_0/" - first_cam_path = sensor._view.prims[0].GetPath().pathString + first_cam_path = spec.camera_prim_paths[0] if not first_cam_path.startswith(env_0_prefix): raise RuntimeError(f"Expected camera prim under '{env_0_prefix}', got '{first_cam_path}'") - self._camera_rel_path = first_cam_path.removeprefix(env_0_prefix) + self._camera_rel_path = spec.camera_path_relative_to_env_0 usd_scene_path = self._exported_usd_path use_cloning = self.cfg.use_cloning @@ -363,16 +353,15 @@ def _setup_object_bindings(self): except Exception as e: logger.warning("Error setting up object bindings: %s", e) - def create_render_data(self, sensor: SensorBase) -> OVRTXRenderData: + def create_render_data(self, spec: CameraRenderSpec) -> OVRTXRenderData: """Create OVRTX-specific RenderData with GPU buffers. Performs OVRTX initialization (stage export, USD load, bindings) on first call, matching the interface of Isaac RTX and Newton Warp which need no separate initialize(). - RenderData holds weak ref to sensor (Newton pattern) to avoid circular reference. """ if not self._initialized_scene: - self.initialize(sensor) - return OVRTXRenderData(sensor, DEVICE) + self.initialize(spec) + return OVRTXRenderData(spec, DEVICE) # Map torch dtypes to their warp counterparts for zero-copy wrapping. _TORCH_TO_WP_DTYPE: dict[torch.dtype, Any] = { @@ -632,9 +621,6 @@ def render(self, render_data: OVRTXRenderData) -> None: def cleanup(self, render_data: OVRTXRenderData | None) -> None: """Release renderer resources. See :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.cleanup`.""" - if render_data is not None: - render_data.sensor = None # Break weak ref (Newton pattern) - self._sensor_ref = None # Unbind before tearing down renderer def _safe_unbind(binding, name: str) -> None: 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 2461d6932fc9..9c26d3c79bf1 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_cfg.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_cfg.py @@ -17,8 +17,10 @@ class OVRTXRendererCfg(RendererCfg): """Configuration for OVRTX Renderer. The OVRTX renderer uses the ovrtx library for high-fidelity RTX-based rendering. - width, height, num_envs, and data_types are obtained from the sensor when - create_render_data() is called (same pattern as Isaac RTX). + width, height, num_envs, and data_types are obtained from the + :class:`~isaaclab.renderers.camera_render_spec.CameraRenderSpec` when + :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.create_render_data` is called + (same pattern as Isaac RTX). """ renderer_type: str = "ovrtx" diff --git a/source/isaaclab_physx/changelog.d/rschmitt_decouple_renderer_camera.rst b/source/isaaclab_physx/changelog.d/rschmitt_decouple_renderer_camera.rst new file mode 100644 index 000000000000..eada0bbb809c --- /dev/null +++ b/source/isaaclab_physx/changelog.d/rschmitt_decouple_renderer_camera.rst @@ -0,0 +1,4 @@ +Changed +^^^^^^^^ + +* Modified the isaac rtx renderer to use the new patterns from renderer/camera decoupling. 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 246ab5a9fe33..242ac3729d0b 100644 --- a/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py +++ b/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py @@ -10,7 +10,6 @@ import json import logging import math -import weakref from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any @@ -23,6 +22,7 @@ from isaaclab.app.settings_manager import get_settings_manager from isaaclab.renderers import BaseRenderer, RenderBufferKind, RenderBufferSpec +from isaaclab.renderers.camera_render_spec import CameraRenderSpec from isaaclab.utils.version import get_isaac_sim_version from isaaclab.utils.warp.kernels import reshape_tiled_image @@ -31,10 +31,9 @@ logger = logging.getLogger(__name__) if TYPE_CHECKING: - from isaaclab.sensors import SensorBase from isaaclab.sensors.camera.camera_data import CameraData - from .isaac_rtx_renderer_cfg import IsaacRtxRendererCfg +from .isaac_rtx_renderer_cfg import IsaacRtxRendererCfg # RTX simple-shading constants. # @@ -74,7 +73,7 @@ class IsaacRtxRenderData: annotators: dict[str, Any] render_product_paths: list[str] output_data: dict[str, torch.Tensor] | None = None - sensor: SensorBase | None = None + spec: CameraRenderSpec | None = None renderer_info: dict[str, Any] = field(default_factory=dict) @@ -137,28 +136,30 @@ def prepare_stage(self, stage: Any, num_envs: int) -> None: See :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.prepare_stage`.""" pass - def create_render_data(self, sensor: SensorBase) -> IsaacRtxRenderData: + def create_render_data(self, spec: CameraRenderSpec) -> IsaacRtxRenderData: """Create render product and annotators for the tiled camera. See :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.create_render_data`.""" import omni.replicator.core as rep from omni.syntheticdata import SyntheticData from pxr import UsdGeom + from isaaclab.sim.utils.stage import get_current_stage + settings = get_settings_manager() isaac_sim_version = get_isaac_sim_version() if isaac_sim_version.major >= 6: - needs_color_render = "rgb" in sensor.cfg.data_types or "rgba" in sensor.cfg.data_types + needs_color_render = "rgb" in spec.cfg.data_types or "rgba" in spec.cfg.data_types if not needs_color_render: settings.set_bool("/rtx/sdg/force/disableColorRender", True) if settings.get("/isaaclab/has_gui"): settings.set_bool("/rtx/sdg/force/disableColorRender", False) else: - if "albedo" in sensor.cfg.data_types: + if "albedo" in spec.cfg.data_types: logger.warning( "Albedo annotator is only supported in Isaac Sim 6.0+. The albedo data type will be ignored." ) - if any(dt in SIMPLE_SHADING_MODES for dt in sensor.cfg.data_types): + if any(dt in SIMPLE_SHADING_MODES for dt in spec.cfg.data_types): logger.warning( "Simple shading annotators are only supported in Isaac Sim 6.0+." " The simple shading data types will be ignored." @@ -166,8 +167,9 @@ def create_render_data(self, sensor: SensorBase) -> IsaacRtxRenderData: # HACK: Isaac Sim 4.5 has a bug in Camera that breaks segmentation # outputs for instanceable assets. Disable instancing as a workaround. + stage = get_current_stage() if isaac_sim_version == version.parse("4.5") and ( - "semantic_segmentation" in sensor.cfg.data_types or "instance_segmentation_fast" in sensor.cfg.data_types + "semantic_segmentation" in spec.cfg.data_types or "instance_segmentation_fast" in spec.cfg.data_types ): logger.warning( "Isaac Sim 4.5 introduced a bug in Camera when outputting instance and semantic" @@ -176,22 +178,18 @@ def create_render_data(self, sensor: SensorBase) -> IsaacRtxRenderData: " usage." ) with Sdf.ChangeBlock(): - for prim in sensor.stage.Traverse(): + for prim in stage.Traverse(): prim.SetInstanceable(False) # Get camera prim paths from sensor view - view = sensor._view - cam_prim_paths = [] - for cam_prim in view.prims: - cam_prim_path = cam_prim.GetPath().pathString + cam_prim_paths = list(spec.camera_prim_paths) + for cam_prim_path in cam_prim_paths: + cam_prim = stage.GetPrimAtPath(cam_prim_path) if not cam_prim.IsA(UsdGeom.Camera): raise RuntimeError(f"Prim at path '{cam_prim_path}' is not a Camera.") - cam_prim_paths.append(cam_prim_path) # Create replicator tiled render product - rp = rep.create.render_product_tiled( - cameras=cam_prim_paths, tile_resolution=(sensor.cfg.width, sensor.cfg.height) - ) + rp = rep.create.render_product_tiled(cameras=cam_prim_paths, tile_resolution=(spec.cfg.width, spec.cfg.height)) render_product_paths = [rp.path] # Synthetic-data instance mapping filter for segmentation; before annotator attach. @@ -200,20 +198,20 @@ def create_render_data(self, sensor: SensorBase) -> IsaacRtxRenderData: ) # Register simple shading if needed - if any(data_type in SIMPLE_SHADING_MODES for data_type in sensor.cfg.data_types): + if any(data_type in SIMPLE_SHADING_MODES for data_type in spec.cfg.data_types): rep.AnnotatorRegistry.register_annotator_from_aov( aov=SIMPLE_SHADING_AOV, output_data_type=np.uint8, output_channels=4 ) # Set simple shading mode (if requested) before rendering - simple_shading_mode = self._resolve_simple_shading_mode(sensor) + simple_shading_mode = self._resolve_simple_shading_mode(spec) if simple_shading_mode is not None: get_settings_manager().set_int(SIMPLE_SHADING_MODE_SETTING, simple_shading_mode) # Define annotators based on requested data types annotators = {} - for annotator_type in sensor.cfg.data_types: + for annotator_type in spec.cfg.data_types: if annotator_type == "rgba" or annotator_type == "rgb": - annotator = rep.AnnotatorRegistry.get_annotator("rgb", device=sensor.device, do_array_copy=False) + annotator = rep.AnnotatorRegistry.get_annotator("rgb", device=spec.device, do_array_copy=False) annotators["rgba"] = annotator elif annotator_type == "albedo": # TODO: this is a temporary solution because replicator has not exposed the annotator yet @@ -222,18 +220,18 @@ def create_render_data(self, sensor: SensorBase) -> IsaacRtxRenderData: aov="DiffuseAlbedoSD", output_data_type=np.uint8, output_channels=4 ) annotator = rep.AnnotatorRegistry.get_annotator( - "DiffuseAlbedoSD", device=sensor.device, do_array_copy=False + "DiffuseAlbedoSD", device=spec.device, do_array_copy=False ) annotators["albedo"] = annotator elif annotator_type in SIMPLE_SHADING_MODES: annotator = rep.AnnotatorRegistry.get_annotator( - SIMPLE_SHADING_AOV, device=sensor.device, do_array_copy=False + SIMPLE_SHADING_AOV, device=spec.device, do_array_copy=False ) annotators[annotator_type] = annotator elif annotator_type == "depth" or annotator_type == "distance_to_image_plane": # keep depth for backwards compatibility annotator = rep.AnnotatorRegistry.get_annotator( - "distance_to_image_plane", device=sensor.device, do_array_copy=False + "distance_to_image_plane", device=spec.device, do_array_copy=False ) annotators[annotator_type] = annotator # note: we are verbose here to make it easier to understand the code. @@ -252,7 +250,7 @@ def create_render_data(self, sensor: SensorBase) -> IsaacRtxRenderData: init_params = {"colorize": self.cfg.colorize_instance_id_segmentation} annotator = rep.AnnotatorRegistry.get_annotator( - annotator_type, init_params, device=sensor.device, do_array_copy=False + annotator_type, init_params, device=spec.device, do_array_copy=False ) annotators[annotator_type] = annotator @@ -260,19 +258,15 @@ def create_render_data(self, sensor: SensorBase) -> IsaacRtxRenderData: for annotator in annotators.values(): annotator.attach(render_product_paths) - # Currently camera owns the renderer and render data. By holding full - # reference of the sensor, we create a circular reference between the - # sensor and the render data. Weak reference ensures proper garbage - # collection. return IsaacRtxRenderData( annotators=annotators, render_product_paths=render_product_paths, - sensor=weakref.ref(sensor), + spec=spec, ) - def _resolve_simple_shading_mode(self, sensor: SensorBase) -> int | None: + def _resolve_simple_shading_mode(self, spec: CameraRenderSpec) -> int | None: """Resolve the requested simple shading mode from data types.""" - requested = [dt for dt in sensor.cfg.data_types if dt in SIMPLE_SHADING_MODES] + requested = [dt for dt in spec.cfg.data_types if dt in SIMPLE_SHADING_MODES] if not requested: return None if len(requested) > 1: @@ -307,9 +301,9 @@ def update_camera( def render(self, render_data: IsaacRtxRenderData): """Extract data from annotators and write to output buffers. See :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.render`.""" - sensor = render_data.sensor() if render_data.sensor else None + spec = render_data.spec output_data = render_data.output_data - if output_data is None or sensor is None: + if output_data is None or spec is None: return # Ensure the RTX renderer has been pumped so annotator buffers are fresh. @@ -317,8 +311,9 @@ def render(self, render_data: IsaacRtxRenderData): # for the current physics step, or if a visualizer already pumped it. ensure_isaac_rtx_render_update() - view_count = sensor._view.count - cfg = sensor.cfg + view_count = spec.view_count + cfg = spec.cfg + device = spec.device def tiling_grid_shape(): cols = math.ceil(math.sqrt(view_count)) @@ -341,9 +336,9 @@ def tiling_grid_shape(): if isinstance(tiled_data_buffer, np.ndarray): # Let warp infer the dtype from numpy array instead of hardcoding uint8 # Different annotators return different dtypes: RGB(uint8), depth(float32), segmentation(uint32) - tiled_data_buffer = wp.array(tiled_data_buffer, device=sensor.device) + tiled_data_buffer = wp.array(tiled_data_buffer, device=device) else: - tiled_data_buffer = tiled_data_buffer.to(device=sensor.device) + tiled_data_buffer = tiled_data_buffer.to(device=device) # process data for different segmentation types # Note: Replicator returns raw buffers of dtype uint32 for segmentation types @@ -354,7 +349,7 @@ def tiling_grid_shape(): or (data_type == "instance_id_segmentation_fast" and self.cfg.colorize_instance_id_segmentation) ): tiled_data_buffer = wp.array( - ptr=tiled_data_buffer.ptr, shape=(*tiled_data_buffer.shape, 4), dtype=wp.uint8, device=sensor.device + ptr=tiled_data_buffer.ptr, shape=(*tiled_data_buffer.shape, 4), dtype=wp.uint8, device=device ) # For motion vectors, use specialized kernel that reads 4 channels but only writes 2 @@ -378,7 +373,7 @@ def tiling_grid_shape(): *list(output_data[data_type].shape[1:]), num_tiles_x, ], - device=sensor.device, + device=device, ) # alias rgb as first 3 channels of rgba @@ -414,4 +409,4 @@ def cleanup(self, render_data: IsaacRtxRenderData | None): if render_data: for annotator in render_data.annotators.values(): annotator.detach(render_data.render_product_paths) - render_data.sensor = None + render_data.spec = None From add60a161091198d3fc386cfbbad5a1a15ebd802 Mon Sep 17 00:00:00 2001 From: myurasov-nv <168484206+myurasov-nv@users.noreply.github.com> Date: Mon, 4 May 2026 12:27:29 -0700 Subject: [PATCH 31/40] Simplifies CI change detection and concurrency groups (#5466) CI cleanup that drops redundant aggregator gate jobs (skipped jobs already satisfy branch protection), fixes reporting of change-detection in the GitHub job summary, simplifies concurrency keys back to github.ref (run # was redundant). ## Type of change - Bug fix (non-breaking change which fixes an issue) ## Checklist - [x] I have read and understood the contribution guidelines - [x] I have run the pre-commit checks with `./isaaclab.sh --format` - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- .github/workflows/build.yaml | 184 +++++++++----------- .github/workflows/check-links.yml | 5 +- .github/workflows/docs.yaml | 2 +- .github/workflows/install-ci.yml | 118 +++++++++---- .github/workflows/labeler.yml | 4 - .github/workflows/license-check.yaml | 2 +- .github/workflows/pre-commit.yaml | 2 +- .github/workflows/test_required_ci_gates.py | 96 ---------- 8 files changed, 169 insertions(+), 244 deletions(-) delete mode 100644 .github/workflows/test_required_ci_gates.py diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index b2da2f9d1709..b971940fe00b 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -71,7 +71,7 @@ env: jobs: changes: - name: Detect Docker Test Changes + name: Detect Changes runs-on: ubuntu-latest outputs: run_docker_tests: ${{ steps.detect.outputs.run_docker_tests }} @@ -80,22 +80,96 @@ jobs: env: GH_TOKEN: ${{ github.token }} PR_NUMBER: ${{ github.event.pull_request.number }} + EVENT_NAME: ${{ github.event_name }} + REPO: ${{ github.repository }} run: | set -euo pipefail - if [ "${{ github.event_name }}" != "pull_request" ]; then - echo "run_docker_tests=true" >> "$GITHUB_OUTPUT" + # Docker test jobs run only when paths in the patterns table change. + # Otherwise they skip via `if:` and report green to branch protection, + # which is why we don't use a workflow-level `paths:` filter (a + # not-triggered required check would block the PR forever). + # config.yaml is included because it controls the base image names and + # tags consumed by the Docker build jobs. + patterns=( + $'^source/\tLibrary source code' + $'^docker/\tContainer build inputs' + $'^tools/\tBuild tooling' + $'^apps/\tStandalone apps' + $'^scripts/\tStandalone scripts' + $'^\\.github/workflows/build\\.yaml$\tThis workflow file' + $'^\\.github/workflows/config\\.yaml$\tBase image config' + $'^\\.github/actions/\tCI actions' + ) + triggered_jobs="Docker build + all test-* matrix jobs" + + render_table() { + local files="$1" entry regex desc count sample + echo "| Pattern | What it covers | Matched files |" + echo "|---|---|---|" + for entry in "${patterns[@]}"; do + IFS=$'\t' read -r regex desc <<< "$entry" + count=$(printf '%s\n' "$files" | grep -cE "$regex" || true) + if [ "$count" -gt 0 ]; then + sample=$(printf '%s\n' "$files" | grep -E "$regex" | head -3 | paste -sd ', ' -) + [ "$count" -gt 3 ] && sample="$sample (and $((count - 3)) more)" + echo "| \`$regex\` | $desc | $sample |" + else + echo "| \`$regex\` | $desc | - |" + fi + done + } + + any_match() { + local files="$1" entry regex + for entry in "${patterns[@]}"; do + IFS=$'\t' read -r regex _ <<< "$entry" + if printf '%s\n' "$files" | grep -qE "$regex"; then + return 0 + fi + done + return 1 + } + + decide() { + local decision="$1" reason="$2" files="${3:-}" + echo "Decision: run_docker_tests=$decision ($reason)" + echo "run_docker_tests=$decision" >> "$GITHUB_OUTPUT" + { + echo "## Docker test gating" + echo "" + if [ "$decision" = "true" ]; then + echo "Docker tests will **run**: $reason." + else + echo "Docker tests will be **skipped**: $reason." + fi + echo "" + echo "Triggered jobs: $triggered_jobs." + if [ -n "$files" ]; then + echo "" + render_table "$files" + fi + } >> "$GITHUB_STEP_SUMMARY" + } + + if [ "$EVENT_NAME" != "pull_request" ]; then + decide true "non-PR event ($EVENT_NAME)" + exit 0 + fi + + if ! changed_files="$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/files" --jq '.[].filename')"; then + # Fail-safe: a transient API error must not block merge. Default to running. + echo "::warning::Could not list changed files; defaulting to running tests" + decide true "fail-safe (could not list changed files)" exit 0 fi - changed_files="$(gh api --paginate "repos/${{ github.repository }}/pulls/${PR_NUMBER}/files" --jq '.[].filename')" printf '%s\n' "$changed_files" - # config.yaml controls the base image names and tags consumed by the Docker build jobs. - if printf '%s\n' "$changed_files" | grep -qE '^(source/|docker/|tools/|apps/|scripts/|\.github/workflows/build\.yaml$|\.github/workflows/config\.yaml$|\.github/actions/)'; then - echo "run_docker_tests=true" >> "$GITHUB_OUTPUT" + if any_match "$changed_files"; then + decide true "relevant paths changed" "$changed_files" else - echo "run_docker_tests=false" >> "$GITHUB_OUTPUT" + decide false "no relevant paths changed" "$changed_files" fi config: @@ -531,100 +605,6 @@ jobs: container-name: isaac-lab-environments-training-test #endregion - docker-tests-gate: - name: Docker Tests Gate - runs-on: ubuntu-latest - needs: - - changes - - build - - build-curobo - - test-isaaclab-tasks - - test-isaaclab-tasks-2 - - test-isaaclab-tasks-3 - - test-isaaclab-core - - test-isaaclab-core-2 - - test-isaaclab-core-3 - - test-isaaclab-rl - - test-isaaclab-mimic - - test-isaaclab-assets - - test-isaaclab-contrib - - test-isaaclab-teleop - - test-isaaclab-visualizers - - test-isaaclab-newton - - test-isaaclab-physx - - test-isaaclab-ov - - test-curobo - - test-skillgen - - test-environments-training - if: always() - steps: - - name: Check Docker test results - env: - CHANGES_RESULT: ${{ needs.changes.result }} - RUN_DOCKER_TESTS: ${{ needs.changes.outputs.run_docker_tests }} - BUILD_RESULT: ${{ needs.build.result }} - BUILD_CUROBO_RESULT: ${{ needs.build-curobo.result }} - TASKS_1_RESULT: ${{ needs.test-isaaclab-tasks.result }} - TASKS_2_RESULT: ${{ needs.test-isaaclab-tasks-2.result }} - TASKS_3_RESULT: ${{ needs.test-isaaclab-tasks-3.result }} - CORE_1_RESULT: ${{ needs.test-isaaclab-core.result }} - CORE_2_RESULT: ${{ needs.test-isaaclab-core-2.result }} - CORE_3_RESULT: ${{ needs.test-isaaclab-core-3.result }} - RL_RESULT: ${{ needs.test-isaaclab-rl.result }} - MIMIC_RESULT: ${{ needs.test-isaaclab-mimic.result }} - ASSETS_RESULT: ${{ needs.test-isaaclab-assets.result }} - CONTRIB_RESULT: ${{ needs.test-isaaclab-contrib.result }} - TELEOP_RESULT: ${{ needs.test-isaaclab-teleop.result }} - VISUALIZERS_RESULT: ${{ needs.test-isaaclab-visualizers.result }} - NEWTON_RESULT: ${{ needs.test-isaaclab-newton.result }} - PHYSX_RESULT: ${{ needs.test-isaaclab-physx.result }} - OV_RESULT: ${{ needs.test-isaaclab-ov.result }} - CUROBO_RESULT: ${{ needs.test-curobo.result }} - SKILLGEN_RESULT: ${{ needs.test-skillgen.result }} - ENVIRONMENTS_TRAINING_RESULT: ${{ needs.test-environments-training.result }} - run: | - set -euo pipefail - - if [ "$CHANGES_RESULT" != "success" ]; then - echo "Change detection failed with result: $CHANGES_RESULT" - exit 1 - fi - - if [ "$RUN_DOCKER_TESTS" != "true" ]; then - echo "Docker tests are not required for this change." - exit 0 - fi - - failures=() - [ "$BUILD_RESULT" = "success" ] || failures+=("Build Base Docker Image: $BUILD_RESULT") - [ "$BUILD_CUROBO_RESULT" = "success" ] || failures+=("Build cuRobo Docker Image: $BUILD_CUROBO_RESULT") - [ "$TASKS_1_RESULT" = "success" ] || failures+=("isaaclab_tasks [1/3]: $TASKS_1_RESULT") - [ "$TASKS_2_RESULT" = "success" ] || failures+=("isaaclab_tasks [2/3]: $TASKS_2_RESULT") - [ "$TASKS_3_RESULT" = "success" ] || failures+=("isaaclab_tasks [3/3]: $TASKS_3_RESULT") - [ "$CORE_1_RESULT" = "success" ] || failures+=("isaaclab (core) [1/3]: $CORE_1_RESULT") - [ "$CORE_2_RESULT" = "success" ] || failures+=("isaaclab (core) [2/3]: $CORE_2_RESULT") - [ "$CORE_3_RESULT" = "success" ] || failures+=("isaaclab (core) [3/3]: $CORE_3_RESULT") - [ "$RL_RESULT" = "success" ] || failures+=("isaaclab_rl: $RL_RESULT") - [ "$MIMIC_RESULT" = "success" ] || failures+=("isaaclab_mimic: $MIMIC_RESULT") - [ "$ASSETS_RESULT" = "success" ] || failures+=("isaaclab_assets: $ASSETS_RESULT") - [ "$CONTRIB_RESULT" = "success" ] || failures+=("isaaclab_contrib: $CONTRIB_RESULT") - [ "$TELEOP_RESULT" = "success" ] || failures+=("isaaclab_teleop: $TELEOP_RESULT") - [ "$VISUALIZERS_RESULT" = "success" ] || failures+=("isaaclab_visualizers: $VISUALIZERS_RESULT") - [ "$NEWTON_RESULT" = "success" ] || failures+=("isaaclab_newton: $NEWTON_RESULT") - [ "$PHYSX_RESULT" = "success" ] || failures+=("isaaclab_physx: $PHYSX_RESULT") - [ "$OV_RESULT" = "success" ] || failures+=("isaaclab_ov: $OV_RESULT") - [ "$CUROBO_RESULT" = "success" ] || failures+=("test-curobo: $CUROBO_RESULT") - [ "$SKILLGEN_RESULT" = "success" ] || failures+=("test-skillgen: $SKILLGEN_RESULT") - [ "$ENVIRONMENTS_TRAINING_RESULT" = "success" ] || failures+=("environments_training: $ENVIRONMENTS_TRAINING_RESULT") - - if [ "${#failures[@]}" -gt 0 ]; then - printf 'Docker checks did not pass:\n' - printf ' - %s\n' "${failures[@]}" - exit 1 - fi - - echo "Docker checks passed." - #region disabled quarantined tests # test-quarantined: # name: "Quarantined Tests" diff --git a/.github/workflows/check-links.yml b/.github/workflows/check-links.yml index b19fe2be267a..edec5899aa3e 100644 --- a/.github/workflows/check-links.yml +++ b/.github/workflows/check-links.yml @@ -22,7 +22,7 @@ on: - cron: '0 0 * * 0' # Every Sunday at midnight UTC concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: @@ -101,6 +101,9 @@ jobs: --exclude 'huggingface\.co/nvidia/X-Mobility' --exclude 'openusd\.org' --exclude 'ubuntu\.com/server/docs' + --exclude 'docs\.ray\.io' + --exclude 'docs\.conda\.io' + --exclude 'stackoverflow\.com' --max-retries 5 --retry-wait-time 10 --timeout 20 diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index b8c6037621ae..593956e0bcb1 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -18,7 +18,7 @@ on: types: [opened, synchronize, reopened] concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: diff --git a/.github/workflows/install-ci.yml b/.github/workflows/install-ci.yml index f2e4ebb537ce..c66e1bf4fce5 100644 --- a/.github/workflows/install-ci.yml +++ b/.github/workflows/install-ci.yml @@ -24,7 +24,7 @@ on: default: '' concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true permissions: @@ -41,21 +41,94 @@ jobs: env: GH_TOKEN: ${{ github.token }} PR_NUMBER: ${{ github.event.pull_request.number }} + EVENT_NAME: ${{ github.event_name }} + REPO: ${{ github.repository }} run: | set -euo pipefail - if [ "${{ github.event_name }}" != "pull_request" ]; then - echo "run_install_tests=true" >> "$GITHUB_OUTPUT" + # Installation tests run only when paths in the patterns table change. + # Otherwise the test job skips via `if:` and reports green to branch + # protection, which is why we don't use a workflow-level `paths:` + # filter (a not-triggered required check would block the PR forever). + patterns=( + $'^apps/\tStandalone apps' + $'^tools/\tBuild tooling' + $'^source/\tLibrary source code' + $'^\\.github/actions/run-package-tests/\tTest action' + $'^\\.github/workflows/install-ci\\.yml$\tThis workflow file' + $'^VERSION$\tVersion file' + $'(^|/)pyproject\\.toml$\tPython project metadata' + $'(^|/)environment\\.ya?ml$\tConda environment file' + ) + triggered_jobs="Installation Tests" + + render_table() { + local files="$1" entry regex desc count sample + echo "| Pattern | What it covers | Matched files |" + echo "|---|---|---|" + for entry in "${patterns[@]}"; do + IFS=$'\t' read -r regex desc <<< "$entry" + count=$(printf '%s\n' "$files" | grep -cE "$regex" || true) + if [ "$count" -gt 0 ]; then + sample=$(printf '%s\n' "$files" | grep -E "$regex" | head -3 | paste -sd ', ' -) + [ "$count" -gt 3 ] && sample="$sample (and $((count - 3)) more)" + echo "| \`$regex\` | $desc | $sample |" + else + echo "| \`$regex\` | $desc | - |" + fi + done + } + + any_match() { + local files="$1" entry regex + for entry in "${patterns[@]}"; do + IFS=$'\t' read -r regex _ <<< "$entry" + if printf '%s\n' "$files" | grep -qE "$regex"; then + return 0 + fi + done + return 1 + } + + decide() { + local decision="$1" reason="$2" files="${3:-}" + echo "Decision: run_install_tests=$decision ($reason)" + echo "run_install_tests=$decision" >> "$GITHUB_OUTPUT" + { + echo "## Installation test gating" + echo "" + if [ "$decision" = "true" ]; then + echo "Installation tests will **run**: $reason." + else + echo "Installation tests will be **skipped**: $reason." + fi + echo "" + echo "Triggered jobs: $triggered_jobs." + if [ -n "$files" ]; then + echo "" + render_table "$files" + fi + } >> "$GITHUB_STEP_SUMMARY" + } + + if [ "$EVENT_NAME" != "pull_request" ]; then + decide true "non-PR event ($EVENT_NAME)" + exit 0 + fi + + if ! changed_files="$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/files" --jq '.[].filename')"; then + # Fail-safe: a transient API error must not block merge. Default to running. + echo "::warning::Could not list changed files; defaulting to running tests" + decide true "fail-safe (could not list changed files)" exit 0 fi - changed_files="$(gh api --paginate "repos/${{ github.repository }}/pulls/${PR_NUMBER}/files" --jq '.[].filename')" printf '%s\n' "$changed_files" - if printf '%s\n' "$changed_files" | grep -qE '^(apps/|tools/|source/|\.github/actions/run-package-tests/|\.github/workflows/install-ci\.yml$|VERSION$)|(^|/)pyproject\.toml$|(^|/)environment\.ya?ml$'; then - echo "run_install_tests=true" >> "$GITHUB_OUTPUT" + if any_match "$changed_files"; then + decide true "relevant paths changed" "$changed_files" else - echo "run_install_tests=false" >> "$GITHUB_OUTPUT" + decide false "no relevant paths changed" "$changed_files" fi install-tests: @@ -82,34 +155,3 @@ jobs: fi tools/run_install_ci.py docker $RUNNER_ARGS -- --tb=short "${PYTEST_EXTRA_ARGS[@]}" - - installation-tests-gate: - name: Installation Tests Gate - needs: [changes, install-tests] - if: always() - runs-on: ubuntu-latest - steps: - - name: Check installation test result - env: - CHANGES_RESULT: ${{ needs.changes.result }} - RUN_INSTALL_TESTS: ${{ needs.changes.outputs.run_install_tests }} - INSTALL_TESTS_RESULT: ${{ needs.install-tests.result }} - run: | - set -euo pipefail - - if [ "$CHANGES_RESULT" != "success" ]; then - echo "Change detection failed with result: $CHANGES_RESULT" - exit 1 - fi - - if [ "$RUN_INSTALL_TESTS" != "true" ]; then - echo "Installation tests are not required for this change." - exit 0 - fi - - if [ "$INSTALL_TESTS_RESULT" != "success" ]; then - echo "Installation Tests did not pass: $INSTALL_TESTS_RESULT" - exit 1 - fi - - echo "Installation Tests passed." diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index fe6fe42e12fc..593aec9a2cb0 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -7,10 +7,6 @@ name: "Pull Request Labeler" on: - pull_request_target -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number }} - cancel-in-progress: true - jobs: labeler: permissions: diff --git a/.github/workflows/license-check.yaml b/.github/workflows/license-check.yaml index 140de1c0e274..0b296f9e74eb 100644 --- a/.github/workflows/license-check.yaml +++ b/.github/workflows/license-check.yaml @@ -10,7 +10,7 @@ on: types: [opened, synchronize, reopened] concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: diff --git a/.github/workflows/pre-commit.yaml b/.github/workflows/pre-commit.yaml index 0b4cc5ac3d40..ef7e5a820512 100644 --- a/.github/workflows/pre-commit.yaml +++ b/.github/workflows/pre-commit.yaml @@ -10,7 +10,7 @@ on: types: [opened, synchronize, reopened] concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: diff --git a/.github/workflows/test_required_ci_gates.py b/.github/workflows/test_required_ci_gates.py deleted file mode 100644 index 1eba84acf0ca..000000000000 --- a/.github/workflows/test_required_ci_gates.py +++ /dev/null @@ -1,96 +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 - -"""Regression tests for required CI checks that must always report.""" - -from __future__ import annotations - -from pathlib import Path -from typing import Any - -import yaml - -_WORKFLOW_DIR = Path(__file__).resolve().parent - - -def _load_workflow(name: str) -> dict[str, Any]: - with (_WORKFLOW_DIR / name).open(encoding="utf-8") as f: - return yaml.safe_load(f) - - -def _on_config(workflow: dict[str, Any]) -> dict[str, Any]: - # PyYAML follows YAML 1.1, where the key "on" is parsed as True. - return workflow.get("on", workflow.get(True, {})) - - -def _as_list(value: str | list[str]) -> list[str]: - if isinstance(value, list): - return value - return [value] - - -def _assert_job_if_is_exactly(job: dict[str, Any], expected: str) -> None: - assert job["if"] == expected - - -def test_required_docker_test_workflow_reports_for_docs_only_prs(): - workflow = _load_workflow("build.yaml") - - pull_request = _on_config(workflow)["pull_request"] - assert "paths" not in pull_request - - jobs = workflow["jobs"] - assert jobs["changes"]["outputs"]["run_docker_tests"] == "${{ steps.detect.outputs.run_docker_tests }}" - - for job_name in ("build", "build-curobo"): - job = jobs[job_name] - assert "changes" in _as_list(job["needs"]) - _assert_job_if_is_exactly(job, "needs.changes.outputs.run_docker_tests == 'true'") - - gate = jobs["docker-tests-gate"] - assert gate["name"] == "Docker Tests Gate" - assert gate["if"] == "always()" - assert gate["needs"] == [ - "changes", - "build", - "build-curobo", - "test-isaaclab-tasks", - "test-isaaclab-tasks-2", - "test-isaaclab-tasks-3", - "test-isaaclab-core", - "test-isaaclab-core-2", - "test-isaaclab-core-3", - "test-isaaclab-rl", - "test-isaaclab-mimic", - "test-isaaclab-assets", - "test-isaaclab-contrib", - "test-isaaclab-teleop", - "test-isaaclab-visualizers", - "test-isaaclab-newton", - "test-isaaclab-physx", - "test-isaaclab-ov", - "test-curobo", - "test-skillgen", - "test-environments-training", - ] - - -def test_required_installation_workflow_reports_for_docs_only_prs(): - workflow = _load_workflow("install-ci.yml") - - pull_request = _on_config(workflow)["pull_request"] - assert "paths" not in pull_request - - jobs = workflow["jobs"] - assert jobs["changes"]["outputs"]["run_install_tests"] == "${{ steps.detect.outputs.run_install_tests }}" - - install_tests = jobs["install-tests"] - assert "changes" in _as_list(install_tests["needs"]) - _assert_job_if_is_exactly(install_tests, "needs.changes.outputs.run_install_tests == 'true'") - - gate = jobs["installation-tests-gate"] - assert gate["name"] == "Installation Tests Gate" - assert gate["if"] == "always()" - assert gate["needs"] == ["changes", "install-tests"] From 9da0c2f82077112940ffaad70a04c1460083bd05 Mon Sep 17 00:00:00 2001 From: ooctipus Date: Mon, 4 May 2026 14:19:58 -0700 Subject: [PATCH 32/40] Refactors visualizer cloning around ClonePlan (#5484) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description Routes the visualizer-side cloning data through a per-group `ClonePlan` map and has the PhysX scene data provider pull a Newton model from those plans directly — replacing the clone-time `visualizer_clone_fn` callback and the `VisualizerPrebuiltArtifacts` payload introduced in #5398. ## Pipeline ``` InteractiveScene.clone_environments plans = clone_from_template(...) # dict[str, ClonePlan] sim.set_clone_plans(plans) # canonical owner ↓ PhysxSceneDataProvider.__init__ plans = sim.get_clone_plans() sources, destinations, mask = aggregate(plans) positions = read_env_xforms(stage) model, state = newton_visualizer_prebuild(...) ``` Single source of truth, single direction. No clone-time callback, no requirements push from sensors, no late-resolve refresh loop. ## Notable changes - **Add** `ClonePlan` (`dest_template`, `prototype_paths`, `clone_mask`); `SimulationContext.{get,set}_clone_plans`; `InteractiveScene.clone_plans` forwarder. - **Remove** `TemplateCloneCfg.visualizer_clone_fn`, `cloner.resolve_visualizer_clone_fn`, `VisualizerPrebuiltArtifacts`, `SimulationContext.{get,set,clear}_scene_data_visualizer_prebuilt_artifact`, `Camera._register_renderer_scene_data_requirements`, `create_newton_visualizer_prebuild_clone_fn`. - **Collapse** three calls to `_refresh_visualizer_clone_fn_from_requirements` into one `_aggregate_scene_data_requirements` after entities are constructed. - **Provider** derives flat `(sources, destinations, mask)` from per-group plans, recovering each source path as `dest_template.format()` and reading per-env positions off the env-template's `xformOp:translate`. ## Type of change - Bug fix - Breaking change *(removes the public symbols listed above. They were introduced in #5398 four days ago; the changelog fragments are filed as `.minor.rst` since the surface had no time to acquire dependents.)* ## Checklist - [x] I have read and understood the contribution guidelines - [x] I have run the `pre-commit` checks with `./isaaclab.sh --format` - [ ] 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 changelog fragments under `source/isaaclab/changelog.d/`, `source/isaaclab_newton/changelog.d/` (both `.minor.rst`), and `source/isaaclab_physx/changelog.d/` (`.skip` — private/internal changes only) - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- .../clone-plan-visualizer-cleanup.minor.rst | 43 +++ source/isaaclab/isaaclab/cloner/__init__.pyi | 4 +- source/isaaclab/isaaclab/cloner/clone_plan.py | 39 +++ source/isaaclab/isaaclab/cloner/cloner_cfg.py | 3 - .../isaaclab/isaaclab/cloner/cloner_utils.py | 97 +++---- .../physics/scene_data_requirements.py | 16 -- .../isaaclab/scene/interactive_scene.py | 90 +++--- .../isaaclab/sensors/camera/camera.py | 25 -- .../isaaclab/sim/simulation_context.py | 33 ++- .../test/scene/test_interactive_scene.py | 82 +++--- source/isaaclab/test/sensors/test_camera.py | 20 -- source/isaaclab/test/sim/test_cloner.py | 123 ++++----- ...scene_data_provider_visualizer_contract.py | 260 ++++++++++++++---- .../test_simulation_context_visualizers.py | 2 +- .../clone-plan-visualizer-cleanup.minor.rst | 9 + .../cloner/newton_replicate.py | 58 +--- .../clone-plan-visualizer-cleanup.skip | 0 .../physx_scene_data_provider.py | 157 ++++++----- 18 files changed, 592 insertions(+), 469 deletions(-) create mode 100644 source/isaaclab/changelog.d/clone-plan-visualizer-cleanup.minor.rst create mode 100644 source/isaaclab/isaaclab/cloner/clone_plan.py create mode 100644 source/isaaclab_newton/changelog.d/clone-plan-visualizer-cleanup.minor.rst create mode 100644 source/isaaclab_physx/changelog.d/clone-plan-visualizer-cleanup.skip diff --git a/source/isaaclab/changelog.d/clone-plan-visualizer-cleanup.minor.rst b/source/isaaclab/changelog.d/clone-plan-visualizer-cleanup.minor.rst new file mode 100644 index 000000000000..8a8a74cb6267 --- /dev/null +++ b/source/isaaclab/changelog.d/clone-plan-visualizer-cleanup.minor.rst @@ -0,0 +1,43 @@ +Added +^^^^^ + +* Added :class:`~isaaclab.cloner.ClonePlan` frozen dataclass capturing per-group + prototype-to-environment mappings (``dest_template``, ``prototype_paths``, + ``clone_mask``). Lets downstream consumers (scene data providers, mesh samplers) + read prototype geometry once and scatter to environments via the per-group mask + instead of walking per-env USD paths. +* Added :meth:`~isaaclab.sim.SimulationContext.get_clone_plans` and + :meth:`~isaaclab.sim.SimulationContext.set_clone_plans` for publishing and + consuming the cloner's per-group plan map. +* Added :attr:`~isaaclab.scene.InteractiveScene.clone_plans` property (forwards to + :meth:`~isaaclab.sim.SimulationContext.get_clone_plans`) so consumers holding a + scene reference can read the published plans without going through the sim + context. + +Changed +^^^^^^^ + +* **Breaking:** :func:`~isaaclab.cloner.clone_from_template` now returns + ``dict[str, ClonePlan]`` instead of ``None``. Bind the result and publish it + through :meth:`~isaaclab.sim.SimulationContext.set_clone_plans` if downstream + consumers (e.g. the PhysX scene data provider's Newton-visualizer build path) + need to read the plan. + +Removed +^^^^^^^ + +* **Breaking:** Removed + :attr:`~isaaclab.cloner.TemplateCloneCfg.visualizer_clone_fn`, + :func:`~isaaclab.cloner.resolve_visualizer_clone_fn`, and + :class:`~isaaclab.physics.scene_data_requirements.VisualizerPrebuiltArtifacts`. + Scene data providers now build backend models from the + :class:`~isaaclab.cloner.ClonePlan` map via + :meth:`~isaaclab.sim.SimulationContext.get_clone_plans` instead of receiving a + prebuilt artifact through a clone-time callback. +* **Breaking:** Removed + :meth:`~isaaclab.sim.SimulationContext.get_scene_data_visualizer_prebuilt_artifact`, + :meth:`~isaaclab.sim.SimulationContext.set_scene_data_visualizer_prebuilt_artifact`, + and + :meth:`~isaaclab.sim.SimulationContext.clear_scene_data_visualizer_prebuilt_artifact`. + Use :meth:`~isaaclab.sim.SimulationContext.get_clone_plans` / + :meth:`~isaaclab.sim.SimulationContext.set_clone_plans` instead. diff --git a/source/isaaclab/isaaclab/cloner/__init__.pyi b/source/isaaclab/isaaclab/cloner/__init__.pyi index a2457ac78e79..8319388a8108 100644 --- a/source/isaaclab/isaaclab/cloner/__init__.pyi +++ b/source/isaaclab/isaaclab/cloner/__init__.pyi @@ -4,6 +4,7 @@ # SPDX-License-Identifier: BSD-3-Clause __all__ = [ + "ClonePlan", "TemplateCloneCfg", "random", "sequential", @@ -12,10 +13,10 @@ __all__ = [ "filter_collisions", "grid_transforms", "make_clone_plan", - "resolve_visualizer_clone_fn", "usd_replicate", ] +from .clone_plan import ClonePlan from .cloner_cfg import TemplateCloneCfg from .cloner_strategies import random, sequential from .cloner_utils import ( @@ -24,6 +25,5 @@ from .cloner_utils import ( filter_collisions, grid_transforms, make_clone_plan, - resolve_visualizer_clone_fn, usd_replicate, ) diff --git a/source/isaaclab/isaaclab/cloner/clone_plan.py b/source/isaaclab/isaaclab/cloner/clone_plan.py new file mode 100644 index 000000000000..4a765463b32d --- /dev/null +++ b/source/isaaclab/isaaclab/cloner/clone_plan.py @@ -0,0 +1,39 @@ +# 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 dataclasses import dataclass, field + +import torch + + +@dataclass(frozen=True) +class ClonePlan: + """Per-group mapping from prototype prims to per-environment clones. + + Produced by :func:`~isaaclab.cloner.clone_from_template` for each prototype group it + discovers under the template root. Lets downstream consumers (e.g. mesh samplers, + ray-cast sensors) read prototype geometry once and scatter to environments via + :attr:`clone_mask` instead of walking per-env USD paths. + + Attributes are population-time invariants and the dataclass is frozen. Hash and + equality operate on :attr:`dest_template` only (the natural identity — it is the key + in :attr:`SimulationContext.get_clone_plans`); the mutable list/tensor fields are + excluded since ``torch.Tensor`` is not hashable and structural equality is rarely the + semantics consumers want. + """ + + dest_template: str + """Destination path template for this group, e.g. ``"/World/envs/env_{}/Object"``.""" + + prototype_paths: list[str] = field(hash=False, compare=False) + """Prototype prim paths in this group, e.g. + ``["/World/template/Object/proto_asset_0", "/World/template/Object/proto_asset_1"]``.""" + + clone_mask: torch.Tensor = field(hash=False, compare=False) + """Boolean tensor of shape ``[num_prototypes_in_group, num_envs]``; + ``clone_mask[i, j]`` is ``True`` iff env ``j`` was populated from + :attr:`prototype_paths` ``[i]``. Each column sums to exactly one.""" diff --git a/source/isaaclab/isaaclab/cloner/cloner_cfg.py b/source/isaaclab/isaaclab/cloner/cloner_cfg.py index 23983a389951..19decec0c011 100644 --- a/source/isaaclab/isaaclab/cloner/cloner_cfg.py +++ b/source/isaaclab/isaaclab/cloner/cloner_cfg.py @@ -73,9 +73,6 @@ class TemplateCloneCfg: physics_clone_fn: callable | None = None """Function used to perform physics replication.""" - visualizer_clone_fn: callable | None = None - """Optional function used to build precomputed visualizer artifacts from the clone plan.""" - clone_strategy: callable = random """Function used to build prototype-to-environment mapping. Default is :func:`random`.""" diff --git a/source/isaaclab/isaaclab/cloner/cloner_utils.py b/source/isaaclab/isaaclab/cloner/cloner_utils.py index d06b38b0f5a5..717d020a90ce 100644 --- a/source/isaaclab/isaaclab/cloner/cloner_utils.py +++ b/source/isaaclab/isaaclab/cloner/cloner_utils.py @@ -9,7 +9,7 @@ import itertools import logging import math -from collections.abc import Callable, Iterator +from collections.abc import Iterator from typing import TYPE_CHECKING import torch @@ -17,13 +17,14 @@ from pxr import Gf, Sdf, Usd, UsdGeom, UsdUtils, Vt import isaaclab.sim as sim_utils -from isaaclab.physics.scene_data_requirements import SceneDataRequirement, VisualizerPrebuiltArtifacts from . import _fabric_notices if TYPE_CHECKING: from .cloner_cfg import TemplateCloneCfg +from .clone_plan import ClonePlan + logger = logging.getLogger(__name__) @@ -104,7 +105,9 @@ def disabled_fabric_change_notifies(stage: Usd.Stage, *, restore: bool = True) - bindings.set_enable(fabric_id, True) -def clone_from_template(stage: Usd.Stage, num_clones: int, template_clone_cfg: TemplateCloneCfg) -> None: +def clone_from_template( + stage: Usd.Stage, num_clones: int, template_clone_cfg: TemplateCloneCfg +) -> dict[str, ClonePlan]: """Clone assets from a template root into per-environment destinations. This utility discovers prototype prims under ``cfg.template_root`` whose names start with @@ -118,6 +121,10 @@ def clone_from_template(stage: Usd.Stage, num_clones: int, template_clone_cfg: T template_clone_cfg: Configuration describing template location, destination pattern, and replication/mapping behavior. + Returns: + Mapping from each group's destination template (e.g. ``"/World/envs/env_{}/Object"``) + to its :class:`ClonePlan`. Empty when no prototype groups are discovered. + Note: This function suspends the Fabric USD notice listener for the duration of the call and **leaves it disabled on return**. It is intended to be invoked from a scene-init @@ -128,6 +135,7 @@ def clone_from_template(stage: Usd.Stage, num_clones: int, template_clone_cfg: T :func:`disabled_fabric_change_notifies` with ``restore=True``. """ cfg: TemplateCloneCfg = template_clone_cfg + plans: dict[str, ClonePlan] = {} # Suspend Fabric's USD notice listener for the duration of bulk authoring. ``restore=False`` # because clone_from_template is only called at scene-init time, which is followed by # ``SimulationContext.reset`` — that reset path does the Fabric resync naturally, and @@ -161,6 +169,15 @@ def clone_from_template(stage: Usd.Stage, num_clones: int, template_clone_cfg: T src, dest, num_clones, cfg.clone_strategy, cfg.device ) + # Per-group plans: slice ``clone_masking`` along the prototype axis using cumulative + # group sizes — each group's mask rows are contiguous in the ``[total_protos, num_envs]`` + # tensor that ``make_clone_plan`` produced. + offsets = [0, *itertools.accumulate(len(g) for g in src)] + plans = { + d: ClonePlan(dest_template=d, prototype_paths=list(ps), clone_mask=clone_masking[lo:hi]) + for ps, d, lo, hi in zip(src, dest, offsets, offsets[1:]) + } + # Spawn the first instance of clones from prototypes, then deactivate the prototypes, those first # instances will be served as sources for usd and physics replication. proto_idx = clone_masking.to(torch.int32).argmax(dim=1) @@ -170,27 +187,27 @@ def clone_from_template(stage: Usd.Stage, num_clones: int, template_clone_cfg: T stage.GetPrimAtPath(cfg.template_root).SetActive(False) get_pos = lambda path: stage.GetPrimAtPath(path).GetAttribute("xformOp:translate").Get() # noqa: E731 positions = torch.tensor([get_pos(clone_path_fmt.format(i)) for i in world_indices]) - # If all prototypes map to env_0, clone whole env_0 to all envs; else clone per-object + # Heterogeneous default: emit per-prototype (sources, destinations, mask) and trust + # env_0..N's existing xforms (proto-spawn above already placed them, so don't + # re-author). When every env happens to pick prototype 0, collapse below to a + # single env_0 → all-envs copy and re-author positions (the destination subtree + # replaces env_1..N's prior xform). + sources = [tpl.format(int(idx)) for tpl, idx in zip(dest_paths, proto_idx.tolist())] + usd_positions: torch.Tensor | None = None if torch.all(proto_idx == 0): - mapping = clone_masking.new_ones(1, num_clones) - replicate_args = [clone_path_fmt.format(0)], [clone_path_fmt], world_indices, mapping - if cfg.clone_physics and cfg.physics_clone_fn is not None: - cfg.physics_clone_fn(stage, *replicate_args, positions=positions, device=cfg.device) - if cfg.visualizer_clone_fn is not None: - cfg.visualizer_clone_fn(stage, *replicate_args, positions=positions, device=cfg.device) - if cfg.clone_usd: - # parse env_origins directly from clone_path - usd_replicate(stage, *replicate_args, positions=positions) - - else: - selected_src = [tpl.format(int(idx)) for tpl, idx in zip(dest_paths, proto_idx.tolist())] - replicate_args = selected_src, dest_paths, world_indices, clone_masking - if cfg.clone_physics and cfg.physics_clone_fn is not None: - cfg.physics_clone_fn(stage, *replicate_args, positions=positions, device=cfg.device) - if cfg.visualizer_clone_fn is not None: - cfg.visualizer_clone_fn(stage, *replicate_args, positions=positions, device=cfg.device) - if cfg.clone_usd: - usd_replicate(stage, *replicate_args) + sources = [clone_path_fmt.format(0)] + dest_paths = [clone_path_fmt] + clone_masking = clone_masking.new_ones(1, num_clones) + usd_positions = positions + + if cfg.clone_physics and cfg.physics_clone_fn is not None: + cfg.physics_clone_fn( + stage, sources, dest_paths, world_indices, clone_masking, positions=positions, device=cfg.device + ) + if cfg.clone_usd: + usd_replicate(stage, sources, dest_paths, world_indices, clone_masking, positions=usd_positions) + + return plans def make_clone_plan( @@ -482,37 +499,3 @@ def grid_transforms(N: int, spacing: float = 1.0, up_axis: str = "z", device="cp ori = torch.zeros((N, 4), device=device) ori[:, 3] = 1.0 # w=1 for identity quaternion return pos, ori - - -def resolve_visualizer_clone_fn( - physics_backend: str, - requirements: SceneDataRequirement, - stage, - set_visualizer_artifact: Callable[[VisualizerPrebuiltArtifacts | None], None], -): - """Return an optional visualizer prebuild hook for clone workflows. - - Args: - physics_backend: Active physics backend name. - requirements: Aggregated scene-data requirements. - stage: USD stage used by the clone callback. - set_visualizer_artifact: Callback for storing prebuilt visualizer artifacts. - - Returns: - Clone callback when the prebuild path is supported; otherwise ``None``. - """ - if "physx" not in physics_backend or not requirements.requires_newton_model: - return None - try: - from isaaclab_newton.cloner.newton_replicate import ( - create_newton_visualizer_prebuild_clone_fn, - ) - except (ImportError, ModuleNotFoundError) as exc: - logger.warning("Visualizer prebuild hook unavailable: failed to import backend helper.") - logger.debug("Visualizer prebuild import failure details: %s", exc) - return None - - return create_newton_visualizer_prebuild_clone_fn( - stage=stage, - set_visualizer_artifact=set_visualizer_artifact, - ) diff --git a/source/isaaclab/isaaclab/physics/scene_data_requirements.py b/source/isaaclab/isaaclab/physics/scene_data_requirements.py index 616592d9b1e0..f67aaa2bb6d9 100644 --- a/source/isaaclab/isaaclab/physics/scene_data_requirements.py +++ b/source/isaaclab/isaaclab/physics/scene_data_requirements.py @@ -13,7 +13,6 @@ from collections.abc import Iterable from dataclasses import dataclass -from typing import Any @dataclass(frozen=True) @@ -24,21 +23,6 @@ class SceneDataRequirement: requires_usd_stage: bool = False -@dataclass(frozen=True) -class VisualizerPrebuiltArtifacts: - """Prebuilt model/state payload shared from scene setup to providers. - - This gets produced during clone-time visualizer prebuild and then read by - scene data providers as a fast path (instead of rebuilding from USD). - """ - - model: Any - state: Any - rigid_body_paths: list[str] - articulation_paths: list[str] - num_envs: int - - _VISUALIZER_REQUIREMENTS: dict[str, SceneDataRequirement] = { "kit": SceneDataRequirement(requires_usd_stage=True), "newton": SceneDataRequirement(requires_newton_model=True), diff --git a/source/isaaclab/isaaclab/scene/interactive_scene.py b/source/isaaclab/isaaclab/scene/interactive_scene.py index 95bd5f76027f..ce744fe4bffe 100644 --- a/source/isaaclab/isaaclab/scene/interactive_scene.py +++ b/source/isaaclab/isaaclab/scene/interactive_scene.py @@ -138,7 +138,6 @@ def __init__(self, cfg: InteractiveSceneCfg): self.sim = SimulationContext.instance() self.stage = get_current_stage() self.stage_id = get_current_stage_id() - self.sim.clear_scene_data_visualizer_prebuilt_artifact() self.physics_backend = self.sim.physics_manager.__name__.lower() requested_viz_types = set(self.sim.resolve_visualizer_types()) if self.physics_backend.startswith("ovphysx"): @@ -165,7 +164,6 @@ def __init__(self, cfg: InteractiveSceneCfg): clone_in_fabric=self.cfg.clone_in_fabric, device=self.device, physics_clone_fn=physics_clone_fn, - visualizer_clone_fn=None, # For ovphysx: env_1..N are created by physx.clone() in the physics # runtime after add_usd(). USD replication of the asset hierarchy # to env_1..N is skipped — only env_0 needs physics prims in the USD. @@ -199,7 +197,10 @@ def __init__(self, cfg: InteractiveSceneCfg): if has_scene_cfg_entities: self._add_entities_from_cfg() - self._refresh_visualizer_clone_fn_from_requirements(requested_viz_types) + # Aggregate scene-data requirements from declared visualizers and constructed sensors, + # then publish to ``SimulationContext`` so downstream providers (constructed later by + # :meth:`SimulationContext.initialize_visualizers`) see the full picture in one read. + self._aggregate_scene_data_requirements(requested_viz_types) if has_scene_cfg_entities: self.clone_environments(copy_from_source=(not self.cfg.replicate_physics)) @@ -216,8 +217,6 @@ def clone_environments(self, copy_from_source: bool = False): If True, clones are independent copies of the source prim and won't reflect its changes (start-up time may increase). Defaults to False. """ - self._refresh_visualizer_clone_fn_from_requirements() - # PhysX-only: set env id bit count for replicated physics. Newton handles env separation in its own API. # Intentionally matches both physx and ovphysx (both are PhysX-based) if self.cfg.replicate_physics and "physx" in self.physics_backend: @@ -231,7 +230,9 @@ def clone_environments(self, copy_from_source: bool = False): with cloner.disabled_fabric_change_notifies(self.stage, restore=False): if self._is_scene_setup_from_cfg(): self.cloner_cfg.clone_physics = not copy_from_source - cloner.clone_from_template(self.stage, num_clones=self.num_envs, template_clone_cfg=self.cloner_cfg) + plans = cloner.clone_from_template( + self.stage, num_clones=self.num_envs, template_clone_cfg=self.cloner_cfg + ) else: mapping = torch.ones((1, self.num_envs), device=self.device, dtype=torch.bool) replicate_args = ( @@ -239,18 +240,37 @@ def clone_environments(self, copy_from_source: bool = False): [self.env_fmt], self._ALL_INDICES, mapping, - self._default_env_origins, ) if not copy_from_source and self.cloner_cfg.physics_clone_fn is not None: - self.cloner_cfg.physics_clone_fn(self.stage, *replicate_args, device=self.cloner_cfg.device) - if self.cloner_cfg.visualizer_clone_fn is not None: - self.cloner_cfg.visualizer_clone_fn(self.stage, *replicate_args, device=self.cloner_cfg.device) + self.cloner_cfg.physics_clone_fn( + self.stage, *replicate_args, positions=self._default_env_origins, device=self.cloner_cfg.device + ) if self.cloner_cfg.clone_usd: - cloner.usd_replicate(self.stage, *replicate_args) + cloner.usd_replicate(self.stage, *replicate_args, positions=self._default_env_origins) + # Synthesize a single trivial ClonePlan so consumers (scene data providers, + # pointcloud samplers, etc.) get a uniform interface regardless of whether + # the scene was authored via prototypes or by hand under env_0. + plans = { + self.env_fmt: cloner.ClonePlan( + dest_template=self.env_fmt, + prototype_paths=[self.env_fmt.format(0)], + clone_mask=mapping, + ) + } - def _refresh_visualizer_clone_fn_from_requirements(self, visualizer_types=()) -> None: - """Refresh clone-time visualizer prebuild hook from current scene-data requirements.""" + # Publish to ``SimulationContext`` (the canonical owner). The :attr:`clone_plans` + # property below forwards reads back through ``sim.get_clone_plans()`` so consumers + # holding a scene reference still see the published plans without a duplicate cache. + self.sim.set_clone_plans(plans) + + def _aggregate_scene_data_requirements(self, visualizer_types=()) -> None: + """Aggregate scene-data requirements from visualizers and sensor renderers. + + Runs once after :meth:`_add_entities_from_cfg` so all sensors are constructed and + their renderer types are visible. Pushes the merged :class:`SceneDataRequirement` to + :class:`SimulationContext` for later consumption by the scene data provider. + """ discovered_req = resolve_scene_data_requirements( visualizer_types=visualizer_types, renderer_types=self._sensor_renderer_types(), @@ -260,33 +280,13 @@ def _refresh_visualizer_clone_fn_from_requirements(self, visualizer_types=()) -> if requirements != current_req: self.sim.update_scene_data_requirements(requirements) - visualizer_clone_fn = cloner.resolve_visualizer_clone_fn( - physics_backend=self.physics_backend, - requirements=requirements, - stage=self.stage, - set_visualizer_artifact=self.sim.set_scene_data_visualizer_prebuilt_artifact, - ) - if visualizer_clone_fn is not None: - logger.debug( - "Enabling visualizer artifact prebuild for clone path " - "(backend=%s, requires_newton_model=%s, requires_usd_stage=%s).", - self.physics_backend, - requirements.requires_newton_model, - requirements.requires_usd_stage, - ) - self.cloner_cfg.visualizer_clone_fn = visualizer_clone_fn - def _sensor_renderer_types(self) -> list[str]: - """Return renderer type names used by scene sensors.""" - renderer_types: list[str] = [] - for sensor in self._sensors.values(): - sensor_cfg = getattr(sensor, "cfg", None) - renderer_cfg = getattr(sensor_cfg, "renderer_cfg", None) - if renderer_cfg is None: - continue - renderer_type = getattr(renderer_cfg, "renderer_type", "default") - renderer_types.append(renderer_type) - return renderer_types + """Return renderer type names used by scene sensors (skipping any without a renderer cfg).""" + return [ + getattr(rcfg, "renderer_type", "default") + for s in self._sensors.values() + if (rcfg := getattr(getattr(s, "cfg", None), "renderer_cfg", None)) is not None + ] def filter_collisions(self, global_prim_paths: list[str] | None = None): """Filter environments collisions. @@ -426,6 +426,18 @@ def surface_grippers(self) -> dict[str, SurfaceGripper]: """A dictionary of the surface grippers in the scene.""" return self._surface_grippers + @property + def clone_plans(self) -> dict[str, cloner.ClonePlan]: + """Per-group clone plans produced by :meth:`clone_environments`. + + Forwards to :meth:`SimulationContext.get_clone_plans`, which is the canonical owner. + Keyed by each group's destination path template + (e.g. ``"/World/envs/env_{}/Object"``); the value records the prototype prim paths + and the per-env prototype assignment mask. Empty until :meth:`clone_environments` + runs, and (for the cfg path) empty when the scene cfg has no template prototypes. + """ + return self.sim.get_clone_plans() + @property def extras(self) -> dict[str, FrameView]: """A dictionary of miscellaneous simulation objects that neither inherit from assets nor sensors. diff --git a/source/isaaclab/isaaclab/sensors/camera/camera.py b/source/isaaclab/isaaclab/sensors/camera/camera.py index 6362cea8ce15..be52668dbd6c 100644 --- a/source/isaaclab/isaaclab/sensors/camera/camera.py +++ b/source/isaaclab/isaaclab/sensors/camera/camera.py @@ -108,7 +108,6 @@ def __init__(self, cfg: CameraCfg): self._check_supported_data_types(cfg) # initialize base class super().__init__(cfg) - self._register_renderer_scene_data_requirements() # TODO(follow-up PR): move this flag flip out of Camera. The cleanest path is # an apply_pre_reset_settings() hook on RendererCfg (default no-op) that @@ -136,30 +135,6 @@ def __init__(self, cfg: CameraCfg): self._renderer: BaseRenderer | None = None self._render_data = None - def _register_renderer_scene_data_requirements(self) -> None: - """Register renderer requirements early enough for clone-time prebuilds.""" - renderer_type = getattr(getattr(self.cfg, "renderer_cfg", None), "renderer_type", None) - if renderer_type is None: - return - - from isaaclab.physics.scene_data_requirements import aggregate_requirements, requirement_for_renderer_type - from isaaclab.sim import SimulationContext - - sim = SimulationContext.instance() - if sim is None: - logger.debug("SimulationContext not available; deferring renderer requirements registration.") - return - - try: - renderer_req = requirement_for_renderer_type(renderer_type) - except ValueError: - return - - current_req = sim.get_scene_data_requirements() - merged_req = aggregate_requirements((current_req, renderer_req)) - if merged_req != current_req: - sim.update_scene_data_requirements(merged_req) - def __del__(self): """Unsubscribes from callbacks and cleans up renderer resources.""" # unsubscribe callbacks diff --git a/source/isaaclab/isaaclab/sim/simulation_context.py b/source/isaaclab/isaaclab/sim/simulation_context.py index 4fe97648a063..89be3163359f 100644 --- a/source/isaaclab/isaaclab/sim/simulation_context.py +++ b/source/isaaclab/isaaclab/sim/simulation_context.py @@ -11,7 +11,7 @@ import traceback from collections.abc import Iterator from contextlib import contextmanager -from typing import Any +from typing import TYPE_CHECKING, Any import toml import torch @@ -25,7 +25,6 @@ from isaaclab.physics import BaseSceneDataProvider, PhysicsManager, SceneDataProvider from isaaclab.physics.scene_data_requirements import ( SceneDataRequirement, - VisualizerPrebuiltArtifacts, resolve_scene_data_requirements, ) from isaaclab.renderers.render_context import RenderContext @@ -34,6 +33,9 @@ from isaaclab.utils.version import has_kit from isaaclab.visualizers.base_visualizer import BaseVisualizer +if TYPE_CHECKING: + from isaaclab.cloner.clone_plan import ClonePlan + from .simulation_cfg import SimulationCfg from .spawners import DomeLightCfg, GroundPlaneCfg @@ -173,7 +175,11 @@ def __init__(self, cfg: SimulationCfg | None = None): self._scene_data_provider: BaseSceneDataProvider | None = None self._visualizers: list[BaseVisualizer] = [] self._scene_data_requirements = SceneDataRequirement() - self._visualizer_prebuilt_artifact: VisualizerPrebuiltArtifacts | None = None + # Per-group clone plans published by InteractiveScene after cloning. Providers (e.g. + # the Newton visualizer model rebuilder on a PhysX backend) consume these to derive + # their own backend args. Empty dict until :meth:`InteractiveScene.clone_environments` + # runs. + self._clone_plans: dict[str, ClonePlan] = {} self._visualizer_step_counter = 0 # Default visualization dt used before/without visualizer initialization. physics_dt = getattr(self.cfg.physics, "dt", None) @@ -627,21 +633,18 @@ def update_scene_data_requirements(self, requirements: SceneDataRequirement) -> """Update scene-data requirements.""" self._scene_data_requirements = requirements - def get_scene_data_visualizer_prebuilt_artifact(self) -> VisualizerPrebuiltArtifacts | None: - """Return optional prebuilt visualizer artifact.""" - return self._visualizer_prebuilt_artifact - - def set_scene_data_visualizer_prebuilt_artifact(self, artifact: VisualizerPrebuiltArtifacts | None) -> None: - """Set or clear the optional visualizer prebuilt artifact. + def get_clone_plans(self) -> dict[str, ClonePlan]: + """Return per-group clone plans published by the scene, keyed by destination template. - The scene (clone flow) writes this once, and providers can read it - during initialization as a fast path. + Set by :meth:`InteractiveScene.clone_environments` after replication. Consumed by + scene data providers that build backend models (e.g. Newton visualizer model on a + PhysX backend) from the same plan the cloner used. Empty dict until the scene clones. """ - self._visualizer_prebuilt_artifact = artifact + return self._clone_plans - def clear_scene_data_visualizer_prebuilt_artifact(self) -> None: - """Clear optional prebuilt artifact in provider context.""" - self.set_scene_data_visualizer_prebuilt_artifact(None) + def set_clone_plans(self, plans: dict[str, ClonePlan]) -> None: + """Set the cloner's per-group clone-plan map.""" + self._clone_plans = plans @property def visualizers(self) -> list[BaseVisualizer]: diff --git a/source/isaaclab/test/scene/test_interactive_scene.py b/source/isaaclab/test/scene/test_interactive_scene.py index ecb758346700..31b577db634f 100644 --- a/source/isaaclab/test/scene/test_interactive_scene.py +++ b/source/isaaclab/test/scene/test_interactive_scene.py @@ -130,17 +130,34 @@ def test_reset_to_env_ids_input_types(device, setup_scene): assert_state_equal(prev_state, scene.get_state()) -def test_clone_environments_non_cfg_invokes_visualizer_clone_fn(monkeypatch: pytest.MonkeyPatch): - """Non-cfg clone path should execute visualizer clone callback with replicate args.""" +def test_clone_environments_non_cfg_publishes_clone_plans(monkeypatch: pytest.MonkeyPatch): + """Non-cfg clone path must dispatch physics + USD replicate and publish a ``ClonePlan``. + + Replaces the old test that asserted a per-call visualizer clone callback was invoked. The + visualizer-fn callback was removed in favor of providers reading + :meth:`SimulationContext.get_clone_plans`; this test asserts the new contract: even + without prototype templates, the scene synthesizes a single trivial ClonePlan. + """ + from isaaclab.cloner import ClonePlan + scene = object.__new__(InteractiveScene) scene.cfg = SimpleNamespace(replicate_physics=False, num_envs=3) scene.stage = object() scene.physics_backend = "physx" scene._sensors = {} + + set_plans_calls: list = [] + sim_state: dict = {"plans": {}} + + def _set_clone_plans(plans): + sim_state["plans"] = plans + set_plans_calls.append(plans) + scene.sim = SimpleNamespace( get_scene_data_requirements=lambda: SceneDataRequirement(), update_scene_data_requirements=lambda requirements: None, - set_scene_data_visualizer_prebuilt_artifact=lambda artifact: None, + set_clone_plans=_set_clone_plans, + get_clone_plans=lambda: sim_state["plans"], ) scene.env_fmt = "/World/envs/env_{}" scene._ALL_INDICES = torch.arange(3, dtype=torch.long) @@ -160,73 +177,72 @@ def _noop_fabric_notices(stage, *, restore=True): monkeypatch.setattr("isaaclab.scene.interactive_scene.cloner.disabled_fabric_change_notifies", _noop_fabric_notices) physics_calls = [] - visualizer_calls = [] usd_calls = [] def _physics_clone_fn(stage, *args, **kwargs): physics_calls.append((stage, args, kwargs)) - def _visualizer_clone_fn(stage, *args, **kwargs): - visualizer_calls.append((stage, args, kwargs)) - def _usd_replicate(stage, *args, **kwargs): usd_calls.append((stage, args, kwargs)) scene.cloner_cfg = SimpleNamespace( device="cpu", physics_clone_fn=_physics_clone_fn, - visualizer_clone_fn=_visualizer_clone_fn, clone_usd=True, ) monkeypatch.setattr("isaaclab.scene.interactive_scene.cloner.usd_replicate", _usd_replicate) scene.clone_environments(copy_from_source=False) assert len(physics_calls) == 1 - assert len(visualizer_calls) == 1 assert len(usd_calls) == 1 mapping = physics_calls[0][1][3] assert mapping.dtype == torch.bool assert mapping.shape == (1, scene.num_envs) + # Plans are published once per clone, regardless of physics/usd flag combinations. + assert len(set_plans_calls) == 1 + plans = set_plans_calls[-1] + assert set(plans.keys()) == {scene.env_fmt} + plan = plans[scene.env_fmt] + assert isinstance(plan, ClonePlan) + assert plan.dest_template == scene.env_fmt + assert plan.prototype_paths == [scene.env_fmt.format(0)] + assert plan.clone_mask.shape == (1, scene.num_envs) + assert scene.clone_plans is plans physics_calls.clear() - visualizer_calls.clear() usd_calls.clear() + set_plans_calls.clear() scene.clone_environments(copy_from_source=True) assert len(physics_calls) == 0 - assert len(visualizer_calls) == 1 assert len(usd_calls) == 1 + assert len(set_plans_calls) == 1 + +def test_aggregate_scene_data_requirements_merges_visualizers_and_renderers(monkeypatch: pytest.MonkeyPatch): + """Scene aggregation must OR visualizer and sensor-renderer requirements onto sim context. -def test_refresh_visualizer_clone_fn_uses_registered_requirements(monkeypatch: pytest.MonkeyPatch): - """Clone-time prebuild hook should be installed from requirements registered after scene init.""" + Replaces the old test that asserted a clone-time visualizer hook was installed from + requirements. The hook is gone; the only remaining behavior is publishing the merged + :class:`SceneDataRequirement` to the simulation context. + """ scene = object.__new__(InteractiveScene) scene.physics_backend = "physx" scene.stage = object() - scene._sensors = {} - scene.cloner_cfg = SimpleNamespace(visualizer_clone_fn=None) + scene._sensors = { + "cam": SimpleNamespace(cfg=SimpleNamespace(renderer_cfg=SimpleNamespace(renderer_type="newton_warp"))) + } - requirements = SceneDataRequirement(requires_newton_model=True) + posted: list = [] scene.sim = SimpleNamespace( - get_scene_data_requirements=lambda: requirements, - update_scene_data_requirements=lambda requirements: None, - set_scene_data_visualizer_prebuilt_artifact=lambda artifact: None, - ) - - captured = {} - - def _resolve_visualizer_clone_fn(**kwargs): - captured.update(kwargs) - return "visualizer-clone-fn" - - monkeypatch.setattr( - "isaaclab.scene.interactive_scene.cloner.resolve_visualizer_clone_fn", - _resolve_visualizer_clone_fn, + get_scene_data_requirements=lambda: SceneDataRequirement(), + update_scene_data_requirements=posted.append, ) - scene._refresh_visualizer_clone_fn_from_requirements() + scene._aggregate_scene_data_requirements({"rerun"}) - assert captured["requirements"].requires_newton_model - assert scene.cloner_cfg.visualizer_clone_fn == "visualizer-clone-fn" + assert len(posted) == 1 + merged = posted[0] + assert merged.requires_newton_model def assert_state_equal(s1: dict, s2: dict, path=""): diff --git a/source/isaaclab/test/sensors/test_camera.py b/source/isaaclab/test/sensors/test_camera.py index e1178192ef63..daed8e95773d 100644 --- a/source/isaaclab/test/sensors/test_camera.py +++ b/source/isaaclab/test/sensors/test_camera.py @@ -17,7 +17,6 @@ import copy import random -from types import SimpleNamespace import numpy as np import pytest @@ -28,9 +27,7 @@ from pxr import Gf, Usd, UsdGeom import isaaclab.sim as sim_utils -from isaaclab.physics.scene_data_requirements import SceneDataRequirement from isaaclab.sensors.camera import Camera, CameraCfg -from isaaclab.sim import SimulationContext pytestmark = pytest.mark.isaacsim_ci @@ -48,23 +45,6 @@ WIDTH = 320 -def test_camera_registers_renderer_scene_data_requirements(monkeypatch: pytest.MonkeyPatch): - """Camera creation path should register renderer-driven scene-data requirements.""" - camera = object.__new__(Camera) - camera.cfg = SimpleNamespace(renderer_cfg=SimpleNamespace(renderer_type="newton_warp")) - updates = [] - sim = SimpleNamespace( - get_scene_data_requirements=lambda: SceneDataRequirement(), - update_scene_data_requirements=updates.append, - ) - - monkeypatch.setattr(SimulationContext, "instance", staticmethod(lambda: sim)) - - camera._register_renderer_scene_data_requirements() - - assert updates == [SceneDataRequirement(requires_newton_model=True)] - - def setup() -> tuple[sim_utils.SimulationContext, CameraCfg, float]: camera_cfg = CameraCfg( height=HEIGHT, diff --git a/source/isaaclab/test/sim/test_cloner.py b/source/isaaclab/test/sim/test_cloner.py index 42b4012e70f9..1f8af90387b5 100644 --- a/source/isaaclab/test/sim/test_cloner.py +++ b/source/isaaclab/test/sim/test_cloner.py @@ -20,9 +20,7 @@ from pxr import UsdGeom import isaaclab.sim as sim_utils -from isaaclab.cloner import usd_replicate -from isaaclab.cloner.cloner_utils import resolve_visualizer_clone_fn -from isaaclab.physics.scene_data_requirements import SceneDataRequirement, VisualizerPrebuiltArtifacts +from isaaclab.cloner import ClonePlan, TemplateCloneCfg, clone_from_template, sequential, usd_replicate from isaaclab.sim import build_simulation_context pytestmark = pytest.mark.isaacsim_ci @@ -223,79 +221,56 @@ def test_clone_decorator_wildcard_patterns( ) -def test_resolve_visualizer_clone_fn_returns_none_when_not_physx_backend(): - """Resolver should ignore non-PhysX backends.""" - hook = resolve_visualizer_clone_fn( - physics_backend="newton", - requirements=SceneDataRequirement(requires_newton_model=True), - stage=object(), - set_visualizer_artifact=lambda artifact: artifact, - ) - assert hook is None - - -def test_resolve_visualizer_clone_fn_returns_none_when_newton_model_not_required(): - """Resolver should not load optional hook when requirement is not requested.""" - hook = resolve_visualizer_clone_fn( - physics_backend="physx", - requirements=SceneDataRequirement(requires_newton_model=False), - stage=object(), - set_visualizer_artifact=lambda artifact: artifact, - ) - assert hook is None - - -def test_resolve_visualizer_clone_fn_returns_callable_when_available(sim): - """Resolver should return a callable hook when backend helper is available.""" - pytest.importorskip("isaaclab_newton.cloner.newton_replicate") - hook = resolve_visualizer_clone_fn( - physics_backend="physx", - requirements=SceneDataRequirement(requires_newton_model=True), - stage=sim_utils.get_current_stage(), - set_visualizer_artifact=lambda artifact: artifact, - ) - assert callable(hook) - - -def test_physx_newton_requirement_hook_populates_prebuilt_artifact(sim, monkeypatch: pytest.MonkeyPatch): - """PhysX + Newton requirement path should populate prebuilt visualizer artifact.""" - newton_replicate = pytest.importorskip("isaaclab_newton.cloner.newton_replicate") +def test_clone_from_template_returns_clone_plan(sim): + """clone_from_template exposes per-group ClonePlan dicts with prototype-to-env masks. - class _FakeModel: - body_label = ["/World/envs/env_0/A", "/World/envs/env_1/A"] - articulation_label = ["/World/envs/env_0/Robot", "/World/envs/env_1/Robot"] - - fake_model = _FakeModel() - fake_state = object() - - def _fake_prebuild(*args, **kwargs): - return fake_model, fake_state + Builds two USD prototypes under one group, clones across four envs with the deterministic + sequential strategy, and asserts the returned dict has one entry keyed by the group's + destination template, with a ``[2, 4]`` boolean mask whose columns sum to one. + """ + num_clones = 4 + cfg = TemplateCloneCfg(device=sim.cfg.device, clone_strategy=sequential, clone_physics=False) - monkeypatch.setattr(newton_replicate, "newton_visualizer_prebuild", _fake_prebuild) + sim_utils.create_prim(cfg.template_root, "Xform") + sim_utils.create_prim(f"{cfg.template_root}/Object", "Xform") + sim_utils.create_prim(f"{cfg.template_root}/Object/proto_asset_0", "Xform") + sim_utils.create_prim(f"{cfg.template_root}/Object/proto_asset_1", "Xform") + sim_utils.create_prim("/World/envs", "Xform") + for i in range(num_clones): + sim_utils.create_prim(f"/World/envs/env_{i}", "Xform", translation=(0, 0, 0)) - captured: list[VisualizerPrebuiltArtifacts] = [] - hook = resolve_visualizer_clone_fn( - physics_backend="physx", - requirements=SceneDataRequirement(requires_newton_model=True), - stage=sim_utils.get_current_stage(), - set_visualizer_artifact=lambda artifact: captured.append(artifact), - ) + stage = sim_utils.get_current_stage() + plans = clone_from_template(stage, num_clones=num_clones, template_clone_cfg=cfg) + + assert isinstance(plans, dict) + assert list(plans.keys()) == ["/World/envs/env_{}/Object"] + plan = plans["/World/envs/env_{}/Object"] + assert isinstance(plan, ClonePlan) + assert plan.dest_template == "/World/envs/env_{}/Object" + assert sorted(plan.prototype_paths) == [ + "/World/template/Object/proto_asset_0", + "/World/template/Object/proto_asset_1", + ] + assert plan.clone_mask.shape == (2, num_clones) + assert plan.clone_mask.dtype == torch.bool + # Each env gets exactly one prototype (column-sum invariant) + assert torch.all(plan.clone_mask.sum(dim=0) == 1) + # Sequential strategy assigns env i → prototype (i % num_protos) + actual_proto_idx = plan.clone_mask.to(torch.int).argmax(dim=0).cpu() + assert torch.equal(actual_proto_idx, torch.tensor([0, 1, 0, 1])) + + +def test_clone_from_template_returns_empty_dict_when_no_prototypes(sim): + """clone_from_template returns an empty dict when no prototypes match the identifier.""" + num_clones = 2 + cfg = TemplateCloneCfg(device=sim.cfg.device, clone_strategy=sequential, clone_physics=False) + + sim_utils.create_prim(cfg.template_root, "Xform") + sim_utils.create_prim("/World/envs", "Xform") + for i in range(num_clones): + sim_utils.create_prim(f"/World/envs/env_{i}", "Xform", translation=(0, 0, 0)) - assert callable(hook) - hook( - stage=sim_utils.get_current_stage(), - sources=["/World/template/A"], - destinations=["/World/envs/env_{}/A"], - env_ids=torch.tensor([0, 1], dtype=torch.long), - mapping=torch.ones((1, 2), dtype=torch.bool), - device="cpu", - ) + stage = sim_utils.get_current_stage() + plans = clone_from_template(stage, num_clones=num_clones, template_clone_cfg=cfg) - assert len(captured) == 1 - artifact = captured[0] - assert isinstance(artifact, VisualizerPrebuiltArtifacts) - assert artifact.model is fake_model - assert artifact.state is fake_state - assert artifact.rigid_body_paths == fake_model.body_label - assert artifact.articulation_paths == fake_model.articulation_label - assert artifact.num_envs == 2 + assert plans == {} diff --git a/source/isaaclab/test/sim/test_physx_scene_data_provider_visualizer_contract.py b/source/isaaclab/test/sim/test_physx_scene_data_provider_visualizer_contract.py index 927fe351d202..979d66cc4a7e 100644 --- a/source/isaaclab/test/sim/test_physx_scene_data_provider_visualizer_contract.py +++ b/source/isaaclab/test/sim/test_physx_scene_data_provider_visualizer_contract.py @@ -7,71 +7,215 @@ from __future__ import annotations +import sys from types import SimpleNamespace -from unittest.mock import patch +import pytest +import torch from isaaclab_physx.scene_data_providers import PhysxSceneDataProvider -from isaaclab.physics.scene_data_requirements import VisualizerPrebuiltArtifacts +from isaaclab.cloner import ClonePlan +PROVIDER_MOD = "isaaclab_physx.scene_data_providers.physx_scene_data_provider" -def _make_provider(): - return object.__new__(PhysxSceneDataProvider) +def _silent_stage() -> SimpleNamespace: + """Stage stub whose ``GetPrimAtPath`` returns an invalid prim — env xforms read as zero.""" + return SimpleNamespace(GetPrimAtPath=lambda path: SimpleNamespace(IsValid=lambda: False)) -def test_get_newton_model_returns_model_when_sync_enabled(): + +@pytest.fixture +def stub_provider(): + """Bare :class:`PhysxSceneDataProvider` with all buffer attrs initialized to defaults. + + Tests assign ``_simulation_context`` and ``_stage`` themselves; everything else is the + pre-build state the build path expects. + """ + p = object.__new__(PhysxSceneDataProvider) + p._device = "cpu" + p._xform_views = {} + p._view_body_index_map = {} + p._view_order_tensors = {} + p._pose_buf_num_bodies = 0 + p._positions_buf = None + p._orientations_buf = None + p._covered_buf = None + p._xform_mask_buf = None + return p + + +@pytest.fixture +def newton_stub(monkeypatch): + """Stub the ``isaaclab_newton`` newton-prebuild module and the side-effect helpers. + + Returned :class:`SimpleNamespace` exposes: + + * ``calls`` — list of kwargs from each prebuild invocation, + * ``model`` / ``state_obj`` — what prebuild returns; tests can override before invoking. + """ + state = SimpleNamespace( + calls=[], + model=SimpleNamespace(body_label=[], articulation_label=[]), + state_obj=object(), + ) + + def _prebuild(**kwargs): + state.calls.append(dict(kwargs)) + return state.model, state.state_obj + + monkeypatch.setitem( + sys.modules, "isaaclab_newton.cloner.newton_replicate", SimpleNamespace(newton_visualizer_prebuild=_prebuild) + ) + monkeypatch.setattr(f"{PROVIDER_MOD}.UsdGeom.GetStageUpAxis", lambda stage: "Z") + monkeypatch.setattr(f"{PROVIDER_MOD}.replace_newton_shape_colors", lambda m, s: None) + return state + + +def test_get_newton_model_returns_model_when_sync_enabled(stub_provider): """Callers receive the full Newton model from :meth:`get_newton_model`.""" - provider = _make_provider() - provider._needs_newton_sync = True - provider._newton_model = "full-model" - - assert provider.get_newton_model() == "full-model" - - -@patch("isaaclab_physx.scene_data_providers.physx_scene_data_provider.replace_newton_shape_colors", lambda m, s: None) -def test_load_prebuilt_artifact_populates_provider_state(): - """Loading the prebuilt artifact sets model, state, and rigid-body paths.""" - provider = _make_provider() - artifact = VisualizerPrebuiltArtifacts( - model="prebuilt-model", - state="prebuilt-state", - rigid_body_paths=["/World/envs/env_0/A"], - articulation_paths=["/World/envs/env_0/Robot"], - num_envs=4, + stub_provider._needs_newton_sync = True + stub_provider._newton_model = "full-model" + assert stub_provider.get_newton_model() == "full-model" + + +def test_build_from_clone_plans_populates_provider_state(stub_provider, newton_stub): + """Building from per-group clone plans sets model, state, and rigid-body paths. + + Asserts the provider derives its own (sources, destinations, mask) from the plans + without consulting any auxiliary spec object: representative source paths are recovered + from ``dest_template.format()``, masks are concatenated + along the prototype axis, and per-env positions are read from stage xforms. + """ + newton_stub.model = SimpleNamespace( + body_label=["/World/envs/env_0/Object/A"], + articulation_label=["/World/envs/env_0/Robot"], + ) + plans = { + "/World/envs/env_{}/Object": ClonePlan( + dest_template="/World/envs/env_{}/Object", + prototype_paths=["/World/template/Object/proto_0", "/World/template/Object/proto_1"], + # proto 0 → env 0, 2 ; proto 1 → env 1, 3 + clone_mask=torch.tensor([[True, False, True, False], [False, True, False, True]], dtype=torch.bool), + ), + "/World/envs/env_{}/Robot": ClonePlan( + dest_template="/World/envs/env_{}/Robot", + prototype_paths=["/World/template/Robot/proto_0"], + clone_mask=torch.ones((1, 4), dtype=torch.bool), + ), + } + stub_provider._simulation_context = SimpleNamespace(get_clone_plans=lambda: plans) + stub_provider._stage = _silent_stage() + + stub_provider._build_newton_model_from_clone_plans() + + assert stub_provider._newton_model is newton_stub.model + assert stub_provider._newton_state is newton_stub.state_obj + assert stub_provider._rigid_body_paths == newton_stub.model.body_label + assert stub_provider._rigid_body_view_paths == newton_stub.model.body_label + newton_stub.model.articulation_label + assert stub_provider._num_envs_at_last_newton_build == 4 + assert stub_provider._last_newton_model_build_source == "built" + + kw = newton_stub.calls[-1] + # Source recovery picks the first-env user per prototype. + assert kw["sources"] == [ + "/World/envs/env_0/Object", + "/World/envs/env_1/Object", + "/World/envs/env_0/Robot", + ] + assert kw["destinations"] == ["/World/envs/env_{}/Object", "/World/envs/env_{}/Object", "/World/envs/env_{}/Robot"] + assert kw["mapping"].shape == (3, 4) + assert kw["positions"].shape == (4, 3) + + +def test_build_from_clone_plans_missing_sets_error_state(stub_provider): + """When no clone plans are published, model/state stay unset.""" + stub_provider._simulation_context = SimpleNamespace(get_clone_plans=lambda: {}) + stub_provider._stage = object() + + stub_provider._build_newton_model_from_clone_plans() + + assert stub_provider._last_newton_model_build_source == "missing" + assert stub_provider._newton_model is None + assert stub_provider._newton_state is None + + +def test_build_from_clone_plans_skips_unused_prototype_rows(stub_provider, newton_stub): + """A prototype row with no assigned env (all-False mask row) is dropped, not raised on. + + When ``num_prototypes > num_envs`` under a sequential strategy (or any strategy that + leaves some prototypes unused), ``clone_mask[row].nonzero()[0]`` would otherwise raise + ``IndexError``. The provider must filter unused rows out of sources/destinations/mask. + """ + # 3 prototypes, 2 envs, sequential: env 0 → proto 0, env 1 → proto 1, proto 2 unused. + plans = { + "/World/envs/env_{}/Object": ClonePlan( + dest_template="/World/envs/env_{}/Object", + prototype_paths=[ + "/World/template/Object/proto_0", + "/World/template/Object/proto_1", + "/World/template/Object/proto_2", + ], + clone_mask=torch.tensor([[True, False], [False, True], [False, False]], dtype=torch.bool), + ) + } + stub_provider._simulation_context = SimpleNamespace(get_clone_plans=lambda: plans) + stub_provider._stage = _silent_stage() + + stub_provider._build_newton_model_from_clone_plans() + + assert stub_provider._last_newton_model_build_source == "built" + kw = newton_stub.calls[-1] + # Unused proto_2 row dropped; only the two assigned prototypes survive. + assert kw["sources"] == ["/World/envs/env_0/Object", "/World/envs/env_1/Object"] + assert kw["mapping"].shape == (2, 2) + + +def test_build_from_clone_plans_uses_dest_template_for_env_lookup(stub_provider, newton_stub): + """Env-origin lookup uses the per-plan ``dest_template`` prefix, not a hardcoded path. + + A scene with a non-default env path (``/Stage/scenes/env_``) should still have its + xform translates read correctly. Replaces the prior hardcoded ``/World/envs/env_``. + """ + visited: list[str] = [] + + def _get_prim(path): + visited.append(path) + return SimpleNamespace(IsValid=lambda: False) + + plans = { + "/Stage/scenes/env_{}/Object": ClonePlan( + dest_template="/Stage/scenes/env_{}/Object", + prototype_paths=["/Stage/template/Object/proto_0"], + clone_mask=torch.ones((1, 3), dtype=torch.bool), + ) + } + stub_provider._simulation_context = SimpleNamespace(get_clone_plans=lambda: plans) + stub_provider._stage = SimpleNamespace(GetPrimAtPath=_get_prim) + + stub_provider._build_newton_model_from_clone_plans() + + assert {f"/Stage/scenes/env_{i}" for i in range(3)} <= set(visited) + assert not any(p.startswith("/World/envs/") for p in visited) + + +def test_clone_plan_is_hashable_with_unhashable_fields(): + """``ClonePlan`` must hash despite carrying a tensor and a list. + + With ``field(hash=False)`` on the unhashable members, hashing operates on + ``dest_template`` only — the natural identity (it is the dict key in + :meth:`SimulationContext.get_clone_plans`). + """ + plan_a = ClonePlan( + dest_template="/World/envs/env_{}/Object", + prototype_paths=["/World/template/Object/proto_0"], + clone_mask=torch.ones((1, 4), dtype=torch.bool), + ) + plan_b = ClonePlan( + dest_template="/World/envs/env_{}/Object", + prototype_paths=["/World/template/Object/proto_99"], + clone_mask=torch.zeros((1, 4), dtype=torch.bool), ) - provider._simulation_context = SimpleNamespace(get_scene_data_visualizer_prebuilt_artifact=lambda: artifact) - provider._stage = None - - provider._xform_views = {"old": object()} - provider._view_body_index_map = {"old": [1]} - provider._view_order_tensors = {"old": object()} - provider._pose_buf_num_bodies = 7 - provider._positions_buf = object() - provider._orientations_buf = object() - provider._covered_buf = object() - provider._xform_mask_buf = object() - provider._load_newton_model_from_prebuilt_artifact() - assert provider._newton_model == "prebuilt-model" - assert provider._newton_state == "prebuilt-state" - assert provider._rigid_body_paths == ["/World/envs/env_0/A"] - assert provider._rigid_body_view_paths == ["/World/envs/env_0/A", "/World/envs/env_0/Robot"] - assert provider._num_envs_at_last_newton_build == 4 - assert provider._last_newton_model_build_source == "prebuilt" - assert provider._xform_views == {} - assert provider._view_body_index_map == {} - assert provider._view_order_tensors == {} - assert provider._pose_buf_num_bodies == 0 - assert provider._positions_buf is None - assert provider._orientations_buf is None - assert provider._covered_buf is None - assert provider._xform_mask_buf is None - - -def test_load_prebuilt_artifact_missing_sets_error_state(): - """When no artifact is registered, model/state stay unset.""" - provider = _make_provider() - provider._simulation_context = SimpleNamespace(get_scene_data_visualizer_prebuilt_artifact=lambda: None) - provider._load_newton_model_from_prebuilt_artifact() - assert provider._last_newton_model_build_source == "missing" - assert provider._newton_model is None - assert provider._newton_state is None + assert isinstance(hash(plan_a), int) + # Equality folds in only dest_template, so two plans with the same destination compare + # equal regardless of prototype/mask differences. + assert plan_a == plan_b diff --git a/source/isaaclab/test/sim/test_simulation_context_visualizers.py b/source/isaaclab/test/sim/test_simulation_context_visualizers.py index 4ac6faabba54..1f86f32872c9 100644 --- a/source/isaaclab/test/sim/test_simulation_context_visualizers.py +++ b/source/isaaclab/test/sim/test_simulation_context_visualizers.py @@ -449,7 +449,7 @@ def _make_context_with_settings( ctx._visualizers = [] ctx._scene_data_provider = _FakeProvider() ctx._scene_data_requirements = None - ctx._visualizer_prebuilt_artifact = None + ctx._clone_plans = {} ctx._visualizer_step_counter = 0 ctx._viz_dt = 0.01 ctx.get_setting = lambda name: settings.get(name) diff --git a/source/isaaclab_newton/changelog.d/clone-plan-visualizer-cleanup.minor.rst b/source/isaaclab_newton/changelog.d/clone-plan-visualizer-cleanup.minor.rst new file mode 100644 index 000000000000..6fed4677a471 --- /dev/null +++ b/source/isaaclab_newton/changelog.d/clone-plan-visualizer-cleanup.minor.rst @@ -0,0 +1,9 @@ +Removed +^^^^^^^ + +* **Breaking:** Removed + ``isaaclab_newton.cloner.newton_replicate.create_newton_visualizer_prebuild_clone_fn``. + Callers that need a Newton model for visualization should call + :func:`~isaaclab_newton.cloner.newton_replicate.newton_visualizer_prebuild` + directly with the ``(sources, destinations, env_ids, mask, positions)`` bundle + derived from :meth:`~isaaclab.sim.SimulationContext.get_clone_plans`. diff --git a/source/isaaclab_newton/isaaclab_newton/cloner/newton_replicate.py b/source/isaaclab_newton/isaaclab_newton/cloner/newton_replicate.py index 2b257de9aec5..34cd35de4fa2 100644 --- a/source/isaaclab_newton/isaaclab_newton/cloner/newton_replicate.py +++ b/source/isaaclab_newton/isaaclab_newton/cloner/newton_replicate.py @@ -5,16 +5,12 @@ from __future__ import annotations -from collections.abc import Callable - import torch import warp as wp from newton import ModelBuilder, solvers from newton._src.usd.schemas import SchemaResolverNewton, SchemaResolverPhysx -from pxr import Usd, UsdGeom - -from isaaclab.physics.scene_data_requirements import VisualizerPrebuiltArtifacts +from pxr import Usd from isaaclab_newton.physics import NewtonManager @@ -243,55 +239,3 @@ def newton_visualizer_prebuild( model = builder.finalize(device=device) state = model.state() return model, state - - -def create_newton_visualizer_prebuild_clone_fn( - stage, - set_visualizer_artifact: Callable[[VisualizerPrebuiltArtifacts | None], None], -): - """Create a cloner callback that prebuilds Newton visualizer artifacts. - - Args: - stage: USD stage used by the clone callback. - set_visualizer_artifact: Callback used to store the produced prebuilt artifact. - - Returns: - Clone callback that builds and stores visualizer prebuilt artifacts. - """ - up_axis = UsdGeom.GetStageUpAxis(stage) - - def _visualizer_clone_fn( - stage, - sources, - destinations, - env_ids, - mapping, - positions=None, - quaternions=None, - device="cpu", - ): - """Prebuild Newton model/state and store visualizer artifacts for clone consumers.""" - model, state = newton_visualizer_prebuild( - stage=stage, - sources=sources, - destinations=destinations, - env_ids=env_ids, - mapping=mapping, - positions=positions, - quaternions=quaternions, - device=device, - up_axis=up_axis, - ) - set_visualizer_artifact( - VisualizerPrebuiltArtifacts( - model=model, - state=state, - rigid_body_paths=list(getattr(model, "body_label", None) or getattr(model, "body_key", [])), - articulation_paths=list( - getattr(model, "articulation_label", None) or getattr(model, "articulation_key", []) - ), - num_envs=int(mapping.size(1)), - ) - ) - - return _visualizer_clone_fn diff --git a/source/isaaclab_physx/changelog.d/clone-plan-visualizer-cleanup.skip b/source/isaaclab_physx/changelog.d/clone-plan-visualizer-cleanup.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_physx/isaaclab_physx/scene_data_providers/physx_scene_data_provider.py b/source/isaaclab_physx/isaaclab_physx/scene_data_providers/physx_scene_data_provider.py index c501e8b32831..9a88660da498 100644 --- a/source/isaaclab_physx/isaaclab_physx/scene_data_providers/physx_scene_data_provider.py +++ b/source/isaaclab_physx/isaaclab_physx/scene_data_providers/physx_scene_data_provider.py @@ -13,6 +13,7 @@ from collections import deque from typing import Any +import torch import warp as wp from pxr import UsdGeom, UsdPhysics @@ -41,7 +42,8 @@ class PhysxSceneDataProvider(BaseSceneDataProvider): - body poses via PhysX tensor views, with FrameView fallback - camera poses & intrinsics - USD stage handles - - Newton model/state (from the simulation context prebuilt payload when required) + - Newton model/state (built locally from the scene's per-group :class:`ClonePlan` map + when required) """ # ---- Environment discovery / metadata ------------------------------------------------- @@ -122,12 +124,12 @@ def __init__(self, stage, simulation_context) -> None: self._xform_mask_buf = None # View index order as device tensors for vectorized scatter in _apply_view_poses. self._view_order_tensors: dict[str, Any] = {} - # Last load outcome (tests / debug): "prebuilt" | "missing" | "error". + # Last load outcome (tests / debug): "built" | "missing" | "error". self._last_newton_model_build_source: str | None = None self._last_newton_model_build_elapsed_ms: float | None = None if self._needs_newton_sync: - self._load_newton_model_from_prebuilt_artifact() + self._build_newton_model_from_clone_plans() self._setup_rigid_body_view() # ---- Newton model + PhysX view setup -------------------------------------------------- @@ -148,87 +150,104 @@ def _refresh_newton_model_if_needed(self) -> None: needs_rebuild = self._newton_model is None or self._newton_state is None needs_rebuild = needs_rebuild or (self._num_envs_at_last_newton_build != num_envs) if needs_rebuild: - self._load_newton_model_from_prebuilt_artifact() + self._build_newton_model_from_clone_plans() self._setup_rigid_body_view() - def _model_body_paths(self, model) -> list[str]: - """Return body paths/keys from a Newton model. - - Args: - model: Newton model object. - - Returns: - Body paths/keys from the model, or an empty list when unavailable. + def _build_newton_model_from_clone_plans(self) -> None: + """Build Newton model and state from the scene's per-group :class:`ClonePlan` map. + + Reads plans :meth:`InteractiveScene.clone_environments` publishes on + :class:`SimulationContext`, derives the flat ``(sources, destinations, mask)`` shape + :func:`isaaclab_newton.cloner.newton_visualizer_prebuild` expects, and caches the + resulting model/state. Per-prototype source paths recover as + ``dest_template.format()``; per-env positions are + read off ``xformOp:translate`` on the env-level prims derived from the same template. + Pre-condition violations raise :class:`RuntimeError` (logged as ``"missing"``); + ``isaaclab_newton`` being absent (optional dep) maps to ``"missing"`` via the + import's own exception types; unexpected failures fall through to ``"error"``. """ - if model is None: - return [] - return list(getattr(model, "body_label", None) or getattr(model, "body_key", [])) - - def _load_newton_model_from_prebuilt_artifact(self) -> None: - """Load Newton model and state from the simulation context prebuilt artifact.""" start_t = time.perf_counter() + source = "missing" try: - artifact = self._simulation_context.get_scene_data_visualizer_prebuilt_artifact() - if not artifact: - self._last_newton_model_build_source = "missing" - logger.error( - "[PhysxSceneDataProvider] No visualizer prebuilt artifact on the simulation context " - "(expected VisualizerPrebuiltArtifacts from scene setup)." - ) - self._clear_newton_model_state() - return - - model = artifact.model - state = artifact.state + plans = self._simulation_context.get_clone_plans() + if not plans: + raise RuntimeError("No clone plans on simulation context.") + from isaaclab_newton.cloner.newton_replicate import newton_visualizer_prebuild + + # Flatten per-group plans into one (sources, destinations, mask) bundle. Source + # paths recover via ``dest_template.format()``; + # all-False rows are dropped (possible when ``num_prototypes > num_envs``). + plan_list = list(plans.values()) + num_envs = plan_list[0].clone_mask.size(1) + if any(p.clone_mask.size(1) != num_envs for p in plan_list): + raise RuntimeError(f"Clone plans disagree on num_envs: {[p.clone_mask.size(1) for p in plan_list]}") + sources, destinations, mask_rows = [], [], [] + for p in plan_list: + for i in range(p.clone_mask.size(0)): + nz = p.clone_mask[i].nonzero(as_tuple=False) + if nz.numel() == 0: + continue + sources.append(p.dest_template.format(int(nz[0].item()))) + destinations.append(p.dest_template) + mask_rows.append(p.clone_mask[i : i + 1]) + if not sources: + raise RuntimeError("All clone-plan prototype rows are empty.") + mask = torch.cat(mask_rows, dim=0) + + # Env-level path template = dest_template up to the first ``{}``. Per-env world + # positions: xformOp:translate read off each env prim; missing prims fall through. + env_path_template = plan_list[0].dest_template.split("{}")[0] + "{}" + positions = torch.zeros((num_envs, 3), dtype=torch.float32, device=self._device) + for i in range(num_envs): + prim = self._stage.GetPrimAtPath(env_path_template.format(i)) + if prim.IsValid() and (v := prim.GetAttribute("xformOp:translate").Get()) is not None: + positions[i] = torch.tensor([v[0], v[1], v[2]], device=self._device) + + model, state = newton_visualizer_prebuild( + stage=self._stage, + sources=sources, + destinations=destinations, + env_ids=torch.arange(num_envs, dtype=torch.long, device=mask.device), + mapping=mask, + positions=positions, + device=self._device, + up_axis=UsdGeom.GetStageUpAxis(self._stage), + ) if model is None or state is None: - self._last_newton_model_build_source = "missing" - logger.error( - "[PhysxSceneDataProvider] Prebuilt artifact is missing model or state; cannot sync PhysX to Newton." - ) - self._clear_newton_model_state() - return - - self._newton_model = model - self._newton_state = state + raise RuntimeError("newton_visualizer_prebuild returned None.") + self._newton_model, self._newton_state = model, state replace_newton_shape_colors(self._newton_model, self._stage) - - body_paths = list(artifact.rigid_body_paths) or self._model_body_paths(model) - self._rigid_body_paths = body_paths - view_paths = list(body_paths) - if artifact.articulation_paths: - seen = set(view_paths) - for path in artifact.articulation_paths: - if path not in seen: - view_paths.append(path) - seen.add(path) - self._rigid_body_view_paths = view_paths + # Newton renamed ``*_key`` → ``*_label`` mid-development; fall back so we work either way. + # ``dict.fromkeys`` preserves order while deduping — articulation roots can overlap rigid bodies. + label_or_key = lambda kind: list(getattr(model, f"{kind}_label", None) or getattr(model, f"{kind}_key", [])) # noqa: E731 + self._rigid_body_paths = label_or_key("body") + self._rigid_body_view_paths = list(dict.fromkeys(self._rigid_body_paths + label_or_key("articulation"))) + # Reset cached views/buffers; rebuilt lazily by ``_setup_rigid_body_view``. self._xform_views.clear() - self._view_body_index_map = {} self._view_order_tensors.clear() + self._view_body_index_map = {} self._pose_buf_num_bodies = 0 - self._positions_buf = None - self._orientations_buf = None - self._covered_buf = None - self._xform_mask_buf = None - self._num_envs_at_last_newton_build = int(artifact.num_envs) - self._last_newton_model_build_source = "prebuilt" + self._positions_buf = self._orientations_buf = self._covered_buf = self._xform_mask_buf = None + self._num_envs_at_last_newton_build = num_envs + source = "built" + except (ImportError, ModuleNotFoundError) as exc: + logger.warning("[PhysxSceneDataProvider] isaaclab_newton not available: %s", exc) + self._clear_newton_model_state() + except RuntimeError as exc: + logger.error("[PhysxSceneDataProvider] %s", exc) + self._clear_newton_model_state() except Exception as exc: - self._last_newton_model_build_source = "error" - logger.error("[PhysxSceneDataProvider] Failed to load Newton model from prebuilt artifact: %s", exc) + source = "error" + logger.error("[PhysxSceneDataProvider] Failed to build Newton model from clone plans: %s", exc) self._clear_newton_model_state() finally: - elapsed_ms = (time.perf_counter() - start_t) * 1000.0 - self._last_newton_model_build_elapsed_ms = elapsed_ms - try: - num_envs = self.get_num_envs() - except Exception: - num_envs = -1 + self._last_newton_model_build_elapsed_ms = (time.perf_counter() - start_t) * 1000.0 + self._last_newton_model_build_source = source logger.debug( - "[PhysxSceneDataProvider] Newton model load source=%s num_envs=%d elapsed_ms=%.2f", - self._last_newton_model_build_source, - num_envs, - elapsed_ms, + "[PhysxSceneDataProvider] Newton model build source=%s elapsed_ms=%.2f", + source, + self._last_newton_model_build_elapsed_ms, ) def _clear_newton_model_state(self) -> None: From 347ce9448cad595d7d6d76075427ed1fb7867983 Mon Sep 17 00:00:00 2001 From: hujc Date: Mon, 4 May 2026 14:52:55 -0700 Subject: [PATCH 33/40] =?UTF-8?q?[Rough=20Locomotion]=20Part=203:=20G1=20o?= =?UTF-8?q?n=20Newton=20(max=5Fiterations=203000=E2=86=925000,=20no=20phys?= =?UTF-8?q?ics=20tuning)=20(#5312)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 1. Summary Enable G1 rough-terrain training on Newton. The only engine-specific change is a `~1.7×` bump on `max_iterations` (Newton = 5000, PhysX = 3000). No physics, solver, reward, or action-space tuning — the G1 rough env config is identical on both backends. ## 2. Core finding — PhysX saturates at iter 3000; Newton matches by iter 5000 Ran both backends for 7500 iter on identical configs. **Reward alone is misleading** — PhysX reward oscillates +16 to +19 across its entire run past iter 3000 without improving. Episode length confirms the plateau: | iter | PhysX reward | PhysX ep_len | Newton reward | Newton ep_len | |---:|---:|---:|---:|---:| | 3000 | **+18.14** | **983** | +6.21 | 979 | | 4000 | +19.38 | 986 | +10.65 | 983 | | **5000** | **+18.04** | **978** | **+16.01** | **984** | | 5500 | +18.19 | 983 | +16.60 | 985 | | 6000 | +17.07 | 981 | +18.86 | 996 | | 6500 | +16.71 | 989 | +19.80 | 996 | | 7000 | +16.27 | 968 | +18.10 | 976 | | 7500 | +15.81 | — | +17.56 | 969 | - **PhysX plateau**: both metrics stable at iter 3000 (+18.14 / 983). No meaningful gain past iter 3000. - **Newton at iter 5000**: (+16.01 / 984) — matches PhysX quality on both reward and ep_len. - **Newton at iter 6000+**: equals or exceeds PhysX on both metrics. Episode length > 960 everywhere means the robot is stable (not falling) even when reward is low — reward alone is not a sufficient convergence signal. ## 3. Ablation record Tested at L40, 4096 envs, seed=42, Newton @ `381781c2` (1.2.0.dev0): | Variant | iter | Reward | Verdict | |---|---:|---:|---| | A0 — Vanilla Newton | 3000 | +6.21 | Newton slower but learning | | A1 — Newton armature 0.01 | 3000 | +5.14 | No help | | A2 — Newton armature 0.03 | 3000 | +6.43 | No help | | D0 — Vanilla Newton (extended) | 5000 | +16.01 | **Matches PhysX plateau quality** | | E0 — Vanilla PhysX | 3000 | +18.14 | PhysX plateau | Key observations: - Armature tuning (0.01, 0.03) does not change Newton's convergence rate on G1. - Damping preset (5 → 20) and finger-removal also tested in earlier rounds; no durable benefit once Newton is given enough iterations. - Newton catches PhysX by iter 5000 on vanilla config, so the framework-level `max_iterations` bump is sufficient on its own. ## 4. Change `source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/g1/agents/rsl_rl_ppo_cfg.py`: ```python from isaaclab_tasks.utils import preset class G1RoughPPORunnerCfg(RslRlOnPolicyRunnerCfg): # Newton needs ~1.7x the PPO iterations to match PhysX on G1. PhysX saturates near iter 3000 # (reward ≈ +18, ep_len ≈ 980) and does not meaningfully improve on either metric past that — # reward oscillates +16 to +19 through iter 7500, ep_len stays flat. Newton reaches the same # (reward, ep_len) quality at iter 5000 (+16 / 984). Comparing reward alone is misleading: # ep_len confirms the robot is stable in both cases. The gap is sample-efficiency, not a # ceiling — no physics or reward tuning closes it. max_iterations = preset(default=3000, newton=5000) ``` G1 `rough_env_cfg.py` is unchanged on this branch — no finger removal, no armature preset, no damping preset. Precedent for per-robot `max_iterations` tuning: Allegro Hand (5000), Spot (20000). ## Type of change - New feature (non-breaking). --- .../changelog.d/g1-rough-terrain-wip.rst | 11 +++++++++++ .../velocity/config/g1/agents/rsl_rl_ppo_cfg.py | 10 +++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 source/isaaclab_tasks/changelog.d/g1-rough-terrain-wip.rst diff --git a/source/isaaclab_tasks/changelog.d/g1-rough-terrain-wip.rst b/source/isaaclab_tasks/changelog.d/g1-rough-terrain-wip.rst new file mode 100644 index 000000000000..9efbf82b8bf6 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/g1-rough-terrain-wip.rst @@ -0,0 +1,11 @@ +Added +^^^^^ + +* Added Newton rough terrain support for the G1 biped locomotion velocity + env. The only engine-specific change is a ~1.7x ``max_iterations`` preset on + :class:`~isaaclab_tasks.manager_based.locomotion.velocity.config.g1.agents.rsl_rl_ppo_cfg.G1RoughPPORunnerCfg` + (Newton = 5000, PhysX = 3000). PhysX saturates near iter 3000 on both + reward (≈ +18) and episode length (≈ 980) and does not meaningfully + improve further; Newton reaches the same (reward, ep_len) quality at + iter 5000. The iteration budget is bumped rather than tuning physics + or reward terms. diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/g1/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/g1/agents/rsl_rl_ppo_cfg.py index 61a6d0261b9f..7b61c184d353 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/g1/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/g1/agents/rsl_rl_ppo_cfg.py @@ -7,11 +7,19 @@ from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg, RslRlPpoActorCriticCfg, RslRlPpoAlgorithmCfg +from isaaclab_tasks.utils import preset + @configclass class G1RoughPPORunnerCfg(RslRlOnPolicyRunnerCfg): num_steps_per_env = 24 - max_iterations = 3000 + # Newton needs ~1.7x the PPO iterations to match PhysX on G1. PhysX saturates near iter 3000 + # (reward ≈ +18, ep_len ≈ 980) and does not meaningfully improve on either metric past that — + # reward oscillates +16 to +19 through iter 7500, ep_len stays flat. Newton reaches the same + # (reward, ep_len) quality at iter 5000 (+16 / 984). Comparing reward alone is misleading: + # ep_len confirms the robot is stable in both cases. The gap is sample-efficiency, not a + # ceiling — no physics or reward tuning closes it. + max_iterations = preset(default=3000, newton=5000) save_interval = 50 experiment_name = "g1_rough" policy = RslRlPpoActorCriticCfg( From 08dbf1f39262e82cda73ed03b8ae335d6aa60b17 Mon Sep 17 00:00:00 2001 From: r-schmitt <139814266+r-schmitt@users.noreply.github.com> Date: Mon, 4 May 2026 18:18:00 -0400 Subject: [PATCH 34/40] add RenderContext to docs (#5489) # Description Add RenderContext to documentation ## Type of change - Documentation update ## 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 updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- .../overview/core-concepts/renderers.rst | 29 ++++++++++++++----- .../overview/core-concepts/sensors/camera.rst | 3 +- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/docs/source/overview/core-concepts/renderers.rst b/docs/source/overview/core-concepts/renderers.rst index 4971e7a1d0cf..2ae8bae28af3 100644 --- a/docs/source/overview/core-concepts/renderers.rst +++ b/docs/source/overview/core-concepts/renderers.rst @@ -39,25 +39,35 @@ The renderer system consists of: 2. **Renderer** — Factory that instantiates the appropriate backend based on renderer configuration class 3. **RendererCfg** — Base configuration; each backend extends it with backend-specific options 4. **Concrete implementations** — Backend-specific renderers in extension packages +5. **RenderContext** — A management class for instantiating and accessing renderer instances using a **RendererCfg**. + After instantiation, a config can then be used to acquire the instance of the renderer as needed. .. code-block:: python - from isaaclab.renderers import BaseRenderer, Renderer + import isaaclab.sim as sim_utils + from isaaclab.renderers import BaseRenderer from isaaclab_newton.renderers import NewtonWarpRendererCfg # Create a Newton Warp renderer (no Isaac Sim required) - renderer: BaseRenderer = Renderer(NewtonWarpRendererCfg()) + sim_ctx = sim_utils.SimulationContext.instance() + # RenderContext.get_renderer will instantiate the renderer backend + # or return an existing renderer with a matching config + renderer: BaseRenderer = sim_ctx.render_context.get_renderer(NewtonWarpRendererCfg()) assert isinstance(renderer, BaseRenderer) For the RTX renderer (requires Isaac Sim): .. code-block:: python - from isaaclab.renderers import Renderer - from isaaclab.renderers import IsaacRtxRendererCfg # or OVRTXRendererCfg + import isaaclab.sim as sim_utils + from isaaclab.renderers import BaseRenderer + from isaaclab_physx.renderers import IsaacRtxRendererCfg # Create an RTX renderer - renderer: BaseRenderer = Renderer(IsaacRtxRendererCfg()) + sim_ctx = sim_utils.SimulationContext.instance() + # RenderContext.get_renderer will instantiate the renderer backend + # or return an existing renderer with a matching config + renderer: BaseRenderer = sim_ctx.render_context.get_renderer(IsaacRtxRendererCfg()) For RTX renderer settings and presets (quality, balanced, performance), see :doc:`/source/how-to/configure_rendering`. @@ -65,8 +75,8 @@ For RTX renderer settings and presets (quality, balanced, performance), see Core concepts ------------- -- **Use the factory**: Always instantiate renderers via the factory with a renderer-specific config class - (e.g. ``Renderer(IsaacRtxRendererCfg())``). Do not import or instantiate concrete backend classes +- **Use the RenderContext**: Always instantiate renderers via the RenderContext with a renderer-specific config class + (e.g. ``sim_ctx.render_context.get_renderer(IsaacRtxRendererCfg())``). Do not import or instantiate concrete backend classes (e.g. ``IsaacRtxRenderer``, ``OVRTXRenderer``) directly—their names and package locations are implementation details and may change without notice. @@ -76,11 +86,14 @@ Core concepts .. code-block:: python + import isaaclab.sim as sim_utils + from isaaclab.renderers import BaseRenderer # Lightweight: does not import OVRTX backend dependencies from isaaclab_ov.renderers import OVRTXRendererCfg # Lazily loads ovrtx when instantiated; may fail if isaaclab_ov / ovrtx is not installed - renderer: BaseRenderer = Renderer(OVRTXRendererCfg()) + sim_ctx = sim_utils.SimulationContext.instance() + renderer: BaseRenderer = sim_ctx.render_context.get_renderer(OVRTXRendererCfg()) Installing the OVRTX renderer ------------------------------ diff --git a/docs/source/overview/core-concepts/sensors/camera.rst b/docs/source/overview/core-concepts/sensors/camera.rst index 9d7d7e0512c5..29673f2bf465 100644 --- a/docs/source/overview/core-concepts/sensors/camera.rst +++ b/docs/source/overview/core-concepts/sensors/camera.rst @@ -6,7 +6,8 @@ Camera ====== Camera sensors in Isaac Lab are renderer-backed sensors: each :class:`~sensors.Camera` instance -is coupled to a **renderer** that produces the image data. The renderer and camera are intentionally +is coupled to a **renderer** that produces the image data. If multiple cameras use the same renderer +type, only one renderer is instantiated and shared between them. The renderer and camera are intentionally isolated from each other — the camera defines *what* to capture (pose, resolution, field of view, data types), while the renderer defines *how* to render it (RTX ray-tracing, Newton Warp rasterizer, etc.). This separation allows the same camera configuration to run across different physics and From 744e371445c48d3bdf438577034ad527a2ca751d Mon Sep 17 00:00:00 2001 From: HuiDong Chen Date: Tue, 5 May 2026 08:14:42 +0800 Subject: [PATCH 35/40] omniverseclient pin (#5487) # Description Update omniverseclient pin to `2.71.1.7015`. Fixes # (issue) ## Type of change - Bug fix (non-breaking change which fixes an issue) - New feature (non-breaking change which adds functionality) - Breaking change (existing functionality will not work without user modification) - Documentation update ## Screenshots Please attach before and after screenshots of the change if applicable. ## Checklist - [ ] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html) - [ ] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [ ] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- source/isaaclab/changelog.d/omniverseclient-pin.rst | 4 ++++ source/isaaclab/setup.py | 2 +- tools/wheel_builder/res/python_packages.toml | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) create mode 100644 source/isaaclab/changelog.d/omniverseclient-pin.rst diff --git a/source/isaaclab/changelog.d/omniverseclient-pin.rst b/source/isaaclab/changelog.d/omniverseclient-pin.rst new file mode 100644 index 000000000000..832820b64a47 --- /dev/null +++ b/source/isaaclab/changelog.d/omniverseclient-pin.rst @@ -0,0 +1,4 @@ +Fixed +^^^^^ + +* Pinned ``omniverseclient`` to ``2.71.1.7015``. diff --git a/source/isaaclab/setup.py b/source/isaaclab/setup.py index 7d14504f48a3..67c18c4c62d1 100644 --- a/source/isaaclab/setup.py +++ b/source/isaaclab/setup.py @@ -43,7 +43,7 @@ "botocore", # livestream "starlette==0.49.1", - "omniverseclient", + "omniverseclient==2.71.1.7015", # testing "pytest", "pytest-mock", diff --git a/tools/wheel_builder/res/python_packages.toml b/tools/wheel_builder/res/python_packages.toml index f6a42b90a1bc..1676fdc8f905 100644 --- a/tools/wheel_builder/res/python_packages.toml +++ b/tools/wheel_builder/res/python_packages.toml @@ -29,7 +29,7 @@ pyproject.dependencies.all = [ "botocore", # livestream "starlette==0.49.1", # TODO: update starlette once Isaac Lab be released with Isaac Sim 6.0.0 - "omniverseclient", + "omniverseclient==2.71.1.7015", # testing "pytest", "pytest-mock", From 33f5f716b636177c721d18d5e2783e292b53de96 Mon Sep 17 00:00:00 2001 From: Alex Omar Date: Mon, 4 May 2026 17:30:34 -0700 Subject: [PATCH 36/40] Update a set of small documentation inconsistencies (#5490) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description I am updating a small set of documentation fixes I found. I stumbled across one and then had claude scour the documentation to find any others. Claude found a bunch and after removing ones I didn't feel required fixing, this is the result. My prompt was ``` I need to scour the documentation and thoroughly check to see that the bash syntax being provided is correct. I need you to go through each rst document and read it in its entirety and pay special attention to the bash commands. Check to see if they are correct. Is this the correct syntax? Does it make sense? Go through the documentation in `docs/` with a very fine tooth comb, checking for any and all discrepancies and issues. ``` Fixes # (issue) ## Type of change - Documentation update ## Screenshots Here's a screenshot showing that the new `::` comment blocks in the batch code render properly and don't cause issues in the rst Screenshot from 2026-05-04 15-59-20 ## 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` - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- docs/source/api/lab/isaaclab.app.rst | 2 +- docs/source/features/population_based_training.rst | 4 ++-- docs/source/features/ray.rst | 4 ++-- docs/source/overview/developer-guide/repo_structure.rst | 6 ++++-- .../overview/imitation-learning/augmented_imitation.rst | 2 +- .../overview/imitation-learning/humanoids_imitation.rst | 3 +-- docs/source/overview/imitation-learning/skillgen.rst | 2 +- .../setup/installation/isaaclab_pip_installation.rst | 2 +- docs/source/setup/installation/pip_installation.rst | 2 +- docs/source/setup/installation/source_installation.rst | 2 +- docs/source/setup/quickstart.rst | 9 +++++---- .../walkthrough/training_jetbot_reward_exploration.rst | 2 +- 12 files changed, 21 insertions(+), 19 deletions(-) diff --git a/docs/source/api/lab/isaaclab.app.rst b/docs/source/api/lab/isaaclab.app.rst index c1440dd631a0..f1b040f1770e 100644 --- a/docs/source/api/lab/isaaclab.app.rst +++ b/docs/source/api/lab/isaaclab.app.rst @@ -77,7 +77,7 @@ The following snippet shows how use the :class:`AppLauncher` in different ways: .. code:: python - import argparser + import argparse from isaaclab.app import AppLauncher diff --git a/docs/source/features/population_based_training.rst b/docs/source/features/population_based_training.rst index 1de85bb57c57..906eaf516437 100644 --- a/docs/source/features/population_based_training.rst +++ b/docs/source/features/population_based_training.rst @@ -122,12 +122,12 @@ Launch *N* workers, where *n* indicates each worker index: --track \ --wandb-name=idx \ --wandb-entity=<**entity**> \ - --wandb-project-name=<**project**> + --wandb-project-name=<**project**> \ agent.pbt.enabled=True \ agent.pbt.num_policies= \ agent.pbt.policy_idx= \ agent.pbt.workspace=<**pbt_workspace_name**> \ - agent.pbt.directory=<**/path/to/shared_folder**> \ + agent.pbt.directory=<**/path/to/shared_folder**> References diff --git a/docs/source/features/ray.rst b/docs/source/features/ray.rst index 0edf935e8389..dbf88d682a24 100644 --- a/docs/source/features/ray.rst +++ b/docs/source/features/ray.rst @@ -224,7 +224,7 @@ the following dependencies are also needed. .. code-block:: bash - python3 -p -m pip install kubernetes Jinja2 + python3 -m pip install kubernetes Jinja2 For use on Kubernetes clusters with KubeRay, such as Google Kubernetes Engine or Amazon Elastic Kubernetes Service, ``kubectl`` is required, and can @@ -276,7 +276,7 @@ Shared Steps Between KubeRay and Pure Ray Part I .. code-block:: bash - python3 -p -m pip install ray[default]==2.31.0 + python3 -m pip install "ray[default]==2.31.0" 2.) Build the Isaac Ray image, and upload it to your container registry of choice. diff --git a/docs/source/overview/developer-guide/repo_structure.rst b/docs/source/overview/developer-guide/repo_structure.rst index a201886c0f8d..d8a5a1b200a5 100644 --- a/docs/source/overview/developer-guide/repo_structure.rst +++ b/docs/source/overview/developer-guide/repo_structure.rst @@ -61,8 +61,10 @@ They are structured as follows: * **demos**: Contains various demo applications that showcase the core framework :mod:`isaaclab`. * **environments**: Contains applications for running environments defined in :mod:`isaaclab_tasks` with different agents. These include a random policy, zero-action policy, teleoperation or scripted state machines. +* **imitation_learning**: Contains applications for training and evaluating policies with various + imitation learning libraries (e.g. robomimic). +* **reinforcement_learning**: Contains applications for training and evaluating policies with various + reinforcement learning libraries (e.g. rsl_rl, rl_games, sb3, skrl). * **tools**: Contains applications for using the tools provided by the framework. These include converting assets, generating datasets, etc. * **tutorials**: Contains step-by-step tutorials for using the APIs provided by the framework. -* **workflows**: Contains applications for using environments with various learning-based frameworks. These include different - reinforcement learning or imitation learning libraries. diff --git a/docs/source/overview/imitation-learning/augmented_imitation.rst b/docs/source/overview/imitation-learning/augmented_imitation.rst index 53df06c1f61b..2a97c58e293d 100644 --- a/docs/source/overview/imitation-learning/augmented_imitation.rst +++ b/docs/source/overview/imitation-learning/augmented_imitation.rst @@ -312,7 +312,7 @@ Using the generated data, we can now train a visuomotor BC agent for ``Isaac-Sta --name bc_rnn_image_franka_stack_mimic_cosmos .. note:: - By default the trained models and logs will be saved to ``IssacLab/logs/robomimic``. + By default the trained models and logs will be saved to ``IsaacLab/logs/robomimic``. Evaluation ^^^^^^^^^^ diff --git a/docs/source/overview/imitation-learning/humanoids_imitation.rst b/docs/source/overview/imitation-learning/humanoids_imitation.rst index 597fe629d4e4..694c3225dbf1 100644 --- a/docs/source/overview/imitation-learning/humanoids_imitation.rst +++ b/docs/source/overview/imitation-learning/humanoids_imitation.rst @@ -83,7 +83,6 @@ Collect five demonstrations by running the following command: --visualizer kit \ --xr \ --device cpu \ - --xr \ --num_demos 5 \ --dataset_file ./datasets/dataset_gr1.hdf5 @@ -194,7 +193,7 @@ The normalization parameters are saved in the model directory under ``PATH_TO_MO Record the normalization parameters for later use in the visualization step. .. note:: - By default the trained models and logs will be saved to ``IssacLab/logs/robomimic``. + By default the trained models and logs will be saved to ``IsaacLab/logs/robomimic``. Visualize the results ^^^^^^^^^^^^^^^^^^^^^ diff --git a/docs/source/overview/imitation-learning/skillgen.rst b/docs/source/overview/imitation-learning/skillgen.rst index be8382b5c5e1..4bd17067f689 100644 --- a/docs/source/overview/imitation-learning/skillgen.rst +++ b/docs/source/overview/imitation-learning/skillgen.rst @@ -407,7 +407,7 @@ Train a policy for the more complex adaptive bin stacking: .. note:: - The training script will save the model checkpoints in the model directory under ``IssacLab/logs/robomimic``. + The training script will save the model checkpoints in the model directory under ``IsaacLab/logs/robomimic``. Evaluating Trained Policies ^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/docs/source/setup/installation/isaaclab_pip_installation.rst b/docs/source/setup/installation/isaaclab_pip_installation.rst index 9ac6b9bbf4f9..bf6aebfefe2e 100644 --- a/docs/source/setup/installation/isaaclab_pip_installation.rst +++ b/docs/source/setup/installation/isaaclab_pip_installation.rst @@ -149,7 +149,7 @@ Installing dependencies .. code-block:: bash unset LD_PRELOAD - export LD_PRELOAD="$LD_PRELOAD:/lib/aarch64-linux-gnu/libgomp.so.1" + export LD_PRELOAD=/lib/aarch64-linux-gnu/libgomp.so.1 This ensures the correct ``libgomp`` library is preloaded for both Isaac Sim and Isaac Lab, removing the preload warnings during runtime. diff --git a/docs/source/setup/installation/pip_installation.rst b/docs/source/setup/installation/pip_installation.rst index a45a66d1de3d..defaab955016 100644 --- a/docs/source/setup/installation/pip_installation.rst +++ b/docs/source/setup/installation/pip_installation.rst @@ -95,7 +95,7 @@ Installing dependencies .. code-block:: bash unset LD_PRELOAD - export LD_PRELOAD="$LD_PRELOAD:/lib/aarch64-linux-gnu/libgomp.so.1" + export LD_PRELOAD=/lib/aarch64-linux-gnu/libgomp.so.1 This ensures the correct ``libgomp`` library is preloaded for both Isaac Sim and Isaac Lab, removing the preload warnings during runtime. diff --git a/docs/source/setup/installation/source_installation.rst b/docs/source/setup/installation/source_installation.rst index 830cdf839c34..a4a9efebf05f 100644 --- a/docs/source/setup/installation/source_installation.rst +++ b/docs/source/setup/installation/source_installation.rst @@ -65,7 +65,7 @@ for the convenience of users. .. tab-item:: :icon:`fa-brands fa-windows` Windows :sync: windows - .. code:: bash + .. code:: batch cd IsaacSim build.bat diff --git a/docs/source/setup/quickstart.rst b/docs/source/setup/quickstart.rst index 39e7ec4cb932..fd63ba11ec8e 100644 --- a/docs/source/setup/quickstart.rst +++ b/docs/source/setup/quickstart.rst @@ -53,9 +53,9 @@ package manager. To begin, create a virtual environment: .. code-block:: batch - # create a virtual environment named env_isaaclab with python3.12 + :: create a virtual environment named env_isaaclab with python3.12 uv venv --python 3.12 --seed env_isaaclab - # activate the virtual environment + :: activate the virtual environment env_isaaclab\Scripts\activate .. tab-item:: conda @@ -151,9 +151,10 @@ Installation is now as easy as navigating to the repo and then calling the root .. tab-item:: :icon:`fa-brands fa-windows` Windows :sync: windows - .. code:: bash + .. code:: batch - isaaclab.bat --install :: or "isaaclab.bat -i" + isaaclab.bat --install + :: or use "isaaclab.bat -i" Quick Start Using Isaac Launchable diff --git a/docs/source/setup/walkthrough/training_jetbot_reward_exploration.rst b/docs/source/setup/walkthrough/training_jetbot_reward_exploration.rst index efdce4689c99..351189183d40 100644 --- a/docs/source/setup/walkthrough/training_jetbot_reward_exploration.rst +++ b/docs/source/setup/walkthrough/training_jetbot_reward_exploration.rst @@ -43,7 +43,7 @@ we can finally run training! Let's see what happens! .. code-block:: bash - python scripts/skrl/train.py --task=Template-Isaac-Lab-Tutorial-Direct-v0 + python scripts/reinforcement_learning/skrl/train.py --task=Template-Isaac-Lab-Tutorial-Direct-v0 .. figure:: https://download.isaacsim.omniverse.nvidia.com/isaaclab/images/walkthrough_naive_webp.webp From 5afbca4f9eebdf708a0f43555e3e3b3d2f620de5 Mon Sep 17 00:00:00 2001 From: HuiDong Chen Date: Tue, 5 May 2026 08:37:00 +0800 Subject: [PATCH 37/40] Reduce rendering test flakiness (#5475) # Description - In the Dexsuite env the success and failure markers are placed exactly at the same location. If both markers are visible, the rendering order will determine which one is visible in the camera output. Hide both markers to avoid this nondeterministic behavior. - Ordering of test cases appear to affect camera outputs for some reason, therefore I move the `newton_renderer` test cases after `isaacsim_rtx_renderer` test cases. Fixes # (issue) ## Type of change - Bug fix (non-breaking change which fixes an issue) - New feature (non-breaking change which adds functionality) - Breaking change (existing functionality will not work without user modification) - Documentation update ## Screenshots Please attach before and after screenshots of the change if applicable. ## 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 updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- .../changelog.d/rendering-test-flakiness.skip | 0 .../newton-isaacsim_rtx_renderer-albedo.png | 4 +- .../newton-isaacsim_rtx_renderer-depth.png | 4 +- .../newton-isaacsim_rtx_renderer-rgb.png | 4 +- .../newton-isaacsim_rtx_renderer-rgba.png | 4 +- ...sim_rtx_renderer-semantic_segmentation.png | 4 +- ...nderer-simple_shading_constant_diffuse.png | 4 +- ...tx_renderer-simple_shading_diffuse_mdl.png | 4 +- ...m_rtx_renderer-simple_shading_full_mdl.png | 4 +- .../newton-newton_renderer-depth.png | 4 +- .../newton-ovrtx_renderer-albedo.png | 4 +- .../newton-ovrtx_renderer-rgb.png | 4 +- .../newton-ovrtx_renderer-rgba.png | 4 +- ...tx_renderer-simple_shading_diffuse_mdl.png | 4 +- .../physx-isaacsim_rtx_renderer-albedo.png | 4 +- .../physx-isaacsim_rtx_renderer-depth.png | 4 +- .../physx-isaacsim_rtx_renderer-rgb.png | 4 +- .../physx-isaacsim_rtx_renderer-rgba.png | 4 +- ...sim_rtx_renderer-semantic_segmentation.png | 4 +- ...nderer-simple_shading_constant_diffuse.png | 4 +- ...tx_renderer-simple_shading_diffuse_mdl.png | 4 +- ...m_rtx_renderer-simple_shading_full_mdl.png | 4 +- .../physx-newton_renderer-depth.png | 4 +- .../physx-newton_renderer-rgb.png | 4 +- .../physx-newton_renderer-rgba.png | 4 +- .../test/rendering_test_utils.py | 44 +++++++++++-------- 26 files changed, 73 insertions(+), 67 deletions(-) create mode 100644 source/isaaclab_tasks/changelog.d/rendering-test-flakiness.skip diff --git a/source/isaaclab_tasks/changelog.d/rendering-test-flakiness.skip b/source/isaaclab_tasks/changelog.d/rendering-test-flakiness.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-isaacsim_rtx_renderer-albedo.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-isaacsim_rtx_renderer-albedo.png index eba5f9524bb2..a49e526d4eee 100644 --- a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-isaacsim_rtx_renderer-albedo.png +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-isaacsim_rtx_renderer-albedo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:97d7f37390b32f99bdaba7c0e06be5b1385518096960d1eaaa5e747f1787f74d -size 3130 +oid sha256:ec727622d2e85c742051daac4c8ad9ce56af4b2d4d10766810a696b60cef82da +size 2579 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-isaacsim_rtx_renderer-depth.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-isaacsim_rtx_renderer-depth.png index df45816cc86a..bf4b0e0290dd 100644 --- a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-isaacsim_rtx_renderer-depth.png +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-isaacsim_rtx_renderer-depth.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e69349cb0c92ba3aa41b925b40cfcffad78f01671e19c812c2310ab2a6756ac1 -size 538 +oid sha256:c003b10810539f538464992860c74ee3bf531b8b4e9b6e0ebe84041d42dba643 +size 532 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-isaacsim_rtx_renderer-rgb.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-isaacsim_rtx_renderer-rgb.png index 3fe43e80f23b..6d21415e1650 100644 --- a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-isaacsim_rtx_renderer-rgb.png +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-isaacsim_rtx_renderer-rgb.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0e5772c11bcff5935e7442ca167380478dada58c7a3c71af1b208aa9921e9f0d -size 18393 +oid sha256:8e199c01ed54ce1f9d3430017b73e5793c09340054288efddde04ea031ff2c19 +size 17594 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-isaacsim_rtx_renderer-rgba.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-isaacsim_rtx_renderer-rgba.png index 4e4af6b5baa7..525aa5b2776e 100644 --- a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-isaacsim_rtx_renderer-rgba.png +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-isaacsim_rtx_renderer-rgba.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e952109fdcf84c7ecd6e0c2a0d1f724415e6e2ab54fc8e224ea6e65c0794327c -size 20912 +oid sha256:63a63059efc82372e87f8c117e94cd2c5690dd982c65bae0002845a704741182 +size 19970 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-isaacsim_rtx_renderer-semantic_segmentation.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-isaacsim_rtx_renderer-semantic_segmentation.png index d4a661009bdb..762a23184316 100644 --- a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-isaacsim_rtx_renderer-semantic_segmentation.png +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-isaacsim_rtx_renderer-semantic_segmentation.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:93570a548512d61f5090af8cfda2d885da9686f405d1181f21c1acfdfa3fb8ae -size 702 +oid sha256:0b8729d722d1780272b24f0804e517ea024ec2b93f4a0fa3e26c82f222f5c9eb +size 700 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png index b7e7e71cb48e..46ce5933fb8b 100644 --- a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:250bd7b5e958080c66f68cbdab73eef4683e500d69b38587d7cf250423543aed -size 1562 +oid sha256:8e1d94f0c6ae2e40a1b0ff9cf27a0f2f9b756ebcebd9ddf27b0da31c89e3a57f +size 1485 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png index 878826e8fdf3..e591d31c5df9 100644 --- a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0c71d8adf9b6ce38d35f6595d87cbf77935f149e68b3df13c1bb76e90e72a62a -size 4111 +oid sha256:058466b74a49061695567d84edeffb21ad9e88d8c83125d6b2b59cb70bdd5a70 +size 3701 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-isaacsim_rtx_renderer-simple_shading_full_mdl.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-isaacsim_rtx_renderer-simple_shading_full_mdl.png index 40a92c529cc4..2cdfe790a7df 100644 --- a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-isaacsim_rtx_renderer-simple_shading_full_mdl.png +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-isaacsim_rtx_renderer-simple_shading_full_mdl.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:221fa320e5cc59fd37449a2acaeeca18c0a7b0aba26ad2bedf1cfda674ff3f7c -size 4336 +oid sha256:1bf8a42b1b3c652ea9f8d79d89a02865ea2c1b347bf61dee810e01890aa00563 +size 4248 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-newton_renderer-depth.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-newton_renderer-depth.png index ad1fbefee57b..39ea590f59aa 100644 --- a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-newton_renderer-depth.png +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-newton_renderer-depth.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:47f7ad39d12f9b9e86d5d717f0b1637a909e629352c3c607a09f034f4fd1f665 -size 1768 +oid sha256:e12023f4f5314fe7214e4cc23368c7e24aef6997d2bfdcdf5a8654e03ea80c3c +size 1064 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-albedo.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-albedo.png index e7c78849a92a..5199099a7587 100644 --- a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-albedo.png +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-albedo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4979bdc7bb0f3786f5bb08e9e2333a36f2f008f3f7bc303dffa88319eef28e20 -size 3463 +oid sha256:7cf76622f5f5cc7e7889fe6032ba4cda22516248fa7bb5e957f181c92c86b42b +size 3054 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-rgb.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-rgb.png index c1d370c90eaa..544e2ffd450b 100644 --- a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-rgb.png +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-rgb.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7f8f2305b02d61096172a83b3729c0fbc94f135d50e2dbfe2f7b3acb655855de -size 14781 +oid sha256:3445142682c88dc5ce11c5d749c7754bf2d54bf0f1aab6420513a733bf3cf645 +size 14919 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-rgba.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-rgba.png index 0b13724ec7d3..c3b229d34871 100644 --- a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-rgba.png +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-rgba.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:86ee5f3822aae350436ae3fcd1edb7bdbdd3472c080059c573cab5d97cf4160e -size 17683 +oid sha256:179a5acba0a763fcc2317cd784f8c347ef48f0d81f8028a1a64996ade67f8706 +size 17836 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png index eeb46bec4489..2e2f6cb257a7 100644 --- a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-ovrtx_renderer-simple_shading_diffuse_mdl.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fbac25af4087687f2ae5434770a724ef1453ec988c39e46d4b53afa22421f5c7 -size 4044 +oid sha256:83ca3d8f55f971d473409c73e77582175906670f3962b56723cccd28d062a868 +size 3513 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-isaacsim_rtx_renderer-albedo.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-isaacsim_rtx_renderer-albedo.png index dea3eff8ff1f..266c129cbf86 100644 --- a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-isaacsim_rtx_renderer-albedo.png +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-isaacsim_rtx_renderer-albedo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:42629303589629819727b1103ce9f2962df1918cf69942673c6bc84674c1dbc0 -size 3104 +oid sha256:fddc33b267fe9810973419babf140faf39b964466bfb6f50193abc8f278cfce4 +size 3052 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-isaacsim_rtx_renderer-depth.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-isaacsim_rtx_renderer-depth.png index dcaf4b767088..56fa75793780 100644 --- a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-isaacsim_rtx_renderer-depth.png +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-isaacsim_rtx_renderer-depth.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:78c4026191543acd93b5ad40193608609e6e9e82419df3595769f1c337d52906 -size 538 +oid sha256:66a908918302e713a2cc82a091c4e82db3da46a06cf08311b3818e93714b9132 +size 532 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-isaacsim_rtx_renderer-rgb.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-isaacsim_rtx_renderer-rgb.png index 02286ee9151d..d36460128aae 100644 --- a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-isaacsim_rtx_renderer-rgb.png +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-isaacsim_rtx_renderer-rgb.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c58d4f31b94ece0fa2c046e8425ca3e9062fe2958d73e18ccb024f16c37eafd7 -size 18208 +oid sha256:d132e5a1d1dbfbeb250345f19e1389019ce79a388d51e94a8e954abc2825029a +size 17348 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-isaacsim_rtx_renderer-rgba.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-isaacsim_rtx_renderer-rgba.png index eb287578ad95..c666fa628df2 100644 --- a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-isaacsim_rtx_renderer-rgba.png +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-isaacsim_rtx_renderer-rgba.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:518f27da2aa3fe7892d977373aaa857c363f4ad55c5f0e046cbd5b3a77235544 -size 20719 +oid sha256:164ec6a8afffc5706573dfcad5747f818f059cd476b65ce9bbfed97c53ed0a83 +size 19729 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-isaacsim_rtx_renderer-semantic_segmentation.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-isaacsim_rtx_renderer-semantic_segmentation.png index e2aa08688491..fcc9576e7e95 100644 --- a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-isaacsim_rtx_renderer-semantic_segmentation.png +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-isaacsim_rtx_renderer-semantic_segmentation.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6e2119a3294737e13549e40ae6685545985a6df609dd1bd73adbe31aa1a6d5c0 -size 690 +oid sha256:f77680ebb1f5c9dedc3419d526b65feb82e38013ce402d7805c75b0ad3be8cd6 +size 696 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png index ccfb409a64a5..38f0a4d9d7e3 100644 --- a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e0d7829371c05612226bb4a3567f009c885bdaeeb5d1616201c3e6a018864033 -size 1565 +oid sha256:70cc200aa9d558e309d0e69c618b8235de0af11d6cf8b524bb7e7777f5d950cc +size 1492 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png index c3131deb20a5..1b833d0f65d6 100644 --- a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:91cdd65e9c067c8448405e3d3b587f1f1478309168ac5c90ceacd8a12eff1c7d -size 3798 +oid sha256:14f277009292a7cc2e21fdb4f2b5e77a0fb1025435badcb0ccc96871bd8c322d +size 3698 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-isaacsim_rtx_renderer-simple_shading_full_mdl.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-isaacsim_rtx_renderer-simple_shading_full_mdl.png index 0a8939f9251b..28122c3bc3c2 100644 --- a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-isaacsim_rtx_renderer-simple_shading_full_mdl.png +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-isaacsim_rtx_renderer-simple_shading_full_mdl.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f62edc212a53998fb4e74cb40ffa7a889a6e1936103d623429fcdbea5440c040 -size 4315 +oid sha256:5e9155c484a7ab6131add9adce634f37d6c8205cb1696108f83acc96f6d19ea6 +size 4241 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-newton_renderer-depth.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-newton_renderer-depth.png index f0e39d40a47b..2b296ef399b7 100644 --- a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-newton_renderer-depth.png +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-newton_renderer-depth.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:31dc6e250e53ffec0e2d2839cfc73284bf5e12d725d7212fa0eeb4f420297b82 -size 1060 +oid sha256:2eb315a195ddddaabb31529efe051c5543d27f9b603bf1eb910b2ad426df22fa +size 1061 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-newton_renderer-rgb.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-newton_renderer-rgb.png index 32cc9b522998..a8365cfc8297 100644 --- a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-newton_renderer-rgb.png +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-newton_renderer-rgb.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2792cc0ee8b489d286dd4e376b5d57f8abd90d1d245975b6f91ec0f68e13d483 -size 1629 +oid sha256:8f280f00e8cace33fe5ccf03ca0767954690285ee1908395bff9e8dbf92994e3 +size 1618 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-newton_renderer-rgba.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-newton_renderer-rgba.png index e19de39ea74f..c6d72a99eba2 100644 --- a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-newton_renderer-rgba.png +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-newton_renderer-rgba.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ec3854d8483d64e9e51f41e40d0bec2f6ff7c3db69b7889247c2d6056ddc1f4a -size 2693 +oid sha256:da6cf857f7248d6c46d9c7b9a742609ac9c81eaeb99c9eeec36714bc34aacd17 +size 2670 diff --git a/source/isaaclab_tasks/test/rendering_test_utils.py b/source/isaaclab_tasks/test/rendering_test_utils.py index c6c797fc937d..c4745eb1f043 100644 --- a/source/isaaclab_tasks/test/rendering_test_utils.py +++ b/source/isaaclab_tasks/test/rendering_test_utils.py @@ -28,15 +28,15 @@ # The max percentage of pixels allowed to differ. If the percentage exceeds this value, the test will fail. # The value is set case by case based on the screen space taken up by the env in camera output images. It # needs to be large enough to tolerate minor rendering noise while small enough to catch unexpected changes. -_MAX_DIFFERENT_PIXELS_PERCENTAGE_BY_ENV_NAME = { +MAX_DIFFERENT_PIXELS_PERCENTAGE_BY_ENV_NAME = { "cartpole": 1.0, # Shadow-hand renderings (incl. ``Isaac-Repose-Cube-Shadow-Vision-Direct-v0``) show up to - # ~3.28 % per-pixel diff from anti-aliasing noise along the many finger/cube edges. 7.0 gives + # ~3.28 % per-pixel diff from anti-aliasing noise along the many finger/cube edges. 5.0 gives # headroom above that without masking real regressions, which the SSIM gate still catches. - "shadow_hand": 7.0, - "dexsuite_kuka": 10.0, # texture aliasing artifacts on the ground (ticket has been filed for OVRTX) + "shadow_hand": 5.0, + # Texture aliasing artifacts on the ground (NVBUG#6116767) + "dexsuite_kuka": 8.0, } -MAX_DIFFERENT_PIXELS_PERCENTAGE_BY_ENV_NAME = _MAX_DIFFERENT_PIXELS_PERCENTAGE_BY_ENV_NAME # Minimum SSIM score below which two images are considered structurally different. SSIM is a perceptual metric # robust to uniform per-pixel noise that penalises structural changes (geometry shifts, swapped colours, missing @@ -47,7 +47,7 @@ # Per-env SSIM overrides. Envs not listed fall back to ``_SSIM_THRESHOLD``. Loosened individually # (not globally) to keep the strict gate active everywhere it already passes. _SSIM_THRESHOLD_BY_ENV_NAME = { - # Texture aliasing artifacts on the ground (ticket has been filed for OVRTX) + # Texture aliasing artifacts on the ground (NVBUG#6116767) "dexsuite_kuka": 0.95, } @@ -117,19 +117,6 @@ "semantic_segmentation", id="physx-isaacsim_rtx-semantic_segmentation", ), - # physx + newton_renderer (warp) - pytest.param( - "physx", - "newton_renderer", - "rgb", - id="physx-newton_warp-rgb", - ), - pytest.param( - "physx", - "newton_renderer", - "depth", - id="physx-newton_warp-depth", - ), # newton + isaacsim_rtx_renderer pytest.param( "newton", @@ -173,6 +160,19 @@ "semantic_segmentation", id="newton-isaacsim_rtx-semantic_segmentation", ), + # physx + newton_renderer (warp) + pytest.param( + "physx", + "newton_renderer", + "rgb", + id="physx-newton_warp-rgb", + ), + pytest.param( + "physx", + "newton_renderer", + "depth", + id="physx-newton_warp-depth", + ), ] KITLESS_PHYSICS_RENDERER_AOV_COMBINATIONS = [ @@ -760,6 +760,12 @@ def rendering_test_dexsuite_kuka( if point_cloud_term is not None: point_cloud_term.params["visualize"] = False + # The success and failure markers are placed exactly at the same location. If both markers are + # visible, the rendering order will determine which one is visible in the camera output. Hide + # both markers to avoid this nondeterministic behavior. + for marker_cfg in env_cfg.commands.object_pose.success_visualizer_cfg.markers.values(): + marker_cfg.visible = False + env = None try: From 4758900c5e2de6676c6aebe9b8539fa2eda048f2 Mon Sep 17 00:00:00 2001 From: rwiltz <165190220+rwiltz@users.noreply.github.com> Date: Tue, 5 May 2026 00:21:00 -0400 Subject: [PATCH 38/40] Restores GR1T2 legacy teleop pipeline for teleop CI (#5254) # Description - Restores legacy `teleop_devices` config (`OpenXRDeviceCfg` / `ManusViveCfg` + `GR1T2RetargeterCfg`) on `PickPlaceGR1T2EnvCfg` alongside the existing `isaac_teleop` pipeline, re-enabling CI validation via `--teleop_device=handtracking`. - Updates `teleop_se3_agent.py` and `record_demos.py` to route between the two stacks: IsaacTeleop is used by default when configured; passing `--teleop_device` explicitly forces the legacy `teleop_devices` path (errors if no matching entry exists). - Removes automatic `--xr` inference from `--teleop_device` containing `"handtracking"`. Users who need XR with the legacy path should pass `--xr` explicitly. Fixes # (issue) ## Type of change - Bug fix (non-breaking change which fixes an issue) ## 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 updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Co-authored-by: Kelly Guo --- .../teleoperation/teleop_se3_agent.py | 89 +++++++++++-------- scripts/tools/record_demos.py | 69 ++++++++------ .../rwiltz-restore-legacy-teleop.rst | 8 ++ .../pick_place/pickplace_gr1t2_env_cfg.py | 43 +++++++++ .../rwiltz-restore-legacy-teleop.rst | 11 +++ .../deprecated/openxr/openxr_device.py | 4 +- .../fourier/gr1_t2_dex_retargeting_utils.py | 4 +- 7 files changed, 158 insertions(+), 70 deletions(-) create mode 100644 source/isaaclab_tasks/changelog.d/rwiltz-restore-legacy-teleop.rst create mode 100644 source/isaaclab_teleop/changelog.d/rwiltz-restore-legacy-teleop.rst diff --git a/scripts/environments/teleoperation/teleop_se3_agent.py b/scripts/environments/teleoperation/teleop_se3_agent.py index cdd5c104c44f..3f1f4ac477fc 100644 --- a/scripts/environments/teleoperation/teleop_se3_agent.py +++ b/scripts/environments/teleoperation/teleop_se3_agent.py @@ -29,12 +29,11 @@ parser.add_argument( "--teleop_device", type=str, - default="keyboard", + default=None, help=( - "Teleop device. Set here (legacy) or via the environment config. If using the environment config, pass the" - " device key/name defined under 'teleop_devices' (it can be a custom name, not necessarily 'handtracking')." - " Built-ins: keyboard, spacemouse, gamepad. Not all tasks support all built-ins." - " If env_cfg has isaac_teleop configured, this argument is ignored and IsaacTeleop stack is used." + "Legacy teleop device name. When omitted, the IsaacTeleop pipeline is used if configured in the env," + " otherwise keyboard is used as fallback. When explicitly provided, the script uses the legacy" + " teleop_devices path and looks up this name in env_cfg.teleop_devices.devices." ), ) parser.add_argument("--task", type=str, default=None, help="Name of the task.") @@ -61,9 +60,6 @@ app_launcher_args = vars(args_cli) -if "handtracking" in args_cli.teleop_device.lower(): - app_launcher_args["xr"] = True - # launch omniverse app app_launcher = AppLauncher(app_launcher_args) simulation_app = app_launcher.app @@ -107,6 +103,18 @@ def _resolve_cloudxr_env(value: str | None) -> str | None: return _CLOUDXR_ENV_SHORTHANDS.get(value.lower(), value) +def _create_builtin_device(device_name: str, sensitivity: float) -> object | None: + """Create a built-in teleop device by name, or return None if unrecognized.""" + name = device_name.lower() + if name == "keyboard": + return Se3Keyboard(Se3KeyboardCfg(pos_sensitivity=0.05 * sensitivity, rot_sensitivity=0.05 * sensitivity)) + elif name == "spacemouse": + return Se3SpaceMouse(Se3SpaceMouseCfg(pos_sensitivity=0.05 * sensitivity, rot_sensitivity=0.05 * sensitivity)) + elif name == "gamepad": + return Se3Gamepad(Se3GamepadCfg(pos_sensitivity=0.1 * sensitivity, rot_sensitivity=0.1 * sensitivity)) + return None + + def main() -> None: """ Run teleoperation with an Isaac Lab manipulation environment. @@ -133,8 +141,12 @@ def main() -> None: # add termination condition for reaching the goal otherwise the environment won't reset env_cfg.terminations.object_reached_goal = DoneTerm(func=mdp.object_reached_goal) - # Check if IsaacTeleop is configured in the environment - use_isaac_teleop = hasattr(env_cfg, "isaac_teleop") and env_cfg.isaac_teleop is not None + # When --teleop_device is explicitly provided, use the legacy teleop_devices path + # even if isaac_teleop is configured. Otherwise prefer isaac_teleop when available. + teleop_device_explicitly_set = args_cli.teleop_device is not None + use_isaac_teleop = ( + not teleop_device_explicitly_set and hasattr(env_cfg, "isaac_teleop") and env_cfg.isaac_teleop is not None + ) if use_isaac_teleop or args_cli.xr: env_cfg = remove_camera_configs(env_cfg) @@ -228,37 +240,36 @@ def stop_teleoperation() -> None: auto_launch_cloudxr=args_cli.auto_launch_cloudxr, ) - elif hasattr(env_cfg, "teleop_devices") and args_cli.teleop_device in env_cfg.teleop_devices.devices: - # Use native Isaac Lab teleop stack - teleop_interface = create_teleop_device( - args_cli.teleop_device, env_cfg.teleop_devices.devices, teleoperation_callbacks - ) - else: - logger.warning( - f"No teleop device '{args_cli.teleop_device}' found in environment config. Creating default." - ) - # Create fallback teleop device - sensitivity = args_cli.sensitivity - if args_cli.teleop_device.lower() == "keyboard": - teleop_interface = Se3Keyboard( - Se3KeyboardCfg(pos_sensitivity=0.05 * sensitivity, rot_sensitivity=0.05 * sensitivity) - ) - elif args_cli.teleop_device.lower() == "spacemouse": - teleop_interface = Se3SpaceMouse( - Se3SpaceMouseCfg(pos_sensitivity=0.05 * sensitivity, rot_sensitivity=0.05 * sensitivity) - ) - elif args_cli.teleop_device.lower() == "gamepad": - teleop_interface = Se3Gamepad( - Se3GamepadCfg(pos_sensitivity=0.1 * sensitivity, rot_sensitivity=0.1 * sensitivity) + elif teleop_device_explicitly_set: + device_name = args_cli.teleop_device + if hasattr(env_cfg, "teleop_devices") and device_name in env_cfg.teleop_devices.devices: + teleop_interface = create_teleop_device( + device_name, env_cfg.teleop_devices.devices, teleoperation_callbacks ) else: - logger.error(f"Unsupported teleop device: {args_cli.teleop_device}") - logger.error("Configure the teleop device in the environment config.") - env.close() - simulation_app.close() - return - - # Add callbacks to fallback device + teleop_interface = _create_builtin_device(device_name, args_cli.sensitivity) + if teleop_interface is None: + logger.error( + f"--teleop_device={device_name} was passed but no matching entry exists in" + " env_cfg.teleop_devices and it is not a built-in device name. Either remove" + " --teleop_device to use the IsaacTeleop pipeline, or add a" + f" '{device_name}' entry under teleop_devices in the environment config." + " Built-in devices: keyboard, spacemouse, gamepad." + ) + env.close() + simulation_app.close() + return + for key, callback in teleoperation_callbacks.items(): + try: + teleop_interface.add_callback(key, callback) + except (ValueError, TypeError) as e: + logger.warning(f"Failed to add callback for key {key}: {e}") + else: + # No --teleop_device and no isaac_teleop: fall back to keyboard + sensitivity = args_cli.sensitivity + teleop_interface = Se3Keyboard( + Se3KeyboardCfg(pos_sensitivity=0.05 * sensitivity, rot_sensitivity=0.05 * sensitivity) + ) for key, callback in teleoperation_callbacks.items(): try: teleop_interface.add_callback(key, callback) diff --git a/scripts/tools/record_demos.py b/scripts/tools/record_demos.py index 75df9e0ee92a..0cac0b59284b 100644 --- a/scripts/tools/record_demos.py +++ b/scripts/tools/record_demos.py @@ -20,8 +20,8 @@ optional arguments: -h, --help Show this help message and exit - --teleop_device Device for interacting with environment. (default: keyboard) - If env_cfg has isaac_teleop configured, this argument is ignored. + --teleop_device Legacy teleop device name. When omitted, IsaacTeleop is used if + configured, otherwise keyboard. When set, forces the legacy path. --dataset_file File path to export recorded demos. (default: "./datasets/dataset.hdf5") --step_hz Environment stepping rate in Hz. (default: 30) --num_demos Number of demonstrations to record. (default: 0) @@ -44,11 +44,11 @@ parser.add_argument( "--teleop_device", type=str, - default="keyboard", + default=None, help=( - "Teleop device. Set here (legacy) or via the environment config. If using the environment config, pass the" - " device key/name defined under 'teleop_devices' (it can be a custom name, not necessarily 'handtracking')." - " Built-ins: keyboard, spacemouse, gamepad. Not all tasks support all built-ins." + "Legacy teleop device name. When omitted, the IsaacTeleop pipeline is used if configured in the env," + " otherwise keyboard is used as fallback. When explicitly provided, the script uses the legacy" + " teleop_devices path and looks up this name in env_cfg.teleop_devices.devices." ), ) parser.add_argument( @@ -91,9 +91,6 @@ app_launcher_args = vars(args_cli) -if "handtracking" in args_cli.teleop_device.lower(): - app_launcher_args["xr"] = True - # launch the simulator app_launcher = AppLauncher(args_cli) simulation_app = app_launcher.app @@ -232,8 +229,12 @@ def create_environment_config( logger.error(f"Failed to parse environment configuration: {e}") exit(1) - # Check if IsaacTeleop is configured - use_isaac_teleop = hasattr(env_cfg, "isaac_teleop") and env_cfg.isaac_teleop is not None + # When --teleop_device is explicitly provided, use the legacy teleop_devices path + # even if isaac_teleop is configured. Otherwise prefer isaac_teleop when available. + teleop_device_explicitly_set = args_cli.teleop_device is not None + use_isaac_teleop = ( + not teleop_device_explicitly_set and hasattr(env_cfg, "isaac_teleop") and env_cfg.isaac_teleop is not None + ) # extract success checking function to invoke in the main loop success_term = None @@ -286,6 +287,16 @@ def create_environment(env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg) -> gym.En exit(1) +def _create_builtin_device(device_name: str) -> object | None: + """Create a built-in teleop device by name, or return None if unrecognized.""" + name = device_name.lower() + if name == "keyboard": + return Se3Keyboard(Se3KeyboardCfg(pos_sensitivity=0.2, rot_sensitivity=0.5)) + elif name == "spacemouse": + return Se3SpaceMouse(Se3SpaceMouseCfg(pos_sensitivity=0.2, rot_sensitivity=0.5)) + return None + + def setup_teleop_device(callbacks: dict[str, Callable], use_isaac_teleop: bool = False) -> object: """Set up the teleoperation device based on configuration. @@ -303,6 +314,7 @@ def setup_teleop_device(callbacks: dict[str, Callable], use_isaac_teleop: bool = Raises: Exception: If teleop device creation fails """ + teleop_device_explicitly_set = args_cli.teleop_device is not None teleop_interface = None try: if use_isaac_teleop: @@ -316,23 +328,26 @@ def setup_teleop_device(callbacks: dict[str, Callable], use_isaac_teleop: bool = auto_launch_cloudxr=args_cli.auto_launch_cloudxr, ) - elif hasattr(env_cfg, "teleop_devices") and args_cli.teleop_device in env_cfg.teleop_devices.devices: - teleop_interface = create_teleop_device(args_cli.teleop_device, env_cfg.teleop_devices.devices, callbacks) - else: - logger.warning( - f"No teleop device '{args_cli.teleop_device}' found in environment config. Creating default." - ) - # Create fallback teleop device - if args_cli.teleop_device.lower() == "keyboard": - teleop_interface = Se3Keyboard(Se3KeyboardCfg(pos_sensitivity=0.2, rot_sensitivity=0.5)) - elif args_cli.teleop_device.lower() == "spacemouse": - teleop_interface = Se3SpaceMouse(Se3SpaceMouseCfg(pos_sensitivity=0.2, rot_sensitivity=0.5)) + elif teleop_device_explicitly_set: + device_name = args_cli.teleop_device + if hasattr(env_cfg, "teleop_devices") and device_name in env_cfg.teleop_devices.devices: + teleop_interface = create_teleop_device(device_name, env_cfg.teleop_devices.devices, callbacks) else: - logger.error(f"Unsupported teleop device: {args_cli.teleop_device}") - logger.error("Supported devices: keyboard, spacemouse, handtracking") - exit(1) - - # Add callbacks to fallback device + teleop_interface = _create_builtin_device(device_name) + if teleop_interface is None: + logger.error( + f"--teleop_device={device_name} was passed but no matching entry exists in" + " env_cfg.teleop_devices and it is not a built-in device name. Either remove" + " --teleop_device to use the IsaacTeleop pipeline, or add a" + f" '{device_name}' entry under teleop_devices in the environment config." + " Built-in devices: keyboard, spacemouse." + ) + exit(1) + for key, callback in callbacks.items(): + teleop_interface.add_callback(key, callback) + else: + # No --teleop_device and no isaac_teleop: fall back to keyboard + teleop_interface = Se3Keyboard(Se3KeyboardCfg(pos_sensitivity=0.2, rot_sensitivity=0.5)) for key, callback in callbacks.items(): teleop_interface.add_callback(key, callback) except Exception as e: diff --git a/source/isaaclab_tasks/changelog.d/rwiltz-restore-legacy-teleop.rst b/source/isaaclab_tasks/changelog.d/rwiltz-restore-legacy-teleop.rst new file mode 100644 index 000000000000..59e71ddc3984 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/rwiltz-restore-legacy-teleop.rst @@ -0,0 +1,8 @@ +Added +^^^^^ + +* Added legacy ``teleop_devices`` configuration (``OpenXRDeviceCfg``, + ``ManusViveCfg``, ``GR1T2RetargeterCfg``) to + :class:`~isaaclab_tasks.manager_based.manipulation.pick_place.pickplace_gr1t2_env_cfg.PickPlaceGR1T2EnvCfg` + alongside the existing ``isaac_teleop`` pipeline, enabling CI validation + via ``--teleop_device=handtracking``. diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/pickplace_gr1t2_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/pickplace_gr1t2_env_cfg.py index b69589083c64..8c95df1041eb 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/pickplace_gr1t2_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/pickplace_gr1t2_env_cfg.py @@ -607,3 +607,46 @@ def __post_init__(self): sim_device=self.sim.device, xr_cfg=self.xr, ) + + # Legacy teleop devices are built lazily via __getattr__ to avoid + # importing runtime-only modules (carb, pxr) at config-load time. + del self.teleop_devices + + def __getattr__(self, name: str): + if name == "teleop_devices": + from isaaclab.devices.device_base import DevicesCfg # noqa: PLC0415 + from isaaclab.devices.openxr import ManusViveCfg, OpenXRDeviceCfg # noqa: PLC0415 + from isaaclab.devices.openxr.retargeters.humanoid.fourier.gr1t2_retargeter import ( # noqa: PLC0415 + GR1T2RetargeterCfg, + ) + + self.teleop_devices = DevicesCfg( + devices={ + "handtracking": OpenXRDeviceCfg( + retargeters=[ + GR1T2RetargeterCfg( + enable_visualization=True, + num_open_xr_hand_joints=2 * 26, + sim_device=self.sim.device, + hand_joint_names=self.actions.upper_body_ik.hand_joint_names, + ), + ], + sim_device=self.sim.device, + xr_cfg=self.xr, + ), + "manusvive": ManusViveCfg( + retargeters=[ + GR1T2RetargeterCfg( + enable_visualization=True, + num_open_xr_hand_joints=2 * 26, + sim_device=self.sim.device, + hand_joint_names=self.actions.upper_body_ik.hand_joint_names, + ), + ], + sim_device=self.sim.device, + xr_cfg=self.xr, + ), + } + ) + return self.teleop_devices + raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'") diff --git a/source/isaaclab_teleop/changelog.d/rwiltz-restore-legacy-teleop.rst b/source/isaaclab_teleop/changelog.d/rwiltz-restore-legacy-teleop.rst new file mode 100644 index 000000000000..4c534f674c8a --- /dev/null +++ b/source/isaaclab_teleop/changelog.d/rwiltz-restore-legacy-teleop.rst @@ -0,0 +1,11 @@ +Changed +^^^^^^^ + +* Changed ``--teleop_device`` default to ``None`` in ``teleop_se3_agent.py`` + and ``record_demos.py``. When omitted, the IsaacTeleop pipeline is used if + the env configures ``isaac_teleop``; otherwise keyboard is used as fallback. + When explicitly provided, the scripts use the legacy ``teleop_devices`` path + and error out if no matching entry exists. +* Removed automatic ``--xr`` detection from ``--teleop_device`` containing + ``"handtracking"``. Users who need XR with the legacy path should pass + ``--xr`` explicitly. diff --git a/source/isaaclab_teleop/isaaclab_teleop/deprecated/openxr/openxr_device.py b/source/isaaclab_teleop/isaaclab_teleop/deprecated/openxr/openxr_device.py index cd57ecf2bd3a..3135e8e5bb4d 100644 --- a/source/isaaclab_teleop/isaaclab_teleop/deprecated/openxr/openxr_device.py +++ b/source/isaaclab_teleop/isaaclab_teleop/deprecated/openxr/openxr_device.py @@ -338,8 +338,8 @@ def _calculate_joint_poses( quati = quat.GetImaginary() quatw = quat.GetReal() else: - quatw = previous_joint_poses[joint_name][3] - quati = previous_joint_poses[joint_name][4:] + quati = previous_joint_poses[joint_name][3:6] + quatw = previous_joint_poses[joint_name][6] # Directly update the dictionary with new data previous_joint_poses[joint_name] = np.array( diff --git a/source/isaaclab_teleop/isaaclab_teleop/deprecated/openxr/retargeters/humanoid/fourier/gr1_t2_dex_retargeting_utils.py b/source/isaaclab_teleop/isaaclab_teleop/deprecated/openxr/retargeters/humanoid/fourier/gr1_t2_dex_retargeting_utils.py index aaeb9bda0314..e832f441e403 100644 --- a/source/isaaclab_teleop/isaaclab_teleop/deprecated/openxr/retargeters/humanoid/fourier/gr1_t2_dex_retargeting_utils.py +++ b/source/isaaclab_teleop/isaaclab_teleop/deprecated/openxr/retargeters/humanoid/fourier/gr1_t2_dex_retargeting_utils.py @@ -158,8 +158,8 @@ def convert_hand_joints(self, hand_poses: dict[str, np.ndarray], operator2mano: # Convert hand pose to the canonical frame. joint_position = joint_position - joint_position[0:1, :] xr_wrist_quat = hand_poses.get("wrist")[3:] - # OpenXR hand uses w,x,y,z order for quaternions but scipy uses x,y,z,w order - wrist_rot = R.from_quat([xr_wrist_quat[1], xr_wrist_quat[2], xr_wrist_quat[3], xr_wrist_quat[0]]).as_matrix() + # OpenXR hand data is in xyzw order, matching scipy's convention + wrist_rot = R.from_quat(xr_wrist_quat).as_matrix() return joint_position @ wrist_rot @ operator2mano From 9ffafbb627a976c5dd8fe1bcc15b6bbf28d1a8cc Mon Sep 17 00:00:00 2001 From: Piotr Barejko Date: Mon, 4 May 2026 23:02:39 -0700 Subject: [PATCH 39/40] Replace add_usd with open_usd (#5491) --- source/isaaclab_ov/changelog.d/pbarejko-open-usd.rst | 7 +++++++ .../isaaclab_ov/renderers/ovrtx_renderer.py | 10 +++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 source/isaaclab_ov/changelog.d/pbarejko-open-usd.rst diff --git a/source/isaaclab_ov/changelog.d/pbarejko-open-usd.rst b/source/isaaclab_ov/changelog.d/pbarejko-open-usd.rst new file mode 100644 index 000000000000..455768ad5a5c --- /dev/null +++ b/source/isaaclab_ov/changelog.d/pbarejko-open-usd.rst @@ -0,0 +1,7 @@ +Fixed +^^^^^ + +* Fixed ``AttributeError: 'Renderer' object has no attribute 'add_usd'`` in + :class:`~isaaclab_ov.renderers.OVRTXRenderer` when using ``ovrtx`` 0.3.0 or + newer. The renderer now calls :meth:`ovrtx.Renderer.open_usd` on 0.3.0+ and + falls back to ``Renderer.add_usd`` on older versions. diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py index 422ecec2f1e7..5d1782373d87 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py @@ -223,9 +223,13 @@ def initialize(self, spec: CameraRenderSpec): logger.info("Loading USD into OvRTX...") try: - handle = self._renderer.add_usd(combined_usd_path, path_prefix=None) - self._usd_handles.append(handle) - logger.info("USD loaded (path: %s, handle: %s)", combined_usd_path, handle) + if _IS_OVRTX_0_3_0_OR_NEWER: + self._renderer.open_usd(combined_usd_path) + logger.info("USD loaded as root layer (path: %s)", combined_usd_path) + else: + handle = self._renderer.add_usd(combined_usd_path, path_prefix=None) + self._usd_handles.append(handle) + logger.info("USD loaded (path: %s, handle: %s)", combined_usd_path, handle) except Exception as e: logger.exception("Error loading USD: %s", e) raise From 2644c1eb0e9d6557ed080833333fd8fbdc074561 Mon Sep 17 00:00:00 2001 From: matthewtrepte Date: Mon, 4 May 2026 23:19:41 -0700 Subject: [PATCH 40/40] Sync video recorder's (--video) renderer backend with the active visualizer (--visualizer) (#5474) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description Extending Brian's original PR: Sync video recorder's (--video) renderer backend with the active visualizer (--visualizer) Change enable cross recording support for Renderers and Visualizers Also fixes https://nvbugs/6121118 ## Type of change - Bug fix (non-breaking change which fixes an issue) - New feature (non-breaking change which adds functionality) ## Screenshots Please attach before and after screenshots of the change if applicable. ## Checklist - [ ] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html) - [ ] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [ ] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Signed-off-by: bdilinila <148156773+bdilinila@users.noreply.github.com> Signed-off-by: Kelly Guo Signed-off-by: matthewtrepte Co-authored-by: Brian Dilinila Co-authored-by: HuiDong Chen Co-authored-by: bdilinila <148156773+bdilinila@users.noreply.github.com> Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: Piotr Barejko Co-authored-by: myurasov-nv <168484206+myurasov-nv@users.noreply.github.com> Co-authored-by: Pascal Roth <57946385+pascal-roth@users.noreply.github.com> Co-authored-by: Antoine Richard Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: ClemensSchwarke Co-authored-by: rwiltz <165190220+rwiltz@users.noreply.github.com> Co-authored-by: Kelly Guo Co-authored-by: Kelly Guo Co-authored-by: ooctipus Co-authored-by: hougantc-nvda <127865892+hougantc-nvda@users.noreply.github.com> Co-authored-by: Piotr Barejko --- apps/isaaclab.python.headless.rendering.kit | 1 + docs/source/features/visualization.rst | 90 +++++ docs/source/how-to/record_video.rst | 59 ++- .../mtrepte-expand_viz_markers.minor.rst | 5 + .../isaaclab/isaaclab/envs/direct_marl_env.py | 4 +- .../isaaclab/isaaclab/envs/direct_rl_env.py | 4 +- .../isaaclab/envs/manager_based_env.py | 2 +- .../isaaclab/envs/manager_based_rl_env.py | 4 +- .../isaaclab/envs/utils/video_recorder.py | 171 ++++++-- .../isaaclab/envs/utils/video_recorder_cfg.py | 14 +- .../isaaclab/visualizers/visualizer_cfg.py | 2 +- .../isaaclab/test/envs/test_video_recorder.py | 366 ++++++++++++++++-- .../mtrepte-expand_viz_markers.skip | 1 + .../newton_gl_perspective_video.py | 50 ++- .../newton_gl_perspective_video_cfg.py | 4 +- .../mtrepte-expand_viz_markers.skip | 2 + .../isaacsim_kit_perspective_video.py | 4 +- .../isaacsim_kit_perspective_video_cfg.py | 4 +- .../mtrepte-expand_viz_markers.skip | 1 + .../isaaclab_tasks/utils/sim_launcher.py | 18 + 20 files changed, 712 insertions(+), 94 deletions(-) create mode 100644 source/isaaclab/changelog.d/mtrepte-expand_viz_markers.minor.rst create mode 100644 source/isaaclab_newton/changelog.d/mtrepte-expand_viz_markers.skip create mode 100644 source/isaaclab_physx/changelog.d/mtrepte-expand_viz_markers.skip create mode 100644 source/isaaclab_tasks/changelog.d/mtrepte-expand_viz_markers.skip diff --git a/apps/isaaclab.python.headless.rendering.kit b/apps/isaaclab.python.headless.rendering.kit index 08ee1ded562a..4c387ddb3425 100644 --- a/apps/isaaclab.python.headless.rendering.kit +++ b/apps/isaaclab.python.headless.rendering.kit @@ -17,6 +17,7 @@ keywords = ["experience", "app", "isaaclab", "python", "camera", "minimal"] [dependencies] # Isaac Lab minimal app "isaaclab.python.headless" = {} +"isaacsim.core.rendering_manager" = {} "omni.replicator.core" = {} # Rendering diff --git a/docs/source/features/visualization.rst b/docs/source/features/visualization.rst index b9d23a45cf91..1ea6497dc501 100644 --- a/docs/source/features/visualization.rst +++ b/docs/source/features/visualization.rst @@ -185,6 +185,96 @@ Also, there is a CLI arg ``--max_visible_envs`` that overrides ``VisualizerCfg.m - any - Run headless; ``--headless`` takes precedence. +Video Recording +--------------- + +Video recording is enabled with the ``--video`` flag. When combined with ``--visualizer``, +the visualizer selection also determines which backend captures the video frames: + +- ``--visualizer kit`` enables ``--video`` capture through the Isaac RTX renderer (Omniverse Replicator). +- ``--visualizer newton`` enables ``--video`` capture through the Newton OpenGL renderer. +- ``--visualizer rerun`` does not produce ``--video`` clips; it records Rerun ``.rrd`` data for replay + through the Rerun visualizer. +- ``--visualizer viser`` does not currently provide a ``--video`` recording backend. + +When both Kit and Newton visualizers are active, Isaac Lab records a single ``--video`` stream and +Kit takes precedence. To record from the renderer/physics stack instead of the active visualizer, +set ``VideoRecorderCfg.backend_source = "renderer"`` in the task configuration. + +.. list-table:: ``--video`` compatibility: visualizer × renderer preset + :header-rows: 1 + :widths: 28 36 36 + + * - Renderer preset + - ``--visualizer kit --video`` + - ``--visualizer newton --video`` + * - ``isaacsim_rtx_renderer`` + - ✅ Kit RTX captures video *(default, no change)* + - ✅ Newton GL captures video *(overrides RTX backend)* + * - ``newton_renderer`` + - ✅ Kit RTX captures video *(overrides Newton backend)* + - ✅ Newton GL captures video *(default, no change)* + * - ``ovrtx_renderer`` + - ❌ **Raises an error** — see note below + - ✅ Newton GL captures video; ovrtx provides camera sensor data + +.. note:: + + ``--visualizer kit`` combined with ``ovrtx_renderer`` raises a ``ValueError`` at startup. + Both Kit (Isaac Sim) and ovrtx ship conflicting RTX hydra libraries compiled against + different USD namespaces (``pxrInternal_v0_25_11`` vs ``ovInternal_v0_25_11``), which + causes a dynamic-linker crash when loaded into the same process. + Use ``--visualizer newton`` instead — it is compatible with all renderer presets. + +**Record video with the ovrtx renderer preset** + +.. code-block:: bash + + ./isaaclab.sh -p scripts/benchmarks/benchmark_rsl_rl.py \ + --task=Isaac-Repose-Cube-Shadow-Vision-Direct-v0 \ + --enable_cameras \ + --visualizer newton \ + --video \ + --video_length=300 \ + --video_interval=2000 \ + --max_iterations=5 \ + --num_envs=1024 \ + --benchmark_backend=summary \ + "presets=newton,ovrtx_renderer,rgb" + +**Record video with the Isaac RTX renderer preset using the Newton video backend** + +.. code-block:: bash + + ./isaaclab.sh -p scripts/benchmarks/benchmark_rsl_rl.py \ + --task=Isaac-Repose-Cube-Shadow-Vision-Direct-v0 \ + --enable_cameras \ + --visualizer newton \ + --video \ + --video_length=300 \ + --video_interval=2000 \ + --max_iterations=5 \ + --num_envs=1024 \ + --benchmark_backend=summary \ + "presets=physx,isaacsim_rtx_renderer,rgb" + +**Record video with the Isaac RTX renderer preset using the Kit video backend** + +.. code-block:: bash + + ./isaaclab.sh -p scripts/benchmarks/benchmark_rsl_rl.py \ + --task=Isaac-Repose-Cube-Shadow-Vision-Direct-v0 \ + --enable_cameras \ + --visualizer kit \ + --video \ + --video_length=300 \ + --video_interval=2000 \ + --max_iterations=5 \ + --num_envs=1024 \ + --benchmark_backend=summary \ + "presets=physx,isaacsim_rtx_renderer,rgb" + + Visualizer Backends ------------------- diff --git a/docs/source/how-to/record_video.rst b/docs/source/how-to/record_video.rst index 01ee6240bb0c..576860a214b2 100644 --- a/docs/source/how-to/record_video.rst +++ b/docs/source/how-to/record_video.rst @@ -3,9 +3,10 @@ Recording video clips during training Isaac Lab supports recording video clips during training using the `gymnasium.wrappers.RecordVideo `_ class. -When the ``--video`` flag is enabled, Isaac Lab captures a perspective view of the scene. The backend -is chosen automatically from the active physics and renderer stack: an Isaac Sim Kit camera or a -Newton GL headless viewer. +When the ``--video`` flag is enabled, Isaac Lab captures a perspective view of the scene. If a Kit or +Newton visualizer is active, that visualizer selects the video backend by default. Otherwise, the +backend is chosen automatically from the active physics and renderer stack: an Isaac Sim Kit camera or +a Newton GL headless viewer. This feature can be enabled by installing ``ffmpeg`` and using the following command line arguments with the training script: @@ -32,8 +33,8 @@ Overview The video recording feature is implemented using the ``VideoRecorder`` class. This class is responsible for resolving the video backend from the scene, capturing the video frames, and saving them to a file. -* ``VideoRecorderCfg`` (``isaaclab.envs.utils.video_recorder_cfg``) holds resolution and world-space - perspective parameters ``camera_position`` and ``camera_target`` (defaults to a diagonal view of the +* ``VideoRecorderCfg`` (``isaaclab.envs.utils.video_recorder_cfg``) holds resolution, backend source, + and world-space perspective parameters ``eye`` and ``lookat`` (defaults to a diagonal view of the scene). * ``VideoRecorder`` (``isaaclab.envs.utils.video_recorder``) picks a video backend from the scene (Kit vs Newton GL), builds the matching low-level capture object, and returns RGB frames via @@ -47,12 +48,12 @@ The video recording feature is implemented using the ``VideoRecorder`` class. Th Configuration: ``VideoRecorderCfg`` ------------------------------------ -The dataclass lives in ``isaaclab.envs.utils.video_recorder_cfg``. Fields ``camera_position`` and -``camera_target`` are the perspective ``eye`` and ``lookat`` points in meters. +The dataclass lives in ``isaaclab.envs.utils.video_recorder_cfg``. Fields ``eye`` and ``lookat`` are +the perspective camera position and target in meters. .. literalinclude:: ../../../source/isaaclab/isaaclab/envs/utils/video_recorder_cfg.py :language: python - :lines: 20-48 + :lines: 20-58 Task framing: ``ViewerCfg`` @@ -72,11 +73,20 @@ Backend selection: Kit vs Newton GL ------------------------------------- ``VideoRecorder`` resolves the implementation from the live :class:`~isaaclab.scene.InteractiveScene`. -If the user provides the PhysX physics (``presets=physx,...``) or Isaac RTX (``presets=isaac_rtx_renderer,...``) in the sensor stack, the Kit path is selected (``omni.replicator`` on -``/OmniverseKit_Persp``). The Newton GL path is selected when Newton physics is active (``presets=newton,...``) or the Newton -Warp renderer (``presets=newton_renderer,...``) appears in the sensor stack - and neither PhysX nor Isaac RTX is present to claim the -Kit path. OVRTX (``presets=ovrtx_renderer,...`` from ``isaaclab_ov``) can pair with IsaacSim or Newton physics; in that case the video backend is -selected via the physics preset. If both Kit and Newton GL signals are present (e.g., ``presets=physx,isaac_rtx_renderer,...`` or ``presets=newton,newton_renderer,...``), the Kit path is chosen. +With the default ``VideoRecorderCfg.backend_source = "visualizer"``, an active ``--visualizer kit`` +selects the Kit path (``omni.replicator`` on ``/OmniverseKit_Persp``), and an active +``--visualizer newton`` selects the Newton GL path. If both visualizers are active, Kit takes +precedence and only one ``--video`` stream is recorded. Rerun records ``.rrd`` replay data through +the Rerun visualizer rather than producing ``--video`` clips, and Viser does not currently provide a +``--video`` recording backend. + +Set ``VideoRecorderCfg.backend_source = "renderer"`` to ignore active visualizers and choose from the +physics/renderer stack instead. In that mode, PhysX physics (``presets=physx,...``) or Isaac RTX +(``presets=isaac_rtx_renderer,...``) selects the Kit path. Newton physics (``presets=newton,...``) or +the Newton Warp renderer (``presets=newton_renderer,...``) selects the Newton GL path when no Kit +signal is present. OVRTX (``presets=ovrtx_renderer,...`` from ``isaaclab_ov``) can pair with IsaacSim +or Newton physics; in that case the video backend is selected via the physics preset. If both Kit and +Newton GL signals are present, the Kit path is chosen. .. literalinclude:: ../../../source/isaaclab/isaaclab/envs/utils/video_recorder.py :language: python @@ -87,8 +97,8 @@ Construction and dispatch -------------------------- When ``env_render_mode`` is ``"rgb_array"`` (as when wrappers or scripts request RGB frames for -video), the recorder instantiates the backend-specific helper and passes through ``camera_position``, -``camera_target``, and window size. +video), the recorder instantiates the backend-specific helper and passes through ``eye``, ``lookat``, +and window size. .. literalinclude:: ../../../source/isaaclab/isaaclab/envs/utils/video_recorder.py :language: python @@ -98,18 +108,19 @@ video), the recorder instantiates the backend-specific helper and passes through Customising the camera view ---------------------------- -When ``--video`` is passed, the recording camera uses the same -position and look-at target as the interactive viewer. The defaults come from +When ``--video`` is passed, the recording camera uses the same configured +position and look-at target as the active Kit or Newton visualizer when that visualizer drives backend +selection. Otherwise, the defaults come from :class:`~isaaclab.envs.common.ViewerCfg`: * ``eye = (7.5, 7.5, 7.5)`` — camera position in world space (metres) * ``lookat = (0.0, 0.0, 0.0)`` — camera look-at target in world space (metres) * Resolution ``1280x720`` -To change the recording angle, override the ``viewer`` field in your task's environment config. -The RL base classes automatically copy ``eye`` and ``lookat`` into ``VideoRecorderCfg`` before -recording starts (when ``origin_type`` is ``"world"``), so the video clip uses the same viewpoint -as the interactive viewport: +To change the recording angle without a visualizer, override the ``viewer`` field in your task's +environment config. The RL base classes automatically copy ``eye`` and ``lookat`` into +``VideoRecorderCfg`` before recording starts (when ``origin_type`` is ``"world"``), so the video clip +uses the same configured viewpoint as the interactive viewport: .. code-block:: python @@ -144,6 +155,12 @@ Summary * - ``newton,...,ovrtx_renderer,...`` (OVRTX + Newton physics) - Newton GL (``"newton_gl"``) - ``newton.viewer.ViewerGL`` on the SDP Newton model + * - ``--visualizer kit`` with default ``backend_source`` + - Kit (``"kit"``) + - Visualizer ``eye`` / ``lookat`` copied to ``/OmniverseKit_Persp`` + Replicator RGB + * - ``--visualizer newton`` with default ``backend_source`` + - Newton GL (``"newton_gl"``) + - Visualizer ``eye`` / ``lookat`` initially, then live Newton viewer camera sync per frame See also diff --git a/source/isaaclab/changelog.d/mtrepte-expand_viz_markers.minor.rst b/source/isaaclab/changelog.d/mtrepte-expand_viz_markers.minor.rst new file mode 100644 index 000000000000..8975a9178b83 --- /dev/null +++ b/source/isaaclab/changelog.d/mtrepte-expand_viz_markers.minor.rst @@ -0,0 +1,5 @@ +Added +^^^^^ + +* Added backend-agnostic :class:`~isaaclab.markers.VisualizationMarkers` support for + marker-capable Kit, Newton, Rerun, and Viser visualizers. diff --git a/source/isaaclab/isaaclab/envs/direct_marl_env.py b/source/isaaclab/isaaclab/envs/direct_marl_env.py index 4c8dbf761157..b8fda8cf0986 100644 --- a/source/isaaclab/isaaclab/envs/direct_marl_env.py +++ b/source/isaaclab/isaaclab/envs/direct_marl_env.py @@ -176,8 +176,8 @@ def _init_sim(self, render_mode: str | None = None, **kwargs): if self.cfg.video_recorder is not None: self.cfg.video_recorder.env_render_mode = render_mode vr = self.cfg.video_recorder - vr.camera_position = tuple(float(x) for x in self.cfg.viewer.eye) - vr.camera_target = tuple(float(x) for x in self.cfg.viewer.lookat) + vr.eye = tuple(float(x) for x in self.cfg.viewer.eye) + vr.lookat = tuple(float(x) for x in self.cfg.viewer.lookat) self.video_recorder: VideoRecorder = self.cfg.video_recorder.class_type(self.cfg.video_recorder, self.scene) else: self.video_recorder = None diff --git a/source/isaaclab/isaaclab/envs/direct_rl_env.py b/source/isaaclab/isaaclab/envs/direct_rl_env.py index 0717743b9c63..9251eb0fe817 100644 --- a/source/isaaclab/isaaclab/envs/direct_rl_env.py +++ b/source/isaaclab/isaaclab/envs/direct_rl_env.py @@ -182,8 +182,8 @@ def _init_sim(self, render_mode: str | None = None, **kwargs): self.cfg.video_recorder.env_render_mode = render_mode # Perspective --video uses same eye/lookat as task viewer (Kit persp + Newton GL). vr = self.cfg.video_recorder - vr.camera_position = tuple(float(x) for x in self.cfg.viewer.eye) - vr.camera_target = tuple(float(x) for x in self.cfg.viewer.lookat) + vr.eye = tuple(float(x) for x in self.cfg.viewer.eye) + vr.lookat = tuple(float(x) for x in self.cfg.viewer.lookat) self.video_recorder: VideoRecorder = self.cfg.video_recorder.class_type(self.cfg.video_recorder, self.scene) else: self.video_recorder = None diff --git a/source/isaaclab/isaaclab/envs/manager_based_env.py b/source/isaaclab/isaaclab/envs/manager_based_env.py index b4bc80ac56be..92db9ad117b5 100644 --- a/source/isaaclab/isaaclab/envs/manager_based_env.py +++ b/source/isaaclab/isaaclab/envs/manager_based_env.py @@ -195,7 +195,7 @@ def _init_sim(self): # Instantiate the video recorder before sim.reset() so that any fallback Camera # (used for state-based envs without an observation camera) is spawned into the USD # stage and registered for the PHYSICS_READY callback before physics initialises. - # env_render_mode and camera_position/camera_target are forwarded by subclasses (e.g. ManagerBasedRLEnv) + # env_render_mode and eye/lookat are forwarded by subclasses (e.g. ManagerBasedRLEnv) # into cfg.video_recorder before calling super().__init__(). if self.cfg.video_recorder is not None: self.video_recorder: VideoRecorder = self.cfg.video_recorder.class_type(self.cfg.video_recorder, self.scene) diff --git a/source/isaaclab/isaaclab/envs/manager_based_rl_env.py b/source/isaaclab/isaaclab/envs/manager_based_rl_env.py index 4c3b329b0387..3e48dcd19f88 100644 --- a/source/isaaclab/isaaclab/envs/manager_based_rl_env.py +++ b/source/isaaclab/isaaclab/envs/manager_based_rl_env.py @@ -81,8 +81,8 @@ def __init__(self, cfg: ManagerBasedRLEnvCfg, render_mode: str | None = None, ** # (env_render_mode="rgb_array") and the perspective view matches the task viewport. if cfg.video_recorder is not None: cfg.video_recorder.env_render_mode = render_mode - cfg.video_recorder.camera_position = tuple(float(x) for x in cfg.viewer.eye) - cfg.video_recorder.camera_target = tuple(float(x) for x in cfg.viewer.lookat) + cfg.video_recorder.eye = tuple(float(x) for x in cfg.viewer.eye) + cfg.video_recorder.lookat = tuple(float(x) for x in cfg.viewer.lookat) # initialize the base class to setup the scene. super().__init__(cfg=cfg) diff --git a/source/isaaclab/isaaclab/envs/utils/video_recorder.py b/source/isaaclab/isaaclab/envs/utils/video_recorder.py index 9ffebdbe9b55..3ff6a7e1a0a4 100644 --- a/source/isaaclab/isaaclab/envs/utils/video_recorder.py +++ b/source/isaaclab/isaaclab/envs/utils/video_recorder.py @@ -5,15 +5,22 @@ """Video recorder implementation. -Captures a single wide-angle perspective view of the scene: +Backend resolution (``--video`` + ``--visualizer``): -* **Kit backends** (PhysX physics or Isaac RTX renderer) — uses - :mod:`isaaclab_physx.video_recording.isaacsim_kit_perspective_video`. -* **Newton backends** (Newton physics or Newton Warp renderer only) — uses - :mod:`isaaclab_newton.video_recording.newton_gl_perspective_video`. +1. **Active visualizer** - ``"kit"`` uses the Kit camera; ``"newton"`` uses the Newton GL viewer. + ``"viser"`` / ``"rerun"`` have no capture API and fall through to rule 2. +2. **Physics/renderer stack** - + - PhysX or Isaac RTX uses the Kit camera; + - Newton physics or Newton Warp uses the Newton GL viewer. + Kit wins when both signals present. Raises if nothing resolves. -If neither a Kit nor a Newton backend is detected, construction raises so users do not -use ``--video`` on unsupported setups. +Set :attr:`~isaaclab.envs.utils.video_recorder_cfg.VideoRecorderCfg.backend_source` to ``"renderer"`` +to ignore active visualizers and record from the physics/renderer stack. + +Camera sync when a visualizer drives the backend: construction copies the visualizer config's +``eye`` / ``lookat`` into the recorder config; each :meth:`~VideoRecorder.render_rgb_array` +call then re-reads the Newton viewer's live ``camera.pos/pitch/yaw``. Kit video uses the +configured ``eye`` / ``lookat`` at construction time. See :mod:`video_recorder_cfg` for configuration. """ @@ -34,13 +41,50 @@ _VideoBackend = Literal["kit", "newton_gl"] +# visualizer types that map to a supported video backend. +# viser and rerun are intentionally absent - they have no video-capture API. +_VISUALIZER_TO_VIDEO_BACKEND: dict[str, _VideoBackend] = { + "kit": "kit", + "newton": "newton_gl", +} + -def _resolve_video_backend(scene: InteractiveScene) -> _VideoBackend: - """Resolve which video backend to use from physics and renderer configs. +def _resolve_video_backend( + scene: InteractiveScene, backend_source: str = "visualizer" +) -> tuple[_VideoBackend, str | None]: + """Return ``(backend, matched_visualizer_type)`` for the active scene. - Priority: PhysX or Isaac RTX -> Kit camera; else Newton or Newton Warp -> GL viewer. - When both are present (e.g. PhysX + Newton Warp), Kit wins. + ``matched_visualizer_type`` is ``"kit"`` / ``"newton"`` when a visualizer drove the + selection, or ``None`` when the physics/renderer preset stack was used instead. + + Args: + scene: The interactive scene that owns the sim context. + backend_source: ``"visualizer"`` to let active visualizers choose the backend, or ``"renderer"`` + to ignore active visualizers and use the physics/renderer stack. + + Raises: + RuntimeError: If no supported backend is detected. """ + if backend_source not in ("visualizer", "renderer"): + raise ValueError("VideoRecorderCfg.backend_source must be either 'visualizer' or 'renderer'.") + + # Prefer the visualizer backend when --visualizer is active alongside --video. + visualizer_types: list[str] = scene.sim.resolve_visualizer_types() if backend_source == "visualizer" else [] + if visualizer_types: + # kit takes priority when multiple visualizers are active + for preferred in ("kit", "newton"): + if preferred in visualizer_types: + backend = _VISUALIZER_TO_VIDEO_BACKEND[preferred] + logger.debug("[VideoRecorder] Using '%s' backend from active '%s' visualizer.", backend, preferred) + return backend, preferred + # only unsupported visualizer types (viser, rerun) are active. + logger.warning( + "[VideoRecorder] Active visualizer(s) %s do not support video capture; " + "falling back to physics/renderer stack detection.", + visualizer_types, + ) + + # fall back to physics/renderer preset stack detection. sim = scene.sim physics_name = sim.physics_manager.__name__.lower() renderer_types: list[str] = scene._sensor_renderer_types() @@ -49,9 +93,9 @@ def _resolve_video_backend(scene: InteractiveScene) -> _VideoBackend: use_newton_gl = "newton" in physics_name or "newton_warp" in renderer_types if use_kit: - return "kit" + return "kit", None if use_newton_gl: - return "newton_gl" + return "newton_gl", None raise RuntimeError( "Video recording (--video) requires a supported backend: " "PhysX or Isaac RTX renderer (Kit camera), or Newton physics / Newton Warp renderer (GL viewer). " @@ -59,6 +103,48 @@ def _resolve_video_backend(scene: InteractiveScene) -> _VideoBackend: ) +def _sync_camera_from_visualizer( + scene: InteractiveScene, + visualizer_type: str, + cfg: VideoRecorderCfg, +) -> None: + """Overwrite ``cfg.eye`` and ``cfg.lookat`` from the active visualizer. + + Args: + scene: The interactive scene that owns the sim context. + visualizer_type: The visualizer type string matched by ``_resolve_video_backend`` + (e.g. ``"kit"`` or ``"newton"``). + cfg: The recorder configuration to update in place. + """ + try: + resolved_cfgs = scene.sim._resolve_visualizer_cfgs() + except Exception as exc: + logger.debug("[VideoRecorder] Could not resolve visualizer cfgs for camera sync: %s", exc) + return + + for vcfg in resolved_cfgs: + if getattr(vcfg, "visualizer_type", None) != visualizer_type: + continue + pos = getattr(vcfg, "eye", None) + tgt = getattr(vcfg, "lookat", None) + if pos is None or tgt is None: + break + cfg.eye = tuple(float(x) for x in pos) + cfg.lookat = tuple(float(x) for x in tgt) + logger.debug( + "[VideoRecorder] Camera synced from '%s' visualizer: position=%s, target=%s.", + visualizer_type, + cfg.eye, + cfg.lookat, + ) + return + + logger.debug( + "[VideoRecorder] Could not find eye/lookat on '%s' visualizer cfg; keeping existing camera values.", + visualizer_type, + ) + + class VideoRecorder: """Records perspective video frames from the scene's active renderer. @@ -72,15 +158,20 @@ def __init__(self, cfg: VideoRecorderCfg, scene: InteractiveScene): self._scene = scene self._backend: _VideoBackend | None = None self._capture = None + # visualizer type that drove backend selection (or None when using physics/renderer stack). + self._matched_visualizer: str | None = None + # live visualizer instance - looked up lazily on first render_rgb_array() call because + # visualizers are initialised by sim.reset(), which runs after VideoRecorder.__init__. + self._live_visualizer = None if cfg.env_render_mode == "rgb_array": - self._backend = _resolve_video_backend(scene) + backend_source = getattr(cfg, "backend_source", "visualizer") + self._backend, self._matched_visualizer = _resolve_video_backend(scene, backend_source) + if self._matched_visualizer is not None: + _sync_camera_from_visualizer(scene, self._matched_visualizer, cfg) if self._backend == "newton_gl": try: - import pyglet - - if not pyglet.options.get("headless", False): - pyglet.options["headless"] = True + import pyglet as _pyglet # noqa: F401 - verify pyglet is available except ImportError as e: raise ImportError( "The Newton GL video backend requires 'pyglet'. Install IsaacLab with './isaaclab.sh -i'." @@ -93,8 +184,8 @@ def __init__(self, cfg: VideoRecorderCfg, scene: InteractiveScene): ncfg = NewtonGlPerspectiveVideoCfg( window_width=cfg.window_width, window_height=cfg.window_height, - camera_position=cfg.camera_position, - camera_target=cfg.camera_target, + eye=cfg.eye, + lookat=cfg.lookat, ) self._capture = create_newton_gl_perspective_video(ncfg) else: @@ -106,15 +197,51 @@ def __init__(self, cfg: VideoRecorderCfg, scene: InteractiveScene): ) kcfg = IsaacsimKitPerspectiveVideoCfg( - camera_position=cfg.camera_position, - camera_target=cfg.camera_target, + eye=cfg.eye, + lookat=cfg.lookat, window_width=cfg.window_width, window_height=cfg.window_height, ) self._capture = create_isaacsim_kit_perspective_video(kcfg) + def _sync_newton_camera(self) -> None: + """Push the Newton visualizer's live camera pose into the capture object. + + Called once per :meth:`render_rgb_array` when a Newton visualizer is active. + The live visualizer instance is resolved lazily (visualizers are initialised by + ``sim.reset()``, which runs after ``VideoRecorder.__init__``). + """ + if self._live_visualizer is None: + for viz in self._scene.sim.visualizers: + if getattr(getattr(viz, "cfg", None), "visualizer_type", None) == "newton": + self._live_visualizer = viz + break + if self._live_visualizer is None: + return + + viewer = getattr(self._live_visualizer, "_viewer", None) + if viewer is None: + return + + import math + + cam = viewer.camera + pos = (float(cam.pos[0]), float(cam.pos[1]), float(cam.pos[2])) + yaw_rad = math.radians(float(cam.yaw)) + pitch_rad = math.radians(float(cam.pitch)) + dx = math.cos(pitch_rad) * math.cos(yaw_rad) + dy = math.cos(pitch_rad) * math.sin(yaw_rad) + dz = math.sin(pitch_rad) + target = (pos[0] + dx, pos[1] + dy, pos[2] + dz) + self._capture.update_camera(pos, target) + def render_rgb_array(self) -> np.ndarray | None: """Return an RGB frame for the resolved backend. Fails if backend is unavailable.""" if self._backend is None or self._capture is None: return None + if self._matched_visualizer == "newton": + # Newton GL camera state lives in the capture object and must be synced each frame + # to follow interactive viewer movement. + self._sync_newton_camera() + # Kit capture uses the configured eye/lookat applied to the recording camera at construction time. return self._capture.render_rgb_array() diff --git a/source/isaaclab/isaaclab/envs/utils/video_recorder_cfg.py b/source/isaaclab/isaaclab/envs/utils/video_recorder_cfg.py index 586779e3e679..c0cbb1d9a11d 100644 --- a/source/isaaclab/isaaclab/envs/utils/video_recorder_cfg.py +++ b/source/isaaclab/isaaclab/envs/utils/video_recorder_cfg.py @@ -12,6 +12,8 @@ from __future__ import annotations +from typing import Literal + from isaaclab.utils import configclass from .video_recorder import VideoRecorder @@ -30,7 +32,7 @@ class VideoRecorderCfg: Set automatically by the environment base classes; do not set manually. """ - camera_position: tuple[float, float, float] = (7.5, 7.5, 7.5) + eye: tuple[float, float, float] = (7.5, 7.5, 7.5) """Perspective camera position in world space (metres). Direct RL / MARL and manager-based RL environments overwrite this from @@ -38,9 +40,17 @@ class VideoRecorderCfg: task viewport for both Kit (PhysX / Isaac RTX) and Newton GL (Newton / OVRTX / etc.). """ - camera_target: tuple[float, float, float] = (0.0, 0.0, 0.0) + lookat: tuple[float, float, float] = (0.0, 0.0, 0.0) """Perspective camera look-at target in world space (metres). Set from ``ViewerCfg.lookat`` at env init.""" + backend_source: Literal["visualizer", "renderer"] = "visualizer" + """Source used to resolve the video capture backend. + + ``"visualizer"`` records from the active Kit or Newton visualizer when one is enabled, and falls back to the + physics/renderer stack otherwise. ``"renderer"`` ignores active visualizers and records from the backend implied by + the physics/renderer stack. + """ + window_width: int = 1280 """Width in pixels of the recorded frame.""" diff --git a/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py b/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py index 80a943d6c4ce..1ee4cde038b5 100644 --- a/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py +++ b/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py @@ -40,7 +40,7 @@ class VisualizerCfg: lookat: tuple[float, float, float] = (0.0, 0.0, 0.0) """Initial camera look-at point (x, y, z) in world coordinates.""" - cam_source: Literal["cfg", "prim_path"] = "prim_path" + cam_source: Literal["cfg", "prim_path"] = "cfg" """Camera source mode: 'cfg' uses eye/lookat, 'prim_path' follows a camera prim.""" cam_prim_path: str = "/World/envs/env_0/Camera" diff --git a/source/isaaclab/test/envs/test_video_recorder.py b/source/isaaclab/test/envs/test_video_recorder.py index 231d490c4c53..91af56632d6b 100644 --- a/source/isaaclab/test/envs/test_video_recorder.py +++ b/source/isaaclab/test/envs/test_video_recorder.py @@ -4,6 +4,7 @@ # SPDX-License-Identifier: BSD-3-Clause """Unit tests for VideoRecorder.""" +import math import sys from types import SimpleNamespace from unittest.mock import MagicMock, patch @@ -12,28 +13,34 @@ import pytest from isaaclab.envs.utils import video_recorder as _video_recorder_module -from isaaclab.envs.utils.video_recorder import VideoRecorder +from isaaclab.envs.utils.video_recorder import VideoRecorder, _resolve_video_backend, _sync_camera_from_visualizer pytestmark = pytest.mark.isaacsim_ci _BLANK_720p = np.zeros((720, 1280, 3), dtype=np.uint8) _DEFAULT_CFG = dict( env_render_mode="rgb_array", - camera_position=(7.5, 7.5, 7.5), - camera_target=(0.0, 0.0, 0.0), + eye=(7.5, 7.5, 7.5), + lookat=(0.0, 0.0, 0.0), + backend_source="visualizer", window_width=1280, window_height=720, ) def _create_recorder(**kw): - """Return a VideoRecorder with __init__ bypassed and all deps mocked out.""" + """Return a VideoRecorder with ``__init__`` bypassed and all deps mocked out.""" backend = kw.pop("_backend", None) + matched_visualizer = kw.pop("_matched_visualizer", None) + live_visualizer = kw.pop("_live_visualizer", None) recorder = object.__new__(VideoRecorder) recorder.cfg = SimpleNamespace(**{**_DEFAULT_CFG, **kw}) recorder._scene = MagicMock() recorder._scene.sensors = {} recorder._scene._sensor_renderer_types = MagicMock(return_value=[]) + recorder._scene.sim.visualizers = [] recorder._backend = backend + recorder._matched_visualizer = matched_visualizer + recorder._live_visualizer = live_visualizer cap = MagicMock() cap.render_rgb_array = MagicMock(return_value=_BLANK_720p) recorder._capture = cap if backend else None @@ -49,18 +56,20 @@ def test_init_perspective_mode_creates_kit_capture(): fake_capture = MagicMock() kit_mod = MagicMock() kit_mod.create_isaacsim_kit_perspective_video = MagicMock(return_value=fake_capture) - with patch.object(_video_recorder_module, "_resolve_video_backend", return_value="kit"): - with patch.dict( - sys.modules, - { - "isaaclab_physx.video_recording": MagicMock(), - "isaaclab_physx.video_recording.isaacsim_kit_perspective_video": kit_mod, - "isaaclab_physx.video_recording.isaacsim_kit_perspective_video_cfg": MagicMock(), - }, - ): - vr = VideoRecorder(cfg, scene) + with patch.object(_video_recorder_module, "_resolve_video_backend", return_value=("kit", None)): + with patch.object(_video_recorder_module, "_sync_camera_from_visualizer"): + with patch.dict( + sys.modules, + { + "isaaclab_physx.video_recording": MagicMock(), + "isaaclab_physx.video_recording.isaacsim_kit_perspective_video": kit_mod, + "isaaclab_physx.video_recording.isaacsim_kit_perspective_video_cfg": MagicMock(), + }, + ): + vr = VideoRecorder(cfg, scene) kit_mod.create_isaacsim_kit_perspective_video.assert_called_once() assert vr._capture is fake_capture + assert vr._matched_visualizer is None def test_init_newton_backend_creates_newton_capture(): @@ -70,19 +79,184 @@ def test_init_newton_backend_creates_newton_capture(): fake_capture = MagicMock() newton_mod = MagicMock() newton_mod.create_newton_gl_perspective_video = MagicMock(return_value=fake_capture) - with patch.object(_video_recorder_module, "_resolve_video_backend", return_value="newton_gl"): - with patch.dict( - sys.modules, - { - "pyglet": MagicMock(), - "isaaclab_newton.video_recording": MagicMock(), - "isaaclab_newton.video_recording.newton_gl_perspective_video": newton_mod, - "isaaclab_newton.video_recording.newton_gl_perspective_video_cfg": MagicMock(), - }, - ): - vr = VideoRecorder(cfg, scene) + with patch.object(_video_recorder_module, "_resolve_video_backend", return_value=("newton_gl", "newton")): + with patch.object(_video_recorder_module, "_sync_camera_from_visualizer"): + with patch.dict( + sys.modules, + { + "pyglet": MagicMock(), + "isaaclab_newton.video_recording": MagicMock(), + "isaaclab_newton.video_recording.newton_gl_perspective_video": newton_mod, + "isaaclab_newton.video_recording.newton_gl_perspective_video_cfg": MagicMock(), + }, + ): + vr = VideoRecorder(cfg, scene) newton_mod.create_newton_gl_perspective_video.assert_called_once() assert vr._capture is fake_capture + assert vr._matched_visualizer == "newton" + + +def test_init_kit_from_visualizer_syncs_camera(): + """When backend comes from a visualizer, _sync_camera_from_visualizer is called.""" + scene = MagicMock() + cfg = SimpleNamespace(**_DEFAULT_CFG) + with patch.object(_video_recorder_module, "_resolve_video_backend", return_value=("kit", "kit")): + with patch.object(_video_recorder_module, "_sync_camera_from_visualizer") as mock_sync: + with patch.dict( + sys.modules, + { + "isaaclab_physx.video_recording": MagicMock(), + "isaaclab_physx.video_recording.isaacsim_kit_perspective_video": MagicMock(), + "isaaclab_physx.video_recording.isaacsim_kit_perspective_video_cfg": MagicMock(), + }, + ): + VideoRecorder(cfg, scene) + mock_sync.assert_called_once_with(scene, "kit", cfg) + + +def test_init_no_visualizer_skips_camera_sync(): + """When backend comes from physics/renderer stack, camera sync is skipped.""" + scene = MagicMock() + cfg = SimpleNamespace(**_DEFAULT_CFG) + with patch.object(_video_recorder_module, "_resolve_video_backend", return_value=("kit", None)): + with patch.object(_video_recorder_module, "_sync_camera_from_visualizer") as mock_sync: + with patch.dict( + sys.modules, + { + "isaaclab_physx.video_recording": MagicMock(), + "isaaclab_physx.video_recording.isaacsim_kit_perspective_video": MagicMock(), + "isaaclab_physx.video_recording.isaacsim_kit_perspective_video_cfg": MagicMock(), + }, + ): + VideoRecorder(cfg, scene) + mock_sync.assert_not_called() + + +def _make_scene(visualizer_types, physics_name="PhysxPhysicsManager", renderer_types=None): + scene = MagicMock() + scene.sim.resolve_visualizer_types.return_value = visualizer_types + scene.sim.physics_manager.__name__ = physics_name + scene._sensor_renderer_types.return_value = renderer_types or [] + return scene + + +def test_resolve_backend_prefers_kit_visualizer(): + """When 'kit' visualizer is active, backend is 'kit' with matched type 'kit'.""" + scene = _make_scene(["kit"]) + backend, matched = _resolve_video_backend(scene) + assert backend == "kit" + assert matched == "kit" + + +def test_resolve_backend_prefers_newton_visualizer(): + """When 'newton' visualizer is active, backend is 'newton_gl' with matched type 'newton'.""" + scene = _make_scene(["newton"], physics_name="NewtonPhysicsManager") + backend, matched = _resolve_video_backend(scene) + assert backend == "newton_gl" + assert matched == "newton" + + +def test_resolve_backend_renderer_source_ignores_visualizer(): + """When backend_source is 'renderer', active visualizers do not drive backend selection.""" + scene = _make_scene(["newton"], physics_name="PhysxPhysicsManager") + backend, matched = _resolve_video_backend(scene, backend_source="renderer") + assert backend == "kit" + assert matched is None + + +def test_resolve_backend_kit_wins_over_newton_visualizer(): + """When both kit and newton visualizers are active, kit takes priority.""" + scene = _make_scene(["newton", "kit"]) + backend, matched = _resolve_video_backend(scene) + assert backend == "kit" + assert matched == "kit" + + +def test_resolve_backend_unsupported_visualizer_falls_through(): + """viser/rerun visualizers fall through to physics stack detection.""" + scene = _make_scene(["viser"], physics_name="PhysxPhysicsManager") + backend, matched = _resolve_video_backend(scene) + assert backend == "kit" + assert matched is None + + +def test_resolve_backend_fallback_physx_returns_none_matched(): + """Physics/renderer fallback returns None as matched visualizer.""" + scene = _make_scene([], physics_name="PhysxPhysicsManager") + backend, matched = _resolve_video_backend(scene) + assert backend == "kit" + assert matched is None + + +def test_resolve_backend_fallback_newton_physics_returns_none_matched(): + """Newton physics fallback returns None as matched visualizer.""" + scene = _make_scene([], physics_name="NewtonPhysicsManager") + backend, matched = _resolve_video_backend(scene) + assert backend == "newton_gl" + assert matched is None + + +def test_resolve_backend_raises_when_no_supported_backend(): + """RuntimeError when no supported backend can be detected.""" + scene = _make_scene([], physics_name="UnknownManager") + with pytest.raises(RuntimeError, match="No supported backend detected"): + _resolve_video_backend(scene) + + +def test_resolve_backend_raises_for_invalid_backend_source(): + """Only 'visualizer' and 'renderer' are valid backend source modes.""" + scene = _make_scene([]) + with pytest.raises(ValueError, match="backend_source"): + _resolve_video_backend(scene, backend_source="invalid") + + +def _make_visualizer_cfg(visualizer_type, eye=None, lookat=None): + return SimpleNamespace(visualizer_type=visualizer_type, eye=eye, lookat=lookat) + + +def test_sync_camera_overwrites_cfg_from_visualizer(): + """Visualizer cfg eye/lookat are written into VideoRecorderCfg.""" + scene = MagicMock() + scene.sim._resolve_visualizer_cfgs.return_value = [ + _make_visualizer_cfg("newton", eye=(1.0, 2.0, 3.0), lookat=(4.0, 5.0, 6.0)), + ] + cfg = SimpleNamespace(**_DEFAULT_CFG) + _sync_camera_from_visualizer(scene, "newton", cfg) + assert cfg.eye == (1.0, 2.0, 3.0) + assert cfg.lookat == (4.0, 5.0, 6.0) + + +def test_sync_camera_skips_wrong_visualizer_type(): + """Only the matching visualizer type updates the cfg.""" + scene = MagicMock() + scene.sim._resolve_visualizer_cfgs.return_value = [ + _make_visualizer_cfg("kit", eye=(9.0, 9.0, 9.0), lookat=(1.0, 1.0, 1.0)), + ] + cfg = SimpleNamespace(**_DEFAULT_CFG) + original_eye = cfg.eye + _sync_camera_from_visualizer(scene, "newton", cfg) + assert cfg.eye == original_eye # unchanged + + +def test_sync_camera_handles_missing_camera_fields(): + """If visualizer cfg has no camera fields, existing cfg values are kept.""" + scene = MagicMock() + vcfg = _make_visualizer_cfg("newton", eye=None, lookat=None) + scene.sim._resolve_visualizer_cfgs.return_value = [vcfg] + cfg = SimpleNamespace(**_DEFAULT_CFG) + original_eye = cfg.eye + _sync_camera_from_visualizer(scene, "newton", cfg) + assert cfg.eye == original_eye + + +def test_sync_camera_handles_resolve_exception(): + """If _resolve_visualizer_cfgs raises, no exception propagates and cfg is unchanged.""" + scene = MagicMock() + scene.sim._resolve_visualizer_cfgs.side_effect = RuntimeError("boom") + cfg = SimpleNamespace(**_DEFAULT_CFG) + original_eye = cfg.eye + _sync_camera_from_visualizer(scene, "newton", cfg) + assert cfg.eye == original_eye def test_render_rgb_array_delegates_to_capture(): @@ -115,3 +289,145 @@ def test_render_rgb_array_calls_capture_each_step(): for _ in range(3): recorder.render_rgb_array() assert recorder._capture.render_rgb_array.call_count == 3 + + +def test_render_rgb_array_calls_sync_newton_camera_when_newton_visualizer(): + """render_rgb_array triggers _sync_newton_camera when matched_visualizer is 'newton'.""" + recorder = _create_recorder(_backend="newton_gl", _matched_visualizer="newton") + with patch.object(recorder, "_sync_newton_camera") as mock_sync: + recorder.render_rgb_array() + mock_sync.assert_called_once() + + +def test_render_rgb_array_skips_sync_for_kit_visualizer(): + """render_rgb_array does NOT call _sync_newton_camera for kit backend.""" + recorder = _create_recorder(_backend="kit", _matched_visualizer="kit") + with patch.object(recorder, "_sync_newton_camera") as mock_sync: + recorder.render_rgb_array() + mock_sync.assert_not_called() + + +def test_render_rgb_array_skips_sync_when_no_visualizer(): + """render_rgb_array does NOT call _sync_newton_camera when using physics/renderer stack.""" + recorder = _create_recorder(_backend="kit", _matched_visualizer=None) + with patch.object(recorder, "_sync_newton_camera") as mock_sync: + recorder.render_rgb_array() + mock_sync.assert_not_called() + + +def _make_newton_visualizer(pos=(1.0, 2.0, 3.0), yaw_deg=45.0, pitch_deg=30.0): + """Return a mock that quacks like a NewtonVisualizer with a live camera.""" + viz = MagicMock() + viz.cfg.visualizer_type = "newton" + cam = MagicMock() + cam.pos = pos + cam.yaw = yaw_deg + cam.pitch = pitch_deg + viz._viewer = MagicMock() + viz._viewer.camera = cam + return viz + + +def test_sync_newton_camera_lazy_lookup_finds_visualizer(): + """_sync_newton_camera resolves the Newton visualizer on the first call.""" + recorder = _create_recorder(_backend="newton_gl", _matched_visualizer="newton") + newton_viz = _make_newton_visualizer() + recorder._scene.sim.visualizers = [newton_viz] + + recorder._sync_newton_camera() + + assert recorder._live_visualizer is newton_viz + recorder._capture.update_camera.assert_called_once() + + +def test_sync_newton_camera_uses_cached_visualizer(): + """_sync_newton_camera uses the cached _live_visualizer and skips the list walk.""" + recorder = _create_recorder(_backend="newton_gl", _matched_visualizer="newton") + newton_viz = _make_newton_visualizer() + # pre-cache the visualizer + recorder._live_visualizer = newton_viz + + other_viz = _make_newton_visualizer(pos=(99.0, 99.0, 99.0)) + # replace sim.visualizers with a second Newton visualizer + # if the cache is bypassed the recorder would use this one instead. + recorder._scene.sim.visualizers = [other_viz] + recorder._sync_newton_camera() + position = recorder._capture.update_camera.call_args[0][0] + assert position != (99.0, 99.0, 99.0) + recorder._capture.update_camera.assert_called_once() + + +def test_sync_newton_camera_correct_position_forwarded(): + """_sync_newton_camera reads cam.pos and passes it as position to update_camera.""" + recorder = _create_recorder(_backend="newton_gl", _matched_visualizer="newton") + newton_viz = _make_newton_visualizer(pos=(10.0, 20.0, 30.0), yaw_deg=0.0, pitch_deg=0.0) + recorder._live_visualizer = newton_viz + + recorder._sync_newton_camera() + + args = recorder._capture.update_camera.call_args + position = args[0][0] + assert position == (10.0, 20.0, 30.0) + + +def test_sync_newton_camera_target_derived_from_pitch_yaw(): + """Target is reconstructed from pitch/yaw and is unit-distance from position.""" + recorder = _create_recorder(_backend="newton_gl", _matched_visualizer="newton") + pos = (0.0, 0.0, 0.0) + yaw_deg, pitch_deg = 0.0, 0.0 # looking along +X at horizon + newton_viz = _make_newton_visualizer(pos=pos, yaw_deg=yaw_deg, pitch_deg=pitch_deg) + recorder._live_visualizer = newton_viz + + recorder._sync_newton_camera() + + args = recorder._capture.update_camera.call_args[0] + position, target = args + dx = target[0] - position[0] + dy = target[1] - position[1] + dz = target[2] - position[2] + dist = math.sqrt(dx**2 + dy**2 + dz**2) + assert abs(dist - 1.0) < 1e-6 + assert abs(dx - 1.0) < 1e-6 + assert abs(dy) < 1e-6 + assert abs(dz) < 1e-6 + + +def test_sync_newton_camera_no_visualizer_does_not_raise(): + """_sync_newton_camera silently skips when no Newton visualizer is registered.""" + recorder = _create_recorder(_backend="newton_gl", _matched_visualizer="newton") + recorder._scene.sim.visualizers = [] + recorder._sync_newton_camera() # must not raise + recorder._capture.update_camera.assert_not_called() + + +def test_sync_newton_camera_skips_non_newton_visualizers(): + """_sync_newton_camera ignores visualizers whose type is not 'newton'.""" + recorder = _create_recorder(_backend="newton_gl", _matched_visualizer="newton") + kit_viz = MagicMock() + kit_viz.cfg.visualizer_type = "kit" + recorder._scene.sim.visualizers = [kit_viz] + recorder._sync_newton_camera() + recorder._capture.update_camera.assert_not_called() + + +def test_sync_newton_camera_skips_when_viewer_is_none(): + """_sync_newton_camera skips camera update when _viewer is None (headless fallback).""" + recorder = _create_recorder(_backend="newton_gl", _matched_visualizer="newton") + viz = MagicMock() + viz.cfg.visualizer_type = "newton" + viz._viewer = None + recorder._live_visualizer = viz + recorder._sync_newton_camera() + recorder._capture.update_camera.assert_not_called() + + +def test_sync_newton_camera_called_per_frame(): + """_sync_newton_camera (and thus update_camera) is called on every render step.""" + recorder = _create_recorder(_backend="newton_gl", _matched_visualizer="newton") + newton_viz = _make_newton_visualizer() + recorder._live_visualizer = newton_viz + + for _ in range(4): + recorder.render_rgb_array() + + assert recorder._capture.update_camera.call_count == 4 diff --git a/source/isaaclab_newton/changelog.d/mtrepte-expand_viz_markers.skip b/source/isaaclab_newton/changelog.d/mtrepte-expand_viz_markers.skip new file mode 100644 index 000000000000..a23b7c7322b3 --- /dev/null +++ b/source/isaaclab_newton/changelog.d/mtrepte-expand_viz_markers.skip @@ -0,0 +1 @@ +Marker visualization changes are covered by the isaaclab fragment. diff --git a/source/isaaclab_newton/isaaclab_newton/video_recording/newton_gl_perspective_video.py b/source/isaaclab_newton/isaaclab_newton/video_recording/newton_gl_perspective_video.py index cbc2af01def7..9c440a657981 100644 --- a/source/isaaclab_newton/isaaclab_newton/video_recording/newton_gl_perspective_video.py +++ b/source/isaaclab_newton/isaaclab_newton/video_recording/newton_gl_perspective_video.py @@ -52,24 +52,54 @@ def _ensure_viewer(self) -> None: viewer.set_world_offsets((0.0, 0.0, 0.0)) viewer.up_axis = 2 - import warp as wp - - ex, ey, ez = self.cfg.camera_position - lx, ly, lz = self.cfg.camera_target - dx, dy, dz = lx - ex, ly - ey, lz - ez - length = math.sqrt(dx**2 + dy**2 + dz**2) - dx, dy, dz = dx / length, dy / length, dz / length - pitch = math.degrees(math.asin(max(-1.0, min(1.0, dz)))) - yaw = math.degrees(math.atan2(dy, dx)) aspect = w / h h_fov = math.radians(self.cfg.horiz_fov_deg) v_fov_deg = math.degrees(2.0 * math.atan(math.tan(h_fov / 2.0) / aspect)) viewer.camera.fov = v_fov_deg - viewer.set_camera(pos=wp.vec3(ex, ey, ez), pitch=pitch, yaw=yaw) self._viewer = viewer + self._apply_camera(self.cfg.eye, self.cfg.lookat) logger.info("[NewtonGlPerspectiveVideo] ViewerGL ready (%dx%d).", w, h) + def _apply_camera( + self, + position: tuple[float, float, float], + target: tuple[float, float, float], + ) -> None: + """Point the recorder's ViewerGL at ``position`` looking toward ``target``.""" + if self._viewer is None: + return + import warp as wp + + ex, ey, ez = position + lx, ly, lz = target + dx, dy, dz = lx - ex, ly - ey, lz - ez + length = math.sqrt(dx**2 + dy**2 + dz**2) + if length > 1e-9: + dx, dy, dz = dx / length, dy / length, dz / length + pitch = math.degrees(math.asin(max(-1.0, min(1.0, dz)))) + yaw = math.degrees(math.atan2(dy, dx)) + self._viewer.set_camera(pos=wp.vec3(ex, ey, ez), pitch=pitch, yaw=yaw) + + def update_camera( + self, + position: tuple[float, float, float], + target: tuple[float, float, float], + ) -> None: + """Update the recorder camera to match ``position`` / ``target``. + + Safe to call before the first :meth:`render_rgb_array` (the viewer is + created lazily; the values will be applied immediately after creation). + When the viewer is already live the camera is repositioned in-place so + the next frame reflects the new viewpoint. + + Args: + position: Camera eye position ``(x, y, z)``. + target: Camera look-at target ``(x, y, z)``. + """ + self._ensure_viewer() + self._apply_camera(position, target) + def render_rgb_array(self) -> np.ndarray: """Return one RGB frame from the Newton GL viewer. Raises on failure.""" self._ensure_viewer() diff --git a/source/isaaclab_newton/isaaclab_newton/video_recording/newton_gl_perspective_video_cfg.py b/source/isaaclab_newton/isaaclab_newton/video_recording/newton_gl_perspective_video_cfg.py index c2f8ef5cbbf8..6408b2ac0e53 100644 --- a/source/isaaclab_newton/isaaclab_newton/video_recording/newton_gl_perspective_video_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/video_recording/newton_gl_perspective_video_cfg.py @@ -29,10 +29,10 @@ class NewtonGlPerspectiveVideoCfg: window_height: int = 720 """Viewer height in pixels.""" - camera_position: tuple[float, float, float] = (7.5, 7.5, 7.5) + eye: tuple[float, float, float] = (7.5, 7.5, 7.5) """Camera position in world space (metres).""" - camera_target: tuple[float, float, float] = (0.0, 0.0, 0.0) + lookat: tuple[float, float, float] = (0.0, 0.0, 0.0) """Camera look-at target in world space (metres).""" horiz_fov_deg: float = 60.0 diff --git a/source/isaaclab_physx/changelog.d/mtrepte-expand_viz_markers.skip b/source/isaaclab_physx/changelog.d/mtrepte-expand_viz_markers.skip new file mode 100644 index 000000000000..4f6915f6b47b --- /dev/null +++ b/source/isaaclab_physx/changelog.d/mtrepte-expand_viz_markers.skip @@ -0,0 +1,2 @@ +Marker visualization changes are covered by the isaaclab fragment. +Marker visualization changes are covered by the isaaclab fragment. diff --git a/source/isaaclab_physx/isaaclab_physx/video_recording/isaacsim_kit_perspective_video.py b/source/isaaclab_physx/isaaclab_physx/video_recording/isaacsim_kit_perspective_video.py index ba844f03380d..4796340f2724 100644 --- a/source/isaaclab_physx/isaaclab_physx/video_recording/isaacsim_kit_perspective_video.py +++ b/source/isaaclab_physx/isaaclab_physx/video_recording/isaacsim_kit_perspective_video.py @@ -36,8 +36,8 @@ def render_rgb_array(self) -> np.ndarray: ViewportManager.set_camera_view( self.cfg.camera_prim_path, - eye=list(self.cfg.camera_position), - target=list(self.cfg.camera_target), + eye=list(self.cfg.eye), + target=list(self.cfg.lookat), ) self._render_product = rep.create.render_product(self.cfg.camera_prim_path, (w, h)) self._rgb_annotator = rep.AnnotatorRegistry.get_annotator("rgb", device="cpu") diff --git a/source/isaaclab_physx/isaaclab_physx/video_recording/isaacsim_kit_perspective_video_cfg.py b/source/isaaclab_physx/isaaclab_physx/video_recording/isaacsim_kit_perspective_video_cfg.py index 8a256c5f133c..c3f9280976aa 100644 --- a/source/isaaclab_physx/isaaclab_physx/video_recording/isaacsim_kit_perspective_video_cfg.py +++ b/source/isaaclab_physx/isaaclab_physx/video_recording/isaacsim_kit_perspective_video_cfg.py @@ -28,10 +28,10 @@ class IsaacsimKitPerspectiveVideoCfg: camera_prim_path: str = "/OmniverseKit_Persp" """Viewport camera prim used for the render product.""" - camera_position: tuple[float, float, float] = (7.5, 7.5, 7.5) + eye: tuple[float, float, float] = (7.5, 7.5, 7.5) """Camera position in world space (metres).""" - camera_target: tuple[float, float, float] = (0.0, 0.0, 0.0) + lookat: tuple[float, float, float] = (0.0, 0.0, 0.0) """Camera look-at target in world space (metres).""" window_width: int = 1280 diff --git a/source/isaaclab_tasks/changelog.d/mtrepte-expand_viz_markers.skip b/source/isaaclab_tasks/changelog.d/mtrepte-expand_viz_markers.skip new file mode 100644 index 000000000000..a23b7c7322b3 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/mtrepte-expand_viz_markers.skip @@ -0,0 +1 @@ +Marker visualization changes are covered by the isaaclab fragment. diff --git a/source/isaaclab_tasks/isaaclab_tasks/utils/sim_launcher.py b/source/isaaclab_tasks/isaaclab_tasks/utils/sim_launcher.py index 658b5a1b873e..3030bda1c4b7 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/utils/sim_launcher.py +++ b/source/isaaclab_tasks/isaaclab_tasks/utils/sim_launcher.py @@ -236,6 +236,24 @@ def launch_simulation( with launch_simulation(env_cfg, args_cli): main() """ + # When --visualizer kit is explicitly requested alongside an ovrtx preset, fail early. + # ovrtx and Kit ship the same RTX hydra libraries under conflicting USD namespaces; + # loading both in the same process causes a dynamic-linker crash. Use + # --visualizer newton instead, which is compatible with ovrtx presets. + early_visualizer_types = _get_visualizer_types(launcher_args) + if "kit" in early_visualizer_types: + has_ovrtx = _scan_config( + env_cfg, [lambda node: isinstance(node, RendererCfg) and getattr(node, "renderer_type", None) == "ovrtx"] + )[0] + if has_ovrtx: + raise ValueError( + "[launch_simulation] '--visualizer kit' is incompatible with 'ovrtx_renderer'. " + "Both Kit (Isaac Sim) and ovrtx ship conflicting RTX hydra libraries " + "(librtx.hydra.so, liblegacy.hydra.so) compiled against different USD namespaces, " + "which causes a dynamic-linker crash when loaded into the same process. " + "Use '--visualizer newton' instead, which is fully compatible with ovrtx presets." + ) + needs_kit, has_kit_cameras, visualizer_types = compute_kit_requirements(env_cfg, launcher_args) visualizer_intent = _compute_visualizer_intent(env_cfg) _set_visualizer_intent_on_launcher_args(launcher_args, visualizer_intent)
Test