diff --git a/.github/actions/run-package-tests/action.yml b/.github/actions/run-package-tests/action.yml index a88ec78c9046..9ee6dcddd9b8 100644 --- a/.github/actions/run-package-tests/action.yml +++ b/.github/actions/run-package-tests/action.yml @@ -41,6 +41,12 @@ inputs: skipped. Combines with filter-pattern (include + exclude). default: '' required: false + test-k-expr: + description: >- + Global pytest -k expression applied inside every per-file pytest run + spawned by tools/conftest.py (combined with device-split selectors). + default: '' + required: false shard-index: description: 'Zero-based shard index' default: '' @@ -286,6 +292,7 @@ runs: pytest-options: ${{ inputs.pytest-options }} filter-pattern: ${{ inputs.filter-pattern }} exclude-pattern: ${{ inputs.exclude-pattern }} + test-k-expr: ${{ inputs.test-k-expr }} shard-index: ${{ inputs.shard-index }} shard-count: ${{ inputs.shard-count }} curobo-only: ${{ inputs.curobo-only }} diff --git a/.github/actions/run-tests/action.yml b/.github/actions/run-tests/action.yml index c4a6859b6399..fbc7043cdf8f 100644 --- a/.github/actions/run-tests/action.yml +++ b/.github/actions/run-tests/action.yml @@ -41,6 +41,14 @@ inputs: excluded. Combines with filter-pattern (include + exclude). default: '' required: false + test-k-expr: + description: >- + Global pytest -k expression applied inside every per-file pytest run + spawned by tools/conftest.py (combined with device-split selectors). + Unlike pytest-options, this reaches the individual test processes, so it + can deselect parametrized cases (e.g. "not ovphysx"). + default: '' + required: false curobo-only: description: 'Run only cuRobo and SkillGen tests (requires the cuRobo Docker image)' default: 'false' @@ -94,6 +102,12 @@ runs: steps: - name: Run Tests in Docker Container shell: bash + env: + # Passed via env instead of inline ${{ }} interpolation: pytest options may + # contain spaces and quotes (e.g. -k "not ovphysx"), which would word-split + # the run_tests positional arguments if substituted textually. + PYTEST_OPTIONS: ${{ inputs.pytest-options }} + TEST_K_EXPR_INPUT: ${{ inputs.test-k-expr }} run: | # Function to run tests in Docker container run_tests() { @@ -116,6 +130,7 @@ runs: local test_node_ids_key="${17}" local wheelhouse_host_dir="${18}" local wheelhouse_packages="${19}" + local test_k_expr="${20}" local logs_pid="" local wait_pid="" local docker_wait_file="/tmp/.docker_exit_${container_name}" @@ -258,6 +273,12 @@ runs: docker_env_vars="$docker_env_vars -e TEST_EXTRA_PIP_PACKAGES" fi + if [ -n "$test_k_expr" ]; then + export TEST_K_EXPR="$test_k_expr" + docker_env_vars="$docker_env_vars -e TEST_K_EXPR" + echo "Setting per-file pytest -k expression: $test_k_expr" + fi + # Volume mount for deps-cache-hit mode: bind-mount the checked-out # source code over /workspace/isaaclab instead of baking it into the image. docker_volume_args="" @@ -491,7 +512,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.exclude-pattern }}" "${{ inputs.curobo-only }}" "${{ inputs.include-files }}" "${{ inputs.quarantined-only }}" "${{ inputs.shard-index }}" "${{ inputs.shard-count }}" "${{ inputs.volume-mount-source }}" "${{ inputs.extra-pip-packages }}" "${{ inputs.test-node-ids-file }}" "${{ inputs.test-node-ids-key }}" "${{ inputs.wheelhouse-host-dir }}" "${{ inputs.wheelhouse-packages }}" + run_tests "${{ inputs.test-path }}" "${{ inputs.result-file }}" "${{ inputs.container-name }}" "${{ inputs.image-tag }}" "${{ inputs.reports-dir }}" "$PYTEST_OPTIONS" "${{ inputs.filter-pattern }}" "${{ inputs.exclude-pattern }}" "${{ inputs.curobo-only }}" "${{ inputs.include-files }}" "${{ inputs.quarantined-only }}" "${{ inputs.shard-index }}" "${{ inputs.shard-count }}" "${{ inputs.volume-mount-source }}" "${{ inputs.extra-pip-packages }}" "${{ inputs.test-node-ids-file }}" "${{ inputs.test-node-ids-key }}" "${{ inputs.wheelhouse-host-dir }}" "${{ inputs.wheelhouse-packages }}" "$TEST_K_EXPR_INPUT" - name: Kill container on cancellation if: cancelled() diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 1bef75f6b010..39f9ffab1b2c 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -596,10 +596,7 @@ jobs: needs: [build, config] if: >- github.event_name != 'push' && - needs.build.result == 'success' && - (needs.config.outputs.ovphysx_wheelhouse_resource == '' || - (github.event_name == 'pull_request' && github.base_ref == 'develop' && github.event.pull_request.head.repo.full_name == github.repository) || - (github.event_name != 'pull_request' && github.ref_name == 'develop')) + needs.build.result == 'success' env: USE_OVPHYSX_WHEELHOUSE: ${{ needs.config.outputs.ovphysx_wheelhouse_resource != '' && ((github.event_name == 'pull_request' && github.base_ref == 'develop' && github.event.pull_request.head.repo.full_name == github.repository) || (github.event_name != 'pull_request' && github.ref_name == 'develop')) }} steps: @@ -613,6 +610,10 @@ jobs: isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} filter-pattern: "isaaclab_ov" + # isaaclab_ovphysx requires ovphysx >= 0.5.1, which is only available from the + # NGC wheelhouse; untrusted runs (fork PRs) fall back to the public pip index + # and keep only the ovrtx-based isaaclab_ov coverage. + exclude-pattern: ${{ env.USE_OVPHYSX_WHEELHOUSE == 'true' && '' || 'isaaclab_ovphysx' }} extra-pip-packages: ${{ env.USE_OVPHYSX_WHEELHOUSE == 'true' && 'ovrtx' || 'ovrtx ovphysx' }} wheelhouse-resource: ${{ env.USE_OVPHYSX_WHEELHOUSE == 'true' && needs.config.outputs.ovphysx_wheelhouse_resource || '' }} wheelhouse-packages: ${{ env.USE_OVPHYSX_WHEELHOUSE == 'true' && 'ovphysx' || '' }} @@ -777,11 +778,7 @@ jobs: timeout-minutes: 120 continue-on-error: ${{ github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' }} needs: [build, config] - if: >- - needs.build.result == 'success' && - (needs.config.outputs.ovphysx_wheelhouse_resource == '' || - (github.event_name == 'pull_request' && github.base_ref == 'develop' && github.event.pull_request.head.repo.full_name == github.repository) || - (github.event_name != 'pull_request' && github.ref_name == 'develop')) + if: needs.build.result == 'success' env: USE_OVPHYSX_WHEELHOUSE: ${{ needs.config.outputs.ovphysx_wheelhouse_resource != '' && ((github.event_name == 'pull_request' && github.base_ref == 'develop' && github.event.pull_request.head.repo.full_name == github.repository) || (github.event_name != 'pull_request' && github.ref_name == 'develop')) }} steps: @@ -795,6 +792,10 @@ jobs: isaacsim-base-image: ${{ needs.config.outputs.isaacsim_image_name }} isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} filter-pattern: "isaaclab_tasks" + # ovphysx-backed test params require ovphysx >= 0.5.1, which is only available + # from the NGC wheelhouse; untrusted runs (fork PRs) fall back to the public pip + # index and keep the newton/ovrtx kitless rendering coverage. + test-k-expr: ${{ env.USE_OVPHYSX_WHEELHOUSE == 'true' && '' || 'not ovphysx' }} extra-pip-packages: ${{ env.USE_OVPHYSX_WHEELHOUSE == 'true' && 'ovrtx' || 'ovrtx ovphysx' }} wheelhouse-resource: ${{ env.USE_OVPHYSX_WHEELHOUSE == 'true' && needs.config.outputs.ovphysx_wheelhouse_resource || '' }} wheelhouse-packages: ${{ env.USE_OVPHYSX_WHEELHOUSE == 'true' && 'ovphysx' || '' }} diff --git a/.github/workflows/daily-compatibility.yml b/.github/workflows/daily-compatibility.yml index 472b07300a5b..a92a9e6ccf07 100644 --- a/.github/workflows/daily-compatibility.yml +++ b/.github/workflows/daily-compatibility.yml @@ -111,7 +111,7 @@ jobs: image-tag: ${{ env.DOCKER_IMAGE_TAG }} pytest-options: "" filter-pattern: "isaaclab_tasks" - extra-pip-packages: "ovrtx ovphysx==0.4.13" + extra-pip-packages: "ovrtx ovphysx==0.5.2+head.f62c22207c" - name: Copy All Test Results from IsaacLab Tasks Container run: | diff --git a/.github/workflows/license-check.yaml b/.github/workflows/license-check.yaml index fb82761f0b9b..45c2befc2dc5 100644 --- a/.github/workflows/license-check.yaml +++ b/.github/workflows/license-check.yaml @@ -15,7 +15,12 @@ concurrency: jobs: license-check: + # Fork PRs cannot access NGC_API_KEY, so they cannot resolve the immutable + # OVPhysX wheel required by the development extras. + if: github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-24.04 + env: + NGC_API_KEY: ${{ secrets.NGC_API_KEY }} steps: - name: Checkout code @@ -23,6 +28,132 @@ jobs: with: filter: tree:0 + - name: Load OVPhysX wheelhouse configuration + id: config + shell: bash + run: | + set -euo pipefail + resource=$(yq -r .ovphysx_wheelhouse_resource .github/workflows/config.yaml) + echo "wheelhouse_resource=${resource}" >> "$GITHUB_OUTPUT" + + - name: Restore NGC CLI cache + uses: actions/cache@v4 + with: + path: ${{ runner.temp }}/ngc-cli-cache/ngccli_linux.zip + key: ngc-cli-${{ runner.os }}-${{ runner.arch }}-4.20.0-5cf084c88998c58ad8abf7849d2d1b41d578423886eb03018df10194e341d35b + + - name: Extract OVPhysX wheelhouse + id: extract-wheelhouse + shell: bash + env: + WHEELHOUSE_RESOURCE: ${{ steps.config.outputs.wheelhouse_resource }} + run: | + set -euo pipefail + + if [ -z "${NGC_API_KEY:-}" ]; then + echo "::error::NGC_API_KEY is unavailable; cannot download the configured OVPhysX wheelhouse" + exit 1 + fi + + NGC_CLI_VERSION="4.20.0" + NGC_CLI_SHA256="5cf084c88998c58ad8abf7849d2d1b41d578423886eb03018df10194e341d35b" + NGC_CLI_URL="https://api.ngc.nvidia.com/v2/resources/nvidia/ngc-apps/ngc_cli/versions/${NGC_CLI_VERSION}/files/ngccli_linux.zip" + + wheelhouse_root="$(mktemp -d "${RUNNER_TEMP:-/tmp}/ovphysx-wheelhouse.XXXXXX")" + download_root="$(mktemp -d "${RUNNER_TEMP:-/tmp}/ovphysx-wheelhouse-download.XXXXXX")" + ngc_home="$(mktemp -d "${RUNNER_TEMP:-/tmp}/ovphysx-ngc-home.XXXXXX")" + ngc_unpack_dir="" + preserve_wheelhouse_root=false + cleanup() { + if [ "$preserve_wheelhouse_root" != "true" ]; then + rm -rf "$wheelhouse_root" + fi + rm -rf "$download_root" "$ngc_home" + if [ -n "$ngc_unpack_dir" ]; then + rm -rf "$ngc_unpack_dir" + fi + } + trap cleanup EXIT + + cache_dir="${RUNNER_TEMP:-/tmp}/ngc-cli-cache" + ngc_zip="${cache_dir}/ngccli_linux.zip" + mkdir -p "$cache_dir" + if [ ! -f "$ngc_zip" ]; then + echo "Downloading NGC CLI ${NGC_CLI_VERSION}" + curl -fsSL -o "$ngc_zip" "$NGC_CLI_URL" + fi + echo "${NGC_CLI_SHA256} ${ngc_zip}" | sha256sum -c - + ngc_unpack_dir="$(mktemp -d "${RUNNER_TEMP:-/tmp}/ngc-cli.XXXXXX")" + unzip -q "$ngc_zip" -d "$ngc_unpack_dir" + export PATH="${ngc_unpack_dir}/ngc-cli:${PATH}" + + export NGC_CLI_API_KEY="$NGC_API_KEY" + export NGC_CLI_ORG=nvidian + export NGC_CLI_TEAM=no-team + export NGC_CLI_HOME="$ngc_home" + + ngc --version + echo "Downloading wheelhouse resource: $WHEELHOUSE_RESOURCE" + ngc registry resource download-version "$WHEELHOUSE_RESOURCE" --dest "$download_root" + + mapfile -t manifests < <(find "$download_root" -type f -name manifest.json) + if [ "${#manifests[@]}" -ne 1 ]; then + echo "::error::expected exactly one manifest.json in downloaded wheelhouse resource, found ${#manifests[@]}" + printf '%s\n' "${manifests[@]}" + exit 1 + fi + + payload_dir="$(dirname "${manifests[0]}")" + if [ ! -d "${payload_dir}/wheelhouse" ]; then + echo "::error::downloaded wheelhouse resource is missing wheelhouse/ next to manifest.json" + exit 1 + fi + + mkdir -p "$wheelhouse_root/wheelhouse" + cp "${payload_dir}/manifest.json" "$wheelhouse_root/manifest.json" + cp "${payload_dir}/wheelhouse/"*.whl "$wheelhouse_root/wheelhouse/" + + python3 - <<'PY' "$wheelhouse_root/manifest.json" "$wheelhouse_root/wheelhouse" + import hashlib + import json + import pathlib + import sys + + manifest_path = pathlib.Path(sys.argv[1]) + wheelhouse_dir = pathlib.Path(sys.argv[2]) + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + + expected = { + "artifact": "ovphysx-wheelhouse", + "platform": "manylinux_2_35_x86_64", + } + for key, value in expected.items(): + actual = manifest.get(key) + if actual != value: + raise SystemExit(f"manifest {key!r} mismatch: expected {value!r}, got {actual!r}") + + wheels = manifest.get("wheels") + if not isinstance(wheels, list) or not wheels: + raise SystemExit("manifest has no wheels list") + + for wheel in wheels: + filename = wheel.get("file") + expected_sha = wheel.get("sha256") + if not filename or not expected_sha: + raise SystemExit(f"invalid wheel manifest entry: {wheel!r}") + wheel_path = wheelhouse_dir / filename + if not wheel_path.is_file(): + raise SystemExit(f"manifest wheel is missing from wheelhouse: {filename}") + actual_sha = hashlib.sha256(wheel_path.read_bytes()).hexdigest() + if actual_sha != expected_sha: + raise SystemExit(f"sha256 mismatch for {filename}: expected {expected_sha}, got {actual_sha}") + + print(f"Validated {len(wheels)} wheelhouse wheels from {manifest_path}") + PY + + echo "wheelhouse_root=$wheelhouse_root" >> "$GITHUB_OUTPUT" + echo "wheelhouse_dir=$wheelhouse_root/wheelhouse" >> "$GITHUB_OUTPUT" + # - name: Install jq # run: sudo apt-get update && sudo apt-get install -y jq @@ -60,6 +191,7 @@ jobs: OMNI_KIT_ACCEPT_EULA: yes ACCEPT_EULA: Y ISAACSIM_ACCEPT_EULA: YES + UV_FIND_LINKS: ${{ steps.extract-wheelhouse.outputs.wheelhouse_dir }} run: | uv sync --extra all # Isaac Sim isn't in the dev pyproject; sync first so it isn't pruned. @@ -153,3 +285,9 @@ jobs: else echo "All packages were checked." fi + + - name: Clean up OVPhysX wheelhouse + if: always() + shell: bash + run: | + rm -rf "${{ steps.extract-wheelhouse.outputs.wheelhouse_root }}" diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 1aa1b57cc4eb..0cceced8f20e 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -44,6 +44,7 @@ Guidelines for modifications: ## Contributors +* Agon Serifi * Alessandro Assirelli * Alex Omar * Alexander Millane @@ -127,6 +128,7 @@ Guidelines for modifications: * Marco Alesiani * Masoud Moghani * Mateo Guaman Castro +* Matthew Taylor * Maurice Rahme * Michael Gussert * Michael Lin diff --git a/docs/source/api/lab/isaaclab.sim.spawners.rst b/docs/source/api/lab/isaaclab.sim.spawners.rst index d42fa13e524e..ce82c003e8e5 100644 --- a/docs/source/api/lab/isaaclab.sim.spawners.rst +++ b/docs/source/api/lab/isaaclab.sim.spawners.rst @@ -267,6 +267,9 @@ Materials MdlFileCfg GlassMdlCfg PhysicsMaterialCfg + RigidBodyMaterialBaseCfg + RigidBodyMaterialFragment + UsdPhysicsRigidBodyMaterialCfg RigidBodyMaterialCfg DeformableBodyMaterialBaseCfg SurfaceDeformableBodyMaterialBaseCfg @@ -303,8 +306,27 @@ Physical Materials :members: :exclude-members: __init__, func +.. autofunction:: spawn_physics_material + .. autofunction:: spawn_rigid_body_material +.. autoclass:: RigidBodyMaterialBaseCfg + :members: + :show-inheritance: + :exclude-members: __init__, func + +.. autofunction:: spawn_rigid_body_material_from_fragments + +.. autoclass:: RigidBodyMaterialFragment + :members: + :show-inheritance: + :exclude-members: __init__, func + +.. autoclass:: UsdPhysicsRigidBodyMaterialCfg + :members: + :show-inheritance: + :exclude-members: __init__, func + .. autoclass:: RigidBodyMaterialCfg :members: :exclude-members: __init__, func diff --git a/docs/source/api/lab_newton/isaaclab_newton.sim.spawners.rst b/docs/source/api/lab_newton/isaaclab_newton.sim.spawners.rst index f42cef3d8167..2db0a1622814 100644 --- a/docs/source/api/lab_newton/isaaclab_newton.sim.spawners.rst +++ b/docs/source/api/lab_newton/isaaclab_newton.sim.spawners.rst @@ -7,10 +7,19 @@ isaaclab_newton.sim.spawners .. autosummary:: + NewtonMaterialCfg NewtonDeformableBodyMaterialCfg NewtonDeformableMaterialCfg NewtonSurfaceDeformableBodyMaterialCfg +Rigid Materials +--------------- + +.. autoclass:: NewtonMaterialCfg + :members: + :show-inheritance: + :exclude-members: __init__, func + Deformable Materials -------------------- diff --git a/docs/source/api/lab_physx/isaaclab_physx.sim.spawners.rst b/docs/source/api/lab_physx/isaaclab_physx.sim.spawners.rst index 7a2ace2a33ff..3e2355d141e2 100644 --- a/docs/source/api/lab_physx/isaaclab_physx.sim.spawners.rst +++ b/docs/source/api/lab_physx/isaaclab_physx.sim.spawners.rst @@ -7,12 +7,27 @@ isaaclab_physx.sim.spawners .. autosummary:: + PhysxRigidBodyMaterialCfg + PhysxMaterialCfg PhysxDeformableBodyMaterialCfg PhysxSurfaceDeformableBodyMaterialCfg PhysXDeformableMaterialCfg DeformableBodyMaterialCfg SurfaceDeformableBodyMaterialCfg +Rigid Materials +--------------- + +.. autoclass:: PhysxRigidBodyMaterialCfg + :members: + :show-inheritance: + :exclude-members: __init__, func + +.. autoclass:: PhysxMaterialCfg + :members: + :show-inheritance: + :exclude-members: __init__, func + Deformable Materials -------------------- diff --git a/docs/source/how-to/capture_sensor_frames.rst b/docs/source/how-to/capture_sensor_frames.rst new file mode 100644 index 000000000000..7988a8346b8b --- /dev/null +++ b/docs/source/how-to/capture_sensor_frames.rst @@ -0,0 +1,180 @@ +.. _how-to-capture-sensor-frames: + +Capturing sensor frames during training +======================================= + +Isaac Lab supports saving image-like outputs from scene sensors during training using the +``CaptureEnvSensors`` Gymnasium wrapper. When ``--capture_env_sensors`` is set to a positive value, +Isaac Lab iterates over the environment's :class:`~isaaclab.scene.InteractiveScene` sensors and +writes each sensor's ``data.output`` tensors (for example, ``rgb``, ``depth``, or ``normals`` from a +:class:`~isaaclab.sensors.Camera`). The flag value is the number of parallel environment views to +tile into each saved frame grid. This differs from :doc:`record_video`, which records a single +perspective viewport clip of the scene. + +The capture flags are registered on the shared training entrypoints for RSL-RL, RL-Games, Stable +Baselines3, and skrl. They are not available on ``play`` scripts. + +This feature can be enabled using the following command line arguments with +``./isaaclab.sh train``: + +* ``--capture_env_sensors``: number of parallel environments to include in each saved frame grid + (default: ``0``, which disables capture) +* ``--capture_env_sensors_length``: length of each captured sensor frame window in **per-episode** + steps (default: ``200``). Set to ``0`` to disable saving even when ``--capture_env_sensors`` is + positive. +* ``--capture_env_sensors_interval``: interval between captured sensor frame windows in **per-episode** + steps (default: ``2000``) +* ``--capture_env_sensors_format``: output format, either ``tensorboard`` (default) or ``file`` + +Enabling sensor capture automatically enables camera rendering during training, the same as +``--video``, which slows down both startup and runtime performance. Sensor capture can also be used +at the same time as ``--video`` when both per-sensor frames and a perspective viewport clip are +needed. + +Example usage: + +.. code-block:: shell + + ./isaaclab.sh train --rl_library rsl_rl --task=Isaac-Reorient-Cube-Shadow-Camera-Direct --capture_env_sensors 4 --capture_env_sensors_length 100 --capture_env_sensors_interval 2000 --capture_env_sensors_format file + + +The captured frames will be saved in the same directory as the training checkpoints, under +``logs////sensor_frames/train``. Here ```` is the training run +directory (the same timestamped folder used for checkpoints and ``run.json``), not the per-episode +index used in output tags and file names. + + +Overview +-------- + +The sensor capture feature wraps the training environment through ``wrap_training_capture`` in +``scripts/reinforcement_learning/common.py`` and saves frames on reset and step when the current +**per-episode** step falls inside a capture window. For each image-like scene sensor, the wrapper: + +* reads ``sensor.data.output`` and skips sensors whose output is not a dictionary of tensors +* resolves each output to a :class:`torch.Tensor`, including + :class:`~isaaclab.utils.warp.ProxyArray` buffers through their ``.torch`` accessor +* selects the first ``capture_num_envs`` parallel environments from each tensor +* replaces non-finite values with zero before display normalization +* normalizes each output for display using :func:`~isaaclab.utils.images.normalize_camera_output_for_display` + and tiles the selected views into a single image grid with + :func:`~isaaclab.utils.images.make_camera_output_grid` +* writes the grid to TensorBoard or to disk as a PNG, depending on ``--capture_env_sensors_format`` + +Sensors with ``None`` for a given data type are skipped. Only tasks that define scene sensors with +image-like outputs (such as camera-based RL environments) produce captured frames. + + +Command-line options +-------------------- + +The training entrypoints register the capture flags in ``add_common_train_args``: + +.. literalinclude:: ../../../scripts/reinforcement_learning/common.py + :language: python + :lines: 240-263 + + +Capture schedule +---------------- + +A frame is saved on every environment reset and after each step while the current **per-episode** +step is inside an active capture window. The episode step counter resets to ``0`` on every +environment reset. A step is inside the window when: + +.. code-block:: text + + step % capture_env_sensors_interval < capture_env_sensors_length + +For example, with ``--capture_env_sensors_length 100`` and ``--capture_env_sensors_interval 2000``, +the wrapper saves episode steps ``0-99`` after each reset. If an episode lasts longer than 2000 +steps, another capture window opens at episode steps ``2000-2099``, then ``4000-4099``, and so on. + +.. note:: + + This schedule uses a **per-episode** step counter. :doc:`record_video` instead keys off the + Gymnasium ``RecordVideo`` wrapper's **global** environment-step counter across episodes. + + +Episode indexing +^^^^^^^^^^^^^^^^ + +Each environment reset increments an episode index that starts at ``1`` on the first reset. Captured +frames are grouped by this index so consecutive episodes can be compared: + +* TensorBoard tags use ``//episode_`` with a five-digit zero-padded + episode index (for example, ``episode_00001``) +* File output uses ``episode__step_.png`` under each sensor and data-type directory, + with a five-digit episode index and an eight-digit step index (for example, + ``episode_00001_step_00000042.png``) + +The ```` value is the per-episode step counter (reset to ``0`` on every environment reset). +In TensorBoard, images are logged with ``global_step`` set to the total number of environment steps +since training started. + + +Image processing +---------------- + +Before tiling, each selected environment view is passed through +:func:`~isaaclab.utils.images.normalize_camera_output_for_display`, which maps common camera data +types to a ``[0, 1]`` float range suitable for PNG export: + +* RGB-like outputs are scaled by ``255`` +* Depth-like outputs (``depth``, ``distance_to_camera``, ``distance_to_image_plane``) are scaled by + their per-frame maximum +* ``albedo`` keeps the first three channels and scales by ``255`` +* ``normals`` are remapped from ``[-1, 1]`` to ``[0, 1]`` + +:func:`~isaaclab.utils.images.make_camera_output_grid` arranges the ``capture_num_envs`` views into a +roughly square grid (``nrow = round(sqrt(num_envs))``) before the image is written. + + +Output formats +-------------- + +**TensorBoard (default).** Each sensor output is logged as a separate image series per episode with +the tag ``//episode_``. View the captures alongside other training +metrics by pointing TensorBoard at the training log directory or the ``sensor_frames/train`` +subdirectory: + +.. code-block:: shell + + ./isaaclab.sh -p -m tensorboard.main --logdir logs/rsl_rl/Isaac-Reorient-Cube-Shadow-Camera-Direct//sensor_frames/train + +**File.** PNG images are written per sensor, data type, episode, and step: + +``//episode__step_.png`` + +Sensor and data-type names are sanitized for file output so they are safe as path components. For +example, a frame from the first episode at step ``42`` for sensor ``front/camera`` and data type +``rgb`` is written as ``front_camera/rgb/episode_00001_step_00000042.png``. + + +Summary +------- + +.. list-table:: + :widths: 30 35 35 + :header-rows: 1 + + * - ``--capture_env_sensors_format`` + - Output location + - How to view + * - ``tensorboard`` (default) + - ``sensor_frames/train`` event files + - TensorBoard image tab (tagged by sensor, data type, and episode index) + * - ``file`` + - ``sensor_frames/train///episode__step_.png`` + - Any image viewer or filesystem browser + + +See also +-------- + +* :doc:`record_video` - record a perspective viewport clip with ``--video`` +* :doc:`/source/overview/reinforcement-learning/rl_existing_scripts` - training entrypoints that + expose the capture flags +* :doc:`/source/overview/core-concepts/visualization` - visualizers and rendering during training +* :doc:`/source/overview/core-concepts/sensors/camera` - camera sensors and annotator data types +* :doc:`proxy_array` - dual CPU/GPU sensor buffers read through ``.torch`` diff --git a/docs/source/how-to/index.rst b/docs/source/how-to/index.rst index 7d4f37449e09..d0362c0cbf6f 100644 --- a/docs/source/how-to/index.rst +++ b/docs/source/how-to/index.rst @@ -146,6 +146,7 @@ This guide explains how to record an animation and video in Isaac Lab. record_animation record_video + capture_sensor_frames Dynamically Modifying Environment Parameters With CurriculumTerm diff --git a/docs/source/how-to/record_video.rst b/docs/source/how-to/record_video.rst index 28b89710546b..2490ab813283 100644 --- a/docs/source/how-to/record_video.rst +++ b/docs/source/how-to/record_video.rst @@ -177,4 +177,5 @@ Summary See also -------- +* :doc:`capture_sensor_frames` - save image-like scene sensor outputs during training * :doc:`/source/overview/core-concepts/visualization` - interactive visualizers diff --git a/docs/source/overview/core-concepts/sensors/camera.rst b/docs/source/overview/core-concepts/sensors/camera.rst index 6928477f2a23..47ef069d107d 100644 --- a/docs/source/overview/core-concepts/sensors/camera.rst +++ b/docs/source/overview/core-concepts/sensors/camera.rst @@ -239,7 +239,7 @@ is :class:`~isaaclab_ov.renderers.OVRTXRendererCfg`, and ``Newton Warp`` is - ✅ * - ``motion_vectors`` - ✅ - - ❌ + - ✅ - ❌ * - ``semantic_segmentation`` - ✅ diff --git a/docs/source/overview/reinforcement-learning/rl_existing_scripts.rst b/docs/source/overview/reinforcement-learning/rl_existing_scripts.rst index 24720d305874..9eaa3d032502 100644 --- a/docs/source/overview/reinforcement-learning/rl_existing_scripts.rst +++ b/docs/source/overview/reinforcement-learning/rl_existing_scripts.rst @@ -67,6 +67,10 @@ Other available presets for this environment: ``albedo``, ``simple_shading_full_mdl``. The ``depth`` preset is intended for benchmarking only (see the environment's config for details). +During training, image-like scene sensor outputs from camera tasks can be saved with +``--capture_env_sensors``. See :doc:`/source/how-to/capture_sensor_frames` for the full capture +schedule and output format details. + RL-Games -------- diff --git a/docs/source/testing/benchmarks.rst b/docs/source/testing/benchmarks.rst index f90ced1a78ca..4e40faeaef61 100644 --- a/docs/source/testing/benchmarks.rst +++ b/docs/source/testing/benchmarks.rst @@ -6,7 +6,7 @@ Benchmarking Framework Isaac Lab provides a comprehensive benchmarking framework for measuring the performance of simulations, training workflows, and system resources. The framework is designed to work without depending on Isaac Sim's benchmark services, enabling standalone benchmarking -with pluggable output backends. +with pluggable output formatters. Overview -------- @@ -23,9 +23,9 @@ The benchmarking framework consists of several key components: ┌───────────┼───────────┐ │ │ │ ▼ ▼ ▼ - ┌───────┐ ┌─────────┐ ┌──────────┐ - │Phases │ │Recorders│ │ Backends │ - └───────┘ └─────────┘ └──────────┘ + ┌───────┐ ┌─────────┐ ┌────────────┐ + │Phases │ │Recorders│ │ Formatters │ + └───────┘ └─────────┘ └────────────┘ **Key Components:** @@ -34,7 +34,7 @@ The benchmarking framework consists of several key components: - **Metadata**: Data classes for recording context (hardware, versions, parameters) - **TestPhase**: Container for organizing measurements into logical groups - **Recorders**: System information collectors (CPU, GPU, memory, versions) -- **Backends**: Output formatters (JSON, Osmo, OmniPerf, Summary) +- **Formatters**: Output formatters (JSON, Osmo, OmniPerf, Summary, Schema) .. seealso:: @@ -59,7 +59,7 @@ Basic usage with :class:`~isaaclab.test.benchmark.BaseIsaacLabBenchmark`: # Initialize benchmark benchmark = BaseIsaacLabBenchmark( benchmark_name="MyBenchmark", - backend_type="json", + formatter_type="json", output_path="./results", ) @@ -113,7 +113,7 @@ Measure environment stepping performance without training: --task Isaac-Cartpole \ --num_envs 4096 \ --num_frames 100 \ - --benchmark_backend json \ + --benchmark_formatter json \ --output_path ./results RL Training Benchmarks @@ -131,7 +131,7 @@ Measure training performance with RSL-RL: --task Isaac-Cartpole \ --num_envs 4096 \ --max_iterations 500 \ - --benchmark_backend json \ + --benchmark_formatter json \ --output_path ./results PhysX Micro-Benchmarks @@ -166,7 +166,7 @@ understanding where time is spent during initialization. ./isaaclab.sh -p scripts/benchmarks/benchmark_startup.py \ --task Isaac-Ant \ --num_envs 4096 \ - --benchmark_backend summary + --benchmark_formatter summary The script profiles five phases independently: @@ -204,7 +204,7 @@ Patterns use ``fnmatch`` syntax (``*`` and ``?`` wildcards): ./isaaclab.sh -p scripts/benchmarks/benchmark_startup.py \ --task Isaac-Ant \ --num_envs 4096 \ - --benchmark_backend omniperf \ + --benchmark_formatter omniperf \ --whitelist_config scripts/benchmarks/startup_whitelist.yaml Phases listed in the YAML use the whitelist; phases not listed fall back to @@ -233,9 +233,9 @@ A default whitelist is provided at ``scripts/benchmarks/startup_whitelist.yaml`` * - ``--whitelist_config`` - None - Path to YAML whitelist file - * - ``--benchmark_backend`` + * - ``--benchmark_formatter`` - ``omniperf`` - - Output backend (``json``, ``osmo``, ``omniperf``, ``summary``) + - Output formatter (``json``, ``osmo``, ``omniperf``, ``summary``, or ``schema``) * - ``--output_path`` - ``.`` - Directory for output files @@ -253,9 +253,9 @@ Common Arguments * - Argument - Default - Description - * - ``--benchmark_backend`` + * - ``--benchmark_formatter`` - ``json`` - - Output backend: ``json``, ``osmo``, ``omniperf``, or ``summary`` + - Output formatter: ``json``, ``osmo``, ``omniperf``, ``summary``, or ``schema`` * - ``--output_path`` - ``./`` - Directory for output files @@ -404,17 +404,17 @@ Example: benchmark.add_measurement("simulation", metadata=task_metadata) benchmark.add_measurement("training", measurement=reward_measurement) -Output Backends ---------------- +Output Formatters +----------------- -JSON Backend -~~~~~~~~~~~~ +JSON Formatter +~~~~~~~~~~~~~~ Full output with all phases, measurements, and metadata: .. code-block:: bash - ./isaaclab.sh -p ... --benchmark_backend json --output_path ./results + ./isaaclab.sh -p ... --benchmark_formatter json --output_path ./results Output structure: @@ -437,14 +437,14 @@ Output structure: } ] -Osmo Backend -~~~~~~~~~~~~ +Osmo Formatter +~~~~~~~~~~~~~~ Simplified key-value format for CI/CD integration: .. code-block:: bash - ./isaaclab.sh -p ... --benchmark_backend osmo --output_path ./results + ./isaaclab.sh -p ... --benchmark_formatter osmo --output_path ./results Output structure: @@ -457,14 +457,14 @@ Output structure: "task": "Isaac-Cartpole" } -OmniPerf Backend -~~~~~~~~~~~~~~~~ +OmniPerf Formatter +~~~~~~~~~~~~~~~~~~ Format for database upload and performance tracking: .. code-block:: bash - ./isaaclab.sh -p ... --benchmark_backend omniperf --output_path ./results + ./isaaclab.sh -p ... --benchmark_formatter omniperf --output_path ./results Output structure: @@ -479,8 +479,20 @@ Output structure: } } -Summary Backend -~~~~~~~~~~~~~~~ +Schema Formatter +~~~~~~~~~~~~~~~~ + +Writes a schema-v1 bundle attached with +:meth:`~isaaclab.test.benchmark.BaseIsaacLabBenchmark.attach_bundle`. Use it +with a ``RuntimeBundle``, ``TrainingBundle``, or ``StartupBundle`` when a +typed, stable output contract is required. + +.. code-block:: bash + + ./isaaclab.sh -p ... --benchmark_formatter schema --output_path ./results + +Summary Formatter +~~~~~~~~~~~~~~~~~ Human-readable console report plus JSON file. Prints a formatted summary to the terminal while also writing the same data as JSON. Standard phases (runtime, @@ -491,7 +503,7 @@ entries. Use when you want a quick readout without opening the JSON: .. code-block:: bash - ./isaaclab.sh -p ... --benchmark_backend summary --output_path ./results + ./isaaclab.sh -p ... --benchmark_formatter summary --output_path ./results When ``summary`` is selected, frametime recorders are enabled automatically when running with Isaac Sim (Kit). @@ -508,7 +520,7 @@ monitoring during blocking operations like RL training loops: benchmark = BaseIsaacLabBenchmark( benchmark_name="TrainingBenchmark", - backend_type="json", + formatter_type="json", output_path="./results", ) @@ -564,13 +576,13 @@ Step 1: Initialize Benchmark from isaaclab.test.benchmark import BaseIsaacLabBenchmark parser = argparse.ArgumentParser() - parser.add_argument("--benchmark_backend", default="json") + parser.add_argument("--benchmark_formatter", default="json") parser.add_argument("--output_path", default="./") args = parser.parse_args() benchmark = BaseIsaacLabBenchmark( benchmark_name="CustomBenchmark", - backend_type=args.benchmark_backend, + formatter_type=args.benchmark_formatter, output_path=args.output_path, ) @@ -635,7 +647,7 @@ The shell scripts in ``scripts/benchmarks/`` are designed for CI/CD integration: name: benchmark-results path: ./benchmark_results/ -For Osmo integration, use the ``osmo`` backend: +For Osmo integration, use the ``osmo`` formatter: .. code-block:: bash @@ -682,15 +694,15 @@ Ensure ``_finalize_impl()`` is called before the script exits: finally: benchmark._finalize_impl() -Backend Not Recognized -~~~~~~~~~~~~~~~~~~~~~~ +Formatter Not Recognized +~~~~~~~~~~~~~~~~~~~~~~~~ -Valid backend types are: ``json``, ``osmo``, ``omniperf``, ``summary`` +Valid formatter types are: ``json``, ``osmo``, ``omniperf``, ``summary``, or ``schema`` .. code-block:: bash # Correct - --benchmark_backend json + --benchmark_formatter json # Incorrect - --benchmark_backend JSON # Case sensitive + --benchmark_formatter JSON # Case sensitive diff --git a/docs/source/tutorials/03_envs/run_rl_training.rst b/docs/source/tutorials/03_envs/run_rl_training.rst index 4723d4110779..22b853048e05 100644 --- a/docs/source/tutorials/03_envs/run_rl_training.rst +++ b/docs/source/tutorials/03_envs/run_rl_training.rst @@ -105,6 +105,10 @@ in the workflow and pass ``--video`` to record the agent behavior. The videos are saved to the ``logs/sb3/Isaac-Cartpole//videos/train`` directory. You can open these videos using any video player. +For tasks with on-scene cameras, you can also save the sensor image outputs directly during training +with ``--capture_env_sensors``. See :doc:`/source/how-to/capture_sensor_frames` for the available +options and output formats. + Interactive execution """"""""""""""""""""" diff --git a/pyproject.toml b/pyproject.toml index f40737cf55ad..caf6b793a9f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -199,6 +199,7 @@ markers = [ "device_split: re-invoke this file once per device (CPU and GPU) in CI due to process-global device locks (e.g., ovphysx<=0.3.7 gap G5)", "windows_ci: mark test to run on Windows platforms in CI", "arm_ci: mark test to run on ARM platforms in CI (e.g. NVIDIA DGX Spark)", + "smoke: tests for core installation, task, and RL functionality", ] # Add pypi.nvidia.com so that `uv pip install isaaclab[isaacsim]` works without --extra-index-url. diff --git a/scripts/reinforcement_learning/common.py b/scripts/reinforcement_learning/common.py index f99a75779520..e5591704ba5c 100644 --- a/scripts/reinforcement_learning/common.py +++ b/scripts/reinforcement_learning/common.py @@ -21,10 +21,13 @@ from typing import Any import gymnasium as gym +import torch +from PIL import Image from isaaclab.app import add_launcher_args from isaaclab.envs import DirectMARLEnvCfg, ManagerBasedRLEnvCfg from isaaclab.utils.dict import print_dict +from isaaclab.utils.images import make_camera_output_grid, normalize_camera_output_for_display from isaaclab.utils.io import dump_yaml RUN_MANIFEST_FILENAME = "run.json" @@ -32,6 +35,113 @@ CHECKPOINT_SELECTORS = frozenset({"latest", "best"}) +class CaptureEnvSensors(gym.Wrapper): + """Capture image-like environment sensor outputs during training.""" + + def __init__( + self, + env: gym.Env, + output_dir: str, + frame_count: int, + capture_num_envs: int, + interval: int, + output_format: str = "tensorboard", + ) -> None: + """Initialize the sensor capture wrapper. + + Args: + env: Gymnasium environment to wrap. + output_dir: Directory where captured frames are written. + frame_count: Number of frames to capture per interval. + capture_num_envs: Number of environment views to capture from each sensor. + interval: Number of environment steps between capture windows. + output_format: Output format. Can be ``"tensorboard"`` or ``"file"``. + """ + super().__init__(env) + self.output_dir = output_dir + os.makedirs(self.output_dir, exist_ok=True) + self.frame_count = max(frame_count, 0) + self.capture_num_envs = max(capture_num_envs, 0) + self.interval = max(interval, 1) + self._step_count = 0 + self._global_step_count = 0 + self._episode_index = 0 + self.writer = None + + if output_format not in {"tensorboard", "file"}: + raise ValueError(f"Unsupported sensor capture output format: {output_format}") + if output_format == "tensorboard": + from torch.utils.tensorboard import SummaryWriter + + self.writer = SummaryWriter(self.output_dir) + + def reset(self, **kwargs) -> Any: + """Reset the wrapped environment and capture the reset frame when scheduled.""" + result = self.env.reset(**kwargs) + self._step_count = 0 + self._episode_index += 1 + self._save_frame() + return result + + def step(self, action) -> Any: + """Step the wrapped environment and capture the resulting frame when scheduled.""" + result = self.env.step(action) + self._step_count += 1 + self._global_step_count += 1 + self._save_frame() + return result + + def close(self) -> None: + """Close the writer and wrapped environment.""" + if self.writer is not None: + self.writer.close() + super().close() + + def _save_frame(self) -> None: + """Write the current sensor outputs when the current step is inside a capture window.""" + if self.frame_count == 0: + return + if self._step_count % self.interval >= self.frame_count: + return + + sensors = getattr(getattr(self.unwrapped, "scene", None), "sensors", {}) + + for sensor_name, sensor in sensors.items(): + camera_outputs = getattr(getattr(sensor, "data", None), "output", None) + if not isinstance(camera_outputs, dict): + continue + + for data_type, output in camera_outputs.items(): + if output is None: + continue + tensor = output if isinstance(output, torch.Tensor) else output.torch + tensor = tensor[: self.capture_num_envs].detach().clone() + condition = torch.logical_or(torch.isinf(tensor), torch.isnan(tensor)) + corrected = torch.where(condition, torch.zeros_like(tensor), tensor) + normalized = normalize_camera_output_for_display(corrected, data_type) + grid = make_camera_output_grid(normalized) + ndarr = grid.mul(255).add_(0.5).clamp_(0, 255).permute(1, 2, 0).to("cpu", torch.uint8).numpy() + tag = f"{sensor_name}/{data_type}/episode_{self._episode_index:05d}" + + if self.writer is not None: + self.writer.add_image(tag, ndarr, global_step=self._global_step_count, dataformats="HWC") + else: + file_path = os.path.join( + self.output_dir, + self._safe_path_name(sensor_name), + self._safe_path_name(data_type), + f"episode_{self._episode_index:05d}_step_{self._step_count:08d}.png", + ) + os.makedirs(os.path.dirname(file_path), exist_ok=True) + result_image = Image.fromarray(ndarr) + result_image.save(file_path) + + @staticmethod + def _safe_path_name(name: str) -> str: + """Return a filesystem-safe path component.""" + return "".join(character if character.isalnum() or character in "._-" else "_" for character in name) + + def dispatch_library_entrypoint( argv: list[str] | None, entrypoints: dict[str, Path], @@ -127,6 +237,31 @@ def add_common_train_args( help="Automatically configured by Ray integration, otherwise None.", ) + parser.add_argument( + "--capture_env_sensors", + type=int, + default=0, + help="Number of environment views to capture from each image-like scene sensor.", + ) + parser.add_argument( + "--capture_env_sensors_length", + type=int, + default=200, + help="Length of each captured sensor frame window (in steps).", + ) + parser.add_argument( + "--capture_env_sensors_interval", + type=int, + default=2000, + help="Interval between captured sensor frame windows (in steps).", + ) + parser.add_argument( + "--capture_env_sensors_format", + choices=["tensorboard", "file"], + default="tensorboard", + help="Format used to save the captured sensor frames.", + ) + def add_isaaclab_launcher_args(parser: argparse.ArgumentParser) -> None: """Add Isaac Lab simulation launcher arguments to a parser. @@ -138,12 +273,12 @@ def add_isaaclab_launcher_args(parser: argparse.ArgumentParser) -> None: def enable_cameras_for_video(args_cli: argparse.Namespace) -> None: - """Enable camera rendering when video recording is requested. + """Enable camera rendering when video recording or sensor capture is requested. Args: args_cli: Parsed command-line arguments. """ - if getattr(args_cli, "video", False): + if getattr(args_cli, "video", False) or getattr(args_cli, "capture_env_sensors", 0) > 0: args_cli.enable_cameras = True @@ -250,6 +385,33 @@ def create_isaaclab_env( return env +def wrap_sensor_capture(env: gym.Env, log_dir: str, args_cli: argparse.Namespace): + """Wrap an environment with sensor capture when requested. + + Args: + env: Gymnasium environment to wrap. + log_dir: Training log directory. + args_cli: Parsed command-line arguments. + + Returns: + The original or sensor-capture-wrapped environment. + """ + if args_cli.capture_env_sensors <= 0: + return env + + output_dir = os.path.join(log_dir, "sensor_frames", "train") + sensor_capture_kwargs = { + "output_dir": output_dir, + "frame_count": args_cli.capture_env_sensors_length, + "capture_num_envs": args_cli.capture_env_sensors, + "interval": args_cli.capture_env_sensors_interval, + "output_format": args_cli.capture_env_sensors_format, + } + print("[INFO] Capturing environment sensor frames during training.") + print_dict(sensor_capture_kwargs, nesting=4) + return CaptureEnvSensors(env, **sensor_capture_kwargs) + + def wrap_record_video(env, log_dir: str, args_cli: argparse.Namespace): """Wrap an environment with video recording when requested. @@ -275,6 +437,13 @@ def wrap_record_video(env, log_dir: str, args_cli: argparse.Namespace): return gym.wrappers.RecordVideo(env, **video_kwargs) +def wrap_training_capture(env: gym.Env, log_dir: str, args_cli: argparse.Namespace) -> gym.Env: + """Apply optional video and sensor capture wrappers for training.""" + env = wrap_record_video(env, log_dir, args_cli) + env = wrap_sensor_capture(env, log_dir, args_cli) + return env + + def dump_train_configs(log_dir: str, env_cfg: Any, agent_cfg: Any) -> None: """Dump training configuration files under a run log directory. diff --git a/scripts/reinforcement_learning/rl_games/train_rl_games.py b/scripts/reinforcement_learning/rl_games/train_rl_games.py index 7a487479609a..a863b9a424bf 100644 --- a/scripts/reinforcement_learning/rl_games/train_rl_games.py +++ b/scripts/reinforcement_learning/rl_games/train_rl_games.py @@ -30,7 +30,7 @@ resolve_checkpoint_selector, set_hydra_args, validate_distributed_device, - wrap_record_video, + wrap_training_capture, write_run_manifest, ) @@ -175,7 +175,7 @@ def run(argv: list[str]) -> None: args_cli, convert_marl_to_single_agent=isinstance(env_cfg, DirectMARLEnvCfg), ) - env = wrap_record_video(env, run_log_dir, args_cli) + env = wrap_training_capture(env, run_log_dir, args_cli) start_time = time.time() env = RlGamesVecEnvWrapper(env, rl_device, clip_obs, clip_actions, obs_groups, concate_obs_groups) diff --git a/scripts/reinforcement_learning/rsl_rl/train_rsl_rl.py b/scripts/reinforcement_learning/rsl_rl/train_rsl_rl.py index 6d30f9faa68b..8f5c797cad40 100644 --- a/scripts/reinforcement_learning/rsl_rl/train_rsl_rl.py +++ b/scripts/reinforcement_learning/rsl_rl/train_rsl_rl.py @@ -30,7 +30,7 @@ resolve_checkpoint_selector, set_hydra_args, validate_distributed_device, - wrap_record_video, + wrap_training_capture, write_run_manifest, ) from packaging import version @@ -176,7 +176,7 @@ def run(argv: list[str]) -> None: else: resume_path = get_checkpoint_path(log_root_path, agent_cfg.load_run, agent_cfg.load_checkpoint) - env = wrap_record_video(env, log_dir, args_cli) + env = wrap_training_capture(env, log_dir, args_cli) start_time = time.time() env = RslRlVecEnvWrapper(env, clip_actions=agent_cfg.clip_actions) diff --git a/scripts/reinforcement_learning/sb3/train_sb3.py b/scripts/reinforcement_learning/sb3/train_sb3.py index 32032c9be8a6..52b73d75f383 100644 --- a/scripts/reinforcement_learning/sb3/train_sb3.py +++ b/scripts/reinforcement_learning/sb3/train_sb3.py @@ -29,7 +29,7 @@ enable_cameras_for_video, resolve_checkpoint_selector, set_hydra_args, - wrap_record_video, + wrap_training_capture, write_run_manifest, ) @@ -140,7 +140,7 @@ def run(argv: list[str]) -> None: args_cli, convert_marl_to_single_agent=isinstance(env_cfg, DirectMARLEnvCfg), ) - env = wrap_record_video(env, log_dir, args_cli) + env = wrap_training_capture(env, log_dir, args_cli) start_time = time.time() env = Sb3VecEnvWrapper(env, fast_variant=not args_cli.keep_all_info) diff --git a/scripts/reinforcement_learning/skrl/train_skrl.py b/scripts/reinforcement_learning/skrl/train_skrl.py index 1be36b42e84d..7ca754f64938 100644 --- a/scripts/reinforcement_learning/skrl/train_skrl.py +++ b/scripts/reinforcement_learning/skrl/train_skrl.py @@ -27,7 +27,7 @@ resolve_checkpoint_selector, set_hydra_args, validate_distributed_device, - wrap_record_video, + wrap_training_capture, write_run_manifest, ) from packaging import version @@ -197,7 +197,7 @@ def run(argv: list[str]) -> None: args_cli, convert_marl_to_single_agent=isinstance(env_cfg, DirectMARLEnvCfg) and algorithm in ["ppo"], ) - env = wrap_record_video(env, log_dir, args_cli) + env = wrap_training_capture(env, log_dir, args_cli) start_time = time.time() env = SkrlVecEnvWrapper(env, ml_framework=args_cli.ml_framework) diff --git a/source/isaaclab/changelog.d/articulation-reordering-p1.skip b/source/isaaclab/changelog.d/articulation-reordering-p1.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab/changelog.d/checkpoint-selectors.rst b/source/isaaclab/changelog.d/checkpoint-selectors.rst deleted file mode 100644 index 5313cdb8e7db..000000000000 --- a/source/isaaclab/changelog.d/checkpoint-selectors.rst +++ /dev/null @@ -1,4 +0,0 @@ -Added -^^^^^ - -* Added ``latest`` and ``best`` checkpoint selectors to unified reinforcement learning training and play commands. diff --git a/source/isaaclab/changelog.d/pverswyvelen-usd-2605-collider-crash-fix.major.rst b/source/isaaclab/changelog.d/pverswyvelen-usd-2605-collider-crash-fix.major.rst new file mode 100644 index 000000000000..041696a230a6 --- /dev/null +++ b/source/isaaclab/changelog.d/pverswyvelen-usd-2605-collider-crash-fix.major.rst @@ -0,0 +1,10 @@ +Changed +^^^^^^^ + +* **Breaking:** Bumped the kit-less ``usd-core`` pin from ``>=25.11,<26.0`` to + ``>=26.5,<27.0`` (OpenUSD 26.05 ABI). OpenUSD 26.05 includes the fix for a + multithreaded crash in ``UsdPhysicsParsingUtility`` that could corrupt the heap + when parsing a single rigid body with many mesh colliders beneath it + (OpenUSD PR #4002 / commit ``060715f``). Versions ``< 26.5`` race during USD + physics parsing and can abort with ``malloc_consolidate(): invalid chunk size`` + / ``double free`` before the first simulation step. diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index f333a9b1cdc5..ec207c8f819e 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 = "8.0.2" +version = "9.0.0" # Description title = "Isaac Lab framework for Robot Learning" diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index 0ec31d8f79dc..818078ed1c53 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,143 @@ Changelog --------- +9.0.0 (2026-07-07) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added a backend-agnostic benchmark core under :mod:`isaaclab.test.benchmark`, + including the ``capture``, ``metrics``, ``builders``, ``stepping``, + and ``profiling`` submodules, for assembling and emitting schema-v1 benchmark + bundles (``RuntimeBundle`` / ``TrainingBundle`` / + ``StartupBundle``). +* Added a ``schema`` output formatter that serializes a benchmark bundle through + :class:`~isaaclab.test.benchmark.BaseIsaacLabBenchmark`, and taught + ``BaseIsaacLabBenchmark`` to emit several formatters in one run from a + comma-separated formatter selection and a new ``attach_bundle`` hook. +* Added runtime and package version metadata to schema benchmark bundles, + including IsaacLab extensions, OVRTX, OVPhysX, MuJoCo, CUDA bindings, and + USD Core. + +Changed +^^^^^^^ + +* **Breaking:** Renamed the benchmark metrics-formatter module + ``isaaclab.test.benchmark.backends`` to ``isaaclab.test.benchmark.formatters``, and the + ``MetricsBackend`` / ``MetricsBackendInterface`` classes to ``MetricsFormatter`` / + ``MetricsFormatterInterface``. The output formatter classes (``JSONFileMetrics``, + ``SummaryMetrics``, ``OsmoKPIFile``, ``OmniPerfKPIFile``) are unchanged but now live in the + ``formatters`` module — update imports from ``isaaclab.test.benchmark.backends`` to + ``isaaclab.test.benchmark.formatters``. The + :class:`~isaaclab.test.benchmark.BaseIsaacLabBenchmark` constructor keeps ``backend_type`` as + an alias for the new ``formatter_type`` argument, so callers that pass ``backend_type=`` + continue to work unchanged. + +Fixed +^^^^^ + +* Fixed multi-phase :class:`~isaaclab.test.benchmark.OsmoKPIFile` output + overwriting earlier phases by writing one phase-suffixed JSON file per phase. +* Fixed benchmark run metadata to use resolved task defaults for physics and + rendering backends. +* Fixed simulation launch failures being reported with a zero process exit + status during Kit fast shutdown. +* Fixed benchmark metadata so Kit-full runs now report Kit and Isaac Sim + versions while Kitless runs report null. +* Fixed benchmark metadata to report the installed OVPhysX runtime version. +* Fixed benchmark metadata to preserve null values for unavailable OVRTX and + OVPhysX runtimes. +* Fixed intermittent kitless Newton training startup failures by avoiding overlapping standalone USD package providers on x86. + + +8.1.2 (2026-07-06) +~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Changed :func:`~isaaclab.utils.images.normalize_camera_output_for_display` to map the + ``motion_vectors`` data type, normalizing the per-pixel ``(u, v)`` offsets by their peak + magnitude and packing them into an RGB image (``u`` -> red, ``v`` -> green) instead of + scaling by ``255`` and returning a two-channel tensor. Callers that consumed the previous + two-channel output should update to expect a three-channel ``[0, 1]`` image. + + +8.1.1 (2026-07-05) +~~~~~~~~~~~~~~~~~~ + +Fixed +^^^^^ + +* Fixed contact sensor activation for rigid bodies nested below other rigid bodies. + + +8.1.0 (2026-07-04) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added the rigid-body physics-material "fragment" classes + :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialFragment` (marker base) and + :class:`~isaaclab.sim.spawners.materials.UsdPhysicsRigidBodyMaterialCfg` (solver-common + ``physics:*`` friction/restitution/density), plus the family writer + :func:`~isaaclab.sim.spawners.materials.spawn_rigid_body_material_from_fragments` and the slot + dispatcher :func:`~isaaclab.sim.spawners.materials.spawn_physics_material`. Relevant + ``physics_material`` slots now accept a single fragment or list alongside their legacy cfg form. + Legacy material cfgs are current-stage-only: the dispatcher raises ``ValueError`` for an explicit + non-current stage, while fragment-based materials support explicit-stage authoring. + +* Added :attr:`~isaaclab.sim.spawners.materials.UsdPhysicsRigidBodyMaterialCfg.density` (writes + ``physics:density``), completing the fragment's coverage of ``UsdPhysics.MaterialAPI``. +* Added :attr:`~isaaclab.sim.spawners.materials.RigidBodyMaterialBaseCfg.density`, so the legacy + rigid material base authors every attribute the fragment authors. + +Changed +^^^^^^^ + +* Narrowed :attr:`~isaaclab.sim.spawners.ShapeCfg.physics_material` from the broad + :class:`~isaaclab.sim.spawners.materials.PhysicsMaterialCfg` to the rigid material base, a single + rigid-material fragment, or a list of fragments. +* Extended the already rigid-only :attr:`~isaaclab.sim.spawners.GroundPlaneCfg.physics_material` + and :attr:`~isaaclab.terrains.TerrainImporterCfg.physics_material` slots from the legacy + :class:`~isaaclab_physx.sim.spawners.materials.RigidBodyMaterialCfg` type to + :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialBaseCfg`, a single + :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialFragment`, or a list of fragments. + Deformable materials remain accepted where deformables can spawn + (:class:`~isaaclab.sim.spawners.FileCfg` and :class:`~isaaclab.sim.spawners.MeshCfg`). +* Changed the generated-terrain material path to use + :func:`~isaaclab.sim.spawners.materials.spawn_physics_material`, enabling fragment lists there. + +Fixed +^^^^^ + +* Fixed mesh spawners rejecting valid rigid physics materials: the rigid-material check compared + against the deprecated leaf class, so canonical + :class:`~isaaclab_physx.sim.spawners.materials.PhysxRigidBodyMaterialCfg` and + :class:`~isaaclab_newton.sim.schemas.NewtonMaterialPropertiesCfg` instances raised + ``ValueError`` on rigid meshes. The check now accepts any + :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialBaseCfg`. +* Fixed malformed fragment inputs reaching an opaque legacy ``func`` call. Direct and dispatched + fragment calls now reject empty, mixed, and non-fragment inputs before creating a material prim. + + +8.0.3 (2026-07-03) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added ``latest`` and ``best`` checkpoint selectors to unified reinforcement learning training and play commands. + +Fixed +^^^^^ + +* Fixed depth-only RTX cameras disabling color rendering in the viewport by restoring GUI-state publication from + :class:`~isaaclab.app.AppLauncher`. + + 8.0.2 (2026-07-02) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab/isaaclab/app/app_launcher.py b/source/isaaclab/isaaclab/app/app_launcher.py index 99215b696afd..1428e0f763f0 100644 --- a/source/isaaclab/isaaclab/app/app_launcher.py +++ b/source/isaaclab/isaaclab/app/app_launcher.py @@ -1263,6 +1263,10 @@ def _load_extensions(self): logging.getLogger().setLevel(logging.INFO) settings = get_settings_manager() + # Publish whether Kit has an interactive GUI (local window, livestream, or XR). + # SimulationContext and renderers consume this setting during their initialization. + settings.set_bool("/isaaclab/has_gui", not self._headless or self._livestream >= 1 or self._xr) + # set setting to indicate Isaac Lab's offscreen_render pipeline should be enabled settings.set_bool("/isaaclab/render/offscreen", self._offscreen_render) diff --git a/source/isaaclab/isaaclab/app/sim_launcher.py b/source/isaaclab/isaaclab/app/sim_launcher.py index 0a5ac08a1101..fc69dd3cdc97 100644 --- a/source/isaaclab/isaaclab/app/sim_launcher.py +++ b/source/isaaclab/isaaclab/app/sim_launcher.py @@ -473,16 +473,21 @@ def launch_simulation( {**base, "visualizer_explicit": True, "visualizer_disable_all": disable_all} ) + exit_code = 0 try: yield physics_cfg except Exception: + exit_code = 1 import traceback traceback.print_exc() raise finally: if close_fn is not None: - close_fn() + if exit_code: + close_fn(exit_code=exit_code) + else: + close_fn() def _ensure_isaac_sim_available() -> None: diff --git a/source/isaaclab/isaaclab/scene/interactive_scene.py b/source/isaaclab/isaaclab/scene/interactive_scene.py index 11df307b2356..055002076634 100644 --- a/source/isaaclab/isaaclab/scene/interactive_scene.py +++ b/source/isaaclab/isaaclab/scene/interactive_scene.py @@ -13,6 +13,7 @@ from isaaclab_physx.assets import SurfaceGripper from isaaclab.renderers.base_renderer import BaseRenderer + from isaaclab.terrains.terrain_importer import TerrainImporter import torch import warp as wp @@ -35,7 +36,6 @@ from isaaclab.sim import SimulationContext from isaaclab.sim.utils.stage import get_current_stage, get_current_stage_id from isaaclab.sim.views import FrameView -from isaaclab.terrains import TerrainImporter, TerrainImporterCfg # Note: This is a temporary import for the VisuoTactileSensorCfg class. # It will be removed once the VisuoTactileSensor class is added to the core Isaac Lab framework. @@ -741,6 +741,8 @@ def _add_entities_from_cfg(self): # noqa: C901 """Add scene entities from the config.""" from isaaclab_physx.assets import SurfaceGripperCfg # noqa: PLC0415 + from isaaclab.terrains.terrain_importer_cfg import TerrainImporterCfg # noqa: PLC0415 + # store paths that are in global collision filter self._global_prim_paths = list() # Resolve the env-namespace convention from the cloner cfg once for this pass. diff --git a/source/isaaclab/isaaclab/scene_data/scene_data_provider.py b/source/isaaclab/isaaclab/scene_data/scene_data_provider.py index ba4c051920e1..8d770a3da570 100644 --- a/source/isaaclab/isaaclab/scene_data/scene_data_provider.py +++ b/source/isaaclab/isaaclab/scene_data/scene_data_provider.py @@ -13,8 +13,6 @@ import numpy as np import warp as wp -from pxr import UsdGeom - import isaaclab.sim as sim_utils from .scene_data_backend import SceneDataBackend, SceneDataFormat @@ -441,6 +439,8 @@ def _walk_camera_prims(stage: Usd.Stage | None) -> dict[str, Any] | None: if stage is None: return None + from pxr import UsdGeom # noqa: PLC0415 + shared_paths: list[str] = [] instances: dict[str, list[tuple[int, str]]] = {} num_envs = -1 diff --git a/source/isaaclab/isaaclab/sensors/sensor_base.py b/source/isaaclab/isaaclab/sensors/sensor_base.py index 8b885a91d148..ee4e5885d78d 100644 --- a/source/isaaclab/isaaclab/sensors/sensor_base.py +++ b/source/isaaclab/isaaclab/sensors/sensor_base.py @@ -20,10 +20,7 @@ import warp as wp -from pxr import UsdPhysics - import isaaclab.sim as sim_utils -from isaaclab.cloner.cloner_utils import iter_clone_plan_matches from isaaclab.physics import PhysicsEvent, PhysicsManager from isaaclab.sim.utils.queries import get_first_matching_ancestor_prim from isaaclab.sim.utils.transforms import resolve_prim_pose @@ -231,6 +228,8 @@ def _initialize_impl(self): clone_plan = self._clone_plan clone_plan_matches = () if clone_plan is not None: + from isaaclab.cloner.cloner_utils import iter_clone_plan_matches # noqa: PLC0415 + clone_plan_matches = tuple(iter_clone_plan_matches(clone_plan, self.cfg.prim_path)) if clone_plan_matches: self._parent_prims = [] @@ -457,6 +456,7 @@ def _resolve_rigid_body_ancestor_expr( mounted directly at the body origin. """ prim, target_expr = sim_utils.resolve_matching_prims_from_source(self.cfg.prim_path)[0] + from pxr import UsdPhysics # noqa: PLC0415 ancestor_prim = get_first_matching_ancestor_prim( prim.GetPath(), predicate=lambda _prim: _prim.HasAPI(UsdPhysics.RigidBodyAPI) diff --git a/source/isaaclab/isaaclab/sim/schemas/schemas.py b/source/isaaclab/isaaclab/sim/schemas/schemas.py index bdcc073d35cd..035f5509f4cf 100644 --- a/source/isaaclab/isaaclab/sim/schemas/schemas.py +++ b/source/isaaclab/isaaclab/sim/schemas/schemas.py @@ -933,33 +933,25 @@ def activate_contact_sensors(prim_path: str, threshold: float = 0.0, stage: Usd. # check if prim is valid if not prim.IsValid(): raise ValueError(f"Prim path '{prim_path}' is not valid.") - # iterate over all children - num_contact_sensors = 0 - all_prims = [prim] - while len(all_prims) > 0: - # get current prim - child_prim = all_prims.pop(0) - # check if prim is a rigid body - # nested rigid bodies are not allowed by SDK so we can safely assume that - # if a prim has a rigid body API, it is a rigid body and we don't need to - # check its children - if child_prim.HasAPI(UsdPhysics.RigidBodyAPI): - # set sleep threshold to zero - child_applied = child_prim.GetAppliedSchemas() - if "PhysxRigidBodyAPI" not in child_applied: - child_prim.AddAppliedSchema("PhysxRigidBodyAPI") - safe_set_attribute_on_usd_prim(child_prim, "physxRigidBody:sleepThreshold", 0.0, camel_case=False) - # add contact report API with threshold of zero - if "PhysxContactReportAPI" not in child_applied: - child_prim.AddAppliedSchema("PhysxContactReportAPI") - safe_set_attribute_on_usd_prim(child_prim, "physxContactReport:threshold", threshold, camel_case=False) - # increment number of contact sensors - num_contact_sensors += 1 - else: - # add all children to tree - all_prims += child_prim.GetChildren() + # collect all rigid bodies under the prim, including nested rigid-body trees + rigid_body_prims = get_all_matching_child_prims( + prim_path, + predicate=lambda child_prim: child_prim.HasAPI(UsdPhysics.RigidBodyAPI), + stage=stage, + traverse_instance_prims=False, + ) + for child_prim in rigid_body_prims: + # set sleep threshold to zero + child_applied = child_prim.GetAppliedSchemas() + if "PhysxRigidBodyAPI" not in child_applied: + child_prim.AddAppliedSchema("PhysxRigidBodyAPI") + safe_set_attribute_on_usd_prim(child_prim, "physxRigidBody:sleepThreshold", 0.0, camel_case=False) + # add contact report API with threshold of zero + if "PhysxContactReportAPI" not in child_applied: + child_prim.AddAppliedSchema("PhysxContactReportAPI") + safe_set_attribute_on_usd_prim(child_prim, "physxContactReport:threshold", threshold, camel_case=False) # check if no contact sensors were found - if num_contact_sensors == 0: + if not rigid_body_prims: descendant_count = 0 frontier = [prim] while frontier: diff --git a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py index d9f789b666f3..80b9f940e417 100644 --- a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py +++ b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py @@ -16,6 +16,7 @@ from isaaclab.sim import converters, schemas from isaaclab.sim.spawners.materials import SurfaceDeformableBodyMaterialBaseCfg +from isaaclab.sim.spawners.materials.physics_materials import spawn_physics_material from isaaclab.sim.utils import ( add_labels, bind_physics_material, @@ -211,7 +212,7 @@ def spawn_ground_plane( # Create physics material if cfg.physics_material is not None: - cfg.physics_material.func(f"{prim_path}/physicsMaterial", cfg.physics_material) + spawn_physics_material(f"{prim_path}/physicsMaterial", cfg.physics_material, stage=stage) # Apply physics material to ground plane collision_prim = get_first_matching_child_prim( prim_path, @@ -478,8 +479,8 @@ def _spawn_from_usd_file( material_path = f"{prim_path}/{cfg.physics_material_path}" else: material_path = cfg.physics_material_path - # create material - cfg.physics_material.func(material_path, cfg.physics_material) + # create material (accepts a legacy material cfg or rigid-body fragment(s)) + spawn_physics_material(material_path, cfg.physics_material, stage=stage) # apply material bind_physics_material(prim_path, material_path, stage=stage) diff --git a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files_cfg.py b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files_cfg.py index ea2cfe9b6c9f..1c9c26e02525 100644 --- a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files_cfg.py +++ b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files_cfg.py @@ -106,9 +106,18 @@ class FileCfg(RigidObjectSpawnerCfg, DeformableObjectSpawnerCfg): This parameter is ignored if `physics_material` is not None. """ - physics_material: materials.PhysicsMaterialCfg | None = None + physics_material: ( + materials.PhysicsMaterialCfg + | materials.RigidBodyMaterialFragment + | list[materials.RigidBodyMaterialFragment] + | None + ) = None """Physics material properties. + Accepts either a legacy material cfg, a single + :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialFragment`, or a list of such + single-namespace fragments. + Note: If None, then no custom physics material will be added. """ @@ -259,5 +268,15 @@ class GroundPlaneCfg(SpawnerCfg): size: tuple[float, float] = (100.0, 100.0) """The size of the ground plane. Defaults to 100 m x 100 m.""" - physics_material: materials.RigidBodyMaterialCfg = materials.RigidBodyMaterialCfg() - """Physics material properties. Defaults to the default rigid body material.""" + physics_material: ( + materials.RigidBodyMaterialBaseCfg + | materials.RigidBodyMaterialFragment + | list[materials.RigidBodyMaterialFragment] + ) = materials.RigidBodyMaterialCfg() + """Physics material properties. Defaults to the default rigid body material. + + The ground plane only spawns a collision plane, so this only accepts rigid-body materials: a + legacy :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialBaseCfg`, a single + :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialFragment`, or a list of such + single-namespace fragments. + """ diff --git a/source/isaaclab/isaaclab/sim/spawners/materials/__init__.pyi b/source/isaaclab/isaaclab/sim/spawners/materials/__init__.pyi index 7ce7815955c2..305c980d2312 100644 --- a/source/isaaclab/isaaclab/sim/spawners/materials/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/spawners/materials/__init__.pyi @@ -5,9 +5,13 @@ __all__ = [ "spawn_rigid_body_material", + "spawn_rigid_body_material_from_fragments", + "spawn_physics_material", "spawn_deformable_body_material", "PhysicsMaterialCfg", "RigidBodyMaterialBaseCfg", + "RigidBodyMaterialFragment", + "UsdPhysicsRigidBodyMaterialCfg", "DeformableBodyMaterialBaseCfg", "DeformableBodyMaterialCfg", "SurfaceDeformableBodyMaterialBaseCfg", @@ -20,10 +24,17 @@ __all__ = [ "VisualMaterialCfg", ] -from .physics_materials import spawn_rigid_body_material, spawn_deformable_body_material +from .physics_materials import ( + spawn_deformable_body_material, + spawn_physics_material, + spawn_rigid_body_material, + spawn_rigid_body_material_from_fragments, +) from .physics_materials_cfg import ( PhysicsMaterialCfg, RigidBodyMaterialBaseCfg, + RigidBodyMaterialFragment, + UsdPhysicsRigidBodyMaterialCfg, DeformableBodyMaterialBaseCfg, DeformableBodyMaterialCfg, SurfaceDeformableBodyMaterialBaseCfg, diff --git a/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials.py b/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials.py index 8c12bee9442d..51f19226bb1e 100644 --- a/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials.py +++ b/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials.py @@ -12,10 +12,138 @@ from isaaclab.sim.schemas.schemas import _apply_namespaced_schemas from isaaclab.sim.utils import clone from isaaclab.sim.utils.stage import get_current_stage +from isaaclab.utils.string import string_to_callable from . import physics_materials_cfg +def spawn_rigid_body_material_from_fragments( + prim_path: str, + fragments: physics_materials_cfg.RigidBodyMaterialFragment + | list[physics_materials_cfg.RigidBodyMaterialFragment] + | tuple[physics_materials_cfg.RigidBodyMaterialFragment, ...], + stage: Usd.Stage | None = None, +) -> Usd.Prim: + """Spawn a rigid-body physics material from one or more single-namespace fragments. + + Creates (or reuses) the ``UsdShade.Material`` prim at ``prim_path``, applies the standard + ``UsdPhysics.MaterialAPI`` anchor, then dispatches each fragment via its + :attr:`~isaaclab.sim.schemas.SchemaFragment.func` to author its namespace onto the material prim. + Backend fragments carry backend-specific namespaces (e.g. PhysX ``physxMaterial:*``) without core + importing a backend. + + .. note:: + Unlike the ``@clone``-decorated :func:`spawn_rigid_body_material`, this writer expects a + concrete ``prim_path`` and does not resolve regex prim-path patterns. Physics materials are + spawned at a single derived path by :func:`spawn_physics_material` and the spawner internals, + so regex resolution does not apply here. + + Args: + prim_path: The prim path to spawn the material at. + fragments: A single :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialFragment`, + or a list/tuple of them. + stage: The stage to spawn on. Defaults to None, in which case the current stage is used. + + Returns: + The spawned rigid body material prim. + + Raises: + ValueError: When ``fragments`` is empty. + ValueError: When a prim already exists at the path and is not a material. + TypeError: When an item is not a rigid-body material fragment. + """ + if not isinstance(fragments, (list, tuple)): + fragments = [fragments] + else: + fragments = list(fragments) + if not fragments: + raise ValueError(f"Cannot spawn a physics material at '{prim_path}' from an empty fragment collection.") + invalid_types = [ + type(fragment).__name__ + for fragment in fragments + if not isinstance(fragment, physics_materials_cfg.RigidBodyMaterialFragment) + ] + if invalid_types: + raise TypeError( + "A physics-material fragment collection must contain only RigidBodyMaterialFragment instances; got" + f" {invalid_types}." + ) + if stage is None: + stage = get_current_stage() + + # create the material prim if none exists yet + if not stage.GetPrimAtPath(prim_path).IsValid(): + UsdShade.Material.Define(stage, prim_path) + prim = stage.GetPrimAtPath(prim_path) + if not prim.IsA(UsdShade.Material): + raise ValueError(f"A prim already exists at path: '{prim_path}' but is not a material.") + + # apply the standard UsdPhysics MaterialAPI anchor (the defining schema for a physics material) + if not UsdPhysics.MaterialAPI(prim): + UsdPhysics.MaterialAPI.Apply(prim) + + # dispatch each fragment's applier (writes its single namespace onto the material prim) + for cfg in fragments: + func = cfg.func if callable(cfg.func) else string_to_callable(cfg.func) + func(cfg, prim_path, stage) + return prim + + +def spawn_physics_material( + prim_path: str, + material: physics_materials_cfg.PhysicsMaterialCfg + | physics_materials_cfg.RigidBodyMaterialFragment + | list[physics_materials_cfg.RigidBodyMaterialFragment] + | tuple[physics_materials_cfg.RigidBodyMaterialFragment, ...], + stage: Usd.Stage | None = None, +) -> Usd.Prim: + """Spawn a physics material from a spawner ``physics_material`` slot value. + + Dispatches the two accepted slot forms: a single rigid-body fragment or list/tuple is spawned via + :func:`spawn_rigid_body_material_from_fragments`; otherwise the value is a legacy material cfg and is + spawned via its own :attr:`func`. Lets spawners accept both the fragment and the legacy interface + from one call site. + + Args: + prim_path: The prim path to spawn the material at. + material: A :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialFragment` (or list/tuple + of them), or a legacy material cfg carrying a :attr:`func`. + stage: The stage to spawn on. Defaults to None, in which case the current stage is used. A + legacy material cfg only supports the current stage (or None); passing a different + stage raises. + Returns: + The spawned material prim. + + Raises: + ValueError: When ``material`` is an empty fragment collection. + ValueError: When ``material`` is a legacy material cfg and ``stage`` is neither ``None`` + nor the current stage. + TypeError: When ``material`` is a fragment collection containing anything other than + :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialFragment` instances. + TypeError: When ``material`` is neither a physics-material cfg nor a rigid-body fragment. + """ + # The family writer owns fragment validation so direct and dispatched calls have one contract. + if isinstance(material, (list, tuple, physics_materials_cfg.RigidBodyMaterialFragment)): + return spawn_rigid_body_material_from_fragments(prim_path, material, stage) + if not isinstance(material, physics_materials_cfg.PhysicsMaterialCfg): + raise TypeError( + "A physics material must be a PhysicsMaterialCfg or rigid-body material fragment; got" + f" '{type(material).__name__}'." + ) + # legacy single-cfg path (rigid or deformable material cfg with its own spawner ``func``). + # Legacy material funcs take only ``(prim_path, cfg)`` and resolve the stage internally via + # ``get_current_stage()`` (their path matching is also current-stage-bound), so an explicit + # different stage cannot be honored on this path -- reject it loudly rather than authoring on + # the wrong stage. The fragment path above supports explicit stages. + if stage is not None and stage != get_current_stage(): + raise ValueError( + f"Legacy material cfg '{type(material).__name__}' can only be spawned on the current" + " stage. Pass the current stage (or None), or use the fragment-based API for" + " explicit-stage authoring." + ) + return material.func(prim_path, material) + + @clone def spawn_rigid_body_material(prim_path: str, cfg: physics_materials_cfg.RigidBodyMaterialBaseCfg) -> Usd.Prim: """Create material with rigid-body physics properties. @@ -26,10 +154,10 @@ def spawn_rigid_body_material(prim_path: str, cfg: physics_materials_cfg.RigidBo PxMaterial `_. The writer is metadata-driven: it always applies the standard ``UsdPhysics.MaterialAPI`` and - writes the friction/restitution fields, then reads ``_usd_applied_schema``, ``_usd_namespace``, - and ``_usd_attr_name_map`` from the cfg to author solver-specific attributes. The applied - schema (e.g. ``PhysxMaterialAPI``) is added only when at least one solver-specific field has a - non-``None`` value at the instance level. + writes the friction/restitution/density fields, then reads ``_usd_applied_schema``, + ``_usd_namespace``, and ``_usd_attr_name_map`` from the cfg to author solver-specific attributes. + The applied schema (e.g. ``PhysxMaterialAPI``) is added only when at least one solver-specific + field has a non-``None`` value at the instance level. .. note:: This function is decorated with :func:`clone` that resolves prim path into list of paths diff --git a/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials_cfg.py b/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials_cfg.py index 5c88731cf8d5..6dbe000e773f 100644 --- a/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials_cfg.py +++ b/source/isaaclab/isaaclab/sim/spawners/materials/physics_materials_cfg.py @@ -9,6 +9,7 @@ from dataclasses import MISSING from typing import ClassVar +from isaaclab.sim.schemas.schemas_cfg import SchemaFragment from isaaclab.utils.configclass import configclass # Names that moved out of this submodule into ``isaaclab_physx.sim.spawners.materials.physics_materials_cfg``. @@ -60,9 +61,10 @@ class PhysicsMaterialCfg: class RigidBodyMaterialBaseCfg(PhysicsMaterialCfg): """Solver-common physics-material parameters for rigid bodies. - Contains the friction and restitution fields from the `UsdPhysics.MaterialAPI`_ that are common - across all simulation backends. For PhysX-only material properties (compliant-contact spring, - combine modes), use :class:`~isaaclab_physx.sim.spawners.materials.PhysxRigidBodyMaterialCfg`. + Contains the friction, restitution, and density fields from the `UsdPhysics.MaterialAPI`_ that + are common across all simulation backends. For properties in the ``physxMaterial`` namespace + (compliant-contact spring and combine modes), use + :class:`~isaaclab_physx.sim.spawners.materials.PhysxRigidBodyMaterialCfg`. See :meth:`spawn_rigid_body_material` for more information. @@ -88,6 +90,60 @@ class RigidBodyMaterialBaseCfg(PhysicsMaterialCfg): restitution: float = 0.0 """The restitution coefficient. Defaults to 0.0.""" + density: float | None = None + """The material density [kg/m^3]. Defaults to None, in which case it is not authored. + + Writes ``physics:density``. It is a fallback for collision shapes bound to this material; + explicitly authored rigid-body mass or density takes precedence. + """ + + +@configclass +class RigidBodyMaterialFragment(SchemaFragment): + """Marker base for rigid-body physics-material fragments; types the ``physics_material`` slot. + + A rigid-body physics material is a single ``UsdShade.Material`` prim that carries one or more + physics-material schemas. The fragments author single namespaces onto that prim: the + solver-common ``physics:*`` friction/restitution/density + (:class:`UsdPhysicsRigidBodyMaterialCfg`) and any backend-specific namespace (e.g. PhysX + ``physxMaterial:*`` via :class:`~isaaclab_physx.sim.spawners.materials.PhysxMaterialCfg`). + The defining ``UsdPhysics.MaterialAPI`` anchor is applied by the family writer + (:func:`~isaaclab.sim.spawners.materials.spawn_rigid_body_material_from_fragments`). + """ + + pass + + +@configclass +class UsdPhysicsRigidBodyMaterialCfg(RigidBodyMaterialFragment): + """``physics:*`` rigid-body material attributes from `UsdPhysics.MaterialAPI`_. + + The ``UsdPhysics.MaterialAPI`` schema is applied as the implicit anchor by the rigid-body material + family writer, so this fragment owns no applied schema of its own. ``None`` fields are left + unchanged on the material prim. + + .. _UsdPhysics.MaterialAPI: https://openusd.org/dev/api/class_usd_physics_material_a_p_i.html + """ + + _usd_namespace: ClassVar[str | None] = "physics" + _usd_applied_schema: ClassVar[str | None] = None # MaterialAPI applied by the family writer + + static_friction: float | None = None + """The static friction coefficient. Writes ``physics:staticFriction``.""" + + dynamic_friction: float | None = None + """The dynamic friction coefficient. Writes ``physics:dynamicFriction``.""" + + restitution: float | None = None + """The restitution coefficient. Writes ``physics:restitution``.""" + + density: float | None = None + """The material density [kg/m^3]. Writes ``physics:density``. + + Participates in mass computation via material binding when no explicit rigid-body mass or + density takes precedence. + """ + @configclass class DeformableBodyMaterialBaseCfg(PhysicsMaterialCfg): diff --git a/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py b/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py index bf0df38db129..f46d06c68560 100644 --- a/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py +++ b/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py @@ -16,7 +16,13 @@ from isaaclab.sim import schemas from isaaclab.sim.utils import bind_physics_material, bind_visual_material, clone, create_prim, get_current_stage -from ..materials import DeformableBodyMaterialBaseCfg, RigidBodyMaterialCfg, SurfaceDeformableBodyMaterialBaseCfg +from ..materials import ( + DeformableBodyMaterialBaseCfg, + RigidBodyMaterialBaseCfg, + RigidBodyMaterialFragment, + SurfaceDeformableBodyMaterialBaseCfg, +) +from ..materials.physics_materials import spawn_physics_material if TYPE_CHECKING: from . import meshes_cfg @@ -367,7 +373,15 @@ def _spawn_mesh_geom_from_mesh( if not isinstance(cfg.physics_material, DeformableBodyMaterialBaseCfg): raise ValueError("Deformable properties require a deformable physics material.") if cfg.rigid_props is not None and cfg.physics_material is not None: - if not isinstance(cfg.physics_material, RigidBodyMaterialCfg): + # accept anything spawn_physics_material accepts for the rigid case: a legacy rigid-body + # material cfg, a single fragment, or a list/tuple of fragments + physics_material_frags = ( + cfg.physics_material if isinstance(cfg.physics_material, (list, tuple)) else [cfg.physics_material] + ) + is_rigid_material = isinstance(cfg.physics_material, RigidBodyMaterialBaseCfg) or all( + isinstance(frag, RigidBodyMaterialFragment) for frag in physics_material_frags + ) + if not is_rigid_material: raise ValueError("Rigid properties require a rigid physics material.") # create all the paths we need for clarity @@ -439,8 +453,8 @@ def _spawn_mesh_geom_from_mesh( material_path = f"{geom_prim_path}/{cfg.physics_material_path}" else: material_path = cfg.physics_material_path - # create material - cfg.physics_material.func(material_path, cfg.physics_material) + # create material (accepts a legacy material cfg or rigid-body fragment(s)) + spawn_physics_material(material_path, cfg.physics_material, stage=stage) # apply material bind_physics_material(prim_path, material_path, stage=stage) diff --git a/source/isaaclab/isaaclab/sim/spawners/meshes/meshes_cfg.py b/source/isaaclab/isaaclab/sim/spawners/meshes/meshes_cfg.py index c6eb26507d3a..aafd4d3cd7e0 100644 --- a/source/isaaclab/isaaclab/sim/spawners/meshes/meshes_cfg.py +++ b/source/isaaclab/isaaclab/sim/spawners/meshes/meshes_cfg.py @@ -57,9 +57,18 @@ class MeshCfg(RigidObjectSpawnerCfg, DeformableObjectSpawnerCfg): This parameter is ignored if `physics_material` is not None. """ - physics_material: materials.PhysicsMaterialCfg | None = None + physics_material: ( + materials.PhysicsMaterialCfg + | materials.RigidBodyMaterialFragment + | list[materials.RigidBodyMaterialFragment] + | None + ) = None """Physics material properties. + Accepts either a legacy material cfg, a single + :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialFragment`, or a list of such + single-namespace fragments. + Note: If None, then no physics material will be added. """ diff --git a/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py b/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py index 2d1b721e9567..2b8177423a88 100644 --- a/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py +++ b/source/isaaclab/isaaclab/sim/spawners/shapes/shapes.py @@ -10,6 +10,7 @@ from pxr import Usd from isaaclab.sim import schemas +from isaaclab.sim.spawners.materials.physics_materials import spawn_physics_material from isaaclab.sim.utils import bind_physics_material, bind_visual_material, clone, create_prim, get_current_stage if TYPE_CHECKING: @@ -316,8 +317,8 @@ def _spawn_geom_from_prim_type( material_path = f"{geom_prim_path}/{cfg.physics_material_path}" else: material_path = cfg.physics_material_path - # create material - cfg.physics_material.func(material_path, cfg.physics_material) + # create material (accepts a legacy material cfg or rigid-body fragment(s)) + spawn_physics_material(material_path, cfg.physics_material, stage=stage) # apply material bind_physics_material(mesh_prim_path, material_path, stage=stage) diff --git a/source/isaaclab/isaaclab/sim/spawners/shapes/shapes_cfg.py b/source/isaaclab/isaaclab/sim/spawners/shapes/shapes_cfg.py index b111bdbb2bf3..04d0bc42a764 100644 --- a/source/isaaclab/isaaclab/sim/spawners/shapes/shapes_cfg.py +++ b/source/isaaclab/isaaclab/sim/spawners/shapes/shapes_cfg.py @@ -37,9 +37,18 @@ class ShapeCfg(RigidObjectSpawnerCfg): If the path is relative, then it will be relative to the prim's path. This parameter is ignored if `physics_material` is not None. """ - physics_material: materials.PhysicsMaterialCfg | None = None + physics_material: ( + materials.RigidBodyMaterialBaseCfg + | materials.RigidBodyMaterialFragment + | list[materials.RigidBodyMaterialFragment] + | None + ) = None """Physics material properties. + Since shapes are rigid-only spawners, this slot accepts the rigid material base class or + rigid-material fragments (single-namespace :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialFragment` + instances or lists thereof). + Note: If None, then no physics material will be added. """ diff --git a/source/isaaclab/isaaclab/sim/utils/queries.py b/source/isaaclab/isaaclab/sim/utils/queries.py index 4d5dcb3ac024..1b38f0aae475 100644 --- a/source/isaaclab/isaaclab/sim/utils/queries.py +++ b/source/isaaclab/isaaclab/sim/utils/queries.py @@ -12,7 +12,6 @@ from collections.abc import Callable from typing import TYPE_CHECKING -from isaaclab.cloner.cloner_utils import resolve_clone_plan_source from isaaclab.sim.simulation_context import SimulationContext from .stage import get_current_stage @@ -418,6 +417,8 @@ def resolve_matching_prims_from_source( RuntimeError: If no prim matches ``path_expr`` and ``raise_if_no_matches`` is True. """ plan = SimulationContext.instance().get_clone_plan() + from isaaclab.cloner.cloner_utils import resolve_clone_plan_source # noqa: PLC0415 + resolved = resolve_clone_plan_source(path_expr, plan) if plan is not None else None if resolved is not None: source_path, dest_glob, asset_suffix = resolved diff --git a/source/isaaclab/isaaclab/sim/utils/stage.py b/source/isaaclab/isaaclab/sim/utils/stage.py index d4c87f4c2e75..b7eaff26937f 100644 --- a/source/isaaclab/isaaclab/sim/utils/stage.py +++ b/source/isaaclab/isaaclab/sim/utils/stage.py @@ -136,15 +136,24 @@ def _modify_path(asset_path: str) -> str: # ############################################################################## -try: - # _context is a singleton design in isaacsim and for that reason - # until we fully replace all modules that references the singleton(such as XformPrim, Prim ....), we have to point - # that singleton to this _context - from isaacsim.core.experimental.utils import stage as sim_stage +_isaacsim_stage_context_synced = False + +def _sync_isaacsim_stage_context() -> None: + """Point Isaac Sim's stage helper at Isaac Lab's thread-local stage context.""" + global _isaacsim_stage_context_synced + + if _isaacsim_stage_context_synced or not has_kit(): + return + + try: + from isaacsim.core.experimental.utils import stage as sim_stage # noqa: PLC0415 + except ImportError: + return + + # Isaac Sim stage helpers read this singleton context. sim_stage._context = _context # type: ignore -except ImportError: - pass + _isaacsim_stage_context_synced = True def create_new_stage() -> Usd.Stage: @@ -167,6 +176,8 @@ def create_new_stage() -> Usd.Stage: sessionLayer=Sdf.Find('anon:0x7fba6c01c5c0:World7-session.usda'), pathResolverContext=) """ + _sync_isaacsim_stage_context() + from pxr import Usd, UsdUtils # noqa: PLC0415 stage: Usd.Stage = Usd.Stage.CreateInMemory() @@ -226,6 +237,8 @@ def open_stage(usd_path: str) -> Usd.Stage: ValueError: When input path is not a supported file type by USD. RuntimeError: When failed to open the stage. """ + _sync_isaacsim_stage_context() + from pxr import Usd # noqa: PLC0415 if not Usd.Stage.IsSupportedFile(usd_path): @@ -523,6 +536,8 @@ def get_current_stage(fabric: bool = False) -> Usd.Stage: sessionLayer=Sdf.Find('anon:0x7fba6c01c5c0:World7-session.usda'), pathResolverContext=) """ + _sync_isaacsim_stage_context() + # First check thread-local context for an in-memory stage stage = getattr(_context, "stage", None) if stage is not None: diff --git a/source/isaaclab/isaaclab/terrains/terrain_importer_cfg.py b/source/isaaclab/isaaclab/terrains/terrain_importer_cfg.py index db34c9a17195..d457c1606c83 100644 --- a/source/isaaclab/isaaclab/terrains/terrain_importer_cfg.py +++ b/source/isaaclab/isaaclab/terrains/terrain_importer_cfg.py @@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Literal import isaaclab.sim as sim_utils +from isaaclab.sim.spawners import materials from isaaclab.utils.configclass import configclass if TYPE_CHECKING: @@ -87,13 +88,20 @@ class TerrainImporterCfg: to the grid color of the imported ground plane. """ - physics_material: sim_utils.RigidBodyMaterialCfg = sim_utils.RigidBodyMaterialCfg() + physics_material: ( + materials.RigidBodyMaterialBaseCfg + | materials.RigidBodyMaterialFragment + | list[materials.RigidBodyMaterialFragment] + ) = materials.RigidBodyMaterialCfg() """The physics material of the terrain. Defaults to a default physics material. The material is created at the path: ``{prim_path}/physicsMaterial``. .. note:: This parameter is used only when the ``terrain_type`` is "generator" or "plane". + + Accepts a legacy rigid material cfg, a single rigid-material fragment, or a list of + rigid-material fragments. """ max_init_terrain_level: int | None = None diff --git a/source/isaaclab/isaaclab/terrains/utils.py b/source/isaaclab/isaaclab/terrains/utils.py index 739f80940021..68498cfde402 100644 --- a/source/isaaclab/isaaclab/terrains/utils.py +++ b/source/isaaclab/isaaclab/terrains/utils.py @@ -14,6 +14,7 @@ from pxr import UsdGeom import isaaclab.sim as sim_utils +from isaaclab.sim.spawners.materials import spawn_physics_material from isaaclab.utils.warp import raycast_mesh @@ -80,7 +81,8 @@ def create_prim_from_mesh(prim_path: str, mesh: trimesh.Trimesh, **kwargs): translation: The translation of the terrain. Defaults to None. orientation: The orientation of the terrain. Defaults to None. visual_material: The visual material to apply. Defaults to None. - physics_material: The physics material to apply. Defaults to None. + physics_material: The physics material to apply. Defaults to None. Accepts a legacy rigid + material cfg, a single rigid-material fragment, or a list of fragments. """ # create parent prim sim_utils.create_prim(prim_path, "Xform") @@ -122,10 +124,9 @@ def create_prim_from_mesh(prim_path: str, mesh: trimesh.Trimesh, **kwargs): visual_material_cfg.func(f"{prim_path}/visualMaterial", visual_material_cfg) sim_utils.bind_visual_material(prim.GetPrimPath(), f"{prim_path}/visualMaterial") # create physics material - if kwargs.get("physics_material") is not None: - physics_material_cfg: sim_utils.RigidBodyMaterialCfg = kwargs.get("physics_material") - # spawn the material - physics_material_cfg.func(f"{prim_path}/physicsMaterial", physics_material_cfg) + physics_material = kwargs.get("physics_material") + if physics_material is not None: + spawn_physics_material(f"{prim_path}/physicsMaterial", physics_material) sim_utils.bind_physics_material(prim.GetPrimPath(), f"{prim_path}/physicsMaterial") diff --git a/source/isaaclab/isaaclab/test/benchmark/benchmark_core.py b/source/isaaclab/isaaclab/test/benchmark/benchmark_core.py index 7daedbc132a8..659929cb821a 100644 --- a/source/isaaclab/isaaclab/test/benchmark/benchmark_core.py +++ b/source/isaaclab/isaaclab/test/benchmark/benchmark_core.py @@ -8,12 +8,33 @@ import time from collections.abc import Sequence from datetime import datetime - -from . import backends -from .backends import get_default_output_filename -from .interfaces import MeasurementDataRecorder -from .measurements import DictMetadata, FloatMetadata, IntMetadata, Measurement, MetadataBase, StringMetadata, TestPhase -from .recorders import CPUInfoRecorder, GPUInfoRecorder, MemoryInfoRecorder, VersionInfoRecorder +from typing import TYPE_CHECKING + +from isaaclab.test.benchmark import formatters +from isaaclab.test.benchmark.formatters import get_default_output_filename +from isaaclab.test.benchmark.interfaces import MeasurementDataRecorder +from isaaclab.test.benchmark.measurements import ( + DictMetadata, + FloatMetadata, + IntMetadata, + ListMeasurement, + Measurement, + MetadataBase, + SingleMeasurement, + StringMetadata, + TestPhase, +) +from isaaclab.test.benchmark.recorders import CPUInfoRecorder, GPUInfoRecorder, MemoryInfoRecorder, VersionInfoRecorder + +if TYPE_CHECKING: + from isaaclab.test.benchmark.schema import ( + LearningCurve, + MeanStd, + Runtime, + RuntimeBundle, + StartupBundle, + TrainingBundle, + ) logger = logging.getLogger(__name__) @@ -39,33 +60,136 @@ def _is_metadata_type(obj: object) -> bool: return type(obj).__name__ in _METADATA_CLASS_NAMES +def _stat_measurements(name: str, stats: "MeanStd", unit: str, scale: float = 1.0) -> list[Measurement]: + """Convert a schema aggregate to flat scalar measurements.""" + measurements: list[Measurement] = [ + SingleMeasurement(name=f"Mean {name}", value=stats.mean * scale, unit=unit), + SingleMeasurement(name=f"Std {name}", value=stats.std * scale, unit=unit), + ] + if stats.peak is not None: + measurements.append(SingleMeasurement(name=f"Max {name}", value=stats.peak * scale, unit=unit)) + return measurements + + +def _runtime_measurements(runtime: "Runtime") -> dict[str, list[Measurement]]: + """Convert schema runtime metrics to startup and runtime phases.""" + startup_fields = ( + ("app_launch", "App Launch Time"), + ("python_imports", "Python Imports Time"), + ("task_config", "Task Creation and Start Time"), + ("env_creation", "Scene Creation Time"), + ("first_step", "Simulation Start Time"), + ) + startup = [ + SingleMeasurement(name=label, value=value * 1000.0, unit="ms") + for field, label in startup_fields + if (value := getattr(runtime.startup_time_s, field)) is not None + ] + if startup: + startup.append( + SingleMeasurement( + name="Total Start Time (Launch to Train)", + value=sum(float(measurement.value) for measurement in startup), + unit="ms", + ) + ) + + runtime_metrics: list[Measurement] = [ + SingleMeasurement(name="Iterations Completed", value=runtime.iterations_completed, unit="count"), + SingleMeasurement(name="Total Wall Time", value=runtime.total_wall_time_s, unit="s"), + SingleMeasurement(name="Steps per Iteration", value=runtime.steps_per_iteration, unit="frames"), + ] + runtime_metrics.extend(_stat_measurements("Iteration Time", runtime.iteration_time_s, "ms", 1000.0)) + runtime_metrics.extend(_stat_measurements("Collection FPS", runtime.collection_fps, "FPS")) + runtime_metrics.extend(_stat_measurements("Total FPS", runtime.total_fps, "FPS")) + runtime_metrics.extend(_stat_measurements("Iterations per Second", runtime.iterations_per_s, "iterations/s")) + return {"startup": startup, "runtime": runtime_metrics} + + +def _curve_measurements(label: str, curve: "LearningCurve", ema_alpha: float) -> list[Measurement]: + """Convert one training curve to scalar and optional series measurements.""" + measurements: list[Measurement] = [ + SingleMeasurement(name=f"Last {label}", value=curve.final_raw, unit="float"), + SingleMeasurement(name=f"EMA {ema_alpha:g} {label}", value=curve.final_ema, unit="float"), + ] + if curve.series_per_iter is not None: + plural = "Rewards" if label == "Reward" else "Episode Lengths" + measurements.append(ListMeasurement(name=plural, value=curve.series_per_iter)) + if curve.series_per_iter: + measurements.append(SingleMeasurement(name=f"Max {plural}", value=max(curve.series_per_iter), unit="float")) + return measurements + + +def _measurements_from_bundle( + bundle: "RuntimeBundle | TrainingBundle | StartupBundle", +) -> dict[str, list[Measurement]]: + """Project a typed bundle into flat phases for non-schema formatters.""" + from isaaclab.test.benchmark.schema import StartupBundle, TrainingBundle + + if isinstance(bundle, StartupBundle): + projected: dict[str, list[Measurement]] = {} + for phase_name, phase in bundle.phases.items(): + measurements: list[Measurement] = [ + SingleMeasurement(name="Wall Clock Time", value=phase.total_time_s, unit="s") + ] + for function in phase.top_functions: + measurements.extend( + [ + SingleMeasurement(name=f"{function.name} Own Time", value=function.own_time_s, unit="s"), + SingleMeasurement(name=f"{function.name} Cumulative Time", value=function.cum_time_s, unit="s"), + SingleMeasurement(name=f"{function.name} Calls", value=function.calls, unit="count"), + ] + ) + projected[phase_name] = measurements + return projected + + projected = _runtime_measurements(bundle.runtime) + if isinstance(bundle, TrainingBundle): + train = _curve_measurements("Reward", bundle.learning.reward, bundle.learning.ema_alpha) + train.extend(_curve_measurements("Episode Length", bundle.learning.ep_length, bundle.learning.ema_alpha)) + if bundle.success_rate is not None: + train.append(SingleMeasurement(name="success_rate", value=bundle.success_rate, unit="float")) + projected["train"] = train + return projected + + class BaseIsaacLabBenchmark: """Base benchmark class for IsaacLab's benchmarks.""" def __init__( self, benchmark_name: str, - backend_type: str, - output_path: str, + formatter_type: str | list[str] | None = None, + output_path: str | None = None, use_recorders: bool = True, output_prefix: str | None = None, workflow_metadata: dict | None = None, frametime_recorders: bool = False, + backend_type: str | list[str] | None = None, ): """Initialize common benchmark state and recorders. Args: benchmark_name: Name of benchmark to use in outputs. - backend_type: Type of backend used to collect and print metrics. + formatter_type: Formatter(s) used to collect and print metrics. Accepts a single + type name, a list of type names, or a comma-separated string (e.g. + ``"schema,omniperf"``); each selected formatter writes its own output file. output_path: Path to output directory. use_recorders: Whether to use recorders to collect metrics. Defaults to True. - output_filename: Filename to use for the output file, defaults to None. + output_prefix: Prefix used to generate the output filename. Defaults to ``None``. workflow_metadata: Metadata describing benchmark, defaults to None. - frametime_recorders: Whether to use frametime recorders to collect metrics. Defaults to True. + frametime_recorders: Whether to use frametime recorders to collect metrics. Defaults to ``False``. + backend_type: Alias for :paramref:`formatter_type`. """ + if formatter_type is None: + formatter_type = backend_type or "omniperf" + elif backend_type is not None and backend_type != formatter_type: + raise ValueError("Specify either formatter_type or backend_type, not both.") + if output_path is None: + raise ValueError("output_path must be provided.") + self.benchmark_name = benchmark_name - # Resolve output path if not os.path.exists(output_path): try: os.makedirs(output_path) @@ -77,9 +201,12 @@ def __init__( logger.warning("No output prefix provided, using default prefix: benchmark") self.output_prefix = get_default_output_filename(output_prefix) - # Get metrics backend - logger.info("Using metrics backend = %s", backend_type) - self._metrics = backends.MetricsBackend.get_instance(instance_type=backend_type) + if isinstance(formatter_type, str): + formatter_type = [t.strip() for t in formatter_type.split(",") if t.strip()] or ["omniperf"] + formatter_type = list(dict.fromkeys(formatter_type)) + logger.info("Using metrics formatters = %s", formatter_type) + self._metrics = [(t, formatters.MetricsFormatter.get_instance(instance_type=t)) for t in formatter_type] + self._bundle = None self._phases: dict[str, TestPhase] = {} # Generate workflow-level metadata @@ -214,6 +341,17 @@ def _metadata_from_dict(self, metadata_dict: dict) -> list[MetadataBase]: metadata.append(curr_meta) return metadata + def attach_bundle(self, bundle: "RuntimeBundle | TrainingBundle | StartupBundle | None") -> None: + """Attach a typed bundle for schema serialization and flat-formatter projection. + + Args: + bundle: Runtime, training, or startup benchmark bundle. + """ + self._bundle = bundle + if bundle is not None: + for phase_name, measurements in _measurements_from_bundle(bundle).items(): + self.add_measurement(phase_name, measurement=measurements) + def update_manual_recorders(self) -> None: """Update manual recorders that don't depend on the kit timeline.""" @@ -239,32 +377,28 @@ def add_measurement( """ if phase_name not in self._phases: self._phases[phase_name] = TestPhase(phase_name=phase_name) - # Add required phase metadata for backends + # Add required phase metadata for formatters phase_metadata = StringMetadata(name="phase", data=phase_name) workflow_metadata = StringMetadata(name="workflow_name", data=self.benchmark_name) self._phases[phase_name].metadata.extend([phase_metadata, workflow_metadata]) if measurement: if isinstance(measurement, Sequence): - # Check that all the elements are of type Measurement for m in measurement: if not _is_measurement_type(m): raise ValueError(f"Measurement element {m} is not of type Measurement") self._phases[phase_name].measurements.extend(measurement) else: - # Check that the element is of type Measurement if not _is_measurement_type(measurement): raise ValueError(f"Measurement element {measurement} is not of type Measurement") self._phases[phase_name].measurements.append(measurement) if metadata: if isinstance(metadata, Sequence): - # Check that all the elements are of type MetadataBase for m in metadata: if not _is_metadata_type(m): raise ValueError(f"Metadata element {m} is not of type MetadataBase") self._phases[phase_name].metadata.extend(metadata) else: - # Check that the element is of type MetadataBase if not _is_metadata_type(metadata): raise ValueError(f"Metadata element {metadata} is not of type MetadataBase") self._phases[phase_name].metadata.append(metadata) @@ -293,15 +427,18 @@ def _finalize_impl(self) -> None: if data.measurements: self.add_measurement("frametime", measurement=data.measurements) - # Check that there are phases to write. if not self._phases: logger.warning("No phases collected.No metrics will be written.") return - # Add the phases to the metrics backend. - for phase in self._phases.values(): - self._metrics.add_metrics(phase) - - self._metrics.finalize(self.output_path, self.output_prefix) + # Add the phases to each metrics formatter and write its output file. When more than one + # formatter is selected, suffix the filename with the formatter key so they don't collide on + # the shared ".json" extension. + multi = len(self._metrics) > 1 + for formatter_key, metrics in self._metrics: + for phase in self._phases.values(): + metrics.add_metrics(phase) + filename = f"{self.output_prefix}_{formatter_key}" if multi else self.output_prefix + metrics.finalize(self.output_path, filename, bundle=self._bundle) self._manual_recorders = None self._frametime_recorders = None diff --git a/source/isaaclab/isaaclab/test/benchmark/builders.py b/source/isaaclab/isaaclab/test/benchmark/builders.py new file mode 100644 index 000000000000..93bfe3674e51 --- /dev/null +++ b/source/isaaclab/isaaclab/test/benchmark/builders.py @@ -0,0 +1,303 @@ +# 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 assembly functions for benchmark bundle dataclasses. + +Converts already-extracted per-iteration series and scalar measurements into +the frozen :mod:`~isaaclab.test.benchmark.schema` dataclasses that are then +serialised by :func:`~isaaclab.test.benchmark.serialize.write_bundle_file`. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from datetime import datetime + +from isaaclab.test.benchmark.metrics import ema, mean_std_peak +from isaaclab.test.benchmark.schema import ( + Hardware, + Learning, + LearningCurve, + Resources, + RunConfig, + RunIdentity, + Runtime, + RuntimeBundle, + StartupBundle, + StartupConfig, + StartupPhase, + StartupTime, + TrainingBundle, + Versions, +) + + +def build_run_config( + physics_backend: str, + rendering_backend: str = "none", + presets: Sequence[str] | None = None, +) -> RunConfig: + """Assemble a :class:`~isaaclab.test.benchmark.schema.RunConfig`. + + Args: + physics_backend: Physics solver preset used by the run + (e.g. ``"physx"``, ``"newton_mjwarp"``). + rendering_backend: Rendering backend, or ``"none"`` for headless runs + with no camera sensors. + presets: Active Hydra preset tokens (e.g. ``["rgb"]``). ``None`` + is treated as an empty list. + + Returns: + Populated :class:`~isaaclab.test.benchmark.schema.RunConfig`. + """ + return RunConfig( + physics_backend=physics_backend, + rendering_backend=rendering_backend, + presets=list(presets) if presets else [], + ) + + +def build_run_identity( + *, + run_id: str, + framework: str | None, + config: RunConfig, + task: str, + seed: int, + start_utc: str, + end_utc: str, + status: str = "completed", + num_envs: int | None = None, + max_iterations: int | None = None, +) -> RunIdentity: + """Assemble a :class:`~isaaclab.test.benchmark.schema.RunIdentity`. + + The wall-clock duration is derived from the ISO-8601 timestamps; it is + clamped to zero so clock skew cannot produce a negative value. + + Args: + run_id: Stable identifier for the run. + framework: RL library (e.g. ``"rsl_rl"``), or ``None`` for non-learning + runs. + config: Physics/rendering/sensor configuration. + task: Gym task id. + seed: Environment/agent seed. + start_utc: ISO-8601 UTC start timestamp. + end_utc: ISO-8601 UTC end timestamp. + status: Terminal status of the run. + num_envs: Number of parallel environments, or ``None`` for startup runs. + max_iterations: Training iteration budget, or ``None`` for non-training + runs. + + Returns: + Populated :class:`~isaaclab.test.benchmark.schema.RunIdentity` with + ``duration_s`` computed from the timestamps [s]. + """ + duration_s = max( + 0.0, + (datetime.fromisoformat(end_utc) - datetime.fromisoformat(start_utc)).total_seconds(), + ) + return RunIdentity( + run_id=run_id, + framework=framework, + config=config, + task=task, + seed=seed, + start_time_utc=start_utc, + end_time_utc=end_utc, + duration_s=duration_s, + status=status, + num_envs=num_envs, + max_iterations=max_iterations, + ) + + +def build_runtime( + *, + startup_time_s: StartupTime, + iteration_times_s: Sequence[float], + collection_fps: Sequence[float], + total_fps: Sequence[float], + steps_per_iteration: int, +) -> Runtime: + """Assemble a :class:`~isaaclab.test.benchmark.schema.Runtime` from raw series. + + Args: + startup_time_s: Per-phase startup wall-clock durations [s]. + iteration_times_s: Per-iteration wall-clock time [s]. + collection_fps: Per-iteration environment-stepping throughput + [frames/s]. + total_fps: Per-iteration end-to-end throughput [frames/s]. + steps_per_iteration: Environment steps collected per iteration. + + Returns: + Populated :class:`~isaaclab.test.benchmark.schema.Runtime` with + aggregated :class:`~isaaclab.test.benchmark.schema.MeanStd` fields. + """ + iter_times = list(iteration_times_s) + iter_per_s = [1.0 / t for t in iter_times if t > 0] + return Runtime( + startup_time_s=startup_time_s, + iterations_completed=len(iter_times), + total_wall_time_s=float(sum(iter_times)), + steps_per_iteration=steps_per_iteration, + iteration_time_s=mean_std_peak(iter_times), + collection_fps=mean_std_peak(collection_fps), + total_fps=mean_std_peak(total_fps), + iterations_per_s=mean_std_peak(iter_per_s), + ) + + +def build_learning( + *, + reward_series: Sequence[float], + ep_length_series: Sequence[float], + ema_alpha: float, + keep_series: bool = True, +) -> Learning: + """Assemble a :class:`~isaaclab.test.benchmark.schema.Learning` from raw curves. + + Args: + reward_series: Per-iteration mean reward values. + ep_length_series: Per-iteration mean episode-length values. + ema_alpha: EMA smoothing factor in ``[0, 1]``; higher values weight + recent observations more. + keep_series: When ``True`` (default) the full per-iteration series is + embedded in the bundle; set to ``False`` to reduce file size. + + Returns: + Populated :class:`~isaaclab.test.benchmark.schema.Learning`. + """ + rewards = list(reward_series) + ep_lengths = list(ep_length_series) + + reward_curve = LearningCurve( + final_raw=float(rewards[-1]) if rewards else 0.0, + final_ema=ema(rewards, ema_alpha), + series_per_iter=rewards if keep_series else None, + ) + ep_length_curve = LearningCurve( + final_raw=float(ep_lengths[-1]) if ep_lengths else 0.0, + final_ema=ema(ep_lengths, ema_alpha), + series_per_iter=ep_lengths if keep_series else None, + ) + return Learning(ema_alpha=ema_alpha, reward=reward_curve, ep_length=ep_length_curve) + + +def build_runtime_bundle( + *, + run: RunIdentity, + versions: Versions, + hardware: Hardware, + runtime: Runtime, + resources: Resources, + extra: dict | None = None, +) -> RuntimeBundle: + """Assemble a :class:`~isaaclab.test.benchmark.schema.RuntimeBundle`. + + Args: + run: Run identity metadata. + versions: Software versions snapshot. + hardware: Host hardware snapshot. + runtime: Aggregated runtime metrics. + resources: Aggregated resource-utilisation metrics. + extra: Optional free-form scalar values not covered by the stable + schema. + + Returns: + Populated :class:`~isaaclab.test.benchmark.schema.RuntimeBundle`. + """ + return RuntimeBundle( + run=run, + versions=versions, + hardware=hardware, + runtime=runtime, + resources=resources, + extra=extra, + ) + + +def build_training_bundle( + *, + run: RunIdentity, + versions: Versions, + hardware: Hardware, + runtime: Runtime, + resources: Resources, + learning: Learning, + success_rate: float | None = None, + checkpoint_path: str | None = None, + video_path: str | None = None, + extra: dict | None = None, +) -> TrainingBundle: + """Assemble a :class:`~isaaclab.test.benchmark.schema.TrainingBundle`. + + Args: + run: Run identity metadata. + versions: Software versions snapshot. + hardware: Host hardware snapshot. + runtime: Aggregated runtime metrics. + resources: Aggregated resource-utilisation metrics. + learning: Aggregated learning curves. + success_rate: Final success rate ``[0..1]``, or ``None`` when the task + does not track one. + checkpoint_path: Path to the final saved policy checkpoint, if any. + video_path: Path to a recorded rollout video/gif, if any. + extra: Optional free-form scalar values not covered by the stable + schema. + + Returns: + Populated :class:`~isaaclab.test.benchmark.schema.TrainingBundle`. + """ + return TrainingBundle( + run=run, + versions=versions, + hardware=hardware, + runtime=runtime, + resources=resources, + learning=learning, + success_rate=success_rate, + checkpoint_path=checkpoint_path, + video_path=video_path, + extra=extra, + ) + + +def build_startup_bundle( + *, + run: RunIdentity, + versions: Versions, + hardware: Hardware, + phases: dict[str, StartupPhase], + top_n: int, + whitelist: str | None, + extra: dict | None = None, +) -> StartupBundle: + """Assemble a :class:`~isaaclab.test.benchmark.schema.StartupBundle`. + + Args: + run: Run identity metadata (``framework``, ``num_envs``, and + ``max_iterations`` are typically ``None`` for startup profiles). + versions: Software versions snapshot. + hardware: Host hardware snapshot. + phases: Per-phase timing and cProfile data, keyed by phase name. + top_n: Number of top cProfile functions retained per phase. + whitelist: Optional cProfile name-filter pattern; ``None`` means no + filtering. + extra: Optional free-form scalar values not covered by the stable + schema. + + Returns: + Populated :class:`~isaaclab.test.benchmark.schema.StartupBundle`. + """ + config = StartupConfig(top_n=top_n, whitelist=whitelist) + return StartupBundle( + run=run, + versions=versions, + hardware=hardware, + phases=phases, + config=config, + extra=extra, + ) diff --git a/source/isaaclab/isaaclab/test/benchmark/capture.py b/source/isaaclab/isaaclab/test/benchmark/capture.py new file mode 100644 index 000000000000..5f93c70f3ef9 --- /dev/null +++ b/source/isaaclab/isaaclab/test/benchmark/capture.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 + +"""Capture helpers for benchmark data extraction. + +Reads recorder data off a ``BaseIsaacLabBenchmark``-like object and maps the +raw measurements and metadata to the typed schema dataclasses +:class:`~isaaclab.test.benchmark.schema.Versions`, +:class:`~isaaclab.test.benchmark.schema.Hardware`, and +:class:`~isaaclab.test.benchmark.schema.Resources`. + +Import-time dependencies stay light: no torch, isaacsim, or RL-library +imports. Preset metadata is imported lazily when run_config_from_presets() +needs it. The benchmark object is accepted at call time; its recorder classes +are never imported here. +""" + +from __future__ import annotations + +import socket +from collections.abc import Sequence +from datetime import datetime, timezone +from typing import Any, get_args + +from isaaclab.test.benchmark.schema import ( + GpuDeviceInfo, + Hardware, + MeanStd, + PhysicsBackend, + Resources, + RunConfig, + Versions, +) + + +def _find_value(measurements: Any, name: str, default: float = 0.0) -> float: + """Scan a measurement list for a :class:`~.measurements.SingleMeasurement` by name. + + Args: + measurements: Sequence of measurement objects, each with ``.name`` and + ``.value`` attributes. + name: Exact measurement name to look up. + default: Value to return when the name is not found. Defaults to ``0.0``. + + Returns: + The ``float`` value of the first matching measurement, or *default*. + """ + if not measurements: + return default + for m in measurements: + if m.name == name: + return float(m.value) + return default + + +def _get_recorder_data(bm: Any, key: str) -> Any | None: + """Safely retrieve :meth:`get_data` output from a named recorder. + + Args: + bm: Benchmark-like object with a ``_manual_recorders`` attribute. + key: Recorder key (e.g. ``"VersionInfo"``). + + Returns: + The :class:`~.interfaces.MeasurementData` returned by the recorder, or + ``None`` when the recorders dict is absent or the key is missing. + """ + recorders = getattr(bm, "_manual_recorders", None) + if recorders is None: + return None + rec = recorders.get(key) + if rec is None: + return None + return rec.get_data() + + +def now_utc_iso() -> str: + """Return the current UTC time as an ISO-8601 string. + + Returns: + ISO-8601 formatted current UTC timestamp. + """ + return datetime.now(timezone.utc).isoformat() + + +def synth_run_id( + framework: str | None, + physics_backend: str, + task: str, + seed: int, + stamp: str, +) -> str: + """Synthesise a stable run identifier from run parameters. + + Args: + framework: RL framework name, or ``None`` for non-learning runs + (substituted with ``"runtime"``). + physics_backend: Physics backend preset string. + task: Gym task id. + seed: Environment/agent seed. + stamp: Timestamp string (e.g. ``"20260612-150000"``). + + Returns: + Underscore-joined run identifier string. + """ + fw = framework or "runtime" + return f"{fw}_{physics_backend}_{task}_{stamp}_seed{seed}" + + +def capture_versions(bm: Any) -> Versions: + """Read software version metadata from a benchmark object. + + Reads ``bm._manual_recorders["VersionInfo"].get_data().metadata`` and maps + the ``*_version`` :class:`~.measurements.StringMetadata` entries plus the + ``dev`` :class:`~.measurements.DictMetadata` to a :class:`~.schema.Versions` + instance. All fields default gracefully when the recorder is absent. + + Args: + bm: Benchmark-like object exposing ``._manual_recorders``. + + Returns: + Populated :class:`~.schema.Versions` dataclass; never raises. + """ + data = _get_recorder_data(bm, "VersionInfo") + if data is None: + return Versions( + isaaclab="unknown", + isaacsim=None, + kit=None, + newton=None, + warp=None, + mjwarp=None, + torch="unknown", + rsl_rl=None, + rl_games=None, + skrl=None, + sb3=None, + git_commit=None, + git_branch=None, + git_dirty=False, + ) + + md = {m.name: m.data for m in data.metadata or []} + dev: dict[str, Any] = md.get("dev") or {} + + return Versions( + isaaclab=md.get("isaaclab_version", "unknown"), + isaacsim=md.get("isaacsim_version"), + kit=md.get("kit_version"), + newton=md.get("newton_version"), + warp=md.get("warp_version"), + mjwarp=md.get("mujoco_warp_version"), + torch=md.get("torch_version", "unknown"), + rsl_rl=md.get("rsl_rl_version"), + rl_games=md.get("rl_games_version"), + skrl=md.get("skrl_version"), + sb3=md.get("stable_baselines3_version"), + git_commit=dev.get("commit_hash"), + git_branch=dev.get("branch"), + git_dirty=dev.get("dirty", False), + numpy=md.get("numpy_version"), + isaaclab_newton=md.get("isaaclab_newton_version"), + isaaclab_physx=md.get("isaaclab_physx_version"), + isaaclab_ov=md.get("isaaclab_ov_version"), + isaaclab_tasks=md.get("isaaclab_tasks_version"), + isaaclab_rl=md.get("isaaclab_rl_version"), + ovrtx=md.get("ovrtx_version"), + ovphysx=md.get("ovphysx_version"), + mujoco=md.get("mujoco_version"), + cuda_bindings=md.get("cuda_bindings_version"), + usd_core=md.get("usd_core_version"), + isaaclab_release=md.get("isaaclab_release_version"), + ) + + +def capture_hardware(bm: Any) -> Hardware: + """Read hardware metadata from a benchmark object. + + Reads GPU, CPU, and memory recorder metadata to populate a + :class:`~.schema.Hardware` instance. All fields default gracefully when + recorders are absent. + + Args: + bm: Benchmark-like object exposing ``._manual_recorders``. + + Returns: + Populated :class:`~.schema.Hardware` dataclass; never raises. + """ + gpu_data = _get_recorder_data(bm, "GPUInfo") + cpu_data = _get_recorder_data(bm, "CPUInfo") + mem_data = _get_recorder_data(bm, "MemoryInfo") + + # GPU devices + gpu_devices: list[GpuDeviceInfo] = [] + if gpu_data is not None: + gpu_md = {m.name: m.data for m in gpu_data.metadata or []} + raw_devices: dict[str, Any] = gpu_md.get("gpu_devices") or {} + for idx_str in sorted(raw_devices.keys(), key=lambda k: int(k)): + d = raw_devices[idx_str] + gpu_devices.append( + GpuDeviceInfo( + name=d["name"], + mem_gb=float(d["total_memory_gb"]), + compute_cap=str(d["compute_capability"]), + ) + ) + + # CPU + cpu_name = "unknown" + cpu_count = 0 + if cpu_data is not None: + cpu_md = {m.name: m.data for m in cpu_data.metadata or []} + cpu_name = cpu_md.get("cpu_name", "unknown") + cpu_count = int(cpu_md.get("physical_cores", 0)) + + # RAM + ram_gb = 0.0 + if mem_data is not None: + mem_md = {m.name: m.data for m in mem_data.metadata or []} + ram_gb = float(mem_md.get("total_ram_gb", 0.0)) + + return Hardware( + hostname=socket.gethostname(), + gpu_devices=gpu_devices, + cpu_name=cpu_name, + cpu_count=cpu_count, + ram_gb=ram_gb, + ) + + +def _preset_target_metadata() -> tuple[str, str, str, dict[str, str]]: + """Return preset selector labels and physics aliases from the preset CLI layer.""" + from isaaclab_tasks.utils.preset_target import PresetTarget + + return ( + PresetTarget.PHYSICS.value, + PresetTarget.RENDERER.value, + PresetTarget.DOMAIN.value, + dict(PresetTarget.PHYSICS.legacy_aliases), + ) + + +def _physics_backend_names() -> set[str]: + """Return physics backend names accepted by the benchmark schema.""" + return set(get_args(PhysicsBackend)) + + +def _rendering_backend_by_preset() -> dict[str, str]: + """Return renderer preset names mapped to benchmark rendering backend names.""" + try: + from isaaclab_tasks.utils.hydra import _preset_fields + from isaaclab_tasks.utils.presets import MultiBackendRendererCfg + except ImportError: + return {} + + return { + name: name.removesuffix("_renderer") for name in _preset_fields(MultiBackendRendererCfg()) if name != "default" + } + + +def _backend_defaults_from_env_cfg(env_cfg: object) -> tuple[str | None, str | None]: + """Return active backend names from a resolved environment configuration.""" + physics_cfg = getattr(getattr(env_cfg, "sim", None), "physics", None) + physics_descriptor = ( + f"{type(physics_cfg).__module__}.{type(physics_cfg).__name__} {getattr(physics_cfg, 'class_type', '')}" + ).lower() + physics = next( + ( + name + for marker, name in ( + ("ovphysx", "ovphysx"), + ("kamino", "newton_kamino"), + ("mjwarp", "newton_mjwarp"), + ("physx", "physx"), + ) + if marker in physics_descriptor + ), + None, + ) + + renderer_names = {"isaac_rtx": "isaacsim_rtx", "ovrtx": "ovrtx", "newton_warp": "newton"} + rendering = None + stack = [env_cfg] + visited: set[int] = set() + while stack and rendering is None: + node = stack.pop() + if id(node) in visited: + continue + visited.add(id(node)) + rendering = renderer_names.get(getattr(node, "renderer_type", None)) + if isinstance(node, dict): + children = node.values() + elif isinstance(node, (list, tuple)): + children = node + else: + try: + children = vars(node).values() + except TypeError: + continue + stack.extend( + child + for child in children + if child is not None and not isinstance(child, (str, bytes, int, float, bool, type)) + ) + return physics, rendering + + +def _expand_preset_tokens(tokens: Sequence[str]) -> list[tuple[str | None, str]]: + """Expand Hydra-style preset tokens into ``(selector, value)`` pairs.""" + physics_label, renderer_label, domain_label, _ = _preset_target_metadata() + expanded: list[tuple[str | None, str]] = [] + for token in tokens: + selector, has_value, raw_value = token.partition("=") + if not has_value: + value = token.strip() + if value: + expanded.append((None, value)) + continue + + selector = selector.strip() + if selector == domain_label: + expanded.extend((selector, value) for value in (v.strip() for v in raw_value.split(",")) if value) + elif selector in (physics_label, renderer_label): + value = raw_value.strip() + if value: + expanded.append((selector, value)) + else: + value = token.strip() + if value: + expanded.append((None, value)) + return expanded + + +def run_config_from_presets(tokens: Sequence[str], *, env_cfg: object | None = None) -> RunConfig: + """Build a :class:`~isaaclab.test.benchmark.RunConfig` from presets and resolved task config. + + Picks backend defaults from ``env_cfg`` when provided, then applies recognised + preset tokens. Without a resolved config, physics defaults to ``"physx"`` and + rendering to ``"none"``. Accepts bare preset names as well as Hydra-style + ``physics=...``, ``renderer=...``, and ``presets=...`` tokens. + + Args: + tokens: Active preset tokens (e.g. ``["newton_mjwarp", "rgb"]``). + env_cfg: Optional resolved task environment configuration. Its active physics + and renderer configurations take precedence over token inference. + + Returns: + Populated :class:`~isaaclab.test.benchmark.RunConfig`. + """ + if not tokens and env_cfg is None: + return RunConfig(physics_backend="physx", rendering_backend="none", presets=[]) + + physics = "physx" + rendering = "none" + expanded_tokens = _expand_preset_tokens(tokens) + physics_label, renderer_label, _, physics_aliases = _preset_target_metadata() + physics_backends = _physics_backend_names() + rendering_backends: dict[str, str] | None = None + + def rendering_backend_for(preset_name: str) -> str | None: + nonlocal rendering_backends + if rendering_backends is None: + rendering_backends = _rendering_backend_by_preset() + if preset_name in rendering_backends: + return rendering_backends[preset_name] + if preset_name.endswith("_renderer"): + return preset_name.removesuffix("_renderer") + return None + + for selector, token in expanded_tokens: + if selector == physics_label: + physics = physics_aliases.get(token, token) + elif selector == renderer_label: + rendering = rendering_backend_for(token) or token + else: + canonical = physics_aliases.get(token, token) + if canonical in physics_backends: + physics = canonical + elif token.endswith("_renderer"): + renderer = rendering_backend_for(token) + if renderer is not None: + rendering = renderer + + if env_cfg is not None: + active_physics, active_rendering = _backend_defaults_from_env_cfg(env_cfg) + physics = active_physics or physics + rendering = active_rendering or rendering + + return RunConfig( + physics_backend=physics, + rendering_backend=rendering, + presets=[token for _, token in expanded_tokens], + ) + + +def capture_resources(bm: Any) -> Resources: + """Read resource-utilisation measurements from a benchmark object. + + Reads GPU utilisation/memory, CPU utilisation, and system RAM from the + corresponding recorders and maps them to :class:`~.schema.Resources`. + + Utilisation fields leave ``peak`` as ``None``; memory fields populate it. + + Args: + bm: Benchmark-like object exposing ``._manual_recorders``. + + Returns: + Populated :class:`~.schema.Resources` dataclass; never raises. + """ + gpu_data = _get_recorder_data(bm, "GPUInfo") + cpu_data = _get_recorder_data(bm, "CPUInfo") + mem_data = _get_recorder_data(bm, "MemoryInfo") + + # --- GPU --- + gpu_meas = gpu_data.measurements if gpu_data is not None else [] + gpu_metadata = {m.name: m.data for m in gpu_data.metadata or []} if gpu_data is not None else {} + gpu_prefix = ( + f"GPU {gpu_metadata.get('gpu_current_device', 0)} " if gpu_metadata.get("gpu_device_count", 1) > 1 else "GPU " + ) + + gpu_util_mean = _find_value(gpu_meas, f"{gpu_prefix}Utilization") + gpu_util_std = _find_value(gpu_meas, f"{gpu_prefix}Utilization std") + + gpu_mem_mean = _find_value(gpu_meas, f"{gpu_prefix}Memory Used") + gpu_mem_std = _find_value(gpu_meas, f"{gpu_prefix}Memory Used std") + _gpu_mem_peak_raw = _find_value(gpu_meas, f"{gpu_prefix}Memory Used peak", default=0.0) + gpu_mem_peak = max(gpu_mem_mean, _gpu_mem_peak_raw) + + # --- CPU --- + cpu_meas = cpu_data.measurements if cpu_data is not None else [] + + cpu_util_mean = _find_value(cpu_meas, "CPU Utilization") + cpu_util_std = _find_value(cpu_meas, "CPU Utilization std") + + # --- Memory --- + mem_meas = mem_data.measurements if mem_data is not None else [] + + ram_mean = _find_value(mem_meas, "System Memory RSS") + ram_std = _find_value(mem_meas, "System Memory RSS std") + _ram_peak_raw = _find_value(mem_meas, "System Memory RSS peak", default=0.0) + ram_peak = max(ram_mean, _ram_peak_raw) + + return Resources( + gpu_util_pct=MeanStd(mean=gpu_util_mean, std=gpu_util_std, peak=None), + gpu_mem_gb=MeanStd(mean=gpu_mem_mean, std=gpu_mem_std, peak=gpu_mem_peak), + cpu_util_pct=MeanStd(mean=cpu_util_mean, std=cpu_util_std, peak=None), + ram_gb=MeanStd(mean=ram_mean, std=ram_std, peak=ram_peak), + ) diff --git a/source/isaaclab/isaaclab/test/benchmark/backends.py b/source/isaaclab/isaaclab/test/benchmark/formatters.py similarity index 81% rename from source/isaaclab/isaaclab/test/benchmark/backends.py rename to source/isaaclab/isaaclab/test/benchmark/formatters.py index 6b6c0acacb10..201892cf7a54 100644 --- a/source/isaaclab/isaaclab/test/benchmark/backends.py +++ b/source/isaaclab/isaaclab/test/benchmark/formatters.py @@ -10,9 +10,12 @@ import textwrap from abc import ABC, abstractmethod from datetime import datetime -from typing import Any +from typing import TYPE_CHECKING, Any -from .measurements import SingleMeasurement, StatisticalMeasurement, TestPhase, TestPhaseEncoder +from isaaclab.test.benchmark.measurements import SingleMeasurement, StatisticalMeasurement, TestPhase, TestPhaseEncoder + +if TYPE_CHECKING: + from isaaclab.test.benchmark.schema import RuntimeBundle, StartupBundle, TrainingBundle logger = logging.getLogger(__name__) @@ -30,8 +33,8 @@ def get_default_output_filename(prefix: str = "benchmark") -> str: return f"{prefix}_{datetime_str}" -class MetricsBackendInterface(ABC): - """Abstract base class for metrics backends.""" +class MetricsFormatterInterface(ABC): + """Abstract base class for metrics Formatters.""" @abstractmethod def add_metrics(self, test_phase: TestPhase) -> None: @@ -48,48 +51,49 @@ def finalize(self, output_path: str, **kwargs) -> None: Args: output_path: Path to write output file(s). - **kwargs: Additional backend-specific options. + **kwargs: Additional formatter-specific options. """ pass -class MetricsBackend: - """Factory for creating metrics backend instances.""" +class MetricsFormatter: + """Factory for creating metrics formatter instances.""" - _instances: dict[str, MetricsBackendInterface] = {} + _instances: dict[str, MetricsFormatterInterface] = {} @classmethod - def get_instance(cls, instance_type: str) -> MetricsBackendInterface: - """Get or create a backend instance by type name. + def get_instance(cls, instance_type: str) -> MetricsFormatterInterface: + """Get or create a formatter instance by type name. Args: - instance_type: Type of backend to create ("json", "osmo", or "omniperf"). + instance_type: Type of formatter to create ("json", "osmo", "omniperf", "summary", or "schema"). Returns: - Backend instance of the requested type. + Formatter instance of the requested type. Raises: ValueError: If the instance_type is not recognized. """ if instance_type not in cls._instances: - backend_map = { + formatter_map = { "json": JSONFileMetrics, "osmo": OsmoKPIFile, "omniperf": OmniPerfKPIFile, "summary": SummaryMetrics, + "schema": SchemaBundleFile, } - if instance_type not in backend_map: - raise ValueError(f"Unknown backend type: {instance_type}. Available: {list(backend_map.keys())}") - cls._instances[instance_type] = backend_map[instance_type]() + if instance_type not in formatter_map: + raise ValueError(f"Unknown formatter type: {instance_type}. Available: {list(formatter_map.keys())}") + cls._instances[instance_type] = formatter_map[instance_type]() return cls._instances[instance_type] @classmethod def reset_instances(cls) -> None: - """Reset all cached backend instances. Useful for testing.""" + """Reset all cached formatter instances. Useful for testing.""" cls._instances.clear() -class JSONFileMetrics(MetricsBackendInterface): +class JSONFileMetrics(MetricsFormatterInterface): """Write metrics to a JSON file at the end of a session.""" def __init__(self) -> None: @@ -106,7 +110,7 @@ def add_metrics(self, test_phase: TestPhase) -> None: .. code-block:: python - backend.add_metrics(test_phase) + formatter.add_metrics(test_phase) """ self.data.append(copy.deepcopy(test_phase)) @@ -116,13 +120,13 @@ def finalize(self, output_path: str, output_filename: str, **kwargs) -> None: Args: output_path: Output path in which metrics file will be stored. output_filename: Output filename. - **kwargs: Additional backend-specific options. + **kwargs: Additional formatter-specific options. Example: .. code-block:: python - backend.finalize("/tmp/metrics", "metrics") + formatter.finalize("/tmp/metrics", "metrics") """ if not self.data: logger.warning("No test data to write. Skipping metrics file generation.") @@ -157,23 +161,23 @@ def finalize(self, output_path: str, output_filename: str, **kwargs) -> None: self.data.clear() -class SummaryMetrics(MetricsBackendInterface): +class SummaryMetrics(MetricsFormatterInterface): """Print a human-readable summary and write JSON metrics.""" def __init__(self) -> None: - """Initialize internal phase storage and JSON backend.""" + """Initialize internal phase storage and JSON formatter.""" self._phases: list[TestPhase] = [] - self._json_backend = JSONFileMetrics() + self._json_formatter = JSONFileMetrics() self._report_width = 86 def add_metrics(self, test_phase: TestPhase) -> None: - """Add metrics from a test phase; store for summary and forward to JSON backend. + """Add metrics from a test phase; store for summary and forward to JSON formatter. Args: test_phase: Test phase containing measurements and metadata. """ self._phases.append(copy.deepcopy(test_phase)) - self._json_backend.add_metrics(test_phase) + self._json_formatter.add_metrics(test_phase) def finalize(self, output_path: str, output_filename: str, **kwargs) -> None: """Write JSON output and print human-readable summary to console. @@ -181,9 +185,9 @@ def finalize(self, output_path: str, output_filename: str, **kwargs) -> None: Args: output_path: Path to write output file(s). output_filename: Base filename for the JSON file. - **kwargs: Additional options passed to the JSON backend. + **kwargs: Additional options passed to the JSON formatter. """ - self._json_backend.finalize(output_path, output_filename, **kwargs) + self._json_formatter.finalize(output_path, output_filename, **kwargs) if self._phases: self._print_summary() self._phases.clear() @@ -394,7 +398,7 @@ def _get_single_measurement(self, phase: TestPhase, name: str) -> SingleMeasurem return None def _summarize_runtime_metrics(self, measurements: list) -> list[str]: - """Build min/mean/max summary rows from SingleMeasurement runtime metrics. + """Build summary rows from scalar runtime statistics. Args: measurements: List of measurements (typically from the runtime phase). @@ -424,6 +428,10 @@ def _summarize_runtime_metrics(self, measurements: list) -> list[str]: base = name[len("Mean ") :] series.setdefault(base, {})["mean"] = float(value) units.setdefault(base, unit) + elif name.startswith("Std "): + base = name[len("Std ") :] + series.setdefault(base, {})["std"] = float(value) + units.setdefault(base, unit) category_order = ["Collection", "Learning", "Step Times", "Throughput", "Other"] categorized: dict[str, list[str]] = {key: [] for key in category_order} @@ -431,10 +439,11 @@ def _summarize_runtime_metrics(self, measurements: list) -> list[str]: raw_unit = units.get(base) unit = (raw_unit or "").strip() if isinstance(raw_unit, str) else "" unit_suffix = f" {unit}" if unit else "" - min_val = self._format_scalar(stats.get("min", 0.0)) - mean_val = self._format_scalar(stats.get("mean", 0.0)) - max_val = self._format_scalar(stats.get("max", 0.0)) - row = f"{base} (min/mean/max): {min_val} / {mean_val} / {max_val}{unit_suffix}" + statistic_order = ("min", "mean", "std", "max") + available = [statistic for statistic in statistic_order if statistic in stats] + values = " / ".join(self._format_scalar(stats[statistic]) for statistic in available) + labels = "/".join(available) + row = f"{base} ({labels}): {values}{unit_suffix}" if "Collection" in base: categorized["Collection"].append(row) @@ -485,7 +494,7 @@ def _format_scalar(self, value: float | int) -> str: return str(value) -class OsmoKPIFile(MetricsBackendInterface): +class OsmoKPIFile(MetricsFormatterInterface): """Write per-phase KPI documents for Osmo ingestion. Only SingleMeasurement metrics and metadata are written as key-value pairs. @@ -504,27 +513,28 @@ def add_metrics(self, test_phase: TestPhase) -> None: .. code-block:: python - backend.add_metrics(test_phase) + formatter.add_metrics(test_phase) """ self._test_phases.append(test_phase) def finalize(self, output_path: str, output_filename: str, **kwargs) -> None: """Write metrics to output file(s). - Each test phase's SingleMeasurement metrics and metadata are written to an output JSON file, at path - `[output_path]/[output_filename].json`. + A single phase is written to ``[output_path]/[output_filename].json``. Multiple phases + are written separately with the phase name appended to each filename. Args: output_path: Output path in which metrics files will be stored. output_filename: Output filename. - **kwargs: Additional backend-specific options. + **kwargs: Additional formatter-specific options. Example: .. code-block:: python - backend.finalize("/tmp/metrics", "kpis") + formatter.finalize("/tmp/metrics", "kpis") """ + multi_phase = len(self._test_phases) > 1 for test_phase in self._test_phases: # Retrieve useful metadata from test_phase phase_name = test_phase.get_metadata_field("phase") @@ -540,16 +550,17 @@ def finalize(self, output_path: str, output_filename: str, **kwargs) -> None: if isinstance(measurement, SingleMeasurement): osmo_kpis[measurement.name] = measurement.value log_statements.append(f"{measurement.name}: {measurement.value} {measurement.unit}") - # Generate the output filename with timestamp - metrics_path = os.path.join(output_path, f"{output_filename}.json") + filename = f"{output_filename}_{phase_name}" if multi_phase else output_filename + metrics_path = os.path.join(output_path, f"{filename}.json") # Dump key-value pairs (fields) to the JSON document json_data = json.dumps(osmo_kpis, indent=4) with open(metrics_path, "w") as f: f.write(json_data) print(f"Results written to: {metrics_path}") + self._test_phases.clear() -class OmniPerfKPIFile(MetricsBackendInterface): +class OmniPerfKPIFile(MetricsFormatterInterface): """Write KPI metrics for upload to a PostgreSQL database.""" def __init__(self) -> None: @@ -565,7 +576,7 @@ def add_metrics(self, test_phase: TestPhase) -> None: .. code-block:: python - backend.add_metrics(test_phase) + formatter.add_metrics(test_phase) """ self._test_phases.append(test_phase) @@ -578,13 +589,13 @@ def finalize(self, output_path: str, output_filename: str, **kwargs) -> None: Args: output_path: Output path in which metrics file will be stored. output_filename: Output filename. - **kwargs: Additional backend-specific options. + **kwargs: Additional formatter-specific options. Example: .. code-block:: python - backend.finalize("/tmp/metrics", "omniperf") + formatter.finalize("/tmp/metrics", "omniperf") """ if not self._test_phases: logger.warning("No test phases to write. Skipping metrics file generation.") @@ -623,3 +634,52 @@ def finalize(self, output_path: str, output_filename: str, **kwargs) -> None: with open(metrics_path, "w") as f: f.write(json_data) print(f"Results written to: {metrics_path}") + self._test_phases.clear() + + +class SchemaBundleFile(MetricsFormatterInterface): + """Serialize a typed benchmark bundle to schema-v1 JSON. + + Unlike the other formatters, this one does not consume the flat measurement + phases collected during a run. Instead it serializes the typed bundle + attached via :meth:`~isaaclab.test.benchmark.benchmark_core.BaseIsaacLabBenchmark.attach_bundle`. + """ + + def add_metrics(self, test_phase: TestPhase) -> None: + """Ignore the provided test phase. + + This formatter serializes the typed bundle attached via + :meth:`~isaaclab.test.benchmark.benchmark_core.BaseIsaacLabBenchmark.attach_bundle`, + not the flat measurement phases, so accumulated phases are ignored by design. + + Args: + test_phase: Test phase to ignore. + """ + pass + + def finalize( + self, + output_path: str, + output_filename: str, + bundle: "RuntimeBundle | TrainingBundle | StartupBundle | None" = None, + **kwargs, + ) -> None: + """Write the attached bundle to a schema-v1 JSON file. + + Args: + output_path: Output path in which the schema file will be stored. + output_filename: Output filename (without extension). + bundle: Typed benchmark bundle to serialize. When ``None``, no file + is written. + **kwargs: Additional formatter-specific options (ignored). + """ + if bundle is None: + logger.warning("SchemaBundleFile selected but no bundle was attached; skipping schema file.") + return + + # Lazy import keeps formatters.py free of the schema layer at module import time. + from isaaclab.test.benchmark.serialize import write_bundle_file + + path = os.path.join(output_path, f"{output_filename}.json") + write_bundle_file(bundle, path) + logger.info("Wrote schema bundle to %s", path) diff --git a/source/isaaclab/isaaclab/test/benchmark/measurements.py b/source/isaaclab/isaaclab/test/benchmark/measurements.py index 54c14b120618..5004a694277c 100644 --- a/source/isaaclab/isaaclab/test/benchmark/measurements.py +++ b/source/isaaclab/isaaclab/test/benchmark/measurements.py @@ -140,7 +140,7 @@ class StringMetadata(MetadataBase): type: Metadata type label. Defaults to "string". """ - data: str + data: str | None type: str = "string" diff --git a/source/isaaclab/isaaclab/test/benchmark/metrics.py b/source/isaaclab/isaaclab/test/benchmark/metrics.py new file mode 100644 index 000000000000..368fd9dd5518 --- /dev/null +++ b/source/isaaclab/isaaclab/test/benchmark/metrics.py @@ -0,0 +1,278 @@ +# 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 + +"""Isaac-Sim-free metric helpers for benchmark bundles: TensorBoard parsing, convergence, EMA, MeanStd aggregation, +and success-rate tracking.""" + +from __future__ import annotations + +import glob +import logging +import os +import statistics +from collections.abc import Sequence +from dataclasses import dataclass + +from tensorboard.backend.event_processing import event_accumulator + +from isaaclab.test.benchmark.schema import Framework, MeanStd + +SUCCESS_RATE_LOG_TAGS = ("Metrics/success_rate", "Episode/Metrics/success_rate") + + +@dataclass(frozen=True) +class RLLibraryDescriptor: + """TensorBoard locations and tags for one RL library benchmark integration. + + Attributes: + framework: Schema framework id. + tfevents_pattern: Glob relative to the run log directory matching TensorBoard event files. + reward_tag: TensorBoard scalar tag for mean reward per iteration. + ep_length_tag: TensorBoard scalar tag for mean episode length per iteration. + """ + + framework: Framework + tfevents_pattern: str + reward_tag: str + ep_length_tag: str + + +RL_LIBRARY_DESCRIPTORS: dict[Framework, RLLibraryDescriptor] = { + "rsl_rl": RLLibraryDescriptor( + framework="rsl_rl", + tfevents_pattern="events*", + reward_tag="Train/mean_reward", + ep_length_tag="Train/mean_episode_length", + ), + "rl_games": RLLibraryDescriptor( + framework="rl_games", + tfevents_pattern="summaries/events*", + reward_tag="rewards/iter", + ep_length_tag="episode_lengths/iter", + ), + "skrl": RLLibraryDescriptor( + framework="skrl", + tfevents_pattern="events*", + reward_tag="Reward / Total reward (mean)", + ep_length_tag="Episode / Total timesteps (mean)", + ), + "sb3": RLLibraryDescriptor( + framework="sb3", + tfevents_pattern="PPO_*/events*", + reward_tag="rollout/ep_rew_mean", + ep_length_tag="rollout/ep_len_mean", + ), +} + + +def parse_tf_logs(log_dir: str, pattern: str = "events*") -> dict[str, list[float]]: + """Load the latest TensorBoard events file under *log_dir* into ``{tag: [values]}``. + + Args: + log_dir: Directory (glob root) to search for event files. + pattern: Glob relative to *log_dir* matching the event files, e.g. + ``"events*"`` (root), ``"summaries/events*"`` (rl_games), or + ``"PPO_*/events*"`` (sb3). + + Returns: + Mapping of each scalar tag to its per-iteration value list; empty when no event file matched. + """ + list_of_files = glob.glob(os.path.join(log_dir, pattern)) + if not list_of_files: + logging.getLogger(__name__).warning( + "No TensorBoard event files matched %r under %r; returning empty log data.", pattern, log_dir + ) + return {} + latest_file = max(list_of_files, key=os.path.getmtime) + ea = event_accumulator.EventAccumulator(latest_file) + ea.Reload() + log_data: dict[str, list[float]] = {} + for tag in ea.Tags()["scalars"]: + log_data[tag] = [event.value for event in ea.Scalars(tag)] + return log_data + + +def get_success_rate_log(log_data: dict[str, list[float]]) -> list[float] | None: + """Return the per-iteration success-rate series from parsed TensorBoard *log_data*. + + Looks up the first present tag from :data:`SUCCESS_RATE_LOG_TAGS` in *log_data* (the + ``{tag: [values]}`` mapping produced by :func:`parse_tf_logs`) and returns its value + series, or ``None`` when no success tag was logged. + + Args: + log_data: Parsed TensorBoard scalars mapping each tag to its per-iteration list. + + Returns: + The per-iteration success-rate values, or ``None`` if no success tag is present. + """ + for tag in SUCCESS_RATE_LOG_TAGS: + if tag in log_data: + return log_data[tag] + return None + + +def success_rate_step_value(extras_log: dict) -> float | None: + """Return the scalar success-rate value for one env step from a live ``extras['log']`` dict. + + Unlike :func:`get_success_rate_log` (which returns a per-iteration *series* from parsed + TensorBoard logs), this reads the single scalar logged per step in the live environment + ``extras['log']`` mapping, coercing a tensor to ``float`` via ``.item()``. + + Args: + extras_log: The ``extras['log']`` mapping from one environment step. + + Returns: + The step's success-rate value as a ``float``, or ``None`` if no success tag is present. + """ + for tag in SUCCESS_RATE_LOG_TAGS: + if tag in extras_log: + val = extras_log[tag] + return float(val.item()) if hasattr(val, "item") else float(val) + return None + + +def check_convergence( + rewards: list[float], + threshold: float, + window_pct: float = 0.2, + cv_threshold: float = 20.0, +) -> dict[str, float | bool]: + """Check whether training rewards have converged. + + Passes when the trailing window mean exceeds *threshold* and the + coefficient of variation (CV) is below *cv_threshold*. + + Args: + rewards: Per-iteration mean reward values. + threshold: Minimum reward to pass. + window_pct: Fraction of iterations for the trailing window. + cv_threshold: Maximum CV (%) for stable convergence. + + Returns: + Dict with ``tail_mean``, ``cv``, and ``passed``. + """ + if not rewards: + return {"tail_mean": 0.0, "cv": 999.9, "passed": False} + window = max(1, int(len(rewards) * window_pct)) + tail = rewards[-window:] + tail_mean = statistics.mean(tail) + tail_std = statistics.stdev(tail) if len(tail) > 1 else 0.0 + cv = (tail_std / abs(tail_mean) * 100) if tail_mean != 0 else 999.9 + passed = tail_mean >= threshold and cv <= cv_threshold + return {"tail_mean": round(tail_mean, 2), "cv": round(cv, 1), "passed": passed} + + +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 = success_rate_step_value(extras.get("log", {})) + if val is not None: + self._iter_sum += 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 property is + used only by the rsl_rl wrapper (whose patched ``env.step`` calls + :meth:`record_step` once per env step); the rl_games observer ends iterations + directly via ``after_steps`` and does not use this property. + 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) + + +def mean_std_peak(values: Sequence[float]) -> MeanStd: + """Aggregate *values* into a :class:`MeanStd` with ``peak`` = max. Empty -> all zeros. + + Args: + values: Per-sample values [same unit as the field being aggregated]. + """ + vals = list(values) + if not vals: + return MeanStd(mean=0.0, std=0.0, peak=0.0) + mean = statistics.mean(vals) + std = statistics.stdev(vals) if len(vals) > 1 else 0.0 + return MeanStd(mean=mean, std=std, peak=max(vals)) + + +def mean_std(values: Sequence[float]) -> MeanStd: + """Aggregate *values* into a :class:`MeanStd` with ``peak`` omitted (``None``). + + Use for quantities where a peak is uninformative (e.g. utilisation). Empty -> zeros. + + Args: + values: Per-sample values. + """ + vals = list(values) + if not vals: + return MeanStd(mean=0.0, std=0.0, peak=None) + mean = statistics.mean(vals) + std = statistics.stdev(vals) if len(vals) > 1 else 0.0 + return MeanStd(mean=mean, std=std, peak=None) + + +def ema(series: Sequence[float], alpha: float) -> float: + """Exponential-moving-average final value of *series* (``e_0 = x_0``). Empty -> ``0.0``. + + Args: + series: Per-iteration values. + alpha: Smoothing factor in [0, 1]; higher weights recent values more. + """ + vals = list(series) + if not vals: + return 0.0 + e = float(vals[0]) + for x in vals[1:]: + e = alpha * float(x) + (1.0 - alpha) * e + return e diff --git a/source/isaaclab/isaaclab/test/benchmark/profiling.py b/source/isaaclab/isaaclab/test/benchmark/profiling.py new file mode 100644 index 000000000000..d108dcfe3bac --- /dev/null +++ b/source/isaaclab/isaaclab/test/benchmark/profiling.py @@ -0,0 +1,128 @@ +# 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 + +"""Isaac-Sim-free cProfile parsing for startup benchmarks. + +Filter to IsaacLab + first-level external calls and return per-function +own/cum time and call counts. +""" + +from __future__ import annotations + +import cProfile +import fnmatch +import io +import logging +import os +import pstats + +logger = logging.getLogger(__name__) + + +def parse_cprofile_stats( + profile: cProfile.Profile, + isaaclab_prefixes: list[str], + top_n: int = 30, + whitelist: list[str] | None = None, +) -> list[tuple[str, float, float, int]]: + """Parse cProfile stats, filtering to IsaacLab + first-level external calls. + + Walks the pstats data and keeps functions that are either (a) inside an + IsaacLab source directory, or (b) directly called by an IsaacLab function. + Results are sorted by own-time (tottime) descending. + + When *whitelist* is provided, only functions whose labels match at least one + ``fnmatch`` pattern are returned. Patterns that match no profiled function + emit a ``(pattern, 0.0, 0.0, 0)`` placeholder so dashboards always receive + consistent keys. The *top_n* parameter is ignored in whitelist mode. + + Args: + profile: A completed cProfile.Profile instance (after .disable()). + isaaclab_prefixes: Absolute file path prefixes identifying IsaacLab source + (e.g. ["/home/user/IsaacLab/source/isaaclab", ...]). + top_n: Maximum number of functions to return. Ignored when + *whitelist* is provided. + whitelist: Optional list of ``fnmatch`` patterns to select specific + functions (e.g. ``["isaaclab.cloner.*:usd_replicate"]``). + + Returns: + List of (label, tottime_ms, cumtime_ms, ncalls) tuples sorted by + tottime descending. + """ + stats = pstats.Stats(profile, stream=io.StringIO()) + + def _is_isaaclab(filename: str) -> bool: + return any(filename.startswith(prefix) for prefix in isaaclab_prefixes) + + def _make_label(filename: str, funcname: str) -> str: + # For builtins/C-extensions the filename is something like "~" or "" + if not filename or filename.startswith("<") or filename == "~": + return funcname + # Convert absolute path to dotted module-style label + for prefix in sorted(isaaclab_prefixes, key=len, reverse=True): + if filename.startswith(prefix): + rel = os.path.relpath(filename, prefix) + # Strip .py, replace os.sep with dot + rel = rel.replace(os.sep, ".").removesuffix(".py") + return f"{rel}:{funcname}" + # External function — try to find the top-level package name + # e.g. ".../site-packages/torch/nn/modules/linear.py" -> "torch.nn.modules.linear" + parts = filename.replace(os.sep, "/").removesuffix(".py").split("/") + # Find "site-packages" anchor or fall back to last 3 components + try: + sp_idx = parts.index("site-packages") + short = ".".join(parts[sp_idx + 1 :]) + except ValueError: + short = ".".join(parts[-3:]) if len(parts) >= 3 else ".".join(parts) + return f"{short}:{funcname}" + + # NOTE: stats.stats is an internal CPython dict, not part of the public pstats API. + # The public get_stats_profile() (Python 3.9+) doesn't expose caller info, which + # we need for the first-level external call filter. If a future Python release + # breaks this, switch to get_stats_profile() and drop the caller-based filtering. + results = [] + for func_key, (_pcalls, ncalls, tottime, cumtime, callers) in stats.stats.items(): + filename, _, funcname = func_key + if _is_isaaclab(filename): + label = _make_label(filename, funcname) + results.append((label, tottime * 1000.0, cumtime * 1000.0, ncalls)) + else: + # Check if any direct caller is an IsaacLab function + for caller_key in callers: + caller_filename = caller_key[0] + if _is_isaaclab(caller_filename): + label = _make_label(filename, funcname) + results.append((label, tottime * 1000.0, cumtime * 1000.0, ncalls)) + break + + # Sort by tottime (own-time) descending + results.sort(key=lambda x: x[1], reverse=True) + + if whitelist is None: + return results[:top_n] + + # Whitelist mode: filter by fnmatch patterns, emit placeholders for unmatched patterns + matched: dict[str, tuple[str, float, float, int]] = {} + matched_patterns: set[str] = set() + for label, tottime, cumtime, ncalls in results: + for pattern in whitelist: + if fnmatch.fnmatch(label, pattern): + if label not in matched: + matched[label] = (label, tottime, cumtime, ncalls) + matched_patterns.add(pattern) + + # Add 0 placeholders for patterns that matched nothing + for pattern in whitelist: + if pattern not in matched_patterns: + logger.warning( + "Whitelist pattern '%s' matched no profiled functions. " + "Check for typos or verify the function ran during this phase.", + pattern, + ) + matched[pattern] = (pattern, 0.0, 0.0, 0) + + filtered = list(matched.values()) + filtered.sort(key=lambda x: x[1], reverse=True) + return filtered diff --git a/source/isaaclab/isaaclab/test/benchmark/recorders/record_version_info.py b/source/isaaclab/isaaclab/test/benchmark/recorders/record_version_info.py index a4396371b3d3..66ca064554a0 100644 --- a/source/isaaclab/isaaclab/test/benchmark/recorders/record_version_info.py +++ b/source/isaaclab/isaaclab/test/benchmark/recorders/record_version_info.py @@ -6,6 +6,7 @@ import importlib.metadata import os import subprocess +import sys from isaaclab.test.benchmark.interfaces import MeasurementData, MeasurementDataRecorder from isaaclab.test.benchmark.measurements import DictMetadata, StringMetadata @@ -16,7 +17,7 @@ class VersionInfoRecorder(MeasurementDataRecorder): def __init__(self): - self._version_info = {} + self._version_info: dict[str, str | None] = {} self._dev_info = {} self._get_version_info() self._get_git_info() @@ -47,9 +48,45 @@ def _get_pkg_version(self, pip_name: str) -> str | None: except Exception: return None - def _record(self, key: str, version: str | None) -> None: - """Store a version entry only if version is non-empty.""" - if version: + def _get_kit_version(self) -> str | None: + """Get the version from the active Kit application.""" + app_module = sys.modules.get("omni.kit.app") + if app_module is None: + return None + try: + app = app_module.get_app() + get_version = getattr(app, "get_kit_version", None) + if not callable(get_version): + get_version = getattr(app, "get_build_version", None) + return str(get_version()) if callable(get_version) else None + except Exception: + return None + + def _get_isaacsim_version(self) -> str | None: + """Get the Isaac Sim version from an install or active Kit runtime.""" + try: + with open(os.path.join(os.environ["ISAAC_PATH"], "VERSION")) as file: + return file.read().strip() + except Exception: + pass + try: + from isaacsim.core.version import get_version + + core, prerelease, _, _, _, _, _, buildtag = get_version() + if core: + version = str(core) + if prerelease: + version += f"-{prerelease}" + if buildtag: + version += f"+{buildtag}" + return version + except Exception: + pass + return self._get_pkg_version("isaacsim") + + def _record(self, key: str, version: str | None, *, nullable: bool = False) -> None: + """Store a version entry, preserving null for explicitly nullable keys.""" + if version or nullable: self._version_info[key] = version def _get_version_info(self) -> None: @@ -60,14 +97,10 @@ def _get_version_info(self) -> None: version = self._get_version("warp", "config.version") or self._get_version("warp") self._record("warp", version) - # isaacsim - self._record("isaacsim", self._get_version("isaacsim")) - - # kit (from omni.kit if available) - version = self._get_version("omni.kit", "app.get_app().get_build_version") - if not version: - version = self._get_version("carb", "settings.get_settings().get('/app/version')") - self._record("kit", version) + # Kit and Isaac Sim are meaningful only for an active Kit runtime. + version = self._get_kit_version() + self._record("kit", version, nullable=True) + self._record("isaacsim", self._get_isaacsim_version() if version else None, nullable=True) # torch self._record("torch", self._get_version("torch")) @@ -83,7 +116,8 @@ def _get_version_info(self) -> None: self._record("isaaclab_rl", self._get_pkg_version("isaaclab_rl")) # Renderers & physics engines - self._record("ovrtx", self._get_pkg_version("ovrtx")) + self._record("ovrtx", self._get_pkg_version("ovrtx"), nullable=True) + self._record("ovphysx", self._get_pkg_version("isaaclab_ovphysx"), nullable=True) self._record("newton", self._get_pkg_version("newton")) self._record("mujoco", self._get_pkg_version("mujoco")) self._record("mujoco_warp", self._get_pkg_version("mujoco-warp")) diff --git a/source/isaaclab/isaaclab/test/benchmark/schema.py b/source/isaaclab/isaaclab/test/benchmark/schema.py index 7a0e2d733fd3..84e6e07f0f2d 100644 --- a/source/isaaclab/isaaclab/test/benchmark/schema.py +++ b/source/isaaclab/isaaclab/test/benchmark/schema.py @@ -94,8 +94,8 @@ class Hardware: class Versions: """Software versions captured at run time. - Framework-specific fields (``rsl_rl``, ``rl_games``, ``skrl``, ``sb3``) are - ``None`` when the corresponding framework is not used by the run. + Version fields are ``None`` when the corresponding runtime or package is + unavailable. """ isaaclab: str @@ -112,6 +112,18 @@ class Versions: git_commit: str | None git_branch: str | None git_dirty: bool + numpy: str | None = None + isaaclab_newton: str | None = None + isaaclab_physx: str | None = None + isaaclab_ov: str | None = None + isaaclab_tasks: str | None = None + isaaclab_rl: str | None = None + ovrtx: str | None = None + ovphysx: str | None = None + mujoco: str | None = None + cuda_bindings: str | None = None + usd_core: str | None = None + isaaclab_release: str | None = None @dataclass(frozen=True) diff --git a/source/isaaclab/isaaclab/test/benchmark/stepping.py b/source/isaaclab/isaaclab/test/benchmark/stepping.py new file mode 100644 index 000000000000..969ce6cd1b55 --- /dev/null +++ b/source/isaaclab/isaaclab/test/benchmark/stepping.py @@ -0,0 +1,85 @@ +# 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 + +"""Backend-agnostic random-action stepping helpers for benchmarks. + +This module is intentionally lightweight: ``torch`` and ``numpy`` are +imported lazily inside each function so that importing this module has +no heavy-weight side effects. +""" + +from __future__ import annotations + +import time +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import torch + + +def sample_random_actions(env) -> torch.Tensor | dict[str, torch.Tensor]: + """Sample random actions for a single-agent or multi-agent environment. + + For multi-agent environments (those where ``env.unwrapped`` exposes an + ``action_spaces`` attribute), one batch of actions is sampled per agent + using that agent's action space. For single-agent environments a uniform + sample in [-1, 1] is returned. + + Args: + env: A Gym-compatible environment wrapper. ``env.unwrapped`` must + expose ``num_envs`` and ``device``, plus either ``action_spaces`` + (multi-agent) or ``single_action_space`` (single-agent). + + Returns: + A ``torch.Tensor`` of shape ``(num_envs, action_dim)`` for + single-agent environments, or a ``dict`` mapping agent name to a + tensor of the same shape for multi-agent environments. + """ + import numpy as np # noqa: PLC0415 + import torch # noqa: PLC0415 + + u = env.unwrapped + + if hasattr(u, "action_spaces"): + # Multi-agent: sample each agent's action space independently. + return { + agent: torch.as_tensor( + np.stack([space.sample() for _ in range(u.num_envs)]), + dtype=torch.float32, + device=u.device, + ) + for agent, space in u.action_spaces.items() + } + else: + # Single-agent: uniform random actions in [-1, 1]. + return 2.0 * torch.rand(u.num_envs, u.single_action_space.shape[0], device=u.device) - 1.0 + + +def run_runtime_loop(env, num_frames: int) -> list[float]: + """Step the environment ``num_frames`` times and record per-step wall times [s]. + + Calls ``env.reset()`` once before the loop, then on each frame samples + random actions via :func:`sample_random_actions`, steps the environment, + and records the elapsed wall-clock time for that step. + + Args: + env: A Gym-compatible environment. + num_frames: Number of environment steps to run. + + Returns: + A list of length ``num_frames`` containing per-step wall times [s]. + """ + env.reset() + + step_times: list[float] = [] + + for _ in range(num_frames): + actions = sample_random_actions(env) + t0 = time.perf_counter_ns() + env.step(actions) + t1 = time.perf_counter_ns() + step_times.append((t1 - t0) / 1e9) + + return step_times diff --git a/source/isaaclab/isaaclab/utils/images.py b/source/isaaclab/isaaclab/utils/images.py index f832af8381e2..bb02548d55cf 100644 --- a/source/isaaclab/isaaclab/utils/images.py +++ b/source/isaaclab/isaaclab/utils/images.py @@ -94,3 +94,39 @@ def normalize_camera_image( if is_normals_like(data_type): return (images + 1.0) * 0.5 return images + + +def normalize_camera_output_for_display(tensor: torch.Tensor, data_type: str) -> torch.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"]: + max_val = normalized.max() + if max_val > 0: + normalized = normalized / max_val + elif data_type in {"albedo"}: + normalized = normalized[..., :3] / 255.0 + elif data_type in {"normals"}: + normalized = (normalized + 1.0) * 0.5 + elif data_type in {"motion_vectors"}: + # Motion vectors are per-pixel (u, v) offsets that can be positive or negative. Normalize by the + # peak magnitude to map into [-1, 1], remap to [0, 1], and pack the two channels into an RGB image + # (u -> R, v -> G, unused B -> 0) so the result can be composed into a grid and saved as an image. + uv = normalized[..., :2] + max_mag = uv.abs().max() + if max_mag > 0: + uv = uv / max_mag + uv = (uv + 1.0) * 0.5 + blue = torch.zeros_like(uv[..., :1]) + normalized = torch.cat([uv, blue], dim=-1) + else: + normalized = normalized / 255.0 + + return normalized + + +def make_camera_output_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)) diff --git a/source/isaaclab/pyproject.toml b/source/isaaclab/pyproject.toml index 989aedb94379..4077d164141a 100644 --- a/source/isaaclab/pyproject.toml +++ b/source/isaaclab/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab" -version = "8.0.2" +version = "9.0.0" description = "Extension providing main framework interfaces and abstractions for robot learning." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] @@ -54,8 +54,8 @@ dependencies = [ "pin-pink==3.1.0 ; platform_system == 'Linux' and platform_machine in 'x86_64 AMD64 aarch64 arm64'", "daqp==0.8.5 ; platform_system == 'Linux' and platform_machine in 'x86_64 AMD64 aarch64 arm64'", # OpenUSD (kit-less mode) - "usd-core>=25.11,<26.0 ; platform_machine in 'x86_64 AMD64'", - "usd-exchange>=2.2 ; platform_machine in 'x86_64 AMD64 aarch64 arm64'", + "usd-core>=26.5,<27.0 ; platform_machine in 'x86_64 AMD64'", + "usd-exchange>=2.2 ; platform_machine in 'aarch64 arm64'", # avoid broken hf-xet pre-release cached on NVIDIA Artifactory "hf-xet>=1.4.1,<2.0.0 ; platform_machine in 'x86_64 AMD64 aarch64 arm64'", ] diff --git a/source/isaaclab/test/app/test_kwarg_launch.py b/source/isaaclab/test/app/test_kwarg_launch.py index 1eb5b59764ac..8c27e9ad4379 100644 --- a/source/isaaclab/test/app/test_kwarg_launch.py +++ b/source/isaaclab/test/app/test_kwarg_launch.py @@ -4,10 +4,14 @@ # SPDX-License-Identifier: BSD-3-Clause import argparse +import logging import pytest +import isaaclab.app as app_module import isaaclab.app.app_launcher as app_launcher_module +import isaaclab.app.sim_launcher as sim_launcher +import isaaclab.utils as utils_module from isaaclab.app import AppLauncher from isaaclab.app.sim_launcher import _ensure_livestream_kit_visualizer @@ -40,6 +44,40 @@ def test_livestream_rejects_disabled_visualizers(): _ensure_livestream_kit_visualizer(args) +def test_launch_simulation_preserves_failure_exit_code(monkeypatch: pytest.MonkeyPatch): + close_args = {} + + class _FakeApp: + def close(self, *, exit_code: int = 0) -> None: + close_args["exit_code"] = exit_code + + class _FakeAppLauncher: + def __init__(self, _launcher_args): + self.app = _FakeApp() + + scan = sim_launcher.Scan( + resolved_physics_cfg=None, + effective_cfg=object(), + visualizer_intent={"has_any_visualizers": False, "has_kit_visualizer": False}, + has_ovrtx=False, + has_kit_camera=False, + has_kit_physics=True, + has_kitless_physics=False, + has_ovphysx_physics=False, + needs_kit=True, + ) + monkeypatch.setattr(sim_launcher, "scan", lambda cfg, physics: scan) + monkeypatch.setattr(sim_launcher, "_ensure_isaac_sim_available", lambda: None) + monkeypatch.setattr(app_module, "AppLauncher", _FakeAppLauncher) + monkeypatch.setattr(utils_module, "has_kit", lambda: False) + + with pytest.raises(RuntimeError, match="sentinel"): + with sim_launcher.launch_simulation(object(), argparse.Namespace()): + raise RuntimeError("sentinel") + + assert close_args == {"exit_code": 1} + + class _DummySettings: def __init__(self): self.values = {} @@ -54,6 +92,40 @@ def set_bool(self, path: str, value: bool) -> None: self.values[path] = value +@pytest.mark.parametrize( + ("headless", "livestream", "xr", "expected_has_gui"), + [ + pytest.param(False, 0, False, True, id="local-window"), + pytest.param(True, 0, False, False, id="headless"), + pytest.param(True, 1, False, True, id="livestream"), + pytest.param(True, 0, True, True, id="xr"), + ], +) +def test_load_extensions_publishes_has_gui_setting( + monkeypatch: pytest.MonkeyPatch, headless: bool, livestream: int, xr: bool, expected_has_gui: bool +): + """Publish the GUI state consumed by SimulationContext and RTX rendering.""" + launcher = AppLauncher.__new__(AppLauncher) + launcher._apply_rtx_determinism = False + launcher._python_logging_level = logging.ERROR + launcher._headless = headless + launcher._livestream = livestream + launcher._enable_cameras = False + launcher._offscreen_render = False + launcher._render_viewport = False + launcher._xr = xr + launcher._video_enabled = False + + settings = _DummySettings() + monkeypatch.setattr(app_launcher_module, "initialize_carb_settings", lambda: None) + monkeypatch.setattr(app_launcher_module, "get_settings_manager", lambda: settings) + monkeypatch.setattr(AppLauncher, "_apply_python_logging_level", lambda _level: None) + + launcher._load_extensions() + + assert settings.values["/isaaclab/has_gui"] is expected_has_gui + + def test_set_visualizer_settings_stores_values(monkeypatch: pytest.MonkeyPatch): settings = _DummySettings() monkeypatch.setattr(app_launcher_module, "get_settings_manager", lambda: settings) diff --git a/source/isaaclab/test/assets/_rigid_object_collection_iface_test_utils.py b/source/isaaclab/test/assets/_rigid_object_collection_iface_test_utils.py new file mode 100644 index 000000000000..426c43f0f7fb --- /dev/null +++ b/source/isaaclab/test/assets/_rigid_object_collection_iface_test_utils.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 + +# ignore private usage of variables warning +# pyright: reportPrivateUsage=none + +"""Shared mocked rigid-object-collection backend factories for interface tests.""" + +import os +import sys +import importlib.util +from unittest.mock import MagicMock + +# When running kitless (e.g., ovphysx backend via run_ovphysx.sh), AppLauncher +# will try to boot Kit and hang. Skip it entirely: run_ovphysx.sh sets +# LD_PRELOAD to the ovphysx libcarb.so, which is the signature of a kitless +# ovphysx run. Also guard the case where neither LD_PRELOAD nor EXP_PATH is +# set (bare Python, no Kit at all). +_kitless = "ovphysx" in os.environ.get("LD_PRELOAD", "") or ( + os.environ.get("LD_PRELOAD", "") == "" and "EXP_PATH" not in os.environ +) + +if not _kitless: + from isaaclab.app import AppLauncher + + simulation_app = AppLauncher(headless=True).app +else: + simulation_app = None + # Stub out the Kit/Omniverse modules that are not present under + # run_ovphysx.sh (pxr, carb, omni, omni.kit[.app] are real on PYTHONPATH). + # ``omni`` is a real namespace package, so missing submodules also need + # to be installed as attributes on it -- ``sys.modules`` alone is not + # enough because attribute access on the real ``omni`` won't fall + # through to ``sys.modules``. + import omni as _omni + + for _mod in ("physics", "physics.tensors", "physx", "timeline", "usd"): + _stub = MagicMock() + sys.modules[f"omni.{_mod}"] = _stub + # Bind the leaf attribute so that ``omni.`` resolves. + setattr(_omni, _mod.split(".", 1)[0], _stub) + for _mod in ("isaacsim.core", "isaacsim.core.simulation_manager"): + sys.modules.setdefault(_mod, MagicMock()) + +import numpy as np +import warp as wp + +from isaaclab.assets.rigid_object.rigid_object_cfg import RigidObjectCfg +from isaaclab.assets.rigid_object_collection.rigid_object_collection_cfg import RigidObjectCollectionCfg +from isaaclab.test.mock_interfaces.utils import MockWrenchComposer + +# Mock SimulationManager.get_physics_sim_view() to return a mock object with gravity +_mock_physics_sim_view = MagicMock() +_mock_physics_sim_view.get_gravity.return_value = (0.0, 0.0, -9.81) + +from isaaclab_physx.physics import PhysxManager as SimulationManager + +SimulationManager.get_physics_sim_view = MagicMock(return_value=_mock_physics_sim_view) + +BACKENDS = ["Mock"] # Mock backend is always available. + +if importlib.util.find_spec("isaaclab_physx") is not None: + from isaaclab_physx.assets.rigid_object_collection.rigid_object_collection import ( + RigidObjectCollection as PhysXRigidObjectCollection, + ) + from isaaclab_physx.assets.rigid_object_collection.rigid_object_collection_data import ( + RigidObjectCollectionData as PhysXRigidObjectCollectionData, + ) + from isaaclab_physx.test.mock_interfaces.views import MockRigidBodyViewWarp as PhysXMockRigidBodyViewWarp + + BACKENDS.append("physx") + +if importlib.util.find_spec("isaaclab_newton") is not None: + from isaaclab_newton.assets.rigid_object_collection.rigid_object_collection import ( + RigidObjectCollection as NewtonRigidObjectCollection, + ) + from isaaclab_newton.assets.rigid_object_collection.rigid_object_collection_data import ( + RigidObjectCollectionData as NewtonRigidObjectCollectionData, + ) + from isaaclab_newton.test.mock_interfaces.mock_newton import MockWrenchComposer as NewtonMockWrenchComposer + from isaaclab_newton.test.mock_interfaces.views import MockNewtonCollectionView as NewtonMockCollectionView + + BACKENDS.append("newton") + +if ( + importlib.util.find_spec("isaaclab_ovphysx") is not None + and importlib.util.find_spec("ovphysx") is not None +): + from isaaclab_ovphysx.assets.rigid_object_collection.rigid_object_collection import ( + RigidObjectCollection as OvPhysxRigidObjectCollection, + ) + from isaaclab_ovphysx.assets.rigid_object_collection.rigid_object_collection_data import ( + RigidObjectCollectionData as OvPhysxRigidObjectCollectionData, + ) + from isaaclab_ovphysx.test.mock_interfaces.views import MockOvPhysxBindingSet + + if hasattr(OvPhysxRigidObjectCollection, "_create_buffers"): + BACKENDS.append("ovphysx") + + +def create_physx_rigid_object_collection( + num_instances: int = 2, + num_bodies: int = 3, + device: str = "cuda:0", +): + """Create a test RigidObjectCollection instance with mocked dependencies.""" + collection = object.__new__(PhysXRigidObjectCollection) + + rigid_objects = {f"object_{i}": RigidObjectCfg(prim_path=f"/World/Object_{i}") for i in range(num_bodies)} + collection.cfg = RigidObjectCollectionCfg(rigid_objects=rigid_objects) + + # View count = num_instances * num_bodies (body-major view order) + mock_view = PhysXMockRigidBodyViewWarp( + count=num_instances * num_bodies, + device=device, + ) + 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, "_num_bodies", num_bodies) + object.__setattr__(collection, "_num_instances", num_instances) + object.__setattr__(collection, "_body_names_list", [f"object_{i}" for i in range(num_bodies)]) + + # Create RigidObjectCollectionData instance + data = PhysXRigidObjectCollectionData(mock_view, num_bodies, device) + object.__setattr__(collection, "_data", data) + data.body_names = [f"object_{i}" for i in range(num_bodies)] + + # 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) + + # Prevent __del__ / _clear_callbacks from raising AttributeError + object.__setattr__(collection, "_initialize_handle", None) + object.__setattr__(collection, "_invalidate_initialize_handle", None) + object.__setattr__(collection, "_prim_deletion_handle", None) + object.__setattr__(collection, "_debug_vis_handle", None) + + # Set up index arrays + 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)) + + return collection, mock_view + + +def create_newton_rigid_object_collection( + num_instances: int = 2, + num_bodies: int = 3, + device: str = "cuda:0", +): + """Create a test Newton RigidObjectCollection instance with mocked dependencies.""" + import isaaclab_newton.assets.rigid_object_collection.rigid_object_collection as newton_coll_module + import isaaclab_newton.assets.rigid_object_collection.rigid_object_collection_data as newton_data_module + + body_names = [f"object_{i}" for i in range(num_bodies)] + + # Create collection-specific mock view with (N, B) root shapes + mock_view = NewtonMockCollectionView( + num_envs=num_instances, + num_bodies=num_bodies, + device=device, + body_names=body_names, + ) + mock_view.set_random_mock_data() + mock_view._noop_setters = True + + # Mock NewtonManager (aliased as SimulationManager in Newton modules) + mock_model = MagicMock() + mock_model.gravity = wp.array(np.array([[0.0, 0.0, -9.81]], dtype=np.float32), dtype=wp.vec3f, device=device) + mock_state = MagicMock() + mock_control = MagicMock() + + mock_manager = MagicMock() + mock_manager.get_model.return_value = mock_model + mock_manager.get_state_0.return_value = mock_state + mock_manager.get_state_1.return_value = mock_state + mock_manager.get_control.return_value = mock_control + + # Patch SimulationManager in both data and collection modules + original_data_manager = newton_data_module.SimulationManager + original_coll_manager = newton_coll_module.SimulationManager + newton_data_module.SimulationManager = mock_manager + newton_coll_module.SimulationManager = mock_manager + + try: + data = NewtonRigidObjectCollectionData(mock_view, num_bodies, device) + finally: + newton_data_module.SimulationManager = original_data_manager + newton_coll_module.SimulationManager = original_coll_manager + + # Create collection shell (bypass __init__) + collection = object.__new__(NewtonRigidObjectCollection) + + rigid_objects = {f"object_{i}": RigidObjectCfg(prim_path=f"/World/Object_{i}") for i in range(num_bodies)} + collection.cfg = RigidObjectCollectionCfg(rigid_objects=rigid_objects) + + object.__setattr__(collection, "_root_view", mock_view) + object.__setattr__(collection, "_device", device) + object.__setattr__(collection, "_num_bodies", num_bodies) + object.__setattr__(collection, "_num_instances", num_instances) + object.__setattr__(collection, "_body_names_list", body_names) + object.__setattr__(collection, "_data", data) + data.body_names = body_names + + # Mock wrench composers (Newton-specific) + mock_inst_wrench = NewtonMockWrenchComposer(collection) + mock_perm_wrench = NewtonMockWrenchComposer(collection) + object.__setattr__(collection, "_instantaneous_wrench_composer", mock_inst_wrench) + object.__setattr__(collection, "_permanent_wrench_composer", mock_perm_wrench) + + # Prevent __del__ / _clear_callbacks from raising AttributeError + object.__setattr__(collection, "_initialize_handle", None) + object.__setattr__(collection, "_invalidate_initialize_handle", None) + object.__setattr__(collection, "_prim_deletion_handle", None) + object.__setattr__(collection, "_debug_vis_handle", None) + + # Index arrays (warp) + 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)) + + return collection, mock_view + + +def create_ovphysx_rigid_object_collection( + num_instances: int = 2, + num_bodies: int = 3, + device: str = "cuda:0", +): + """Create a test OVPhysX RigidObjectCollection instance with mocked tensor bindings.""" + body_names = [f"object_{i}" for i in range(num_bodies)] + + collection = object.__new__(OvPhysxRigidObjectCollection) + + rigid_objects = {f"object_{i}": RigidObjectCfg(prim_path=f"/World/Object_{i}") for i in range(num_bodies)} + collection.cfg = RigidObjectCollectionCfg(rigid_objects=rigid_objects) + + # Use articulation-mode bindings with num_joints=0 to get (N, B, ...) shaped tensors. + mock_bindings = MockOvPhysxBindingSet( + num_instances=num_instances, + num_joints=0, + num_bodies=num_bodies, + body_names=body_names, + asset_kind="articulation", + ) + mock_bindings.set_random_data() + + object.__setattr__(collection, "_device", device) + object.__setattr__(collection, "_ovphysx", MagicMock()) + object.__setattr__(collection, "_bindings", mock_bindings.bindings) + object.__setattr__(collection, "_num_instances", num_instances) + object.__setattr__(collection, "_num_bodies", num_bodies) + object.__setattr__(collection, "_body_names_list", body_names) + + # Create RigidObjectCollectionData + data = OvPhysxRigidObjectCollectionData(mock_bindings.bindings, num_bodies, device) + data.num_instances = num_instances + data.num_bodies = num_bodies + data._is_primed = True + object.__setattr__(collection, "_data", data) + + # Allocate the buffers that RigidObjectCollection normally allocates in _initialize_impl. + collection._create_buffers() + + # Replace the real wrench composers with mocks for iface coverage. + 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) + + # Prevent __del__ / _clear_callbacks from raising + object.__setattr__(collection, "_initialize_handle", None) + object.__setattr__(collection, "_invalidate_initialize_handle", None) + object.__setattr__(collection, "_prim_deletion_handle", None) + object.__setattr__(collection, "_debug_vis_handle", None) + + return collection, mock_bindings + + +def create_mock_rigid_object_collection( + num_instances: int = 2, + num_bodies: int = 3, + device: str = "cuda:0", +): + from isaaclab.test.mock_interfaces.assets.mock_rigid_object_collection import MockRigidObjectCollection + + obj = MockRigidObjectCollection( + num_instances=num_instances, + num_bodies=num_bodies, + device=device, + ) + return obj, None + + +def get_rigid_object_collection( + backend: str, + num_instances: int = 2, + num_bodies: int = 3, + device: str = "cuda:0", +): + if backend == "physx": + return create_physx_rigid_object_collection(num_instances, num_bodies, device) + elif backend == "ovphysx": + return create_ovphysx_rigid_object_collection(num_instances, num_bodies, device) + elif backend == "newton": + return create_newton_rigid_object_collection(num_instances, num_bodies, device) + elif backend.lower() == "mock": + return create_mock_rigid_object_collection(num_instances, num_bodies, device) + else: + raise ValueError(f"Invalid backend: {backend}") diff --git a/source/isaaclab/test/assets/_rigid_object_iface_test_utils.py b/source/isaaclab/test/assets/_rigid_object_iface_test_utils.py new file mode 100644 index 000000000000..1004d3db0480 --- /dev/null +++ b/source/isaaclab/test/assets/_rigid_object_iface_test_utils.py @@ -0,0 +1,313 @@ +# 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 + +# ignore private usage of variables warning +# pyright: reportPrivateUsage=none + +"""Shared mocked rigid-object backend factories for interface tests.""" + +import os +import sys +import importlib.util +from unittest.mock import MagicMock + +# When running kitless (e.g., ovphysx backend via run_ovphysx.sh), AppLauncher +# will try to boot Kit and hang. Skip it entirely: run_ovphysx.sh sets +# LD_PRELOAD to the ovphysx libcarb.so, which is the signature of a kitless +# ovphysx run. Also guard the case where neither LD_PRELOAD nor EXP_PATH is +# set (bare Python, no Kit at all). +_kitless = "ovphysx" in os.environ.get("LD_PRELOAD", "") or ( + os.environ.get("LD_PRELOAD", "") == "" and "EXP_PATH" not in os.environ +) + +if not _kitless: + from isaaclab.app import AppLauncher + + simulation_app = AppLauncher(headless=True).app +else: + simulation_app = None + # Stub out the Kit/Omniverse modules that are not present under + # run_ovphysx.sh (pxr, carb, omni, omni.kit[.app] are real on PYTHONPATH). + # ``omni`` is a real namespace package, so missing submodules also need + # to be installed as attributes on it -- ``sys.modules`` alone is not + # enough because attribute access on the real ``omni`` won't fall + # through to ``sys.modules``. + import omni as _omni + + for _mod in ("physics", "physics.tensors", "physx", "timeline", "usd"): + _stub = MagicMock() + sys.modules[f"omni.{_mod}"] = _stub + # Bind the leaf attribute so that ``omni.`` resolves. + setattr(_omni, _mod.split(".", 1)[0], _stub) + for _mod in ("isaacsim.core", "isaacsim.core.simulation_manager"): + sys.modules.setdefault(_mod, MagicMock()) + +import numpy as np +import warp as wp + +from isaaclab.assets.rigid_object.rigid_object_cfg import RigidObjectCfg +from isaaclab.test.mock_interfaces.utils import MockWrenchComposer + +# Mock SimulationManager.get_physics_sim_view() to return a mock object with gravity +# This is needed because the Data classes call SimulationManager.get_physics_sim_view().get_gravity() +# but there's no actual physics scene when running unit tests +_mock_physics_sim_view = MagicMock() +_mock_physics_sim_view.get_gravity.return_value = (0.0, 0.0, -9.81) + +from isaaclab_physx.physics import PhysxManager as SimulationManager + +SimulationManager.get_physics_sim_view = MagicMock(return_value=_mock_physics_sim_view) + +BACKENDS = ["Mock"] # Mock backend is always available. + +if importlib.util.find_spec("isaaclab_physx") is not None: + from isaaclab_physx.assets.rigid_object.rigid_object import RigidObject as PhysXRigidObject + from isaaclab_physx.assets.rigid_object.rigid_object_data import RigidObjectData as PhysXRigidObjectData + from isaaclab_physx.test.mock_interfaces.views import MockRigidBodyViewWarp as PhysXMockRigidBodyViewWarp + + BACKENDS.append("physx") + +if importlib.util.find_spec("isaaclab_newton") is not None: + from isaaclab_newton.assets.rigid_object.rigid_object import RigidObject as NewtonRigidObject + from isaaclab_newton.assets.rigid_object.rigid_object_data import RigidObjectData as NewtonRigidObjectData + from isaaclab_newton.test.mock_interfaces.views import MockNewtonArticulationView as NewtonMockArticulationView + + BACKENDS.append("newton") + +if ( + importlib.util.find_spec("isaaclab_ovphysx") is not None + and importlib.util.find_spec("ovphysx") is not None +): + from isaaclab_ovphysx.assets.rigid_object.rigid_object import RigidObject as OvPhysxRigidObject + from isaaclab_ovphysx.assets.rigid_object.rigid_object_data import RigidObjectData as OvPhysxRigidObjectData + from isaaclab_ovphysx.test.mock_interfaces.views import MockOvPhysxBindingSet + + BACKENDS.append("ovphysx") + + +def create_physx_rigid_object( + num_instances: int = 2, + device: str = "cuda:0", +): + """Create a test RigidObject instance with mocked dependencies.""" + body_names = ["body_0"] + + rigid_object = object.__new__(PhysXRigidObject) + + rigid_object.cfg = RigidObjectCfg(prim_path="/World/Object") + + # Create PhysX mock view + mock_view = PhysXMockRigidBodyViewWarp( + count=num_instances, + device=device, + ) + 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) + + # Create RigidObjectData instance (SimulationManager already mocked at module level) + data = PhysXRigidObjectData(mock_view, device) + object.__setattr__(rigid_object, "_data", data) + + # Set body names on data + data.body_names = body_names + + # 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) + + # Prevent __del__ / _clear_callbacks from raising AttributeError + object.__setattr__(rigid_object, "_initialize_handle", None) + object.__setattr__(rigid_object, "_invalidate_initialize_handle", None) + object.__setattr__(rigid_object, "_prim_deletion_handle", None) + object.__setattr__(rigid_object, "_debug_vis_handle", None) + + # Set up index arrays (warp arrays for 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 + + +def create_newton_rigid_object( + num_instances: int = 2, + device: str = "cuda:0", +): + """Create a test Newton RigidObject instance with mocked dependencies.""" + import isaaclab_newton.assets.rigid_object.rigid_object_data as newton_data_module + + body_names = ["body_0"] + + # Create Newton mock view (uses ArticulationView with num_bodies=1 for rigid objects) + mock_view = NewtonMockArticulationView( + num_instances=num_instances, + num_bodies=1, + num_joints=0, + device=device, + is_fixed_base=False, + joint_names=[], + body_names=body_names, + ) + mock_view.set_random_mock_data() + mock_view._noop_setters = True + + # Mock NewtonManager (aliased as SimulationManager in Newton modules) + mock_model = MagicMock() + mock_model.gravity = wp.array(np.array([[0.0, 0.0, -9.81]], dtype=np.float32), dtype=wp.vec3f, device=device) + mock_state = MagicMock() + mock_control = MagicMock() + + mock_manager = MagicMock() + mock_manager.get_model.return_value = mock_model + mock_manager.get_state_0.return_value = mock_state + mock_manager.get_state_1.return_value = mock_state + mock_manager.get_control.return_value = mock_control + + # Patch SimulationManager in the Newton data module + original_sim_manager = newton_data_module.SimulationManager + newton_data_module.SimulationManager = mock_manager + + try: + data = NewtonRigidObjectData(mock_view, device) + finally: + newton_data_module.SimulationManager = original_sim_manager + + # Create RigidObject shell (bypass __init__) + rigid_object = object.__new__(NewtonRigidObject) + + rigid_object.cfg = RigidObjectCfg(prim_path="/World/Object") + + object.__setattr__(rigid_object, "_root_view", mock_view) + object.__setattr__(rigid_object, "_device", device) + object.__setattr__(rigid_object, "_data", data) + + # 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) + + # Prevent __del__ / _clear_callbacks from raising AttributeError + object.__setattr__(rigid_object, "_initialize_handle", None) + object.__setattr__(rigid_object, "_invalidate_initialize_handle", None) + object.__setattr__(rigid_object, "_prim_deletion_handle", None) + object.__setattr__(rigid_object, "_debug_vis_handle", None) + + # Newton uses wp.array for indices + 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)) + + # Newton uses wp.bool masks + 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((1,), dtype=wp.bool, device=device)) + + return rigid_object, mock_view + + +def create_ovphysx_rigid_object( + num_instances: int = 2, + device: str = "cuda:0", +): + """Create a test OvPhysX RigidObject instance with mocked tensor bindings.""" + body_names = ["base_link"] + + obj = object.__new__(OvPhysxRigidObject) + + obj.cfg = RigidObjectCfg(prim_path="/World/object") + + # Create mock binding set + mock_bindings = MockOvPhysxBindingSet( + num_instances=num_instances, + num_joints=0, + num_bodies=1, + body_names=body_names, + asset_kind="rigid_object", + ) + mock_bindings.set_random_data() + + object.__setattr__(obj, "_device", device) + object.__setattr__(obj, "_ovphysx", MagicMock()) + object.__setattr__(obj, "_root_view", mock_bindings.view) + object.__setattr__(obj, "_bindings", mock_bindings.bindings) + object.__setattr__(obj, "_num_instances", num_instances) + object.__setattr__(obj, "_num_bodies", 1) + object.__setattr__(obj, "_body_names", body_names) + + # Create RigidObjectData + data = OvPhysxRigidObjectData(mock_bindings.view, device) + data.num_instances = num_instances + data.num_bodies = 1 + data._is_primed = True + object.__setattr__(obj, "_data", data) + + # Build the buffers RigidObject normally allocates in _initialize_impl + # (_ALL_INDICES, _ALL_*_MASK, pinned CPU staging buffers, wrench buf). + # _create_buffers also instantiates real WrenchComposers; those get + # replaced with mocks just below. + obj._create_buffers() + + # Replace the real wrench composers with mocks for iface coverage. + mock_inst_wrench = MockWrenchComposer(obj) + mock_perm_wrench = MockWrenchComposer(obj) + object.__setattr__(obj, "_instantaneous_wrench_composer", mock_inst_wrench) + object.__setattr__(obj, "_permanent_wrench_composer", mock_perm_wrench) + + # Prevent __del__ / _clear_callbacks from raising + object.__setattr__(obj, "_initialize_handle", None) + object.__setattr__(obj, "_invalidate_initialize_handle", None) + object.__setattr__(obj, "_prim_deletion_handle", None) + object.__setattr__(obj, "_debug_vis_handle", None) + + return obj, mock_bindings + + +def create_mock_rigid_object( + num_instances: int = 2, + device: str = "cuda:0", +): + from isaaclab.test.mock_interfaces.assets.mock_rigid_object import MockRigidObject + + obj = MockRigidObject( + num_instances=num_instances, + device=device, + ) + return obj, None # No view for mock backend + + +def get_rigid_object( + backend: str, + num_instances: int = 2, + device: str = "cuda:0", +): + if backend == "physx": + return create_physx_rigid_object(num_instances, device) + elif backend == "ovphysx": + return create_ovphysx_rigid_object(num_instances, device) + elif backend == "newton": + return create_newton_rigid_object(num_instances, device) + elif backend.lower() == "mock": + return create_mock_rigid_object(num_instances, device) + else: + raise ValueError(f"Invalid backend: {backend}") 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 e5753943bcb4..ee5f24df8d21 100644 --- a/source/isaaclab/test/assets/test_rigid_object_collection_iface.py +++ b/source/isaaclab/test/assets/test_rigid_object_collection_iface.py @@ -14,330 +14,11 @@ The setup is a bit convoluted so that we can run these tests without requiring Isaac Sim or GPU simulation. """ -"""Launch Isaac Sim Simulator first (when available).""" - -import os -import sys -from unittest.mock import MagicMock - -# When running kitless (e.g., ovphysx backend via run_ovphysx.sh), AppLauncher -# will try to boot Kit and hang. Skip it entirely: run_ovphysx.sh sets -# LD_PRELOAD to the ovphysx libcarb.so, which is the signature of a kitless -# ovphysx run. Also guard the case where neither LD_PRELOAD nor EXP_PATH is -# set (bare Python, no Kit at all). -_kitless = "ovphysx" in os.environ.get("LD_PRELOAD", "") or ( - os.environ.get("LD_PRELOAD", "") == "" and "EXP_PATH" not in os.environ -) - -if not _kitless: - from isaaclab.app import AppLauncher - - simulation_app = AppLauncher(headless=True).app -else: - simulation_app = None - # Stub out the Kit/Omniverse modules that are not present under - # run_ovphysx.sh (pxr, carb, omni, omni.kit[.app] are real on PYTHONPATH). - # ``omni`` is a real namespace package, so missing submodules also need - # to be installed as attributes on it -- ``sys.modules`` alone is not - # enough because attribute access on the real ``omni`` won't fall - # through to ``sys.modules``. - import omni as _omni - - for _mod in ("physics", "physics.tensors", "physx", "timeline", "usd"): - _stub = MagicMock() - sys.modules[f"omni.{_mod}"] = _stub - # Bind the leaf attribute so that ``omni.`` resolves. - setattr(_omni, _mod.split(".", 1)[0], _stub) - for _mod in ("isaacsim.core", "isaacsim.core.simulation_manager"): - sys.modules.setdefault(_mod, MagicMock()) - import numpy as np import pytest import torch import warp as wp - -from isaaclab.assets.rigid_object.rigid_object_cfg import RigidObjectCfg -from isaaclab.assets.rigid_object_collection.rigid_object_collection_cfg import RigidObjectCollectionCfg -from isaaclab.test.mock_interfaces.utils import MockWrenchComposer - -# Mock SimulationManager.get_physics_sim_view() to return a mock object with gravity -_mock_physics_sim_view = MagicMock() -_mock_physics_sim_view.get_gravity.return_value = (0.0, 0.0, -9.81) - -from isaaclab_physx.physics import PhysxManager as SimulationManager - -SimulationManager.get_physics_sim_view = MagicMock(return_value=_mock_physics_sim_view) - -""" -Check which backends are available. -""" - -BACKENDS = ["Mock"] # Mock backend is always available. - -try: - from isaaclab_physx.assets.rigid_object_collection.rigid_object_collection import ( - RigidObjectCollection as PhysXRigidObjectCollection, - ) - from isaaclab_physx.assets.rigid_object_collection.rigid_object_collection_data import ( - RigidObjectCollectionData as PhysXRigidObjectCollectionData, - ) - from isaaclab_physx.test.mock_interfaces.views import MockRigidBodyViewWarp as PhysXMockRigidBodyViewWarp - - BACKENDS.append("physx") -except ImportError: - pass - -try: - from isaaclab_newton.assets.rigid_object_collection.rigid_object_collection import ( - RigidObjectCollection as NewtonRigidObjectCollection, - ) - from isaaclab_newton.assets.rigid_object_collection.rigid_object_collection_data import ( - RigidObjectCollectionData as NewtonRigidObjectCollectionData, - ) - from isaaclab_newton.test.mock_interfaces.mock_newton import MockWrenchComposer as NewtonMockWrenchComposer - from isaaclab_newton.test.mock_interfaces.views import MockNewtonCollectionView as NewtonMockCollectionView - - BACKENDS.append("newton") -except ImportError: - pass - -try: - from isaaclab_ovphysx.assets.rigid_object_collection.rigid_object_collection import ( - RigidObjectCollection as OvPhysxRigidObjectCollection, - ) - from isaaclab_ovphysx.assets.rigid_object_collection.rigid_object_collection_data import ( - RigidObjectCollectionData as OvPhysxRigidObjectCollectionData, - ) - from isaaclab_ovphysx.test.mock_interfaces.views import MockOvPhysxBindingSet - - # Guard against stub implementations (not yet functional). - if not hasattr(OvPhysxRigidObjectCollection, "_create_buffers"): - raise AttributeError("OvPhysxRigidObjectCollection is a stub; skipping ovphysx backend") - - BACKENDS.append("ovphysx") -except (ImportError, AttributeError): - pass - - -def create_physx_rigid_object_collection( - num_instances: int = 2, - num_bodies: int = 3, - device: str = "cuda:0", -): - """Create a test RigidObjectCollection instance with mocked dependencies.""" - collection = object.__new__(PhysXRigidObjectCollection) - - rigid_objects = {f"object_{i}": RigidObjectCfg(prim_path=f"/World/Object_{i}") for i in range(num_bodies)} - collection.cfg = RigidObjectCollectionCfg(rigid_objects=rigid_objects) - - # View count = num_instances * num_bodies (body-major view order) - mock_view = PhysXMockRigidBodyViewWarp( - count=num_instances * num_bodies, - device=device, - ) - 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, "_num_bodies", num_bodies) - object.__setattr__(collection, "_num_instances", num_instances) - object.__setattr__(collection, "_body_names_list", [f"object_{i}" for i in range(num_bodies)]) - - # Create RigidObjectCollectionData instance - data = PhysXRigidObjectCollectionData(mock_view, num_bodies, device) - object.__setattr__(collection, "_data", data) - data.body_names = [f"object_{i}" for i in range(num_bodies)] - - # 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) - - # Prevent __del__ / _clear_callbacks from raising AttributeError - object.__setattr__(collection, "_initialize_handle", None) - object.__setattr__(collection, "_invalidate_initialize_handle", None) - object.__setattr__(collection, "_prim_deletion_handle", None) - object.__setattr__(collection, "_debug_vis_handle", None) - - # Set up index arrays - 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)) - - return collection, mock_view - - -def create_newton_rigid_object_collection( - num_instances: int = 2, - num_bodies: int = 3, - device: str = "cuda:0", -): - """Create a test Newton RigidObjectCollection instance with mocked dependencies.""" - import isaaclab_newton.assets.rigid_object_collection.rigid_object_collection as newton_coll_module - import isaaclab_newton.assets.rigid_object_collection.rigid_object_collection_data as newton_data_module - - body_names = [f"object_{i}" for i in range(num_bodies)] - - # Create collection-specific mock view with (N, B) root shapes - mock_view = NewtonMockCollectionView( - num_envs=num_instances, - num_bodies=num_bodies, - device=device, - body_names=body_names, - ) - mock_view.set_random_mock_data() - mock_view._noop_setters = True - - # Mock NewtonManager (aliased as SimulationManager in Newton modules) - mock_model = MagicMock() - mock_model.gravity = wp.array(np.array([[0.0, 0.0, -9.81]], dtype=np.float32), dtype=wp.vec3f, device=device) - mock_state = MagicMock() - mock_control = MagicMock() - - mock_manager = MagicMock() - mock_manager.get_model.return_value = mock_model - mock_manager.get_state_0.return_value = mock_state - mock_manager.get_state_1.return_value = mock_state - mock_manager.get_control.return_value = mock_control - - # Patch SimulationManager in both data and collection modules - original_data_manager = newton_data_module.SimulationManager - original_coll_manager = newton_coll_module.SimulationManager - newton_data_module.SimulationManager = mock_manager - newton_coll_module.SimulationManager = mock_manager - - try: - data = NewtonRigidObjectCollectionData(mock_view, num_bodies, device) - finally: - newton_data_module.SimulationManager = original_data_manager - newton_coll_module.SimulationManager = original_coll_manager - - # Create collection shell (bypass __init__) - collection = object.__new__(NewtonRigidObjectCollection) - - rigid_objects = {f"object_{i}": RigidObjectCfg(prim_path=f"/World/Object_{i}") for i in range(num_bodies)} - collection.cfg = RigidObjectCollectionCfg(rigid_objects=rigid_objects) - - object.__setattr__(collection, "_root_view", mock_view) - object.__setattr__(collection, "_device", device) - object.__setattr__(collection, "_num_bodies", num_bodies) - object.__setattr__(collection, "_num_instances", num_instances) - object.__setattr__(collection, "_body_names_list", body_names) - object.__setattr__(collection, "_data", data) - data.body_names = body_names - - # Mock wrench composers (Newton-specific) - mock_inst_wrench = NewtonMockWrenchComposer(collection) - mock_perm_wrench = NewtonMockWrenchComposer(collection) - object.__setattr__(collection, "_instantaneous_wrench_composer", mock_inst_wrench) - object.__setattr__(collection, "_permanent_wrench_composer", mock_perm_wrench) - - # Prevent __del__ / _clear_callbacks from raising AttributeError - object.__setattr__(collection, "_initialize_handle", None) - object.__setattr__(collection, "_invalidate_initialize_handle", None) - object.__setattr__(collection, "_prim_deletion_handle", None) - object.__setattr__(collection, "_debug_vis_handle", None) - - # Index arrays (warp) - 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)) - - return collection, mock_view - - -def create_ovphysx_rigid_object_collection( - num_instances: int = 2, - num_bodies: int = 3, - device: str = "cuda:0", -): - """Create a test OVPhysX RigidObjectCollection instance with mocked tensor bindings.""" - body_names = [f"object_{i}" for i in range(num_bodies)] - - collection = object.__new__(OvPhysxRigidObjectCollection) - - rigid_objects = {f"object_{i}": RigidObjectCfg(prim_path=f"/World/Object_{i}") for i in range(num_bodies)} - collection.cfg = RigidObjectCollectionCfg(rigid_objects=rigid_objects) - - # Use articulation-mode bindings with num_joints=0 to get (N, B, ...) shaped tensors. - mock_bindings = MockOvPhysxBindingSet( - num_instances=num_instances, - num_joints=0, - num_bodies=num_bodies, - body_names=body_names, - asset_kind="articulation", - ) - mock_bindings.set_random_data() - - object.__setattr__(collection, "_device", device) - object.__setattr__(collection, "_ovphysx", MagicMock()) - object.__setattr__(collection, "_bindings", mock_bindings.bindings) - object.__setattr__(collection, "_num_instances", num_instances) - object.__setattr__(collection, "_num_bodies", num_bodies) - object.__setattr__(collection, "_body_names_list", body_names) - - # Create RigidObjectCollectionData - data = OvPhysxRigidObjectCollectionData(mock_bindings.bindings, num_bodies, device) - data.num_instances = num_instances - data.num_bodies = num_bodies - data._is_primed = True - object.__setattr__(collection, "_data", data) - - # Allocate the buffers that RigidObjectCollection normally allocates in _initialize_impl. - collection._create_buffers() - - # Replace the real wrench composers with mocks for iface coverage. - 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) - - # Prevent __del__ / _clear_callbacks from raising - object.__setattr__(collection, "_initialize_handle", None) - object.__setattr__(collection, "_invalidate_initialize_handle", None) - object.__setattr__(collection, "_prim_deletion_handle", None) - object.__setattr__(collection, "_debug_vis_handle", None) - - return collection, mock_bindings - - -def create_mock_rigid_object_collection( - num_instances: int = 2, - num_bodies: int = 3, - device: str = "cuda:0", -): - from isaaclab.test.mock_interfaces.assets.mock_rigid_object_collection import MockRigidObjectCollection - - obj = MockRigidObjectCollection( - num_instances=num_instances, - num_bodies=num_bodies, - device=device, - ) - return obj, None - - -def get_rigid_object_collection( - backend: str, - num_instances: int = 2, - num_bodies: int = 3, - device: str = "cuda:0", -): - if backend == "physx": - return create_physx_rigid_object_collection(num_instances, num_bodies, device) - elif backend == "ovphysx": - return create_ovphysx_rigid_object_collection(num_instances, num_bodies, device) - elif backend == "newton": - return create_newton_rigid_object_collection(num_instances, num_bodies, device) - elif backend.lower() == "mock": - return create_mock_rigid_object_collection(num_instances, num_bodies, device) - else: - raise ValueError(f"Invalid backend: {backend}") +from _rigid_object_collection_iface_test_utils import BACKENDS, get_rigid_object_collection @pytest.fixture diff --git a/source/isaaclab/test/assets/test_rigid_object_iface.py b/source/isaaclab/test/assets/test_rigid_object_iface.py index 772130149ee3..c934def4bf36 100644 --- a/source/isaaclab/test/assets/test_rigid_object_iface.py +++ b/source/isaaclab/test/assets/test_rigid_object_iface.py @@ -13,318 +13,11 @@ The setup is a bit convoluted so that we can run these tests without requiring Isaac Sim or GPU simulation. """ -"""Launch Isaac Sim Simulator first (when available).""" - -import os -import sys -from unittest.mock import MagicMock - -# When running kitless (e.g., ovphysx backend via run_ovphysx.sh), AppLauncher -# will try to boot Kit and hang. Skip it entirely: run_ovphysx.sh sets -# LD_PRELOAD to the ovphysx libcarb.so, which is the signature of a kitless -# ovphysx run. Also guard the case where neither LD_PRELOAD nor EXP_PATH is -# set (bare Python, no Kit at all). -_kitless = "ovphysx" in os.environ.get("LD_PRELOAD", "") or ( - os.environ.get("LD_PRELOAD", "") == "" and "EXP_PATH" not in os.environ -) - -if not _kitless: - from isaaclab.app import AppLauncher - - simulation_app = AppLauncher(headless=True).app -else: - simulation_app = None - # Stub out the Kit/Omniverse modules that are not present under - # run_ovphysx.sh (pxr, carb, omni, omni.kit[.app] are real on PYTHONPATH). - # ``omni`` is a real namespace package, so missing submodules also need - # to be installed as attributes on it -- ``sys.modules`` alone is not - # enough because attribute access on the real ``omni`` won't fall - # through to ``sys.modules``. - import omni as _omni - - for _mod in ("physics", "physics.tensors", "physx", "timeline", "usd"): - _stub = MagicMock() - sys.modules[f"omni.{_mod}"] = _stub - # Bind the leaf attribute so that ``omni.`` resolves. - setattr(_omni, _mod.split(".", 1)[0], _stub) - for _mod in ("isaacsim.core", "isaacsim.core.simulation_manager"): - sys.modules.setdefault(_mod, MagicMock()) - import numpy as np import pytest import torch import warp as wp - -from isaaclab.assets.rigid_object.rigid_object_cfg import RigidObjectCfg -from isaaclab.test.mock_interfaces.utils import MockWrenchComposer - -# Mock SimulationManager.get_physics_sim_view() to return a mock object with gravity -# This is needed because the Data classes call SimulationManager.get_physics_sim_view().get_gravity() -# but there's no actual physics scene when running unit tests -_mock_physics_sim_view = MagicMock() -_mock_physics_sim_view.get_gravity.return_value = (0.0, 0.0, -9.81) - -from isaaclab_physx.physics import PhysxManager as SimulationManager - -SimulationManager.get_physics_sim_view = MagicMock(return_value=_mock_physics_sim_view) - -""" -Check which backends are available. -""" - -BACKENDS = ["Mock"] # Mock backend is always available. - -try: - from isaaclab_physx.assets.rigid_object.rigid_object import RigidObject as PhysXRigidObject - from isaaclab_physx.assets.rigid_object.rigid_object_data import RigidObjectData as PhysXRigidObjectData - from isaaclab_physx.test.mock_interfaces.views import MockRigidBodyViewWarp as PhysXMockRigidBodyViewWarp - - BACKENDS.append("physx") -except ImportError: - pass - -try: - from isaaclab_newton.assets.rigid_object.rigid_object import RigidObject as NewtonRigidObject - from isaaclab_newton.assets.rigid_object.rigid_object_data import RigidObjectData as NewtonRigidObjectData - from isaaclab_newton.test.mock_interfaces.views import MockNewtonArticulationView as NewtonMockArticulationView - - BACKENDS.append("newton") -except ImportError: - pass - -try: - from isaaclab_ovphysx.assets.rigid_object.rigid_object import RigidObject as OvPhysxRigidObject - from isaaclab_ovphysx.assets.rigid_object.rigid_object_data import RigidObjectData as OvPhysxRigidObjectData - from isaaclab_ovphysx.test.mock_interfaces.views import MockOvPhysxBindingSet - - BACKENDS.append("ovphysx") -except (ImportError, AttributeError): - pass - - -def create_physx_rigid_object( - num_instances: int = 2, - device: str = "cuda:0", -): - """Create a test RigidObject instance with mocked dependencies.""" - body_names = ["body_0"] - - rigid_object = object.__new__(PhysXRigidObject) - - rigid_object.cfg = RigidObjectCfg(prim_path="/World/Object") - - # Create PhysX mock view - mock_view = PhysXMockRigidBodyViewWarp( - count=num_instances, - device=device, - ) - 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) - - # Create RigidObjectData instance (SimulationManager already mocked at module level) - data = PhysXRigidObjectData(mock_view, device) - object.__setattr__(rigid_object, "_data", data) - - # Set body names on data - data.body_names = body_names - - # 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) - - # Prevent __del__ / _clear_callbacks from raising AttributeError - object.__setattr__(rigid_object, "_initialize_handle", None) - object.__setattr__(rigid_object, "_invalidate_initialize_handle", None) - object.__setattr__(rigid_object, "_prim_deletion_handle", None) - object.__setattr__(rigid_object, "_debug_vis_handle", None) - - # Set up index arrays (warp arrays for 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 - - -def create_newton_rigid_object( - num_instances: int = 2, - device: str = "cuda:0", -): - """Create a test Newton RigidObject instance with mocked dependencies.""" - import isaaclab_newton.assets.rigid_object.rigid_object_data as newton_data_module - - body_names = ["body_0"] - - # Create Newton mock view (uses ArticulationView with num_bodies=1 for rigid objects) - mock_view = NewtonMockArticulationView( - num_instances=num_instances, - num_bodies=1, - num_joints=0, - device=device, - is_fixed_base=False, - joint_names=[], - body_names=body_names, - ) - mock_view.set_random_mock_data() - mock_view._noop_setters = True - - # Mock NewtonManager (aliased as SimulationManager in Newton modules) - mock_model = MagicMock() - mock_model.gravity = wp.array(np.array([[0.0, 0.0, -9.81]], dtype=np.float32), dtype=wp.vec3f, device=device) - mock_state = MagicMock() - mock_control = MagicMock() - - mock_manager = MagicMock() - mock_manager.get_model.return_value = mock_model - mock_manager.get_state_0.return_value = mock_state - mock_manager.get_state_1.return_value = mock_state - mock_manager.get_control.return_value = mock_control - - # Patch SimulationManager in the Newton data module - original_sim_manager = newton_data_module.SimulationManager - newton_data_module.SimulationManager = mock_manager - - try: - data = NewtonRigidObjectData(mock_view, device) - finally: - newton_data_module.SimulationManager = original_sim_manager - - # Create RigidObject shell (bypass __init__) - rigid_object = object.__new__(NewtonRigidObject) - - rigid_object.cfg = RigidObjectCfg(prim_path="/World/Object") - - object.__setattr__(rigid_object, "_root_view", mock_view) - object.__setattr__(rigid_object, "_device", device) - object.__setattr__(rigid_object, "_data", data) - - # 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) - - # Prevent __del__ / _clear_callbacks from raising AttributeError - object.__setattr__(rigid_object, "_initialize_handle", None) - object.__setattr__(rigid_object, "_invalidate_initialize_handle", None) - object.__setattr__(rigid_object, "_prim_deletion_handle", None) - object.__setattr__(rigid_object, "_debug_vis_handle", None) - - # Newton uses wp.array for indices - 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)) - - # Newton uses wp.bool masks - 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((1,), dtype=wp.bool, device=device)) - - return rigid_object, mock_view - - -def create_ovphysx_rigid_object( - num_instances: int = 2, - device: str = "cuda:0", -): - """Create a test OvPhysX RigidObject instance with mocked tensor bindings.""" - body_names = ["base_link"] - - obj = object.__new__(OvPhysxRigidObject) - - obj.cfg = RigidObjectCfg(prim_path="/World/object") - - # Create mock binding set - mock_bindings = MockOvPhysxBindingSet( - num_instances=num_instances, - num_joints=0, - num_bodies=1, - body_names=body_names, - asset_kind="rigid_object", - ) - mock_bindings.set_random_data() - - object.__setattr__(obj, "_device", device) - object.__setattr__(obj, "_ovphysx", MagicMock()) - object.__setattr__(obj, "_bindings", mock_bindings.bindings) - object.__setattr__(obj, "_num_instances", num_instances) - object.__setattr__(obj, "_num_bodies", 1) - object.__setattr__(obj, "_body_names", body_names) - - # Create RigidObjectData - data = OvPhysxRigidObjectData(mock_bindings.bindings, device) - data.num_instances = num_instances - data.num_bodies = 1 - data._is_primed = True - object.__setattr__(obj, "_data", data) - - # Build the buffers RigidObject normally allocates in _initialize_impl - # (_ALL_INDICES, _ALL_*_MASK, pinned CPU staging buffers, wrench buf). - # _create_buffers also instantiates real WrenchComposers; those get - # replaced with mocks just below. - obj._create_buffers() - - # Replace the real wrench composers with mocks for iface coverage. - mock_inst_wrench = MockWrenchComposer(obj) - mock_perm_wrench = MockWrenchComposer(obj) - object.__setattr__(obj, "_instantaneous_wrench_composer", mock_inst_wrench) - object.__setattr__(obj, "_permanent_wrench_composer", mock_perm_wrench) - - # Prevent __del__ / _clear_callbacks from raising - object.__setattr__(obj, "_initialize_handle", None) - object.__setattr__(obj, "_invalidate_initialize_handle", None) - object.__setattr__(obj, "_prim_deletion_handle", None) - object.__setattr__(obj, "_debug_vis_handle", None) - - return obj, mock_bindings - - -def create_mock_rigid_object( - num_instances: int = 2, - device: str = "cuda:0", -): - from isaaclab.test.mock_interfaces.assets.mock_rigid_object import MockRigidObject - - obj = MockRigidObject( - num_instances=num_instances, - device=device, - ) - return obj, None # No view for mock backend - - -def get_rigid_object( - backend: str, - num_instances: int = 2, - device: str = "cuda:0", -): - if backend == "physx": - return create_physx_rigid_object(num_instances, device) - elif backend == "ovphysx": - return create_ovphysx_rigid_object(num_instances, device) - elif backend == "newton": - return create_newton_rigid_object(num_instances, device) - elif backend.lower() == "mock": - return create_mock_rigid_object(num_instances, device) - else: - raise ValueError(f"Invalid backend: {backend}") +from _rigid_object_iface_test_utils import BACKENDS, get_rigid_object @pytest.fixture diff --git a/source/isaaclab/test/benchmark/test_benchmark_core.py b/source/isaaclab/test/benchmark/test_benchmark_core.py index d679880d82ce..03bba5bcb0e8 100644 --- a/source/isaaclab/test/benchmark/test_benchmark_core.py +++ b/source/isaaclab/test/benchmark/test_benchmark_core.py @@ -3,321 +3,269 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Unit tests for BaseIsaacLabBenchmark class.""" +"""Tests for BaseIsaacLabBenchmark.""" import json import os -import tempfile +from dataclasses import replace import pytest -from isaaclab.test.benchmark import backends +from isaaclab.test.benchmark import formatters from isaaclab.test.benchmark.benchmark_core import BaseIsaacLabBenchmark from isaaclab.test.benchmark.measurements import SingleMeasurement, StringMetadata -# ============================================================================== -# BaseIsaacLabBenchmark Tests -# ============================================================================== +@pytest.fixture(autouse=True) +def reset_formatters(): + formatters.MetricsFormatter.reset_instances() + yield + formatters.MetricsFormatter.reset_instances() + + +def _minimal_runtime_bundle(): + from isaaclab.test.benchmark.schema import ( + GpuDeviceInfo, + Hardware, + MeanStd, + Resources, + RunConfig, + RunIdentity, + Runtime, + RuntimeBundle, + StartupTime, + Versions, + ) + + return RuntimeBundle( + run=RunIdentity( + run_id="runtime_newton_mjwarp_Isaac-Ant-Direct-v0_20260422-131500_seed42", + framework=None, + config=RunConfig(physics_backend="newton_mjwarp", rendering_backend="none"), + task="Isaac-Ant-Direct-v0", + seed=42, + start_time_utc="2026-04-22T13:15:00Z", + end_time_utc="2026-04-22T13:15:10Z", + duration_s=10.0, + status="completed", + num_envs=16, + ), + versions=Versions( + isaaclab="4.6.8", + isaacsim=None, + kit=None, + newton=None, + warp=None, + mjwarp=None, + torch="2.5.1", + rsl_rl=None, + rl_games=None, + skrl=None, + sb3=None, + git_commit=None, + git_branch=None, + git_dirty=False, + ), + hardware=Hardware( + hostname="benchmark-host", + gpu_devices=[GpuDeviceInfo(name="NVIDIA H100 80GB", mem_gb=80.0, compute_cap="9.0")], + cpu_name="AMD EPYC 7763", + cpu_count=64, + ram_gb=512.0, + ), + runtime=Runtime( + startup_time_s=StartupTime(app_launch=1.0, env_creation=2.0, first_step=0.5), + iterations_completed=1, + total_wall_time_s=4.0, + steps_per_iteration=24, + iteration_time_s=MeanStd(mean=1.0, std=0.0), + collection_fps=MeanStd(mean=100.0, std=0.0), + total_fps=MeanStd(mean=100.0, std=0.0), + iterations_per_s=MeanStd(mean=1.0, std=0.0), + ), + resources=Resources( + gpu_util_pct=MeanStd(mean=80.0, std=5.0), + gpu_mem_gb=MeanStd(mean=10.0, std=0.5, peak=12.0), + cpu_util_pct=MeanStd(mean=30.0, std=4.0), + ram_gb=MeanStd(mean=20.0, std=1.0, peak=24.0), + ), + ) + + +def _minimal_training_bundle(): + from isaaclab.test.benchmark.schema import Learning, LearningCurve, TrainingBundle + + bundle = _minimal_runtime_bundle() + return TrainingBundle( + run=replace(bundle.run, framework="rsl_rl", max_iterations=1), + versions=bundle.versions, + hardware=bundle.hardware, + runtime=bundle.runtime, + resources=bundle.resources, + learning=Learning( + ema_alpha=0.95, + reward=LearningCurve(final_raw=3.0, final_ema=2.5, series_per_iter=[1.0, 3.0]), + ep_length=LearningCurve(final_raw=20.0, final_ema=18.0, series_per_iter=[10.0, 20.0]), + ), + success_rate=0.75, + ) + + +def _minimal_startup_bundle(): + from isaaclab.test.benchmark.schema import CProfileFunction, StartupBundle, StartupConfig, StartupPhase + + bundle = _minimal_runtime_bundle() + return StartupBundle( + run=replace(bundle.run, num_envs=None), + versions=bundle.versions, + hardware=bundle.hardware, + phases={ + "python_imports": StartupPhase( + total_time_s=0.25, + top_functions=[ + CProfileFunction( + name="isaaclab_tasks.utils:import_packages", own_time_s=0.1, cum_time_s=0.2, calls=2 + ) + ], + ) + }, + config=StartupConfig(top_n=1, whitelist=None), + ) -class TestBaseIsaacLabBenchmark: - """Tests for BaseIsaacLabBenchmark.""" - @pytest.fixture - def temp_output_dir(self): - """Create a temporary output directory.""" - with tempfile.TemporaryDirectory() as tmpdir: - yield tmpdir +def _formatter_keys(benchmark: BaseIsaacLabBenchmark) -> list[str]: + return [key for key, _ in benchmark._metrics] - @pytest.fixture(autouse=True) - def reset_backends(self): - """Reset backend instances before each test.""" - backends.MetricsBackend.reset_instances() - yield - backends.MetricsBackend.reset_instances() - def test_initialization(self, temp_output_dir): - """Test benchmark initializes correctly.""" - benchmark = BaseIsaacLabBenchmark( - benchmark_name="test_benchmark", - backend_type="omniperf", - output_path=temp_output_dir, - use_recorders=False, - output_prefix="test", - ) - assert benchmark.benchmark_name == "test_benchmark" - assert benchmark.output_path == temp_output_dir - assert "test_" in benchmark.output_prefix - - def test_initialization_creates_output_dir(self): - """Test that initialization creates output directory if it doesn't exist.""" - with tempfile.TemporaryDirectory() as tmpdir: - output_path = os.path.join(tmpdir, "nested", "output") - _benchmark = BaseIsaacLabBenchmark( # noqa: F841 - benchmark_name="test_benchmark", - backend_type="omniperf", - output_path=output_path, - use_recorders=False, - ) - assert os.path.exists(output_path) +def test_benchmark_collects_metadata_measurements_and_writes_json(tmp_path): + output_path = tmp_path / "nested" / "output" + benchmark = BaseIsaacLabBenchmark( + benchmark_name="my_workflow", + formatter_type="omniperf", + output_path=str(output_path), + use_recorders=False, + output_prefix="test", + ) - def test_initialization_with_recorders(self, temp_output_dir): - """Test benchmark initializes with recorders enabled.""" - benchmark = BaseIsaacLabBenchmark( - benchmark_name="test_benchmark", - backend_type="omniperf", - output_path=temp_output_dir, - use_recorders=True, - ) - assert benchmark._use_recorders is True - assert "CPUInfo" in benchmark._manual_recorders - assert "GPUInfo" in benchmark._manual_recorders - assert "MemoryInfo" in benchmark._manual_recorders - assert "VersionInfo" in benchmark._manual_recorders - - def test_initialization_without_recorders(self, temp_output_dir): - """Test benchmark initializes with recorders disabled.""" - benchmark = BaseIsaacLabBenchmark( - benchmark_name="test_benchmark", - backend_type="omniperf", - output_path=temp_output_dir, - use_recorders=False, - ) - assert benchmark._use_recorders is False - assert not hasattr(benchmark, "_manual_recorders") or benchmark._manual_recorders is None - - def test_add_measurement(self, temp_output_dir): - """Test adding measurements to phases.""" - benchmark = BaseIsaacLabBenchmark( - benchmark_name="test_benchmark", - backend_type="omniperf", - output_path=temp_output_dir, - use_recorders=False, - ) - measurement = SingleMeasurement(name="test_metric", value=42.0, unit="ms") - benchmark.add_measurement("test_phase", measurement=measurement) - assert "test_phase" in benchmark._phases - assert len(benchmark._phases["test_phase"].measurements) == 1 - assert benchmark._phases["test_phase"].measurements[0].name == "test_metric" - - def test_add_multiple_measurements(self, temp_output_dir): - """Test adding multiple measurements to a phase.""" - benchmark = BaseIsaacLabBenchmark( - benchmark_name="test_benchmark", - backend_type="omniperf", - output_path=temp_output_dir, - use_recorders=False, - ) - measurements = [ + benchmark.add_measurement( + "runtime", + measurement=[ SingleMeasurement(name="metric1", value=10.0, unit="ms"), SingleMeasurement(name="metric2", value=20.0, unit="ms"), - ] - benchmark.add_measurement("test_phase", measurement=measurements) - assert len(benchmark._phases["test_phase"].measurements) == 2 - - def test_add_metadata(self, temp_output_dir): - """Test adding metadata to phases.""" + ], + ) + benchmark.add_measurement("runtime", metadata=StringMetadata(name="custom", data="value")) + benchmark._finalize_impl() + + with open(benchmark.output_file_path) as f: + data = json.load(f) + + assert output_path.exists() + assert benchmark.benchmark_name == "my_workflow" + assert benchmark._use_recorders is False + assert not hasattr(benchmark, "_manual_recorders") or benchmark._manual_recorders is None + assert data["benchmark_info"]["workflow_name"] == "my_workflow" + assert "timestamp" in data["benchmark_info"] + assert data["runtime"]["metric1"] == 10.0 + assert data["runtime"]["metric2"] == 20.0 + assert data["runtime"]["custom"] == "value" + + +def test_benchmark_updates_recorders_and_cleans_up(tmp_path): + benchmark = BaseIsaacLabBenchmark( + benchmark_name="test_benchmark", + formatter_type="omniperf", + output_path=str(tmp_path), + use_recorders=True, + output_prefix="test", + ) + + benchmark.add_measurement("runtime", measurement=SingleMeasurement(name="execution_time", value=100.5, unit="ms")) + benchmark.update_manual_recorders() + assert benchmark._manual_recorders["CPUInfo"]._n >= 1 + assert benchmark._manual_recorders["MemoryInfo"]._rss_n >= 1 + + benchmark._finalize_impl() + assert os.path.exists(benchmark.output_file_path) + assert benchmark._manual_recorders is None + assert benchmark._frametime_recorders is None + + +def test_formatter_selection_and_output_filenames(tmp_path): + default_benchmark = BaseIsaacLabBenchmark( + "default", formatter_type=" ", output_path=str(tmp_path), use_recorders=False + ) + assert _formatter_keys(default_benchmark) == ["omniperf"] + + single = BaseIsaacLabBenchmark("single", formatter_type="json", output_path=str(tmp_path), use_recorders=False) + single.add_measurement("runtime", measurement=SingleMeasurement(name="execution_time", value=100.5, unit="ms")) + single._finalize_impl() + assert os.path.exists(os.path.join(str(tmp_path), f"{single.output_prefix}.json")) + assert not os.path.exists(os.path.join(str(tmp_path), f"{single.output_prefix}_json.json")) + + multi = BaseIsaacLabBenchmark( + "multi", + formatter_type="schema,json,json", + output_path=str(tmp_path), + use_recorders=False, + output_prefix="test", + ) + assert _formatter_keys(multi) == ["schema", "json"] + multi.attach_bundle(_minimal_runtime_bundle()) + multi.add_measurement("runtime", measurement=SingleMeasurement(name="execution_time", value=100.5, unit="ms")) + multi._finalize_impl() + + schema_path = os.path.join(str(tmp_path), f"{multi.output_prefix}_schema.json") + json_path = os.path.join(str(tmp_path), f"{multi.output_prefix}_json.json") + assert os.path.exists(schema_path) + assert os.path.exists(json_path) + + with open(schema_path) as f: + schema_data = json.load(f) + with open(json_path) as f: + json_data = json.load(f) + assert schema_data != json_data + assert schema_data["run"]["task"] == "Isaac-Ant-Direct-v0" + + +def test_attached_bundles_are_projected_to_flat_formatters(tmp_path): + cases = [ + (_minimal_runtime_bundle(), "runtime", "Mean Total FPS", 100.0), + (_minimal_training_bundle(), "train", "Last Reward", 3.0), + (_minimal_startup_bundle(), "python_imports", "Wall Clock Time", 0.25), + ] + + for index, (bundle, phase, metric, expected) in enumerate(cases): benchmark = BaseIsaacLabBenchmark( - benchmark_name="test_benchmark", - backend_type="omniperf", - output_path=temp_output_dir, + f"bundle_{index}", + formatter_type="omniperf", + output_path=str(tmp_path), use_recorders=False, + output_prefix=f"bundle_{index}", ) - metadata = StringMetadata(name="test_key", data="test_value") - benchmark.add_measurement("test_phase", metadata=metadata) - assert "test_phase" in benchmark._phases - # Phase metadata includes automatic "phase" and "workflow_name" entries plus our custom one - assert len(benchmark._phases["test_phase"].metadata) == 3 - metadata_names = [m.name for m in benchmark._phases["test_phase"].metadata] - assert "test_key" in metadata_names - assert "phase" in metadata_names - assert "workflow_name" in metadata_names - - def test_update_manual_recorders(self, temp_output_dir): - """Test updating manual recorders.""" - benchmark = BaseIsaacLabBenchmark( - benchmark_name="test_benchmark", - backend_type="omniperf", - output_path=temp_output_dir, - use_recorders=True, - ) - # Should not raise - benchmark.update_manual_recorders() - # Check recorders were updated - CPUInfoRecorder has _n attribute - assert benchmark._manual_recorders["CPUInfo"]._n >= 1 - assert benchmark._manual_recorders["MemoryInfo"]._rss_n >= 1 - - def test_update_manual_recorders_disabled(self, temp_output_dir): - """Test that update_manual_recorders is a no-op when recorders are disabled.""" - benchmark = BaseIsaacLabBenchmark( - benchmark_name="test_benchmark", - backend_type="omniperf", - output_path=temp_output_dir, - use_recorders=False, - ) - # Should not raise - benchmark.update_manual_recorders() - - def test_finalize_generates_output(self, temp_output_dir): - """Test that finalize creates output file.""" - benchmark = BaseIsaacLabBenchmark( - benchmark_name="test_benchmark", - backend_type="omniperf", - output_path=temp_output_dir, - use_recorders=True, - output_prefix="test", - ) - benchmark.add_measurement( - "runtime", measurement=SingleMeasurement(name="execution_time", value=100.5, unit="ms") - ) - benchmark.update_manual_recorders() + benchmark.attach_bundle(bundle) benchmark._finalize_impl() - # Check output file exists - assert os.path.exists(benchmark.output_file_path) - - def test_finalize_output_contains_measurements(self, temp_output_dir): - """Test that finalized output contains added measurements.""" - benchmark = BaseIsaacLabBenchmark( - benchmark_name="test_benchmark", - backend_type="omniperf", - output_path=temp_output_dir, - use_recorders=False, - output_prefix="test", - ) - benchmark.add_measurement( - "runtime", measurement=SingleMeasurement(name="execution_time", value=100.5, unit="ms") - ) - benchmark._finalize_impl() - - # Read and verify output with open(benchmark.output_file_path) as f: data = json.load(f) - - # Check that runtime phase is present with our measurement - assert "runtime" in data - assert "execution_time" in data["runtime"] - assert data["runtime"]["execution_time"] == 100.5 - - def test_finalize_cleans_up_recorders(self, temp_output_dir): - """Test that finalize cleans up recorders.""" - benchmark = BaseIsaacLabBenchmark( - benchmark_name="test_benchmark", - backend_type="omniperf", - output_path=temp_output_dir, - use_recorders=True, - output_prefix="test", - ) - benchmark.add_measurement( - "runtime", measurement=SingleMeasurement(name="execution_time", value=100.5, unit="ms") - ) - benchmark.update_manual_recorders() - benchmark._finalize_impl() - - # Recorders should be set to None - assert benchmark._manual_recorders is None - assert benchmark._frametime_recorders is None - - def test_workflow_metadata_in_output(self, temp_output_dir): - """Test that workflow name and timestamp metadata are in output.""" - benchmark = BaseIsaacLabBenchmark( - benchmark_name="my_workflow", - backend_type="omniperf", - output_path=temp_output_dir, - use_recorders=False, - output_prefix="test", - ) - benchmark._finalize_impl() - - with open(benchmark.output_file_path) as f: - data = json.load(f) - - # Check benchmark_info phase has workflow metadata - assert "benchmark_info" in data - assert "workflow_name" in data["benchmark_info"] - assert data["benchmark_info"]["workflow_name"] == "my_workflow" - assert "timestamp" in data["benchmark_info"] - - -# ============================================================================== -# MetricsBackend Factory Tests -# ============================================================================== - - -class TestMetricsBackendFactory: - """Tests for MetricsBackend factory class.""" - - @pytest.fixture - def temp_output_dir(self): - """Create a temporary output directory.""" - with tempfile.TemporaryDirectory() as tmpdir: - yield tmpdir - - @pytest.fixture(autouse=True) - def reset_backends(self): - """Reset backend instances before each test.""" - backends.MetricsBackend.reset_instances() - yield - backends.MetricsBackend.reset_instances() - - def test_get_json_backend(self): - """Test getting JSON backend instance.""" - backend = backends.MetricsBackend.get_instance("json") - assert isinstance(backend, backends.JSONFileMetrics) - - def test_get_osmo_backend(self): - """Test getting Osmo backend instance.""" - backend = backends.MetricsBackend.get_instance("osmo") - assert isinstance(backend, backends.OsmoKPIFile) - - def test_get_omniperf_backend(self): - """Test getting OmniPerf backend instance.""" - backend = backends.MetricsBackend.get_instance("omniperf") - assert isinstance(backend, backends.OmniPerfKPIFile) - - def test_get_summary_backend(self): - """Test getting Summary backend instance.""" - backend = backends.MetricsBackend.get_instance("summary") - assert isinstance(backend, backends.SummaryMetrics) - - def test_summary_backend_finalize_writes_json(self, temp_output_dir): - """Test that SummaryMetrics finalize writes JSON output (and does not raise).""" - backend = backends.MetricsBackend.get_instance("summary") - from isaaclab.test.benchmark.measurements import StringMetadata, TestPhase - - phase = TestPhase(phase_name="runtime") - phase.measurements.append(SingleMeasurement(name="Test FPS", value=60.0, unit="FPS")) - phase.metadata.append(StringMetadata(name="runtime workflow_name", data="summary_test")) - phase.metadata.append(StringMetadata(name="runtime phase", data="runtime")) - backend.add_metrics(phase) - output_path = temp_output_dir - output_filename = "summary_test" - backend.finalize(output_path, output_filename) - expected_path = os.path.join(output_path, f"{output_filename}.json") - assert os.path.exists(expected_path) - with open(expected_path) as f: - data = json.load(f) - assert isinstance(data, list) and len(data) >= 1 - assert any(p.get("phase_name") == "runtime" for p in data) - - def test_invalid_backend_type_raises_error(self): - """Test that invalid backend type raises ValueError.""" - with pytest.raises(ValueError, match="Unknown backend type"): - backends.MetricsBackend.get_instance("invalid_type") - - def test_backend_instance_is_cached(self): - """Test that backend instances are cached and reused.""" - backend1 = backends.MetricsBackend.get_instance("omniperf") - backend2 = backends.MetricsBackend.get_instance("omniperf") - assert backend1 is backend2 - - def test_reset_instances(self): - """Test that reset_instances clears the cache.""" - backend1 = backends.MetricsBackend.get_instance("omniperf") - backends.MetricsBackend.reset_instances() - backend2 = backends.MetricsBackend.get_instance("omniperf") - assert backend1 is not backend2 - - -if __name__ == "__main__": - pytest.main([__file__, "-v", "--maxfail=1"]) + assert data[phase][metric] == expected + + +def test_metrics_formatter_factory_registration_cache_and_errors(): + expected = { + "json": formatters.JSONFileMetrics, + "osmo": formatters.OsmoKPIFile, + "omniperf": formatters.OmniPerfKPIFile, + "summary": formatters.SummaryMetrics, + "schema": formatters.SchemaBundleFile, + } + for key, cls in expected.items(): + assert isinstance(formatters.MetricsFormatter.get_instance(key), cls) + + formatter = formatters.MetricsFormatter.get_instance("omniperf") + assert formatter is formatters.MetricsFormatter.get_instance("omniperf") + formatters.MetricsFormatter.reset_instances() + assert formatter is not formatters.MetricsFormatter.get_instance("omniperf") + + with pytest.raises(ValueError, match="Unknown formatter type"): + formatters.MetricsFormatter.get_instance("invalid_type") diff --git a/source/isaaclab/test/benchmark/test_builders.py b/source/isaaclab/test/benchmark/test_builders.py new file mode 100644 index 000000000000..1555c55e5652 --- /dev/null +++ b/source/isaaclab/test/benchmark/test_builders.py @@ -0,0 +1,182 @@ +# 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 benchmark bundle builders (Isaac-Sim-free).""" + +import json +import os + +import pytest + +from isaaclab.test.benchmark import builders +from isaaclab.test.benchmark.schema import ( + GpuDeviceInfo, + Hardware, + MeanStd, + Resources, + RuntimeBundle, + StartupTime, + TrainingBundle, + Versions, +) +from isaaclab.test.benchmark.serialize import write_bundle_file + + +def _versions(): + return Versions( + isaaclab="4.6.8", + isaacsim=None, + kit=None, + newton=None, + warp=None, + mjwarp=None, + torch="2.5.1", + rsl_rl=None, + rl_games=None, + skrl=None, + sb3=None, + git_commit=None, + git_branch=None, + git_dirty=False, + ) + + +def _hardware(): + return Hardware( + hostname="h", + gpu_devices=[GpuDeviceInfo(name="g", mem_gb=80.0, compute_cap="9.0")], + cpu_name="c", + cpu_count=64, + ram_gb=512.0, + ) + + +def _resources(): + return Resources( + gpu_util_pct=MeanStd(80.0, 5.0), + gpu_mem_gb=MeanStd(10.0, 0.5, 12.0), + cpu_util_pct=MeanStd(30.0, 4.0), + ram_gb=MeanStd(20.0, 1.0, 24.0), + ) + + +def test_run_config_presets_default_empty(): + assert builders.build_run_config("physx").presets == [] + assert builders.build_run_config("newton_mjwarp", presets=["rgb"]).presets == ["rgb"] + + +def test_run_identity_computes_duration(): + run = builders.build_run_identity( + run_id="x", + framework="rsl_rl", + config=builders.build_run_config("newton_mjwarp"), + task="t", + seed=0, + start_utc="2026-04-22T13:15:00+00:00", + end_utc="2026-04-22T13:15:30+00:00", + num_envs=4096, + max_iterations=500, + ) + assert run.duration_s == pytest.approx(30.0) + + +def test_build_runtime_aggregates(): + rt = builders.build_runtime( + startup_time_s=StartupTime(app_launch=1.0, env_creation=2.0, first_step=0.5), + iteration_times_s=[1.0, 1.0, 2.0], + collection_fps=[100.0, 110.0], + total_fps=[90.0, 95.0], + steps_per_iteration=24, + ) + assert rt.iterations_completed == 3 + assert rt.total_wall_time_s == pytest.approx(4.0) + assert rt.total_fps.peak == pytest.approx(95.0) + assert rt.iterations_per_s.mean > 0 + + +def test_build_training_bundle_round_trips(tmp_path): + run = builders.build_run_identity( + run_id="x", + framework="rsl_rl", + config=builders.build_run_config("physx"), + task="t", + seed=0, + start_utc="2026-04-22T13:15:00+00:00", + end_utc="2026-04-22T13:16:00+00:00", + num_envs=16, + max_iterations=5, + ) + rt = builders.build_runtime( + startup_time_s=StartupTime(1.0, 2.0, 0.5), + iteration_times_s=[1.0, 1.0], + collection_fps=[100.0], + total_fps=[90.0], + steps_per_iteration=24, + ) + learning = builders.build_learning(reward_series=[1.0, 2.0, 3.0], ep_length_series=[10.0, 12.0], ema_alpha=0.1) + b = builders.build_training_bundle( + run=run, + versions=_versions(), + hardware=_hardware(), + runtime=rt, + resources=_resources(), + learning=learning, + success_rate=0.9, + checkpoint_path="m.pt", + ) + assert isinstance(b, TrainingBundle) + assert b.learning.reward.final_raw == pytest.approx(3.0) + p = os.path.join(tmp_path, "training.json") + write_bundle_file(b, p) + with open(p) as fh: + data = json.load(fh) + assert data["success_rate"] == pytest.approx(0.9) + assert data["runtime"]["total_fps"]["mean"] == pytest.approx(90.0) + + +def test_build_learning_empty_series(): + learning = builders.build_learning(reward_series=[], ep_length_series=[], ema_alpha=0.1) + assert learning.reward.final_raw == pytest.approx(0.0) + assert learning.reward.final_ema == pytest.approx(0.0) + assert learning.reward.series_per_iter == [] + assert learning.ep_length.final_raw == pytest.approx(0.0) + assert learning.ep_length.final_ema == pytest.approx(0.0) + assert learning.ep_length.series_per_iter == [] + + +def test_build_learning_keep_series_false(): + learning = builders.build_learning( + reward_series=[1.0, 2.0], ep_length_series=[10.0], ema_alpha=0.1, keep_series=False + ) + assert learning.reward.series_per_iter is None + assert learning.ep_length.series_per_iter is None + + +def test_build_runtime_bundle_no_learning(tmp_path): + run = builders.build_run_identity( + run_id="x", + framework=None, + config=builders.build_run_config("newton_mjwarp"), + task="t", + seed=0, + start_utc="2026-04-22T13:15:00+00:00", + end_utc="2026-04-22T13:15:10+00:00", + num_envs=16, + ) + rt = builders.build_runtime( + startup_time_s=StartupTime(1.0, 2.0, 0.5), + iteration_times_s=[1.0], + collection_fps=[100.0], + total_fps=[100.0], + steps_per_iteration=24, + ) + b = builders.build_runtime_bundle( + run=run, versions=_versions(), hardware=_hardware(), runtime=rt, resources=_resources() + ) + assert isinstance(b, RuntimeBundle) + p = os.path.join(tmp_path, "runtime.json") + write_bundle_file(b, p) + with open(p) as fh: + assert json.load(fh)["run"]["framework"] is None diff --git a/source/isaaclab/test/benchmark/test_capture.py b/source/isaaclab/test/benchmark/test_capture.py new file mode 100644 index 000000000000..01c3ba794a7e --- /dev/null +++ b/source/isaaclab/test/benchmark/test_capture.py @@ -0,0 +1,312 @@ +# 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 benchmark capture helpers (Isaac-Sim-free, fake recorders).""" + +from types import SimpleNamespace +from typing import Literal + +import pytest + +import isaaclab.test.benchmark.capture as capture +from isaaclab.test.benchmark.capture import ( + capture_hardware, + capture_resources, + capture_versions, + run_config_from_presets, + synth_run_id, +) +from isaaclab.test.benchmark.interfaces import MeasurementData +from isaaclab.test.benchmark.measurements import ( + DictMetadata, + FloatMetadata, + IntMetadata, + SingleMeasurement, + StringMetadata, +) +from isaaclab.test.benchmark.schema import Hardware, Resources, Versions + + +class _Rec: + def __init__(self, data): + self._data = data + + def get_data(self): + return self._data + + +class _Bm: + def __init__(self, recorders): + self._manual_recorders = recorders + + +def test_capture_versions_renames_and_defaults(): + md = [ + StringMetadata(name="isaaclab_version", data="4.6.8"), + StringMetadata(name="torch_version", data="2.5.1"), + StringMetadata(name="mujoco_warp_version", data="0.0.4"), + StringMetadata(name="stable_baselines3_version", data="2.3.0"), + DictMetadata(name="dev", data={"commit_hash": "abc123", "branch": "develop", "dirty": True}), + ] + bm = _Bm({"VersionInfo": _Rec(MeasurementData(measurements=[], metadata=md, artefacts=[]))}) + v = capture_versions(bm) + assert isinstance(v, Versions) + assert v.isaaclab == "4.6.8" and v.torch == "2.5.1" + assert v.mjwarp == "0.0.4" + assert v.sb3 == "2.3.0" + assert v.git_commit == "abc123" and v.git_branch == "develop" and v.git_dirty is True + assert v.isaacsim is None + + +def test_capture_versions_preserves_runtime_packages(): + md = [ + StringMetadata(name="numpy_version", data="2.4.4"), + StringMetadata(name="isaaclab_newton_version", data="1.0.2"), + StringMetadata(name="isaaclab_physx_version", data="2.0.1"), + StringMetadata(name="isaaclab_ov_version", data="0.4.6"), + StringMetadata(name="isaaclab_tasks_version", data="8.0.1"), + StringMetadata(name="isaaclab_rl_version", data="0.6.1"), + StringMetadata(name="ovrtx_version", data=None), + StringMetadata(name="ovphysx_version", data="3.0.5"), + StringMetadata(name="mujoco_version", data="3.8.1"), + StringMetadata(name="cuda_bindings_version", data="12.9.4"), + StringMetadata(name="usd_core_version", data="25.11"), + StringMetadata(name="isaaclab_release_version", data="3.0.0"), + ] + bm = _Bm({"VersionInfo": _Rec(MeasurementData(measurements=[], metadata=md, artefacts=[]))}) + + versions = capture_versions(bm) + + assert versions.numpy == "2.4.4" + assert versions.isaaclab_newton == "1.0.2" + assert versions.isaaclab_physx == "2.0.1" + assert versions.isaaclab_ov == "0.4.6" + assert versions.isaaclab_tasks == "8.0.1" + assert versions.isaaclab_rl == "0.6.1" + assert versions.ovrtx is None + assert versions.ovphysx == "3.0.5" + assert versions.mujoco == "3.8.1" + assert versions.cuda_bindings == "12.9.4" + assert versions.usd_core == "25.11" + assert versions.isaaclab_release == "3.0.0" + + +def test_capture_resources_peaks(): + gpu = _Rec( + MeasurementData( + measurements=[ + SingleMeasurement(name="GPU Utilization", value=80.0, unit="%"), + SingleMeasurement(name="GPU Utilization std", value=5.0, unit="%"), + SingleMeasurement(name="GPU Memory Used", value=10.0, unit="GB"), + SingleMeasurement(name="GPU Memory Used std", value=0.5, unit="GB"), + SingleMeasurement(name="GPU Memory Used peak", value=12.0, unit="GB"), + ], + metadata=[], + artefacts=[], + ) + ) + cpu = _Rec( + MeasurementData( + measurements=[ + SingleMeasurement(name="CPU Utilization", value=30.0, unit="%"), + SingleMeasurement(name="CPU Utilization std", value=4.0, unit="%"), + ], + metadata=[], + artefacts=[], + ) + ) + mem = _Rec( + MeasurementData( + measurements=[ + SingleMeasurement(name="System Memory RSS", value=20.0, unit="GB"), + SingleMeasurement(name="System Memory RSS std", value=1.0, unit="GB"), + SingleMeasurement(name="System Memory RSS peak", value=24.0, unit="GB"), + ], + metadata=[], + artefacts=[], + ) + ) + r = capture_resources(_Bm({"GPUInfo": gpu, "CPUInfo": cpu, "MemoryInfo": mem})) + assert isinstance(r, Resources) + assert r.gpu_util_pct.peak is None + assert r.gpu_mem_gb.peak == pytest.approx(12.0) + assert r.ram_gb.peak == pytest.approx(24.0) + assert r.cpu_util_pct.peak is None + + +def test_capture_resources_uses_current_gpu_for_multiple_devices(): + gpu = _Rec( + MeasurementData( + measurements=[ + SingleMeasurement(name="GPU 1 Utilization", value=80.0, unit="%"), + SingleMeasurement(name="GPU 1 Utilization std", value=5.0, unit="%"), + SingleMeasurement(name="GPU 1 Memory Used", value=10.0, unit="GB"), + SingleMeasurement(name="GPU 1 Memory Used std", value=0.5, unit="GB"), + SingleMeasurement(name="GPU 1 Memory Used peak", value=12.0, unit="GB"), + ], + metadata=[ + IntMetadata(name="gpu_device_count", data=2), + IntMetadata(name="gpu_current_device", data=1), + ], + artefacts=[], + ) + ) + resources = capture_resources(_Bm({"GPUInfo": gpu})) + + assert resources.gpu_util_pct.mean == pytest.approx(80.0) + assert resources.gpu_mem_gb.peak == pytest.approx(12.0) + + +def test_capture_hardware(): + gpu = _Rec( + MeasurementData( + measurements=[], + metadata=[ + DictMetadata( + name="gpu_devices", + data={"0": {"name": "H100", "total_memory_gb": 80.0, "compute_capability": "9.0"}}, + ), + ], + artefacts=[], + ) + ) + cpu = _Rec( + MeasurementData( + measurements=[], + metadata=[ + StringMetadata(name="cpu_name", data="EPYC"), + IntMetadata(name="physical_cores", data=64), + ], + artefacts=[], + ) + ) + mem = _Rec( + MeasurementData( + measurements=[], + metadata=[FloatMetadata(name="total_ram_gb", data=512.0)], + artefacts=[], + ) + ) + h = capture_hardware(_Bm({"GPUInfo": gpu, "CPUInfo": cpu, "MemoryInfo": mem})) + assert isinstance(h, Hardware) + assert h.gpu_devices[0].name == "H100" and h.gpu_devices[0].mem_gb == pytest.approx(80.0) + assert h.gpu_devices[0].compute_cap == "9.0" + assert h.cpu_name == "EPYC" and h.cpu_count == 64 and h.ram_gb == pytest.approx(512.0) + assert isinstance(h.hostname, str) and h.hostname + + +def test_capture_handles_missing_recorders(): + bm = _Bm(None) + assert isinstance(capture_versions(bm), Versions) + assert isinstance(capture_hardware(bm), Hardware) + assert isinstance(capture_resources(bm), Resources) + + +def test_synth_run_id(): + rid = synth_run_id("rsl_rl", "physx", "Isaac-Ant-Direct-v0", 42, "20260612-150000") + assert "rsl_rl" in rid and "physx" in rid and "42" in rid + + +def test_run_config_from_presets_resolves_backend_configuration(monkeypatch): + cases = [ + ([], "physx", "none", []), + ( + ["newton_mjwarp", "ovrtx_renderer", "rgb"], + "newton_mjwarp", + "ovrtx", + ["newton_mjwarp", "ovrtx_renderer", "rgb"], + ), + (["newton"], "newton_mjwarp", "none", ["newton"]), + ( + ["physics=newton_mjwarp", "renderer=ovrtx_renderer", "presets=rgb,depth"], + "newton_mjwarp", + "ovrtx", + ["newton_mjwarp", "ovrtx_renderer", "rgb", "depth"], + ), + ] + for tokens, physics, rendering, presets in cases: + cfg = run_config_from_presets(tokens) + assert cfg.physics_backend == physics + assert cfg.rendering_backend == rendering + assert cfg.presets == presets + + monkeypatch.setattr(capture, "PhysicsBackend", Literal["physx", "newton_mjwarp_vbd"], raising=False) + assert run_config_from_presets(["newton_mjwarp_vbd"]).physics_backend == "newton_mjwarp_vbd" + + env_cfg = SimpleNamespace( + sim=SimpleNamespace(physics=SimpleNamespace(class_type="isaaclab_newton.physics:NewtonMJWarpManager")), + camera=SimpleNamespace(renderer_cfg=SimpleNamespace(renderer_type="isaac_rtx")), + ) + cfg = run_config_from_presets([], env_cfg=env_cfg) + assert cfg.physics_backend == "newton_mjwarp" + assert cfg.rendering_backend == "isaacsim_rtx" + + +def test_capture_resources_peak_clamped_to_mean_when_peak_row_absent(): + # Build a recorder that has mean/std rows but no peak rows. + # capture_resources must clamp peak to mean rather than leaving it at 0.0 + # (which would violate MeanStd.__post_init__ since peak < mean). + gpu = _Rec( + MeasurementData( + measurements=[ + SingleMeasurement(name="GPU Utilization", value=5.0, unit="%"), + SingleMeasurement(name="GPU Utilization std", value=1.0, unit="%"), + SingleMeasurement(name="GPU Memory Used", value=10.0, unit="GB"), + SingleMeasurement(name="GPU Memory Used std", value=0.5, unit="GB"), + # No "GPU Memory Used peak" row — peak defaults to 0.0 before clamping. + ], + metadata=[], + artefacts=[], + ) + ) + mem = _Rec( + MeasurementData( + measurements=[ + SingleMeasurement(name="System Memory RSS", value=10.0, unit="GB"), + SingleMeasurement(name="System Memory RSS std", value=0.2, unit="GB"), + # No "System Memory RSS peak" row. + ], + metadata=[], + artefacts=[], + ) + ) + cpu = _Rec( + MeasurementData( + measurements=[ + SingleMeasurement(name="CPU Utilization", value=20.0, unit="%"), + SingleMeasurement(name="CPU Utilization std", value=2.0, unit="%"), + ], + metadata=[], + artefacts=[], + ) + ) + # Must not raise ValueError from MeanStd.__post_init__. + r = capture_resources(_Bm({"GPUInfo": gpu, "CPUInfo": cpu, "MemoryInfo": mem})) + assert r.gpu_mem_gb.peak == pytest.approx(10.0) + assert r.ram_gb.peak == pytest.approx(10.0) + + +def test_capture_hardware_gpu_devices_sorted_by_numeric_index(): + # Keys "10", "2", "0" should be returned in numeric order 0, 2, 10 — not lexical "0","10","2". + gpu = _Rec( + MeasurementData( + measurements=[], + metadata=[ + DictMetadata( + name="gpu_devices", + data={ + "10": {"name": "H100-10", "total_memory_gb": 80.0, "compute_capability": "9.0"}, + "2": {"name": "H100-2", "total_memory_gb": 80.0, "compute_capability": "9.0"}, + "0": {"name": "H100-0", "total_memory_gb": 80.0, "compute_capability": "9.0"}, + }, + ), + ], + artefacts=[], + ) + ) + bm = _Bm({"GPUInfo": gpu}) + h = capture_hardware(bm) + names = [d.name for d in h.gpu_devices] + assert names == ["H100-0", "H100-2", "H100-10"] diff --git a/source/isaaclab/test/benchmark/test_formatters.py b/source/isaaclab/test/benchmark/test_formatters.py new file mode 100644 index 000000000000..c1bc3f860a2a --- /dev/null +++ b/source/isaaclab/test/benchmark/test_formatters.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 + +"""Tests for metrics formatters.""" + +import json +import logging +import os + +import pytest + +from isaaclab.test.benchmark import formatters +from isaaclab.test.benchmark.measurements import SingleMeasurement, StringMetadata, TestPhase +from isaaclab.test.benchmark.schema import ( + GpuDeviceInfo, + Hardware, + MeanStd, + Resources, + RunConfig, + RunIdentity, + Runtime, + RuntimeBundle, + StartupTime, + Versions, +) + + +def _minimal_runtime_bundle() -> RuntimeBundle: + return RuntimeBundle( + run=RunIdentity( + run_id="runtime_newton_mjwarp_Isaac-Ant-Direct-v0_20260422-131500_seed42", + framework=None, + config=RunConfig(physics_backend="newton_mjwarp", rendering_backend="none"), + task="Isaac-Ant-Direct-v0", + seed=42, + start_time_utc="2026-04-22T13:15:00Z", + end_time_utc="2026-04-22T13:15:10Z", + duration_s=10.0, + status="completed", + num_envs=16, + ), + versions=Versions( + isaaclab="4.6.8", + isaacsim=None, + kit=None, + newton=None, + warp=None, + mjwarp=None, + torch="2.5.1", + rsl_rl=None, + rl_games=None, + skrl=None, + sb3=None, + git_commit=None, + git_branch=None, + git_dirty=False, + ), + hardware=Hardware( + hostname="benchmark-host", + gpu_devices=[GpuDeviceInfo(name="NVIDIA H100 80GB", mem_gb=80.0, compute_cap="9.0")], + cpu_name="AMD EPYC 7763", + cpu_count=64, + ram_gb=512.0, + ), + runtime=Runtime( + startup_time_s=StartupTime(app_launch=1.0, env_creation=2.0, first_step=0.5), + iterations_completed=1, + total_wall_time_s=4.0, + steps_per_iteration=24, + iteration_time_s=MeanStd(mean=1.0, std=0.0), + collection_fps=MeanStd(mean=100.0, std=0.0), + total_fps=MeanStd(mean=100.0, std=0.0), + iterations_per_s=MeanStd(mean=1.0, std=0.0), + ), + resources=Resources( + gpu_util_pct=MeanStd(mean=80.0, std=5.0), + gpu_mem_gb=MeanStd(mean=10.0, std=0.5, peak=12.0), + cpu_util_pct=MeanStd(mean=30.0, std=4.0), + ram_gb=MeanStd(mean=20.0, std=1.0, peak=24.0), + ), + ) + + +@pytest.fixture(autouse=True) +def reset_formatters(): + formatters.MetricsFormatter.reset_instances() + yield + formatters.MetricsFormatter.reset_instances() + + +def test_schema_bundle_file_serializes_bundle_and_handles_missing_bundle(tmp_path, caplog): + formatter = formatters.MetricsFormatter.get_instance("schema") + phase = TestPhase(phase_name="runtime") + phase.measurements.append(SingleMeasurement(name="Test FPS", value=60.0, unit="FPS")) + formatter.add_metrics(phase) + formatter.finalize(str(tmp_path), "runtime", bundle=_minimal_runtime_bundle()) + + with open(os.path.join(str(tmp_path), "runtime.json")) as f: + data = json.load(f) + assert isinstance(formatter, formatters.SchemaBundleFile) + assert data["run"]["task"] == "Isaac-Ant-Direct-v0" + assert data["run"]["framework"] is None + assert data["runtime"]["total_fps"]["mean"] == pytest.approx(100.0) + assert data["resources"]["gpu_mem_gb"]["peak"] == pytest.approx(12.0) + assert data["schema_version"] + assert "Test FPS" not in json.dumps(data) + + with caplog.at_level(logging.WARNING, logger="isaaclab.test.benchmark.formatters"): + formatter.finalize(str(tmp_path), "missing", bundle=None) + assert not os.path.exists(os.path.join(str(tmp_path), "missing.json")) + assert any("no bundle" in record.message.lower() for record in caplog.records) + + +@pytest.mark.parametrize("formatter_cls", [formatters.OsmoKPIFile, formatters.OmniPerfKPIFile]) +def test_kpi_formatters_clear_phases_after_finalize(tmp_path, formatter_cls): + formatter = formatter_cls() + phase = TestPhase(phase_name="runtime") + phase.metadata.append(StringMetadata(name="phase", data="runtime")) + phase.measurements.append(SingleMeasurement(name="FPS", value=60.0, unit="FPS")) + + formatter.add_metrics(phase) + formatter.finalize(str(tmp_path), "first") + formatter.finalize(str(tmp_path), "second") + + assert os.path.exists(os.path.join(str(tmp_path), "first.json")) + assert not os.path.exists(os.path.join(str(tmp_path), "second.json")) + + +def test_osmo_writes_one_file_per_phase(tmp_path): + formatter = formatters.OsmoKPIFile() + for phase_name, value in (("startup", 1.0), ("runtime", 60.0)): + phase = TestPhase(phase_name=phase_name) + phase.metadata.append(StringMetadata(name="phase", data=phase_name)) + phase.measurements.append(SingleMeasurement(name="FPS", value=value, unit="FPS")) + formatter.add_metrics(phase) + + formatter.finalize(str(tmp_path), "metrics") + + assert sorted(path.name for path in tmp_path.glob("*.json")) == ["metrics_runtime.json", "metrics_startup.json"] + with open(tmp_path / "metrics_runtime.json") as f: + assert json.load(f)["FPS"] == 60.0 diff --git a/source/isaaclab/test/benchmark/test_metrics.py b/source/isaaclab/test/benchmark/test_metrics.py new file mode 100644 index 000000000000..53f14dff701f --- /dev/null +++ b/source/isaaclab/test/benchmark/test_metrics.py @@ -0,0 +1,178 @@ +# 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 benchmark metrics helpers (Isaac-Sim-free).""" + +import pytest + +from isaaclab.test.benchmark.metrics import ( + RL_LIBRARY_DESCRIPTORS, + SUCCESS_RATE_LOG_TAGS, + SuccessRateTracker, + check_convergence, + ema, + get_success_rate_log, + mean_std, + mean_std_peak, + parse_tf_logs, + success_rate_step_value, +) +from isaaclab.test.benchmark.schema import MeanStd + + +@pytest.mark.parametrize( + ("framework", "tfevents_pattern", "reward_tag", "ep_length_tag"), + [ + ("rsl_rl", "events*", "Train/mean_reward", "Train/mean_episode_length"), + ("rl_games", "summaries/events*", "rewards/iter", "episode_lengths/iter"), + ("skrl", "events*", "Reward / Total reward (mean)", "Episode / Total timesteps (mean)"), + ("sb3", "PPO_*/events*", "rollout/ep_rew_mean", "rollout/ep_len_mean"), + ], +) +def test_rl_library_descriptors( + framework: str, + tfevents_pattern: str, + reward_tag: str, + ep_length_tag: str, +): + descriptor = RL_LIBRARY_DESCRIPTORS[framework] + + assert descriptor.framework == framework + assert descriptor.tfevents_pattern == tfevents_pattern + assert descriptor.reward_tag == reward_tag + assert descriptor.ep_length_tag == ep_length_tag + + +def test_mean_std_peak_computes_peak(): + ms = mean_std_peak([1.0, 2.0, 3.0]) + assert isinstance(ms, MeanStd) + assert ms.mean == pytest.approx(2.0) + assert ms.std == pytest.approx(1.0) + assert ms.peak == pytest.approx(3.0) + + +def test_mean_std_omits_peak(): + ms = mean_std([10.0, 20.0]) + assert ms.peak is None + assert ms.mean == pytest.approx(15.0) + + +def test_mean_std_empty_is_zero(): + ms = mean_std_peak([]) + assert ms.mean == 0.0 and ms.std == 0.0 and ms.peak == 0.0 + + +def test_ema_matches_manual(): + series = [0.0, 10.0, 10.0] + a = 0.5 + e = series[0] + for x in series[1:]: + e = a * x + (1 - a) * e + assert ema(series, a) == pytest.approx(e) + + +def test_ema_empty_is_zero(): + assert ema([], 0.1) == 0.0 + + +def test_check_convergence_passes_on_stable_high_rewards(): + res = check_convergence([100.0] * 10, threshold=50.0) + assert res["passed"] is True + assert res["tail_mean"] == pytest.approx(100.0) + + +def test_check_convergence_fails_when_below_threshold(): + res = check_convergence([1.0] * 10, threshold=50.0) + assert res["passed"] is False + + +def test_get_success_rate_log_prefers_first_tag(): + data = {"Episode/Metrics/success_rate": [0.5], "Metrics/success_rate": [0.9]} + assert get_success_rate_log(data) == [0.9] + assert get_success_rate_log({}) is None + + +def test_success_rate_tracker_convergence(): + t = SuccessRateTracker(threshold=0.5, window=2, num_steps_per_env=1) + for v in (0.6, 0.7): + t.record_step({"log": {"Metrics/success_rate": v}}) + t.end_iteration() + assert t.converged is True + assert t.tail_mean == pytest.approx(0.65) + + +def test_check_convergence_high_cv_fails_despite_mean_above_threshold(): + # Series whose tail mean clears the threshold but CV is too high to pass. + # With window_pct=1.0, the whole series is the tail. + # [1, 1000] -> tail_mean ~500.5, threshold=2.0, cv >> 20 -> passed False. + rewards = [1.0, 1000.0] + res = check_convergence(rewards, threshold=2.0, window_pct=1.0) + assert res["passed"] is False + assert res["cv"] > 20.0 + assert res["tail_mean"] >= 2.0 + + +def test_check_convergence_empty_rewards(): + res = check_convergence([], threshold=1.0) + assert res == {"tail_mean": 0.0, "cv": 999.9, "passed": False} + + +def test_success_rate_tracker_multi_step_boundary(): + # num_steps_per_env=3: boundary fires after step 3, not after 1 or 2. + t = SuccessRateTracker(threshold=0.5, window=1, num_steps_per_env=3) + t.record_step({"log": {"Metrics/success_rate": 0.6}}) + assert t.at_iteration_boundary is False + t.record_step({"log": {"Metrics/success_rate": 0.6}}) + assert t.at_iteration_boundary is False + t.record_step({"log": {"Metrics/success_rate": 0.6}}) + assert t.at_iteration_boundary is True + t.end_iteration() + assert len(t.history) == 1 + + +def test_success_rate_tracker_item_tensor_path(): + class _T: + def item(self): + return 0.7 + + t = SuccessRateTracker(threshold=0.5, window=1, num_steps_per_env=1) + t.record_step({"log": {"Metrics/success_rate": _T()}}) + mean = t.end_iteration() + assert mean == pytest.approx(0.7) + assert t.history == [pytest.approx(0.7)] + + +def test_success_rate_tracker_no_data_end_iteration_returns_none(): + t = SuccessRateTracker(threshold=0.5, window=1, num_steps_per_env=1) + t.record_step({"log": {}}) + assert t._step_count == 1 + result = t.end_iteration() + assert result is None + assert t.history == [] + + +def test_parse_tf_logs_empty_dir_returns_empty(tmp_path, caplog): + import logging + + with caplog.at_level(logging.WARNING): + result = parse_tf_logs(str(tmp_path)) + assert result == {} + assert any("No TensorBoard event files" in r.getMessage() for r in caplog.records) + + +def test_check_convergence_zero_mean_returns_high_cv(): + result = check_convergence([0.0, 0.0, 0.0, 0.0, 0.0], threshold=0.3) + assert result["tail_mean"] == 0.0 + assert result["cv"] == 999.9 + assert result["passed"] is False + + +def test_success_rate_step_value_reads_scalar(): + import torch + + tag = SUCCESS_RATE_LOG_TAGS[0] + assert success_rate_step_value({tag: torch.tensor(0.75)}) == pytest.approx(0.75) + assert success_rate_step_value({tag: 0.5}) == pytest.approx(0.5) + assert success_rate_step_value({"other/metric": 1.0}) is None diff --git a/source/isaaclab/test/benchmark/test_profiling.py b/source/isaaclab/test/benchmark/test_profiling.py new file mode 100644 index 000000000000..f8396b112536 --- /dev/null +++ b/source/isaaclab/test/benchmark/test_profiling.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 + +"""Tests for benchmark cProfile parsing (Isaac-Sim-free).""" + +import cProfile +import os + +from isaaclab.test.benchmark.profiling import parse_cprofile_stats + + +def _profiled(): + def inner(n): + return sum(range(n)) + + p = cProfile.Profile() + p.enable() + for _ in range(3): + inner(1000) + p.disable() + return p + + +def test_parse_cprofile_returns_4_tuples(): + p = _profiled() + # Treat this test file's directory as an "isaaclab prefix" so functions + # defined here are captured by the filter. + prefixes = [os.path.dirname(os.path.abspath(__file__))] + rows = parse_cprofile_stats(p, prefixes, top_n=10) + assert rows, "expected at least one profiled function" + for row in rows: + assert len(row) == 4 + label, own_ms, cum_ms, calls = row + assert isinstance(label, str) + assert isinstance(own_ms, float) and isinstance(cum_ms, float) + assert isinstance(calls, int) and calls >= 1 + + +def test_whitelist_placeholder_is_4_tuple(): + p = _profiled() + prefixes = [os.path.dirname(os.path.abspath(__file__))] + rows = parse_cprofile_stats(p, prefixes, whitelist=["nonexistent.module:does_not_exist"]) + # unmatched pattern yields a placeholder row, still a 4-tuple + assert any(r[0] == "nonexistent.module:does_not_exist" and r[3] == 0 for r in rows) + + +def test_parse_cprofile_prefers_most_specific_source_root(): + namespace = {} + exec( + compile( + "def profile_target():\n return sum(range(10))", + "/tmp/repro/source/isaaclab_tasks/isaaclab_tasks/example.py", + "exec", + ), + namespace, + ) + profile = cProfile.Profile() + profile.runcall(namespace["profile_target"]) + + rows = parse_cprofile_stats( + profile, + ["/tmp/repro/source/isaaclab", "/tmp/repro/source/isaaclab_tasks"], + ) + + assert any(label == "isaaclab_tasks.example:profile_target" for label, *_ in rows) diff --git a/source/isaaclab/test/benchmark/test_recorders.py b/source/isaaclab/test/benchmark/test_recorders.py index b708035067bb..003cfc66fbf1 100644 --- a/source/isaaclab/test/benchmark/test_recorders.py +++ b/source/isaaclab/test/benchmark/test_recorders.py @@ -5,6 +5,10 @@ """Unit tests for benchmark recorder classes.""" +import builtins +import sys +import types + import pytest from isaaclab.test.benchmark.interfaces import MeasurementData @@ -596,12 +600,138 @@ def test_captures_core_versions(self, recorder): assert "numpy" in versions assert "isaaclab" in versions - def test_version_values_are_strings(self, recorder): - """Test that version values are strings.""" + def test_captures_renderer_runtime_versions(self, monkeypatch): + versions_by_distribution = {"ovrtx": "0.3.1", "isaaclab_ovphysx": "3.0.5"} + monkeypatch.setattr( + VersionInfoRecorder, + "_get_pkg_version", + lambda _self, distribution: versions_by_distribution.get(distribution), + ) + + versions = VersionInfoRecorder().get_initial_data()["version_metadata"] + assert versions["ovrtx"] == "0.3.1" + assert versions["ovphysx"] == "3.0.5" + + def test_captures_active_kit_versions(self, monkeypatch, tmp_path): + """Test that versions are captured from an active Kit runtime.""" + isaac_path = tmp_path / "isaacsim" + isaac_path.mkdir() + (isaac_path / "VERSION").write_text("6.0.0-test") + monkeypatch.setenv("ISAAC_PATH", str(isaac_path)) + + omni = types.ModuleType("omni") + kit = types.ModuleType("omni.kit") + app = types.ModuleType("omni.kit.app") + app.get_app = lambda: types.SimpleNamespace(get_build_version=lambda: "110.1.1-test") + omni.kit = kit + kit.app = app + monkeypatch.setitem(sys.modules, "omni", omni) + monkeypatch.setitem(sys.modules, "omni.kit", kit) + monkeypatch.setitem(sys.modules, "omni.kit.app", app) + + versions = VersionInfoRecorder().get_initial_data()["version_metadata"] + + assert versions["kit"] == "110.1.1-test" + assert versions["isaacsim"] == "6.0.0-test" + + def test_captures_isaacsim_version_without_isaac_path(self, monkeypatch): + """Test that an active Kit runtime provides the Isaac Sim application version.""" + monkeypatch.delenv("ISAAC_PATH", raising=False) + + omni = types.ModuleType("omni") + kit = types.ModuleType("omni.kit") + app = types.ModuleType("omni.kit.app") + app.get_app = lambda: types.SimpleNamespace(get_kit_version=lambda: "110.1.1-test") + omni.kit = kit + kit.app = app + isaacsim = types.ModuleType("isaacsim") + core = types.ModuleType("isaacsim.core") + version = types.ModuleType("isaacsim.core.version") + version.get_version = lambda: ("6.0.0", "rc.59", "6", "0", "0", "rc", "59", "main.0.test") + isaacsim.core = core + core.version = version + monkeypatch.setitem(sys.modules, "omni", omni) + monkeypatch.setitem(sys.modules, "omni.kit", kit) + monkeypatch.setitem(sys.modules, "omni.kit.app", app) + monkeypatch.setitem(sys.modules, "isaacsim", isaacsim) + monkeypatch.setitem(sys.modules, "isaacsim.core", core) + monkeypatch.setitem(sys.modules, "isaacsim.core.version", version) + monkeypatch.setitem(sys.modules, "carb", types.ModuleType("carb")) + + versions = VersionInfoRecorder().get_initial_data()["version_metadata"] + + assert versions["isaacsim"] == "6.0.0-rc.59+main.0.test" + + def test_records_null_kit_versions_without_active_kit(self, monkeypatch): + """Test that Kit versions are null when no Kit runtime is active.""" + monkeypatch.delenv("ISAAC_PATH", raising=False) + + omni = types.ModuleType("omni") + kit = types.ModuleType("omni.kit") + app = types.ModuleType("omni.kit.app") + app.get_app = lambda: None + omni.kit = kit + kit.app = app + isaacsim = types.ModuleType("isaacsim") + isaacsim.__version__ = "should-not-be-recorded" + carb = types.ModuleType("carb") + monkeypatch.setitem(sys.modules, "omni", omni) + monkeypatch.setitem(sys.modules, "isaacsim", isaacsim) + monkeypatch.setitem(sys.modules, "carb", carb) + monkeypatch.setitem(sys.modules, "omni.kit", kit) + monkeypatch.setitem(sys.modules, "omni.kit.app", app) + + versions = VersionInfoRecorder().get_initial_data()["version_metadata"] + + assert versions["kit"] is None + assert versions["isaacsim"] is None + + def test_records_null_kit_versions_without_importing_kit(self, monkeypatch): + """Test that Kitless runs do not import the Kit application module.""" + monkeypatch.delenv("ISAAC_PATH", raising=False) + for module_name in ("omni.kit.app", "omni.kit", "omni"): + monkeypatch.delitem(sys.modules, module_name, raising=False) + + kit_imports = [] + original_import = builtins.__import__ + + def import_without_kit(name, globals=None, locals=None, fromlist=(), level=0): + if name == "omni.kit.app": + kit_imports.append(name) + raise AssertionError("Kit must not be imported for Kitless runs") + return original_import(name, globals, locals, fromlist, level) + + monkeypatch.setattr(builtins, "__import__", import_without_kit) + + versions = VersionInfoRecorder().get_initial_data()["version_metadata"] + + assert kit_imports == [] + assert versions["kit"] is None + assert versions["isaacsim"] is None + + def test_records_null_renderer_runtime_versions_when_unavailable(self, monkeypatch): + """Test that unavailable renderer runtime versions are null.""" + monkeypatch.setattr(VersionInfoRecorder, "_get_pkg_version", lambda _self, _distribution: None) + + recorder = VersionInfoRecorder() + versions = recorder.get_initial_data()["version_metadata"] + metadata = {entry.name: entry.data for entry in recorder.get_data().metadata} + + assert versions["ovrtx"] is None + assert versions["ovphysx"] is None + assert metadata["ovrtx_version"] is None + assert metadata["ovphysx_version"] is None + + def test_version_values_are_strings_or_null(self, recorder): + """Test that version values are strings or null for runtime packages.""" data = recorder.get_initial_data() - for version in data["version_metadata"].values(): - assert isinstance(version, str) - assert len(version) > 0 + nullable_versions = {"kit", "isaacsim", "ovrtx", "ovphysx"} + for name, version in data["version_metadata"].items(): + if name in nullable_versions: + assert version is None or isinstance(version, str) + else: + assert isinstance(version, str) + assert len(version) > 0 def test_git_info_structure(self, recorder): """Test that git info has expected fields when available.""" diff --git a/source/isaaclab/test/benchmark/test_stepping.py b/source/isaaclab/test/benchmark/test_stepping.py new file mode 100644 index 000000000000..d2c53117230c --- /dev/null +++ b/source/isaaclab/test/benchmark/test_stepping.py @@ -0,0 +1,75 @@ +# 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 runtime stepping helpers.""" + +import numpy as np +import torch + +from isaaclab.test.benchmark.stepping import run_runtime_loop, sample_random_actions + + +class _Space: + def __init__(self, n): + self.shape = (n,) + + +class _Env: + class _U: + num_envs = 4 + device = "cpu" + single_action_space = _Space(3) + + def __init__(self): + self.unwrapped = _Env._U() + self.reset_called = False + self.steps = 0 + + def reset(self): + self.reset_called = True + + def step(self, actions): + self.steps += 1 + return (None, None, None, {}) + + +def test_sample_single_agent_shape_and_range(): + a = sample_random_actions(_Env()) + assert isinstance(a, torch.Tensor) + assert tuple(a.shape) == (4, 3) + assert float(a.min()) >= -1.0 - 1e-6 and float(a.max()) <= 1.0 + 1e-6 + + +def test_run_runtime_loop_steps_and_times(): + env = _Env() + times = run_runtime_loop(env, num_frames=5) + assert env.reset_called and env.steps == 5 + assert len(times) == 5 and all(t >= 0.0 for t in times) + + +class _MASpace: + def __init__(self, n): + self._n = n + + def sample(self): + return np.zeros(self._n, dtype=np.float32) + + +class _MAEnv: + class _U: + num_envs = 4 + device = "cpu" + action_spaces = {"a0": _MASpace(3), "a1": _MASpace(2)} + + def __init__(self): + self.unwrapped = _MAEnv._U() + + +def test_sample_multi_agent_returns_dict_per_agent(): + actions = sample_random_actions(_MAEnv()) + assert isinstance(actions, dict) + assert set(actions) == {"a0", "a1"} + assert tuple(actions["a0"].shape) == (4, 3) + assert tuple(actions["a1"].shape) == (4, 2) diff --git a/source/isaaclab/test/cli/test_source_package_metadata.py b/source/isaaclab/test/cli/test_source_package_metadata.py index ec2b1c354613..11e62204c9a6 100644 --- a/source/isaaclab/test/cli/test_source_package_metadata.py +++ b/source/isaaclab/test/cli/test_source_package_metadata.py @@ -20,8 +20,14 @@ def _repo_root() -> Path: raise RuntimeError("Could not find Isaac Lab repository root.") -def test_isaaclab_usd_core_pin_stays_on_isaacsim_compatible_usd25_abi(): - """The kit-less USD package must stay on the Isaac Sim compatible USD 25 ABI.""" +def test_isaaclab_usd_core_pin_includes_multithreaded_collider_crash_fix(): + """The kit-less USD package must include the OpenUSD 26.05 fix for the multithreaded + UsdPhysicsParsingUtility crash (one rigid body with many mesh colliders). + + See OpenUSD PR #4002 / commit 060715f ("[usdPhysics] fix for a multithreaded crash if + one rigidbody has multiple colliders beneath"), first released in OpenUSD 26.05 + (usd-core 26.5). Versions < 26.5 race and can corrupt the heap during USD physics parsing. + """ with (_repo_root() / "source/isaaclab/pyproject.toml").open("rb") as f: pyproject = tomllib.load(f) @@ -29,4 +35,16 @@ def test_isaaclab_usd_core_pin_stays_on_isaacsim_compatible_usd25_abi(): dependency for dependency in pyproject["project"]["dependencies"] if dependency.startswith("usd-core") ] - assert usd_core_dependencies == ["usd-core>=25.11,<26.0 ; platform_machine in 'x86_64 AMD64'"] + assert usd_core_dependencies == ["usd-core>=26.5,<27.0 ; platform_machine in 'x86_64 AMD64'"] + + +def test_isaaclab_standalone_usd_providers_are_platform_disjoint(): + """Standalone USD packages must not overlap on platforms where both ship ``pxr``.""" + with (_repo_root() / "source/isaaclab/pyproject.toml").open("rb") as f: + pyproject = tomllib.load(f) + + usd_exchange_dependencies = [ + dependency for dependency in pyproject["project"]["dependencies"] if dependency.startswith("usd-exchange") + ] + + assert usd_exchange_dependencies == ["usd-exchange>=2.2 ; platform_machine in 'aarch64 arm64'"] diff --git a/source/isaaclab/test/install_ci/cli/test_cli_install_in_globalenv_smoke.py b/source/isaaclab/test/install_ci/cli/test_cli_install_in_globalenv_smoke.py index 1d39de3137ac..10d0ff5ff345 100644 --- a/source/isaaclab/test/install_ci/cli/test_cli_install_in_globalenv_smoke.py +++ b/source/isaaclab/test/install_ci/cli/test_cli_install_in_globalenv_smoke.py @@ -19,6 +19,7 @@ from utils import run_cmd +@pytest.mark.smoke class Test_Cli_Install_In_Globalenv_Smoke: """./isaaclab.sh -i with no uv/conda env active (system Python).""" diff --git a/source/isaaclab/test/install_ci/cli/test_cli_install_in_uvenv_smoke.py b/source/isaaclab/test/install_ci/cli/test_cli_install_in_uvenv_smoke.py index 322c4b29352a..7de51d7afc73 100644 --- a/source/isaaclab/test/install_ci/cli/test_cli_install_in_uvenv_smoke.py +++ b/source/isaaclab/test/install_ci/cli/test_cli_install_in_uvenv_smoke.py @@ -33,6 +33,7 @@ def _skip_if_isaacsim_unavailable() -> None: pytest.skip("isaacsim is not importable and _isaac_sim link not found, skipping") +@pytest.mark.smoke class Test_Cli_Install_In_Uvenv_Smoke(UV_Mixin): """./isaaclab.sh -u/-i smoke checks plus optional submodule (mimic) and feature (newton) installs.""" diff --git a/source/isaaclab/test/install_ci/conftest.py b/source/isaaclab/test/install_ci/conftest.py index 747330dfd849..d1dc50ab486d 100644 --- a/source/isaaclab/test/install_ci/conftest.py +++ b/source/isaaclab/test/install_ci/conftest.py @@ -100,6 +100,7 @@ def pytest_addoption(parser: pytest.Parser) -> None: def pytest_configure(config: pytest.Config) -> None: + config.addinivalue_line("markers", "smoke: tests for core installation, task, and RL functionality") config.addinivalue_line("markers", "bug: bug-regression tests (use bug id as argument)") config.addinivalue_line("markers", "gpu: tests that require a GPU") config.addinivalue_line("markers", "docker: tests that only run inside Docker") diff --git a/source/isaaclab/test/install_ci/misc/test_wheel_builder_smoke.py b/source/isaaclab/test/install_ci/misc/test_wheel_builder_smoke.py index e111b0fec3ce..fe79a3842284 100644 --- a/source/isaaclab/test/install_ci/misc/test_wheel_builder_smoke.py +++ b/source/isaaclab/test/install_ci/misc/test_wheel_builder_smoke.py @@ -33,6 +33,7 @@ from utils import UV_Mixin, run_cmd +@pytest.mark.smoke class Test_Wheel_Builder_Smoke(UV_Mixin): """Test building the isaaclab wheel and installing it in a uv environment.""" diff --git a/source/isaaclab/test/install_ci/pytest.ini b/source/isaaclab/test/install_ci/pytest.ini index ab7e9d50137a..93b6cec4183a 100644 --- a/source/isaaclab/test/install_ci/pytest.ini +++ b/source/isaaclab/test/install_ci/pytest.ini @@ -6,6 +6,7 @@ python_files = *_test.py *_tests.py markers = + smoke: tests for core installation, task, and RL functionality bug: regression tests (bug id as argument) gpu: tests that require a GPU docker: tests that ONLY run inside Docker diff --git a/source/isaaclab/test/install_ci/uv_pip/test_uv_pip_install_isaaclab_all_isaacsim_trains_cartpole.py b/source/isaaclab/test/install_ci/uv_pip/test_uv_pip_install_isaaclab_all_isaacsim_trains_cartpole.py index eab7d5ba9372..7f1355840f3b 100644 --- a/source/isaaclab/test/install_ci/uv_pip/test_uv_pip_install_isaaclab_all_isaacsim_trains_cartpole.py +++ b/source/isaaclab/test/install_ci/uv_pip/test_uv_pip_install_isaaclab_all_isaacsim_trains_cartpole.py @@ -58,6 +58,7 @@ def setup_class(cls): pytest.skip("uv is not available") @pytest.mark.docker + @pytest.mark.smoke @pytest.mark.uv @pytest.mark.slow @pytest.mark.gpu diff --git a/source/isaaclab/test/sim/test_material_fragments.py b/source/isaaclab/test/sim/test_material_fragments.py new file mode 100644 index 000000000000..8caf9a99fd92 --- /dev/null +++ b/source/isaaclab/test/sim/test_material_fragments.py @@ -0,0 +1,523 @@ +# 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 + +from pxr import UsdPhysics, UsdShade + +import isaaclab.sim as sim_utils +from isaaclab.sim import SimulationCfg, SimulationContext + +# ------------------------------------------------------------------------------------- +# RigidBodyMaterialFragment marker + metadata +# ------------------------------------------------------------------------------------- + + +def test_rigid_body_material_fragment_metadata_defaults(): + from isaaclab.sim.schemas import SchemaFragment + from isaaclab.sim.spawners.materials.physics_materials_cfg import ( + RigidBodyMaterialFragment, + UsdPhysicsRigidBodyMaterialCfg, + ) + + cfg = UsdPhysicsRigidBodyMaterialCfg(static_friction=0.7) + assert isinstance(cfg, RigidBodyMaterialFragment) and isinstance(cfg, SchemaFragment) + assert type(cfg)._usd_namespace == "physics" + assert type(cfg)._usd_applied_schema is None # MaterialAPI applied by the family writer + assert cfg.func == "isaaclab.sim.schemas:apply_namespaced" + assert cfg.static_friction == 0.7 and cfg.dynamic_friction is None + + +def test_physx_material_fragment_metadata_defaults(): + from isaaclab_physx.sim.spawners.materials.physics_materials_cfg import PhysxMaterialCfg + + from isaaclab.sim.spawners.materials.physics_materials_cfg import RigidBodyMaterialFragment + + cfg = PhysxMaterialCfg(compliant_contact_stiffness=100.0) + assert isinstance(cfg, RigidBodyMaterialFragment) + assert type(cfg)._usd_namespace == "physxMaterial" + assert type(cfg)._usd_applied_schema == "PhysxMaterialAPI" + assert cfg.func == "isaaclab.sim.schemas:apply_namespaced" + + +# ------------------------------------------------------------------------------------- +# spawn_rigid_body_material_from_fragments: spawn prim + anchor + multi-namespace compose +# ------------------------------------------------------------------------------------- + + +def test_spawn_rigid_body_material_from_fragments_composes_namespaces(): + from isaaclab_physx.sim.spawners.materials.physics_materials_cfg import PhysxMaterialCfg + + from isaaclab.sim.spawners.materials.physics_materials import spawn_rigid_body_material_from_fragments + from isaaclab.sim.spawners.materials.physics_materials_cfg import UsdPhysicsRigidBodyMaterialCfg + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = spawn_rigid_body_material_from_fragments( + "/World/Mat", + [ + UsdPhysicsRigidBodyMaterialCfg(static_friction=0.7, dynamic_friction=0.6, restitution=0.1), + PhysxMaterialCfg(compliant_contact_stiffness=100.0, friction_combine_mode="max"), + ], + stage, + ) + assert prim.IsA(UsdShade.Material) + assert bool(UsdPhysics.MaterialAPI(prim)) # neutral anchor applied by the writer + assert prim.GetAttribute("physics:staticFriction").Get() == pytest.approx(0.7) + assert prim.GetAttribute("physics:dynamicFriction").Get() == pytest.approx(0.6) + assert prim.GetAttribute("physics:restitution").Get() == pytest.approx(0.1) + # the PhysX fragment applied its own schema and namespace + assert "PhysxMaterialAPI" in prim.GetAppliedSchemas() + assert prim.GetAttribute("physxMaterial:compliantContactStiffness").Get() == pytest.approx(100.0) + assert prim.GetAttribute("physxMaterial:frictionCombineMode").Get() == "max" + + +def test_spawn_rigid_body_material_from_fragments_accepts_single_fragment(): + from isaaclab.sim.spawners.materials.physics_materials import spawn_rigid_body_material_from_fragments + from isaaclab.sim.spawners.materials.physics_materials_cfg import UsdPhysicsRigidBodyMaterialCfg + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = spawn_rigid_body_material_from_fragments( + "/World/Mat2", UsdPhysicsRigidBodyMaterialCfg(static_friction=0.3), stage + ) + assert prim.IsA(UsdShade.Material) + assert bool(UsdPhysics.MaterialAPI(prim)) + assert prim.GetAttribute("physics:staticFriction").Get() == pytest.approx(0.3) + + +def test_spawn_physics_material_dispatches_fragments_and_legacy(): + """The shared dispatcher handles both a rigid-body fragment collection and a legacy material + cfg carrying its own ``func``.""" + from isaaclab_physx.sim.spawners.materials.physics_materials_cfg import PhysxRigidBodyMaterialCfg + + from isaaclab.sim.spawners.materials.physics_materials import spawn_physics_material + from isaaclab.sim.spawners.materials.physics_materials_cfg import UsdPhysicsRigidBodyMaterialCfg + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + + # tuple form is accepted by the low-level dispatcher alongside the list form used by cfg slots + frag_prim = spawn_physics_material("/World/MaterialA", (UsdPhysicsRigidBodyMaterialCfg(static_friction=0.4),)) + assert bool(UsdPhysics.MaterialAPI(frag_prim)) + assert frag_prim.GetAttribute("physics:staticFriction").Get() == pytest.approx(0.4) + + # legacy single-cfg form (RigidBodyMaterialBaseCfg subclass with its own spawner func) + legacy_prim = spawn_physics_material("/World/MaterialB", PhysxRigidBodyMaterialCfg(static_friction=0.9)) + assert bool(UsdPhysics.MaterialAPI(legacy_prim)) + assert legacy_prim.GetAttribute("physics:staticFriction").Get() == pytest.approx(0.9) + + +def test_spawn_physics_material_rejects_non_current_stage_for_legacy(): + """The legacy path is current-stage-bound; an explicit different stage raises instead of + silently authoring on the current stage. Passing the current stage explicitly stays valid + (the in-tree spawners do so unconditionally).""" + from isaaclab_physx.sim.spawners.materials.physics_materials_cfg import PhysxRigidBodyMaterialCfg + + from pxr import Usd + + from isaaclab.sim.spawners.materials import spawn_physics_material + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + other = Usd.Stage.CreateInMemory() + with pytest.raises(ValueError, match="current stage"): + spawn_physics_material("/World/MatOther", PhysxRigidBodyMaterialCfg(), stage=other) + # nothing leaked onto the current stage + assert not sim_utils.get_current_stage().GetPrimAtPath("/World/MatOther").IsValid() + # explicit current stage remains supported + prim = spawn_physics_material("/World/MatCurrent", PhysxRigidBodyMaterialCfg(), stage=sim_utils.get_current_stage()) + assert prim.IsValid() + + +def test_fragment_writer_validates_inputs_before_authoring(): + """Direct and dispatched fragment calls share one validation contract and do not leave prims.""" + from isaaclab_physx.sim.spawners.materials.physics_materials_cfg import PhysxRigidBodyMaterialCfg + + from isaaclab.sim.spawners.materials.physics_materials import ( + spawn_physics_material, + spawn_rigid_body_material_from_fragments, + ) + from isaaclab.sim.spawners.materials.physics_materials_cfg import UsdPhysicsRigidBodyMaterialCfg + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + + with pytest.raises(ValueError): + spawn_rigid_body_material_from_fragments("/World/MatEmpty", [], stage) + # a list mixing a fragment with a legacy cfg is not a valid fragment list + with pytest.raises(TypeError): + spawn_physics_material( + "/World/MatMixed", + [UsdPhysicsRigidBodyMaterialCfg(static_friction=0.4), PhysxRigidBodyMaterialCfg(static_friction=0.9)], + ) + with pytest.raises(TypeError): + spawn_rigid_body_material_from_fragments("/World/MatLegacy", PhysxRigidBodyMaterialCfg(), stage) + with pytest.raises(TypeError): + spawn_physics_material("/World/MatInvalid", object()) + for path in ("MatEmpty", "MatMixed", "MatLegacy", "MatInvalid"): + assert not stage.GetPrimAtPath(f"/World/{path}").IsValid() + + +def test_spawn_rigid_body_material_from_fragments_leaves_none_fields_unwritten(): + from isaaclab.sim.spawners.materials.physics_materials import spawn_rigid_body_material_from_fragments + from isaaclab.sim.spawners.materials.physics_materials_cfg import UsdPhysicsRigidBodyMaterialCfg + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = spawn_rigid_body_material_from_fragments( + "/World/Mat3", [UsdPhysicsRigidBodyMaterialCfg(static_friction=0.5)], stage + ) + # only the authored field is written; None fields are left unauthored (partial update) + assert prim.GetAttribute("physics:staticFriction").Get() == pytest.approx(0.5) + assert not prim.GetAttribute("physics:dynamicFriction").HasAuthoredValue() + + +# ------------------------------------------------------------------------------------- +# UsdPhysicsRigidBodyMaterialCfg: density round-trip + physics:* schema parity +# ------------------------------------------------------------------------------------- + + +def test_usd_physics_rigid_body_material_density_round_trips(): + """``physics:density`` participates in mass computation via material binding; it must author + the same as the other ``UsdPhysics.MaterialAPI`` friction/restitution fields.""" + from isaaclab.sim.spawners.materials.physics_materials import spawn_rigid_body_material_from_fragments + from isaaclab.sim.spawners.materials.physics_materials_cfg import UsdPhysicsRigidBodyMaterialCfg + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = spawn_rigid_body_material_from_fragments( + "/World/MatDensity", [UsdPhysicsRigidBodyMaterialCfg(density=1200.0)], stage + ) + assert prim.GetAttribute("physics:density").Get() == pytest.approx(1200.0) + + +def test_usd_physics_rigid_body_material_fragment_matches_material_api_schema(): + """Schema-parity guard: the set of ``physics:*`` attrs the neutral fragment can author must + equal the attribute set on ``UsdPhysics.MaterialAPI`` (4 attrs: static/dynamic friction, + restitution, density). Catches drift if the schema gains/loses an attribute.""" + import dataclasses + + from isaaclab.sim.spawners.materials.physics_materials_cfg import UsdPhysicsRigidBodyMaterialCfg + from isaaclab.utils.string import to_camel_case + + fragment_fields = {f.name for f in dataclasses.fields(UsdPhysicsRigidBodyMaterialCfg) if f.name != "func"} + schema_attr_names = {name.split(":", 1)[1] for name in UsdPhysics.MaterialAPI.GetSchemaAttributeNames()} + fragment_attr_names = {to_camel_case(name, "cC") for name in fragment_fields} + assert fragment_attr_names == schema_attr_names + + +# ------------------------------------------------------------------------------------- +# PhysxMaterialCfg: damping-combine-mode + compliant-contact-acceleration-spring +# ------------------------------------------------------------------------------------- + + +def test_physx_material_fragment_authors_damping_combine_mode_and_acceleration_spring(): + from isaaclab_physx.sim.spawners.materials.physics_materials_cfg import PhysxMaterialCfg + + from isaaclab.sim.spawners.materials.physics_materials import spawn_rigid_body_material_from_fragments + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + prim = spawn_rigid_body_material_from_fragments( + "/World/MatPhysxExtra", + [PhysxMaterialCfg(damping_combine_mode="min", compliant_contact_acceleration_spring=True)], + stage, + ) + assert "PhysxMaterialAPI" in prim.GetAppliedSchemas() + assert prim.GetAttribute("physxMaterial:dampingCombineMode").Get() == "min" + assert prim.GetAttribute("physxMaterial:compliantContactAccelerationSpring").Get() is True + + +# ------------------------------------------------------------------------------------- +# Finding 4: mesh spawner must accept a fragment list for a rigid physics_material +# ------------------------------------------------------------------------------------- + + +def test_spawn_mesh_with_rigid_props_accepts_fragment_list_physics_material(): + """Regression test: the rigid-vs-deformable material guard in the mesh spawner used to reject + a fragment / fragment-list ``physics_material`` outright. A rigid-body fragment list must spawn + and bind successfully.""" + from isaaclab.sim.spawners.materials.physics_materials_cfg import UsdPhysicsRigidBodyMaterialCfg + from isaaclab.sim.spawners.meshes.meshes_cfg import MeshCuboidCfg + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + cfg = MeshCuboidCfg( + size=(1.0, 1.0, 1.0), + rigid_props=sim_utils.RigidBodyPropertiesCfg(), + collision_props=sim_utils.CollisionPropertiesCfg(), + physics_material=[UsdPhysicsRigidBodyMaterialCfg(static_friction=0.65, dynamic_friction=0.55)], + ) + prim = cfg.func("/World/MeshCubeFrag", cfg, stage=stage) + assert prim.IsValid() + material_prim = stage.GetPrimAtPath("/World/MeshCubeFrag/geometry/material") + assert material_prim.IsValid() + assert bool(UsdPhysics.MaterialAPI(material_prim)) + assert material_prim.GetAttribute("physics:staticFriction").Get() == pytest.approx(0.65) + # material binding: the mesh prim carries a physics-purpose material binding + binding_api = UsdShade.MaterialBindingAPI(stage.GetPrimAtPath("/World/MeshCubeFrag/geometry/mesh")) + bound_material, _ = binding_api.ComputeBoundMaterial(materialPurpose="physics") + assert bound_material.GetPath() == material_prim.GetPath() + + +# ------------------------------------------------------------------------------------- +# Finding 5: ground-plane spawner must accept a fragment-list physics_material +# ------------------------------------------------------------------------------------- + + +def test_spawn_ground_plane_accepts_fragment_list_physics_material(): + from isaaclab.sim.spawners.from_files.from_files_cfg import GroundPlaneCfg + from isaaclab.sim.spawners.materials.physics_materials_cfg import UsdPhysicsRigidBodyMaterialCfg + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + cfg = GroundPlaneCfg(physics_material=[UsdPhysicsRigidBodyMaterialCfg(static_friction=0.42)]) + prim = cfg.func("/World/groundPlane", cfg) + assert prim.IsValid() + material_prim = stage.GetPrimAtPath("/World/groundPlane/physicsMaterial") + assert material_prim.IsValid() + assert bool(UsdPhysics.MaterialAPI(material_prim)) + assert material_prim.GetAttribute("physics:staticFriction").Get() == pytest.approx(0.42) + # the collision (Plane) prim under the ground plane must bind to the spawned material + from isaaclab.sim.utils import get_first_matching_child_prim + + collision_prim = get_first_matching_child_prim( + "/World/groundPlane", predicate=lambda _prim: _prim.GetTypeName() == "Plane", stage=stage + ) + assert collision_prim is not None + binding_api = UsdShade.MaterialBindingAPI(collision_prim) + bound_material, _ = binding_api.ComputeBoundMaterial(materialPurpose="physics") + assert bound_material.GetPath() == material_prim.GetPath() + + +# ------------------------------------------------------------------------------------- +# Regression: the mesh spawner's rigid-material guard must also accept legacy (non-fragment) +# rigid-body material cfgs, not just the deprecated ``RigidBodyMaterialCfg`` alias. +# ------------------------------------------------------------------------------------- + + +def test_spawn_mesh_with_rigid_props_accepts_legacy_physx_rigid_body_material(): + """Regression test: the mesh guard used to check ``isinstance(cfg.physics_material, + RigidBodyMaterialCfg)`` -- the deprecated PhysX leaf alias -- which rejected the canonical + legacy :class:`~isaaclab_physx.sim.spawners.materials.PhysxRigidBodyMaterialCfg` even though + :func:`~isaaclab.sim.spawners.materials.spawn_physics_material` accepts it.""" + from isaaclab_physx.sim.spawners.materials.physics_materials_cfg import PhysxRigidBodyMaterialCfg + + from isaaclab.sim.spawners.meshes.meshes_cfg import MeshCuboidCfg + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + cfg = MeshCuboidCfg( + size=(1.0, 1.0, 1.0), + rigid_props=sim_utils.RigidBodyPropertiesCfg(), + collision_props=sim_utils.CollisionPropertiesCfg(), + physics_material=PhysxRigidBodyMaterialCfg(static_friction=0.65, dynamic_friction=0.55), + ) + prim = cfg.func("/World/MeshCubeLegacyPhysx", cfg, stage=stage) + assert prim.IsValid() + material_prim = stage.GetPrimAtPath("/World/MeshCubeLegacyPhysx/geometry/material") + assert material_prim.IsValid() + assert bool(UsdPhysics.MaterialAPI(material_prim)) + assert material_prim.GetAttribute("physics:staticFriction").Get() == pytest.approx(0.65) + binding_api = UsdShade.MaterialBindingAPI(stage.GetPrimAtPath("/World/MeshCubeLegacyPhysx/geometry/mesh")) + bound_material, _ = binding_api.ComputeBoundMaterial(materialPurpose="physics") + assert bound_material.GetPath() == material_prim.GetPath() + + +def test_spawn_mesh_with_rigid_props_accepts_legacy_newton_material(): + """Same regression as above for Newton's legacy rigid-body material cfg, which also derives + from :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialBaseCfg` (not the deprecated + PhysX ``RigidBodyMaterialCfg`` alias) and must not be rejected by the mesh guard.""" + from isaaclab_newton.sim.schemas import NewtonMaterialPropertiesCfg + + from isaaclab.sim.spawners.meshes.meshes_cfg import MeshCuboidCfg + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + stage = sim_utils.get_current_stage() + cfg = MeshCuboidCfg( + size=(1.0, 1.0, 1.0), + rigid_props=sim_utils.RigidBodyPropertiesCfg(), + collision_props=sim_utils.CollisionPropertiesCfg(), + physics_material=NewtonMaterialPropertiesCfg(torsional_friction=0.3, rolling_friction=0.001), + ) + prim = cfg.func("/World/MeshCubeLegacyNewton", cfg, stage=stage) + assert prim.IsValid() + material_prim = stage.GetPrimAtPath("/World/MeshCubeLegacyNewton/geometry/material") + assert material_prim.IsValid() + assert bool(UsdPhysics.MaterialAPI(material_prim)) + assert material_prim.GetAttribute("newton:torsionalFriction").Get() == pytest.approx(0.3) + assert material_prim.GetAttribute("newton:rollingFriction").Get() == pytest.approx(0.001) + binding_api = UsdShade.MaterialBindingAPI(stage.GetPrimAtPath("/World/MeshCubeLegacyNewton/geometry/mesh")) + bound_material, _ = binding_api.ComputeBoundMaterial(materialPurpose="physics") + assert bound_material.GetPath() == material_prim.GetPath() + + +# ------------------------------------------------------------------------------------- +# PhysxRigidBodyMaterialCfg (legacy): damping-combine-mode + compliant-contact-acceleration-spring +# ------------------------------------------------------------------------------------- + + +def test_legacy_physx_rigid_body_material_authors_damping_combine_mode_and_acceleration_spring(): + """The legacy :class:`~isaaclab_physx.sim.spawners.materials.PhysxRigidBodyMaterialCfg` must + author the same two ``physxMaterial:*`` attributes as the + :class:`~isaaclab_physx.sim.spawners.materials.PhysxMaterialCfg` fragment, since the legacy + spawner is metadata-driven off the same ``physxMaterial`` namespace.""" + from isaaclab_physx.sim.spawners.materials.physics_materials_cfg import PhysxRigidBodyMaterialCfg + + from isaaclab.sim.spawners.materials import spawn_rigid_body_material + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + cfg = PhysxRigidBodyMaterialCfg(damping_combine_mode="min", compliant_contact_acceleration_spring=True) + prim = spawn_rigid_body_material("/World/MatLegacyPhysxExtra", cfg) + assert "PhysxMaterialAPI" in prim.GetAppliedSchemas() + assert prim.GetAttribute("physxMaterial:dampingCombineMode").Get() == "min" + assert prim.GetAttribute("physxMaterial:compliantContactAccelerationSpring").Get() is True + + +# ------------------------------------------------------------------------------------- +# Generated-terrain routing: create_prim_from_mesh must accept a fragment-list physics_material +# ------------------------------------------------------------------------------------- + + +def test_create_prim_from_mesh_accepts_fragment_list(): + """Generated terrain routes its material through the dispatcher, so a fragment list works + end-to-end instead of crashing on the legacy-only spawn path.""" + import trimesh + from isaaclab_newton.sim.spawners.materials.physics_materials_cfg import NewtonMaterialCfg + + from isaaclab.sim.spawners.materials.physics_materials_cfg import UsdPhysicsRigidBodyMaterialCfg + from isaaclab.terrains.utils import create_prim_from_mesh + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + mesh = trimesh.creation.box(extents=(1.0, 1.0, 0.2)) + create_prim_from_mesh( + "/World/terrainFrag", + mesh, + physics_material=[UsdPhysicsRigidBodyMaterialCfg(static_friction=0.9), NewtonMaterialCfg(rolling_friction=0.1)], + ) + stage = sim_utils.get_current_stage() + mat = stage.GetPrimAtPath("/World/terrainFrag/physicsMaterial") + assert mat.IsValid() + assert mat.GetAttribute("physics:staticFriction").Get() == pytest.approx(0.9) + assert mat.GetAttribute("newton:rollingFriction").Get() == pytest.approx(0.1) + + +def test_legacy_base_cfg_authors_density(): + """The legacy rigid material base authors ``physics:density``, matching the USD fragment. + + ``UsdPhysics.MaterialAPI`` defines four properties; explicitly set values must be available + through both interfaces, including material density read by Newton's importer. + """ + from isaaclab.sim.spawners.materials import spawn_rigid_body_material + from isaaclab.sim.spawners.materials.physics_materials_cfg import RigidBodyMaterialBaseCfg + + sim_utils.create_new_stage() + SimulationContext(SimulationCfg(dt=0.01)) + prim = spawn_rigid_body_material("/World/LegacyDensity", RigidBodyMaterialBaseCfg(density=800.0)) + assert prim.GetAttribute("physics:density").Get() == pytest.approx(800.0) + # None default -> unauthored (backward compatible) + prim2 = spawn_rigid_body_material("/World/LegacyDensityNone", RigidBodyMaterialBaseCfg()) + assert not prim2.GetAttribute("physics:density").HasAuthoredValue() + + +def test_public_default_material_types_remain_backward_compatible(): + """Fragment support must not silently replace the released default config objects.""" + from isaaclab_physx.sim.spawners.materials.physics_materials_cfg import RigidBodyMaterialCfg + + from isaaclab.sim.spawners.from_files.from_files_cfg import GroundPlaneCfg + from isaaclab.terrains.terrain_importer_cfg import TerrainImporterCfg + + defaults = ( + SimulationCfg().physics_material, + GroundPlaneCfg().physics_material, + TerrainImporterCfg(prim_path="/World/terrain").physics_material, + ) + assert all(type(material) is RigidBodyMaterialCfg for material in defaults) + + +def test_physx_fragment_and_legacy_cfg_match_material_api_schema(): + """Both PhysX interfaces cover the properties declared by ``PhysxMaterialAPI``.""" + import dataclasses + + from isaaclab_physx.sim.spawners.materials.physics_materials_cfg import ( + PhysxMaterialCfg, + PhysxRigidBodyMaterialCfg, + ) + + from pxr import PhysxSchema + + from isaaclab.sim.spawners.materials.physics_materials_cfg import RigidBodyMaterialBaseCfg + from isaaclab.utils.string import to_camel_case + + def fields(cls): + return {f.name for f in dataclasses.fields(cls) if f.name != "func"} + + base = fields(RigidBodyMaterialBaseCfg) + schema_attrs = {name.split(":", 1)[1] for name in PhysxSchema.PhysxMaterialAPI.GetSchemaAttributeNames()} + fragment_attrs = {to_camel_case(name, "cC") for name in fields(PhysxMaterialCfg)} + legacy_attrs = {to_camel_case(name, "cC") for name in fields(PhysxRigidBodyMaterialCfg) - base} + assert fragment_attrs == schema_attrs + assert legacy_attrs == schema_attrs + + +# ------------------------------------------------------------------------------------- +# Slot-typing contract +# ------------------------------------------------------------------------------------- + + +def test_material_slot_unions_match_spawner_kind(): + """Structural slot typing: rigid-only spawner slots admit the rigid base + fragments and + exclude the deformable-admitting root; mixed spawners keep the broad root. New slots added + to this list keep the contract enforced.""" + import sys + import typing + + from isaaclab.sim.spawners.from_files.from_files_cfg import FileCfg, GroundPlaneCfg + from isaaclab.sim.spawners.materials import physics_materials_cfg as mats + from isaaclab.sim.spawners.meshes.meshes_cfg import MeshCfg + from isaaclab.sim.spawners.shapes.shapes_cfg import ShapeCfg + from isaaclab.terrains.terrain_importer_cfg import TerrainImporterCfg + + def union_args(cls): + # typing.get_type_hints(cls) resolves annotations across the whole MRO, including the + # inherited ``func: Callable[..., Usd.Prim]`` from ``SpawnerCfg``, whose ``Usd`` import is + # TYPE_CHECKING-only and unresolvable at runtime. Evaluate the ``physics_material`` + # annotation directly against its declaring class's module globals instead -- each class + # in this list declares the field itself, so this never needs the base class's namespace. + annotation = cls.__dict__["__annotations__"]["physics_material"] + resolved = eval(annotation, vars(sys.modules[cls.__module__])) + return set(typing.get_args(resolved)) + + for cls in (ShapeCfg, GroundPlaneCfg, TerrainImporterCfg): + args = union_args(cls) + assert mats.RigidBodyMaterialBaseCfg in args, f"{cls.__name__} must admit the rigid base" + assert mats.RigidBodyMaterialFragment in args, f"{cls.__name__} must admit fragments" + assert mats.PhysicsMaterialCfg not in args, f"{cls.__name__} is rigid-only" + for cls in (FileCfg, MeshCfg): + args = union_args(cls) + assert mats.PhysicsMaterialCfg in args, f"{cls.__name__} spawns deformables too" + assert mats.RigidBodyMaterialFragment in args diff --git a/source/isaaclab/test/sim/test_schemas.py b/source/isaaclab/test/sim/test_schemas.py index 32143a8bb430..f22c7db4ad78 100644 --- a/source/isaaclab/test/sim/test_schemas.py +++ b/source/isaaclab/test/sim/test_schemas.py @@ -752,6 +752,33 @@ def test_modify_properties_on_articulation_usd(setup_simulation): _validate_articulation_properties_on_prim("/World/asset", arti_cfg, True) +@pytest.mark.isaacsim_ci +def test_activate_contact_sensors_nested_rigid_bodies(setup_simulation): + """Test contact-report schemas are applied to nested rigid-body trees.""" + stage = sim_utils.get_current_stage() + + rigid_body_paths = [ + "/World/Robot/Geometry/pelvis", + "/World/Robot/Geometry/pelvis/left_hip", + "/World/Robot/Geometry/pelvis/left_hip/left_knee", + ] + sim_utils.create_prim("/World/Robot", prim_type="Xform") + sim_utils.create_prim("/World/Robot/Geometry", prim_type="Xform") + for prim_path in rigid_body_paths: + sim_utils.create_prim(prim_path, prim_type="Xform") + UsdPhysics.RigidBodyAPI.Apply(stage.GetPrimAtPath(prim_path)) + + schemas.activate_contact_sensors("/World/Robot", threshold=2.5) + + for prim_path in rigid_body_paths: + prim = stage.GetPrimAtPath(prim_path) + applied_schemas = prim.GetAppliedSchemas() + assert "PhysxRigidBodyAPI" in applied_schemas + assert "PhysxContactReportAPI" in applied_schemas + assert prim.GetAttribute("physxRigidBody:sleepThreshold").Get() == pytest.approx(0.0) + assert prim.GetAttribute("physxContactReport:threshold").Get() == pytest.approx(2.5) + + @pytest.mark.isaacsim_ci def test_defining_rigid_body_properties_on_prim(setup_simulation): """Test defining rigid body properties on a prim.""" diff --git a/source/isaaclab/test/test_reinforcement_learning_common.py b/source/isaaclab/test/test_reinforcement_learning_common.py new file mode 100644 index 000000000000..4f563e90ca74 --- /dev/null +++ b/source/isaaclab/test/test_reinforcement_learning_common.py @@ -0,0 +1,219 @@ +# 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 shared reinforcement learning script utilities.""" + +from __future__ import annotations + +import argparse +import importlib.util +import sys +from pathlib import Path +from types import ModuleType, SimpleNamespace +from typing import Any + +import gymnasium as gym +import pytest +import torch + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[3] + + +def _load_rl_common_module() -> ModuleType: + module_path = _repo_root() / "scripts" / "reinforcement_learning" / "common.py" + spec = importlib.util.spec_from_file_location("isaaclab_test_reinforcement_learning_common", module_path) + if spec is None or spec.loader is None: + raise ImportError(f"Could not load reinforcement learning common module from {module_path}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +_rl_common = _load_rl_common_module() +CaptureEnvSensors: Any = getattr(_rl_common, "CaptureEnvSensors") +add_common_train_args: Any = getattr(_rl_common, "add_common_train_args") +enable_cameras_for_video: Any = getattr(_rl_common, "enable_cameras_for_video") +wrap_sensor_capture: Any = getattr(_rl_common, "wrap_sensor_capture") + + +class _FakeEnv(gym.Env): + """Minimal Gymnasium env exposing an IsaacLab-style scene sensor mapping.""" + + def __init__(self, sensors: dict[str, Any] | None = None) -> None: + self.scene = SimpleNamespace(sensors=sensors or {}) + self.closed = False + + def reset(self, **kwargs: Any) -> tuple[dict[str, torch.Tensor], dict[str, Any]]: + return {"obs": torch.zeros(1)}, {} + + def step(self, action: Any) -> tuple[dict[str, torch.Tensor], float, bool, bool, dict[str, Any]]: + return {"obs": torch.ones(1)}, 0.0, False, False, {} + + def close(self) -> None: + self.closed = True + + +def _make_sensor(output: dict[str, Any]) -> SimpleNamespace: + return SimpleNamespace(data=SimpleNamespace(output=output)) + + +def _make_capture_wrapper(tmp_path: Path, **kwargs: Any) -> Any: + defaults = { + "env": _FakeEnv(), + "output_dir": str(tmp_path), + "frame_count": 1, + "capture_num_envs": 1, + "interval": 1, + "output_format": "file", + } + defaults.update(kwargs) + return CaptureEnvSensors(**defaults) + + +def test_capture_env_sensors_saves_file_outputs_on_scheduled_steps( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """File capture writes image grids during the active capture window.""" + rgb = torch.tensor( + [ + [[[0, 127, 255, 9], [255, 0, 127, 9]]], + [[[42, 42, 42, 9], [43, 43, 43, 9]]], + ], + dtype=torch.uint8, + ) + env = _FakeEnv({"front/camera": _make_sensor({"rgb": rgb})}) + saved_images: list[Any] = [] + saved_paths: list[Path] = [] + + class _FakeImage: + def __init__(self, image: Any) -> None: + self.image = image + + def save(self, path: str) -> None: + saved_images.append(self.image.copy()) + saved_paths.append(Path(path)) + + monkeypatch.setattr(_rl_common.Image, "fromarray", _FakeImage) + wrapper = _make_capture_wrapper( + tmp_path, + env=env, + frame_count=2, + capture_num_envs=1, + interval=3, + ) + + wrapper.reset() + wrapper.step(None) + wrapper.step(None) + wrapper.step(None) + + relative_paths = [path.relative_to(tmp_path).as_posix() for path in saved_paths] + assert relative_paths == [ + "front_camera/rgb/episode_00001_step_00000000.png", + "front_camera/rgb/episode_00001_step_00000001.png", + "front_camera/rgb/episode_00001_step_00000003.png", + ] + assert all(image.shape == (1, 2, 4) for image in saved_images) + assert all((image == rgb[0].numpy()).all() for image in saved_images) + + +def test_capture_env_sensors_accepts_proxyarray_torch_buffers(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """ProxyArray-style buffers are read through their ``.torch`` accessor.""" + image_buffer = SimpleNamespace(torch=torch.ones((3, 2, 2, 4), dtype=torch.float32)) + env = _FakeEnv({"camera": _make_sensor({"rgb": image_buffer})}) + captured_tensors: list[torch.Tensor] = [] + + def fake_normalize(tensor: torch.Tensor, data_type: str) -> torch.Tensor: + captured_tensors.append(tensor.clone()) + return tensor + + monkeypatch.setattr(_rl_common, "normalize_camera_output_for_display", fake_normalize) + monkeypatch.setattr(_rl_common, "make_camera_output_grid", lambda images: torch.zeros((4, 1, 1))) + wrapper = _make_capture_wrapper(tmp_path, env=env, capture_num_envs=2) + + wrapper.reset() + + assert len(captured_tensors) == 1 + assert captured_tensors[0].shape == (2, 2, 2, 4) + + +def test_capture_env_sensors_skips_none_outputs(tmp_path: Path) -> None: + """Missing sensor outputs are skipped instead of being written.""" + env = _FakeEnv({"camera": _make_sensor({"rgb": None})}) + wrapper = _make_capture_wrapper(tmp_path, env=env) + + wrapper.reset() + + assert not any(tmp_path.rglob("*.png")) + + +def test_capture_env_sensors_rejects_unknown_output_format(tmp_path: Path) -> None: + """Only tensorboard and file output formats are supported.""" + with pytest.raises(ValueError, match="Unsupported sensor capture output format"): + _make_capture_wrapper(tmp_path, output_format="invalid") + + +def test_wrap_sensor_capture_uses_training_sensor_frame_directory(tmp_path: Path) -> None: + """The train helper wraps the env with the configured sensor capture output directory.""" + env = _FakeEnv() + args_cli = argparse.Namespace( + capture_env_sensors=2, + capture_env_sensors_length=5, + capture_env_sensors_interval=7, + capture_env_sensors_format="file", + ) + + wrapped_env = wrap_sensor_capture(env, str(tmp_path), args_cli) + + assert isinstance(wrapped_env, CaptureEnvSensors) + assert Path(wrapped_env.output_dir) == tmp_path / "sensor_frames" / "train" + assert wrapped_env.frame_count == 5 + assert wrapped_env.capture_num_envs == 2 + assert wrapped_env.interval == 7 + assert wrapped_env.env is env + + +def test_wrap_sensor_capture_returns_env_when_disabled(tmp_path: Path) -> None: + """The train helper leaves the env unwrapped when sensor capture is disabled.""" + env = _FakeEnv() + args_cli = argparse.Namespace(capture_env_sensors=0) + + assert wrap_sensor_capture(env, str(tmp_path), args_cli) is env + + +def test_common_train_args_include_sensor_capture_options() -> None: + """Common train parsers expose sensor capture CLI arguments.""" + parser = argparse.ArgumentParser() + add_common_train_args(parser, agent_default=None, agent_help="", include_agent=False) + + args_cli = parser.parse_args( + [ + "--capture_env_sensors", + "3", + "--capture_env_sensors_length", + "4", + "--capture_env_sensors_interval", + "5", + "--capture_env_sensors_format", + "file", + ] + ) + + assert args_cli.capture_env_sensors == 3 + assert args_cli.capture_env_sensors_length == 4 + assert args_cli.capture_env_sensors_interval == 5 + assert args_cli.capture_env_sensors_format == "file" + + +def test_enable_cameras_for_video_enables_cameras_for_sensor_capture() -> None: + """Sensor capture requires camera rendering even when normal video capture is disabled.""" + args_cli = argparse.Namespace(video=False, capture_env_sensors=1, enable_cameras=False) + + enable_cameras_for_video(args_cli) + + assert args_cli.enable_cameras diff --git a/source/isaaclab/test/utils/test_images.py b/source/isaaclab/test/utils/test_images.py index 92bb3e7de9df..3cf5b9faac2a 100644 --- a/source/isaaclab/test/utils/test_images.py +++ b/source/isaaclab/test/utils/test_images.py @@ -180,3 +180,52 @@ def test_unknown_type_passthrough(self, device, data_type): src = torch.ones((2, 4, 4, 3), device=device) out = normalize_camera_image(src, data_type) assert out is src + + +class TestNormalizeCameraOutputForDisplay: + """Display normalization for capture and golden-image workflows.""" + + def test_rgb_scales_to_unit_range(self, device): + from isaaclab.utils.images import normalize_camera_output_for_display + + src = torch.tensor([[[[0.0, 127.0, 255.0]]]], device=device) + out = normalize_camera_output_for_display(src, "rgb") + expected = torch.tensor([[[[0.0, 127.0 / 255.0, 1.0]]]], device=device) + torch.testing.assert_close(out, expected) + + def test_depth_scales_by_max(self, device): + from isaaclab.utils.images import normalize_camera_output_for_display + + src = torch.tensor([[[[0.0], [2.0], [4.0]]]], device=device) + out = normalize_camera_output_for_display(src, "distance_to_camera") + expected = torch.tensor([[[[0.0], [0.5], [1.0]]]], device=device) + torch.testing.assert_close(out, expected) + + def test_albedo_keeps_rgb_channels(self, device): + from isaaclab.utils.images import normalize_camera_output_for_display + + src = torch.tensor([[[[255.0, 128.0, 64.0, 9.0]]]], device=device) + out = normalize_camera_output_for_display(src, "albedo") + expected = torch.tensor([[[[1.0, 128.0 / 255.0, 64.0 / 255.0]]]], device=device) + torch.testing.assert_close(out, expected) + + def test_motion_vectors_map_uv_to_rgb(self, device): + from isaaclab.utils.images import normalize_camera_output_for_display + + # (u, v) offsets; peak magnitude is 4.0, so values map to [-1, 1] -> [0, 1] and gain a zero B channel. + src = torch.tensor([[[[4.0, -2.0], [0.0, 4.0]]]], device=device) + out = normalize_camera_output_for_display(src, "motion_vectors") + expected = torch.tensor([[[[1.0, 0.25, 0.0], [0.5, 1.0, 0.0]]]], device=device) + assert out.shape[-1] == 3 + torch.testing.assert_close(out, expected) + + +class TestMakeCameraOutputGrid: + """Grid composition for multi-env camera capture.""" + + def test_single_batch_produces_channel_first_grid(self, device): + from isaaclab.utils.images import make_camera_output_grid + + images = torch.ones((1, 2, 3, 3), device=device) + grid = make_camera_output_grid(images) + assert grid.shape == (3, 2, 3) diff --git a/source/isaaclab_assets/config/extension.toml b/source/isaaclab_assets/config/extension.toml index 38e4707a8098..ea533ef3419a 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.4.0" +version = "0.4.2" # Description title = "Isaac Lab Assets" diff --git a/source/isaaclab_assets/docs/CHANGELOG.rst b/source/isaaclab_assets/docs/CHANGELOG.rst index a11a177c97f2..04cc3850c7bd 100644 --- a/source/isaaclab_assets/docs/CHANGELOG.rst +++ b/source/isaaclab_assets/docs/CHANGELOG.rst @@ -1,6 +1,26 @@ Changelog --------- +0.4.2 (2026-07-07) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :data:`~isaaclab_assets.robots.fourbar_pole.FOURBAR_POLE_CFG` for a parallel + four-bar linkage with an inverted pendulum pole on the coupler. + + +0.4.1 (2026-07-04) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :data:`~isaaclab_assets.robots.dr_legs.DR_LEGS_IMPLICIT_PD_CFG` for the Disney DR Legs + closed-loop biped. + + 0.4.0 (2026-06-27) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_assets/isaaclab_assets/__init__.pyi b/source/isaaclab_assets/isaaclab_assets/__init__.pyi index dc69e0b8c71c..84f2c12f68e9 100644 --- a/source/isaaclab_assets/isaaclab_assets/__init__.pyi +++ b/source/isaaclab_assets/isaaclab_assets/__init__.pyi @@ -24,6 +24,7 @@ __all__ = [ "FRANKA_PANDA_CFG", "FRANKA_PANDA_HIGH_PD_CFG", "FRANKA_ROBOTIQ_GRIPPER_CFG", + "FOURBAR_POLE_CFG", "GALBOT_ONE_CHARLIE_CFG", "HUMANOID_CFG", "HUMANOID_28_CFG", @@ -87,6 +88,7 @@ from .robots import ( FRANKA_PANDA_CFG, FRANKA_PANDA_HIGH_PD_CFG, FRANKA_ROBOTIQ_GRIPPER_CFG, + FOURBAR_POLE_CFG, GALBOT_ONE_CHARLIE_CFG, HUMANOID_CFG, HUMANOID_28_CFG, diff --git a/source/isaaclab_assets/isaaclab_assets/robots/__init__.pyi b/source/isaaclab_assets/isaaclab_assets/robots/__init__.pyi index 2440b5d8a795..246bfc05fb07 100644 --- a/source/isaaclab_assets/isaaclab_assets/robots/__init__.pyi +++ b/source/isaaclab_assets/isaaclab_assets/robots/__init__.pyi @@ -24,6 +24,7 @@ __all__ = [ "FRANKA_PANDA_CFG", "FRANKA_PANDA_HIGH_PD_CFG", "FRANKA_ROBOTIQ_GRIPPER_CFG", + "FOURBAR_POLE_CFG", "GALBOT_ONE_CHARLIE_CFG", "HUMANOID_CFG", "HUMANOID_28_CFG", @@ -77,6 +78,7 @@ from .cartpole import CARTPOLE_CFG from .cassie import CASSIE_CFG from .fourier import GR1T2_CFG, GR1T2_HIGH_PD_CFG from .franka import FRANKA_PANDA_CFG, FRANKA_PANDA_HIGH_PD_CFG, FRANKA_ROBOTIQ_GRIPPER_CFG +from .fourbar_pole import FOURBAR_POLE_CFG from .galbot import GALBOT_ONE_CHARLIE_CFG from .humanoid import HUMANOID_CFG from .humanoid_28 import HUMANOID_28_CFG diff --git a/source/isaaclab_assets/isaaclab_assets/robots/dr_legs.py b/source/isaaclab_assets/isaaclab_assets/robots/dr_legs.py new file mode 100644 index 000000000000..5fd8d3acbe5f --- /dev/null +++ b/source/isaaclab_assets/isaaclab_assets/robots/dr_legs.py @@ -0,0 +1,116 @@ +# 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 + +"""Configuration for the Disney DR Legs closed-loop biped. + +DR Legs is a parallel-linkage bipedal lower body with 30 joints (12 actuated + 18 passive +linkage DOFs) plus 6 loop-closing joints. The Kamino solver enforces the loop-closing joints +as bilateral constraints in maximal coordinates. + +The following configuration is available: + +* :data:`DR_LEGS_IMPLICIT_PD_CFG`: DR Legs with implicit (solver-side) PD on the + 12 actuated joints and zero-PD on the 18 passive linkage joints. +""" + +import isaaclab.sim as sim_utils +from isaaclab.actuators import ImplicitActuatorCfg +from isaaclab.assets.articulation import ArticulationCfg +from isaaclab.utils.assets import NEWTON_ASSET_DIR, retrieve_git_asset_path + +_DR_LEGS_USD_PATH = retrieve_git_asset_path(NEWTON_ASSET_DIR, "disneyresearch/dr_legs/usd/dr_legs.usda") + +DR_LEGS_JOINT_ORDER: list[str] = [ + "j1_l_i", + "j2_l_i", + "j3_l_i", + "j4_l_i", + "j6_l_i", + "j7_l_i", + "j8_l_i", + "j1_l_o", + "j2_l_o", + "j3_l_o", + "j4_l_o", + "j5_l_o", + "j6_l_o", + "j7_l_o", + "j8_l_o", + "j1_r_i", + "j2_r_i", + "j3_r_i", + "j4_r_i", + "j6_r_i", + "j7_r_i", + "j8_r_i", + "j1_r_o", + "j2_r_o", + "j3_r_o", + "j4_r_o", + "j5_r_o", + "j6_r_o", + "j7_r_o", + "j8_r_o", +] +"""Canonical ordering of the 30 (6 loop-closure joints are excluded) DR Legs joints.""" + +DR_LEGS_ACTUATED_JOINTS: list[str] = [ + "j1_l_i", + "j2_l_i", + "j6_l_i", + "j7_l_i", + "j2_l_o", + "j7_l_o", + "j1_r_i", + "j2_r_i", + "j6_r_i", + "j7_r_i", + "j2_r_o", + "j7_r_o", +] +"""The 12 servo-driven joints (6 per leg).""" + +DR_LEGS_PASSIVE_JOINTS: list[str] = [j for j in DR_LEGS_JOINT_ORDER if j not in DR_LEGS_ACTUATED_JOINTS] +"""The 18 closed-loop linkage DOFs that are not driven by an actuator on real hardware.""" + + +DR_LEGS_IMPLICIT_PD_CFG = ArticulationCfg( + spawn=sim_utils.UsdFileCfg( + usd_path=_DR_LEGS_USD_PATH, + activate_contact_sensors=True, + rigid_props=sim_utils.RigidBodyPropertiesCfg( + disable_gravity=False, + max_depenetration_velocity=10.0, + enable_gyroscopic_forces=True, + ), + articulation_props=sim_utils.NewtonArticulationRootPropertiesCfg(self_collision_enabled=True), + copy_from_source=False, + ), + init_state=ArticulationCfg.InitialStateCfg( + pos=(0.0, 0.0, 0.27), + # Closed-loop FK is only valid at the assembled reference (all joint coords zero). + joint_pos={".*": 0.0}, + joint_vel={".*": 0.0}, + ), + articulation_root_prim_path="/pelvis", + actuators={ + "driven_joints": ImplicitActuatorCfg( + joint_names_expr=DR_LEGS_ACTUATED_JOINTS, + stiffness=5.0, + damping=0.2, + effort_limit_sim=3.1, + ), + # Linkage DOFs are undriven: explicit zeros so the solver ignores USD drive defaults. + "passive_joints": ImplicitActuatorCfg( + joint_names_expr=DR_LEGS_PASSIVE_JOINTS, + stiffness=0.0, + damping=0.0, + armature=0.0, + friction=0.0, + effort_limit_sim=400.0, + ), + }, +) +"""DR Legs with implicit PD on the 12 actuated joints (Kamino solver, closed-loop).""" diff --git a/source/isaaclab_assets/isaaclab_assets/robots/fourbar_pole.py b/source/isaaclab_assets/isaaclab_assets/robots/fourbar_pole.py new file mode 100644 index 000000000000..a6b3b266f24f --- /dev/null +++ b/source/isaaclab_assets/isaaclab_assets/robots/fourbar_pole.py @@ -0,0 +1,64 @@ +# 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 + +"""Configuration for a parallel four-bar linkage with an inverted pendulum pole on the coupler.""" + +import isaaclab.sim as sim_utils +from isaaclab.actuators import ImplicitActuatorCfg +from isaaclab.assets import ArticulationCfg +from isaaclab.utils.assets import NEWTON_ASSET_DIR, retrieve_git_asset_path + +_FOURBAR_POLE_USD = retrieve_git_asset_path(NEWTON_ASSET_DIR, "fourbar_pole/usd/fourbar_pole.usda") + +## +# Configuration +## + +FOURBAR_POLE_CFG = ArticulationCfg( + spawn=sim_utils.UsdFileCfg( + usd_path=_FOURBAR_POLE_USD, + rigid_props=sim_utils.RigidBodyPropertiesCfg( + rigid_body_enabled=True, + max_linear_velocity=1000.0, + max_angular_velocity=1000.0, + max_depenetration_velocity=100.0, + enable_gyroscopic_forces=True, + ), + articulation_props=sim_utils.ArticulationRootPropertiesCfg( + enabled_self_collisions=False, + solver_position_iteration_count=4, + solver_velocity_iteration_count=0, + sleep_threshold=0.005, + stabilization_threshold=0.001, + ), + ), + init_state=ArticulationCfg.InitialStateCfg( + pos=(0.0, 0.0, 1.5), + joint_pos={ + "ground_to_crank": 0.0, + "crank_to_coupler": 0.0, + "coupler_to_rocker": 0.0, + "coupler_to_pole": 0.0, + }, + ), + actuators={ + "fourbar_actuator": ImplicitActuatorCfg( + joint_names_expr=["ground_to_crank"], + effort_limit_sim=400.0, + stiffness=0.0, + damping=10.0, + ), + # Add an actuator to the pole even though it is not actuated by the user. + # This makes the Kamino forward kinematics solver treat the pole as + # an actuated joint and preserve its user-specified joint position on resets. + "pole_actuator": ImplicitActuatorCfg( + joint_names_expr=["coupler_to_pole"], + effort_limit_sim=400.0, + stiffness=0.0, + damping=0.0, + ), + }, +) +"""Configuration for a parallel four-bar linkage with a pole on the coupler midpoint.""" diff --git a/source/isaaclab_assets/pyproject.toml b/source/isaaclab_assets/pyproject.toml index cbd4b3a156e2..c212565d63a6 100644 --- a/source/isaaclab_assets/pyproject.toml +++ b/source/isaaclab_assets/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_assets" -version = "0.4.0" +version = "0.4.2" description = "Extension containing configuration instances of different assets and sensors." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_contrib/test/deformable/test_rigid_deformable_coupling.py b/source/isaaclab_contrib/test/deformable/test_rigid_deformable_coupling.py index d835f8fb4582..d9c6cbc67dff 100644 --- a/source/isaaclab_contrib/test/deformable/test_rigid_deformable_coupling.py +++ b/source/isaaclab_contrib/test/deformable/test_rigid_deformable_coupling.py @@ -219,6 +219,7 @@ def generate_lateral_rigid_and_deformable_cubes( return rigid_cube, deformable_cube +@pytest.mark.smoke @pytest.mark.parametrize( "sim", [("featherstone", "kinematic")], diff --git a/source/isaaclab_newton/config/extension.toml b/source/isaaclab_newton/config/extension.toml index ebdec1167e11..4894b26e39b8 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 = "1.5.1" +version = "1.6.0" # 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 8a642cda9394..d44e6d25e885 100644 --- a/source/isaaclab_newton/docs/CHANGELOG.rst +++ b/source/isaaclab_newton/docs/CHANGELOG.rst @@ -1,6 +1,60 @@ Changelog --------- +1.6.0 (2026-07-04) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :class:`~isaaclab_newton.sim.spawners.materials.NewtonMaterialCfg`, a single-namespace + ``newton`` rigid-body physics-material fragment (torsional and rolling friction) backing + ``NewtonMaterialAPI``. Composes with other rigid-body material fragments (e.g. + :class:`~isaaclab.sim.spawners.materials.UsdPhysicsRigidBodyMaterialCfg`) in a fragment list. +* Added :attr:`~isaaclab_newton.sim.spawners.materials.NewtonMaterialCfg.contact_stiffness`, + :attr:`~isaaclab_newton.sim.spawners.materials.NewtonMaterialCfg.contact_damping`, + :attr:`~isaaclab_newton.sim.spawners.materials.NewtonMaterialCfg.contact_friction_gain`, and + :attr:`~isaaclab_newton.sim.spawners.materials.NewtonMaterialCfg.contact_adhesion` (writing + ``newton:contactStiffness``, ``newton:contactDamping``, ``newton:contactFrictionGain``, and + ``newton:contactAdhesion``), matching the per-material contact attributes Newton's USD schema + resolver reads in place of the deprecated per-shape ``ke``/``kd``/``kf``/``ka`` parameters. +* Added the contact attributes + (:attr:`~isaaclab_newton.sim.schemas.NewtonMaterialPropertiesCfg.contact_stiffness`, + :attr:`~isaaclab_newton.sim.schemas.NewtonMaterialPropertiesCfg.contact_damping`, + :attr:`~isaaclab_newton.sim.schemas.NewtonMaterialPropertiesCfg.contact_friction_gain`, and + :attr:`~isaaclab_newton.sim.schemas.NewtonMaterialPropertiesCfg.contact_adhesion`) to + :class:`~isaaclab_newton.sim.schemas.NewtonMaterialPropertiesCfg`, matching + :class:`~isaaclab_newton.sim.spawners.materials.NewtonMaterialCfg`. + +Changed +^^^^^^^ + +* **Breaking:** Changed :class:`~isaaclab_newton.physics.NewtonKaminoManager` to require exactly + one articulation per environment. :meth:`~isaaclab_newton.physics.NewtonKaminoManager._build_solver` + raises a ``RuntimeError`` at solver initialization when an environment contains multiple + articulations. Multiple articulations per environment are not yet supported in IsaacLab's Kamino integration. + +* Changed :class:`~isaaclab_newton.physics.NewtonManager` to route forward kinematics through a + solver-specialized hook bound during solver initialization. Kamino overrides this hook to call + :meth:`SolverKamino.reset` with :class:`SolverKamino.ResetConfig.from_joints` when + :attr:`~isaaclab_newton.physics.KaminoSolverCfg.use_fk_solver` is enabled. Environment resets + now share a single per-articulation mask for both :meth:`~isaaclab_newton.physics.NewtonManager.forward` + and pre-step reconcile, replacing the separate per-world Kamino reset mask. + +Fixed +^^^^^ + +* Fixed environment resets writing updated state into the wrong double-buffered simulation + state when ``use_cuda_graph`` was disabled. With an odd number of substeps the canonical input + state buffer flipped each step while asset write paths kept targeting the original binding, so + reset environments stayed inconsistent for solvers with separate input/output states (e.g. + :class:`~isaaclab_newton.physics.NewtonKaminoManager`). + +* Fixed Kamino forward kinematics on environment resets leaving incorrect body poses for + closed-loop systems. Reset environments are now always updated through Kamino's loop-closure FK + solver instead of Newton's articulated ``eval_fk``. + + 1.5.1 (2026-07-01) ~~~~~~~~~~~~~~~~~~ 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 eb0af1c10432..be4c5c92e181 100644 --- a/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation_data.py +++ b/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation_data.py @@ -125,11 +125,12 @@ def update(self, dt: float) -> None: def _ensure_fk_fresh(self) -> None: """Run forward kinematics if joint state has changed since the last FK update. - Newton's ``state.body_q`` (per-body world transforms) is updated by ``eval_fk``, - invoked here through ``SimulationManager.forward()``. After a manual joint or root - write that bypassed the sim step (``write_*_to_sim_*``), ``_fk_timestamp`` is set - to ``-1.0`` to force a refresh on the next read of any property that depends on - body poses (``body_link_pose_w``, the Jacobian properties, ``mass_matrix``). + Newton's ``state.body_q`` (per-body world transforms) is updated by the active + solver manager's ``forward()``, which calls a solver-specialized FK hook. + After a manual joint or root write that bypassed the sim step (``write_*_to_sim_*``), + ``_fk_timestamp`` is set to ``-1.0`` to force a refresh on the next read of any + property that depends on body poses (``body_link_pose_w``, the Jacobian properties, + ``mass_matrix``). """ if self._fk_timestamp < self._sim_timestamp: SimulationManager.forward() diff --git a/source/isaaclab_newton/isaaclab_newton/cloner/newton_clone_utils.py b/source/isaaclab_newton/isaaclab_newton/cloner/newton_clone_utils.py index 976d51dd201b..e73632d656f0 100644 --- a/source/isaaclab_newton/isaaclab_newton/cloner/newton_clone_utils.py +++ b/source/isaaclab_newton/isaaclab_newton/cloner/newton_clone_utils.py @@ -32,6 +32,7 @@ def build_source_builders( for source in sources: builder = create_builder() solvers.SolverMuJoCo.register_custom_attributes(builder) + solvers.SolverKamino.register_custom_attributes(builder) builder.add_usd( stage, root_path=source, diff --git a/source/isaaclab_newton/isaaclab_newton/physics/kamino_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/kamino_manager.py index 292c2c047141..85bc166b2c33 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/kamino_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/kamino_manager.py @@ -14,7 +14,6 @@ from newton.solvers import SolverKamino from isaaclab.physics import PhysicsManager -from isaaclab.utils.timer import Timer from .kamino_manager_cfg import KaminoSolverCfg from .newton_manager import NewtonManager @@ -22,6 +21,31 @@ logger = logging.getLogger(__name__) +def _model_has_loop_closing_joints(model: Model) -> bool: + """Return whether ``model`` contains converted loop-closing articulation joints. + + Newton stores regular tree joints in ``[articulation_start[i], articulation_end[i])`` and + loop-closing joints in ``[articulation_end[i], articulation_start[i + 1])``. Loop closures + are present when the next articulation sentinel exceeds the tree joint end for any + articulation. + + Args: + model: Finalized Newton model to inspect. + + Returns: + ``True`` if at least one articulation has loop-closing joints. + """ + articulation_start = model.articulation_start + articulation_end = model.articulation_end + if articulation_start is None or articulation_end is None: + return False + articulation_start_np = articulation_start.numpy() + articulation_end_np = articulation_end.numpy() + if articulation_end_np.shape[0] == 0: + return False + return bool((articulation_start_np[1:] > articulation_end_np).any()) + + class NewtonKaminoManager(NewtonManager): """:class:`NewtonManager` specialization for the Kamino solver. @@ -30,86 +54,51 @@ class NewtonKaminoManager(NewtonManager): Kamino's internal collision detector handles contact generation. """ + # Annotate the concrete solver type. + _solver: SolverKamino + @classmethod - def _forward_kamino(cls, world_mask: wp.array | None = None) -> None: - """Kamino-specific forward kinematics via ``solver.reset()``. + def _get_kamino_solver_cfg(cls) -> KaminoSolverCfg: + cfg = PhysicsManager._cfg + if cfg is None: + raise RuntimeError("Physics manager is not initialized.") + solver_cfg = getattr(cfg, "solver_cfg", None) + if not isinstance(solver_cfg, KaminoSolverCfg): + raise TypeError(f"Expected KaminoSolverCfg, got {type(solver_cfg).__name__}.") + return solver_cfg + + @classmethod + def _eval_fk_impl(cls, world_reset_mask: wp.array | None, fk_mask: wp.array | None) -> None: + """Update body states from joint coordinates. + + For the Kamino (maximal-coordinate) solver, body poses/velocities are the authoritative + simulation state. When :attr:`KaminoSolverCfg.use_fk_solver` is enabled, this calls + :meth:`SolverKamino.reset`, which runs Kamino's loop-closure forward kinematics: it reads + body poses/velocities from the joint coordinates (including the base body's pose/twist) + and writes back a consistent full joint and body state. - ``SolverKamino.ResetConfig.from_joints()`` consumes Newton's full - canonical state arrays directly, including free-joint coordinates. + When ``use_fk_solver`` is disabled, falls back to Newton's articulated ``eval_fk`` over + ``fk_mask``; the caller is then responsible for writing constraint-consistent joint values. Args: - world_mask: Per-world mask indicating which worlds to reset. - Shape ``(num_worlds,)``, dtype ``wp.bool``. If None, resets all worlds. + world_reset_mask: Per-world mask passed to :meth:`SolverKamino.reset` (``None`` means all). + fk_mask: Per-articulation mask of articulations to update (``None`` means all). """ - cls._solver.reset( - state=cls._state_0, - world_mask=world_mask, - config=SolverKamino.ResetConfig.from_joints(), - ) - - @classmethod - def step(cls) -> None: - """Step the physics simulation.""" - sim = PhysicsManager._sim - if sim is None or not sim.is_playing(): - return - - # Kamino: run solver.reset() with the accumulated world mask to reinitialise - # internal state (warm-start containers, constraint multipliers) for reset worlds. - # Note: runs every step. solver.reset() with an all-False world_mask is a no-op - # (kernels check mask per-world and skip). The cost of a no-op launch is negligible - # compared to the complexity of maintaining a separate boolean guard. - cls._forward_kamino(world_mask=cls._world_reset_mask) - - # Notify solver of model changes - if cls._model_changes: - with wp.ScopedDevice(PhysicsManager._device): - for change in cls._model_changes: - cls._solver.notify_model_changed(change) - NewtonManager._model_changes = set() - - # Lazy CUDA graph capture: deferred from initialize_solver() when RTX was active. - # By the time step() is first called, RTX has fully initialized (all cudaImportExternalMemory - # calls are done) and is idle between render frames — giving us a clean capture window. - cfg = PhysicsManager._cfg - device = PhysicsManager._device - if cls._graph_capture_pending and cfg is not None and cfg.use_cuda_graph and "cuda" in device: # type: ignore[union-attr] - NewtonManager._graph_capture_pending = False - NewtonManager._graph = cls._capture_relaxed_graph(device) - if cls._graph is not None: - # Kamino: StateKamino.from_newton() lazily allocates body_f_total, - # joint_q_prev, and joint_lambdas via wp.clone/wp.zeros during the - # first step() inside graph capture. Replay once to pin those - # memory-pool addresses before any eager solver.reset() call. - wp.capture_launch(cls._graph) - logger.info("Newton CUDA graph captured (deferred relaxed mode, RTX-compatible)") - else: - logger.warning("Newton deferred CUDA graph capture failed; using eager execution") - - # Ensure body_q is up-to-date before collision detection. - # After env resets, joint_q is written but body_q (used by - # broadphase/narrowphase) is stale until FK runs. - # Only runs FK for dirtied articulations via the accumulated mask. - if cls._needs_collision_pipeline: - eval_fk(cls._model, cls._state_0.joint_q, cls._state_0.joint_qd, cls._state_0, cls._fk_reset_mask) - - # Zero both masks after consumption - NewtonManager._world_reset_mask.zero_() - NewtonManager._fk_reset_mask.zero_() - - # Step simulation (graphed or not; _graph is None when capture is disabled or failed) - if cfg is not None and cfg.use_cuda_graph and cls._graph is not None and "cuda" in device: # type: ignore[union-attr] - wp.capture_launch(cls._graph) + if cls._get_kamino_solver_cfg().use_fk_solver: + cls._solver.reset( + cls._state_0, + world_mask=world_reset_mask, + config=SolverKamino.ResetConfig.from_joints(), + ) else: - with wp.ScopedDevice(device): - cls._simulate_physics_only() - if cls._usdrt_stage is not None: - cls._mark_transforms_dirty() + eval_fk(cls._model, cls._state_0.joint_q, cls._state_0.joint_qd, cls._state_0, fk_mask) - # Launch solver-specific debug logging after stepping. - cls._log_solver_debug() - - PhysicsManager._sim_time += cls._solver_dt * cls._num_substeps + # Reset solver internals without performing Kamino's FK. + cls._solver.reset( + cls._state_0, + world_mask=world_reset_mask, + config=SolverKamino.ResetConfig.preserve(), + ) @classmethod def _build_solver(cls, model: Model, solver_cfg: KaminoSolverCfg) -> None: @@ -118,40 +107,41 @@ def _build_solver(cls, model: Model, solver_cfg: KaminoSolverCfg) -> None: Sets :attr:`NewtonManager._needs_collision_pipeline` to ``True`` only when ``use_collision_detector=False`` (Kamino's internal detector handles contacts otherwise). + + Sets :attr:`NewtonManager._needs_fk_before_step` because Kamino treats body state as + authoritative: reset worlds (written via joint coordinates) must be reconciled before each + step. The shared :attr:`NewtonManager._world_reset_mask` and + :attr:`NewtonManager._fk_reset_mask` restrict that pre-step reconcile and + :meth:`NewtonManager.forward` to reset worlds, so non-reset worlds keep their live, + authoritative body state through Kamino's :meth:`_eval_fk_impl` overwrite. + + Raises: + RuntimeError: If the model has more than one articulation per environment. The Kamino + interface in IsaacLab currently only supports one articulation per environment. """ + + # Set the max contacts per world if specified. + if solver_cfg.max_contacts_per_world is not None: + model.rigid_contact_max = int(solver_cfg.max_contacts_per_world) * model.world_count + logger.info( + "[KAMINO] Capping rigid_contact_max to %d (%d/world * %d worlds)", + model.rigid_contact_max, + solver_cfg.max_contacts_per_world, + model.world_count, + ) + + # Set the use_fk_solver flag based on the model's articulation structure if not specified by user. + if solver_cfg.use_fk_solver is None: + solver_cfg.use_fk_solver = _model_has_loop_closing_joints(model) + + if solver_cfg.use_fk_solver and model.articulation_count != model.world_count: + raise RuntimeError( + "The Kamino FK solver requires exactly one articulation per environment, but the model" + f" has {model.articulation_count} articulations across {model.world_count} environments." + " Multiple articulations per environment are not yet supported in Kamino's FK solver." + ) + NewtonManager._solver = SolverKamino(model, solver_cfg.to_solver_config()) NewtonManager._use_single_state = False NewtonManager._needs_collision_pipeline = not solver_cfg.use_collision_detector - - @classmethod - def _capture_or_defer_cuda_graph(cls) -> None: - """Capture the physics CUDA graph, or defer if RTX is initializing.""" - cfg = PhysicsManager._cfg - device = PhysicsManager._device - use_cuda_graph = cfg is not None and cfg.use_cuda_graph and "cuda" in device # type: ignore[union-attr] - - with Timer(name="newton_cuda_graph", msg="CUDA graph took:"): - if not use_cuda_graph: - NewtonManager._graph = None - return - if cls._usdrt_stage is None: - # No RTX active — use standard Warp capture (cudaStreamCaptureModeGlobal). - with wp.ScopedCapture() as capture: - cls._simulate_physics_only() - NewtonManager._graph = capture.graph - logger.info("Newton CUDA graph captured (standard Warp mode)") - - # TODO: streamline this with base NewtonManager - # Kamino: StateKamino.from_newton() lazily allocates body_f_total, - # joint_q_prev, and joint_lambdas via wp.clone/wp.zeros during the - # first step() inside graph capture. Replay once to pin those - # memory-pool addresses before any eager solver.reset() call. - wp.capture_launch(cls._graph) - else: - # RTX is active during initialization — cudaImportExternalMemory and other - # non-capturable RTX ops run on background CUDA streams right now. - # Defer capture to the first step() call, after RTX is fully initialized - # and idle between render frames (clean capture window). - NewtonManager._graph = None - NewtonManager._graph_capture_pending = True - logger.info("Newton CUDA graph capture deferred until first step() (RTX active)") + NewtonManager._needs_fk_before_step = True diff --git a/source/isaaclab_newton/isaaclab_newton/physics/kamino_manager_cfg.py b/source/isaaclab_newton/isaaclab_newton/physics/kamino_manager_cfg.py index e88e54e59ab3..90fbb067aaf0 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/kamino_manager_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/kamino_manager_cfg.py @@ -48,13 +48,33 @@ class KaminoSolverCfg(NewtonSolverCfg): use_collision_detector: bool = False """Whether to use Kamino's internal collision detector instead of Newton's pipeline.""" - use_fk_solver: bool = True + use_fk_solver: bool | None = None """Whether to enable the forward kinematics solver for state resets. - Required for proper environment resets. The FK solver computes consistent body poses - from joint angles after state writes, which is essential for maximal-coordinate solvers. + When ``None``, Kamino will automatically determine whether to use the FK solver based on the model's + articulation structure. If the model has loop-closing joints, the FK solver will be used. + + When ``True``, :meth:`NewtonKaminoManager._eval_fk_impl` reconciles body state via + :meth:`SolverKamino.reset` with :class:`SolverKamino.ResetConfig.from_joints`. Kamino's FK + solver computes consistent body poses/velocities from the joint coordinates (including the + base joint for floating bases), resolves passive / loop-closure joints, and writes back a + consistent full joint state. Environment resets only need to write actuated DOFs in + ``joint_q``; passive values are filled in by FK. This is required for closed-loop systems. + + When ``False``, Newton's articulated ``eval_fk`` is used instead over the full + ``joint_q`` / ``joint_qd``. It is then up to the user to specify constraint-consistent + values. This is the faster option for purely articulated (tree-structured) systems. """ + fk_use_regularization: bool = True + """Whether to regularize the FK reset solve (Tikhonov term on body poses).""" + + fk_regularization_weight: float = 1e-5 + """Weight of the FK reset regularizer, used when :attr:`fk_use_regularization` is ``True``.""" + + fk_tolerance: float = 1e-5 + """Convergence tolerance of the FK reset solve.""" + sparse_jacobian: bool = False """Whether to use sparse Jacobian computation.""" @@ -145,6 +165,16 @@ class KaminoSolverCfg(NewtonSolverCfg): Only used when :attr:`use_collision_detector` is ``True``. If ``None``, Newton's default is used. """ + max_contacts_per_world: int | None = None + """Cap the per-world contact pre-allocation handed to Kamino. + + When ``None``, Kamino falls back to ``geoms.world_minimum_contacts`` derived from the + collision pipeline, which over-allocates dramatically for contact-rich assets. Set this + to bound GPU memory for multi-env training of contact-heavy tasks (e.g. legged + locomotion or manipulation). The total ``model.rigid_contact_max`` is computed as + ``max_contacts_per_world * model.world_count`` before solver construction. + """ + dynamics_preconditioning: bool = True """Whether to use preconditioning in the constrained dynamics solver. @@ -165,10 +195,15 @@ def to_solver_config(self) -> SolverKamino.Config: CollisionDetectorConfig, ConstrainedDynamicsConfig, ConstraintStabilizationConfig, + ForwardKinematicsSolverConfig, PADMMSolverConfig, ) from newton.solvers import SolverKamino + # Kamino Manager will set the automatic value before. This is a fallback to true if that mechanism was bypassed. + if self.use_fk_solver is None: + self.use_fk_solver = True + # Build collision detector config if using Kamino's internal detector collision_detector = None if self.use_collision_detector: @@ -190,6 +225,11 @@ def to_solver_config(self) -> SolverKamino.Config: collect_solver_info=self.collect_solver_info, compute_solution_metrics=self.compute_solution_metrics, collision_detector=collision_detector, + fk=ForwardKinematicsSolverConfig( + use_regularization=self.fk_use_regularization, + regularization_weight=self.fk_regularization_weight, + tolerance=self.fk_tolerance, + ), constraints=ConstraintStabilizationConfig( alpha=self.constraints_alpha, beta=self.constraints_beta, diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index 7d04c6e0ea5d..0a953d434eeb 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -190,6 +190,18 @@ def state(self) -> Model: return NewtonManager.get_state_0() +def _eval_fk_unbound(world_reset_mask: wp.array | None, fk_mask: wp.array | None) -> None: + """Default :attr:`NewtonManager._eval_fk` value before a solver is initialized. + + Raises so a stray ``forward()`` / ``step()`` before ``initialize_solver()`` fails loudly + instead of silently running a wrong (or no) FK. + """ + raise RuntimeError( + "FK hook is not bound. NewtonManager.initialize_solver() must run " + "(via reset()) before forward()/step() can run forward kinematics." + ) + + class NewtonManager(PhysicsManager): """Abstract Newton physics manager for Isaac Lab. @@ -254,9 +266,12 @@ def provides_implicit_damping(cls) -> bool: _pending_extended_state_attributes: set[str] = set() _pending_extended_contact_attributes: set[str] = set() _report_contacts: bool = False - # Per-world reset masks (allocated in start_simulation, consumed in step) + + # Per-world reset masks (allocated in start_simulation, consumed in step/forward). _world_reset_mask: wp.array | None = None # (num_envs,) wp.bool — for SolverKamino.reset(world_mask=...) _fk_reset_mask: wp.array | None = None # (articulation_count,) wp.bool — for eval_fk(mask=...) + # Solver-specialized FK delegate. Bound in initialize_solver() to the active subclass's choice of FK implementation. + _eval_fk: Callable[[wp.array | None, wp.array | None], None] = _eval_fk_unbound # Newton actuator adapter (owns actuators and double-buffered states) _adapter: NewtonActuatorAdapter | None = None @@ -268,6 +283,10 @@ def provides_implicit_damping(cls) -> bool: # CUDA graphing _graph = None _graph_capture_pending: bool = False + # When True, the next _capture_or_defer_graph() (e.g. after a hard reset) + # defers capture to the first step() instead of capturing synchronously, + # so it runs on a fully-idle device against freshly-rebuilt buffers. + _defer_capture_next: bool = False # USD/Fabric sync _newton_stage_path = None @@ -360,20 +379,111 @@ def reset(cls, soft: bool = False) -> None: soft: If True, skip full reinitialization. """ if not soft: + # A full (hard) reset reallocates the Newton model/state/control, + # solver, collision pipeline and contact buffers (see + # ``start_simulation`` / ``initialize_solver``). Any CUDA graph we + # previously captured baked in the OLD device pointers of those + # buffers, so replaying it after this reset would dereference freed + # memory (CUDA error 700 in ``wp_cuda_graph_launch``). + # + # We CANNOT re-capture synchronously here: back-to-back capture of + # the contact+actuator pipeline immediately after a rebuild is + # unstable. Instead we: + # 1. Hard-invalidate the graph up front so ``step()`` falls back + # to eager execution and can never launch a stale graph. + # 2. Schedule a DEFERRED re-capture; ``step()`` re-captures against + # the freshly allocated buffers in a clean idle window. + had_graph = cls._graph is not None or cls._graph_capture_pending + NewtonManager._graph = None + NewtonManager._graph_capture_pending = False + # Force the capture inside initialize_solver() to defer to the first + # step() (idle device) instead of re-capturing synchronously on a + # model whose rebuild just freed the buffers the previous capture and + # its cached graph-exec/module state referenced. + NewtonManager._defer_capture_next = True + + # ROOT-CAUSE FIX: NewtonManager.reset() re-finalizes the model + rebuilds + # the solver/state, but ``_initialize_contacts`` only builds a NEW + # CollisionPipeline ``if cls._collision_pipeline is None`` -- and reset() + # historically never nulled it. So the STALE CollisionPipeline (and its + # ``_contacts``), still bound to the OLD (now-freed) model's shape_* / + # broad-phase candidate_pair device buffers, was reused against the + # freshly-finalized model. Its first (eager) narrow-phase launch + # (``narrow_phase_kernel_gjk_mpr``) then reads freed/mismatched memory -> + # CUDA 700, entirely outside any CUDA graph. Null them here so + # ``initialize_solver()`` rebuilds a fresh pipeline + contacts on the new + # model. + if cls._collision_pipeline is not None or cls._contacts is not None: + logger.debug( + "NewtonManager.reset() releasing stale CollisionPipeline + contacts " + "so they are rebuilt against the re-finalized model." + ) + NewtonManager._collision_pipeline = None + NewtonManager._contacts = None + cls.start_simulation() cls.initialize_solver() + cfg = PhysicsManager._cfg + device = PhysicsManager._device + wants_graph = ( + cfg is not None + and getattr(cfg, "use_cuda_graph", False) + and device is not None + and "cuda" in device + and cls._supports_cuda_graph_capture() + ) + if wants_graph: + # Drop stale Warp graph-exec / module state carried across the + # rebuild, then make sure all rebuild allocations/copies have + # completed before the deferred capture launches. + with contextlib.suppress(Exception): + wp.synchronize_device(device) + NewtonManager._graph_capture_pending = True + if had_graph: + logger.debug( + "NewtonManager.reset() invalidated an active CUDA graph; it will be " + "re-captured on the next step() against the freshly-allocated buffers." + ) + else: + NewtonManager._defer_capture_next = False + + @classmethod + def _eval_fk_impl(cls, world_reset_mask: wp.array | None, fk_mask: wp.array | None) -> None: + """Update body states from joint coordinates. + + Solver-specialized FK implementation. The base implementation runs Newton's generic + ``eval_fk`` over the articulations selected by ``fk_mask``. Subclasses may override + this method to use a solver-specific FK. + + Args: + world_reset_mask: Per-world mask of environments to reset (``None`` means all). + Unused by the base implementation; consumed by solver-specific overrides such as + :meth:`NewtonKaminoManager._eval_fk_impl`. + fk_mask: Per-articulation mask of articulations to update (``None`` means all). + """ + eval_fk(cls._model, cls._state_0.joint_q, cls._state_0.joint_qd, cls._state_0, fk_mask) + @classmethod def forward(cls) -> None: """Update articulation kinematics without stepping physics. - Runs Newton's generic forward kinematics (``eval_fk``) over **all** - articulations to compute body poses from joint coordinates. This is - the full (unmasked) FK path used during initial setup. For incremental - per-environment updates after resets, see :meth:`invalidate_fk` which - accumulates masks consumed by :meth:`step`. + Update body poses from joint coordinates via the solver-specialized FK delegate + (:attr:`_eval_fk`, bound to the active subclass's :meth:`_eval_fk_impl` in + :meth:`initialize_solver`). Only the articulations flagged dirty in + :attr:`_fk_reset_mask` and :attr:`_world_reset_mask` (see :meth:`invalidate_fk`) are + updated. The masks are consumed (zeroed) afterwards so the next :meth:`step` does not + redundantly re-solve them. + + The delegate (rather than a direct ``cls._eval_fk_impl`` call) is required because the + data layer invokes ``NewtonManager.forward()`` on the base class, where ``cls`` is the + base ``NewtonManager``; the bound delegate dispatches to the concrete subclass override. """ - eval_fk(cls._model, cls._state_0.joint_q, cls._state_0.joint_qd, cls._state_0, None) + cls._eval_fk(cls._world_reset_mask, cls._fk_reset_mask) + if cls._fk_reset_mask is not None: + cls._fk_reset_mask.zero_() + if cls._world_reset_mask is not None: + cls._world_reset_mask.zero_() @classmethod def pre_render(cls) -> None: @@ -651,6 +761,12 @@ def step(cls) -> None: NewtonManager._graph_capture_pending = False NewtonManager._graph = cls._capture_relaxed_graph(device) if cls._graph is not None: + # Kamino: StateKamino.from_newton() lazily allocates body_f_total, + # joint_q_prev, and joint_lambdas via wp.clone/wp.zeros during the + # first step() inside graph capture. Replay once to pin those + # memory-pool addresses before any eager solver.reset() call. + if isinstance(cls._solver, SolverKamino): + wp.capture_launch(cls._graph) logger.info("Newton CUDA graph captured (deferred relaxed mode, RTX-compatible)") else: logger.warning("Newton deferred CUDA graph capture failed; using eager execution") @@ -659,10 +775,11 @@ def step(cls) -> None: # After env resets or kinematic root writes, joint_q is written but # body_q is stale until FK runs. Collision-based solvers need this for # broadphase/narrowphase; collider-based solvers such as MPM need it - # for their internal collider queries. + # for their internal collider queries. Maximal-coordinate solvers + # that treat body state as the main state (e.g. Kamino) require FK before step. # Only runs FK for dirtied articulations via the accumulated mask. if cls._needs_collision_pipeline or cls._needs_fk_before_step: - eval_fk(cls._model, cls._state_0.joint_q, cls._state_0.joint_qd, cls._state_0, cls._fk_reset_mask) + cls._eval_fk(cls._world_reset_mask, cls._fk_reset_mask) # Zero both masks after consumption NewtonManager._world_reset_mask.zero_() @@ -759,6 +876,7 @@ def clear(cls): NewtonManager._contacts = None NewtonManager._needs_collision_pipeline = False NewtonManager._needs_fk_before_step = False + NewtonManager._eval_fk = _eval_fk_unbound NewtonManager._collision_pipeline = None NewtonManager._collision_cfg = None NewtonManager._newton_contact_sensors = {} @@ -1126,7 +1244,8 @@ def start_simulation(cls) -> None: NewtonManager._state_0 = cls._model.state() NewtonManager._state_1 = cls._model.state() NewtonManager._control = cls._model.control() - eval_fk(cls._model, cls._state_0.joint_q, cls._state_0.joint_qd, cls._state_0, None) + # The initial body-state update from joint coordinates is deferred to the tail of + # initialize_solver(), where it runs through the solver-specialized FK delegate after the solver is initialized. # The single global actuator adapter is built lazily on the first # call to ``activate_newton_actuator_path`` from any Newton-fast-path @@ -1136,7 +1255,7 @@ def start_simulation(cls) -> None: NewtonManager._adapter = None NewtonManager._use_newton_actuators_active = False - # Allocate per-world reset masks (used by all solvers for masked FK, and by Kamino for masked reset) + # Allocate per-world reset masks (used by all solvers for masked FK, and by Kamino for masked reset). NewtonManager._world_reset_mask = wp.zeros(cls._model.world_count, dtype=wp.bool, device=device) NewtonManager._fk_reset_mask = wp.zeros(cls._model.articulation_count, dtype=wp.bool, device=device) @@ -1409,6 +1528,17 @@ def initialize_solver(cls) -> None: ) cls._initialize_contacts() + # Bind the solver-specialized FK delegate to the active subclass's _eval_fk_impl so + # that forward()/step() dispatch correctly even when forward() is invoked through the + # base class (the data layer imports NewtonManager directly). ``cls`` is the concrete + # subclass here, since initialize_solver is reached via sim.physics_manager.reset(). + NewtonManager._eval_fk = cls._eval_fk_impl + + # Establish the initial kinematically-consistent body state through the + # solver-specialized FK delegate, now that the solver and the delegate both exist. + # Runs before graph capture below so the capture warmup sees a valid body_q. + cls._eval_fk(None, None) + if cls._usdrt_stage is not None: cls._setup_cubric_bindings() @@ -1474,7 +1604,7 @@ def _capture_or_defer_graph(cls) -> None: if use_cuda_graph: with Timer(name="newton_cuda_graph", msg="CUDA graph took:"): - if cls._usdrt_stage is None: + if cls._usdrt_stage is None and not cls._defer_capture_next: simulate = cls._simulate_full if cls._is_all_graphable() else cls._simulate_physics_only with wp.ScopedCapture() as capture: simulate() @@ -1487,6 +1617,15 @@ def _capture_or_defer_graph(cls) -> None: # memory-pool addresses before any eager solver.reset() call. if isinstance(cls._solver, SolverKamino): wp.capture_launch(cls._graph) + elif cls._defer_capture_next: + # Reset path: defer capture to the first step() so it happens on a + # fully-idle device against the freshly-rebuilt buffers (avoids the + # second-capture fault seen when re-capturing synchronously right + # after a model/solver/contact rebuild). + NewtonManager._graph = None + NewtonManager._graph_capture_pending = True + NewtonManager._defer_capture_next = False + logger.info("Newton CUDA graph capture deferred (post-reset, idle-window)") else: # RTX is active during initialization — cudaImportExternalMemory and other # non-capturable RTX ops run on background CUDA streams right now. @@ -1645,7 +1784,7 @@ def _run_solver_substeps(cls, contacts) -> None: cls._collision_pipeline.collide(cls._state_0, contacts) else: cfg = PhysicsManager._cfg - need_copy_on_last = (cfg is not None and cfg.use_cuda_graph) and cls._num_substeps % 2 == 1 # type: ignore[union-attr] + need_copy_on_last = cfg is not None and cls._num_substeps % 2 == 1 for i in range(cls._num_substeps): cls._step_solver(cls._state_0, cls._state_1, cls._control, contacts, cls._solver_dt) if need_copy_on_last and i == cls._num_substeps - 1: diff --git a/source/isaaclab_newton/isaaclab_newton/sim/__init__.pyi b/source/isaaclab_newton/isaaclab_newton/sim/__init__.pyi index 27677fbd6dbf..1e684304e810 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/__init__.pyi +++ b/source/isaaclab_newton/isaaclab_newton/sim/__init__.pyi @@ -7,6 +7,7 @@ __all__ = [ "NewtonDeformableBodyPropertiesCfg", "NewtonDeformableBodyMaterialCfg", "NewtonDeformableMaterialCfg", + "NewtonMaterialCfg", "NewtonSurfaceDeformableBodyMaterialCfg", "MPMGridCfg", "MPMParticleMaterialCfg", @@ -22,6 +23,7 @@ from .schemas import NewtonDeformableBodyPropertiesCfg from .spawners.materials import ( NewtonDeformableBodyMaterialCfg, NewtonDeformableMaterialCfg, + NewtonMaterialCfg, NewtonSurfaceDeformableBodyMaterialCfg, ) from .spawners.mpm import MPMGridCfg, MPMParticleMaterialCfg, MPMParticleSpawnerCfg, MPMPointsCfg diff --git a/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py b/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py index 05d63921efde..86485af3aa7f 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/sim/schemas/schemas_cfg.py @@ -501,6 +501,35 @@ class NewtonMaterialPropertiesCfg(RigidBodyMaterialBaseCfg): Range: [0, inf). """ + contact_stiffness: float | None = None + """Contact normal-force stiffness [N/m]. + + Writes ``newton:contactStiffness``. Replaces the deprecated per-shape ``ke`` contact parameter; + used by the SemiImplicit, Featherstone, MuJoCo, and VBD solvers. + """ + + contact_damping: float | None = None + """Contact normal-force damping coefficient [N·s/m]. + + Writes ``newton:contactDamping``. Replaces the deprecated per-shape ``kd`` contact parameter; + used by the SemiImplicit, Featherstone, MuJoCo, and VBD solvers. + """ + + contact_friction_gain: float | None = None + """Friction-force stiffness gain used by the tangential (friction) contact response [N·s/m]. + + Writes ``newton:contactFrictionGain``. Replaces the deprecated per-shape ``kf`` contact + parameter; used by the SemiImplicit and Featherstone solvers. + """ + + contact_adhesion: float | None = None + """Contact adhesion distance: shapes closer than this threshold experience an attractive + (adhesive) force [m]. + + Writes ``newton:contactAdhesion``. Replaces the deprecated per-shape ``ka`` contact parameter; + used by the SemiImplicit and Featherstone solvers. + """ + @configclass class MujocoFixedTendonCfg(FixedTendonFragment): diff --git a/source/isaaclab_newton/isaaclab_newton/sim/spawners/materials/__init__.pyi b/source/isaaclab_newton/isaaclab_newton/sim/spawners/materials/__init__.pyi index c3f13216f781..5ee626b240bd 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/spawners/materials/__init__.pyi +++ b/source/isaaclab_newton/isaaclab_newton/sim/spawners/materials/__init__.pyi @@ -7,6 +7,7 @@ __all__ = [ "spawn_deformable_body_material", "NewtonDeformableBodyMaterialCfg", "NewtonDeformableMaterialCfg", + "NewtonMaterialCfg", "NewtonSurfaceDeformableBodyMaterialCfg", ] @@ -14,5 +15,6 @@ from .physics_materials import spawn_deformable_body_material from .physics_materials_cfg import ( NewtonDeformableBodyMaterialCfg, NewtonDeformableMaterialCfg, + NewtonMaterialCfg, NewtonSurfaceDeformableBodyMaterialCfg, ) diff --git a/source/isaaclab_newton/isaaclab_newton/sim/spawners/materials/physics_materials_cfg.py b/source/isaaclab_newton/isaaclab_newton/sim/spawners/materials/physics_materials_cfg.py index f7b63a8701c6..e0027a815530 100644 --- a/source/isaaclab_newton/isaaclab_newton/sim/spawners/materials/physics_materials_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/sim/spawners/materials/physics_materials_cfg.py @@ -10,6 +10,7 @@ from isaaclab.sim.spawners.materials.physics_materials_cfg import ( DeformableBodyMaterialBaseCfg, + RigidBodyMaterialFragment, SurfaceDeformableBodyMaterialBaseCfg, ) from isaaclab.utils.configclass import configclass @@ -77,3 +78,73 @@ class NewtonSurfaceDeformableBodyMaterialCfg(SurfaceDeformableBodyMaterialBaseCf edge_kd: float = 1e-2 """Bending damping [N*m*s]. Used by Newton backend for cloth meshes.""" + + +@configclass +class NewtonMaterialCfg(RigidBodyMaterialFragment): + """``newton:*`` rigid-body material attributes read by Newton's USD material schema resolver. + + Single-namespace fragment (see + :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialFragment`) for the Newton-only + friction knobs (torsional and rolling friction) and the per-material contact model (contact + stiffness/damping, friction gain, adhesion) that replaces the deprecated per-shape + ``ke``/``kd``/``kf``/``ka`` parameters. The ``NewtonMaterialAPI`` schema is applied (and the + ``newton:*`` attributes authored) by the generic :func:`~isaaclab.sim.schemas.apply_namespaced` + writer. ``None`` fields are left unchanged. + + .. note:: + The generated ``NewtonMaterialAPI`` USD schema currently only declares the two friction + attributes; the four contact attributes are still authored as raw ``newton:*`` USD + attributes and are read directly by Newton's schema resolver. + + Composes with other rigid-body material fragments (e.g. + :class:`~isaaclab.sim.spawners.materials.UsdPhysicsRigidBodyMaterialCfg`) in the same fragment + list passed to + :func:`~isaaclab.sim.spawners.materials.spawn_rigid_body_material_from_fragments`. For the + legacy (non-fragment) equivalent, see + :class:`~isaaclab_newton.sim.schemas.NewtonMaterialPropertiesCfg`. + """ + + _usd_namespace: ClassVar[str | None] = "newton" + _usd_applied_schema: ClassVar[str | None] = "NewtonMaterialAPI" + + torsional_friction: float | None = None + """Torsional friction coefficient (resistance to spinning at a contact point) [dimensionless]. + + Writes ``newton:torsionalFriction``. Range: [0, inf). + """ + + rolling_friction: float | None = None + """Rolling friction coefficient (resistance to rolling motion) [dimensionless]. + + Writes ``newton:rollingFriction``. Range: [0, inf). + """ + + contact_stiffness: float | None = None + """Contact normal-force stiffness [N/m]. + + Writes ``newton:contactStiffness``. Replaces the deprecated per-shape ``ke`` contact parameter; + used by the SemiImplicit, Featherstone, MuJoCo, and VBD solvers. + """ + + contact_damping: float | None = None + """Contact normal-force damping coefficient [N·s/m]. + + Writes ``newton:contactDamping``. Replaces the deprecated per-shape ``kd`` contact parameter; + used by the SemiImplicit, Featherstone, MuJoCo, and VBD solvers. + """ + + contact_friction_gain: float | None = None + """Friction-force stiffness gain used by the tangential (friction) contact response [N·s/m]. + + Writes ``newton:contactFrictionGain``. Replaces the deprecated per-shape ``kf`` contact + parameter; used by the SemiImplicit and Featherstone solvers. + """ + + contact_adhesion: float | None = None + """Contact adhesion distance: shapes closer than this threshold experience an attractive + (adhesive) force [m]. + + Writes ``newton:contactAdhesion``. Replaces the deprecated per-shape ``ka`` contact parameter; + used by the SemiImplicit and Featherstone solvers. + """ diff --git a/source/isaaclab_newton/pyproject.toml b/source/isaaclab_newton/pyproject.toml index c01a6cc1ebad..a855efb7ae63 100644 --- a/source/isaaclab_newton/pyproject.toml +++ b/source/isaaclab_newton/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_newton" -version = "1.5.1" +version = "1.6.0" description = "Extension providing IsaacLab with Newton specific abstractions." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_newton/test/cloner/test_rename_builder_labels.py b/source/isaaclab_newton/test/cloner/test_rename_builder_labels.py index 28f116a68d73..2de4e556b288 100644 --- a/source/isaaclab_newton/test/cloner/test_rename_builder_labels.py +++ b/source/isaaclab_newton/test/cloner/test_rename_builder_labels.py @@ -334,6 +334,7 @@ def test_visualization_builder_uses_clone_plan_sources_and_rewrites_labels(self) mock.patch.object(visualization_builder_module, "SchemaResolverNewton", lambda: object()), mock.patch.object(visualization_builder_module, "SchemaResolverPhysx", lambda: object()), mock.patch.object(newton_clone_utils_module.solvers.SolverMuJoCo, "register_custom_attributes"), + mock.patch.object(newton_clone_utils_module.solvers.SolverKamino, "register_custom_attributes"), ): builder = visualization_builder_module.build_visualization_builder_from_stage_envs( stage, env_paths, clone_plan diff --git a/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py b/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py index 991a5f63da89..56bf4c33f1f1 100644 --- a/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py +++ b/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py @@ -651,3 +651,72 @@ def counting_collide(state, contacts): # Expect: 1 (top-of-tick) + expected_mid_loop_collides. assert calls["n"] == 1 + expected_mid_loop_collides + + +# --------------------------------------------------------------------------- +# Regression: an env reset written through the data layer must land in the +# manager's canonical _state_0 after an odd number of steps when CUDA graphs +# are disabled (the use_cuda_graph state-swap gating bug). +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("num_steps", [1, 3]) +def test_reset_lands_in_state_0_after_odd_kamino_steps_without_cuda_graph(num_steps): + """An env reset written through the data-layer binding lands in ``_state_0``. + + Kamino is double-buffered (``_use_single_state=False``), so each substep + ping-pongs ``_state_0`` / ``_state_1``. With a single substep the loop must + copy the result back into ``_state_0`` instead of swapping, otherwise after + an *odd* number of steps the canonical ``_state_0`` ends up on the other + buffer. This copy-on-last was previously gated on ``use_cuda_graph``, so with + CUDA graphs disabled ``_state_0`` flipped buffers and env-reset writes landed + in the stale buffer. + + :class:`~isaaclab_newton.assets.ArticulationData` binds its joint-state write + target to ``_state_0.joint_q`` once at setup (``_sim_bind_joint_pos``) and + never re-binds on env resets, so a flipped ``_state_0`` makes reset writes + miss the live state. This test reproduces that contract without a full USD + articulation: it caches the same ``_state_0.joint_q`` binding, steps Kamino an + odd number of times, writes a sentinel through the cached binding (mimicking + the reset write), and asserts the manager's ``_state_0`` observes it. + + Without the fix the swap-on-last flips ``_state_0`` for odd ``num_steps`` and + the sentinel lands in ``_state_1`` instead, so the final assertion fails. + """ + sentinel = 1.2345 + sim_cfg = SimulationCfg( + dt=1.0 / 120.0, + device="cuda:0", + gravity=(0.0, 0.0, -9.81), + physics=NewtonCfg( + solver_cfg=KaminoSolverCfg(), + num_substeps=1, + use_cuda_graph=False, + ), + ) + + with build_simulation_context(sim_cfg=sim_cfg) as sim: + builder = NewtonManager.create_builder() + body = builder.add_body(mass=1.0) + builder.add_joint_revolute(parent=-1, child=body, axis=(0, 0, 1)) + NewtonManager.set_builder(builder) + sim.reset() + + # Kamino keeps separate input/output states; the bug only exists there. + assert NewtonManager._use_single_state is False + # The data layer binds its joint-state write target to _state_0 at setup. + reset_target = NewtonManager._state_0.joint_q + assert reset_target.shape[0] > 0 # guard against a vacuous assertion + + for _ in range(num_steps): + sim.step(render=False) + + # An env reset writes joint state through the (still bound) target. + reset_target.fill_(sentinel) + + # The reset must be visible in the manager's canonical _state_0; if the + # buffer flipped it landed in _state_1 instead. + canonical_joint_q = NewtonManager._state_0.joint_q.numpy() + assert np.allclose(canonical_joint_q, sentinel), ( + f"reset write did not land in _state_0 after {num_steps} steps: {canonical_joint_q}" + ) diff --git a/source/isaaclab_newton/test/sim/test_newton_schemas.py b/source/isaaclab_newton/test/sim/test_newton_schemas.py index 1774792d3d03..0f78c8e39507 100644 --- a/source/isaaclab_newton/test/sim/test_newton_schemas.py +++ b/source/isaaclab_newton/test/sim/test_newton_schemas.py @@ -164,6 +164,72 @@ def test_newton_material_no_schema_when_none(setup_sim): assert "NewtonMaterialAPI" not in prim.GetAppliedSchemas() +@pytest.mark.isaacsim_ci +def test_newton_material_fragment_composes_with_usd_physics_fragment(setup_sim): + """NewtonMaterialCfg is a rigid-body material fragment (backend symmetry with the PhysX + fragment): it must compose in a fragment list with UsdPhysicsRigidBodyMaterialCfg and author + both the ``newton:*`` and solver-common ``physics:*`` namespaces on the same material prim.""" + from isaaclab_newton.sim.spawners.materials import NewtonMaterialCfg + + from isaaclab.sim.spawners.materials.physics_materials import spawn_rigid_body_material_from_fragments + from isaaclab.sim.spawners.materials.physics_materials_cfg import UsdPhysicsRigidBodyMaterialCfg + + prim = spawn_rigid_body_material_from_fragments( + "/World/newton_mat_frag", + [ + UsdPhysicsRigidBodyMaterialCfg(static_friction=0.6, dynamic_friction=0.5), + NewtonMaterialCfg(torsional_friction=0.3, rolling_friction=0.001), + ], + ) + assert bool(UsdPhysics.MaterialAPI(prim)) + assert prim.GetAttribute("physics:staticFriction").Get() == pytest.approx(0.6) + assert prim.GetAttribute("physics:dynamicFriction").Get() == pytest.approx(0.5) + assert "NewtonMaterialAPI" in prim.GetAppliedSchemas() + assert prim.GetAttribute("newton:torsionalFriction").Get() == pytest.approx(0.3) + assert prim.GetAttribute("newton:rollingFriction").Get() == pytest.approx(0.001) + + +@pytest.mark.isaacsim_ci +def test_newton_material_fragment_authors_all_six_newton_attrs(setup_sim): + """Regression test: Newton's USD material schema resolver (``SchemaResolverNewton``) reads six + ``newton:*`` material attributes -- the two friction knobs plus four contact-model attributes + (``contactStiffness``/``contactDamping``/``contactFrictionGain``/``contactAdhesion``) that + replace the deprecated per-shape ``ke``/``kd``/``kf``/``ka`` parameters. All six must round-trip + through :class:`~isaaclab_newton.sim.spawners.materials.NewtonMaterialCfg`, even though the + generated ``NewtonMaterialAPI`` schema currently only declares the two friction attributes.""" + from isaaclab_newton.sim.spawners.materials import NewtonMaterialCfg + from newton._src.usd.schema_resolver import PrimType + from newton._src.usd.schemas import SchemaResolverNewton + + from isaaclab.sim.spawners.materials import spawn_rigid_body_material_from_fragments + + prim = spawn_rigid_body_material_from_fragments( + "/World/newton_mat_contact", + NewtonMaterialCfg( + torsional_friction=0.3, + rolling_friction=0.001, + contact_stiffness=2500.0, + contact_damping=100.0, + contact_friction_gain=1000.0, + contact_adhesion=0.01, + ), + ) + expected = { + "mu_torsional": 0.3, + "mu_rolling": 0.001, + "ke": 2500.0, + "kd": 100.0, + "kf": 1000.0, + "ka": 0.01, + } + + assert "NewtonMaterialAPI" in prim.GetAppliedSchemas() + resolver = SchemaResolverNewton() + assert set(resolver.mapping[PrimType.MATERIAL]) == set(expected) + for key, value in expected.items(): + assert resolver.get_value(prim, PrimType.MATERIAL, key) == pytest.approx(value) + + # --------------------------------------------------------------------------- # Newton articulation root # --------------------------------------------------------------------------- @@ -347,3 +413,16 @@ def test_newton_mesh_collision_mixed_namespace_write(setup_sim): applied = prim.GetAppliedSchemas() assert "NewtonCollisionAPI" in applied assert "NewtonMeshCollisionAPI" in applied + + +@pytest.mark.isaacsim_ci +def test_newton_legacy_cfg_authors_contact_attrs(setup_sim): + """The legacy Newton material cfg authors all newton:* attributes the fragment authors.""" + mat_cfg = NewtonMaterialPropertiesCfg( + contact_stiffness=1.0e4, contact_damping=250.0, contact_friction_gain=40.0, contact_adhesion=0.02 + ) + prim = spawn_rigid_body_material("/World/newton_mat_contact", mat_cfg) + assert prim.GetAttribute("newton:contactStiffness").Get() == pytest.approx(1.0e4) + assert prim.GetAttribute("newton:contactDamping").Get() == pytest.approx(250.0) + assert prim.GetAttribute("newton:contactFrictionGain").Get() == pytest.approx(40.0) + assert prim.GetAttribute("newton:contactAdhesion").Get() == pytest.approx(0.02) diff --git a/source/isaaclab_ov/changelog.d/ovrtx-instance-segmentation-fast.rst b/source/isaaclab_ov/changelog.d/ovrtx-instance-segmentation-fast.rst deleted file mode 100644 index 7a2d05faa43f..000000000000 --- a/source/isaaclab_ov/changelog.d/ovrtx-instance-segmentation-fast.rst +++ /dev/null @@ -1,13 +0,0 @@ -Added -^^^^^ - -* Added :attr:`~isaaclab_ov.renderers.OVRTXRendererCfg.colorize_instance_segmentation` and - :attr:`~isaaclab_ov.renderers.OVRTXRendererCfg.colorize_instance_id_segmentation` config fields - to :class:`~isaaclab_ov.renderers.OVRTXRendererCfg`. -* Added support for the ``instance_segmentation_fast`` and ``instance_id_segmentation_fast`` - data types in the OVRTX renderer, via the ``NonStableInstanceSegmentation`` and - ``InstanceSegmentationSD`` AOVs respectively. When the corresponding - :attr:`~isaaclab_ov.renderers.OVRTXRendererCfg.colorize_instance_segmentation` / - :attr:`~isaaclab_ov.renderers.OVRTXRendererCfg.colorize_instance_id_segmentation` flag is - ``True`` (default), instance IDs are colorized and returned as ``uint8`` RGBA; when ``False``, - raw ``uint32`` instance IDs are returned. diff --git a/source/isaaclab_ov/config/extension.toml b/source/isaaclab_ov/config/extension.toml index 5acd5995397f..b097ac973a16 100644 --- a/source/isaaclab_ov/config/extension.toml +++ b/source/isaaclab_ov/config/extension.toml @@ -1,5 +1,5 @@ [package] -version = "0.5.3" +version = "0.6.0" 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 29a6576b9513..fa12b2d081d9 100644 --- a/source/isaaclab_ov/docs/CHANGELOG.rst +++ b/source/isaaclab_ov/docs/CHANGELOG.rst @@ -1,6 +1,47 @@ Changelog --------- +0.6.0 (2026-07-06) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added ``motion_vectors`` data type support to :class:`~isaaclab_ov.renderers.OVRTXRenderer`. + +Fixed +^^^^^ + +* Removed overly broad ``except Exception`` handling in :class:`~isaaclab_ov.renderers.ovrtx_renderer.OVRTXRenderer` + that downgraded failures in scene initialization, camera and object binding setup, scene partition writes, + Newton transform syncing, and :meth:`~isaaclab_ov.renderers.ovrtx_renderer.OVRTXRenderer.render` to log + warnings and silently continue. These now propagate so callers can decide how to handle the failure. + + +0.5.4 (2026-07-03) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :attr:`~isaaclab_ov.renderers.OVRTXRendererCfg.colorize_instance_segmentation` and + :attr:`~isaaclab_ov.renderers.OVRTXRendererCfg.colorize_instance_id_segmentation` config fields + to :class:`~isaaclab_ov.renderers.OVRTXRendererCfg`. +* Added support for the ``instance_segmentation_fast`` and ``instance_id_segmentation_fast`` + data types in the OVRTX renderer, via the ``NonStableInstanceSegmentation`` and + ``InstanceSegmentationSD`` AOVs respectively. When the corresponding + :attr:`~isaaclab_ov.renderers.OVRTXRendererCfg.colorize_instance_segmentation` / + :attr:`~isaaclab_ov.renderers.OVRTXRendererCfg.colorize_instance_id_segmentation` flag is + ``True`` (default), instance IDs are colorized and returned as ``uint8`` RGBA; when ``False``, + raw ``uint32`` instance IDs are returned. + +Changed +^^^^^^^ + +* Consolidated the OVRTX tile-extraction Warp kernels into a single generic + :func:`~isaaclab_ov.renderers.ovrtx_renderer_kernels.extract_all_tiles_kernel`. + + 0.5.3 (2026-07-01) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py index 38ae6b41a1f2..cbbcb9a06157 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py @@ -60,11 +60,7 @@ from .ovrtx_renderer_cfg import OVRTXRendererCfg from .ovrtx_renderer_kernels import ( create_camera_transforms_kernel, - extract_all_depth_tiles_kernel, - extract_all_rgb_float_tiles_kernel, - extract_all_rgb_half_tiles_kernel, - extract_all_rgba_tiles_kernel, - extract_all_uint32_tiles_kernel, + extract_all_tiles_kernel, generate_random_colors_from_ids_kernel, sync_newton_transforms_kernel, ) @@ -269,6 +265,7 @@ def supported_output_types(self) -> dict[RenderBufferKind, RenderBufferSpec]: RenderBufferKind.DISTANCE_TO_IMAGE_PLANE: RenderBufferSpec(1, wp.float32), RenderBufferKind.DISTANCE_TO_CAMERA: RenderBufferSpec(1, wp.float32), RenderBufferKind.NORMALS: RenderBufferSpec(3, wp.float32), + RenderBufferKind.MOTION_VECTORS: RenderBufferSpec(2, wp.float32), } @property @@ -372,64 +369,59 @@ def _initialize_from_spec(self, spec: CameraRenderSpec): raise RuntimeError(f"Expected camera prim under '{env_0_prefix}', got '{first_cam_path}'") self._camera_rel_path = spec.camera_path_relative_to_env_0 - if self._exported_usd_string is not None: - logger.info("Injecting camera definitions...") + logger.info("Injecting camera definitions...") - render_product_string, render_product_path = build_render_product_as_string( - width=width, - 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) + if self._exported_usd_string is None: + raise RuntimeError("Expected an exported USD string from stage") - combined_usd_string = self._exported_usd_string + "\n\n" + render_product_string - self._exported_usd_string = None # Free memory + render_product_string, render_product_path = build_render_product_as_string( + width=width, + 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) - # If temp_usd_dir is set, write the combined USD stage to a temporary file. - if self.cfg.temp_usd_dir is not None: - _write_file(Path(self.cfg.temp_usd_dir), "ovrtx_renderer_stage.usda", combined_usd_string) + combined_usd_string = self._exported_usd_string + "\n\n" + render_product_string + self._exported_usd_string = None # Free memory - logger.info("Loading USD into OvRTX...") - try: - self._renderer.open_usd_from_string(combined_usd_string) - logger.info("OVRTX loaded USD from string successfully") - except Exception as e: - logger.exception("Error loading USD: %s", e) - raise + # If temp_usd_dir is set, write the combined USD stage to a temporary file. + if self.cfg.temp_usd_dir is not None: + _write_file(Path(self.cfg.temp_usd_dir), "ovrtx_renderer_stage.usda", combined_usd_string) - if num_envs > 1: - self._clone_sources_in_ovrtx() - self._update_scene_partitions_after_clone(num_envs) + logger.info("Loading USD into OvRTX...") + self._renderer.open_usd_from_string(combined_usd_string) + logger.info("OVRTX loaded USD from string successfully") - self._initialized_scene = True + if num_envs > 1: + self._clone_sources_in_ovrtx() + self._update_scene_partitions_after_clone(num_envs) - camera_paths = [f"/World/envs/env_{i}/{self._camera_rel_path}" for i in range(num_envs)] - self._camera_binding = self._renderer.bind_attribute( - prim_paths=camera_paths, - attribute_name="omni:xform", - semantic=Semantic.XFORM_MAT4x4, - prim_mode=PrimMode.EXISTING_ONLY, - ) + self._initialized_scene = True - # OVRTX requires omni:resetXformStack on cameras for correct world transform binding - try: - self._renderer.write_attribute( - prim_paths=camera_paths, - attribute_name="omni:resetXformStack", - tensor=np.full(num_envs, True, dtype=np.bool_), - ) - except Exception as e: - logger.warning("Failed to write omni:resetXformStack: %s", e) + camera_paths = [f"/World/envs/env_{i}/{self._camera_rel_path}" for i in range(num_envs)] + self._camera_binding = self._renderer.bind_attribute( + prim_paths=camera_paths, + attribute_name="omni:xform", + semantic=Semantic.XFORM_MAT4x4, + prim_mode=PrimMode.EXISTING_ONLY, + ) - if self._camera_binding is not None: - logger.info("Camera binding created successfully") - else: - logger.warning("Camera binding is None") + # OVRTX requires omni:resetXformStack on cameras for correct world transform binding + self._renderer.write_attribute( + prim_paths=camera_paths, + attribute_name="omni:resetXformStack", + tensor=np.full(num_envs, True, dtype=np.bool_), + ) - self._setup_object_bindings() + if self._camera_binding is not None: + logger.info("Camera binding created successfully") + else: + raise RuntimeError("Camera binding is None — cannot render without a valid camera binding") + + self._setup_newton_object_bindings() def _clone_sources_in_ovrtx(self): """Clone sources in OVRTX using the scene :class:`~isaaclab.cloner.ClonePlan`.""" @@ -472,76 +464,72 @@ def _update_scene_partitions_after_clone(self, num_envs: int): env_prim_paths = [f"/World/envs/env_{i}" for i in range(num_envs)] camera_prim_paths = [f"/World/envs/env_{i}/{self._camera_rel_path}" for i in range(num_envs)] - try: - self._renderer.write_attribute( - env_prim_paths, - "primvars:omni:scenePartition", - partition_tokens, - semantic=Semantic.TOKEN_STRING, - ) - logger.info("Written primvars:omni:scenePartition to %d environments", num_envs) + self._renderer.write_attribute( + env_prim_paths, + "primvars:omni:scenePartition", + partition_tokens, + semantic=Semantic.TOKEN_STRING, + ) + logger.info("Written primvars:omni:scenePartition to %d environments", num_envs) - self._renderer.write_attribute( - camera_prim_paths, - "omni:scenePartition", - partition_tokens, - semantic=Semantic.TOKEN_STRING, - ) - logger.info("Written omni:scenePartition to %d cameras", num_envs) - except Exception as e: - logger.warning("Failed to write scene partitions: %s", e, exc_info=True) + self._renderer.write_attribute( + camera_prim_paths, + "omni:scenePartition", + partition_tokens, + semantic=Semantic.TOKEN_STRING, + ) + logger.info("Written omni:scenePartition to %d cameras", num_envs) - def _setup_object_bindings(self): + def _setup_newton_object_bindings(self): """Setup OVRTX bindings for scene objects to sync with Newton physics.""" try: from isaaclab_newton.physics import NewtonManager + except ImportError: + logger.debug("isaaclab_newton physics not available, skipping object bindings") + return - newton_model = NewtonManager.get_model() - if newton_model is None: - logger.info("Newton model not available, skipping object bindings") - return + if SimulationContext.instance() is None: + logger.info("No active simulation context, will not set up ovrtx object bindings for newton") + return - all_body_paths = getattr(newton_model, "body_label", None) - if all_body_paths is None: - logger.info("Newton model has no body_label, skipping object bindings") - return + newton_model = NewtonManager.get_model() + if newton_model is None: + logger.debug("Newton model not available, skipping object bindings") + return - object_paths = [] - newton_indices = [] - for idx, path in enumerate(all_body_paths): - if "/World/envs/" in path and self._camera_rel_path not in path and "GroundPlane" not in path: - object_paths.append(path) - newton_indices.append(idx) + all_body_paths = getattr(newton_model, "body_label", None) + if all_body_paths is None: + logger.info("Newton model has no body_label, skipping object bindings") + return - if len(object_paths) == 0: - logger.info("No dynamic objects found for binding") - return + object_paths = [] + newton_indices = [] + for idx, path in enumerate(all_body_paths): + if "/World/envs/" in path and self._camera_rel_path not in path and "GroundPlane" not in path: + object_paths.append(path) + newton_indices.append(idx) - self._object_binding = self._renderer.bind_attribute( - prim_paths=object_paths, - attribute_name="omni:xform", - semantic=Semantic.XFORM_MAT4x4, - prim_mode=PrimMode.EXISTING_ONLY, - ) + if len(object_paths) == 0: + logger.info("No dynamic objects found for binding") + return - try: - self._renderer.write_attribute( - prim_paths=object_paths, - attribute_name="omni:resetXformStack", - tensor=np.full(len(object_paths), True, dtype=np.bool_), - ) - except Exception as e: - logger.warning("Failed to write omni:resetXformStack on objects: %s", e) + self._object_binding = self._renderer.bind_attribute( + prim_paths=object_paths, + attribute_name="omni:xform", + semantic=Semantic.XFORM_MAT4x4, + prim_mode=PrimMode.EXISTING_ONLY, + ) - if self._object_binding is not None: - logger.info("Object binding created successfully") - self._object_newton_indices = wp.array(newton_indices, dtype=wp.int32, device=self._device) - else: - logger.warning("Object binding is None") - except ImportError: - logger.info("Newton not available, skipping object bindings") - except Exception as e: - logger.warning("Error setting up object bindings: %s", e) + self._renderer.write_attribute( + prim_paths=object_paths, + attribute_name="omni:resetXformStack", + tensor=np.full(len(object_paths), True, dtype=np.bool_), + ) + + if self._object_binding is None: + raise RuntimeError("Failed to create OVRTX object bindings") + + self._object_newton_indices = wp.array(newton_indices, dtype=wp.int32, device=self._device) def create_render_data(self, spec: CameraRenderSpec) -> OVRTXRenderData: """Create OVRTX-specific RenderData with GPU buffers. @@ -590,26 +578,26 @@ def update_transforms(self) -> None: if self._object_binding is None or self._object_newton_indices is None: return - try: - from isaaclab_newton.physics import NewtonManager + # If self._object_newton_indices is not None, then Newton's the current physics backend - newton_state = NewtonManager.get_state() - if newton_state is None: - return - body_q = getattr(newton_state, "body_q", None) - if body_q is None: - return + from isaaclab_newton.physics import NewtonManager - with self._object_binding.map(device=Device.CUDA, device_id=self._device_id) as attr_mapping: - ovrtx_transforms = wp.from_dlpack(attr_mapping.tensor, dtype=wp.mat44d) - wp.launch( - kernel=sync_newton_transforms_kernel, - dim=len(self._object_newton_indices), - inputs=[ovrtx_transforms, self._object_newton_indices, body_q], - device=self._device, - ) - except Exception as e: - logger.warning("Failed to update object transforms: %s", e) + newton_state = NewtonManager.get_state() + if newton_state is None: + raise RuntimeError("Newton state should not be None") + + body_q = getattr(newton_state, "body_q", None) + if body_q is None: + return + + with self._object_binding.map(device=Device.CUDA, device_id=self._device_id) as attr_mapping: + ovrtx_transforms = wp.from_dlpack(attr_mapping.tensor, dtype=wp.mat44d) + wp.launch( + kernel=sync_newton_transforms_kernel, + dim=len(self._object_newton_indices), + inputs=[ovrtx_transforms, self._object_newton_indices, body_q], + device=self._device, + ) def update_camera( self, @@ -728,18 +716,47 @@ def _process_id_segmentation_render_var( if data_torch.dim() == 2: data_torch = data_torch.unsqueeze(-1) tiled_data = wp.from_torch(data_torch, dtype=wp.uint32) - wp.launch( - kernel=extract_all_uint32_tiles_kernel, - dim=(render_data.num_envs, render_data.height, render_data.width), - inputs=[ - tiled_data, - output_buffers[buffer_key], - render_data.num_cols, - render_data.width, - render_data.height, - ], - device=self._device, - ) + self._launch_extract_all_tiles(render_data, tiled_data, output_buffers[buffer_key]) + + def _launch_extract_all_tiles( + self, render_data: OVRTXRenderData, tiled_buffer: wp.array, output_buffer: wp.array + ) -> None: + """Launch ``extract_all_tiles_kernel`` for one tiled/output buffer pair. + + This is the only place that should launch ``extract_all_tiles_kernel``: it validates that + ``output_buffer`` cannot read past the end of ``tiled_buffer`` (the kernel derives its per-thread + channel loop bound from ``output_buffer``'s last dimension) before every launch, so callers cannot + accidentally skip the check. + + Args: + render_data: OVRTX render data for the current frame. + tiled_buffer: 3D array of shape (H, W, C) holding all tiles packed into one buffer. + output_buffer: 4D array of shape (num_envs, H, W, C) to receive the per-env tiles, with C no + greater than ``tiled_buffer``'s channel count. + + Raises: + ValueError: If ``output_buffer``'s channel count exceeds ``tiled_buffer``'s. + """ + tiled_channels = tiled_buffer.shape[-1] + output_channels = output_buffer.shape[-1] + if output_channels > tiled_channels: + raise ValueError( + f"Output buffer has {output_channels} channels but the tiled buffer only has {tiled_channels};" + " extract_all_tiles_kernel would read out of bounds." + ) + + wp.launch( + kernel=extract_all_tiles_kernel, + dim=(render_data.num_envs, render_data.height, render_data.width), + inputs=[ + tiled_buffer, + output_buffer, + render_data.num_cols, + render_data.width, + render_data.height, + ], + device=self._device, + ) def _extract_rgba_tiles( self, @@ -755,19 +772,7 @@ def _extract_rgba_tiles( 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_buffer, - render_data.num_cols, - render_data.width, - render_data.height, - num_channels, - ], - device=self._device, - ) + self._launch_extract_all_tiles(render_data, tiled_data, output_buffer) def _extract_depth_tiles( self, render_data: OVRTXRenderData, tiled_depth_data: wp.array, output_buffers: dict @@ -775,18 +780,7 @@ def _extract_depth_tiles( """Extract per-env depth tiles into output_buffers (single kernel launch).""" 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, - dim=(render_data.num_envs, render_data.height, render_data.width), - inputs=[ - tiled_depth_data, - output_buffers[depth_type], - render_data.num_cols, - render_data.width, - render_data.height, - ], - device=self._device, - ) + self._launch_extract_all_tiles(render_data, tiled_depth_data, output_buffers[depth_type]) def _extract_hdr_color_tiles( self, render_data: OVRTXRenderData, tiled_data: wp.array, output_buffers: dict @@ -794,24 +788,9 @@ def _extract_hdr_color_tiles( """Extract per-env HdrColor tiles into output_buffers.""" if "rgb_hdr" not in output_buffers: return - if tiled_data.dtype == wp.float16: - kernel = extract_all_rgb_half_tiles_kernel - elif tiled_data.dtype == wp.float32: - kernel = extract_all_rgb_float_tiles_kernel - else: + if tiled_data.dtype not in (wp.float16, wp.float32): raise TypeError(f"Unsupported OVRTX HdrColor dtype: {tiled_data.dtype}.") - wp.launch( - kernel=kernel, - dim=(render_data.num_envs, render_data.height, render_data.width), - inputs=[ - tiled_data, - output_buffers["rgb_hdr"], - render_data.num_cols, - render_data.width, - render_data.height, - ], - device=self._device, - ) + self._launch_extract_all_tiles(render_data, tiled_data, output_buffers["rgb_hdr"]) def _prepare_ppisp_hdr_source( self, render_data: OVRTXRenderData, tiled_data: wp.array, output_buffers: dict @@ -905,18 +884,15 @@ def _process_render_frame(self, render_data: OVRTXRenderData, frame, output_buff if "NormalSD" in frame.render_vars and "normals" in output_buffers: with frame.render_vars["NormalSD"].map(device=Device.CUDA) as mapping: tiled_normals_data = wp.from_dlpack(mapping.tensor) - wp.launch( - kernel=extract_all_rgb_float_tiles_kernel, - dim=(render_data.num_envs, render_data.height, render_data.width), - inputs=[ - tiled_normals_data, - output_buffers["normals"], - render_data.num_cols, - render_data.width, - render_data.height, - ], - device=self._device, - ) + self._launch_extract_all_tiles(render_data, tiled_normals_data, output_buffers["normals"]) + + # For motion vectors, extract only the first two (u, v) channels from the tiled buffer. + # Note: mirrors the Isaac RTX renderer's handling of the "TargetMotionSD" AOV + # (check: https://github.com/isaac-sim/IsaacLab/issues/2003). + if "TargetMotionSD" in frame.render_vars and "motion_vectors" in output_buffers: + with frame.render_vars["TargetMotionSD"].map(device=Device.CUDA) as mapping: + tiled_motion_vectors_data = wp.from_dlpack(mapping.tensor) + self._launch_extract_all_tiles(render_data, tiled_motion_vectors_data, output_buffers["motion_vectors"]) def render(self, render_data: OVRTXRenderData) -> None: """Render the scene into the provided RenderData.""" @@ -924,28 +900,25 @@ def render(self, render_data: OVRTXRenderData) -> None: raise RuntimeError("Scene not initialized. Call initialize() first.") if self._renderer is None or len(self._render_product_paths) == 0: return - try: - products = self._renderer.step( - render_products=set(self._render_product_paths), - delta_time=1.0 / 60.0, + products = self._renderer.step( + render_products=set(self._render_product_paths), + delta_time=1.0 / 60.0, + ) + product_path = self._render_product_paths[0] + if product_path in products and len(products[product_path].frames) > 0: + self._process_render_frame( + render_data, + products[product_path].frames[0], + render_data.warp_buffers, ) - product_path = self._render_product_paths[0] - if product_path in products and len(products[product_path].frames) > 0: - self._process_render_frame( - render_data, - products[product_path].frames[0], - render_data.warp_buffers, - ) - # Post-render PPISP: HDR scene-linear → LDR RGBA. Source/destination - # buffers are the same warp buffer map used by extraction. - if render_data.ppisp_pipeline is not None: - render_data.ppisp_pipeline.apply( - render_data.warp_buffers[str(RenderBufferKind.RGB_HDR)], - render_data.warp_buffers[str(RenderBufferKind.RGBA)], - ) - except Exception as e: - logger.warning("OVRTX rendering failed: %s", e, exc_info=True) + # Post-render PPISP: HDR scene-linear → LDR RGBA. Source/destination + # buffers are the same warp buffer map used by extraction. + if render_data.ppisp_pipeline is not None: + render_data.ppisp_pipeline.apply( + render_data.warp_buffers[str(RenderBufferKind.RGB_HDR)], + render_data.warp_buffers[str(RenderBufferKind.RGBA)], + ) def cleanup(self, render_data: OVRTXRenderData | None) -> None: """Release renderer resources. See :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.cleanup`.""" 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 e7f56eff04e9..15037dd5d7cf 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_kernels.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_kernels.py @@ -5,6 +5,8 @@ """Warp kernels for OVRTX rendering pipeline.""" +from typing import Any + import warp as wp @@ -72,92 +74,32 @@ 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_cols: int, - tile_width: int, - tile_height: int, - num_channels: int, -): - """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] - - # 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_rgb_float_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_envs, H, W, 3) - num_cols: int, - tile_width: int, - tile_height: int, -): - """Extract ALL RGB float tiles from a tiled buffer in a single kernel launch.""" - 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] - 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] - - -@wp.kernel -def extract_all_rgb_half_tiles_kernel( - tiled_buffer: wp.array(dtype=wp.float16, ndim=3), # type: ignore - output_buffer: wp.array(dtype=wp.float32, ndim=4), # type: ignore (num_envs, H, W, 3) +def extract_all_tiles_kernel( + tiled_buffer: wp.array(dtype=Any, ndim=3), # type: ignore + output_buffer: wp.array(dtype=Any, ndim=4), # type: ignore num_cols: int, tile_width: int, tile_height: int, ): - """Extract ALL RGB half tiles into float32 output in a single kernel launch.""" - 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] = wp.float32(tiled_buffer[src_y, src_x, 0]) - output_buffer[env_idx, y, x, 1] = wp.float32(tiled_buffer[src_y, src_x, 1]) - output_buffer[env_idx, y, x, 2] = wp.float32(tiled_buffer[src_y, src_x, 2]) + """Extract ALL tiles from a tiled buffer into per-env tiles in a single kernel launch. + Generic over the tile channel layout (RGB, RGBA, or single-channel depth) and dtype (e.g. uint8 color, + float32 depth/HDR color, or float16 HDR color widened to float32 on output). The channel count is taken + from ``output_buffer`` rather than passed explicitly, and each element is cast to the output dtype, so the + same kernel body serves every tile-extraction case; see the :func:`warp.overload` registrations below for + the concrete dtype pairs this is compiled for. -@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. + Precondition: + ``output_buffer``'s channel count (last dimension) must not exceed ``tiled_buffer``'s (the per-thread + channel loop below indexes ``tiled_buffer`` up to that bound). In this package, always launch this + kernel through ``OVRTXRenderer._launch_extract_all_tiles``, which validates this before every launch + instead of relying on each call site to remember to check. Passing a wider output buffer than the + tiled input reads out of bounds on the GPU. 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. + tiled_buffer: 3D array of shape (H, W, C) holding all tiles packed into one buffer. + output_buffer: 4D array of shape (num_envs, H, W, C) to receive the per-env tiles, with C no greater + than ``tiled_buffer``'s channel count. num_cols: number of columns in the tiled buffer. tile_width: width of each tile. tile_height: height of each tile. @@ -167,7 +109,30 @@ def extract_all_depth_tiles_kernel( 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] + for channel in range(output_buffer.shape[3]): + output_buffer[env_idx, y, x, channel] = output_buffer.dtype(tiled_buffer[src_y, src_x, channel]) + + +# uint8 color tiles (e.g. RGB/RGBA, semantic segmentation). +wp.overload( + extract_all_tiles_kernel, + [wp.array(dtype=wp.uint8, ndim=3), wp.array(dtype=wp.uint8, ndim=4), int, int, int], +) +# float32 tiles (e.g. depth, normals, HDR color). +wp.overload( + extract_all_tiles_kernel, + [wp.array(dtype=wp.float32, ndim=3), wp.array(dtype=wp.float32, ndim=4), int, int, int], +) +# float16 tiles (e.g. HDR color), widened to a float32 output buffer. +wp.overload( + extract_all_tiles_kernel, + [wp.array(dtype=wp.float16, ndim=3), wp.array(dtype=wp.float32, ndim=4), int, int, int], +) +# uint32 tiles (e.g. raw instance segmentation IDs). +wp.overload( + extract_all_tiles_kernel, + [wp.array(dtype=wp.uint32, ndim=3), wp.array(dtype=wp.uint32, ndim=4), int, int, int], +) @wp.kernel @@ -294,23 +259,6 @@ def random_color_from_id(input_id: wp.uint32) -> wp.uint32: return color -@wp.kernel -def extract_all_uint32_tiles_kernel( - tiled_buffer: wp.array(dtype=wp.uint32, ndim=3), # type: ignore (TH, TW, 1) - output_buffer: wp.array(dtype=wp.uint32, ndim=4), # type: ignore (num_envs, H, W, 1) - num_cols: int, - tile_width: int, - tile_height: int, -): - """Extract all single-channel uint32 tiles (e.g. raw instance segmentation IDs).""" - 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 generate_random_colors_from_ids_kernel( input_ids: wp.array(dtype=wp.uint32, ndim=3), # type: ignore diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_usd.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_usd.py index 443e0d21e4dd..d0e98aa6e0a5 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_usd.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_usd.py @@ -26,20 +26,36 @@ def get_render_var_config(data_types: list[str]) -> tuple[str, str, str]: use_instance_seg = "instance_segmentation_fast" in data_types use_instance_id_seg = "instance_id_segmentation_fast" in data_types use_normals = "normals" in data_types + use_motion_vectors = "motion_vectors" in data_types use_rgb = any(dt in ["rgb", "rgba"] for dt in data_types) use_hdr = "rgb_hdr" in data_types if use_depth and not ( - use_rgb or use_albedo or use_semantic or use_instance_seg or use_instance_id_seg or use_normals + use_rgb + or use_albedo + or use_semantic + or use_instance_seg + or use_instance_id_seg + or use_normals + or use_motion_vectors ): source = "DistanceToCameraSD" if use_distance_to_camera else "DistanceToImagePlaneSD" return "/Render/Vars/depth", "depth", source - if use_albedo and not (use_rgb or use_semantic or use_instance_seg or use_instance_id_seg or use_normals): + if use_albedo and not ( + use_rgb or use_semantic or use_instance_seg or use_instance_id_seg or use_normals or use_motion_vectors + ): return "/Render/Vars/albedo", "albedo", "DiffuseAlbedoSD" - if use_semantic and not (use_rgb or use_albedo or use_normals): + if use_semantic and not (use_rgb or use_albedo or use_normals or use_motion_vectors): return "/Render/Vars/semantic", "semantic", "SemanticSegmentation" if use_instance_seg and not ( - use_rgb or use_albedo or use_semantic or use_instance_id_seg or use_normals or use_depth or use_hdr + use_rgb + or use_albedo + or use_semantic + or use_instance_id_seg + or use_normals + or use_depth + or use_hdr + or use_motion_vectors ): return ( "/Render/Vars/NonStableInstanceSegmentation", @@ -47,13 +63,30 @@ def get_render_var_config(data_types: list[str]) -> tuple[str, str, str]: "NonStableInstanceSegmentation", ) if use_instance_id_seg and not ( - use_rgb or use_albedo or use_semantic or use_instance_seg or use_normals or use_depth or use_hdr + use_rgb + or use_albedo + or use_semantic + or use_instance_seg + or use_normals + or use_depth + or use_hdr + or use_motion_vectors ): return "/Render/Vars/InstanceSegmentationSD", "InstanceSegmentationSD", "InstanceSegmentationSD" if use_normals and not ( - use_rgb or use_albedo or use_semantic or use_instance_seg or use_instance_id_seg or use_depth + use_rgb + or use_albedo + or use_semantic + or use_instance_seg + or use_instance_id_seg + or use_depth + or use_motion_vectors ): return "/Render/Vars/NormalSD", "NormalSD", "NormalSD" + if use_motion_vectors and not ( + use_rgb or use_albedo or use_semantic or use_instance_seg or use_instance_id_seg or use_depth or use_normals + ): + return "/Render/Vars/TargetMotionSD", "TargetMotionSD", "TargetMotionSD" if use_hdr and not use_rgb: return "/Render/Vars/HdrColor", "HdrColor", "HdrColor" return "/Render/Vars/LdrColor", "LdrColor", "LdrColor" diff --git a/source/isaaclab_ov/pyproject.toml b/source/isaaclab_ov/pyproject.toml index 022cc51d7892..0c250169eb89 100644 --- a/source/isaaclab_ov/pyproject.toml +++ b/source/isaaclab_ov/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_ov" -version = "0.5.3" +version = "0.6.0" description = "Extension providing Omniverse renderers (OVRTX, ovphysx, etc.) for tiled camera rendering." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py b/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py index 0c918f76ed36..a968baebee36 100644 --- a/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py +++ b/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py @@ -88,10 +88,12 @@ def test_ovrtx_supported_output_types_key_set(): RenderBufferKind.DISTANCE_TO_IMAGE_PLANE, RenderBufferKind.DISTANCE_TO_CAMERA, RenderBufferKind.NORMALS, + RenderBufferKind.MOTION_VECTORS, } assert specs[RenderBufferKind.RGBA] == RenderBufferSpec(4, wp.uint8) assert specs[RenderBufferKind.RGB_HDR] == RenderBufferSpec(3, wp.float32) assert specs[RenderBufferKind.DEPTH] == RenderBufferSpec(1, wp.float32) + assert specs[RenderBufferKind.MOTION_VECTORS] == RenderBufferSpec(2, wp.float32) def test_ovrtx_set_outputs_wraps_caller_torch_zero_copy(): @@ -210,6 +212,39 @@ def fake_clone(src, *, device): assert clone_calls == [(source, "cuda:0")] +class _FakeArray: + def __init__(self, shape): + self.shape = shape + + +def test_launch_extract_all_tiles_rejects_wider_output_channels(): + """An output wider than the tiled input would read out of bounds, so it must raise before launching.""" + renderer = _make_ovrtx_renderer_without_backend() + renderer._device = "cpu" + render_data = _make_ovrtx_render_data() + + with pytest.raises(ValueError, match="out of bounds"): + renderer._launch_extract_all_tiles(render_data, _FakeArray((8, 16, 3)), _FakeArray((2, 8, 16, 4))) + + +def test_launch_extract_all_tiles_launches_kernel_when_channels_are_compatible(monkeypatch): + """Equal or narrower output channel counts pass validation and reach the kernel launch.""" + renderer = _make_ovrtx_renderer_without_backend() + renderer._device = "cpu" + render_data = _make_ovrtx_render_data() + render_data.num_cols = 2 + + launch_calls = [] + monkeypatch.setattr(wp, "launch", lambda **kwargs: launch_calls.append(kwargs)) + + tiled_buffer = _FakeArray((8, 16, 4)) + output_buffer = _FakeArray((2, 8, 16, 3)) + renderer._launch_extract_all_tiles(render_data, tiled_buffer, output_buffer) + + assert len(launch_calls) == 1 + assert launch_calls[0]["inputs"][:2] == [tiled_buffer, output_buffer] + + def test_ovrtx_read_output_is_a_no_op_after_consolidation(): """OVRTXRenderer.read_output is a no-op once set_outputs wires up zero-copy.""" renderer = _make_ovrtx_renderer_without_backend() diff --git a/source/isaaclab_ov/test/test_ovrtx_renderer_kernels.py b/source/isaaclab_ov/test/test_ovrtx_renderer_kernels.py index 301b7efa0c14..31b35350a370 100644 --- a/source/isaaclab_ov/test/test_ovrtx_renderer_kernels.py +++ b/source/isaaclab_ov/test/test_ovrtx_renderer_kernels.py @@ -11,10 +11,7 @@ import pytest import warp as wp from isaaclab_ov.renderers.ovrtx_renderer_kernels import ( - extract_all_depth_tiles_kernel, - extract_all_rgb_float_tiles_kernel, - extract_all_rgb_half_tiles_kernel, - extract_all_rgba_tiles_kernel, + extract_all_tiles_kernel, generate_random_colors_from_ids_kernel, ) @@ -93,6 +90,26 @@ def _reference_extract_all_depth_tiles( return out +def _reference_extract_all_uint32_tiles( + tiled_np: np.ndarray, + num_envs: int, + num_cols: int, + tile_width: int, + tile_height: int, +) -> np.ndarray: + """NumPy reference for the uint32 (e.g. raw instance segmentation ID) case of ``extract_all_tiles_kernel``.""" + out = np.zeros((num_envs, tile_height, tile_width, 1), dtype=np.uint32) + for env_idx in range(num_envs): + tile_x = env_idx % num_cols + tile_y = env_idx // num_cols + for y in range(tile_height): + for x in range(tile_width): + src_y = tile_y * tile_height + y + src_x = tile_x * tile_width + x + out[env_idx, y, x, 0] = tiled_np[src_y, src_x, 0] + return out + + def _reference_extract_all_rgba_tiles( tiled_np: np.ndarray, num_envs: int, @@ -141,8 +158,63 @@ def _reference_extract_all_rgb_float_tiles( return out +def _reference_extract_all_motion_vector_tiles( + tiled_np: np.ndarray, + num_envs: int, + num_cols: int, + tile_width: int, + tile_height: int, +) -> np.ndarray: + """NumPy reference for the motion-vector case of ``extract_all_tiles_kernel``.""" + out = np.zeros((num_envs, tile_height, tile_width, 2), dtype=np.float32) + for env_idx in range(num_envs): + tile_x = env_idx % num_cols + tile_y = env_idx // num_cols + for y in range(tile_height): + for x in range(tile_width): + src_y = tile_y * tile_height + y + src_x = tile_x * tile_width + x + out[env_idx, y, x, 0] = tiled_np[src_y, src_x, 0] + out[env_idx, y, x, 1] = tiled_np[src_y, src_x, 1] + return out + + +class TestExtractAllMotionVectorTilesKernel: + """Tests for the motion-vector case of ``extract_all_tiles_kernel`` used by OVRTX TargetMotionSD.""" + + def test_two_by_two_tile_grid_drops_extra_channels(self): + """The kernel reads only the first two channels, even if the source buffer has more (e.g. 4).""" + 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, 4), dtype=np.float32) + for h in range(tiled_h): + for w in range(tiled_w): + tiled_np[h, w, 0] = float(h * 1000 + w) + tiled_np[h, w, 1] = float(h * 1000 + w + 100) + tiled_np[h, w, 2] = float(h * 1000 + w + 200) + tiled_np[h, w, 3] = float(h * 1000 + w + 300) + + tiled_wp = wp.array(tiled_np, dtype=wp.float32, ndim=3, device=DEVICE) + output_wp = wp.zeros(shape=(num_envs, tile_height, tile_width, 2), dtype=wp.float32, device=DEVICE) + + wp.launch( + kernel=extract_all_tiles_kernel, + dim=(num_envs, tile_height, tile_width), + inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height], + device=DEVICE, + ) + wp.synchronize() + + expected = _reference_extract_all_motion_vector_tiles(tiled_np, num_envs, num_cols, tile_width, tile_height) + np.testing.assert_allclose(output_wp.numpy(), expected, rtol=0, atol=0) + + class TestExtractAllDepthTilesKernel: - """Tests for ``extract_all_depth_tiles_kernel``.""" + """Tests for the depth case of ``extract_all_tiles_kernel``.""" def test_two_by_two_tile_grid(self): num_cols = 2 @@ -160,7 +232,7 @@ def test_two_by_two_tile_grid(self): 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, + kernel=extract_all_tiles_kernel, dim=(num_envs, tile_height, tile_width), inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height], device=DEVICE, @@ -181,7 +253,7 @@ def test_single_tile(self): 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, + kernel=extract_all_tiles_kernel, dim=(num_envs, tile_height, tile_width), inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height], device=DEVICE, @@ -210,7 +282,7 @@ def test_various_layouts(self, num_cols, num_envs, tile_width, tile_height): 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, + kernel=extract_all_tiles_kernel, dim=(num_envs, tile_height, tile_width), inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height], device=DEVICE, @@ -221,8 +293,86 @@ def test_various_layouts(self, num_cols, num_envs, tile_width, tile_height): np.testing.assert_allclose(output_wp.numpy(), expected, rtol=1e-6, atol=1e-6) +class TestExtractAllUint32TilesKernel: + """Tests for the uint32 (e.g. raw instance segmentation ID) case of ``extract_all_tiles_kernel``.""" + + def test_two_by_two_tile_grid(self): + num_cols = 2 + num_envs = 4 + tile_width = 2 + tile_height = 3 + tiled_h = (num_envs // num_cols) * tile_height + tiled_w = num_cols * tile_width + rng = np.random.default_rng(98765) + tiled_np = rng.integers(0, 2**31, size=(tiled_h, tiled_w, 1), dtype=np.uint32) + + tiled_wp = wp.array(tiled_np, dtype=wp.uint32, ndim=3, device=DEVICE) + output_wp = wp.zeros(shape=(num_envs, tile_height, tile_width, 1), dtype=wp.uint32, device=DEVICE) + + wp.launch( + kernel=extract_all_tiles_kernel, + dim=(num_envs, tile_height, tile_width), + inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height], + device=DEVICE, + ) + wp.synchronize() + + expected = _reference_extract_all_uint32_tiles(tiled_np, num_envs, num_cols, tile_width, tile_height) + np.testing.assert_array_equal(output_wp.numpy(), expected) + + def test_single_tile(self): + num_cols = 1 + num_envs = 1 + tile_width = 4 + tile_height = 4 + tiled_np = np.arange(tile_height * tile_width, dtype=np.uint32).reshape(tile_height, tile_width, 1) + + tiled_wp = wp.array(tiled_np, dtype=wp.uint32, ndim=3, device=DEVICE) + output_wp = wp.zeros(shape=(num_envs, tile_height, tile_width, 1), dtype=wp.uint32, device=DEVICE) + + wp.launch( + kernel=extract_all_tiles_kernel, + dim=(num_envs, tile_height, tile_width), + inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height], + device=DEVICE, + ) + wp.synchronize() + + expected = _reference_extract_all_uint32_tiles(tiled_np, num_envs, num_cols, tile_width, tile_height) + np.testing.assert_array_equal(output_wp.numpy(), expected) + + @pytest.mark.parametrize( + ("num_cols", "num_envs", "tile_width", "tile_height"), + [ + (3, 6, 2, 2), + (1, 3, 5, 1), + (4, 8, 1, 1), + ], + ) + def test_various_layouts(self, num_cols, num_envs, tile_width, tile_height): + num_rows = (num_envs + num_cols - 1) // num_cols + tiled_h = num_rows * tile_height + tiled_w = num_cols * tile_width + rng = np.random.default_rng(13579) + tiled_np = rng.integers(0, 2**31, size=(tiled_h, tiled_w, 1), dtype=np.uint32) + + tiled_wp = wp.array(tiled_np, dtype=wp.uint32, ndim=3, device=DEVICE) + output_wp = wp.zeros(shape=(num_envs, tile_height, tile_width, 1), dtype=wp.uint32, device=DEVICE) + + wp.launch( + kernel=extract_all_tiles_kernel, + dim=(num_envs, tile_height, tile_width), + inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height], + device=DEVICE, + ) + wp.synchronize() + + expected = _reference_extract_all_uint32_tiles(tiled_np, num_envs, num_cols, tile_width, tile_height) + np.testing.assert_array_equal(output_wp.numpy(), expected) + + class TestExtractAllRgbaTilesKernel: - """Tests for ``extract_all_rgba_tiles_kernel``.""" + """Tests for the RGB/RGBA case of ``extract_all_tiles_kernel``.""" def test_two_by_two_tile_grid_rgba(self): num_cols = 2 @@ -244,9 +394,9 @@ def test_two_by_two_tile_grid_rgba(self): 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, + kernel=extract_all_tiles_kernel, dim=(num_envs, tile_height, tile_width), - inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height, num_channels], + inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height], device=DEVICE, ) wp.synchronize() @@ -268,9 +418,9 @@ def test_single_tile_rgb(self): 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, + kernel=extract_all_tiles_kernel, dim=(num_envs, tile_height, tile_width), - inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height, num_channels], + inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height], device=DEVICE, ) wp.synchronize() @@ -280,8 +430,8 @@ def test_single_tile_rgb(self): ) 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).""" + def test_three_channel_output_skips_alpha(self): + """A 3-channel output buffer only copies RGB, even if the tiled input has an alpha channel.""" num_cols = 1 num_envs = 1 tile_width = 2 @@ -298,14 +448,14 @@ def test_num_channels_not_four_skips_alpha(self): output_wp = wp.zeros(shape=(1, 2, 2, 3), dtype=wp.uint8, device=DEVICE) wp.launch( - kernel=extract_all_rgba_tiles_kernel, + kernel=extract_all_tiles_kernel, dim=(1, tile_height, tile_width), - inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height, 2], + inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height], device=DEVICE, ) wp.synchronize() - expected = _reference_extract_all_rgba_tiles(tiled_np, num_envs, num_cols, tile_width, tile_height, 2) + expected = _reference_extract_all_rgba_tiles(tiled_np, num_envs, num_cols, tile_width, tile_height, 3) np.testing.assert_array_equal(output_wp.numpy(), expected) @pytest.mark.parametrize( @@ -333,9 +483,9 @@ def test_various_layouts(self, num_cols, num_envs, tile_width, tile_height, num_ ) wp.launch( - kernel=extract_all_rgba_tiles_kernel, + kernel=extract_all_tiles_kernel, dim=(num_envs, tile_height, tile_width), - inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height, num_channels], + inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height], device=DEVICE, ) wp.synchronize() @@ -347,7 +497,7 @@ def test_various_layouts(self, num_cols, num_envs, tile_width, tile_height, num_ class TestExtractAllRgbFloatTilesKernel: - """Tests for ``extract_all_rgb_float_tiles_kernel`` used by OVRTX HdrColor.""" + """Tests for the HdrColor (float32/float16) case of ``extract_all_tiles_kernel``.""" def test_two_by_two_tile_grid(self): num_cols = 2 @@ -367,7 +517,7 @@ def test_two_by_two_tile_grid(self): output_wp = wp.zeros(shape=(num_envs, tile_height, tile_width, 3), dtype=wp.float32, device=DEVICE) wp.launch( - kernel=extract_all_rgb_float_tiles_kernel, + kernel=extract_all_tiles_kernel, dim=(num_envs, tile_height, tile_width), inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height], device=DEVICE, @@ -395,7 +545,7 @@ def test_half_input_writes_float_output(self): output_wp = wp.zeros(shape=(num_envs, tile_height, tile_width, 3), dtype=wp.float32, device=DEVICE) wp.launch( - kernel=extract_all_rgb_half_tiles_kernel, + kernel=extract_all_tiles_kernel, dim=(num_envs, tile_height, tile_width), inputs=[tiled_wp, output_wp, num_cols, tile_width, tile_height], device=DEVICE, diff --git a/source/isaaclab_ov/test/test_ovrtx_usd.py b/source/isaaclab_ov/test/test_ovrtx_usd.py index d5fb78c38d09..41e002997f39 100644 --- a/source/isaaclab_ov/test/test_ovrtx_usd.py +++ b/source/isaaclab_ov/test/test_ovrtx_usd.py @@ -99,6 +99,20 @@ def test_ovrtx_instance_id_segmentation_fast_uses_instance_segmentation_sd_rende ) +def test_ovrtx_motion_vectors_uses_target_motion_render_var(): + """Requesting motion vectors from OVRTX selects the TargetMotionSD render variable.""" + assert get_render_var_config(["motion_vectors"]) == ( + "/Render/Vars/TargetMotionSD", + "TargetMotionSD", + "TargetMotionSD", + ) + + +def test_ovrtx_motion_vectors_with_rgb_falls_back_to_rgb(): + """OVRTX only supports one main AOV at a time; combining motion vectors with RGB keeps RGB.""" + assert get_render_var_config(["rgb", "motion_vectors"]) == ("/Render/Vars/LdrColor", "LdrColor", "LdrColor") + + def test_ovrtx_rgb_and_rgb_hdr_author_both_render_vars(): """Requesting LDR RGB and RGB_HDR keeps both OVRTX render variables.""" render_var_configs = get_render_var_configs(["rgb", "rgb_hdr"]) diff --git a/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_rigidobjectcollection_view.major.rst b/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_rigidobjectcollection_view.major.rst deleted file mode 100644 index 9f467944463a..000000000000 --- a/source/isaaclab_ovphysx/changelog.d/antoiner-feat-ovphysx_rigidobjectcollection_view.major.rst +++ /dev/null @@ -1,9 +0,0 @@ -Changed -^^^^^^^ - -* **Breaking:** :attr:`~isaaclab_ovphysx.assets.RigidObjectCollection.root_view` now returns an - :class:`~isaaclab_ovphysx.sim.views.OvPhysxView` binding manager instead of a raw ``dict`` - mapping ``TensorType`` to ``TensorBinding``. The view wraps the fused multi-prim bindings - (``prim_paths=[...]``) and stores each under the collection's ``LINK_*``/``BODY_*`` data-class - key via ``key_aliases`` (mapped from the underlying ``RIGID_BODY_*`` type). Replace - ``root_view[tensor_type]`` with ``root_view.try_binding_for(tensor_type)``. diff --git a/source/isaaclab_ovphysx/changelog.d/ovphysx-current-runtime-compat.rst b/source/isaaclab_ovphysx/changelog.d/ovphysx-current-runtime-compat.rst deleted file mode 100644 index 35fe74f59781..000000000000 --- a/source/isaaclab_ovphysx/changelog.d/ovphysx-current-runtime-compat.rst +++ /dev/null @@ -1,7 +0,0 @@ -Fixed -^^^^^ - -* Updated :class:`~isaaclab_ovphysx.physics.OvPhysxManager` for the current - OVPhysX constructor, stage-reset, and synchronous-step APIs. -* Synchronized GPU-to-host property staging before OVPhysX consumes the pinned - host buffers. diff --git a/source/isaaclab_ovphysx/config/extension.toml b/source/isaaclab_ovphysx/config/extension.toml index a3253f6b5fcc..17482a81446f 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 = "5.0.1" +version = "6.1.1" # 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 9b8010163d1a..1f62c943719e 100644 --- a/source/isaaclab_ovphysx/docs/CHANGELOG.rst +++ b/source/isaaclab_ovphysx/docs/CHANGELOG.rst @@ -1,6 +1,50 @@ Changelog --------- +6.1.1 (2026-07-07) +~~~~~~~~~~~~~~~~~~ + +Fixed +^^^^^ + +* Fixed the OVPhysX optional runtime dependency to install + ``ovphysx==0.5.2+head.f62c22207c``, matching the supported runtime wheel. + + +6.1.0 (2026-07-06) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :attr:`~isaaclab_ovphysx.physics.OvPhysxCfg.enable_enhanced_determinism` and + :attr:`~isaaclab_ovphysx.physics.OvPhysxCfg.enable_external_forces_every_iteration`, mirroring the + equivalent PhysX solver settings already exposed on + :class:`~isaaclab_physx.physics.PhysxCfg`. + + +6.0.0 (2026-07-03) +~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* **Breaking:** :attr:`~isaaclab_ovphysx.assets.RigidObjectCollection.root_view` now returns an + :class:`~isaaclab_ovphysx.sim.views.OvPhysxView` binding manager instead of a raw ``dict`` + mapping ``TensorType`` to ``TensorBinding``. The view wraps the fused multi-prim bindings + (``prim_paths=[...]``) and stores each under the collection's ``LINK_*``/``BODY_*`` data-class + key via ``key_aliases`` (mapped from the underlying ``RIGID_BODY_*`` type). Replace + ``root_view[tensor_type]`` with ``root_view.try_binding_for(tensor_type)``. + +Fixed +^^^^^ + +* Updated :class:`~isaaclab_ovphysx.physics.OvPhysxManager` for the current + OVPhysX constructor, stage-reset, and synchronous-step APIs. +* Synchronized GPU-to-host property staging before OVPhysX consumes the pinned + host buffers. + + 5.0.1 (2026-07-01) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py index 458f38f9b541..626d693c10bd 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py @@ -714,15 +714,15 @@ def _configure_physx_scene_prim(scene_prim, cfg, device: str) -> None: so we write the apiSchemas list entry and scene attributes directly via raw Sdf metadata manipulation instead of using the high-level USD API. - The schema and scene-query-support attribute are applied regardless of - device. The GPU-specific dynamics/broadphase/capacity attributes are + The schema, scene-query-support, and solver-determinism/accuracy attributes are applied + regardless of device. The GPU-specific dynamics/broadphase/capacity attributes are applied only when ``device == "gpu"`` — without them PhysX defaults to CPU broadphase even when OVPhysX is configured for GPU execution. Args: scene_prim: The /World/PhysicsScene prim to configure. - cfg: The :class:`OvPhysxCfg` carrying GPU buffer-capacity values. - Only consulted when ``device == "gpu"``. + cfg: The :class:`OvPhysxCfg` carrying solver-determinism flags and GPU buffer-capacity + values. The GPU buffer-capacity values are only consulted when ``device == "gpu"``. device: Resolved physics device — one of ``"cpu"`` or ``"gpu"``. """ from pxr import Sdf @@ -741,6 +741,14 @@ def _configure_physx_scene_prim(scene_prim, cfg, device: str) -> None: enable_sq = getattr(sim_cfg, "enable_scene_query_support", False) scene_prim.CreateAttribute("physxScene:enableSceneQuerySupport", Sdf.ValueTypeNames.Bool).Set(enable_sq) + if cfg is not None: + scene_prim.CreateAttribute("physxScene:enableEnhancedDeterminism", Sdf.ValueTypeNames.Bool).Set( + cfg.enable_enhanced_determinism + ) + scene_prim.CreateAttribute("physxScene:enableExternalForcesEveryIteration", Sdf.ValueTypeNames.Bool).Set( + cfg.enable_external_forces_every_iteration + ) + if device == "gpu": scene_prim.CreateAttribute("physxScene:enableGPUDynamics", Sdf.ValueTypeNames.Bool).Set(True) scene_prim.CreateAttribute("physxScene:broadphaseType", Sdf.ValueTypeNames.String).Set("GPU") diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager_cfg.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager_cfg.py index 96047e46363d..5de6e902e440 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager_cfg.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager_cfg.py @@ -22,6 +22,21 @@ class OvPhysxCfg(PhysicsCfg): class_type = "{DIR}.ovphysx_manager:OvPhysxManager" + enable_enhanced_determinism: bool = False + """Enable/disable improved determinism at the expense of performance. Defaults to False. + + For more information on PhysX determinism, please check `here`_. + + .. _here: https://nvidia-omniverse.github.io/PhysX/physx/5.4.1/docs/RigidBodyDynamics.html#enhanced-determinism + """ + + enable_external_forces_every_iteration: bool = False + """Enable/disable external forces every position iteration in the TGS solver. Default is False. + + This can help improve the accuracy of velocity updates. Consider enabling this flag if the + velocities generated by the simulation are noisy. + """ + gpu_max_rigid_contact_count: int = 2**23 """Size of the GPU rigid-body contact buffer.""" diff --git a/source/isaaclab_ovphysx/pyproject.toml b/source/isaaclab_ovphysx/pyproject.toml index e39a2a01bd9a..8d85e5154407 100644 --- a/source/isaaclab_ovphysx/pyproject.toml +++ b/source/isaaclab_ovphysx/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_ovphysx" -version = "5.0.1" +version = "6.1.1" description = "Extension providing IsaacLab with ovphysx/TensorBindingsAPI specific abstractions." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] @@ -23,7 +23,7 @@ Homepage = "https://github.com/isaac-sim/IsaacLab" Repository = "https://github.com/isaac-sim/IsaacLab" [project.optional-dependencies] -ovphysx = ["ovphysx==0.4.13"] +ovphysx = ["ovphysx==0.5.2+head.f62c22207c"] [tool.setuptools] include-package-data = true diff --git a/source/isaaclab_physx/config/extension.toml b/source/isaaclab_physx/config/extension.toml index 9ea4a736d54c..3e148db6c69b 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 = "2.6.2" +version = "2.7.0" # 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 88a48a0ce623..c4f44204ed27 100644 --- a/source/isaaclab_physx/docs/CHANGELOG.rst +++ b/source/isaaclab_physx/docs/CHANGELOG.rst @@ -1,6 +1,23 @@ Changelog --------- +2.7.0 (2026-07-04) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :class:`~isaaclab_physx.sim.spawners.materials.PhysxMaterialCfg`, a single-namespace + ``physxMaterial`` rigid-body physics-material fragment (compliant-contact spring stiffness/damping + and the friction/restitution combine-mode tokens) backing ``PhysxMaterialAPI``. +* Added :attr:`~isaaclab_physx.sim.spawners.materials.PhysxMaterialCfg.damping_combine_mode` (writes + ``physxMaterial:dampingCombineMode``) and + :attr:`~isaaclab_physx.sim.spawners.materials.PhysxMaterialCfg.compliant_contact_acceleration_spring` + (writes ``physxMaterial:compliantContactAccelerationSpring``), completing the fragment's coverage + of ``PhysxMaterialAPI``. Also added the same two fields to the legacy + :class:`~isaaclab_physx.sim.spawners.materials.PhysxRigidBodyMaterialCfg`. + + 2.6.2 (2026-07-02) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/__init__.pyi b/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/__init__.pyi index 315458ea5d24..7a018a14ecd6 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/__init__.pyi +++ b/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/__init__.pyi @@ -7,6 +7,7 @@ __all__ = [ "DeformableBodyMaterialCfg", "PhysXDeformableMaterialCfg", "PhysxDeformableBodyMaterialCfg", + "PhysxMaterialCfg", "PhysxRigidBodyMaterialCfg", "PhysxSurfaceDeformableBodyMaterialCfg", "RigidBodyMaterialCfg", @@ -17,6 +18,7 @@ from .physics_materials_cfg import ( DeformableBodyMaterialCfg, PhysXDeformableMaterialCfg, PhysxDeformableBodyMaterialCfg, + PhysxMaterialCfg, PhysxRigidBodyMaterialCfg, PhysxSurfaceDeformableBodyMaterialCfg, RigidBodyMaterialCfg, diff --git a/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/physics_materials_cfg.py b/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/physics_materials_cfg.py index 345fadc88704..3560bc12bb3b 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/physics_materials_cfg.py +++ b/source/isaaclab_physx/isaaclab_physx/sim/spawners/materials/physics_materials_cfg.py @@ -12,6 +12,7 @@ from isaaclab.sim.spawners.materials.physics_materials_cfg import ( DeformableBodyMaterialBaseCfg, RigidBodyMaterialBaseCfg, + RigidBodyMaterialFragment, SurfaceDeformableBodyMaterialBaseCfg, ) from isaaclab.utils.configclass import configclass @@ -154,12 +155,13 @@ class PhysxRigidBodyMaterialCfg(RigidBodyMaterialBaseCfg): Extends :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialBaseCfg` with the `PhysxMaterialAPI`_ schema fields: compliant-contact spring (stiffness/damping) and the - friction/restitution combine-mode tokens. None of these fields have a Newton consumer - today; they are PhysX-engine-only knobs. + friction/restitution combine-mode tokens. Newton's USD importer also recognizes stiffness and + damping as fallback contact parameters; the acceleration-spring flag and combine modes are + PhysX-only. See :meth:`~isaaclab.sim.spawners.materials.spawn_rigid_body_material` for more information. - .. _PhysxMaterialAPI: https://docs.omniverse.nvidia.com/kit/docs/omni_usd_schema_physics/104.2/class_physx_schema_physx_material_a_p_i.html + .. _PhysxMaterialAPI: https://docs.omniverse.nvidia.com/kit/docs/omni_usd_schema_physics/latest/class_physx_schema_physx_material_a_p_i.html """ # -- Class metadata (not dataclass fields) -- @@ -172,14 +174,23 @@ class PhysxRigidBodyMaterialCfg(RigidBodyMaterialBaseCfg): """Spring stiffness for a compliant contact model using implicit springs. A higher stiffness results in behavior closer to a rigid contact. The compliant contact model - is only enabled if the stiffness is larger than 0. PhysX-only; not consumed by Newton. + is only enabled if the stiffness is larger than 0. Newton's USD importer also recognizes this + attribute as a fallback contact-stiffness value. """ compliant_contact_damping: float | None = None """Damping coefficient for a compliant contact model using implicit springs. Irrelevant if compliant contacts are disabled when :attr:`compliant_contact_stiffness` is set - to zero and rigid contacts are active. PhysX-only; not consumed by Newton. + to zero and rigid contacts are active. Newton's USD importer also recognizes this attribute as + a fallback contact-damping value. + """ + + compliant_contact_acceleration_spring: bool | None = None + """Whether the compliant contact spring is formulated in acceleration (rather than force) units. + + Only relevant when compliant contacts are enabled (:attr:`compliant_contact_stiffness` is + non-zero). PhysX-only; not consumed by Newton. """ friction_combine_mode: Literal["average", "min", "multiply", "max"] | None = None @@ -202,6 +213,73 @@ class PhysxRigidBodyMaterialCfg(RigidBodyMaterialBaseCfg): `__. """ + damping_combine_mode: Literal["average", "min", "multiply", "max"] | None = None + """Determines the way the (compliant-contact) damping coefficient is combined during collisions. + + .. attention:: + + When two physics materials with different combine modes collide, the combine mode with + the higher priority will be used. The priority order is provided `here + `__. + """ + + +@configclass +class PhysxMaterialCfg(RigidBodyMaterialFragment): + """``physxMaterial:*`` rigid-body material attributes from `PhysxMaterialAPI`_. + + Single-namespace fragment (see :class:`~isaaclab.sim.spawners.materials.RigidBodyMaterialFragment`) + for the PhysX material namespace: compliant-contact spring (stiffness/damping), which Newton's + USD importer can read as fallback contact parameters, plus PhysX-only acceleration-spring and + combine-mode knobs. The ``PhysxMaterialAPI`` schema is applied (and the ``physxMaterial:*`` + attributes authored) by the generic :func:`~isaaclab.sim.schemas.apply_namespaced` writer. + ``None`` fields are left unchanged. + + .. _PhysxMaterialAPI: https://docs.omniverse.nvidia.com/kit/docs/omni_usd_schema_physics/latest/class_physx_schema_physx_material_a_p_i.html + """ + + _usd_namespace: ClassVar[str | None] = "physxMaterial" + _usd_applied_schema: ClassVar[str | None] = "PhysxMaterialAPI" + + compliant_contact_stiffness: float | None = None + """Spring stiffness for a compliant contact model using implicit springs. + + A higher stiffness results in behavior closer to a rigid contact. The compliant contact model is + only enabled if the stiffness is larger than 0. Writes ``physxMaterial:compliantContactStiffness``. + """ + + compliant_contact_damping: float | None = None + """Damping coefficient for a compliant contact model using implicit springs. + + Irrelevant if compliant contacts are disabled (``compliant_contact_stiffness`` is zero). Writes + ``physxMaterial:compliantContactDamping``. + """ + + compliant_contact_acceleration_spring: bool | None = None + """Whether the compliant contact spring is formulated in acceleration (rather than force) units. + + Writes ``physxMaterial:compliantContactAccelerationSpring``. Only relevant when compliant + contacts are enabled (:attr:`compliant_contact_stiffness` is non-zero). + """ + + friction_combine_mode: Literal["average", "min", "multiply", "max"] | None = None + """How friction is combined during collisions. Writes ``physxMaterial:frictionCombineMode``.""" + + restitution_combine_mode: Literal["average", "min", "multiply", "max"] | None = None + """How restitution is combined during collisions. Writes ``physxMaterial:restitutionCombineMode``.""" + + damping_combine_mode: Literal["average", "min", "multiply", "max"] | None = None + """How the (compliant-contact) damping coefficient is combined during collisions. + + Writes ``physxMaterial:dampingCombineMode``. + + .. attention:: + + When two physics materials with different combine modes collide, the combine mode with + the higher priority will be used. The priority order is provided `here + `__. + """ + @configclass class RigidBodyMaterialCfg(PhysxRigidBodyMaterialCfg): diff --git a/source/isaaclab_physx/pyproject.toml b/source/isaaclab_physx/pyproject.toml index 63d0495a2a75..82a80f7e5324 100644 --- a/source/isaaclab_physx/pyproject.toml +++ b/source/isaaclab_physx/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_physx" -version = "2.6.2" +version = "2.7.0" description = "Extension providing IsaacLab with PhysX specific abstractions." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_contract.py b/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_contract.py index 7f16b2f80343..65cd093ce332 100644 --- a/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_contract.py +++ b/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_contract.py @@ -9,7 +9,8 @@ import sys import types -from unittest.mock import MagicMock, patch +from types import SimpleNamespace +from unittest.mock import MagicMock, call, patch import pytest import warp as wp @@ -52,3 +53,55 @@ def test_isaac_rtx_supported_output_types_include_rgb_hdr(monkeypatch): specs = renderer.supported_output_types() assert specs[RenderBufferKind.RGB_HDR] == RenderBufferSpec(3, wp.float32) + + +@pytest.mark.parametrize( + ("has_gui", "expected_disable_color_render"), + [ + pytest.param(True, False, id="gui-keeps-color-rendering"), + pytest.param(False, True, id="headless-uses-depth-only-rendering"), + ], +) +def test_depth_only_camera_color_render_setting(monkeypatch, has_gui, expected_disable_color_render): + """Depth-only cameras must not disable color rendering for an active GUI. + + ``disableColorRender`` is a global RTX setting, so enabling it for a depth-only + camera also blacks out the viewport. Headless execution should retain the + depth-only optimization. + """ + _, syntheticdata_module = _install_omni_stubs(monkeypatch) + monkeypatch.setattr(syntheticdata_module, "SyntheticData", MagicMock(), raising=False) + + import isaaclab_physx.renderers.isaac_rtx_renderer as rtx_renderer + from isaaclab_physx.renderers.isaac_rtx_renderer_cfg import IsaacRtxRendererCfg + + import isaaclab.sim.utils.stage as stage_utils + + settings = MagicMock() + settings.get.return_value = has_gui + + # Camera validation terminates create_render_data immediately after the + # color-render setting is selected, keeping this a lightweight unit test. + stage = MagicMock() + stage.GetPrimAtPath.return_value.IsA.return_value = False + spec = SimpleNamespace( + camera_prim_paths=["/World/NotACamera"], + cfg=SimpleNamespace(data_types=["depth"]), + ) + renderer = rtx_renderer.IsaacRtxRenderer.__new__(rtx_renderer.IsaacRtxRenderer) + renderer.cfg = IsaacRtxRendererCfg() + + with ( + patch.object(rtx_renderer, "get_settings_manager", return_value=settings), + patch.object(rtx_renderer, "get_isaac_sim_version", return_value=version.parse("6.0")), + patch.object(stage_utils, "get_current_stage", return_value=stage), + pytest.raises(RuntimeError, match="is not a Camera"), + ): + renderer.create_render_data(spec) + + color_render_calls = [ + setting_call + for setting_call in settings.set_bool.call_args_list + if setting_call.args[0] == "/rtx/sdg/force/disableColorRender" + ] + assert color_render_calls[-1] == call("/rtx/sdg/force/disableColorRender", expected_disable_color_render) diff --git a/source/isaaclab_rl/changelog.d/mh-rslrl_bump.minor.rst b/source/isaaclab_rl/changelog.d/mh-rslrl_bump.minor.rst deleted file mode 100644 index 7411f890bac5..000000000000 --- a/source/isaaclab_rl/changelog.d/mh-rslrl_bump.minor.rst +++ /dev/null @@ -1,14 +0,0 @@ -Changed -^^^^^^^ - -* Bumped the ``rsl-rl-lib`` dependency to ``5.4.1``, which natively supports image-only policies - (observation sets with no 1D groups). -* Changed :attr:`~isaaclab_rl.rsl_rl.RslRlCNNModelCfg.class_name` to default to rsl-rl's - ``CNNModel`` now that it supports image-only observations out of the box. - -Removed -^^^^^^^ - -* Removed the Isaac Lab ``CNNModel`` override of rsl-rl's ``CNNModel`` that previously added - image-only observation support. Use rsl-rl's ``CNNModel`` (the new default of - :attr:`~isaaclab_rl.rsl_rl.RslRlCNNModelCfg.class_name`) instead. diff --git a/source/isaaclab_rl/config/extension.toml b/source/isaaclab_rl/config/extension.toml index 01fd9d01f402..5ce250dea3e2 100644 --- a/source/isaaclab_rl/config/extension.toml +++ b/source/isaaclab_rl/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "0.6.2" +version = "0.7.0" # Description title = "Isaac Lab RL" diff --git a/source/isaaclab_rl/docs/CHANGELOG.rst b/source/isaaclab_rl/docs/CHANGELOG.rst index b27c41f52949..8598ede916e4 100644 --- a/source/isaaclab_rl/docs/CHANGELOG.rst +++ b/source/isaaclab_rl/docs/CHANGELOG.rst @@ -1,6 +1,25 @@ Changelog --------- +0.7.0 (2026-07-03) +~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Bumped the ``rsl-rl-lib`` dependency to ``5.4.1``, which natively supports image-only policies + (observation sets with no 1D groups). +* Changed :attr:`~isaaclab_rl.rsl_rl.RslRlCNNModelCfg.class_name` to default to rsl-rl's + ``CNNModel`` now that it supports image-only observations out of the box. + +Removed +^^^^^^^ + +* Removed the Isaac Lab ``CNNModel`` override of rsl-rl's ``CNNModel`` that previously added + image-only observation support. Use rsl-rl's ``CNNModel`` (the new default of + :attr:`~isaaclab_rl.rsl_rl.RslRlCNNModelCfg.class_name`) instead. + + 0.6.2 (2026-06-28) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_rl/pyproject.toml b/source/isaaclab_rl/pyproject.toml index 9fc5f43ba201..ae351b10958c 100644 --- a/source/isaaclab_rl/pyproject.toml +++ b/source/isaaclab_rl/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_rl" -version = "0.6.2" +version = "0.7.0" description = "Extension containing reinforcement learning related utilities." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_tasks/changelog.d/cartpole-state-mdp-alignment.rst b/source/isaaclab_tasks/changelog.d/cartpole-state-mdp-alignment.rst deleted file mode 100644 index 390b96365a9d..000000000000 --- a/source/isaaclab_tasks/changelog.d/cartpole-state-mdp-alignment.rst +++ /dev/null @@ -1,14 +0,0 @@ -Added -^^^^^ - -* Added the ``ovphysx`` physics preset to the manager-based Cartpole tasks. - -Changed -^^^^^^^ - -* **Breaking:** Aligned the direct and manager-based Cartpole MDPs, including state observations, reset distributions, - termination conditions, episode horizon, reward convention, camera frame stacking, and camera lighting. Retrain - policies previously trained on the Cartpole tasks. - -* Changed all Cartpole camera renderer variants to stack two frames by default, and reduced the RSL-RL camera CNN - size while retaining reliable convergence. diff --git a/source/isaaclab_tasks/changelog.d/ovrtx-instance-segmentation-fast.rst b/source/isaaclab_tasks/changelog.d/ovrtx-instance-segmentation-fast.rst deleted file mode 100644 index 7dba962376bd..000000000000 --- a/source/isaaclab_tasks/changelog.d/ovrtx-instance-segmentation-fast.rst +++ /dev/null @@ -1,9 +0,0 @@ -Added -^^^^^ - -* Added ``instance_segmentation_fast`` and ``instance_id_segmentation_fast`` rendering test - presets to the rendering test utilities for Cartpole, ShadowHand, and Dexsuite environments, - covering both the OVRTX and Isaac RTX renderers. Because instance IDs are non-stable across - runs, golden images are saved with full RGBA colors but comparison is restricted to the alpha - channel only, which encodes the instance mask shape reliably. SSIM is also disabled for these - data types; only the per-pixel L2 gate is used to decide pass/fail. diff --git a/source/isaaclab_tasks/config/extension.toml b/source/isaaclab_tasks/config/extension.toml index ccae571210e1..2fdf98600c1a 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 = "8.1.2" +version = "8.1.6" # Description title = "Isaac Lab Environments" diff --git a/source/isaaclab_tasks/docs/CHANGELOG.rst b/source/isaaclab_tasks/docs/CHANGELOG.rst index a101818c1648..7ad8be7ffe9f 100644 --- a/source/isaaclab_tasks/docs/CHANGELOG.rst +++ b/source/isaaclab_tasks/docs/CHANGELOG.rst @@ -1,6 +1,90 @@ Changelog --------- +8.1.6 (2026-07-07) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :class:`~isaaclab_tasks.core.fourbar_pole.fourbar_pole_manager_env_cfg.FourbarPoleSwingupEnvCfg` + as the ``Isaac-Fourbar-Pole-Swingup`` Gym environment for four-bar pole swing-up with the Kamino solver. + +Changed +^^^^^^^ + +* Changed the Disney DR Legs walk / hold-pose Kamino stepping to ``sim.dt = 1/150`` + with ``decimation = 3`` (previously ``0.004`` / ``5``). This keeps the 50 Hz control + rate while reducing the number of Kamino solver calls per control step from 5 to 3, + for roughly 1.6x faster simulation with equivalent task performance. +* Changed the ``newton_kamino`` presets of the velocity (A1, AnymalB, AnymalC, Go1, Go2, + Cassie, G1, H1, Spot), cabinet, and shadow-hand reorient tasks to use the default + single physics substep (removed the ``num_substeps=2`` override), and dropped the + redundant explicit ``num_substeps`` overrides from the cartpole, ant, and reach Kamino + presets. + + +8.1.5 (2026-07-06) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added ``motion_vectors`` to the rendering correctness test matrix for cartpole, shadow hand, and + dexsuite kuka allegro lift environments. + +Fixed +^^^^^ + +* Fixed flaky ``motion_vectors`` golden-image comparisons on PhysX backends (``physx`` and + ``ovphysx``) by enabling enhanced determinism and per-iteration external forces on the PhysX + solver, which otherwise produces run-to-run noisy velocities that this AOV encodes directly. + + +8.1.4 (2026-07-04) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added ``Isaac-DrLegs-HoldPose-v0`` and ``Isaac-DrLegs-Walk-v0`` Kamino closed-loop locomotion + tasks via :class:`~isaaclab_tasks.contrib.dr_legs.hold_pose_env_cfg.DrLegsHoldPoseEnvCfg` and + :class:`~isaaclab_tasks.contrib.dr_legs.walk_env_cfg.DrLegsWalkEnvCfg`. + +* Added ``newton_kamino`` physics presets to core and contrib velocity, reach, cabinet, and Shadow + Hand environment configurations. + +Fixed +^^^^^ + +* Fixed reach task table spawn offset for Newton ``newton_mjwarp`` and ``newton_kamino`` presets. + + +8.1.3 (2026-07-03) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added ``instance_segmentation_fast`` and ``instance_id_segmentation_fast`` rendering test + presets to the rendering test utilities for Cartpole, ShadowHand, and Dexsuite environments, + covering both the OVRTX and Isaac RTX renderers. Because instance IDs are non-stable across + runs, golden images are saved with full RGBA colors but comparison is restricted to the alpha + channel only, which encodes the instance mask shape reliably. SSIM is also disabled for these + data types; only the per-pixel L2 gate is used to decide pass/fail. +* Added the ``ovphysx`` physics preset to the manager-based Cartpole tasks. + +Changed +^^^^^^^ + +* **Breaking:** Aligned the direct and manager-based Cartpole MDPs, including state observations, reset distributions, + termination conditions, episode horizon, reward convention, camera frame stacking, and camera lighting. Retrain + policies previously trained on the Cartpole tasks. + +* Changed all Cartpole camera renderer variants to stack two frames by default, and reduced the RSL-RL camera CNN + size while retaining reliable convergence. + + 8.1.2 (2026-07-01) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/__init__.py new file mode 100644 index 000000000000..8cfe3a75c5dd --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/__init__.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 + +"""DR Legs closed-loop biped locomotion tasks (Kamino solver).""" + +import gymnasium as gym + +from . import agents + +gym.register( + id="Isaac-DrLegs-HoldPose-v0", + entry_point="isaaclab.envs:ManagerBasedRLEnv", + disable_env_checker=True, + kwargs={ + "env_cfg_entry_point": f"{__name__}.hold_pose_env_cfg:DrLegsHoldPoseEnvCfg", + "rsl_rl_cfg_entry_point": f"{agents.__name__}.rsl_rl_ppo_cfg:DrLegsHoldPosePPORunnerCfg", + }, +) + +gym.register( + id="Isaac-DrLegs-Walk-v0", + entry_point="isaaclab.envs:ManagerBasedRLEnv", + disable_env_checker=True, + kwargs={ + "env_cfg_entry_point": f"{__name__}.walk_env_cfg:DrLegsWalkEnvCfg", + "rsl_rl_cfg_entry_point": f"{agents.__name__}.rsl_rl_ppo_cfg:DrLegsWalkPPORunnerCfg", + }, +) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/agents/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/agents/__init__.py new file mode 100644 index 000000000000..460a30569089 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/agents/__init__.py @@ -0,0 +1,4 @@ +# 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 diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/agents/rsl_rl_ppo_cfg.py new file mode 100644 index 000000000000..8fbc732f2569 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/agents/rsl_rl_ppo_cfg.py @@ -0,0 +1,50 @@ +# 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 isaaclab.utils.configclass import configclass + +from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg + + +@configclass +class DrLegsHoldPosePPORunnerCfg(RslRlOnPolicyRunnerCfg): + num_steps_per_env = 24 + max_iterations = 3000 + save_interval = 100 + experiment_name = "dr_legs_hold_pose" + obs_groups = {"actor": ["policy"], "critic": ["policy", "critic"]} + actor = RslRlMLPModelCfg( + hidden_dims=[512, 512, 512], + activation="elu", + obs_normalization=True, + distribution_cfg=RslRlMLPModelCfg.GaussianDistributionCfg(init_std=1.0), + ) + critic = RslRlMLPModelCfg( + hidden_dims=[512, 512, 512], + activation="elu", + obs_normalization=True, + ) + algorithm = RslRlPpoAlgorithmCfg( + value_loss_coef=1.0, + use_clipped_value_loss=True, + clip_param=0.2, + entropy_coef=0.001, + num_learning_epochs=5, + num_mini_batches=4, + learning_rate=1.0e-3, + schedule="adaptive", + gamma=0.99, + lam=0.95, + desired_kl=0.01, + max_grad_norm=1.0, + ) + + +@configclass +class DrLegsWalkPPORunnerCfg(DrLegsHoldPosePPORunnerCfg): + def __post_init__(self): + super().__post_init__() + self.experiment_name = "dr_legs_walk" + self.max_iterations = 3000 diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/hold_pose_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/hold_pose_env_cfg.py new file mode 100644 index 000000000000..a2c15669aefa --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/hold_pose_env_cfg.py @@ -0,0 +1,278 @@ +# 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 + +"""Hold-pose environment for the Disney DR Legs closed-loop biped (Kamino solver). + +The robot must keep its pelvis upright at a target height. +""" + +from isaaclab_newton.physics import KaminoSolverCfg, NewtonCfg, NewtonShapeCfg + +import isaaclab.sim as sim_utils +from isaaclab.assets import AssetBaseCfg +from isaaclab.envs import ManagerBasedRLEnvCfg +from isaaclab.managers import EventTermCfg as EventTerm +from isaaclab.managers import ObservationGroupCfg as ObsGroup +from isaaclab.managers import ObservationTermCfg as ObsTerm +from isaaclab.managers import RewardTermCfg as RewTerm +from isaaclab.managers import SceneEntityCfg +from isaaclab.managers import TerminationTermCfg as DoneTerm +from isaaclab.scene import InteractiveSceneCfg +from isaaclab.sim import SimulationCfg +from isaaclab.utils.configclass import configclass + +import isaaclab_tasks.contrib.dr_legs.mdp as mdp +from isaaclab_tasks.utils import PresetCfg + +from isaaclab_assets.robots.dr_legs import DR_LEGS_ACTUATED_JOINTS, DR_LEGS_IMPLICIT_PD_CFG + +## +# Shared constants +## + +_NUM_ENVS = 4096 + +_ACTUATED_JOINT_CFG = SceneEntityCfg("robot", joint_names=DR_LEGS_ACTUATED_JOINTS, preserve_order=True) + +_PHYSICS_MATERIAL = sim_utils.RigidBodyMaterialCfg( + friction_combine_mode="average", + restitution_combine_mode="average", + static_friction=1.0, + dynamic_friction=0.8, + restitution=0.0, +) + + +def _kamino_newton_cfg() -> NewtonCfg: + """Kamino solver preset for DR Legs (closed-loop, implicit PD).""" + return NewtonCfg( + solver_cfg=KaminoSolverCfg( + integrator="moreau", + sparse_jacobian=True, + sparse_dynamics=False, + use_collision_detector=False, + collision_detector_pipeline="unified", + collision_detector_max_contacts_per_pair=8, + use_fk_solver=True, + constraints_alpha=0.1, + padmm_max_iterations=100, + padmm_primal_tolerance=1.0e-5, + padmm_dual_tolerance=1.0e-5, + padmm_compl_tolerance=1.0e-5, + padmm_rho_0=0.02, + padmm_eta=1.0e-5, + padmm_use_acceleration=True, + padmm_warmstart_mode="containers", + padmm_contact_warmstart_method="geom_pair_net_force", + padmm_use_graph_conditionals=False, + max_contacts_per_world=32, + ), + use_cuda_graph=True, + default_shape_cfg=NewtonShapeCfg(margin=0.0, gap=0.001), + ) + + +@configclass +class DrLegsPhysicsCfg(PresetCfg): + """Physics backend preset (DR Legs runs only under the Kamino solver).""" + + default: NewtonCfg = _kamino_newton_cfg() + newton_kamino: NewtonCfg = _kamino_newton_cfg() + + +## +# Scene definition +## + + +@configclass +class HoldPoseSceneCfg(InteractiveSceneCfg): + ground = AssetBaseCfg( + prim_path="/World/ground", + spawn=sim_utils.GroundPlaneCfg(size=(100.0, 100.0), physics_material=_PHYSICS_MATERIAL), + ) + + robot = DR_LEGS_IMPLICIT_PD_CFG.replace(prim_path="{ENV_REGEX_NS}/Robot") + + dome_light = AssetBaseCfg( + prim_path="/World/DomeLight", + spawn=sim_utils.DomeLightCfg(intensity=2000.0, color=(0.75, 0.75, 0.75)), + ) + + +## +# MDP settings +## + + +@configclass +class ActionsCfg: + joint_pos = mdp.JointPositionActionCfg( + asset_name="robot", + joint_names=DR_LEGS_ACTUATED_JOINTS, + preserve_order=True, + scale=0.3, + use_default_offset=True, + ) + + +@configclass +class ObservationsCfg: + @configclass + class PolicyCfg(ObsGroup): + projected_gravity = ObsTerm(func=mdp.projected_gravity) + base_ang_vel = ObsTerm(func=mdp.base_ang_vel) + joint_pos = ObsTerm(func=mdp.joint_pos_rel, params={"asset_cfg": _ACTUATED_JOINT_CFG}) + setpoint_hist = ObsTerm( + func=mdp.joint_position_setpoints, + params={"action_term_name": "joint_pos"}, + history_length=2, + flatten_history_dim=True, + ) + + def __post_init__(self) -> None: + self.enable_corruption = False + self.concatenate_terms = True + + @configclass + class CriticCfg(ObsGroup): + base_lin_vel = ObsTerm(func=mdp.base_lin_vel) + joint_vel_rel = ObsTerm(func=mdp.joint_vel_rel, scale=0.2) + + def __post_init__(self) -> None: + self.enable_corruption = False + self.concatenate_terms = True + + policy: PolicyCfg = PolicyCfg() + critic: CriticCfg = CriticCfg() + + +@configclass +class EventCfg: + randomize_joint_params = EventTerm( + func=mdp.randomize_joint_parameters, + mode="startup", + params={ + "asset_cfg": _ACTUATED_JOINT_CFG, + "operation": "scale", + "distribution": "uniform", + "armature_distribution_params": (0.8, 1.2), + }, + ) + + randomize_actuator_gains = EventTerm( + func=mdp.randomize_actuator_gains, + mode="startup", + params={ + "asset_cfg": _ACTUATED_JOINT_CFG, + "operation": "scale", + "distribution": "uniform", + "stiffness_distribution_params": (0.8, 1.2), + "damping_distribution_params": (0.8, 1.2), + }, + ) + + physics_material = EventTerm( + func=mdp.randomize_rigid_body_material, + mode="startup", + params={ + "asset_cfg": SceneEntityCfg("robot", body_names=".*"), + "static_friction_range": (0.7, 1.2), + "dynamic_friction_range": (0.5, 1.0), + "restitution_range": (0.0, 0.0), + "num_buckets": 64, + "make_consistent": True, + }, + ) + + reset_base = EventTerm( + func=mdp.reset_root_state_uniform, + mode="reset", + params={ + "pose_range": { + "x": (-0.05, 0.05), + "y": (-0.05, 0.05), + "z": (0.01, 0.015), + "roll": (-0.1, 0.1), + "pitch": (-0.1, 0.1), + "yaw": (-3.14159, 3.14159), + }, + "velocity_range": { + "x": (-0.2, 0.2), + "y": (-0.2, 0.2), + "z": (-0.1, 0.1), + "roll": (-0.2, 0.2), + "pitch": (-0.2, 0.2), + "yaw": (-0.2, 0.2), + }, + }, + ) + + reset_robot_joints = EventTerm( + func=mdp.reset_joints_by_offset, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot"), + "position_range": (-0.1, 0.1), + "velocity_range": (-0.05, 0.05), + }, + ) + + +@configclass +class RewardsCfg: + alive = RewTerm(func=mdp.is_alive, weight=5.0) + flat_orientation = RewTerm(func=mdp.flat_orientation_l2, weight=-1.0) + height = RewTerm(func=mdp.base_height_l2, weight=-1.0, params={"target_height": 0.265}) + lin_vel = RewTerm(func=mdp.base_lin_vel_l2, weight=-1.0) + ang_vel = RewTerm(func=mdp.base_ang_vel_l2, weight=-0.5) + joint_torque = RewTerm( + func=mdp.joint_pd_command_l2, + weight=-1.0e-5, + params={"asset_cfg": _ACTUATED_JOINT_CFG, "stiffness": 5.0, "damping": 0.2}, + ) + action_rate = RewTerm(func=mdp.action_rate_l2, weight=-0.1) + action_rate2 = RewTerm(func=mdp.ActionRate2L2, weight=-0.01) + joint_pos_deviation = RewTerm( + func=mdp.joint_deviation_l1, + weight=-2.0, + params={"asset_cfg": _ACTUATED_JOINT_CFG}, + ) + + +@configclass +class TerminationsCfg: + time_out = DoneTerm(func=mdp.time_out, time_out=True) + root_height = DoneTerm(func=mdp.root_height_below_minimum, params={"minimum_height": 0.12}) + bad_orientation = DoneTerm(func=mdp.bad_orientation, params={"limit_angle": 1.0}) + + +## +# Environment configuration +## + + +@configclass +class DrLegsHoldPoseEnvCfg(ManagerBasedRLEnvCfg): + """DR Legs hold-pose environment (Newton/Kamino backend).""" + + scene: HoldPoseSceneCfg = HoldPoseSceneCfg(num_envs=_NUM_ENVS, env_spacing=2.0) + observations: ObservationsCfg = ObservationsCfg() + actions: ActionsCfg = ActionsCfg() + events: EventCfg = EventCfg() + rewards: RewardsCfg = RewardsCfg() + terminations: TerminationsCfg = TerminationsCfg() + sim: SimulationCfg = SimulationCfg( + dt=1 / 150, + render_interval=3, + physics=DrLegsPhysicsCfg(), + physics_material=_PHYSICS_MATERIAL, + ) + + def __post_init__(self) -> None: + self.decimation = 3 + self.episode_length_s = 10.0 + self.viewer.eye = (1.5, 0.5, 0.5) + self.viewer.lookat = (0.0, 0.0, 0.265) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/mdp/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/mdp/__init__.py new file mode 100644 index 000000000000..1876a0b22bf0 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/mdp/__init__.py @@ -0,0 +1,14 @@ +# 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 + +"""MDP terms for the DR Legs tasks. + +Re-exports the stock Isaac Lab manager-based MDP terms and adds the few +DR-Legs-specific terms that have no stock equivalent. +""" + +from isaaclab.utils.module import lazy_export + +lazy_export() diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/mdp/__init__.pyi b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/mdp/__init__.pyi new file mode 100644 index 000000000000..8d178a6b5ed2 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/mdp/__init__.pyi @@ -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 + +__all__ = [ + # observations + "gait_phase", + "joint_position_setpoints", + # rewards + "ActionRate2L2", + "base_ang_vel_l2", + "base_lin_vel_l2", + "contact_matching", + "feet_flat", + "feet_parallel", + "feet_touchdown_vel", + "foot_clearance", + "joint_pd_command_l2", + "joint_pos_tracking_exp", + "root_orientation_exp", +] + +from .observations import gait_phase, joint_position_setpoints +from .rewards import ( + ActionRate2L2, + base_ang_vel_l2, + base_lin_vel_l2, + contact_matching, + feet_flat, + feet_parallel, + feet_touchdown_vel, + foot_clearance, + joint_pd_command_l2, + joint_pos_tracking_exp, + root_orientation_exp, +) +from isaaclab.envs.mdp import * diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/mdp/observations.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/mdp/observations.py new file mode 100644 index 000000000000..2c25c5a012ed --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/mdp/observations.py @@ -0,0 +1,35 @@ +# 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 + +"""DR-Legs-specific observation terms.""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING + +import torch + +if TYPE_CHECKING: + from isaaclab.envs import ManagerBasedEnv, ManagerBasedRLEnv + +__all__ = ["gait_phase", "joint_position_setpoints"] + + +def joint_position_setpoints(env: ManagerBasedEnv, action_term_name: str = "joint_pos") -> torch.Tensor: + """Joint position setpoints (the action term's ``processed_actions``, in radians).""" + action_term = env.action_manager.get_term(action_term_name) + return action_term.processed_actions.clone() + + +def gait_phase(env: ManagerBasedRLEnv, period: float = 1.0) -> torch.Tensor: + """Cyclic gait clock encoded as ``(sin(2*pi*phi), cos(2*pi*phi))``. + + ``phi`` is the episode time wrapped to ``period`` and normalized to ``[0, 1)``. + """ + episode_time_s = env.episode_length_buf.float() * env.step_dt + phase = (episode_time_s % period) / period + two_pi = 2.0 * math.pi + return torch.stack([torch.sin(two_pi * phase), torch.cos(two_pi * phase)], dim=-1) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/mdp/rewards.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/mdp/rewards.py new file mode 100644 index 000000000000..9c0818af2f21 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/mdp/rewards.py @@ -0,0 +1,224 @@ +# 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 + +"""DR-Legs-specific reward terms with no stock Isaac Lab equivalent. + +Includes the posture/effort terms used by the hold-pose task and the gait/contact/feet +terms used by the walk task. The exponential kernels follow the convention +``exp(-error / (2 * sigma**2))`` and the quaternion error is the squared norm of the +imaginary part of the relative quaternion (scalar-last ``(x, y, z, w)``, matching +:mod:`isaaclab.utils.math`). +""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING + +import torch + +import isaaclab.utils.math as math_utils +from isaaclab.managers import ManagerTermBase, RewardTermCfg, SceneEntityCfg + +if TYPE_CHECKING: + from isaaclab.envs import ManagerBasedRLEnv + +__all__ = [ + "base_lin_vel_l2", + "base_ang_vel_l2", + "joint_pd_command_l2", + "joint_pos_tracking_exp", + "ActionRate2L2", + "contact_matching", + "foot_clearance", + "feet_parallel", + "feet_flat", + "feet_touchdown_vel", + "root_orientation_exp", +] + + +## +# Kernel and gait helpers (private). +## + + +def _exp_se(error: torch.Tensor, sigma: float) -> torch.Tensor: + return torch.exp(-error / (2.0 * sigma * sigma)) + + +def _quat_inv_mul(q1: torch.Tensor, q2: torch.Tensor) -> torch.Tensor: + return math_utils.quat_mul(math_utils.quat_conjugate(q1), q2) + + +def _exp_quat_err(q1: torch.Tensor, q2: torch.Tensor, sigma: float) -> torch.Tensor: + err_imag = _quat_inv_mul(q1, q2)[:, :3] + return _exp_se(torch.sum(torch.square(err_imag), dim=1), sigma) + + +def _gait_phase(env: ManagerBasedRLEnv, gait_period: float) -> torch.Tensor: + episode_time = env.episode_length_buf.float() * env.step_dt + return (episode_time % gait_period) / gait_period + + +def _reference_contacts(phase: torch.Tensor) -> torch.Tensor: + left = (phase < 0.5).float() + right = (phase >= 0.5).float() + return torch.stack([left, right], dim=1) + + +## +# Posture / effort terms. +## + + +def joint_pos_tracking_exp( + env: ManagerBasedRLEnv, + asset_cfg: SceneEntityCfg = SceneEntityCfg("robot"), + std: float = 0.5, +) -> torch.Tensor: + asset = env.scene[asset_cfg.name] + joint_ids = asset_cfg.joint_ids + error = asset.data.joint_pos[:, joint_ids] - asset.data.default_joint_pos[:, joint_ids] + return torch.exp(-torch.sum(torch.square(error), dim=1) / std**2) + + +def base_lin_vel_l2(env: ManagerBasedRLEnv, asset_cfg: SceneEntityCfg = SceneEntityCfg("robot")) -> torch.Tensor: + asset = env.scene[asset_cfg.name] + return torch.sum(torch.square(asset.data.root_lin_vel_b[:, :3]), dim=1) + + +def base_ang_vel_l2(env: ManagerBasedRLEnv, asset_cfg: SceneEntityCfg = SceneEntityCfg("robot")) -> torch.Tensor: + asset = env.scene[asset_cfg.name] + return torch.sum(torch.square(asset.data.root_ang_vel_b[:, :3]), dim=1) + + +def joint_pd_command_l2( + env: ManagerBasedRLEnv, + asset_cfg: SceneEntityCfg = SceneEntityCfg("robot"), + stiffness: float = 5.0, + damping: float = 0.2, +) -> torch.Tensor: + asset = env.scene[asset_cfg.name] + joint_ids = asset_cfg.joint_ids + joint_pos = asset.data.joint_pos[:, joint_ids] + joint_vel = asset.data.joint_vel[:, joint_ids] + joint_pos_target = asset.data.joint_pos_target[:, joint_ids] + pd_command = stiffness * (joint_pos_target - joint_pos) - damping * joint_vel + return torch.sum(torch.square(pd_command), dim=1) + + +class ActionRate2L2(ManagerTermBase): + def __init__(self, cfg: RewardTermCfg, env: ManagerBasedRLEnv): + super().__init__(cfg, env) + dim = env.action_manager.total_action_dim + self._prev_action = torch.zeros((env.num_envs, dim), device=env.device) + self._prev_prev_action = torch.zeros((env.num_envs, dim), device=env.device) + + def reset(self, env_ids: torch.Tensor | None = None): + if env_ids is None: + env_ids = slice(None) + self._prev_action[env_ids] = 0.0 + self._prev_prev_action[env_ids] = 0.0 + + def __call__(self, env: ManagerBasedRLEnv) -> torch.Tensor: + current_action = env.action_manager.action + jerk = current_action - 2.0 * self._prev_action + self._prev_prev_action + reward = torch.sum(torch.square(jerk), dim=1) + self._prev_prev_action[:] = self._prev_action + self._prev_action[:] = current_action + return reward + + +## +# Gait / feet / orientation terms (walk task). +## + + +def contact_matching( + env: ManagerBasedRLEnv, + sensor_cfg: SceneEntityCfg, + threshold: float = 1.0, + gait_period: float = 1.0, +) -> torch.Tensor: + contact_sensor = env.scene.sensors[sensor_cfg.name] + net_forces = contact_sensor.data.net_forces_w.torch[:, sensor_cfg.body_ids] + observed = torch.linalg.norm(net_forces, dim=-1) > threshold + reference = _reference_contacts(_gait_phase(env, gait_period)).bool() + return torch.sum(observed == reference, dim=1).float() - 1.0 + + +def foot_clearance( + env: ManagerBasedRLEnv, + asset_cfg: SceneEntityCfg, + target_height: float = 0.065, + sigma: float = 0.01, + gait_period: float = 1.0, +) -> torch.Tensor: + asset = env.scene[asset_cfg.name] + foot_ids = asset_cfg.body_ids + foot_z = asset.data.body_link_pos_w.torch[:, foot_ids, 2] + phase = _gait_phase(env, gait_period) + two_pi = 2.0 * math.pi + left_target = target_height * torch.clamp(torch.sin(two_pi * (phase - 0.5)), min=0.0) + right_target = target_height * torch.clamp(torch.sin(two_pi * phase), min=0.0) + left_swing = (left_target > 0.0).float() + right_swing = (right_target > 0.0).float() + left_reward = left_swing * _exp_se(torch.square(left_target - foot_z[:, 0]), sigma) + right_reward = right_swing * _exp_se(torch.square(right_target - foot_z[:, 1]), sigma) + return left_reward + right_reward + + +def feet_parallel( + env: ManagerBasedRLEnv, + asset_cfg: SceneEntityCfg, + sigma: float = 0.2, +) -> torch.Tensor: + asset = env.scene[asset_cfg.name] + foot_quat = asset.data.body_link_quat_w.torch[:, asset_cfg.body_ids] + left_yaw = math_utils.yaw_quat(foot_quat[:, 0]) + right_yaw = math_utils.yaw_quat(foot_quat[:, 1]) + return _exp_quat_err(left_yaw, right_yaw, sigma) + + +def feet_flat( + env: ManagerBasedRLEnv, + asset_cfg: SceneEntityCfg, + sigma: float = 0.2, +) -> torch.Tensor: + """Reward keeping each foot level (small roll/pitch); foot yaw is ignored. Mean over feet.""" + asset = env.scene[asset_cfg.name] + foot_quat = asset.data.body_link_quat_w.torch[:, asset_cfg.body_ids] + num_envs, num_feet = foot_quat.shape[0], foot_quat.shape[1] + quat = foot_quat.reshape(-1, 4) + tilt = _quat_inv_mul(math_utils.yaw_quat(quat), quat) + tilt_sq = torch.sum(torch.square(tilt[:, :3]), dim=1).reshape(num_envs, num_feet) + return _exp_se(tilt_sq, sigma).mean(dim=1) + + +def feet_touchdown_vel( + env: ManagerBasedRLEnv, + asset_cfg: SceneEntityCfg, + sensor_cfg: SceneEntityCfg, + threshold: float = 1.0, +) -> torch.Tensor: + asset = env.scene[asset_cfg.name] + contact_sensor = env.scene.sensors[sensor_cfg.name] + foot_z_vel = asset.data.body_com_lin_vel_w.torch[:, asset_cfg.body_ids, 2] + net_forces = contact_sensor.data.net_forces_w.torch[:, sensor_cfg.body_ids] + contact = (torch.linalg.norm(net_forces, dim=-1) > threshold).float() + downward_vel = torch.clamp(-foot_z_vel, min=0.0) + return torch.sum(contact * downward_vel, dim=-1) + + +def root_orientation_exp( + env: ManagerBasedRLEnv, + asset_cfg: SceneEntityCfg = SceneEntityCfg("robot"), + sigma: float = 0.2, +) -> torch.Tensor: + asset = env.scene[asset_cfg.name] + root_quat = asset.data.root_link_quat_w.torch + tilt = _quat_inv_mul(math_utils.yaw_quat(root_quat), root_quat) + return _exp_se(torch.sum(torch.square(tilt[:, :3]), dim=1), sigma) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/walk_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/walk_env_cfg.py new file mode 100644 index 000000000000..16382957aca6 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/dr_legs/walk_env_cfg.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 + +"""Velocity-tracking walk environment for the Disney DR Legs closed-loop biped (Kamino). + +Extends the hold-pose task with a base-velocity command, a gait-phase clock, a foot +contact sensor, and gait / contact / foot-clearance rewards. +""" + +import math + +from isaaclab_newton.sensors import ContactSensorCfg as NewtonContactSensorCfg + +from isaaclab.managers import ObservationTermCfg as ObsTerm +from isaaclab.managers import RewardTermCfg as RewTerm +from isaaclab.managers import SceneEntityCfg +from isaaclab.utils.configclass import configclass + +import isaaclab_tasks.contrib.dr_legs.mdp as mdp + +from isaaclab_assets.robots.dr_legs import DR_LEGS_ACTUATED_JOINTS + +from .hold_pose_env_cfg import DrLegsHoldPoseEnvCfg, HoldPoseSceneCfg, ObservationsCfg + +## +# Shared constants +## + +# Full left+right gait cycle [s]. Shared by the gait-phase observation and the rewards. +_GAIT_PERIOD = 1.0 + +# The 12 servo-driven joints (used by the implicit-PD effort proxy). +_ACTUATED_JOINT_CFG = SceneEntityCfg("robot", joint_names=DR_LEGS_ACTUATED_JOINTS, preserve_order=True) + +# Foot bodies in canonical [left, right] order for the kinematics and contact rewards. +_FOOT_ASSET_CFG = SceneEntityCfg("robot", body_names=["foot_l", "foot_r"], preserve_order=True) +_FOOT_SENSOR_CFG = SceneEntityCfg("contact_forces", body_names=["foot_l", "foot_r"], preserve_order=True) + + +@configclass +class WalkSceneCfg(HoldPoseSceneCfg): + contact_forces = NewtonContactSensorCfg( + prim_path="{ENV_REGEX_NS}/Robot/foot_.*", + history_length=3, + track_air_time=True, + ) + + +@configclass +class CommandsCfg: + base_velocity = mdp.UniformVelocityCommandCfg( + asset_name="robot", + resampling_time_range=(10.0, 10.0), + rel_standing_envs=0.1, + rel_heading_envs=1.0, + heading_command=True, + heading_control_stiffness=0.5, + debug_vis=False, + ranges=mdp.UniformVelocityCommandCfg.Ranges( + lin_vel_x=(-0.3, 0.3), + lin_vel_y=(-0.3, 0.3), + ang_vel_z=(-0.8, 0.8), + heading=(-math.pi, math.pi), + ), + ) + + +@configclass +class WalkObservationsCfg(ObservationsCfg): + @configclass + class WalkPolicyCfg(ObservationsCfg.PolicyCfg): + velocity_commands = ObsTerm(func=mdp.generated_commands, params={"command_name": "base_velocity"}) + gait_phase = ObsTerm(func=mdp.gait_phase, params={"period": _GAIT_PERIOD}) + + policy: WalkPolicyCfg = WalkPolicyCfg() + + +@configclass +class WalkRewardsCfg: + """Gait / contact / feet reward suite for velocity-tracking walking.""" + + # -- positive (task / gait) + survival = RewTerm(func=mdp.is_alive, weight=10.0) + contact_matching = RewTerm( + func=mdp.contact_matching, + weight=10.0, + params={"sensor_cfg": _FOOT_SENSOR_CFG, "threshold": 1.0, "gait_period": _GAIT_PERIOD}, + ) + track_lin_vel_xy = RewTerm( + func=mdp.track_lin_vel_xy_exp, + weight=5.0, + params={"command_name": "base_velocity", "std": math.sqrt(0.125)}, + ) + root_orientation = RewTerm( + func=mdp.root_orientation_exp, + weight=5.0, + params={"asset_cfg": SceneEntityCfg("robot"), "sigma": 0.2}, + ) + foot_clearance = RewTerm( + func=mdp.foot_clearance, + weight=5.0, + params={ + "asset_cfg": _FOOT_ASSET_CFG, + "target_height": 0.065, + "sigma": 0.01, + "gait_period": _GAIT_PERIOD, + }, + ) + track_ang_vel_z = RewTerm( + func=mdp.track_ang_vel_z_exp, + weight=2.0, + params={"command_name": "base_velocity", "std": math.sqrt(0.5)}, + ) + feet_parallel = RewTerm( + func=mdp.feet_parallel, + weight=2.0, + params={"asset_cfg": _FOOT_ASSET_CFG, "sigma": 0.2}, + ) + feet_flat = RewTerm( + func=mdp.feet_flat, + weight=2.0, + params={"asset_cfg": _FOOT_ASSET_CFG, "sigma": 0.2}, + ) + + # -- negative (penalties) + feet_touchdown_vel = RewTerm( + func=mdp.feet_touchdown_vel, + weight=-2.0, + params={"asset_cfg": _FOOT_ASSET_CFG, "sensor_cfg": _FOOT_SENSOR_CFG, "threshold": 1.0}, + ) + lin_vel_z = RewTerm(func=mdp.lin_vel_z_l2, weight=-0.5) + ang_vel_xy = RewTerm(func=mdp.ang_vel_xy_l2, weight=-0.5) + torques = RewTerm( + func=mdp.joint_pd_command_l2, + weight=-0.001, + params={"asset_cfg": _ACTUATED_JOINT_CFG, "stiffness": 5.0, "damping": 0.2}, + ) + action_rate = RewTerm(func=mdp.action_rate_l2, weight=-0.001) + action_2nd_derivative = RewTerm(func=mdp.ActionRate2L2, weight=-5.0e-5) + + +@configclass +class DrLegsWalkEnvCfg(DrLegsHoldPoseEnvCfg): + """DR Legs velocity-tracking walk environment (Newton/Kamino backend).""" + + scene: WalkSceneCfg = WalkSceneCfg(num_envs=4096, env_spacing=2.0) + observations: WalkObservationsCfg = WalkObservationsCfg() + commands: CommandsCfg = CommandsCfg() + rewards: WalkRewardsCfg = WalkRewardsCfg() diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/a1/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/a1/flat_env_cfg.py index c29ea2500da4..c794ef52fbeb 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/a1/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/a1/flat_env_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg +from isaaclab_newton.physics import KaminoSolverCfg, MJWarpSolverCfg, NewtonCfg from isaaclab_physx.physics import PhysxCfg from isaaclab.sim import SimulationCfg @@ -29,6 +29,7 @@ class PhysicsCfg(PresetCfg): debug_mode=False, ) physx = default + newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64)) @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_b/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_b/flat_env_cfg.py index 88799d6df62e..49eb5234cac1 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_b/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_b/flat_env_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg +from isaaclab_newton.physics import KaminoSolverCfg, MJWarpSolverCfg, NewtonCfg from isaaclab_physx.physics import PhysxCfg from isaaclab.sim import SimulationCfg @@ -29,6 +29,7 @@ class PhysicsCfg(PresetCfg): debug_mode=False, ) physx = default + newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64)) @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_c/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_c/flat_env_cfg.py index 65795ad8230a..dfe215c777ab 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_c/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_c/flat_env_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg +from isaaclab_newton.physics import KaminoSolverCfg, MJWarpSolverCfg, NewtonCfg from isaaclab_physx.physics import PhysxCfg from isaaclab.sim import SimulationCfg @@ -29,6 +29,7 @@ class PhysicsCfg(PresetCfg): debug_mode=False, ) physx = default + newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64)) @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/go1/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/go1/flat_env_cfg.py index ff8a24d0217f..736b83784a94 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/go1/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/go1/flat_env_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg +from isaaclab_newton.physics import KaminoSolverCfg, MJWarpSolverCfg, NewtonCfg from isaaclab_physx.physics import PhysxCfg from isaaclab.sim import SimulationCfg @@ -28,6 +28,7 @@ class PhysicsCfg(PresetCfg): num_substeps=1, debug_mode=False, ) + newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64)) @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/cabinet/cabinet_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/cabinet/cabinet_env_cfg.py index a1502f7b2de8..870825b2db37 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/cabinet/cabinet_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/cabinet/cabinet_env_cfg.py @@ -6,7 +6,7 @@ from dataclasses import MISSING -from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg +from isaaclab_newton.physics import KaminoSolverCfg, MJWarpSolverCfg, NewtonCfg from isaaclab_physx.physics import PhysxCfg import isaaclab.sim as sim_utils @@ -73,6 +73,11 @@ class CabinetSimCfg(PresetCfg): debug_mode=False, ), ) + newton_kamino: SimulationCfg = SimulationCfg( + dt=1 / 600, + render_interval=1, + physics=NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64)), + ) ## diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_env_cfg.py index 7637e42ab7d1..b4b418ab4e76 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_env_cfg.py @@ -57,7 +57,6 @@ class CartpolePhysicsCfg(PresetCfg): collision_detector_pipeline="unified", collision_detector_max_contacts_per_pair=8, ), - num_substeps=1, debug_mode=False, use_cuda_graph=True, ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_manager_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_manager_env_cfg.py index 73f59e6885b0..2454f4890fac 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_manager_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_manager_env_cfg.py @@ -67,7 +67,6 @@ class CartpolePhysicsCfg(PresetCfg): collision_detector_pipeline="unified", collision_detector_max_contacts_per_pair=8, ), - num_substeps=1, debug_mode=False, use_cuda_graph=True, ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/__init__.py new file mode 100644 index 000000000000..b30913f7e058 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/__init__.py @@ -0,0 +1,24 @@ +# 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 + +"""Fourbar-pole swing-up environment.""" + +import gymnasium as gym + +from . import agents + +## +# Register Gym environments -- manager-based workflow. +## + +gym.register( + id="Isaac-Fourbar-Pole-Swingup", + entry_point="isaaclab.envs:ManagerBasedRLEnv", + disable_env_checker=True, + kwargs={ + "env_cfg_entry_point": f"{__name__}.fourbar_pole_manager_env_cfg:FourbarPoleSwingupEnvCfg", + "rsl_rl_cfg_entry_point": f"{agents.__name__}.rsl_rl_manager_ppo_cfg:FourbarPolePPORunnerCfg", + }, +) diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/agents/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/agents/__init__.py new file mode 100644 index 000000000000..460a30569089 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/agents/__init__.py @@ -0,0 +1,4 @@ +# 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 diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/agents/rsl_rl_manager_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/agents/rsl_rl_manager_ppo_cfg.py new file mode 100644 index 000000000000..c37510ea4b0c --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/agents/rsl_rl_manager_ppo_cfg.py @@ -0,0 +1,46 @@ +# 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 isaaclab.utils.configclass import configclass + +from isaaclab_rl.rsl_rl import ( + RslRlMLPModelCfg, + RslRlOnPolicyRunnerCfg, + RslRlPpoAlgorithmCfg, +) + + +@configclass +class FourbarPolePPORunnerCfg(RslRlOnPolicyRunnerCfg): + num_steps_per_env = 16 + max_iterations = 300 + save_interval = 50 + experiment_name = "fourbar_pole" + obs_groups = {"actor": ["policy"], "critic": ["policy"]} + actor = RslRlMLPModelCfg( + hidden_dims=[64, 64], + activation="elu", + obs_normalization=True, + distribution_cfg=RslRlMLPModelCfg.GaussianDistributionCfg(init_std=1.0), + ) + critic = RslRlMLPModelCfg( + hidden_dims=[64, 64], + activation="elu", + obs_normalization=False, + ) + algorithm = RslRlPpoAlgorithmCfg( + value_loss_coef=1.0, + use_clipped_value_loss=True, + clip_param=0.2, + entropy_coef=0.001, + num_learning_epochs=5, + num_mini_batches=4, + learning_rate=1.0e-3, + schedule="adaptive", + gamma=0.99, + lam=0.95, + desired_kl=0.01, + max_grad_norm=1.0, + ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/fourbar_pole_manager_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/fourbar_pole_manager_env_cfg.py new file mode 100644 index 000000000000..4708f24746f8 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/fourbar_pole_manager_env_cfg.py @@ -0,0 +1,256 @@ +# 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 copy +import math +from dataclasses import MISSING + +from isaaclab_newton.physics import KaminoSolverCfg, NewtonCfg + +import isaaclab.sim as sim_utils +from isaaclab.assets import ArticulationCfg, AssetBaseCfg +from isaaclab.envs import ManagerBasedRLEnvCfg +from isaaclab.managers import EventTermCfg as EventTerm +from isaaclab.managers import ObservationGroupCfg as ObsGroup +from isaaclab.managers import ObservationTermCfg as ObsTerm +from isaaclab.managers import RewardTermCfg as RewTerm +from isaaclab.managers import SceneEntityCfg +from isaaclab.managers import TerminationTermCfg as DoneTerm +from isaaclab.scene import InteractiveSceneCfg +from isaaclab.utils.configclass import configclass +from isaaclab.visualizers import VisualizerCfg + +import isaaclab_tasks.core.fourbar_pole.mdp as mdp +from isaaclab_tasks.core.fourbar_pole.mdp.rewards import UprightSuccessRateCommandCfg +from isaaclab_tasks.utils import PresetCfg + +## +# Pre-defined configs +## +from isaaclab_assets.robots.fourbar_pole import FOURBAR_POLE_CFG # isort:skip + + +## +# Physics backend presets +## + + +@configclass +class FourbarPolePhysicsCfg(PresetCfg): + """Physics backends for the fourbar-pole task. + + Only the kamino (Newton constraint) solver is wired up for now; the closed + four-bar kinematic loop is resolved as an equality constraint by kamino. The + ``default`` field mirrors ``newton_kamino`` so the task uses it without any + ``physics=`` selector. Additional backends will be added later. + """ + + default: NewtonCfg = MISSING + newton_kamino: NewtonCfg = NewtonCfg( + solver_cfg=KaminoSolverCfg( + integrator="euler", + use_fk_solver=True, + sparse_jacobian=True, + constraints_alpha=0.1, + padmm_max_iterations=100, + padmm_rho_0=0.1, + padmm_warmstart_mode="containers", + ), + num_substeps=1, + debug_mode=False, + use_cuda_graph=True, + ) + + def __post_init__(self): + if not isinstance(self.default, NewtonCfg): + self.default = copy.deepcopy(self.newton_kamino) + + +## +# Scene definition +## + + +@configclass +class FourbarPoleSceneCfg(InteractiveSceneCfg): + """Configuration for a fourbar-pole scene.""" + + # ground plane + ground = AssetBaseCfg( + prim_path="/World/ground", + spawn=sim_utils.GroundPlaneCfg(size=(100.0, 100.0)), + ) + + # fourbar-pole + robot: ArticulationCfg = FOURBAR_POLE_CFG.replace(prim_path="{ENV_REGEX_NS}/Robot") + + # lights + dome_light = AssetBaseCfg( + prim_path="/World/DomeLight", + spawn=sim_utils.DomeLightCfg(color=(0.9, 0.9, 0.9), intensity=500.0), + ) + + +## +# MDP settings +## + + +@configclass +class CommandsCfg: + """Command specifications for the MDP.""" + + # Command term to track pole upright success rate + upright = UprightSuccessRateCommandCfg( + asset_cfg=SceneEntityCfg("robot", joint_names=["coupler_to_pole"]), + threshold=0.95, + ) + + +@configclass +class ActionsCfg: + """Action specifications for the MDP.""" + + joint_effort = mdp.JointEffortActionCfg(asset_name="robot", joint_names=["ground_to_crank"], scale=50.0) + + +@configclass +class ObservationsCfg: + """Observation specifications for the MDP.""" + + @configclass + class PolicyCfg(ObsGroup): + """Observations for policy group.""" + + # pole encoded as (cos, sin, vel) to avoid the +-pi wrap discontinuity during swing-up + pole_cos = ObsTerm( + func=mdp.joint_pos_cos, + params={"asset_cfg": SceneEntityCfg("robot", joint_names=["coupler_to_pole"])}, + ) + pole_sin = ObsTerm( + func=mdp.joint_pos_sin, + params={"asset_cfg": SceneEntityCfg("robot", joint_names=["coupler_to_pole"])}, + ) + pole_vel = ObsTerm( + func=mdp.joint_vel_rel, + params={"asset_cfg": SceneEntityCfg("robot", joint_names=["coupler_to_pole"])}, + ) + + # crank encoded with (pos, vel). No wrapping possible due to joint limits. + crank_pos = ObsTerm( + func=mdp.joint_pos_rel, + params={"asset_cfg": SceneEntityCfg("robot", joint_names=["ground_to_crank"])}, + ) + crank_vel = ObsTerm( + func=mdp.joint_vel_rel, + params={"asset_cfg": SceneEntityCfg("robot", joint_names=["ground_to_crank"])}, + ) + + def __post_init__(self) -> None: + self.enable_corruption = False + self.concatenate_terms = True + + # observation groups + policy: PolicyCfg = PolicyCfg() + + +@configclass +class EventCfg: + """Configuration for events.""" + + # reset crank around its default (0) so the cart starts at varied positions + reset_crank_position = EventTerm( + func=mdp.reset_joints_by_offset, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot", joint_names=["ground_to_crank"]), + "position_range": (-0.25 * math.pi, 0.25 * math.pi), + "velocity_range": (-0.5, 0.5), + }, + ) + + # reset pole around its default (0) with a small perturbation + reset_pole_position = EventTerm( + func=mdp.reset_joints_by_offset, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot", joint_names=["coupler_to_pole"]), + "position_range": (-math.pi, math.pi), + "velocity_range": (-0.5, 0.5), + }, + ) + + +@configclass +class RewardsCfg: + """Reward terms for the MDP.""" + + # (1) Primary task: swing the pole up and keep it upright (cos is maximal upright) + pole_upright = RewTerm( + func=mdp.pole_upright, + weight=1.0, + params={"asset_cfg": SceneEntityCfg("robot", joint_names=["coupler_to_pole"])}, + ) + # (2) Shaping: damp the pole angular velocity to settle at the top + pole_vel = RewTerm( + func=mdp.joint_vel_l1, + weight=-0.01, + params={"asset_cfg": SceneEntityCfg("robot", joint_names=["coupler_to_pole"])}, + ) + # (3) Shaping: discourage excessive crank motion + crank_vel = RewTerm( + func=mdp.joint_vel_l1, + weight=-0.05, + params={"asset_cfg": SceneEntityCfg("robot", joint_names=["ground_to_crank"])}, + ) + # (4) Shaping: penalize control effort + effort = RewTerm( + func=mdp.action_l2, + weight=-0.01, + ) + + +@configclass +class TerminationsCfg: + """Termination terms for the MDP.""" + + # Time out only -- the pole must be free to hang and the parallelogram is geometrically bounded. + time_out = DoneTerm(func=mdp.time_out, time_out=True) + + +## +# Environment configuration +## + + +@configclass +class FourbarPoleSwingupEnvCfg(ManagerBasedRLEnvCfg): + """Configuration for the fourbar-pole swing-up environment.""" + + # Scene settings + scene: FourbarPoleSceneCfg = FourbarPoleSceneCfg(num_envs=4096, env_spacing=4.0, clone_in_fabric=True) + # Basic settings + observations: ObservationsCfg = ObservationsCfg() + actions: ActionsCfg = ActionsCfg() + commands: CommandsCfg = CommandsCfg() + events: EventCfg = EventCfg() + # MDP settings + rewards: RewardsCfg = RewardsCfg() + terminations: TerminationsCfg = TerminationsCfg() + + # Post initialization + def __post_init__(self) -> None: + """Post initialization.""" + # general settings + self.decimation = 2 + self.episode_length_s = 5 + # viewer settings + self.viewer.eye = (12.0, 0.0, 4.0) + # Match Newton GL / --video camera to the task viewport when --viz newton creates the visualizer. + self.sim.default_visualizer_cfg = VisualizerCfg(eye=self.viewer.eye, lookat=self.viewer.lookat) + # simulation settings + self.sim.dt = 1 / 120 + self.sim.render_interval = self.decimation + self.sim.physics = FourbarPolePhysicsCfg() diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/mdp/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/mdp/__init__.py new file mode 100644 index 000000000000..20219001d70e --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/mdp/__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 + +"""This sub-module contains the functions that are specific to the fourbar-pole environments.""" + +from isaaclab.utils.module import lazy_export + +lazy_export() diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/mdp/__init__.pyi b/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/mdp/__init__.pyi new file mode 100644 index 000000000000..1b852382866f --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/mdp/__init__.pyi @@ -0,0 +1,21 @@ +# 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__ = [ + "UprightSuccessRateCommand", + "UprightSuccessRateCommandCfg", + "joint_pos_cos", + "joint_pos_sin", + "pole_upright", +] + +from .rewards import ( + UprightSuccessRateCommand, + UprightSuccessRateCommandCfg, + joint_pos_cos, + joint_pos_sin, + pole_upright, +) +from isaaclab.envs.mdp import * diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/mdp/rewards.py b/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/mdp/rewards.py new file mode 100644 index 000000000000..f03243c6ae45 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/core/fourbar_pole/mdp/rewards.py @@ -0,0 +1,91 @@ +# 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 + +"""Functions specific to the fourbar-pole swing-up environments. + +This module holds both observation helpers (``joint_pos_cos`` / ``joint_pos_sin``) +that encode an angle without the ``+-pi`` wrap discontinuity, and the swing-up +reward / success-metric terms. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import TYPE_CHECKING + +import torch + +from isaaclab.managers import CommandTerm, CommandTermCfg, SceneEntityCfg +from isaaclab.utils.configclass import configclass + +if TYPE_CHECKING: + from isaaclab.assets import Articulation + from isaaclab.envs import ManagerBasedRLEnv + + +def joint_pos_cos(env: ManagerBasedRLEnv, asset_cfg: SceneEntityCfg) -> torch.Tensor: + """Cosine of the selected joint positions. + + Encodes the angle without the wrap-around discontinuity at ``+-pi`` so the + policy sees a smooth signal as the pole swings through the bottom. + """ + asset: Articulation = env.scene[asset_cfg.name] + return torch.cos(asset.data.joint_pos.torch[:, asset_cfg.joint_ids]) + + +def joint_pos_sin(env: ManagerBasedRLEnv, asset_cfg: SceneEntityCfg) -> torch.Tensor: + """Sine of the selected joint positions (companion to :func:`joint_pos_cos`).""" + asset: Articulation = env.scene[asset_cfg.name] + return torch.sin(asset.data.joint_pos.torch[:, asset_cfg.joint_ids]) + + +def pole_upright(env: ManagerBasedRLEnv, asset_cfg: SceneEntityCfg) -> torch.Tensor: + """Uprightness reward for the pole, maximal (``+1``) when fully upright. + + The pole joint is zero when upright and ``+-pi`` when hanging, so ``cos`` gives + a dense shaping signal in ``[-1, 1]`` that drives the swing-up. + """ + asset: Articulation = env.scene[asset_cfg.name] + return torch.sum(torch.cos(asset.data.joint_pos.torch[:, asset_cfg.joint_ids]), dim=1) + + +class UprightSuccessRateCommand(CommandTerm): + """Command term that tracks pole-upright terminal success as a metric.""" + + cfg: UprightSuccessRateCommandCfg + + def __init__(self, cfg: UprightSuccessRateCommandCfg, env: ManagerBasedRLEnv): + super().__init__(cfg, env) + self._asset_cfg = cfg.asset_cfg + self._asset_cfg.resolve(env.scene) + self._asset: Articulation = env.scene[self._asset_cfg.name] + + self._command = torch.zeros((self.num_envs, 1), device=self.device) + self.metrics["success_rate"] = torch.zeros(self.num_envs, device=self.device) + + @property + def command(self) -> torch.Tensor: + return self._command + + def _update_metrics(self): + pole_pos = self._asset.data.joint_pos.torch[:, self._asset_cfg.joint_ids] + self.metrics["success_rate"] = (torch.cos(pole_pos) > self.cfg.threshold).all(dim=1).float() + + def _resample_command(self, env_ids: Sequence[int]): + pass + + def _update_command(self): + pass + + +@configclass +class UprightSuccessRateCommandCfg(CommandTermCfg): + """Configuration for :class:`UprightSuccessRateCommand`.""" + + class_type: type[UprightSuccessRateCommand] | str = "{DIR}.rewards:UprightSuccessRateCommand" + resampling_time_range: tuple[float, float] = (1e6, 1e6) + + asset_cfg: SceneEntityCfg = SceneEntityCfg("robot", joint_names=["coupler_to_pole"]) + threshold: float = 0.95 diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/locomotion/ant/ant_direct_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/locomotion/ant/ant_direct_env_cfg.py index 510526a59981..3e47bbb896ba 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/locomotion/ant/ant_direct_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/locomotion/ant/ant_direct_env_cfg.py @@ -56,7 +56,6 @@ class AntPhysicsCfg(PresetCfg): collision_detector_pipeline="unified", collision_detector_max_contacts_per_pair=8, ), - num_substeps=2, debug_mode=False, use_cuda_graph=True, ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/locomotion/ant/ant_manager_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/locomotion/ant/ant_manager_env_cfg.py index e89988dd7bc3..84d09abeb542 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/locomotion/ant/ant_manager_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/locomotion/ant/ant_manager_env_cfg.py @@ -63,7 +63,6 @@ class AntPhysicsCfg(PresetCfg): collision_detector_pipeline="unified", collision_detector_max_contacts_per_pair=8, ), - num_substeps=2, debug_mode=False, use_cuda_graph=True, ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/reach/reach_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/reach/reach_env_cfg.py index cd226b543def..26b40be6cc85 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/reach/reach_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/reach/reach_env_cfg.py @@ -5,7 +5,7 @@ from dataclasses import MISSING -from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg +from isaaclab_newton.physics import KaminoSolverCfg, MJWarpSolverCfg, NewtonCfg from isaaclab_physx.physics import PhysxCfg import isaaclab.envs.mdp as mdp @@ -43,6 +43,9 @@ class ReachPhysicsCfg(PresetCfg): num_substeps=1, debug_mode=False, ) + newton_kamino: NewtonCfg = NewtonCfg( + solver_cfg=KaminoSolverCfg(max_contacts_per_world=32), + ) default = physx @@ -65,7 +68,7 @@ class TableCfg(PresetCfg): newton_mjwarp: ArticulationCfg = ArticulationCfg( prim_path="/World/envs/env_.*/Table", init_state=ArticulationCfg.InitialStateCfg( - pos=(0.5, 0.15, -0.5), rot=(0, 0, 0.707, 0.707), joint_pos={}, joint_vel={} + pos=(0.5, 0, -0.5), rot=(0, 0, 0.707, 0.707), joint_pos={}, joint_vel={} ), spawn=sim_utils.CuboidCfg( size=(0.9, 1.3, 1.00), @@ -76,6 +79,7 @@ class TableCfg(PresetCfg): articulation_root_prim_path="", ) + newton_kamino = newton_mjwarp default = physx diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/reorient/config/shadow_hand/shadow_hand_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/reorient/config/shadow_hand/shadow_hand_env_cfg.py index c4d785115555..f6bdae2bd0d8 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/reorient/config/shadow_hand/shadow_hand_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/reorient/config/shadow_hand/shadow_hand_env_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg +from isaaclab_newton.physics import KaminoSolverCfg, MJWarpSolverCfg, NewtonCfg from isaaclab_physx.physics import PhysxCfg import isaaclab.envs.mdp as mdp @@ -135,6 +135,7 @@ class ShadowHandEventCfg(PresetCfg): physx = PhysxEventCfg() newton_mjwarp = NewtonEventCfg() default = physx + newton_kamino = newton_mjwarp @configclass @@ -207,6 +208,7 @@ class ShadowHandRobotCfg(PresetCfg): soft_joint_pos_limit_factor=1.0, ) default = physx + newton_kamino = newton_mjwarp @configclass @@ -246,6 +248,7 @@ class ObjectCfg(PresetCfg): articulation_root_prim_path="", ) default = physx + newton_kamino = newton_mjwarp @configclass @@ -263,6 +266,7 @@ class ShadowHandSceneCfg(PresetCfg): num_envs=8192, env_spacing=0.75, replicate_physics=True, clone_in_fabric=False ) default: InteractiveSceneCfg = physx + newton_kamino = newton_mjwarp @configclass @@ -287,6 +291,7 @@ class PhysicsCfg(PresetCfg): debug_mode=False, ) default = physx + newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=128)) @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/cassie/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/cassie/flat_env_cfg.py index 02bae7147b9e..684594ca217a 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/cassie/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/cassie/flat_env_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg +from isaaclab_newton.physics import KaminoSolverCfg, MJWarpSolverCfg, NewtonCfg from isaaclab_physx.physics import PhysxCfg from isaaclab.sim import SimulationCfg @@ -29,6 +29,7 @@ class PhysicsCfg(PresetCfg): debug_mode=False, ) physx = default + newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64)) @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/g1/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/g1/flat_env_cfg.py index 02a3b4d1252f..ed735096f3b0 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/g1/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/g1/flat_env_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg +from isaaclab_newton.physics import KaminoSolverCfg, MJWarpSolverCfg, NewtonCfg from isaaclab_physx.physics import PhysxCfg from isaaclab.managers import SceneEntityCfg @@ -29,6 +29,7 @@ class PhysicsCfg(PresetCfg): num_substeps=1, debug_mode=False, ) + newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64)) @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/go2/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/go2/flat_env_cfg.py index bb6c63472336..11c6aa509d5a 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/go2/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/go2/flat_env_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg +from isaaclab_newton.physics import KaminoSolverCfg, MJWarpSolverCfg, NewtonCfg from isaaclab_physx.physics import PhysxCfg from isaaclab.sim import SimulationCfg @@ -29,6 +29,7 @@ class PhysicsCfg(PresetCfg): debug_mode=False, ) physx = default + newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64)) @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/h1/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/h1/flat_env_cfg.py index 2b30240f5fbe..ab636aac9c38 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/h1/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/h1/flat_env_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg +from isaaclab_newton.physics import KaminoSolverCfg, MJWarpSolverCfg, NewtonCfg from isaaclab_physx.physics import PhysxCfg from isaaclab.sim import SimulationCfg @@ -29,6 +29,7 @@ class PhysicsCfg(PresetCfg): debug_mode=False, ) physx = default + newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64)) @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/spot/flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/spot/flat_env_cfg.py index b89fa7ef126a..671aa659cd22 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/spot/flat_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/spot/flat_env_cfg.py @@ -3,7 +3,13 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg, NewtonCollisionPipelineCfg, NewtonShapeCfg +from isaaclab_newton.physics import ( + KaminoSolverCfg, + MJWarpSolverCfg, + NewtonCfg, + NewtonCollisionPipelineCfg, + NewtonShapeCfg, +) from isaaclab_physx.physics import PhysxCfg import isaaclab.sim as sim_utils @@ -43,6 +49,7 @@ class PhysicsCfg(PresetCfg): debug_mode=False, default_shape_cfg=NewtonShapeCfg(margin=0.01), ) + newton_kamino = NewtonCfg(solver_cfg=KaminoSolverCfg(max_contacts_per_world=64)) ## @@ -223,6 +230,7 @@ class SpotEventCfg(PresetCfg): default = SpotPhysxEventCfg() newton_mjwarp = SpotNewtonEventCfg() physx = default + newton_kamino = newton_mjwarp @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/velocity_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/velocity_env_cfg.py index 16e4fb0d04d1..24e20b9622f8 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/velocity_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/velocity_env_cfg.py @@ -80,6 +80,7 @@ class RoughPhysicsCfg(PresetCfg): class VelocityEnvContactSensorCfg(PresetCfg): default = PhysXContactSensorCfg(prim_path="{ENV_REGEX_NS}/Robot/.*", history_length=3, track_air_time=True) newton_mjwarp = NewtonContactSensorCfg(prim_path="{ENV_REGEX_NS}/Robot/.*", history_length=3, track_air_time=True) + newton_kamino = newton_mjwarp physx = default ovphysx = OvPhysXContactSensorCfg(prim_path="{ENV_REGEX_NS}/Robot/.*", history_length=3, track_air_time=True) diff --git a/source/isaaclab_tasks/pyproject.toml b/source/isaaclab_tasks/pyproject.toml index f09c5c86a8f0..47db04587cfc 100644 --- a/source/isaaclab_tasks/pyproject.toml +++ b/source/isaaclab_tasks/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "isaaclab_tasks" -version = "8.1.2" +version = "8.1.6" description = "Extension containing suite of environments for robot learning." license = {text = "BSD-3-Clause"} authors = [{name = "Isaac Lab Project Developers"}] diff --git a/source/isaaclab_tasks/test/contrib/test_contrib_environments_smoke.py b/source/isaaclab_tasks/test/contrib/test_contrib_environments_smoke.py index ae0c8382f5ad..ea1ae57d9fc3 100644 --- a/source/isaaclab_tasks/test/contrib/test_contrib_environments_smoke.py +++ b/source/isaaclab_tasks/test/contrib/test_contrib_environments_smoke.py @@ -37,6 +37,7 @@ from env_test_utils import _run_environments, setup_environment # isort: skip +@pytest.mark.smoke @pytest.mark.parametrize("num_envs, device", [(2, "cuda")]) @pytest.mark.parametrize( "task_name", diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-ovrtx_renderer-motion_vectors.png b/source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-ovrtx_renderer-motion_vectors.png new file mode 100644 index 000000000000..4de42c3eb817 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/cartpole/ovphysx-ovrtx_renderer-motion_vectors.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:15a6be1c6d16f00eee85812248ae0751d74809a1941c46199fc9d70da846b0a3 +size 20631 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-motion_vectors.png b/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-motion_vectors.png new file mode 100644 index 000000000000..65c7eeb235cf --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-motion_vectors.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e72c9c3b8052b67b26cc8553dd097915860af12ba997083a28e746babd322a28 +size 20584 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/physx-isaacsim_rtx_renderer-motion_vectors.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/physx-isaacsim_rtx_renderer-motion_vectors.png new file mode 100644 index 000000000000..926df161aa15 --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_hetero/physx-isaacsim_rtx_renderer-motion_vectors.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f79522cbf6d3e84c61dcde71f23984971a645ceefece920418e4d837e1d5d7de +size 8670 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/physx-isaacsim_rtx_renderer-motion_vectors.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/physx-isaacsim_rtx_renderer-motion_vectors.png new file mode 100644 index 000000000000..147efe8d21da --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka_homo/physx-isaacsim_rtx_renderer-motion_vectors.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d7f2cfee1e6415ec92374319331ea496299471f37ea1ee0f66ffe9618e82b604 +size 8629 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/physx-isaacsim_rtx_renderer-motion_vectors.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/physx-isaacsim_rtx_renderer-motion_vectors.png new file mode 100644 index 000000000000..6100a39069bf --- /dev/null +++ b/source/isaaclab_tasks/test/golden_images/shadow_hand/physx-isaacsim_rtx_renderer-motion_vectors.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c387e14db0c8662374d37709da3c657cb4b2488f4329f686c8d84b6e56d2b995 +size 28482 diff --git a/source/isaaclab_tasks/test/rendering_test_utils.py b/source/isaaclab_tasks/test/rendering_test_utils.py index a4424f645b4a..bf25f3e723a2 100644 --- a/source/isaaclab_tasks/test/rendering_test_utils.py +++ b/source/isaaclab_tasks/test/rendering_test_utils.py @@ -14,6 +14,7 @@ import torch from PIL import Image, ImageChops +from isaaclab.utils.images import make_camera_output_grid, normalize_camera_output_for_display from isaaclab.utils.warp import ProxyArray # Directory containing golden images. @@ -59,12 +60,15 @@ # outputs where the per-pixel value distribution is highly non-uniform after normalisation (e.g. depth, where we # divide by the max value so tiny absolute differences near the far plane dominate windowed variance). For these # data types we still compute SSIM for reporting, but only the per-pixel L2 gate is used to decide pass/fail. +# ``motion_vectors`` is included for the same reason: per-pixel (u, v) offsets are normalized by their max +# magnitude, so small absolute differences in near-static regions can dominate windowed variance. _SSIM_DISABLED_DATA_TYPES: set[str] = { "depth", "distance_to_camera", "distance_to_image_plane", "instance_segmentation_fast", "instance_id_segmentation_fast", + "motion_vectors", } # Directory for comparison images saved during the test session. @@ -94,6 +98,7 @@ "normals", "instance_segmentation_fast", "instance_id_segmentation_fast", + "motion_vectors", ) @@ -238,6 +243,44 @@ def _redirect_ovrtx_renderer_log_to_stdout(env_cfg: Any) -> None: renderer_cfg.log_file_path = "CON" if os.name == "nt" else "/dev/stdout" +def _maybe_enable_physx_determinism_for_motion(env_cfg: Any, physics_backend: str, data_type: str) -> None: + """Trade PhysX solver performance for determinism/accuracy when testing ``motion_vectors``. + + PhysX's default TGS solver settings produce noisy per-step velocities (see the "TGS solver ... may + cause noisy velocities" warning logged by ``physx_manager``), and ``motion_vectors`` encodes velocity + directly. That noise differs run-to-run, making golden-image comparisons for this AOV flaky on CI. + Applies to both PhysX backends (``"physx"`` and ``"ovphysx"``, which share the same underlying PhysX + solver). No-op for any other ``(physics_backend, data_type)`` combination. + + Args: + env_cfg: The resolved environment config, exposing ``sim.physics`` as a + :class:`~isaaclab_physx.physics.PhysxCfg` when ``physics_backend == "physx"``, or an + :class:`~isaaclab_ovphysx.physics.OvPhysxCfg` when ``physics_backend == "ovphysx"``. + physics_backend: The physics backend under test (``"physx"``, ``"newton"``, or ``"ovphysx"``). + data_type: The camera data type under test. + """ + if physics_backend not in ("physx", "ovphysx") or data_type != "motion_vectors": + return + + env_cfg.sim.physics.enable_enhanced_determinism = True + env_cfg.sim.physics.enable_external_forces_every_iteration = True + + +def _skip_if_newton_motion_vectors(physics_backend: str, data_type: str) -> None: + """Skip ``motion_vectors`` golden-image tests running on the Newton physics backend. + + Newton is not yet deterministic enough for the ``motion_vectors`` AOV: per-pixel (u, v) offsets + encode sub-step body motion, which varies run-to-run under the Newton solver, so golden-image + comparison is unreliable. No-op for any other ``(physics_backend, data_type)`` combination. + + Args: + physics_backend: The physics backend under test (``"physx"``, ``"newton"``, or ``"ovphysx"``). + data_type: The camera data type under test. + """ + if physics_backend == "newton" and data_type == "motion_vectors": + pytest.skip("Newton physics is not deterministic enough for motion_vectors golden-image testing.") + + def _physics_preset_name(physics_backend: str) -> str: """Translate the historical ``"newton"`` backend label (still used by golden-image filenames and ``pytest.param`` IDs) to the renamed Hydra preset @@ -246,24 +289,6 @@ def _physics_preset_name(physics_backend: str) -> str: return "newton_mjwarp" if physics_backend == "newton" else physics_backend -def _normalize_tensor(tensor: torch.Tensor, data_type: str) -> torch.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"]: - max_val = normalized.max() - if max_val > 0: - normalized = normalized / max_val - elif data_type in {"albedo"}: - normalized = normalized[..., :3] / 255.0 - elif data_type in {"normals"}: - normalized = (normalized + 1.0) * 0.5 - else: - normalized = normalized / 255.0 - - return normalized - - def _save_comparison_image(img: Image.Image, filename: str) -> str: """Save a PIL image under the comparison images directory.""" path = os.path.join(_COMPARISON_IMAGES_DIR, _COMPARISON_IMAGE_SUBDIR, filename) @@ -473,13 +498,6 @@ def _require_ovlibs_install(request, monkeypatch: pytest.MonkeyPatch): return _require_ovlibs_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 @@ -585,8 +603,8 @@ def validate_camera_outputs( failed_data_types[data_type] = f"Camera output '{data_type}' has no non-zero pixels." continue - normalized = _normalize_tensor(corrected, data_type) - grid = _make_grid(normalized) + normalized = normalize_camera_output_for_display(corrected, data_type) + grid = make_camera_output_grid(normalized) ndarr = grid.mul(255).add_(0.5).clamp_(0, 255).permute(1, 2, 0).to("cpu", torch.uint8).numpy() result_image = Image.fromarray(ndarr) @@ -662,6 +680,30 @@ def _alpha_only(img: Image.Image) -> Image.Image: pytest.fail(reason) +def maybe_step_env_for_motion(env: Any, data_type: str, num_steps: int = 2, action_value: float = 0.0) -> None: + """Step ``env`` so motion-vector AOVs have real inter-frame motion to encode. + + Motion vectors compare the current frame's transforms against the previous frame's; the first frame + rendered after a reset has no render history, so its motion vectors would otherwise be all zero. No-op + for any ``data_type`` other than ``"motion_vectors"``. + + Args: + env: The environment to step. Must expose ``action_space`` and ``step`` (``DirectRLEnv`` / + ``ManagerBasedRLEnv``). + data_type: The camera data type under test. + num_steps: Number of steps to take before capturing camera output. + action_value: Constant value applied to every action component on every step. Defaults to + ``0.0`` (motion then comes only from physics already in flight, e.g. gravity/initial velocity). + Pass a non-zero value to additionally induce actuated motion (e.g. cartpole cart translation). + """ + if data_type != "motion_vectors": + return + + action = torch.full(env.action_space.shape, action_value, device=env.device) + for _ in range(num_steps): + env.step(action) + + def rendering_test_shadow_hand( physics_backend: str, renderer: str, @@ -670,6 +712,7 @@ def rendering_test_shadow_hand( ) -> None: if physics_backend == "ovphysx": pytest.skip("ovphysx is not supported yet.") + _skip_if_newton_motion_vectors(physics_backend, data_type) from isaaclab.utils.configclass import configclass @@ -687,6 +730,7 @@ class _ShadowHandTiledCameraTestCfg(ShadowHandTiledCameraCfg): normals = _ShadowHandBaseTiledCameraCfg(data_types=["normals"]) instance_segmentation_fast = _ShadowHandBaseTiledCameraCfg(data_types=["instance_segmentation_fast"]) instance_id_segmentation_fast = _ShadowHandBaseTiledCameraCfg(data_types=["instance_id_segmentation_fast"]) + motion_vectors = _ShadowHandBaseTiledCameraCfg(data_types=["motion_vectors"]) @configclass class _ShadowHandCameraTestEnvCfg(ShadowHandCameraEnvCfg): @@ -702,14 +746,18 @@ class _ShadowHandCameraTestEnvCfg(ShadowHandCameraEnvCfg): if renderer == "ovrtx_renderer": _redirect_ovrtx_renderer_log_to_stdout(env_cfg) - if data_type in {"depth", "distance_to_camera", "distance_to_image_plane"}: - # Disable CNN forward pass as it cannot be meaningfully trained from depth alone and will raise a ValueError. + _maybe_enable_physx_determinism_for_motion(env_cfg, physics_backend, data_type) + + if data_type in {"depth", "distance_to_camera", "distance_to_image_plane", "motion_vectors"}: + # Disable CNN forward pass as it cannot be meaningfully trained from depth/motion vectors alone + # and will raise a ValueError. env_cfg.feature_extractor.enabled = False env = None try: env = ShadowHandCameraEnv(env_cfg) + maybe_step_env_for_motion(env, data_type) maybe_save_stage("shadow_hand", physics_backend, renderer, data_type) validate_camera_outputs( @@ -735,6 +783,8 @@ def rendering_test_cartpole( data_type: str, comparison_scores: list[dict], ) -> None: + _skip_if_newton_motion_vectors(physics_backend, data_type) + from isaaclab.utils.configclass import configclass from isaaclab_tasks.core.cartpole.cartpole_direct_camera_env import CartpoleCameraEnv @@ -755,6 +805,7 @@ class _CartpoleTiledCameraTestCfg(CartpoleTiledCameraCfg): instance_id_segmentation_fast = CartpoleTiledCameraCfg.BaseCartpoleTiledCameraCfg( data_types=["instance_id_segmentation_fast"] ) + motion_vectors = CartpoleTiledCameraCfg.BaseCartpoleTiledCameraCfg(data_types=["motion_vectors"]) @configclass class _BaseCartpoleCameraEnvTestCfg(CartpoleCameraEnvCfg.BaseCartpoleCameraEnvCfg): @@ -780,6 +831,9 @@ class _CartpoleCameraTestEnvCfg(CartpoleCameraEnvCfg): instance_id_segmentation_fast = _BaseCartpoleCameraEnvTestCfg( observation_space=[4, 100, 100], tiled_camera=_CartpoleTiledCameraTestCfg() ) + motion_vectors = CartpoleCameraEnvCfg.BaseCartpoleCameraEnvCfg( + observation_space=[2, 100, 100], tiled_camera=_CartpoleTiledCameraTestCfg() + ) env_cfg = _CartpoleCameraTestEnvCfg() env_cfg = _apply_overrides_to_env_cfg( @@ -791,10 +845,15 @@ class _CartpoleCameraTestEnvCfg(CartpoleCameraEnvCfg): if renderer == "ovrtx_renderer": _redirect_ovrtx_renderer_log_to_stdout(env_cfg) + _maybe_enable_physx_determinism_for_motion(env_cfg, physics_backend, data_type) + env = None try: env = CartpoleCameraEnv(env_cfg) + # Nudge the cart with a small constant force so motion vectors also capture cart translation, + # not just pole dynamics already in flight from the randomized reset. + maybe_step_env_for_motion(env, data_type, action_value=0.5) camera_outputs = env._tiled_camera.data.output if renderer == "ovrtx_renderer": # The first output access creates the selected OVRTX render-variable mapping. Give @@ -836,6 +895,7 @@ def rendering_test_dexsuite_kuka( ) -> None: if physics_backend == "ovphysx": pytest.skip("ovphysx is not supported yet.") + _skip_if_newton_motion_vectors(physics_backend, data_type) from isaaclab.envs import ManagerBasedRLEnv from isaaclab.sensors import CameraCfg @@ -888,6 +948,9 @@ class _DexsuiteBaseTiledCameraTestCfg(BaseTiledCameraCfg): instance_id_segmentation_fast256 = BASE_CAMERA_CFG.replace( data_types=["instance_id_segmentation_fast"], width=256, height=256 ) + motion_vectors64 = BASE_CAMERA_CFG.replace(data_types=["motion_vectors"], width=64, height=64) + motion_vectors128 = BASE_CAMERA_CFG.replace(data_types=["motion_vectors"], width=128, height=128) + motion_vectors256 = BASE_CAMERA_CFG.replace(data_types=["motion_vectors"], width=256, height=256) @configclass class _DexsuiteSingleCameraTestSceneCfg(SingleCameraSceneCfg): @@ -916,6 +979,8 @@ class _DexsuiteKukaAllegroLiftCameraTestEnvCfg(DexsuiteKukaAllegroLiftCameraEnvC if renderer == "ovrtx_renderer": _redirect_ovrtx_renderer_log_to_stdout(env_cfg) + _maybe_enable_physx_determinism_for_motion(env_cfg, physics_backend, data_type) + # Disable the observation point-cloud visualisation markers (/Visuals/ObservationPointCloud). # The underlying point sampling uses the global numpy/torch RNG, so marker positions shift # across processes and show up as random red dots in the rendered camera output. Since this @@ -937,6 +1002,7 @@ class _DexsuiteKukaAllegroLiftCameraTestEnvCfg(DexsuiteKukaAllegroLiftCameraEnvC try: env = ManagerBasedRLEnv(env_cfg) + maybe_step_env_for_motion(env, data_type) maybe_save_stage(test_name, physics_backend, renderer, data_type) validate_camera_outputs( test_name, diff --git a/tools/conftest.py b/tools/conftest.py index a34812ddccab..963853194f3d 100644 --- a/tools/conftest.py +++ b/tools/conftest.py @@ -738,6 +738,9 @@ def run_individual_tests(test_files, workspace_root, isaacsim_ci, test_node_ids_ xml_reports = [] cold_cache_applied = False test_node_ids_by_file = test_node_ids_by_file or {} + global_k_expr = os.environ.get("TEST_K_EXPR", "").strip() or None + if global_k_expr is not None: + print(f"Applying global pytest -k expression to every test file: '{global_k_expr}'") for test_file in test_files: print(f"\n\n🚀 Running {test_file} independently...\n") @@ -786,6 +789,8 @@ def run_individual_tests(test_files, workspace_root, isaacsim_ci, test_node_ids_ merged_status: dict | None = None for suffix, k_expr in passes: + if global_k_expr is not None: + k_expr = f"({k_expr}) and ({global_k_expr})" if k_expr else global_k_expr report, status, was_failure = _run_one_pass(ctx, k_expr=k_expr, suffix=suffix) if report is not None: xml_reports.append(report)